Session hooks: a session cannot start another with no one asked, and every reason is told
Follow-on to the merged PR #115, "Session hooks: under bypassPermissions, the protected hook tells and asks nothing" (decision 0019). The Maintainer's rule: a review item found on a merged PR is fixed in a follow-on PR. A Muse Wonder (Meta) review of #115 reported three findings, and all three were real.
What changes
- A session cannot start another with no one asked (major). Outside bypass,
tools/hooks/protected.pynow asks before a command that runs Claude Code:- When it asks: the arguments carry a permission-bypassing word, Claude Code is given a word the hook cannot read, it is given
--settings <file>, orCLAUDE_CONFIG_DIRis set in the command. - The words are data: they sit under a new
sessionskey inconfig/hooks.json. - Wrappers it reads through:
env,exec,command,npx,pnpm dlx,bunx,sh -c, aliases and functions defined in the command, and$(which claude). - Any other program: its arguments and here-documents are data, so
git log --grep ā¦,echo ā¦,gh pr create --body ā¦and a commit message written as a here-document are not asked about. - Under bypass: told, as every reason is.
- The rule is recorded in new decision 0021, "Only the Maintainer starts a session with no one asked".
- When it asks: the arguments carry a permission-bypassing word, Claude Code is given a word the hook cannot read, it is given
- 0019's "every reason is told" is now held (major).
test_under_bypass_permissions_every_reason_is_told_as_it_would_be_askedrequires the bypass text to equal the ask text word for word. It covers a protected path, a merge,unasked_session,config_unreadable,budget_spentandhook_failed. The two mutants Muse named are now killed. - The
config_unreadablesentence no longer says it asks (minor).
Also changed
- Hook mutants: the first ones in
tools/mutants.py. A mutant can now name tests to run first, which takes a hook mutant from about 2.5 minutes to about 1 second. - A counter-example from #135:
hooks.a-lens-input-with-no-lens.jsongainssessions.
Verified on a git archive export at 16cfc22: make verify passes (506 tests); python3 -m tools.mutants kills 364 of 364; check_decisions is clean.
Reviewed by: a fresh-context Claude review and a security pass. Their findings, all fixed: false positives on read-only commands, --settings <file> and CLAUDE_CONFIG_DIR, the new rule given its own record, a missing mutant, and a stray whitespace change.
Second-family reads of 420e2b8: Muse Wonder (muse-wonder-1.0) found seven issues and AGY (Gemini 4 Argon) five; all twelve were real and are fixed in 16cfc22. The fixes:
- Commands are read before matching: wrappers resolve to their first program, past their options (
command -p,exec -a,time -p,corepack,npm exec,env -S). $(which claude)and its backtick form: replaced by the name before the command is split, which also fixes a bug that joined the next command onto Claude Code.- Variables: a known variable is split into words;
--settingsand the config-directory variables are read after expansion;CLAUDE_CONFIG_DIRandHOMEset anywhere in the command count. - Inline settings: JSON is decoded before it is checked.
xargs: counts as feeding Claude Code anywhere in the wrapper chain.- Tests: 12 mutants added.
This was the one fix round for spellings. Later spellings are answered and filed.
Known limits (the hook guards against mistakes; it is not the boundary):
- Claude Code started from inside another program, such as
python3 -c 'os.system(ā¦)', is not asked about. - A bypass word split in code to dodge the check is not caught.
š¤ Generated with Claude Code