Skip to main content

Module hook_gates

Module hook_gates 

Source
Expand description

Claude Code lifecycle-hook gate projection (ticket .kranz/tickets/claude-code-hook-gate-projection.md, KRZ-302).

NOT crate::hooks — that module is the D-F webhook triggers the engine EMITS to the operator; this module is about hooks the Claude Code CLI runs INSIDE a worker session. The names are kept apart deliberately.

§What this is

Deterministic kranz gates are projected onto Claude Code lifecycle hooks so a failure is enforced IN-PROCESS during the worker session instead of only being discovered afterwards: a PreToolUse hook matcher on the file-writing tools (Write|Edit|MultiEdit|NotebookEdit) runs a guard command (kranz hook-guard) that judges the tool call’s target path against the mission’s declared touch_set and BLOCKS an out-of-contract write before it happens, leaving a structured record the engine folds into the event log as hook.gate.fired events once the session ends.

The engine-side gate ladder remains AUTHORITATIVE — hooks are defense-in-depth, never a replacement (same philosophy as sandbox-vs-scrutiny: hooks bound what the session agrees to; the engine-side out-of-contract sweep in crate::contract_sweep judges what actually happened, committed, afterwards). A hook-BYPASSED failure — hook config removed by the session, a write via Bash redirection instead of the Write tool, a guard that errored, a CLI too old to know hooks — is still caught by that sweep, and every test pinning the sweep is untouched by this module.

§Why the out-of-contract write rule is the first (and only) projection

It is the one deterministic gate that maps onto a SINGLE tool call: one path, judged against one glob set, with a verdict the model can act on (write inside the touch set instead, or surface the need for a touch-path grant). The contract command assertions deliberately stay engine-side: they are whole shell commands with pipes, timeouts, and anti-vacuity greps evaluated over captured output — re-implementing that inside a per-tool-call hook would duplicate the engine’s command execution with strictly worse evidence, and blocking a Bash call pre-execution would prejudge a command whose verdict depends on its OUTPUT.

§Hooks schema targeted

Claude Code hooks as documented 2026-08-04 (the public hooks reference, content current to CLI v2.1.221; hooks themselves GA since CLI v1.0.38, 2025-06-30). The repo’s verified-CLI ground truth (docs/design.md) is claude 2.1.198, which fully supports the schema used here. The generated config uses only the long-stable subset — pipe-separated exact-match matcher, type/command/timeout handler fields, and exit-code semantics — which behaves identically on every hooks-capable CLI:

{ "hooks": { "PreToolUse": [ {
      "matcher": "Write|Edit|MultiEdit|NotebookEdit",
      "hooks": [ { "type": "command",
                   "command": "<kranz> hook-guard --config <spec.json>",
                   "timeout": 10 } ] } ] } }

delivered through the session’s existing --settings JSON (a documented settings tier that honors the hooks key). MultiEdit is matched for older-CLI compatibility; current CLIs merged it into Edit, so the entry is harmless dead weight there.

Exit-code semantics the guard relies on (PreToolUse): exit 2 BLOCKS the tool call and feeds stderr back to the model; exit 0 lets the normal permission flow proceed; any OTHER non-zero exit is a non-blocking error (the action proceeds, a hook-error notice lands in the transcript). The guard therefore fails OPEN on its own internal errors (exit 1): a broken guard must never freeze a session, and the miss is still judged by the authoritative engine-side sweep.

§Degradation posture

  • A pre-hooks CLI (< v1.0.38) ignores the unknown hooks settings key; the hook simply never fires, no records exist, and the sweep judges. crate::backend_claude::discover_claude_binary probes that a candidate RUNS --version but gates on no version floor, so this degradation is by-construction (documented here), not probed.
  • Sandboxed sessions: the guard is a child of the session CLI and runs with the session’s already-cleared env — no new credential or env channel is introduced (the hook command line carries only paths). Its spec/record files live under the session-private scratch root (crate::backend_claude::scratch_home_root), which every process sandbox tier keeps writable (Seatbelt’s write_allowlist, bwrap’s --bind set), and the host kranz binary is executable there (Seatbelt allow process*, bwrap whole-fs ro-bind). A CONTAINER sandbox whose image lacks the host kranz binary cannot exec the guard: the hook then errors non-blocking and enforcement stays engine-side.
  • The record file is SESSION-WRITABLE evidence, like a worker report: a hostile session can forge or corrupt it, so folding is tolerant (malformed lines are skipped), bounded (FOLD_CAP), scrubbed, and the events are record-only in the reducer — never consulted to judge.

Structs§

HookGateRecord
One structured hook outcome, appended as one JSON line to record_file by kranz hook-guard. Session-writable evidence — parsed tolerantly, never trusted (see module docs).
HookGateSpec
Everything kranz hook-guard needs to judge one tool call, written by the engine at spec-build time into the session-private scratch root. The hook command line carries ONLY this file’s path: no touch-set on the command line, no env vars — the session’s already-cleared env is the whole channel (house rule: no new ambient credential or env channel).

Enums§

GuardVerdict
The guard’s verdict on one tool call.

Constants§

FOLD_CAP
Max hook records folded into the event log per run. The record file is session-writable, so an unbounded fold would let a hostile or looping session flood the append-only log; the records beyond the cap stay in the scratch file and the truncation is logged.
HOOK_GATE_ID
The gate identity the first projection enforces: the same defect-class name the engine-side sweep reports (contract_sweep::FINDING_CLASS), so an in-process block and an after-the-fact finding read as ONE gate evaluated at two layers.
HOOK_GUARD_SUBCOMMAND
The kranz subcommand the hook command line invokes. Named in the generated settings JSON and matched by the CLI’s clap surface.
SPEC_VERSION
Schema version of the per-session spec file (HookGateSpec) and of the record lines (HookGateRecord) — both bump together.

Functions§

evaluate
Judge one PreToolUse tool call against spec. file_path is the target path from the tool input (tool_input.file_path, or notebook_path on older CLIs’ NotebookEdit), as the CLI reported it — absolute, or relative to the session cwd.
project_worker_hook_gates
Project the mission’s out-of-contract write rule onto spec’s per-session settings (worker sessions only; read-only roles deny the write tools outright and have nothing to project).
record_file
The guard-appended record file the engine folds after the session ends.
records_to_events
Fold one session’s hook records into hook.gate.fired event kinds, ready for the run’s log target (called by runner::run_session_to AFTER the session stream closes, so a gate-failing action inside the session lands as a structured event BEFORE worker.completed and the rest of session-end processing).
spec_file
The engine-written spec file the hook command is pointed at.
worker_hook_settings
The --settings JSON block projecting the out-of-contract write rule onto a PreToolUse hook. command is the fully-quoted hook command line (see project_worker_hook_gates). Pure so the exact wire shape is unit-testable without spawning anything.