danielmiessler / danielmiessler/LifeOS
Safety hook auto-allows mcp__* calls the operator listed in permissions.ask, resolving the prompt that entry exists to raise
- Dominant language
- TypeScript
- Stars
- 19k
- Forks
- 2.5k
- Avg merge
- 8d 17h
- Merged PRs (30d)
- 1
Description
## 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____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/Safety.hook.ts:197-206` — `emitAllow()` emits `decision: { behavior: "allow" }`
- writer — `LifeOS/install/hooks/lib/safety-classifier.ts:436-439` — `classifyCommand()` allows every `mcp__*` tool as its first statement
- reader — `/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
```shell
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:
```shell
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
- [x] 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.
- [x] 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.
- [x] 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).
Contributor guide
No contributing guide indexed for this repository
Research direction
Run the supplied clean-tree stdin repro first, then read LifeOS/install/hooks/Safety.hook.ts around lines 197-206 and 264 and safety-classifier.ts:436-439. Trace how PermissionRequest decisions are emitted and how the mcp__* classification bypasses settings.json; done means an operator-listed MCP tool no longer receives an allow decision while the secret-shape negative control remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, typescript
- Domain
- authorization, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 62/100