1use kranz_engine::hook_gates::{GuardVerdict, HookGateRecord, HookGateSpec};
28use kranz_engine::hook_status::STDIN_PAYLOAD_MAX_BYTES;
29use std::io::Read;
30use std::path::Path;
31
32const EXIT_GUARD_ERROR: i32 = 1;
34const EXIT_BLOCK: i32 = 2;
36
37pub fn run_hook_guard(config: &Path, stdin: &mut impl Read) -> i32 {
41 let spec = match HookGateSpec::load(config) {
42 Ok(spec) => spec,
43 Err(e) => {
44 eprintln!(
45 "kranz hook-guard: failed to load the hook spec {}: {e} \
46 (failing open; the engine-side out-of-contract sweep remains authoritative)",
47 config.display()
48 );
49 return EXIT_GUARD_ERROR;
50 }
51 };
52
53 let mut payload_bytes = Vec::new();
61 if let Err(e) = stdin
62 .take((STDIN_PAYLOAD_MAX_BYTES + 1) as u64)
63 .read_to_end(&mut payload_bytes)
64 {
65 return guard_error(
66 &spec,
67 &format!("failed to read the hook payload on stdin: {e}"),
68 );
69 }
70 if payload_bytes.len() > STDIN_PAYLOAD_MAX_BYTES {
71 return guard_error(
72 &spec,
73 &format!("hook payload exceeds {STDIN_PAYLOAD_MAX_BYTES} bytes"),
74 );
75 }
76 let payload: serde_json::Value = match serde_json::from_slice(&payload_bytes) {
77 Ok(payload) => payload,
78 Err(e) => {
79 return guard_error(&spec, &format!("hook payload was not JSON: {e}"));
80 }
81 };
82 let Some(tool_name) = payload.get("tool_name").and_then(|v| v.as_str()) else {
83 return guard_error(&spec, "hook payload carried no tool_name");
84 };
85 let hook_event = payload
86 .get("hook_event_name")
87 .and_then(|v| v.as_str())
88 .unwrap_or("PreToolUse");
89 let file_path = payload
92 .pointer("/tool_input/file_path")
93 .or_else(|| payload.pointer("/tool_input/notebook_path"))
94 .and_then(|v| v.as_str());
95 let session_id = payload.get("session_id").and_then(|v| v.as_str());
96 let tool_use_id = payload.get("tool_use_id").and_then(|v| v.as_str());
97
98 match kranz_engine::hook_gates::evaluate(&spec, tool_name, file_path) {
99 GuardVerdict::Allow => 0,
100 GuardVerdict::Block { subject, reason } => {
101 let record = HookGateRecord::blocked(
102 &spec,
103 hook_event,
104 tool_name,
105 &subject,
106 &reason,
107 session_id,
108 tool_use_id,
109 );
110 let _ = record.append_to(&spec.record_file);
114 eprintln!("{reason}");
117 EXIT_BLOCK
118 }
119 }
120}
121
122fn guard_error(spec: &HookGateSpec, note: &str) -> i32 {
126 let _ = HookGateRecord::error(spec, note).append_to(&spec.record_file);
127 eprintln!("kranz hook-guard: {note} (failing open)");
128 EXIT_GUARD_ERROR
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn write_spec(dir: &Path, touch_set: &[&str]) -> std::path::PathBuf {
136 let spec = HookGateSpec {
137 version: kranz_engine::hook_gates::SPEC_VERSION,
138 gate: kranz_engine::hook_gates::HOOK_GATE_ID.to_string(),
139 session_cwd: dir.to_path_buf(),
140 touch_set: touch_set.iter().map(|s| s.to_string()).collect(),
141 record_file: dir.join("records.jsonl"),
142 };
143 let path = dir.join("spec.json");
144 std::fs::write(&path, serde_json::to_string_pretty(&spec).unwrap()).unwrap();
145 path
146 }
147
148 fn payload(tool: &str, file_path: Option<&str>) -> String {
149 let input = match file_path {
150 Some(path) => serde_json::json!({ "file_path": path }),
151 None => serde_json::json!({}),
152 };
153 serde_json::json!({
154 "session_id": "cli-session-1",
155 "transcript_path": "/tmp/t.jsonl",
156 "cwd": "/tmp",
157 "hook_event_name": "PreToolUse",
158 "tool_name": tool,
159 "tool_input": input,
160 "tool_use_id": "toolu_1",
161 })
162 .to_string()
163 }
164
165 #[test]
169 fn hook_gate_projection_guard_blocks_and_records_out_of_contract_writes() {
170 let dir = tempfile::tempdir().unwrap();
171 let config = write_spec(dir.path(), &["src/**"]);
172
173 let stdin = payload("Write", Some("/outside/the/checkout.md")).into_bytes();
174 let code = run_hook_guard(&config, &mut stdin.as_slice());
176 assert_eq!(code, 2);
177 let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
178 let record: serde_json::Value =
179 serde_json::from_str(records.lines().next().unwrap()).unwrap();
180 assert_eq!(record["verdict"], "blocked");
181 assert_eq!(record["gate"], kranz_engine::hook_gates::HOOK_GATE_ID);
182 assert_eq!(record["hookEvent"], "PreToolUse");
183 assert_eq!(record["tool"], "Write");
184 assert_eq!(record["sessionId"], "cli-session-1");
185 assert_eq!(record["toolUseId"], "toolu_1");
186
187 let stdin = payload("Edit", Some("src/lib.rs")).into_bytes();
189 let code = run_hook_guard(&config, &mut stdin.as_slice());
190 assert_eq!(code, 0);
191 let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
192 assert_eq!(records.lines().count(), 1, "an allow records nothing");
193
194 let stdin = payload("Write", Some("docs/oops.md")).into_bytes();
196 let code = run_hook_guard(&config, &mut stdin.as_slice());
197 assert_eq!(code, 2);
198 let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
199 let record: serde_json::Value =
200 serde_json::from_str(records.lines().nth(1).unwrap()).unwrap();
201 assert_eq!(record["subject"], "docs/oops.md");
202 }
203
204 #[test]
208 fn hook_gate_projection_guard_failures_fail_open_loudly() {
209 let dir = tempfile::tempdir().unwrap();
210 let config = write_spec(dir.path(), &["src/**"]);
211
212 let mut stdin = b"{not json".as_slice();
214 let code = run_hook_guard(&config, &mut stdin);
215 assert_eq!(code, 1);
216 let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
217 let record: serde_json::Value =
218 serde_json::from_str(records.lines().next().unwrap()).unwrap();
219 assert_eq!(record["verdict"], "error");
220
221 let missing = dir.path().join("no-such-spec.json");
224 let stdin = payload("Write", Some("src/lib.rs")).into_bytes();
225 let code = run_hook_guard(&missing, &mut stdin.as_slice());
226 assert_eq!(code, 1);
227 }
228
229 #[test]
233 fn hook_guard_stdin_read_is_bounded_fail_open() {
234 let dir = tempfile::tempdir().unwrap();
235 let config = write_spec(dir.path(), &["src/**"]);
236
237 let oversized = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES + 1];
238 let code = run_hook_guard(&config, &mut oversized.as_slice());
239 assert_eq!(code, 1, "over the cap is a guard error, failing open");
240 let records = std::fs::read_to_string(dir.path().join("records.jsonl")).unwrap();
241 let record: serde_json::Value =
242 serde_json::from_str(records.lines().next().unwrap()).unwrap();
243 assert_eq!(record["verdict"], "error");
244
245 let at_cap = vec![b'x'; STDIN_PAYLOAD_MAX_BYTES];
248 let code = run_hook_guard(&config, &mut at_cap.as_slice());
249 assert_eq!(code, 1, "at the cap the payload is read and judged");
250 }
251}