Skip to content

Safety hook auto-allows mcp__* calls the operator listed in permissions.ask, resolving the prompt that entry exists to raise #2087

Description

@Steffen025

Version

LifeOS 7.40.4 / Safety hook (Safety.hook.ts @Version 1.3.15)

What is broken

Safety.hook.ts answers PermissionRequest by classifying the call and emitting
decision: allow when the classifier says allow — and a PermissionRequest event fires only when a
prompt is already pending, so an allow there is documented to resolve that prompt.
The classifier never reads the operator's settings.json, so the hook cannot know which calls the
operator explicitly asked to be prompted about — and permissions.ask is precisely the mechanism
whose only observable effect is that prompt.

For MCP tools the classifier does not inspect anything at all:
classifyCommand() returns { decision: "allow", reasons: ["mcp-pre-vetted"] } for every tool
whose name starts with mcp__, as its first statement. One narrower check runs ahead of it — the
#1275 secret-shape scan over tool_input — and that is the only thing standing between an
operator-gated MCP call and an automatic allow.

What the repro below measures is the hook by itself: driven on stdin with a PermissionRequest
payload naming a tool the operator listed in permissions.ask, it emits decision: allow and logs
the decision. The step after that is inferred, not measured — per the harness's documented
PermissionRequest semantics an allow decision resolves the pending prompt, so an operator who
adds mcp__<server>__delete_issue to permissions.ask and waits for the prompt should not get
one. I did not instrument a live session to watch that happen; what I measured is that the hook
supplies the allow, and that it does so without ever reading the list the operator wrote.

The hook's own log does not mark the entry as overridden either:
permission-decisions.jsonl records allow with reason mcp-pre-vetted, which reads as a routine
approval rather than as a cancelled gate. The setting looks installed and is inert.

This is not the same claim as "the hook cannot make the system stricter", which the code already
documents. It is that the hook makes the system looser than the operator's own configuration,
in the one direction a PermissionRequest responder is able to move it.

Reader and writer disagree about who decides:

  • writer — LifeOS/install/hooks/Safety.hook.ts:264emitAllow() on result.decision === "allow", unconditional on operator settings
  • writer — LifeOS/install/hooks/Safety.hook.ts:197-206emitAllow() emits decision: { behavior: "allow" }
  • writer — LifeOS/install/hooks/lib/safety-classifier.ts:436-439classifyCommand() allows every mcp__* tool as its first statement
  • reader — <configRoot>/settings.json permissions.ask — never opened by either file. grep -c -i settings returns 0 on both LifeOS/install/hooks/Safety.hook.ts and LifeOS/install/hooks/lib/safety-classifier.ts: the word does not occur in either file, in code or in comments.

Where (file:line)

LifeOS/install/hooks/Safety.hook.ts:264

Repro on a clean tree

git clone --branch v7.40.4 --depth 1 https://github.com/danielmiessler/LifeOS.git /tmp/lifeos-7404
cd /tmp/lifeos-7404 && git rev-parse HEAD
# → be9e8ef889f00a29f4fd677dee4772fdf32e07ce

# An operator settings.json that asks to be prompted for one MCP tool.
mkdir -p /tmp/sb/.claude/LIFEOS
cat > /tmp/sb/.claude/settings.json <<'JSON'
{ "permissions": { "ask": ["mcp__tracker__delete_issue", "Bash(rm:*)"], "deny": [] } }
JSON

# Drive the hook's PermissionRequest path on stdin, with HOME, LIFEOS_DIR and
# CLAUDE_CONFIG_DIR all pinned to the scratch directory above.
printf '%s' '{"hook_event_name":"PermissionRequest","tool_name":"mcp__tracker__delete_issue","tool_input":{"issueKey":"ABC-123"}}' \
| env HOME=/tmp/sb LIFEOS_DIR=/tmp/sb/.claude/LIFEOS CLAUDE_CONFIG_DIR=/tmp/sb/.claude \
  bun LifeOS/install/hooks/Safety.hook.ts
echo "EXIT=$?"
# → {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
# → EXIT=0

# The tool the operator named in permissions.ask is allowed by the hook.

# The hook's own log, same run:
cat /tmp/sb/.claude/LIFEOS/MEMORY/OBSERVABILITY/permission-decisions.jsonl
# → {"ts":"…","tool":"mcp__tracker__delete_issue","cmd_prefix":"","cmd_sha":"…",
# →  "decision":"allow","reasons":["mcp-pre-vetted"],"cache":"miss"}

Negative control

The same tool, the same settings, one field added to tool_input that trips the #1275 secret-shape
scan. If the hook always emitted allow, this would be indistinguishable from the run above and the
finding would be about the harness rather than about which inputs the hook consults:

printf '%s' '{"hook_event_name":"PermissionRequest","tool_name":"mcp__tracker__delete_issue","tool_input":{"issueKey":"ABC-123","note":"AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY"}}' \
| env HOME=/tmp/sb LIFEOS_DIR=/tmp/sb/.claude/LIFEOS CLAUDE_CONFIG_DIR=/tmp/sb/.claude \
  bun LifeOS/install/hooks/Safety.hook.ts
echo "EXIT=$?"
EXIT=0

Empty stdout — no allow emitted, so nothing from the hook resolves the native prompt. Red in the
sense the field asks for:
the hook is demonstrably capable of withholding allow on this exact tool call, and the run above
shows what it withholds it for. A secret in the payload is consulted; the operator's
permissions.ask entry naming the tool is not.

Suggested fix

Shape only, untested. Before emitAllow(), match the call against the operator's
permissions.ask from the effective settings and return without emitting on
a hit — the same "simply DON'T emitAllow" move the #1275 path already makes, with the operator's
own list as the trigger. Ambiguity in that matching should resolve toward not emitting: a false
match costs one prompt, a false miss costs a silent gated call. Reading effective settings from
inside a hook is the part I have not built and would not guess at.

A smaller variant if that is unwelcome: narrow mcp-pre-vetted so it does not cover every mcp__*
name unconditionally. It is the widest allow in the classifier and it is a prefix match.

Before submitting

  • I searched open and closed issues for this defect.
    Searched PermissionRequest permissions ask, Safety hook auto-approve allow. No prior
    report of this path. MCP write tools bypass SecurityPipeline — open exfiltration channel via blessed mcp__* traffic #1275 is referenced in the code as the origin of the MCP secret scan and
    addresses egress content, not operator permission entries. permissions.ask: the 8 shipped .env rules produce no prompt under the shipped defaultMode "auto", and .env is readable through 10 allow-listed Bash verbs regardless #1790 (closed COMPLETED) is the
    mode-side sibling and is a different mechanism: there permissions.ask is inert because of
    the shipped defaultMode, and it concerns the .env rules; here the ask entry is
    cancelled by the hook's own allow, and only for mcp__* tools.
  • The repro runs against a clean tree of the version above, not against my modified install.
    Fresh --depth 1 clone; the hook runs with HOME, LIFEOS_DIR and CLAUDE_CONFIG_DIR all
    pinned to a scratch directory. Verified afterwards that the hook wrote only into that
    directory (permission-decisions.jsonl, permission-cache.json) and touched nothing else.
  • I removed personal data from the pasted output — real names, absolute home paths, tokens, my
    own content. Paths are /tmp/...; the tool name and the AWS-shaped string are synthetic
    (the latter is the value from AWS's own public documentation example).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions