Skip to main content

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}