You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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() on result.decision === "allow", unconditional on operator settings
writer — LifeOS/install/hooks/lib/safety-classifier.ts:436-439 — classifyCommand() allows every mcp__* tool as its first statement
reader — <configRoot>/settings.jsonpermissions.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:
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.
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).
Version
LifeOS 7.40.4 / Safety hook (
Safety.hook.ts@Version 1.3.15)What is broken
Safety.hook.tsanswersPermissionRequestby classifying the call and emittingdecision: allowwhen the classifier says allow — and aPermissionRequestevent fires only when aprompt is already pending, so an
allowthere is documented to resolve that prompt.The classifier never reads the operator's
settings.json, so the hook cannot know which calls theoperator explicitly asked to be prompted about — and
permissions.askis precisely the mechanismwhose 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 toolwhose 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 anoperator-gated MCP call and an automatic allow.
What the repro below measures is the hook by itself: driven on stdin with a
PermissionRequestpayload naming a tool the operator listed in
permissions.ask, it emitsdecision: allowand logsthe decision. The step after that is inferred, not measured — per the harness's documented
PermissionRequestsemantics anallowdecision resolves the pending prompt, so an operator whoadds
mcp__<server>__delete_issuetopermissions.askand waits for the prompt should not getone. 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.jsonlrecordsallowwith reasonmcp-pre-vetted, which reads as a routineapproval 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
PermissionRequestresponder is able to move it.Reader and writer disagree about who decides:
LifeOS/install/hooks/Safety.hook.ts:264—emitAllow()onresult.decision === "allow", unconditional on operator settingsLifeOS/install/hooks/Safety.hook.ts:197-206—emitAllow()emitsdecision: { behavior: "allow" }LifeOS/install/hooks/lib/safety-classifier.ts:436-439—classifyCommand()allows everymcp__*tool as its first statement<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:264Repro on a clean tree
Negative control
The same tool, the same settings, one field added to
tool_inputthat trips the #1275 secret-shapescan. 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:
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.askentry naming the tool is not.Suggested fix
Shape only, untested. Before
emitAllow(), match the call against the operator'spermissions.askfrom the effective settings and return without emitting ona 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-vettedso it does not cover everymcp__*name unconditionally. It is the widest allow in the classifier and it is a prefix match.
Before submitting
Searched
PermissionRequest permissions ask,Safety hook auto-approve allow. No priorreport 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.askis inert because ofthe shipped
defaultMode, and it concerns the.envrules; here theaskentry iscancelled by the hook's own
allow, and only formcp__*tools.Fresh
--depth 1clone; the hook runs withHOME,LIFEOS_DIRandCLAUDE_CONFIG_DIRallpinned to a scratch directory. Verified afterwards that the hook wrote only into that
directory (
permission-decisions.jsonl,permission-cache.json) and touched nothing else.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).