Session hooks: a session cannot start another with no one asked, and every reason is told

Merged
DO
4d ago
+636-18

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

  1. A session cannot start another with no one asked (major). Outside bypass, tools/hooks/protected.py now 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>, or CLAUDE_CONFIG_DIR is set in the command.
    • The words are data: they sit under a new sessions key in config/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".
  2. 0019's "every reason is told" is now held (major). test_under_bypass_permissions_every_reason_is_told_as_it_would_be_asked requires the bypass text to equal the ask text word for word. It covers a protected path, a merge, unasked_session, config_unreadable, budget_spent and hook_failed. The two mutants Muse named are now killed.
  3. The config_unreadable sentence 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.json gains sessions.

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; --settings and the config-directory variables are read after expansion; CLAUDE_CONFIG_DIR and HOME set 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