use crate::backend::SessionSpec;
use crate::contract_sweep;
use crate::events::EventKind;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
pub const HOOK_GATE_ID: &str = contract_sweep::FINDING_CLASS;
pub const HOOK_GUARD_SUBCOMMAND: &str = "hook-guard";
pub const SPEC_VERSION: u32 = 1;
const HOOK_TIMEOUT_SECS: u32 = 10;
pub const FOLD_CAP: usize = 64;
const RECORD_SUBJECT_MAX: usize = 500;
const RECORD_DETAIL_MAX: usize = 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookGateSpec {
pub version: u32,
pub gate: String,
pub session_cwd: PathBuf,
pub touch_set: Vec<String>,
pub record_file: PathBuf,
}
impl HookGateSpec {
pub fn load(path: &Path) -> std::io::Result<Self> {
let text = std::fs::read_to_string(path)?;
serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
}
fn hook_gate_dir(session_id: &str) -> PathBuf {
crate::backend_claude::scratch_home_root(session_id).join("hook-gate")
}
pub fn spec_file(session_id: &str) -> PathBuf {
hook_gate_dir(session_id).join("spec.json")
}
pub fn record_file(session_id: &str) -> PathBuf {
hook_gate_dir(session_id).join("records.jsonl")
}
pub fn worker_hook_settings(command: &str) -> Value {
json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": command,
"timeout": HOOK_TIMEOUT_SECS,
}
]
}
]
}
})
}
pub fn project_worker_hook_gates(spec: &mut SessionSpec, touch_set: &[String]) {
if touch_set.is_empty() {
return;
}
let session_id = spec.session_id.clone();
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
tracing::warn!(
session_id = %session_id,
error = %e,
"hook gate projection skipped: current_exe unresolved; \
the engine-side out-of-contract sweep remains authoritative"
);
return;
}
};
let gate_spec = HookGateSpec {
version: SPEC_VERSION,
gate: HOOK_GATE_ID.to_string(),
session_cwd: spec.cwd.clone(),
touch_set: touch_set.to_vec(),
record_file: record_file(&session_id),
};
let spec_path = spec_file(&session_id);
let written = (|| -> std::io::Result<()> {
if let Some(parent) = spec_path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(&gate_spec).map_err(std::io::Error::other)?;
std::fs::write(&spec_path, text)
})();
if let Err(e) = written {
tracing::warn!(
session_id = %session_id,
error = %e,
"hook gate projection skipped: spec file write failed; \
the engine-side out-of-contract sweep remains authoritative"
);
return;
}
let command = format!(
"{} {} --config {}",
shell_quote(&exe),
HOOK_GUARD_SUBCOMMAND,
shell_quote(&spec_path)
);
spec.settings_json = Some(worker_hook_settings(&command));
tracing::info!(
session_id = %session_id,
gate = HOOK_GATE_ID,
"out-of-contract write rule projected onto a PreToolUse lifecycle hook \
(defense-in-depth; the engine-side sweep remains authoritative)"
);
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', r"'\''"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuardVerdict {
Allow,
Block { subject: String, reason: String },
}
pub fn evaluate(spec: &HookGateSpec, tool_name: &str, file_path: Option<&str>) -> GuardVerdict {
let Some(raw) = file_path.filter(|p| !p.trim().is_empty()) else {
return GuardVerdict::Block {
subject: "(unresolved)".to_string(),
reason: format!(
"kranz hook gate ({}) blocked {tool_name}: the tool call carried no file \
path the guard can judge, so the write is out of contract by default",
spec.gate
),
};
};
let raw_path = Path::new(raw);
let absolute = if raw_path.is_absolute() {
resolve_existing_prefix(raw_path)
} else {
resolve_existing_prefix(&spec.session_cwd.join(raw_path))
};
let cwd = resolve_existing_prefix(&spec.session_cwd);
let rel = match absolute.strip_prefix(&cwd) {
Ok(rel) => rel,
Err(_) => {
return GuardVerdict::Block {
subject: raw.to_string(),
reason: format!(
"kranz hook gate ({}) blocked {tool_name}: {raw} is outside the mission \
checkout, which no touch-set glob can ever cover",
spec.gate
),
};
}
};
let rel_str = rel.to_string_lossy().replace('\\', "/");
match contract_sweep::touch_set_includes(&spec.touch_set, &rel_str) {
Ok(true) => GuardVerdict::Allow,
Ok(false) => GuardVerdict::Block {
subject: rel_str.clone(),
reason: format!(
"kranz hook gate ({}) blocked {tool_name}: {rel_str} matches none of the \
mission's declared touch-set globs — relocate the write under a declared \
path, or stop and surface the need for a touch-path grant",
spec.gate
),
},
Err(e) => GuardVerdict::Block {
subject: rel_str,
reason: format!(
"kranz hook gate ({}) blocked {tool_name}: touch-set glob compile error: {e}",
spec.gate
),
},
}
}
fn resolve_existing_prefix(path: &Path) -> PathBuf {
let lexical = normalize_lexical(path);
let mut probe = lexical.as_path();
let mut suffix = Vec::new();
loop {
if let Ok(mut resolved) = probe.canonicalize() {
for component in suffix.iter().rev() {
resolved.push(component);
}
return normalize_lexical(&resolved);
}
let Some(name) = probe.file_name() else {
return lexical;
};
suffix.push(name.to_os_string());
let Some(parent) = probe.parent() else {
return lexical;
};
probe = parent;
}
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookGateRecord {
pub v: u32,
pub ts: DateTime<Utc>,
pub gate: String,
pub hook_event: String,
pub tool: String,
pub subject: String,
pub verdict: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_use_id: Option<String>,
}
impl HookGateRecord {
pub fn blocked(
spec: &HookGateSpec,
hook_event: &str,
tool: &str,
subject: &str,
reason: &str,
session_id: Option<&str>,
tool_use_id: Option<&str>,
) -> Self {
HookGateRecord {
v: SPEC_VERSION,
ts: Utc::now(),
gate: spec.gate.clone(),
hook_event: hook_event.to_string(),
tool: tool.to_string(),
subject: subject.to_string(),
verdict: "blocked".to_string(),
detail: Some(reason.to_string()),
session_id: session_id.map(str::to_string),
tool_use_id: tool_use_id.map(str::to_string),
}
}
pub fn error(spec: &HookGateSpec, note: &str) -> Self {
HookGateRecord {
v: SPEC_VERSION,
ts: Utc::now(),
gate: spec.gate.clone(),
hook_event: "PreToolUse".to_string(),
tool: String::new(),
subject: String::new(),
verdict: "error".to_string(),
detail: Some(note.to_string()),
session_id: None,
tool_use_id: None,
}
}
pub fn append_to(&self, record_file: &Path) -> std::io::Result<()> {
use std::io::Write as _;
if let Some(parent) = record_file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(record_file)?;
let line = serde_json::to_string(self).map_err(std::io::Error::other)?;
writeln!(file, "{line}")
}
}
pub fn records_to_events(session_id: &str, run_id: &str) -> Vec<EventKind> {
let file = record_file(session_id);
let contents = match std::fs::read_to_string(&file) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
tracing::warn!(
session_id,
error = %e,
"hook gate record file unreadable; folding nothing \
(the engine-side sweep remains authoritative)"
);
return Vec::new();
}
};
let mut events = Vec::new();
let mut skipped = 0usize;
let mut truncated = false;
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
if events.len() >= FOLD_CAP {
truncated = true;
break;
}
match serde_json::from_str::<HookGateRecord>(line) {
Ok(record) => events.push(record_into_event(record, run_id)),
Err(_) => skipped += 1,
}
}
if skipped > 0 || truncated {
tracing::warn!(
session_id,
skipped,
truncated,
"hook gate record fold dropped session-authored lines \
(malformed or over the fold cap)"
);
}
events
}
fn record_into_event(record: HookGateRecord, run_id: &str) -> EventKind {
EventKind::HookGateFired {
run_id: run_id.to_string(),
gate: crate::scrub::scrub(&record.gate),
hook_event: crate::scrub::scrub(&record.hook_event),
tool: crate::scrub::scrub(&record.tool),
subject: crate::scrub::scrub_and_truncate(&record.subject, RECORD_SUBJECT_MAX),
verdict: crate::scrub::scrub(&record.verdict),
detail: record
.detail
.map(|d| crate::scrub::scrub_and_truncate(&d, RECORD_DETAIL_MAX)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec_fixture(cwd: &Path, touch_set: &[&str]) -> HookGateSpec {
HookGateSpec {
version: SPEC_VERSION,
gate: HOOK_GATE_ID.to_string(),
session_cwd: cwd.to_path_buf(),
touch_set: touch_set.iter().map(|s| s.to_string()).collect(),
record_file: cwd.join("records.jsonl"),
}
}
#[test]
fn hook_gate_projection_settings_shape_matches_the_hooks_schema() {
let settings =
worker_hook_settings("'/usr/local/bin/kranz' hook-guard --config '/tmp/s/spec.json'");
let groups = settings["hooks"]["PreToolUse"]
.as_array()
.expect("PreToolUse matcher groups");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0]["matcher"], "Write|Edit|MultiEdit|NotebookEdit");
let handlers = groups[0]["hooks"].as_array().expect("handlers");
assert_eq!(handlers.len(), 1);
assert_eq!(handlers[0]["type"], "command");
assert_eq!(
handlers[0]["command"],
"'/usr/local/bin/kranz' hook-guard --config '/tmp/s/spec.json'"
);
assert!(
handlers[0]["timeout"].as_u64().unwrap() <= 30,
"the guard is a local path check; the CLI's 600s default must not stand"
);
}
#[test]
fn hook_gate_projection_worker_spec_carries_settings_and_spec_file() {
let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
let mut spec = SessionSpec {
cwd: PathBuf::from("/repo/worktree"),
prompt: crate::backend::PromptMode::SingleShot("task".to_string()),
append_system_prompt: None,
model: "stub".to_string(),
effort: "low".to_string(),
session_id: session_id.clone(),
resume: None,
permission_mode: None,
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
tools: Vec::new(),
writable: true,
settings_json: None,
json_schema: None,
max_budget_usd: None,
max_turns: None,
env: Default::default(),
sandbox: None,
hook_status: None,
};
let touch_set = vec!["src/**".to_string(), "!src/generated/**".to_string()];
project_worker_hook_gates(&mut spec, &touch_set);
let settings = spec.settings_json.expect("hook settings projected");
let command = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(command.contains(HOOK_GUARD_SUBCOMMAND), "{command}");
assert!(command.contains("--config"), "{command}");
let loaded = HookGateSpec::load(&spec_file(&session_id)).expect("spec file written");
assert_eq!(loaded.version, SPEC_VERSION);
assert_eq!(loaded.gate, HOOK_GATE_ID);
assert_eq!(loaded.session_cwd, PathBuf::from("/repo/worktree"));
assert_eq!(loaded.touch_set, touch_set);
assert_eq!(loaded.record_file, record_file(&session_id));
assert!(
loaded
.record_file
.starts_with(crate::backend_claude::scratch_home_root(&session_id)),
"the record file must live under the session-private scratch root"
);
let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
}
#[test]
fn hook_gate_projection_empty_touch_set_projects_nothing() {
let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
let mut spec = SessionSpec {
cwd: PathBuf::from("/repo"),
prompt: crate::backend::PromptMode::SingleShot("task".to_string()),
append_system_prompt: None,
model: "stub".to_string(),
effort: "low".to_string(),
session_id: session_id.clone(),
resume: None,
permission_mode: None,
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
tools: Vec::new(),
writable: true,
settings_json: None,
json_schema: None,
max_budget_usd: None,
max_turns: None,
env: Default::default(),
sandbox: None,
hook_status: None,
};
project_worker_hook_gates(&mut spec, &[]);
assert!(spec.settings_json.is_none());
assert!(!spec_file(&session_id).exists());
}
fn test_cwd() -> PathBuf {
if cfg!(windows) {
PathBuf::from(r"C:\repo\wt")
} else {
PathBuf::from("/repo/wt")
}
}
fn under(cwd: &Path, rel: &str) -> String {
cwd.join(rel).display().to_string()
}
#[test]
fn hook_gate_projection_evaluate_judges_against_the_touch_set() {
let cwd = test_cwd();
let spec = spec_fixture(&cwd, &["src/**", "!src/generated/**"]);
assert_eq!(
evaluate(&spec, "Write", Some(&under(&cwd, "src/lib.rs"))),
GuardVerdict::Allow
);
assert_eq!(
evaluate(&spec, "Edit", Some("src/lib.rs")),
GuardVerdict::Allow
);
assert_eq!(
evaluate(&spec, "Write", Some(&under(&cwd, "docs/../src/lib.rs"))),
GuardVerdict::Allow
);
let blocked = evaluate(&spec, "Write", Some(&under(&cwd, "docs/oops.md")));
let GuardVerdict::Block { subject, reason } = blocked else {
panic!("docs/oops.md must block: {blocked:?}")
};
assert_eq!(subject, "docs/oops.md");
assert!(reason.contains("touch-set"), "{reason}");
assert!(matches!(
evaluate(&spec, "Edit", Some("src/generated/x.rs")),
GuardVerdict::Block { .. }
));
}
#[test]
fn hook_gate_projection_evaluate_fails_closed_on_unjudgeable_writes() {
let cwd = test_cwd();
let spec = spec_fixture(&cwd, &["src/**"]);
let outside = if cfg!(windows) {
r"C:\outside\checkout.md"
} else {
"/outside/checkout.md"
};
match evaluate(&spec, "Write", Some(outside)) {
GuardVerdict::Block { subject, reason } => {
assert_eq!(subject, outside);
assert!(reason.contains("outside the mission checkout"), "{reason}");
}
GuardVerdict::Allow => panic!("{outside} must never allow"),
}
assert!(matches!(
evaluate(&spec, "Write", Some("../../etc/passwd")),
GuardVerdict::Block { .. }
));
match evaluate(&spec, "NotebookEdit", None) {
GuardVerdict::Block { subject, .. } => assert_eq!(subject, "(unresolved)"),
GuardVerdict::Allow => panic!("a path-less write tool call must fail closed"),
}
let broken = spec_fixture(&cwd, &["["]);
assert!(matches!(
evaluate(&broken, "Write", Some("src/lib.rs")),
GuardVerdict::Block { .. }
));
}
#[cfg(unix)]
#[test]
fn hook_gate_projection_resolves_path_aliases_and_symlink_escapes() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let checkout = root.path().join("checkout");
let src = checkout.join("src");
let outside = root.path().join("outside");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let alias = root.path().join("checkout-alias");
symlink(&checkout, &alias).unwrap();
let spec = spec_fixture(&alias, &["src/**"]);
assert_eq!(
evaluate(
&spec,
"Write",
Some(&checkout.join("src/new.ts").display().to_string())
),
GuardVerdict::Allow,
"the canonical spelling of an aliased checkout must allow"
);
assert_eq!(
evaluate(
&spec,
"Write",
Some(&alias.join("src/new.ts").display().to_string())
),
GuardVerdict::Allow,
"the alias spelling of the same checkout must allow"
);
symlink(&outside, checkout.join("escape")).unwrap();
assert!(matches!(
evaluate(&spec, "Write", Some("escape/outside.ts")),
GuardVerdict::Block { .. }
));
}
#[test]
fn hook_gate_projection_records_fold_tolerantly_and_stamp_the_run() {
let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
let spec = spec_fixture(&test_cwd(), &["src/**"]);
let file = record_file(&session_id);
let record = HookGateRecord::blocked(
&spec,
"PreToolUse",
"Write",
"docs/oops.md",
"outside the touch set",
Some("cli-session-1"),
Some("toolu_1"),
);
record.append_to(&file).unwrap();
{
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&file)
.unwrap();
writeln!(f, "{{not json").unwrap();
writeln!(f).unwrap();
}
HookGateRecord::error(&spec, "stdin was not JSON")
.append_to(&file)
.unwrap();
let events = records_to_events(&session_id, "run-xyz");
assert_eq!(events.len(), 2, "garbage lines must be skipped");
match &events[0] {
EventKind::HookGateFired {
run_id,
gate,
hook_event,
tool,
subject,
verdict,
detail,
} => {
assert_eq!(run_id, "run-xyz", "the run id is engine-stamped");
assert_eq!(gate, HOOK_GATE_ID);
assert_eq!(hook_event, "PreToolUse");
assert_eq!(tool, "Write");
assert_eq!(subject, "docs/oops.md");
assert_eq!(verdict, "blocked");
assert_eq!(detail.as_deref(), Some("outside the touch set"));
}
other => panic!("expected hook.gate.fired, got {other:?}"),
}
match &events[1] {
EventKind::HookGateFired { verdict, .. } => assert_eq!(verdict, "error"),
other => panic!("expected the error record, got {other:?}"),
}
assert!(records_to_events("no-such-session-hook-gate-projection", "r").is_empty());
let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
}
#[test]
fn hook_gate_projection_fold_is_capped() {
let session_id = format!("hook-gate-projection-{}", uuid::Uuid::new_v4());
let spec = spec_fixture(&test_cwd(), &["src/**"]);
let file = record_file(&session_id);
for i in 0..(FOLD_CAP + 10) {
HookGateRecord::blocked(
&spec,
"PreToolUse",
"Write",
&format!("docs/{i}.md"),
"r",
None,
None,
)
.append_to(&file)
.unwrap();
}
let events = records_to_events(&session_id, "run-cap");
assert_eq!(events.len(), FOLD_CAP);
let _ = std::fs::remove_dir_all(crate::backend_claude::scratch_home_root(&session_id));
}
#[test]
fn hook_gate_projection_shell_quote_escapes_single_quotes() {
assert_eq!(shell_quote(Path::new("/a/b")), "'/a/b'");
assert_eq!(shell_quote(Path::new("/a/o'brien")), r"'/a/o'\''brien'");
}
}