#2087·LifeOS

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

Author: Steffen025Created Sep 10, 2026Updated Sep 17, 2026

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

bash
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:

bash
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. #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: 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).