use kranz_engine::hook_gates::{GuardVerdict, HookGateRecord, HookGateSpec};
use kranz_engine::hook_status::STDIN_PAYLOAD_MAX_BYTES;
use std::io::Read;
use std::path::Path;
const EXIT_GUARD_ERROR: i32 = 1;
const EXIT_BLOCK: i32 = 2;
pub fn run_hook_guard(config: &Path, stdin: &mut impl Read) -> i32 {
let spec = match HookGateSpec::load(config) {
Ok(spec) => spec,
Err(e) => {
eprintln!(
"kranz hook-guard: failed to load the hook spec {}: {e} \
(failing open; the engine-side out-of-contract sweep remains authoritative)",
config.display()
);
return EXIT_GUARD_ERROR;
}
};
let mut payload_bytes = Vec::new();
if let Err(e) = stdin
.take((STDIN_PAYLOAD_MAX_BYTES + 1) as u64)
.read_to_end(&mut payload_bytes)
{
return guard_error(
&spec,
&format!("failed to read the hook payload on stdin: {e}"),
);
}
if payload_bytes.len() > STDIN_PAYLOAD_MAX_BYTES {
return guard_error(
&spec,
&format!("hook payload exceeds {STDIN_PAYLOAD_MAX_BYTES} bytes"),
);
}
let payload: serde_json::Value = match serde_json::from_slice(&payload_bytes) {
Ok(payload) => payload,
Err(e) => {
return guard_error(&spec, &format!("hook payload was not JSON: {e}"));
}
};
let Some(tool_name) = payload.get("tool_name").and_then(|v| v.as_str()) else {
return guard_error(&spec, "hook payload carried no tool_name");
};
let hook_event = payload
.get("hook_event_name")
.and_then(|v| v.as_str())
.unwrap_or("PreToolUse");
let file_path = payload
.pointer("/tool_input/file_path")
.or_else(|| payload.pointer("/tool_input/notebook_path"))
.and_then(|v| v.as_str());
let session_id = payload.get("session_id").and_then(|v| v.as_str());
let tool_use_id = payload.get("tool_use_id").and_then(|v| v.as_str());
match kranz_engine::hook_gates::evaluate(&spec, tool_name, file_path) {
GuardVerdict::Allow => 0,
GuardVerdict::Block { subject, reason } => {
let record = HookGateRecord::blocked(
&spec,
hook_event,
tool_name,
&subject,
&reason,
session_id,
tool_use_id,
);
let _ = record.append_to(&spec.record_file);
eprintln!("{reason}");
EXIT_BLOCK
}
}
}
fn guard_error(spec: &HookGateSpec, note: &str) -> i32 {
let _ = HookGateRecord::error(spec, note).append_to(&spec.record_file);
eprintln!("kranz hook-guard: {note} (failing open)");
EXIT_GUARD_ERROR
}
#[cfg(test)]
mod tests {
use super::*;
fn write_spec(dir: &Path, touch_set: &[&str]) -> std::path::PathBuf {
let spec = HookGateSpec {
version: kranz_engine::hook_gates::SPEC_VERSION,
gate: kranz_engine::hook_gates::HOOK_GATE_ID.to_string(),
session_cwd: dir.to_path_buf(),
touch_set: touch_set.iter().map(|s| s.to_string()).collect(),
record_file: dir.join("records.jsonl"),
};
let path = dir.join("spec.json");
std::fs::write(&path, serde_json::to_string_pretty(&spec).unwrap()).unwrap();
path
}
fn payload(tool: &str, file_path: Option<&str>) -> String {
let input = match file_path {
Some(path) => serde_json::json!({ "file_path": path }),
None => serde_json::json!({}),
};
serde_json::json!({
"session_id": "cli-session-1",
"transcript_path": "/tmp/t.jsonl",
"cwd": "/tmp",
"hook_event_name": "PreToolUse",
"tool_name": tool,
"tool_input": input,
"tool_use_id": "toolu_1",
})
.to_string()
}
#[test]
fn hook_gate_projection_guard_blocks_and_records_out_of_contract_writes() {
let dir = tempfile::tempdir().unwrap();
let config = write_spec(dir.path(), &["src/**"]);
let stdin = payload("Write", Some("/outside/the/checkout.md")).into_bytes();
let code = run_hook_guard(&config, &mut stdin.as_slice());
assert_eq!(code, 2);
let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
let record: serde_json::Value =
serde_json::from_str(records.lines().next().unwrap()).unwrap();
assert_eq!(record["verdict"], "blocked");
assert_eq!(record["gate"], kranz_engine::hook_gates::HOOK_GATE_ID);
assert_eq!(record["hookEvent"], "PreToolUse");
assert_eq!(record["tool"], "Write");
assert_eq!(record["sessionId"], "cli-session-1");
assert_eq!(record["toolUseId"], "toolu_1");
let stdin = payload("Edit", Some("src/lib.rs")).into_bytes();
let code = run_hook_guard(&config, &mut stdin.as_slice());
assert_eq!(code, 0);
let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
assert_eq!(records.lines().count(), 1, "an allow records nothing");
let stdin = payload("Write", Some("docs/oops.md")).into_bytes();
let code = run_hook_guard(&config, &mut stdin.as_slice());
assert_eq!(code, 2);
let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
let record: serde_json::Value =
serde_json::from_str(records.lines().nth(1).unwrap()).unwrap();
assert_eq!(record["subject"], "docs/oops.md");
}
#[test]
fn hook_gate_projection_guard_failures_fail_open_loudly() {
let dir = tempfile::tempdir().unwrap();
let config = write_spec(dir.path(), &["src/**"]);
let mut stdin = b"{not json".as_slice();
let code = run_hook_guard(&config, &mut stdin);
assert_eq!(code, 1);
let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
let record: serde_json::Value =
serde_json::from_str(records.lines().next().unwrap()).unwrap();
assert_eq!(record["verdict"], "error");
let missing = dir.path().join("no-such-spec.json");
let stdin = payload("Write", Some("src/lib.rs")).into_bytes();
let code = run_hook_guard(&missing, &mut stdin.as_slice());
assert_eq!(code, 1);
}
#[test]
fn hook_guard_stdin_read_is_bounded_fail_open() {
let dir = tempfile::tempdir().unwrap();
let config = write_spec(dir.path(), &["src/**"]);
let oversized = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES + 1];
let code = run_hook_guard(&config, &mut oversized.as_slice());
assert_eq!(code, 1, "over the cap is a guard error, failing open");
let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
let record: serde_json::Value =
serde_json::from_str(records.lines().next().unwrap()).unwrap();
assert_eq!(record["verdict"], "error");
let at_cap = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES];
let code = run_hook_guard(&config, &mut at_cap.as_slice());
assert_eq!(code, 1, "at the cap the payload is read and judged");
}
}