Safety hook auto-allows mcp__* calls the operator listed in permissions.ask, resolving the prompt that entry exists to raise
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:264—emitAllow()onresult.decision === "allow", unconditional on operator settings - writer —
LifeOS/install/hooks/Safety.hook.ts:197-206—emitAllow()emitsdecision: { behavior: "allow" } - writer —
LifeOS/install/hooks/lib/safety-classifier.ts:436-439—classifyCommand()allows everymcp__*tool as its first statement - reader —
<configRoot>/settings.jsonpermissions.ask— never opened by either file.grep -c -i settingsreturns0on bothLifeOS/install/hooks/Safety.hook.tsandLifeOS/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=0Empty 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. #1275 is referenced in the code as the origin of the MCP secret scan and addresses egress content, not operator permission entries. #1790 (closed COMPLETED) is the mode-side sibling and is a different mechanism: therepermissions.askis inert because of the shippeddefaultMode, and it concerns the.envrules; here theaskentry is cancelled by the hook's ownallow, and only formcp__*tools. - The repro runs against a clean tree of the version above, not against my modified install.
Fresh
--depth 1clone; the hook runs withHOME,LIFEOS_DIRandCLAUDE_CONFIG_DIRall 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).
Source: danielmiessler/LifeOS