1use serde_json::{json, Map, Value};
13use std::path::Path;
14
15use crate::Step;
16
17pub const RUNNERS: &[&str] = &[
19 "copilot", "gemini", "windsurf", "factory", "kiro", "cline", "qwen", "crush",
20];
21
22#[must_use]
24pub fn is_runner(name: &str) -> bool {
25 RUNNERS.contains(&name)
26}
27
28fn asks(runner: &str) -> bool {
30 matches!(runner, "copilot" | "factory" | "qwen")
31}
32
33fn gemini_event(raw: &str) -> &str {
35 match raw {
36 "BeforeTool" => "PreToolUse",
37 "AfterTool" => "PostToolUse",
38 "BeforeAgent" => "UserPromptSubmit",
39 other => other,
40 }
41}
42
43fn command_of(args: &Value) -> Option<String> {
46 if let Some(c) = args["command"].as_str() {
47 return Some(c.to_string());
48 }
49 args["commands"].as_array().map(|list| {
50 list.iter()
51 .filter_map(Value::as_str)
52 .collect::<Vec<_>>()
53 .join("\n")
54 })
55}
56
57#[must_use]
62pub fn normalize_input(runner: &str, input: &str) -> String {
63 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
64 return input.to_string();
65 };
66 let mut out = Map::new();
67 let put = |out: &mut Map<String, Value>, k: &str, val: Option<&str>| {
68 if let Some(s) = val.filter(|s| !s.is_empty()) {
69 out.insert(k.into(), Value::String(s.to_string()));
70 }
71 };
72 match runner {
73 "windsurf" => {
74 let action = v["agent_action_name"].as_str().unwrap_or("");
75 let info = &v["tool_info"];
76 let event = match action {
77 "pre_run_command" => "PreToolUse",
78 "post_run_command" => "PostToolUse",
79 "pre_user_prompt" => "UserPromptSubmit",
80 other => other,
81 };
82 out.insert("hook_event_name".into(), json!(event));
83 put(&mut out, "session_id", v["trajectory_id"].as_str());
84 put(&mut out, "cwd", info["cwd"].as_str());
85 if let Some(line) = info["command_line"].as_str() {
86 out.insert("tool_name".into(), json!("Bash"));
87 out.insert("tool_input".into(), json!({ "command": line }));
88 }
89 put(&mut out, "prompt", info["user_prompt"].as_str());
90 }
91 "cline" => {
92 put(&mut out, "session_id", v["taskId"].as_str());
93 put(&mut out, "cwd", v["workspaceRoots"][0].as_str());
94 if let Some(pre) = v.get("preToolUse").filter(|p| p.is_object()) {
95 let tool = pre["toolName"].as_str().or_else(|| pre["tool"].as_str());
96 out.insert("hook_event_name".into(), json!("PreToolUse"));
97 put(&mut out, "tool_name", tool);
98 let args = &pre["parameters"];
99 match command_of(args) {
100 Some(c) => {
101 out.insert("tool_input".into(), json!({ "command": c }));
102 }
103 None => {
104 out.insert("tool_input".into(), args.clone());
105 }
106 }
107 } else if let Some(call) = v.get("tool_call").filter(|p| p.is_object()) {
108 out.insert("hook_event_name".into(), json!("PreToolUse"));
109 put(&mut out, "tool_name", call["name"].as_str());
110 let args = &call["input"];
111 match command_of(args) {
112 Some(c) => {
113 out.insert("tool_input".into(), json!({ "command": c }));
114 }
115 None => {
116 out.insert("tool_input".into(), args.clone());
117 }
118 }
119 } else if let Some(p) = v["userPromptSubmit"]["prompt"].as_str() {
120 out.insert("hook_event_name".into(), json!("UserPromptSubmit"));
121 out.insert("prompt".into(), json!(p));
122 } else {
123 return input.to_string();
124 }
125 }
126 "copilot" if v.get("toolName").is_some() || v.get("sessionId").is_some() => {
127 let event = if v.get("toolName").is_some() {
129 "PreToolUse"
130 } else if v.get("prompt").is_some() {
131 "UserPromptSubmit"
132 } else {
133 "SessionStart"
134 };
135 out.insert("hook_event_name".into(), json!(event));
136 put(&mut out, "session_id", v["sessionId"].as_str());
137 put(&mut out, "cwd", v["cwd"].as_str());
138 put(&mut out, "tool_name", v["toolName"].as_str());
139 put(&mut out, "prompt", v["prompt"].as_str());
140 let args = match &v["toolArgs"] {
141 Value::String(s) => serde_json::from_str(s).unwrap_or(json!({ "command": s })),
142 other => other.clone(),
143 };
144 if !args.is_null() {
145 out.insert("tool_input".into(), args);
146 }
147 }
148 "gemini" => {
149 let mut v = v;
150 if let Some(e) = v["hook_event_name"].as_str() {
151 let seat = gemini_event(e).to_string();
152 v["hook_event_name"] = Value::String(seat);
153 }
154 return v.to_string();
155 }
156 "crush" if v.get("hook_event_name").is_none() => {
157 let mut v = v;
158 let event = v["event"].as_str().unwrap_or("PreToolUse").to_string();
159 v["hook_event_name"] = Value::String(event);
160 return v.to_string();
161 }
162 _ => return input.to_string(),
163 }
164 Value::Object(out).to_string()
165}
166
167#[derive(Debug, Default, PartialEq, Eq)]
169struct Said {
170 decision: Option<String>,
171 reason: String,
172 context: String,
173}
174
175fn read_answer(out: &str) -> Said {
176 let trimmed = out.trim();
177 let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
178 if trimmed.starts_with("deny:") {
180 return Said {
181 decision: Some("deny".into()),
182 reason: trimmed.trim_start_matches("deny:").trim().to_string(),
183 context: String::new(),
184 };
185 }
186 return Said {
187 context: trimmed.to_string(),
188 ..Said::default()
189 };
190 };
191 let specific = &v["hookSpecificOutput"];
192 let decision = specific["permissionDecision"]
193 .as_str()
194 .or_else(|| v["decision"].as_str())
195 .map(|d| if d == "block" { "deny" } else { d })
196 .filter(|d| matches!(*d, "deny" | "ask"))
197 .map(str::to_string);
198 let reason = specific["permissionDecisionReason"]
199 .as_str()
200 .or_else(|| v["reason"].as_str())
201 .unwrap_or("")
202 .to_string();
203 let context = specific["additionalContext"]
204 .as_str()
205 .or_else(|| v["context"].as_str())
206 .unwrap_or("")
207 .to_string();
208 Said {
209 decision,
210 reason,
211 context,
212 }
213}
214
215#[must_use]
219pub fn answer(runner: &str, event: &str, out: &str) -> (String, String, i32) {
220 let mut said = read_answer(out);
221 if said.decision.as_deref() == Some("ask") && !asks(runner) {
222 said.decision = Some("deny".into());
223 said.reason = format!(
224 "ask the person before running this: {}. This runner cannot ask, so retrying \
225 returns this same refusal: stop, tell the person the exact command, and leave \
226 it for them to run.",
227 said.reason
228 );
229 }
230 let line = |v: Value| v.to_string() + "\n";
231 match (runner, said.decision.as_deref()) {
232 ("factory" | "qwen", _) => (out.to_string(), String::new(), 0),
233 ("copilot", Some(d)) => (
234 line(json!({
235 "permissionDecision": d,
236 "permissionDecisionReason": said.reason,
237 })),
238 String::new(),
239 0,
240 ),
241 ("copilot", None) => (String::new(), String::new(), 0),
242 ("gemini", d) => {
243 let mut v = Map::new();
244 if let Some(d) = d {
245 v.insert("decision".into(), json!(d));
246 v.insert("reason".into(), json!(said.reason));
247 }
248 if !said.context.is_empty() {
249 v.insert(
250 "hookSpecificOutput".into(),
251 json!({ "hookEventName": event, "additionalContext": said.context }),
252 );
253 }
254 (line(Value::Object(v)), String::new(), 0)
255 }
256 ("windsurf" | "kiro", Some(_)) => (String::new(), said.reason + "\n", 2),
257 ("kiro", None) if !said.context.is_empty() => (said.context + "\n", String::new(), 0),
258 ("windsurf" | "kiro", None) => (String::new(), String::new(), 0),
259 ("cline", Some(_)) => (
260 line(json!({ "cancel": true, "errorMessage": said.reason })),
261 String::new(),
262 0,
263 ),
264 ("cline", None) => {
265 let mut v = json!({ "cancel": false });
266 if !said.context.is_empty() {
267 v["contextModification"] = json!(said.context);
268 }
269 (line(v), String::new(), 0)
270 }
271 ("crush", Some(d)) => (
272 line(json!({ "decision": d, "reason": said.reason })),
273 String::new(),
274 0,
275 ),
276 ("crush", None) if !said.context.is_empty() => {
277 (line(json!({ "context": said.context })), String::new(), 0)
278 }
279 _ => (String::new(), String::new(), 0),
280 }
281}
282
283#[must_use]
285pub fn raw_event(input: &str) -> String {
286 serde_json::from_str::<Value>(input.trim())
287 .ok()
288 .and_then(|v| {
289 ["hook_event_name", "agent_action_name", "hookName", "event"]
290 .iter()
291 .find_map(|k| v[*k].as_str().map(str::to_string))
292 })
293 .unwrap_or_default()
294}
295
296pub const INNER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(12);
300
301fn failed_closed(event: &str, what: &str) -> String {
304 json!({
305 "hookSpecificOutput": {
306 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
307 "permissionDecision": "deny",
308 "permissionDecisionReason": format!(
309 "the ljos guard hook {what}, so this call is refused unchecked. \
310 Run `ljos doctor` and fix the hook; retrying returns this same refusal."
311 ),
312 }
313 })
314 .to_string()
315}
316
317#[must_use]
322pub fn run_inner(
323 mut cmd: std::process::Command,
324 stdin: &str,
325 event: &str,
326 timeout: std::time::Duration,
327) -> String {
328 use std::io::{Read, Write};
329 let spawned = cmd
330 .stdin(std::process::Stdio::piped())
331 .stdout(std::process::Stdio::piped())
332 .spawn();
333 let mut child = match spawned {
334 Ok(child) => child,
335 Err(e) => return failed_closed(event, &format!("did not start ({e})")),
336 };
337 let input = stdin.to_string();
340 if let Some(mut pipe) = child.stdin.take() {
343 std::thread::spawn(move || {
344 let _ = pipe.write_all(input.as_bytes());
345 });
346 }
347 let reader = child.stdout.take().map(|mut pipe| {
348 std::thread::spawn(move || {
349 let mut buf = Vec::new();
350 let _ = pipe.read_to_end(&mut buf);
351 buf
352 })
353 });
354 let deadline = std::time::Instant::now() + timeout;
355 let status = loop {
356 match child.try_wait() {
357 Ok(Some(status)) => break Ok(status),
358 Ok(None) if std::time::Instant::now() >= deadline => {
359 let _ = child.kill();
360 let _ = child.wait();
361 break Err(format!("gave no answer within {} s", timeout.as_secs_f32()));
362 }
363 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
364 Err(e) => break Err(format!("could not be waited on ({e})")),
365 }
366 };
367 let status = match status {
368 Ok(status) => status,
369 Err(what) => return failed_closed(event, &what),
372 };
373 let out = reader
374 .and_then(|r| r.join().ok())
375 .map(|b| String::from_utf8_lossy(&b).into_owned())
376 .unwrap_or_default();
377 if status.success() || read_answer(&out).decision.is_some() {
378 return out;
379 }
380 let what = match status.code() {
381 Some(code) => format!("exited with status {code}"),
382 None => "was killed by a signal".to_string(),
383 };
384 failed_closed(event, &what)
385}
386
387pub fn relay(runner: &str, input: &str, limit: usize, event: Option<&str>) -> anyhow::Result<i32> {
392 let normalized = normalize_input(runner, input);
393 let raw = raw_event(input);
394 let text = match std::env::current_exe() {
395 Ok(exe) => {
396 let mut cmd = std::process::Command::new(exe);
397 cmd.arg("hook").arg("--limit").arg(limit.to_string());
398 if let Some(e) = event {
399 cmd.arg("--event").arg(e);
400 }
401 run_inner(cmd, &normalized, &raw, INNER_TIMEOUT)
402 }
403 Err(e) => failed_closed(&raw, &format!("could not find its own binary ({e})")),
404 };
405 let (out, err, code) = answer(runner, &raw, &text);
406 print!("{out}");
407 eprint!("{err}");
408 Ok(code)
409}
410
411#[must_use]
413pub fn runner_command(base: &str, runner: &str) -> String {
414 format!("{base} --runner {runner}")
415}
416
417fn ours(v: &Value, runner: &str) -> bool {
418 v.to_string().contains(&format!("--runner {runner}"))
419}
420
421fn ensure_in(obj: &mut Map<String, Value>, key: &str, item: Value, runner: &str) -> bool {
424 let list = obj.entry(key.to_string()).or_insert_with(|| json!([]));
425 let Some(list) = list.as_array_mut() else {
426 return false;
427 };
428 if list.iter().any(|g| ours(g, runner)) {
429 return false;
430 }
431 list.push(item);
432 true
433}
434
435fn group(matcher: Option<&str>, command: &str, timeout: u64, runner: &str) -> Value {
436 let mut hook = json!({ "type": "command", "command": command, "timeout": timeout });
437 if runner == "gemini" {
438 hook["name"] = json!("ljos");
439 }
440 let mut g = json!({ "hooks": [hook] });
441 if let Some(m) = matcher {
442 g["matcher"] = json!(m);
443 }
444 g
445}
446
447fn hooks_of(obj: &mut Map<String, Value>) -> Result<&mut Map<String, Value>, String> {
448 obj.entry("hooks")
449 .or_insert_with(|| json!({}))
450 .as_object_mut()
451 .ok_or_else(|| "hooks is not an object".to_string())
452}
453
454fn spec(runner: &str, root: &mut Value, command: &str) -> Result<bool, String> {
458 let obj = root
459 .as_object_mut()
460 .ok_or_else(|| "not a JSON object".to_string())?;
461 let changed = match runner {
462 "copilot" => {
463 let want = json!({
464 "version": 1,
465 "hooks": { "PreToolUse": [{
466 "type": "command", "matcher": "Bash", "bash": command, "timeoutSec": 20
467 }]}
468 });
469 let same = Value::Object(obj.clone()) == want;
470 *obj = want.as_object().cloned().unwrap_or_default();
471 !same
472 }
473 "kiro" => {
474 let want = json!({
475 "version": "v1",
476 "hooks": [{
477 "name": "ljos guard",
478 "description": "The seat's guard on shell commands; written by ljos onboard.",
479 "trigger": "PreToolUse",
480 "matcher": "shell",
481 "action": { "type": "command", "command": command },
482 "timeout": 20
483 }]
484 });
485 let same = Value::Object(obj.clone()) == want;
486 *obj = want.as_object().cloned().unwrap_or_default();
487 !same
488 }
489 "gemini" => {
490 let hooks = hooks_of(obj)?;
491 let a = ensure_in(
492 hooks,
493 "BeforeTool",
494 group(Some("run_shell_command"), command, 20_000, runner),
495 runner,
496 );
497 let b = ensure_in(
498 hooks,
499 "BeforeAgent",
500 group(None, command, 15_000, runner),
501 runner,
502 );
503 a || b
504 }
505 "qwen" => {
506 let hooks = hooks_of(obj)?;
507 let a = ensure_in(
508 hooks,
509 "PreToolUse",
510 group(Some("^run_shell_command$"), command, 20, runner),
511 runner,
512 );
513 let b = ensure_in(
514 hooks,
515 "UserPromptSubmit",
516 group(None, command, 15, runner),
517 runner,
518 );
519 a || b
520 }
521 "factory" => {
522 let a = ensure_in(
524 obj,
525 "PreToolUse",
526 group(Some("Execute"), command, 20, runner),
527 runner,
528 );
529 let b = ensure_in(
530 obj,
531 "UserPromptSubmit",
532 group(None, command, 15, runner),
533 runner,
534 );
535 a || b
536 }
537 "windsurf" => {
538 let hooks = hooks_of(obj)?;
539 ensure_in(
540 hooks,
541 "pre_run_command",
542 json!({ "command": command, "show_output": false }),
543 runner,
544 )
545 }
546 "crush" => {
547 let hooks = hooks_of(obj)?;
548 ensure_in(
549 hooks,
550 "PreToolUse",
551 json!({ "name": "ljos", "matcher": "^bash$", "command": command, "timeout": 20 }),
552 runner,
553 )
554 }
555 other => return Err(format!("no hook shape for {other}")),
556 };
557 Ok(changed)
558}
559
560#[must_use]
562pub fn cline_script(command: &str) -> String {
563 format!("#!/bin/sh\n# Written by ljos onboard: the seat's hook for Cline.\nexec {command}\n")
564}
565
566#[must_use]
568pub fn hook_step(runner: &str, file: &Path, base_command: &str, dry: bool) -> Step {
569 let what = "hook".to_string();
570 let command = runner_command(base_command, runner);
571 if runner == "cline" {
572 return cline_step(file, &command, dry);
573 }
574 let mut root: Value = match std::fs::read_to_string(file) {
575 Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
576 Ok(v) => v,
577 Err(e) => {
578 return Step {
579 what,
580 detail: format!("{}: not JSON: {e}", file.display()),
581 ok: false,
582 }
583 }
584 },
585 _ => json!({}),
586 };
587 let changed = match spec(runner, &mut root, &command) {
588 Ok(c) => c,
589 Err(e) => {
590 return Step {
591 what,
592 detail: format!("{}: {e}", file.display()),
593 ok: false,
594 }
595 }
596 };
597 if !changed {
598 return Step {
599 what,
600 detail: format!("{} carries the seat's {runner} hook", file.display()),
601 ok: true,
602 };
603 }
604 if dry {
605 return Step {
606 what,
607 detail: format!("would write the seat's {runner} hook to {}", file.display()),
608 ok: true,
609 };
610 }
611 let written = file
612 .parent()
613 .map_or(Ok(()), std::fs::create_dir_all)
614 .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
615 .and_then(|text| std::fs::write(file, text + "\n"));
616 match written {
617 Ok(()) => Step {
618 what,
619 detail: format!("wrote the seat's {runner} hook to {}", file.display()),
620 ok: true,
621 },
622 Err(e) => Step {
623 what,
624 detail: format!("{}: {e}", file.display()),
625 ok: false,
626 },
627 }
628}
629
630fn cline_step(dir: &Path, command: &str, dry: bool) -> Step {
633 let what = "hook".to_string();
634 let text = cline_script(command);
635 let mut todo = Vec::new();
636 for event in ["PreToolUse", "UserPromptSubmit"] {
637 let path = dir.join(event);
638 match std::fs::read_to_string(&path) {
639 Ok(t) if t == text => {}
640 Ok(t) if !t.contains("ljos onboard") => {
641 return Step {
642 what,
643 detail: format!(
644 "{} is someone else's hook; add `{command}` to it",
645 path.display()
646 ),
647 ok: false,
648 }
649 }
650 _ => todo.push(path),
651 }
652 }
653 if todo.is_empty() {
654 return Step {
655 what,
656 detail: format!("{} carries the seat's cline hooks", dir.display()),
657 ok: true,
658 };
659 }
660 if dry {
661 return Step {
662 what,
663 detail: format!("would write the seat's cline hooks to {}", dir.display()),
664 ok: true,
665 };
666 }
667 for path in &todo {
668 let written = std::fs::create_dir_all(dir)
669 .and_then(|()| std::fs::write(path, &text))
670 .and_then(|()| set_executable(path));
671 if let Err(e) = written {
672 return Step {
673 what,
674 detail: format!("{}: {e}", path.display()),
675 ok: false,
676 };
677 }
678 }
679 Step {
680 what,
681 detail: format!("wrote the seat's cline hooks to {}", dir.display()),
682 ok: true,
683 }
684}
685
686#[cfg(unix)]
687fn set_executable(path: &Path) -> std::io::Result<()> {
688 use std::os::unix::fs::PermissionsExt;
689 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
690}
691
692#[cfg(not(unix))]
693fn set_executable(_path: &Path) -> std::io::Result<()> {
694 Ok(())
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 const DENY: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no force push (seat rule `ljos-policyd`)"}}"#;
702 const ASK: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"a push"}}"#;
703 const NOTE: &str = r#"{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"lesson: run the tests"}}"#;
704
705 fn payload(runner: &str, input: Value) -> Value {
706 serde_json::from_str(&normalize_input(runner, &input.to_string())).unwrap()
707 }
708
709 #[test]
710 fn each_runner_payload_reads_as_the_command_it_runs() {
711 let cases = [
712 (
713 "windsurf",
714 json!({"agent_action_name": "pre_run_command", "trajectory_id": "t1",
715 "tool_info": {"command_line": "git push -f", "cwd": "/tmp"}}),
716 ),
717 (
718 "cline",
719 json!({"hookName": "PreToolUse", "taskId": "t1", "workspaceRoots": ["/tmp"],
720 "preToolUse": {"toolName": "execute_command", "parameters": {"command": "git push -f"}}}),
721 ),
722 (
723 "cline",
724 json!({"hookName": "tool_call", "taskId": "t1", "workspaceRoots": ["/tmp"],
725 "tool_call": {"id": "c", "name": "run_commands", "input": {"commands": ["git push -f"]}}}),
726 ),
727 (
728 "copilot",
729 json!({"sessionId": "t1", "timestamp": 1, "cwd": "/tmp", "toolName": "bash",
730 "toolArgs": "{\"command\": \"git push -f\"}"}),
731 ),
732 (
733 "copilot",
734 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
735 "tool_name": "Bash", "tool_input": {"command": "git push -f"}}),
736 ),
737 (
738 "gemini",
739 json!({"hook_event_name": "BeforeTool", "session_id": "t1", "cwd": "/tmp",
740 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
741 ),
742 (
743 "crush",
744 json!({"event": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
745 "tool_name": "bash", "tool_input": {"command": "git push -f"}}),
746 ),
747 (
748 "kiro",
749 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
750 "tool_name": "shell", "tool_input": {"command": "git push -f"}}),
751 ),
752 (
753 "factory",
754 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
755 "tool_name": "Execute", "tool_input": {"command": "git push -f"}}),
756 ),
757 (
758 "qwen",
759 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
760 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
761 ),
762 ];
763 for (runner, input) in cases {
764 let v = payload(runner, input.clone());
765 let call = crate::hook_call(&v.to_string());
766 assert_eq!(call.event, "PreToolUse", "{runner}: {v}");
767 assert_eq!(call.cue, "git push -f", "{runner}: {v}");
768 assert_eq!(call.session.as_deref(), Some("t1"), "{runner}: {v}");
769 assert_eq!(
770 crate::hook_directory(&v.to_string()).unwrap(),
771 std::fs::canonicalize("/tmp").unwrap(),
772 "{runner}"
773 );
774 }
775 let prompt = payload(
776 "gemini",
777 json!({"hook_event_name": "BeforeAgent", "session_id": "t", "prompt": "fix the build"}),
778 );
779 let call = crate::hook_call(&prompt.to_string());
780 assert_eq!(
781 (call.event.as_str(), call.cue.as_str()),
782 ("UserPromptSubmit", "fix the build")
783 );
784 let prompt = payload(
785 "cline",
786 json!({"hookName": "UserPromptSubmit", "taskId": "t", "userPromptSubmit": {"prompt": "fix it"}}),
787 );
788 assert_eq!(
789 crate::hook_call(&prompt.to_string()).event,
790 "UserPromptSubmit"
791 );
792 }
793
794 #[test]
795 fn a_deny_reaches_each_runner_in_its_own_contract() {
796 let (out, _, code) = answer("copilot", "PreToolUse", DENY);
797 let v: Value = serde_json::from_str(&out).unwrap();
798 assert_eq!((v["permissionDecision"].as_str(), code), (Some("deny"), 0));
799 assert!(v["permissionDecisionReason"]
800 .as_str()
801 .unwrap()
802 .contains("force push"));
803
804 let (out, _, code) = answer("gemini", "BeforeTool", DENY);
805 let v: Value = serde_json::from_str(&out).unwrap();
806 assert_eq!((v["decision"].as_str(), code), (Some("deny"), 0));
807 assert!(v["reason"].as_str().unwrap().contains("force push"));
808
809 for runner in ["windsurf", "kiro"] {
810 let (out, err, code) = answer(runner, "PreToolUse", DENY);
811 assert_eq!((out.as_str(), code), ("", 2), "{runner}");
812 assert!(err.contains("force push"), "{runner}");
813 }
814
815 let (out, _, _) = answer("cline", "PreToolUse", DENY);
816 let v: Value = serde_json::from_str(&out).unwrap();
817 assert_eq!(v["cancel"], json!(true));
818 assert!(v["errorMessage"].as_str().unwrap().contains("force push"));
819
820 let (out, _, _) = answer("crush", "PreToolUse", DENY);
821 let v: Value = serde_json::from_str(&out).unwrap();
822 assert_eq!(v["decision"], json!("deny"));
823
824 for runner in ["factory", "qwen"] {
825 let (out, _, code) = answer(runner, "PreToolUse", DENY);
826 assert_eq!(
827 (out.as_str(), code),
828 (DENY, 0),
829 "{runner} takes Claude Code's shape"
830 );
831 }
832 }
833
834 #[test]
835 fn an_ask_is_a_deny_where_the_runner_cannot_ask() {
836 let (out, _, _) = answer("copilot", "PreToolUse", ASK);
837 assert!(out.contains("\"ask\""));
838 for runner in ["gemini", "cline", "crush"] {
839 let (out, _, _) = answer(runner, "PreToolUse", ASK);
840 assert!(!out.contains("\"ask\""), "{runner}: {out}");
841 assert!(out.contains("cannot ask"), "{runner}: {out}");
842 }
843 let (_, err, code) = answer("windsurf", "PreToolUse", ASK);
844 assert_eq!(code, 2);
845 assert!(err.contains("ask the person"));
846 }
847
848 #[test]
849 fn an_allow_says_nothing_and_a_note_reaches_runners_that_take_one() {
850 for runner in ["copilot", "windsurf", "kiro", "crush"] {
851 assert_eq!(
852 answer(runner, "PreToolUse", ""),
853 (String::new(), String::new(), 0)
854 );
855 }
856 let (out, _, code) = answer("cline", "PreToolUse", "");
857 assert_eq!(
858 serde_json::from_str::<Value>(&out).unwrap(),
859 json!({"cancel": false})
860 );
861 assert_eq!(code, 0);
862 let (out, _, _) = answer("gemini", "BeforeAgent", NOTE);
863 let v: Value = serde_json::from_str(&out).unwrap();
864 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "BeforeAgent");
865 assert_eq!(
866 v["hookSpecificOutput"]["additionalContext"],
867 "lesson: run the tests"
868 );
869 let (out, _, _) = answer("cline", "UserPromptSubmit", NOTE);
870 assert!(out.contains("contextModification"));
871 }
872
873 #[cfg(unix)]
874 fn sh(script: &str) -> std::process::Command {
875 let mut cmd = std::process::Command::new("sh");
876 cmd.arg("-c").arg(script);
877 cmd
878 }
879
880 #[cfg(unix)]
881 fn inner(script: &str, timeout_ms: u64) -> String {
882 run_inner(
883 sh(script),
884 r#"{"tool_name":"Bash"}"#,
885 "PreToolUse",
886 std::time::Duration::from_millis(timeout_ms),
887 )
888 }
889
890 #[cfg(unix)]
891 #[test]
892 fn an_inner_hook_that_fails_is_a_deny_in_every_runner() {
893 let cases = [
894 ("cat >/dev/null; exit 3", "exited with status 3"),
895 ("cat >/dev/null; kill -SEGV $$", "killed by a signal"),
896 ("sleep 5", "no answer within"),
897 ];
898 for (script, why) in cases {
899 let out = inner(script, 300);
900 assert_eq!(
901 read_answer(&out).decision.as_deref(),
902 Some("deny"),
903 "{script}"
904 );
905 assert!(out.contains(why), "{script}: {out}");
906 for runner in RUNNERS {
907 let (stdout, stderr, code) = answer(runner, "PreToolUse", &out);
908 let said = format!("{stdout}{stderr}");
909 assert!(
910 said.contains("refused unchecked")
911 && (code == 2 || said.contains("deny") || said.contains("\"cancel\":true")),
912 "{runner} on {script}: {said} exit {code}"
913 );
914 }
915 }
916 let missing = run_inner(
917 std::process::Command::new("/nonexistent/ljos"),
918 "{}",
919 "PreToolUse",
920 std::time::Duration::from_secs(1),
921 );
922 assert!(missing.contains("did not start"), "{missing}");
923 }
924
925 #[cfg(unix)]
926 #[test]
927 fn an_inner_hook_that_answers_keeps_its_answer() {
928 assert_eq!(inner("cat >/dev/null", 2_000), "", "a clean allow");
929 assert_eq!(
930 inner(&format!("cat >/dev/null; printf '%s' '{DENY}'"), 2_000),
931 DENY
932 );
933 assert_eq!(
934 inner(
935 &format!("cat >/dev/null; printf '%s' '{DENY}'; exit 1"),
936 2_000
937 ),
938 DENY,
939 "a deny printed before a non-zero exit stands"
940 );
941 let note = inner(
942 &format!("cat >/dev/null; printf '%s' '{NOTE}'; exit 1"),
943 2_000,
944 );
945 assert_eq!(read_answer(¬e).decision.as_deref(), Some("deny"));
946 }
947
948 #[test]
949 fn hook_files_are_written_in_each_runner_shape_and_never_twice() {
950 let tmp = tempfile::tempdir().unwrap();
951 let dir = tmp.path().to_path_buf();
952 let base = "/opt/bin/ljos hook";
953 for runner in RUNNERS {
954 let file = if *runner == "cline" {
955 dir.join("cline-hooks")
956 } else {
957 dir.join(format!("{runner}.json"))
958 };
959 if *runner == "gemini" {
960 std::fs::write(&file, r#"{"mcpServers":{"x":{}},"hooks":{"BeforeTool":[{"matcher":"x","hooks":[]}]}}"#).unwrap();
962 }
963 let first = hook_step(runner, &file, base, false);
964 assert!(first.ok, "{runner}: {}", first.detail);
965 assert!(first.detail.contains("wrote"), "{runner}: {}", first.detail);
966 let again = hook_step(runner, &file, base, false);
967 assert!(
968 again.ok && again.detail.contains("carries"),
969 "{runner}: {}",
970 again.detail
971 );
972 let command = format!("{base} --runner {runner}");
973 if *runner == "cline" {
974 for event in ["PreToolUse", "UserPromptSubmit"] {
975 let text = std::fs::read_to_string(file.join(event)).unwrap();
976 assert!(text.contains(&command));
977 }
978 continue;
979 }
980 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
981 let found = |p: &str| v.pointer(p).cloned().unwrap_or(Value::Null);
982 match *runner {
983 "copilot" => {
984 assert_eq!(found("/version"), json!(1));
985 assert_eq!(found("/hooks/PreToolUse/0/bash"), json!(command));
986 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("Bash"));
987 }
988 "gemini" => {
989 assert_eq!(found("/mcpServers/x"), json!({}));
990 assert_eq!(found("/hooks/BeforeTool/0/matcher"), json!("x"));
991 assert_eq!(
992 found("/hooks/BeforeTool/1/matcher"),
993 json!("run_shell_command")
994 );
995 assert_eq!(
996 found("/hooks/BeforeTool/1/hooks/0/timeout"),
997 json!(20_000),
998 "milliseconds"
999 );
1000 assert_eq!(
1001 found("/hooks/BeforeAgent/0/hooks/0/command"),
1002 json!(command)
1003 );
1004 }
1005 "qwen" => {
1006 assert_eq!(
1007 found("/hooks/PreToolUse/0/matcher"),
1008 json!("^run_shell_command$")
1009 );
1010 assert_eq!(
1011 found("/hooks/PreToolUse/0/hooks/0/timeout"),
1012 json!(20),
1013 "seconds"
1014 );
1015 }
1016 "factory" => {
1017 assert_eq!(found("/PreToolUse/0/matcher"), json!("Execute"));
1018 assert_eq!(found("/PreToolUse/0/hooks/0/command"), json!(command));
1019 }
1020 "windsurf" => {
1021 assert_eq!(found("/hooks/pre_run_command/0/command"), json!(command));
1022 }
1023 "kiro" => {
1024 assert_eq!(found("/version"), json!("v1"));
1025 assert_eq!(found("/hooks/0/trigger"), json!("PreToolUse"));
1026 assert_eq!(found("/hooks/0/action/command"), json!(command));
1027 }
1028 "crush" => {
1029 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("^bash$"));
1030 assert_eq!(found("/hooks/PreToolUse/0/command"), json!(command));
1031 }
1032 _ => unreachable!(),
1033 }
1034 }
1035 let foreign = dir.join("foreign-cline");
1036 std::fs::create_dir_all(&foreign).unwrap();
1037 std::fs::write(foreign.join("PreToolUse"), "#!/bin/sh\necho mine\n").unwrap();
1038 let step = hook_step("cline", &foreign, base, false);
1039 assert!(
1040 !step.ok && step.detail.contains("someone else's"),
1041 "{}",
1042 step.detail
1043 );
1044 }
1045}