Skip to main content

kranz_cli/
hook_guard.rs

1//! `kranz hook-guard` — the command Claude Code lifecycle hooks invoke
2//! inside worker sessions (ticket
3//! `.kranz/tickets/claude-code-hook-gate-projection.md`, KRZ-302; the engine
4//! side is [`kranz_engine::hook_gates`], which also records the targeted
5//! hooks schema version).
6//!
7//! This is an INTERNAL plumbing command, never an operator surface: the
8//! engine installs it into a worker session's `--settings` JSON as a
9//! `PreToolUse` hook on the file-writing tools. The Claude Code CLI pipes
10//! the hook payload JSON to stdin; the guard judges the tool call's target
11//! path against the per-session spec file the engine wrote, appends a
12//! structured record to the session's record file, and exits:
13//!
14//! - **0** — the write is in contract; the normal permission flow proceeds.
15//! - **2** — BLOCK: stderr is fed back to the model as the refusal reason;
16//!   a `blocked` record was appended (the engine folds it into a
17//!   `hook.gate.fired` event after the session).
18//! - **1** — the GUARD itself failed (unreadable spec, unparseable
19//!   payload): a non-blocking error in Claude Code, so the action proceeds
20//!   and a hook-error notice lands in the transcript. Fail-OPEN by design —
21//!   a broken guard must never freeze a session, and the miss is still
22//!   judged by the authoritative engine-side out-of-contract sweep.
23//!
24//! The guard runs with the session's already-cleared environment and reads
25//! nothing but the spec file and stdin — no new credential or env channel.
26
27use kranz_engine::hook_gates::{GuardVerdict, HookGateRecord, HookGateSpec};
28use kranz_engine::hook_status::STDIN_PAYLOAD_MAX_BYTES;
29use std::io::Read;
30use std::path::Path;
31
32/// Exit code: the guard itself failed open (non-blocking in Claude Code).
33const EXIT_GUARD_ERROR: i32 = 1;
34/// Exit code: the tool call is BLOCKED; stderr is shown to the model.
35const EXIT_BLOCK: i32 = 2;
36
37/// Run the guard: read the hook payload from `stdin`, judge it against the
38/// spec at `config`, record the outcome, and return the process exit code.
39/// Split from the clap dispatch so tests drive it in-process.
40pub fn run_hook_guard(config: &Path, stdin: &mut impl Read) -> i32 {
41    let spec = match HookGateSpec::load(config) {
42        Ok(spec) => spec,
43        Err(e) => {
44            eprintln!(
45                "kranz hook-guard: failed to load the hook spec {}: {e} \
46                 (failing open; the engine-side out-of-contract sweep remains authoritative)",
47                config.display()
48            );
49            return EXIT_GUARD_ERROR;
50        }
51    };
52
53    // Bounded read — the same idiom and cap as the hook-status relay
54    // (crates/cli/src/hook_status.rs, 14th-pass review: this read was
55    // unbounded): the payload is CLI-produced but the channel is
56    // session-adjacent, so a boundless read would let a broken or hostile
57    // producer exhaust memory in the guard. Over the cap fails OPEN like
58    // any guard error — enforcement never silently blocks on guard failure;
59    // the engine-side sweep stays authoritative.
60    let mut payload_bytes = Vec::new();
61    if let Err(e) = stdin
62        .take((STDIN_PAYLOAD_MAX_BYTES + 1) as u64)
63        .read_to_end(&mut payload_bytes)
64    {
65        return guard_error(
66            &spec,
67            &format!("failed to read the hook payload on stdin: {e}"),
68        );
69    }
70    if payload_bytes.len() > STDIN_PAYLOAD_MAX_BYTES {
71        return guard_error(
72            &spec,
73            &format!("hook payload exceeds {STDIN_PAYLOAD_MAX_BYTES} bytes"),
74        );
75    }
76    let payload: serde_json::Value = match serde_json::from_slice(&payload_bytes) {
77        Ok(payload) => payload,
78        Err(e) => {
79            return guard_error(&spec, &format!("hook payload was not JSON: {e}"));
80        }
81    };
82    let Some(tool_name) = payload.get("tool_name").and_then(|v| v.as_str()) else {
83        return guard_error(&spec, "hook payload carried no tool_name");
84    };
85    let hook_event = payload
86        .get("hook_event_name")
87        .and_then(|v| v.as_str())
88        .unwrap_or("PreToolUse");
89    // Write/Edit carry `file_path`; older CLIs' NotebookEdit carried
90    // `notebook_path` — accept either (defensive: its shape is undocumented).
91    let file_path = payload
92        .pointer("/tool_input/file_path")
93        .or_else(|| payload.pointer("/tool_input/notebook_path"))
94        .and_then(|v| v.as_str());
95    let session_id = payload.get("session_id").and_then(|v| v.as_str());
96    let tool_use_id = payload.get("tool_use_id").and_then(|v| v.as_str());
97
98    match kranz_engine::hook_gates::evaluate(&spec, tool_name, file_path) {
99        GuardVerdict::Allow => 0,
100        GuardVerdict::Block { subject, reason } => {
101            let record = HookGateRecord::blocked(
102                &spec,
103                hook_event,
104                tool_name,
105                &subject,
106                &reason,
107                session_id,
108                tool_use_id,
109            );
110            // Best-effort: an append failure must not change the verdict —
111            // the block still stands (and lands in the transcript), only
112            // the structured event is lost.
113            let _ = record.append_to(&spec.record_file);
114            // stderr is fed back to the model on exit 2: the reason tells it
115            // how to recover (relocate the write, or surface a grant need).
116            eprintln!("{reason}");
117            EXIT_BLOCK
118        }
119    }
120}
121
122/// The fail-open branch: record the guard error (best-effort) and exit
123/// non-blocking, so a broken guard never freezes the session and the miss
124/// stays visible to the engine-side sweep.
125fn guard_error(spec: &HookGateSpec, note: &str) -> i32 {
126    let _ = HookGateRecord::error(spec, note).append_to(&spec.record_file);
127    eprintln!("kranz hook-guard: {note} (failing open)");
128    EXIT_GUARD_ERROR
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn write_spec(dir: &Path, touch_set: &[&str]) -> std::path::PathBuf {
136        let spec = HookGateSpec {
137            version: kranz_engine::hook_gates::SPEC_VERSION,
138            gate: kranz_engine::hook_gates::HOOK_GATE_ID.to_string(),
139            session_cwd: dir.to_path_buf(),
140            touch_set: touch_set.iter().map(|s| s.to_string()).collect(),
141            record_file: dir.join("records.jsonl"),
142        };
143        let path = dir.join("spec.json");
144        std::fs::write(&path, serde_json::to_string_pretty(&spec).unwrap()).unwrap();
145        path
146    }
147
148    fn payload(tool: &str, file_path: Option<&str>) -> String {
149        let input = match file_path {
150            Some(path) => serde_json::json!({ "file_path": path }),
151            None => serde_json::json!({}),
152        };
153        serde_json::json!({
154            "session_id": "cli-session-1",
155            "transcript_path": "/tmp/t.jsonl",
156            "cwd": "/tmp",
157            "hook_event_name": "PreToolUse",
158            "tool_name": tool,
159            "tool_input": input,
160            "tool_use_id": "toolu_1",
161        })
162        .to_string()
163    }
164
165    /// An out-of-contract Write is BLOCKED (exit 2) and lands in the record
166    /// file as a structured `blocked` record; an in-contract Write exits 0
167    /// and records nothing.
168    #[test]
169    fn hook_gate_projection_guard_blocks_and_records_out_of_contract_writes() {
170        let dir = tempfile::tempdir().unwrap();
171        let config = write_spec(dir.path(), &["src/**"]);
172
173        let stdin = payload("Write", Some("/outside/the/checkout.md")).into_bytes();
174        // Path outside the checkout → blocked. (session_cwd is the tempdir.)
175        let code = run_hook_guard(&config, &mut stdin.as_slice());
176        assert_eq!(code, 2);
177        let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
178        let record: serde_json::Value =
179            serde_json::from_str(records.lines().next().unwrap()).unwrap();
180        assert_eq!(record["verdict"], "blocked");
181        assert_eq!(record["gate"], kranz_engine::hook_gates::HOOK_GATE_ID);
182        assert_eq!(record["hookEvent"], "PreToolUse");
183        assert_eq!(record["tool"], "Write");
184        assert_eq!(record["sessionId"], "cli-session-1");
185        assert_eq!(record["toolUseId"], "toolu_1");
186
187        // In-contract relative path → allowed, no record appended.
188        let stdin = payload("Edit", Some("src/lib.rs")).into_bytes();
189        let code = run_hook_guard(&config, &mut stdin.as_slice());
190        assert_eq!(code, 0);
191        let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
192        assert_eq!(records.lines().count(), 1, "an allow records nothing");
193
194        // Out-of-contract relative path → blocked, repo-relative subject.
195        let stdin = payload("Write", Some("docs/oops.md")).into_bytes();
196        let code = run_hook_guard(&config, &mut stdin.as_slice());
197        assert_eq!(code, 2);
198        let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
199        let record: serde_json::Value =
200            serde_json::from_str(records.lines().nth(1).unwrap()).unwrap();
201        assert_eq!(record["subject"], "docs/oops.md");
202    }
203
204    /// Guard failures fail OPEN (exit 1, non-blocking in Claude Code) and
205    /// leave an `error` record when the spec was loadable — never a frozen
206    /// session, never a silent miss.
207    #[test]
208    fn hook_gate_projection_guard_failures_fail_open_loudly() {
209        let dir = tempfile::tempdir().unwrap();
210        let config = write_spec(dir.path(), &["src/**"]);
211
212        // Unparseable payload → exit 1 + error record.
213        let mut stdin = b"{not json".as_slice();
214        let code = run_hook_guard(&config, &mut stdin);
215        assert_eq!(code, 1);
216        let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
217        let record: serde_json::Value =
218            serde_json::from_str(records.lines().next().unwrap()).unwrap();
219        assert_eq!(record["verdict"], "error");
220
221        // A missing spec file → exit 1 (no record possible: the record file
222        // path lives in the spec).
223        let missing = dir.path().join("no-such-spec.json");
224        let stdin = payload("Write", Some("src/lib.rs")).into_bytes();
225        let code = run_hook_guard(&missing, &mut stdin.as_slice());
226        assert_eq!(code, 1);
227    }
228
229    /// 14th-pass review: the stdin read is bounded like the hook-status
230    /// relay's — an oversized payload fails OPEN (exit 1, an `error`
231    /// record), never an unbounded buffer in the guard.
232    #[test]
233    fn hook_guard_stdin_read_is_bounded_fail_open() {
234        let dir = tempfile::tempdir().unwrap();
235        let config = write_spec(dir.path(), &["src/**"]);
236
237        let oversized = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES + 1];
238        let code = run_hook_guard(&config, &mut oversized.as_slice());
239        assert_eq!(code, 1, "over the cap is a guard error, failing open");
240        let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
241        let record: serde_json::Value =
242            serde_json::from_str(records.lines().next().unwrap()).unwrap();
243        assert_eq!(record["verdict"], "error");
244
245        // Exactly AT the cap the read still proceeds (and fails open on the
246        // non-JSON bytes) — the bound does not eat legitimate payloads.
247        let at_cap = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES];
248        let code = run_hook_guard(&config, &mut at_cap.as_slice());
249        assert_eq!(code, 1, "at the cap the payload is read and judged");
250    }
251}