kranz_engine/hook_gates.rs
1//! Claude Code lifecycle-hook gate projection (ticket
2//! `.kranz/tickets/claude-code-hook-gate-projection.md`, KRZ-302).
3//!
4//! NOT [`crate::hooks`] — that module is the D-F webhook triggers the engine
5//! EMITS to the operator; this module is about hooks the Claude Code CLI runs
6//! INSIDE a worker session. The names are kept apart deliberately.
7//!
8//! # What this is
9//!
10//! Deterministic kranz gates are projected onto Claude Code lifecycle hooks
11//! so a failure is enforced IN-PROCESS during the worker session instead of
12//! only being discovered afterwards: a `PreToolUse` hook matcher on the
13//! file-writing tools (`Write|Edit|MultiEdit|NotebookEdit`) runs a guard
14//! command (`kranz hook-guard`) that judges the tool call's target path
15//! against the mission's declared `touch_set` and BLOCKS an out-of-contract
16//! write before it happens, leaving a structured record the engine folds
17//! into the event log as `hook.gate.fired` events once the session ends.
18//!
19//! The engine-side gate ladder remains AUTHORITATIVE — hooks are
20//! defense-in-depth, never a replacement (same philosophy as
21//! sandbox-vs-scrutiny: hooks bound what the session agrees to; the
22//! engine-side out-of-contract sweep in [`crate::contract_sweep`] judges
23//! what actually happened, committed, afterwards). A hook-BYPASSED failure
24//! — hook config removed by the session, a write via `Bash` redirection
25//! instead of the `Write` tool, a guard that errored, a CLI too old to know
26//! hooks — is still caught by that sweep, and every test pinning the sweep
27//! is untouched by this module.
28//!
29//! # Why the out-of-contract write rule is the first (and only) projection
30//!
31//! It is the one deterministic gate that maps onto a SINGLE tool call: one
32//! path, judged against one glob set, with a verdict the model can act on
33//! (write inside the touch set instead, or surface the need for a
34//! touch-path grant). The contract `command` assertions deliberately stay
35//! engine-side: they are whole shell commands with pipes, timeouts, and
36//! anti-vacuity greps evaluated over captured output — re-implementing that
37//! inside a per-tool-call hook would duplicate the engine's command
38//! execution with strictly worse evidence, and blocking a `Bash` call
39//! pre-execution would prejudge a command whose verdict depends on its
40//! OUTPUT.
41//!
42//! # Hooks schema targeted
43//!
44//! Claude Code hooks as documented 2026-08-04 (the public hooks reference,
45//! content current to CLI v2.1.221; hooks themselves GA since CLI v1.0.38,
46//! 2025-06-30). The repo's verified-CLI ground truth (docs/design.md) is
47//! claude 2.1.198, which fully supports the schema used here. The generated
48//! config uses only the long-stable subset — pipe-separated exact-match
49//! `matcher`, `type`/`command`/`timeout` handler fields, and exit-code
50//! semantics — which behaves identically on every hooks-capable CLI:
51//!
52//! ```json
53//! { "hooks": { "PreToolUse": [ {
54//! "matcher": "Write|Edit|MultiEdit|NotebookEdit",
55//! "hooks": [ { "type": "command",
56//! "command": "<kranz> hook-guard --config <spec.json>",
57//! "timeout": 10 } ] } ] } }
58//! ```
59//!
60//! delivered through the session's existing `--settings` JSON (a documented
61//! settings tier that honors the `hooks` key). `MultiEdit` is matched for
62//! older-CLI compatibility; current CLIs merged it into `Edit`, so the
63//! entry is harmless dead weight there.
64//!
65//! Exit-code semantics the guard relies on (PreToolUse): exit 2 BLOCKS the
66//! tool call and feeds stderr back to the model; exit 0 lets the normal
67//! permission flow proceed; any OTHER non-zero exit is a non-blocking error
68//! (the action proceeds, a hook-error notice lands in the transcript). The
69//! guard therefore fails OPEN on its own internal errors (exit 1): a broken
70//! guard must never freeze a session, and the miss is still judged by the
71//! authoritative engine-side sweep.
72//!
73//! # Degradation posture
74//!
75//! - A pre-hooks CLI (< v1.0.38) ignores the unknown `hooks` settings key;
76//! the hook simply never fires, no records exist, and the sweep judges.
77//! [`crate::backend_claude::discover_claude_binary`] probes that a
78//! candidate RUNS `--version` but gates on no version floor, so this
79//! degradation is by-construction (documented here), not probed.
80//! - Sandboxed sessions: the guard is a child of the session CLI and runs
81//! with the session's already-cleared env — no new credential or env
82//! channel is introduced (the hook command line carries only paths). Its
83//! spec/record files live under the session-private scratch root
84//! ([`crate::backend_claude::scratch_home_root`]), which every process
85//! sandbox tier keeps writable (Seatbelt's `write_allowlist`, bwrap's
86//! `--bind` set), and the host `kranz` binary is executable there
87//! (Seatbelt `allow process*`, bwrap whole-fs ro-bind). A CONTAINER
88//! sandbox whose image lacks the host kranz binary cannot exec the guard:
89//! the hook then errors non-blocking and enforcement stays engine-side.
90//! - The record file is SESSION-WRITABLE evidence, like a worker report: a
91//! hostile session can forge or corrupt it, so folding is tolerant
92//! (malformed lines are skipped), bounded ([`FOLD_CAP`]), scrubbed, and
93//! the events are record-only in the reducer — never consulted to judge.
94
95use crate::backend::SessionSpec;
96use crate::contract_sweep;
97use crate::events::EventKind;
98use chrono::{DateTime, Utc};
99use serde::{Deserialize, Serialize};
100use serde_json::{json, Value};
101use std::path::{Path, PathBuf};
102
103/// The gate identity the first projection enforces: the same defect-class
104/// name the engine-side sweep reports ([`contract_sweep::FINDING_CLASS`]),
105/// so an in-process block and an after-the-fact finding read as ONE gate
106/// evaluated at two layers.
107pub const HOOK_GATE_ID: &str = contract_sweep::FINDING_CLASS;
108
109/// The `kranz` subcommand the hook command line invokes. Named in the
110/// generated settings JSON and matched by the CLI's clap surface.
111pub const HOOK_GUARD_SUBCOMMAND: &str = "hook-guard";
112
113/// Schema version of the per-session spec file ([`HookGateSpec`]) and of
114/// the record lines ([`HookGateRecord`]) — both bump together.
115pub const SPEC_VERSION: u32 = 1;
116
117/// Bounds a wedged hook invocation (seconds; the CLI's default is 600,
118/// absurd for a local path check).
119const HOOK_TIMEOUT_SECS: u32 = 10;
120
121/// Max hook records folded into the event log per run. The record file is
122/// session-writable, so an unbounded fold would let a hostile or looping
123/// session flood the append-only log; the records beyond the cap stay in
124/// the scratch file and the truncation is logged.
125pub const FOLD_CAP: usize = 64;
126
127/// Max chars kept on a folded record's subject / detail (the file is
128/// session-authored, so every persisted string is scrubbed AND bounded).
129const RECORD_SUBJECT_MAX: usize = 500;
130const RECORD_DETAIL_MAX: usize = 1000;
131
132// ---------------------------------------------------------------------------
133// Per-session spec file (engine-written, guard-read)
134// ---------------------------------------------------------------------------
135
136/// Everything `kranz hook-guard` needs to judge one tool call, written by
137/// the engine at spec-build time into the session-private scratch root. The
138/// hook command line carries ONLY this file's path: no touch-set on the
139/// command line, no env vars — the session's already-cleared env is the
140/// whole channel (house rule: no new ambient credential or env channel).
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct HookGateSpec {
144 /// [`SPEC_VERSION`] at write time.
145 pub version: u32,
146 /// Gate identity ([`HOOK_GATE_ID`]).
147 pub gate: String,
148 /// The session's working directory (the mission worktree in worktree
149 /// mode, the repo root in checkout mode): the root touch-set globs are
150 /// relative to, and the base relative `file_path`s resolve against.
151 pub session_cwd: PathBuf,
152 /// The mission's declared touch-set globs, verbatim (gitignore-style:
153 /// `!` negates, last match wins — [`contract_sweep::touch_set_includes`]).
154 pub touch_set: Vec<String>,
155 /// Absolute path of the record file the guard appends to
156 /// ([`record_file`]). Session-writable by construction (see module docs).
157 pub record_file: PathBuf,
158}
159
160impl HookGateSpec {
161 /// Load the spec the hook command was pointed at. Any IO/parse failure
162 /// is the caller's fail-open (exit 1) branch.
163 pub fn load(path: &Path) -> std::io::Result<Self> {
164 let text = std::fs::read_to_string(path)?;
165 serde_json::from_str(&text)
166 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
167 }
168}
169
170/// The per-session hook-gate dir under the session-private scratch root —
171/// the one tree every sandbox tier keeps worker-writable (see module docs).
172fn hook_gate_dir(session_id: &str) -> PathBuf {
173 crate::backend_claude::scratch_home_root(session_id).join("hook-gate")
174}
175
176/// The engine-written spec file the hook command is pointed at.
177pub fn spec_file(session_id: &str) -> PathBuf {
178 hook_gate_dir(session_id).join("spec.json")
179}
180
181/// The guard-appended record file the engine folds after the session ends.
182pub fn record_file(session_id: &str) -> PathBuf {
183 hook_gate_dir(session_id).join("records.jsonl")
184}
185
186// ---------------------------------------------------------------------------
187// Settings projection (engine side, spec-build time)
188// ---------------------------------------------------------------------------
189
190/// The `--settings` JSON block projecting the out-of-contract write rule
191/// onto a `PreToolUse` hook. `command` is the fully-quoted hook command
192/// line (see [`project_worker_hook_gates`]). Pure so the exact wire shape
193/// is unit-testable without spawning anything.
194pub fn worker_hook_settings(command: &str) -> Value {
195 json!({
196 "hooks": {
197 "PreToolUse": [
198 {
199 "matcher": "Write|Edit|MultiEdit|NotebookEdit",
200 "hooks": [
201 {
202 "type": "command",
203 "command": command,
204 "timeout": HOOK_TIMEOUT_SECS,
205 }
206 ]
207 }
208 ]
209 }
210 })
211}
212
213/// Project the mission's out-of-contract write rule onto `spec`'s
214/// per-session settings (worker sessions only; read-only roles deny the
215/// write tools outright and have nothing to project).
216///
217/// An EMPTY `touch_set` is advisory-off, mirroring the engine-side sweep's
218/// skip: no hook config, byte-identical session. Otherwise the per-session
219/// spec file is written into the session-private scratch root and
220/// `settings_json` becomes the hook block (workers carry no other settings
221/// today — the field is `None` by construction at every spec site).
222///
223/// Every failure here degrades to NO hook config with a loud warning —
224/// never a spawn error: the projection is defense-in-depth and the
225/// engine-side sweep stays authoritative without it.
226pub fn project_worker_hook_gates(spec: &mut SessionSpec, touch_set: &[String]) {
227 if touch_set.is_empty() {
228 return;
229 }
230 let session_id = spec.session_id.clone();
231 let exe = match std::env::current_exe() {
232 Ok(exe) => exe,
233 Err(e) => {
234 tracing::warn!(
235 session_id = %session_id,
236 error = %e,
237 "hook gate projection skipped: current_exe unresolved; \
238 the engine-side out-of-contract sweep remains authoritative"
239 );
240 return;
241 }
242 };
243 let gate_spec = HookGateSpec {
244 version: SPEC_VERSION,
245 gate: HOOK_GATE_ID.to_string(),
246 session_cwd: spec.cwd.clone(),
247 touch_set: touch_set.to_vec(),
248 record_file: record_file(&session_id),
249 };
250 let spec_path = spec_file(&session_id);
251 let written = (|| -> std::io::Result<()> {
252 if let Some(parent) = spec_path.parent() {
253 std::fs::create_dir_all(parent)?;
254 }
255 let text = serde_json::to_string_pretty(&gate_spec).map_err(std::io::Error::other)?;
256 std::fs::write(&spec_path, text)
257 })();
258 if let Err(e) = written {
259 tracing::warn!(
260 session_id = %session_id,
261 error = %e,
262 "hook gate projection skipped: spec file write failed; \
263 the engine-side out-of-contract sweep remains authoritative"
264 );
265 return;
266 }
267 let command = format!(
268 "{} {} --config {}",
269 shell_quote(&exe),
270 HOOK_GUARD_SUBCOMMAND,
271 shell_quote(&spec_path)
272 );
273 spec.settings_json = Some(worker_hook_settings(&command));
274 tracing::info!(
275 session_id = %session_id,
276 gate = HOOK_GATE_ID,
277 "out-of-contract write rule projected onto a PreToolUse lifecycle hook \
278 (defense-in-depth; the engine-side sweep remains authoritative)"
279 );
280}
281
282/// Single-quote a path for the shell-form hook command line (`sh -c`
283/// semantics): the only safe interpolation is none at all, so every
284/// single-quote in the path is closed-escaped-reopened. Engine-controlled
285/// paths make this defensive, but a repo under a quoted directory must not
286/// corrupt the command line.
287fn shell_quote(path: &Path) -> String {
288 format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
289}
290
291// ---------------------------------------------------------------------------
292// Guard evaluation (shared by the `kranz hook-guard` CLI and tests)
293// ---------------------------------------------------------------------------
294
295/// The guard's verdict on one tool call.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum GuardVerdict {
298 /// The write is inside the touch set; the hook exits 0 and the normal
299 /// permission flow proceeds.
300 Allow,
301 /// The write is out of contract; the hook records it and exits 2 with
302 /// `reason` on stderr (fed back to the model by the CLI).
303 Block { subject: String, reason: String },
304}
305
306/// Judge one `PreToolUse` tool call against `spec`. `file_path` is the
307/// target path from the tool input (`tool_input.file_path`, or
308/// `notebook_path` on older CLIs' NotebookEdit), as the CLI reported it —
309/// absolute, or relative to the session cwd.
310///
311/// Fail-CLOSED on every unjudgeable shape (no path, path outside the
312/// session checkout, broken touch-set glob): the projection exists to stop
313/// out-of-contract writes, and a write the guard cannot name is one the
314/// engine-side sweep could never attribute either. Blocking is recoverable
315/// — the CLI feeds the reason back to the model, which can relocate the
316/// write or surface the need for a touch-path grant.
317pub fn evaluate(spec: &HookGateSpec, tool_name: &str, file_path: Option<&str>) -> GuardVerdict {
318 let Some(raw) = file_path.filter(|p| !p.trim().is_empty()) else {
319 return GuardVerdict::Block {
320 subject: "(unresolved)".to_string(),
321 reason: format!(
322 "kranz hook gate ({}) blocked {tool_name}: the tool call carried no file \
323 path the guard can judge, so the write is out of contract by default",
324 spec.gate
325 ),
326 };
327 };
328 let raw_path = Path::new(raw);
329 let absolute = if raw_path.is_absolute() {
330 resolve_existing_prefix(raw_path)
331 } else {
332 resolve_existing_prefix(&spec.session_cwd.join(raw_path))
333 };
334 let cwd = resolve_existing_prefix(&spec.session_cwd);
335 let rel = match absolute.strip_prefix(&cwd) {
336 Ok(rel) => rel,
337 Err(_) => {
338 return GuardVerdict::Block {
339 subject: raw.to_string(),
340 reason: format!(
341 "kranz hook gate ({}) blocked {tool_name}: {raw} is outside the mission \
342 checkout, which no touch-set glob can ever cover",
343 spec.gate
344 ),
345 };
346 }
347 };
348 // Forward-slash repo-relative form, matching `git diff --name-only` and
349 // the sweep's glob semantics (Windows separators normalized).
350 let rel_str = rel.to_string_lossy().replace('\\', "/");
351 match contract_sweep::touch_set_includes(&spec.touch_set, &rel_str) {
352 Ok(true) => GuardVerdict::Allow,
353 Ok(false) => GuardVerdict::Block {
354 subject: rel_str.clone(),
355 reason: format!(
356 "kranz hook gate ({}) blocked {tool_name}: {rel_str} matches none of the \
357 mission's declared touch-set globs — relocate the write under a declared \
358 path, or stop and surface the need for a touch-path grant",
359 spec.gate
360 ),
361 },
362 Err(e) => GuardVerdict::Block {
363 subject: rel_str,
364 reason: format!(
365 "kranz hook gate ({}) blocked {tool_name}: touch-set glob compile error: {e}",
366 spec.gate
367 ),
368 },
369 }
370}
371
372/// Resolve the longest existing prefix before appending any not-yet-created
373/// suffix. Hook targets are often new files, so `canonicalize(path)` alone is
374/// insufficient; resolving the parent still collapses macOS's
375/// `/var` -> `/private/var` alias and existing symlink escapes. If no prefix
376/// resolves, retain the lexical path so the guard keeps its fail-closed
377/// `strip_prefix` posture.
378fn resolve_existing_prefix(path: &Path) -> PathBuf {
379 let lexical = normalize_lexical(path);
380 let mut probe = lexical.as_path();
381 let mut suffix = Vec::new();
382
383 loop {
384 if let Ok(mut resolved) = probe.canonicalize() {
385 for component in suffix.iter().rev() {
386 resolved.push(component);
387 }
388 return normalize_lexical(&resolved);
389 }
390 let Some(name) = probe.file_name() else {
391 return lexical;
392 };
393 suffix.push(name.to_os_string());
394 let Some(parent) = probe.parent() else {
395 return lexical;
396 };
397 probe = parent;
398 }
399}
400
401/// Lexical normalization used after filesystem aliases have been resolved:
402/// `.` is dropped and `..` pops one component.
403fn normalize_lexical(path: &Path) -> PathBuf {
404 let mut out = PathBuf::new();
405 for component in path.components() {
406 match component {
407 std::path::Component::CurDir => {}
408 std::path::Component::ParentDir => {
409 out.pop();
410 }
411 other => out.push(other.as_os_str()),
412 }
413 }
414 out
415}
416
417// ---------------------------------------------------------------------------
418// Records: the guard appends; the engine folds after the session
419// ---------------------------------------------------------------------------
420
421/// One structured hook outcome, appended as one JSON line to
422/// [`record_file`] by `kranz hook-guard`. Session-writable evidence —
423/// parsed tolerantly, never trusted (see module docs).
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct HookGateRecord {
427 /// [`SPEC_VERSION`] at write time.
428 pub v: u32,
429 pub ts: DateTime<Utc>,
430 /// Gate identity ([`HOOK_GATE_ID`]).
431 pub gate: String,
432 /// The lifecycle event that fired (`"PreToolUse"`).
433 pub hook_event: String,
434 /// The tool whose call was judged (`Write`, `Edit`, ...).
435 pub tool: String,
436 /// The judged target: the repo-relative path when it resolved inside
437 /// the checkout, else the raw path / `(unresolved)`.
438 pub subject: String,
439 /// `"blocked"` (the write was refused in-process) or `"error"` (the
440 /// guard itself failed open — the action proceeded and only the
441 /// engine-side sweep can judge it).
442 pub verdict: String,
443 /// The guard's reason / error note.
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub detail: Option<String>,
446 /// The CLI's session id from the hook payload, when present.
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub session_id: Option<String>,
449 /// The CLI's tool-use id from the hook payload, when present.
450 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub tool_use_id: Option<String>,
452}
453
454impl HookGateRecord {
455 /// A `blocked` record for one refused tool call.
456 pub fn blocked(
457 spec: &HookGateSpec,
458 hook_event: &str,
459 tool: &str,
460 subject: &str,
461 reason: &str,
462 session_id: Option<&str>,
463 tool_use_id: Option<&str>,
464 ) -> Self {
465 HookGateRecord {
466 v: SPEC_VERSION,
467 ts: Utc::now(),
468 gate: spec.gate.clone(),
469 hook_event: hook_event.to_string(),
470 tool: tool.to_string(),
471 subject: subject.to_string(),
472 verdict: "blocked".to_string(),
473 detail: Some(reason.to_string()),
474 session_id: session_id.map(str::to_string),
475 tool_use_id: tool_use_id.map(str::to_string),
476 }
477 }
478
479 /// An `error` record: the guard loaded its spec but could not judge
480 /// (unparseable hook payload, missing tool name) and failed open.
481 pub fn error(spec: &HookGateSpec, note: &str) -> Self {
482 HookGateRecord {
483 v: SPEC_VERSION,
484 ts: Utc::now(),
485 gate: spec.gate.clone(),
486 hook_event: "PreToolUse".to_string(),
487 tool: String::new(),
488 subject: String::new(),
489 verdict: "error".to_string(),
490 detail: Some(note.to_string()),
491 session_id: None,
492 tool_use_id: None,
493 }
494 }
495
496 /// Append this record as one JSON line to `record_file`. Best-effort by
497 /// the caller: an append failure never changes the exit verdict.
498 pub fn append_to(&self, record_file: &Path) -> std::io::Result<()> {
499 use std::io::Write as _;
500 if let Some(parent) = record_file.parent() {
501 std::fs::create_dir_all(parent)?;
502 }
503 let mut file = std::fs::OpenOptions::new()
504 .create(true)
505 .append(true)
506 .open(record_file)?;
507 let line = serde_json::to_string(self).map_err(std::io::Error::other)?;
508 writeln!(file, "{line}")
509 }
510}
511
512/// Fold one session's hook records into `hook.gate.fired` event kinds,
513/// ready for the run's log target (called by `runner::run_session_to`
514/// AFTER the session stream closes, so a gate-failing action inside the
515/// session lands as a structured event BEFORE `worker.completed` and the
516/// rest of session-end processing).
517///
518/// `session_id` names the record file ([`record_file`]); `run_id` is
519/// STAMPED from the run's own metadata — never read from the
520/// session-writable file, so a forged record cannot attach itself to
521/// another run (the reducer validates the run reference as a corruption
522/// guard). Missing/unreadable file → no events (a session that never fired
523/// the hook — or bypassed it — is the ordinary case, and the engine-side
524/// sweep judges either way). Malformed lines are skipped with a warning:
525/// the file is session-authored and must never break the run that produced
526/// it. Bounded by [`FOLD_CAP`]; every persisted string is scrubbed and
527/// truncated (same discipline as `worker.message` content).
528pub fn records_to_events(session_id: &str, run_id: &str) -> Vec<EventKind> {
529 let file = record_file(session_id);
530 let contents = match std::fs::read_to_string(&file) {
531 Ok(contents) => contents,
532 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
533 Err(e) => {
534 tracing::warn!(
535 session_id,
536 error = %e,
537 "hook gate record file unreadable; folding nothing \
538 (the engine-side sweep remains authoritative)"
539 );
540 return Vec::new();
541 }
542 };
543 let mut events = Vec::new();
544 let mut skipped = 0usize;
545 let mut truncated = false;
546 for line in contents.lines() {
547 if line.trim().is_empty() {
548 continue;
549 }
550 if events.len() >= FOLD_CAP {
551 truncated = true;
552 break;
553 }
554 match serde_json::from_str::<HookGateRecord>(line) {
555 Ok(record) => events.push(record_into_event(record, run_id)),
556 Err(_) => skipped += 1,
557 }
558 }
559 if skipped > 0 || truncated {
560 tracing::warn!(
561 session_id,
562 skipped,
563 truncated,
564 "hook gate record fold dropped session-authored lines \
565 (malformed or over the fold cap)"
566 );
567 }
568 events
569}
570
571/// Map one parsed record onto its event, scrubbing and bounding every
572/// session-authored string before it lands in the append-only log.
573fn record_into_event(record: HookGateRecord, run_id: &str) -> EventKind {
574 EventKind::HookGateFired {
575 run_id: run_id.to_string(),
576 gate: crate::scrub::scrub(&record.gate),
577 hook_event: crate::scrub::scrub(&record.hook_event),
578 tool: crate::scrub::scrub(&record.tool),
579 subject: crate::scrub::scrub_and_truncate(&record.subject, RECORD_SUBJECT_MAX),
580 verdict: crate::scrub::scrub(&record.verdict),
581 detail: record
582 .detail
583 .map(|d| crate::scrub::scrub_and_truncate(&d, RECORD_DETAIL_MAX)),
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 fn spec_fixture(cwd: &Path, touch_set: &[&str]) -> HookGateSpec {
592 HookGateSpec {
593 version: SPEC_VERSION,
594 gate: HOOK_GATE_ID.to_string(),
595 session_cwd: cwd.to_path_buf(),
596 touch_set: touch_set.iter().map(|s| s.to_string()).collect(),
597 record_file: cwd.join("records.jsonl"),
598 }
599 }
600
601 /// The generated `--settings` block has exactly the documented hooks
602 /// schema shape (see module docs): PreToolUse, the write-tool matcher,
603 /// one command handler with the quoted guard invocation and a bounded
604 /// timeout.
605 #[test]
606 fn hook_gate_projection_settings_shape_matches_the_hooks_schema() {
607 let settings =
608 worker_hook_settings("'/usr/local/bin/kranz' hook-guard --config '/tmp/s/spec.json'");
609 let groups = settings["hooks"]["PreToolUse"]
610 .as_array()
611 .expect("PreToolUse matcher groups");
612 assert_eq!(groups.len(), 1);
613 assert_eq!(groups[0]["matcher"], "Write|Edit|MultiEdit|NotebookEdit");
614 let handlers = groups[0]["hooks"].as_array().expect("handlers");
615 assert_eq!(handlers.len(), 1);
616 assert_eq!(handlers[0]["type"], "command");
617 assert_eq!(
618 handlers[0]["command"],
619 "'/usr/local/bin/kranz' hook-guard --config '/tmp/s/spec.json'"
620 );
621 assert!(
622 handlers[0]["timeout"].as_u64().unwrap() <= 30,
623 "the guard is a local path check; the CLI's 600s default must not stand"
624 );
625 }
626
627 /// Projection onto a worker spec: the spec file lands under the
628 /// session-private scratch root (the tree every sandbox tier keeps
629 /// writable), carrying the touch set verbatim, and `settings_json`
630 /// names the guard command and that spec file.
631 #[test]
632 fn hook_gate_projection_worker_spec_carries_settings_and_spec_file() {
633 let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
634 let mut spec = SessionSpec {
635 cwd: PathBuf::from("/repo/worktree"),
636 prompt: crate::backend::PromptMode::SingleShot("task".to_string()),
637 append_system_prompt: None,
638 model: "stub".to_string(),
639 effort: "low".to_string(),
640 session_id: session_id.clone(),
641 resume: None,
642 permission_mode: None,
643 allowed_tools: Vec::new(),
644 disallowed_tools: Vec::new(),
645 tools: Vec::new(),
646 writable: true,
647 settings_json: None,
648 json_schema: None,
649 max_budget_usd: None,
650 max_turns: None,
651 env: Default::default(),
652 sandbox: None,
653 hook_status: None,
654 };
655 let touch_set = vec!["src/**".to_string(), "!src/generated/**".to_string()];
656 project_worker_hook_gates(&mut spec, &touch_set);
657
658 let settings = spec.settings_json.expect("hook settings projected");
659 let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
660 .as_str()
661 .unwrap();
662 assert!(command.contains(HOOK_GUARD_SUBCOMMAND), "{command}");
663 assert!(command.contains("--config"), "{command}");
664
665 let loaded = HookGateSpec::load(&spec_file(&session_id)).expect("spec file written");
666 assert_eq!(loaded.version, SPEC_VERSION);
667 assert_eq!(loaded.gate, HOOK_GATE_ID);
668 assert_eq!(loaded.session_cwd, PathBuf::from("/repo/worktree"));
669 assert_eq!(loaded.touch_set, touch_set);
670 assert_eq!(loaded.record_file, record_file(&session_id));
671 assert!(
672 loaded
673 .record_file
674 .starts_with(crate::backend_claude::scratch_home_root(&session_id)),
675 "the record file must live under the session-private scratch root"
676 );
677
678 let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
679 }
680
681 /// An empty touch set is advisory-off (the sweep's posture): no
682 /// settings, no spec file, byte-identical session.
683 #[test]
684 fn hook_gate_projection_empty_touch_set_projects_nothing() {
685 let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
686 let mut spec = SessionSpec {
687 cwd: PathBuf::from("/repo"),
688 prompt: crate::backend::PromptMode::SingleShot("task".to_string()),
689 append_system_prompt: None,
690 model: "stub".to_string(),
691 effort: "low".to_string(),
692 session_id: session_id.clone(),
693 resume: None,
694 permission_mode: None,
695 allowed_tools: Vec::new(),
696 disallowed_tools: Vec::new(),
697 tools: Vec::new(),
698 writable: true,
699 settings_json: None,
700 json_schema: None,
701 max_budget_usd: None,
702 max_turns: None,
703 env: Default::default(),
704 sandbox: None,
705 hook_status: None,
706 };
707 project_worker_hook_gates(&mut spec, &[]);
708 assert!(spec.settings_json.is_none());
709 assert!(!spec_file(&session_id).exists());
710 }
711
712 /// An absolute fixture root for the platform (`/repo/wt` unix,
713 /// `C:\repo\wt` Windows): the guard's path resolution is lexical and
714 /// platform-relative, so tests must anchor on a genuinely absolute path
715 /// or `is_absolute()` splits the fixtures across platforms.
716 fn test_cwd() -> PathBuf {
717 if cfg!(windows) {
718 PathBuf::from(r"C:\repo\wt")
719 } else {
720 PathBuf::from("/repo/wt")
721 }
722 }
723
724 /// The display form of `cwd.join(rel)` — the shape a hook payload's
725 /// absolute `file_path` takes.
726 fn under(cwd: &Path, rel: &str) -> String {
727 cwd.join(rel).display().to_string()
728 }
729
730 /// The guard blocks a write outside the touch set, allows one inside
731 /// it, honors `!`-negation, and resolves relative and `..`-carrying
732 /// paths against the session cwd before judging.
733 #[test]
734 fn hook_gate_projection_evaluate_judges_against_the_touch_set() {
735 let cwd = test_cwd();
736 let spec = spec_fixture(&cwd, &["src/**", "!src/generated/**"]);
737
738 // In-contract absolute and relative paths both allow.
739 assert_eq!(
740 evaluate(&spec, "Write", Some(&under(&cwd, "src/lib.rs"))),
741 GuardVerdict::Allow
742 );
743 assert_eq!(
744 evaluate(&spec, "Edit", Some("src/lib.rs")),
745 GuardVerdict::Allow
746 );
747 // Dot-dot normalized before judging: this lands in-contract.
748 assert_eq!(
749 evaluate(&spec, "Write", Some(&under(&cwd, "docs/../src/lib.rs"))),
750 GuardVerdict::Allow
751 );
752
753 // Out-of-contract blocks, with the repo-relative subject named.
754 let blocked = evaluate(&spec, "Write", Some(&under(&cwd, "docs/oops.md")));
755 let GuardVerdict::Block { subject, reason } = blocked else {
756 panic!("docs/oops.md must block: {blocked:?}")
757 };
758 assert_eq!(subject, "docs/oops.md");
759 assert!(reason.contains("touch-set"), "{reason}");
760
761 // The `!`-negated subtree is out of contract even under src/**.
762 assert!(matches!(
763 evaluate(&spec, "Edit", Some("src/generated/x.rs")),
764 GuardVerdict::Block { .. }
765 ));
766 }
767
768 /// Fail-closed shapes: a path outside the checkout and a missing path
769 /// both block (the sweep could never attribute what the guard cannot
770 /// name).
771 #[test]
772 fn hook_gate_projection_evaluate_fails_closed_on_unjudgeable_writes() {
773 let cwd = test_cwd();
774 let spec = spec_fixture(&cwd, &["src/**"]);
775 let outside = if cfg!(windows) {
776 r"C:\outside\checkout.md"
777 } else {
778 "/outside/checkout.md"
779 };
780
781 match evaluate(&spec, "Write", Some(outside)) {
782 GuardVerdict::Block { subject, reason } => {
783 assert_eq!(subject, outside);
784 assert!(reason.contains("outside the mission checkout"), "{reason}");
785 }
786 GuardVerdict::Allow => panic!("{outside} must never allow"),
787 }
788 // `..` escaping the checkout normalizes to an outside path → block.
789 assert!(matches!(
790 evaluate(&spec, "Write", Some("../../etc/passwd")),
791 GuardVerdict::Block { .. }
792 ));
793 match evaluate(&spec, "NotebookEdit", None) {
794 GuardVerdict::Block { subject, .. } => assert_eq!(subject, "(unresolved)"),
795 GuardVerdict::Allow => panic!("a path-less write tool call must fail closed"),
796 }
797 // A broken touch-set glob blocks loudly rather than waving writes through.
798 let broken = spec_fixture(&cwd, &["["]);
799 assert!(matches!(
800 evaluate(&broken, "Write", Some("src/lib.rs")),
801 GuardVerdict::Block { .. }
802 ));
803 }
804
805 /// The same checkout may arrive through two absolute spellings (macOS's
806 /// `/var` and `/private/var` is the live receipt). Both must judge
807 /// identically, while a symlink that actually escapes the checkout stays
808 /// blocked.
809 #[cfg(unix)]
810 #[test]
811 fn hook_gate_projection_resolves_path_aliases_and_symlink_escapes() {
812 use std::os::unix::fs::symlink;
813
814 let root = tempfile::tempdir().unwrap();
815 let checkout = root.path().join("checkout");
816 let src = checkout.join("src");
817 let outside = root.path().join("outside");
818 std::fs::create_dir_all(&src).unwrap();
819 std::fs::create_dir_all(&outside).unwrap();
820
821 let alias = root.path().join("checkout-alias");
822 symlink(&checkout, &alias).unwrap();
823 let spec = spec_fixture(&alias, &["src/**"]);
824
825 assert_eq!(
826 evaluate(
827 &spec,
828 "Write",
829 Some(&checkout.join("src/new.ts").display().to_string())
830 ),
831 GuardVerdict::Allow,
832 "the canonical spelling of an aliased checkout must allow"
833 );
834 assert_eq!(
835 evaluate(
836 &spec,
837 "Write",
838 Some(&alias.join("src/new.ts").display().to_string())
839 ),
840 GuardVerdict::Allow,
841 "the alias spelling of the same checkout must allow"
842 );
843
844 symlink(&outside, checkout.join("escape")).unwrap();
845 assert!(matches!(
846 evaluate(&spec, "Write", Some("escape/outside.ts")),
847 GuardVerdict::Block { .. }
848 ));
849 }
850
851 /// Records round-trip through the JSONL file, and the fold is tolerant
852 /// (garbage lines skipped), bounded, scrubbed, and stamps the run id
853 /// from the RUN — never from the session-writable line.
854 #[test]
855 fn hook_gate_projection_records_fold_tolerantly_and_stamp_the_run() {
856 let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
857 let spec = spec_fixture(&test_cwd(), &["src/**"]);
858 let file = record_file(&session_id);
859
860 let record = HookGateRecord::blocked(
861 &spec,
862 "PreToolUse",
863 "Write",
864 "docs/oops.md",
865 "outside the touch set",
866 Some("cli-session-1"),
867 Some("toolu_1"),
868 );
869 record.append_to(&file).unwrap();
870 // A session-authored garbage line between valid ones.
871 {
872 use std::io::Write as _;
873 let mut f = std::fs::OpenOptions::new()
874 .append(true)
875 .open(&file)
876 .unwrap();
877 writeln!(f, "{{not json").unwrap();
878 writeln!(f).unwrap();
879 }
880 HookGateRecord::error(&spec, "stdin was not JSON")
881 .append_to(&file)
882 .unwrap();
883
884 let events = records_to_events(&session_id, "run-xyz");
885 assert_eq!(events.len(), 2, "garbage lines must be skipped");
886 match &events[0] {
887 EventKind::HookGateFired {
888 run_id,
889 gate,
890 hook_event,
891 tool,
892 subject,
893 verdict,
894 detail,
895 } => {
896 assert_eq!(run_id, "run-xyz", "the run id is engine-stamped");
897 assert_eq!(gate, HOOK_GATE_ID);
898 assert_eq!(hook_event, "PreToolUse");
899 assert_eq!(tool, "Write");
900 assert_eq!(subject, "docs/oops.md");
901 assert_eq!(verdict, "blocked");
902 assert_eq!(detail.as_deref(), Some("outside the touch set"));
903 }
904 other => panic!("expected hook.gate.fired, got {other:?}"),
905 }
906 match &events[1] {
907 EventKind::HookGateFired { verdict, .. } => assert_eq!(verdict, "error"),
908 other => panic!("expected the error record, got {other:?}"),
909 }
910
911 // A missing file folds to nothing (hook never fired / bypassed).
912 assert!(records_to_events("no-such-session-hook-gate-projection", "r").is_empty());
913
914 let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
915 }
916
917 /// The fold cap bounds a flooding (or hostile) record file.
918 #[test]
919 fn hook_gate_projection_fold_is_capped() {
920 let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
921 let spec = spec_fixture(&test_cwd(), &["src/**"]);
922 let file = record_file(&session_id);
923 for i in 0..(FOLD_CAP + 10) {
924 HookGateRecord::blocked(
925 &spec,
926 "PreToolUse",
927 "Write",
928 &format!("docs/{i}.md"),
929 "r",
930 None,
931 None,
932 )
933 .append_to(&file)
934 .unwrap();
935 }
936 let events = records_to_events(&session_id, "run-cap");
937 assert_eq!(events.len(), FOLD_CAP);
938 let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
939 }
940
941 /// Shell quoting: a path containing a single quote must not corrupt the
942 /// command line.
943 #[test]
944 fn hook_gate_projection_shell_quote_escapes_single_quotes() {
945 assert_eq!(shell_quote(Path::new("/a/b")), "'/a/b'");
946 assert_eq!(shell_quote(Path::new("/a/o'brien")), r"'/a/o'\''brien'");
947 }
948}