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
hookssettings key; the hook simply never fires, no records exist, and the sweep judges.crate::backend_claude::discover_claude_binaryprobes that a candidate RUNS--versionbut 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’swrite_allowlist, bwrap’s--bindset), and the hostkranzbinary is executable there (Seatbeltallow 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§
- Hook
Gate Record - One structured hook outcome, appended as one JSON line to
record_filebykranz hook-guard. Session-writable evidence — parsed tolerantly, never trusted (see module docs). - Hook
Gate Spec - Everything
kranz hook-guardneeds 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§
- Guard
Verdict - 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
kranzsubcommand 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
PreToolUsetool call againstspec.file_pathis the target path from the tool input (tool_input.file_path, ornotebook_pathon 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.firedevent kinds, ready for the run’s log target (called byrunner::run_session_toAFTER the session stream closes, so a gate-failing action inside the session lands as a structured event BEFOREworker.completedand the rest of session-end processing). - spec_
file - The engine-written spec file the hook command is pointed at.
- worker_
hook_ settings - The
--settingsJSON block projecting the out-of-contract write rule onto aPreToolUsehook.commandis the fully-quoted hook command line (seeproject_worker_hook_gates). Pure so the exact wire shape is unit-testable without spawning anything.