1use serde_json::{json, Map, Value};
16use std::path::Path;
17
18use crate::Step;
19
20pub const RUNNERS: &[&str] = &[
22 "copilot",
23 "gemini",
24 "windsurf",
25 "factory",
26 "kiro",
27 "cline",
28 "qwen",
29 "crush",
30 "auggie",
31 "goose",
32 "amp",
33 "openhands",
34];
35
36fn needs_script(runner: &str) -> bool {
40 matches!(runner, "auggie" | "amp")
41}
42
43#[must_use]
45pub fn script_path(runner: &str, file: &Path) -> std::path::PathBuf {
46 file.parent()
47 .unwrap_or_else(|| Path::new("."))
48 .join(format!("ljos-guard-{runner}.sh"))
49}
50
51#[must_use]
53pub fn is_runner(name: &str) -> bool {
54 RUNNERS.contains(&name)
55}
56
57fn denies_by_exit(runner: &str) -> bool {
60 matches!(
61 runner,
62 "windsurf" | "factory" | "kiro" | "qwen" | "crush" | "goose" | "amp" | "openhands"
63 )
64}
65
66fn reason_stderr(reason: &str) -> String {
67 if reason.trim().is_empty() {
68 "ljos denied this command.\n".to_string()
69 } else {
70 format!("{reason}\n")
71 }
72}
73
74fn gemini_event(raw: &str) -> &str {
76 match raw {
77 "BeforeTool" => "PreToolUse",
78 "AfterTool" => "PostToolUse",
79 "BeforeAgent" => "UserPromptSubmit",
80 other => other,
81 }
82}
83
84fn command_of(args: &Value) -> Option<String> {
87 if let Some(c) = args["command"].as_str() {
88 return Some(c.to_string());
89 }
90 args["commands"].as_array().map(|list| {
91 list.iter()
92 .filter_map(Value::as_str)
93 .collect::<Vec<_>>()
94 .join("\n")
95 })
96}
97
98#[must_use]
103pub fn normalize_input(runner: &str, input: &str) -> String {
104 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
105 return input.to_string();
106 };
107 let mut out = Map::new();
108 let put = |out: &mut Map<String, Value>, k: &str, val: Option<&str>| {
109 if let Some(s) = val.filter(|s| !s.is_empty()) {
110 out.insert(k.into(), Value::String(s.to_string()));
111 }
112 };
113 match runner {
114 "windsurf" => {
115 let action = v["agent_action_name"].as_str().unwrap_or("");
116 let info = &v["tool_info"];
117 let event = match action {
118 "pre_run_command" => "PreToolUse",
119 "post_run_command" => "PostToolUse",
120 "pre_user_prompt" => "UserPromptSubmit",
121 other => other,
122 };
123 out.insert("hook_event_name".into(), json!(event));
124 put(&mut out, "session_id", v["trajectory_id"].as_str());
125 put(&mut out, "cwd", info["cwd"].as_str());
126 if let Some(line) = info["command_line"].as_str() {
127 out.insert("tool_name".into(), json!("Bash"));
128 out.insert("tool_input".into(), json!({ "command": line }));
129 }
130 put(&mut out, "prompt", info["user_prompt"].as_str());
131 }
132 "cline" => {
133 put(&mut out, "session_id", v["taskId"].as_str());
134 put(&mut out, "cwd", v["workspaceRoots"][0].as_str());
135 if let Some(pre) = v.get("preToolUse").filter(|p| p.is_object()) {
136 let tool = pre["toolName"].as_str().or_else(|| pre["tool"].as_str());
137 out.insert("hook_event_name".into(), json!("PreToolUse"));
138 put(&mut out, "tool_name", tool);
139 let args = &pre["parameters"];
140 match command_of(args) {
141 Some(c) => {
142 out.insert("tool_input".into(), json!({ "command": c }));
143 }
144 None => {
145 out.insert("tool_input".into(), args.clone());
146 }
147 }
148 } else if let Some(call) = v.get("tool_call").filter(|p| p.is_object()) {
149 out.insert("hook_event_name".into(), json!("PreToolUse"));
150 put(&mut out, "tool_name", call["name"].as_str());
151 let args = &call["input"];
152 match command_of(args) {
153 Some(c) => {
154 out.insert("tool_input".into(), json!({ "command": c }));
155 }
156 None => {
157 out.insert("tool_input".into(), args.clone());
158 }
159 }
160 } else if let Some(p) = v["userPromptSubmit"]["prompt"].as_str() {
161 out.insert("hook_event_name".into(), json!("UserPromptSubmit"));
162 out.insert("prompt".into(), json!(p));
163 } else {
164 return input.to_string();
165 }
166 }
167 "copilot" if v.get("toolName").is_some() || v.get("sessionId").is_some() => {
168 let event = if v.get("toolName").is_some() {
170 "PreToolUse"
171 } else if v.get("prompt").is_some() {
172 "UserPromptSubmit"
173 } else {
174 "SessionStart"
175 };
176 out.insert("hook_event_name".into(), json!(event));
177 put(&mut out, "session_id", v["sessionId"].as_str());
178 put(&mut out, "cwd", v["cwd"].as_str());
179 put(&mut out, "tool_name", v["toolName"].as_str());
180 put(&mut out, "prompt", v["prompt"].as_str());
181 let args = match &v["toolArgs"] {
182 Value::String(s) => serde_json::from_str(s).unwrap_or(json!({ "command": s })),
183 other => other.clone(),
184 };
185 if !args.is_null() {
186 out.insert("tool_input".into(), args);
187 }
188 }
189 "gemini" => {
190 let mut v = v;
191 if let Some(e) = v["hook_event_name"].as_str() {
192 let seat = gemini_event(e).to_string();
193 v["hook_event_name"] = Value::String(seat);
194 }
195 return v.to_string();
196 }
197 "goose" => {
198 let event = v["event"].as_str().unwrap_or("PreToolUse");
199 out.insert("hook_event_name".into(), json!(event));
200 put(&mut out, "session_id", v["session_id"].as_str());
201 put(&mut out, "cwd", v["working_dir"].as_str());
202 put(&mut out, "tool_name", v["tool_name"].as_str());
203 put(&mut out, "prompt", v["message"].as_str());
204 if !v["tool_input"].is_null() {
205 out.insert("tool_input".into(), v["tool_input"].clone());
206 }
207 }
208 "openhands" => {
209 let event = v["event_type"].as_str().unwrap_or("PreToolUse");
210 out.insert("hook_event_name".into(), json!(event));
211 put(&mut out, "session_id", v["session_id"].as_str());
212 put(&mut out, "cwd", v["working_dir"].as_str());
213 put(&mut out, "prompt", v["message"].as_str());
214 let tool = v["tool_name"]
215 .as_str()
216 .map(|t| if t == "terminal" { "Bash" } else { t });
217 put(&mut out, "tool_name", tool);
218 if !v["tool_input"].is_null() {
219 out.insert("tool_input".into(), v["tool_input"].clone());
220 }
221 }
222 "auggie" => {
223 let mut v = v;
224 if v["tool_name"].as_str() == Some("launch-process") {
225 v["tool_name"] = json!("Bash");
226 }
227 return v.to_string();
228 }
229 "amp" => {
230 let tool = std::env::var("AGENT_TOOL_NAME").unwrap_or_else(|_| "Bash".into());
233 out.insert("hook_event_name".into(), json!("PreToolUse"));
234 out.insert("tool_name".into(), json!(tool));
235 if let Ok(t) = std::env::var("AMP_THREAD_ID") {
236 put(&mut out, "session_id", Some(&t));
237 }
238 put(&mut out, "cwd", v["cwd"].as_str());
239 match v["cmd"]
240 .as_str()
241 .map(str::to_string)
242 .or_else(|| command_of(&v))
243 {
244 Some(c) => {
245 out.insert("tool_input".into(), json!({ "command": c }));
246 }
247 None => {
248 out.insert("tool_input".into(), v.clone());
249 }
250 }
251 }
252 "crush" if v.get("hook_event_name").is_none() => {
253 let mut v = v;
254 let event = v["event"].as_str().unwrap_or("PreToolUse").to_string();
255 v["hook_event_name"] = Value::String(event);
256 return v.to_string();
257 }
258 _ => return input.to_string(),
259 }
260 Value::Object(out).to_string()
261}
262
263fn read_answer(out: &str) -> String {
268 let trimmed = out.trim();
269 let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
270 return trimmed.to_string();
271 };
272 let specific = &v["hookSpecificOutput"];
273 let reason = specific["permissionDecisionReason"]
274 .as_str()
275 .or_else(|| v["reason"].as_str())
276 .map(str::trim)
277 .filter(|r| !r.is_empty())
278 .map(|r| {
279 if r.starts_with("warning:") {
280 r.to_string()
281 } else {
282 format!("warning: {r}")
283 }
284 });
285 let context = specific["additionalContext"]
286 .as_str()
287 .or_else(|| v["context"].as_str())
288 .filter(|c| !c.trim().is_empty())
289 .map(str::to_string);
290 reason
291 .into_iter()
292 .chain(context)
293 .collect::<Vec<_>>()
294 .join("\n")
295}
296
297fn denial(event: &str, out: &str) -> Option<(String, String)> {
301 if !gate_event(event) {
302 return None;
303 }
304 let trimmed = out.trim();
305 let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
306 let reason = trimmed.strip_prefix("deny:")?;
308 return Some((reason.trim().to_string(), String::new()));
309 };
310 let specific = &v["hookSpecificOutput"];
311 let decision = specific["permissionDecision"]
312 .as_str()
313 .or_else(|| v["decision"].as_str());
314 if !matches!(decision, Some("deny" | "block")) {
315 return None;
316 }
317 let field = |inner: &str, top: &str| {
318 specific[inner]
319 .as_str()
320 .or_else(|| v[top].as_str())
321 .unwrap_or("")
322 .to_string()
323 };
324 Some((
325 field("permissionDecisionReason", "reason"),
326 field("additionalContext", "context"),
327 ))
328}
329
330fn denied(
333 runner: &str,
334 event: &str,
335 out: &str,
336 reason: &str,
337 context: &str,
338) -> (String, String, i32) {
339 let line = |v: Value| v.to_string() + "\n";
340 let mut rendered = match runner {
341 "factory" | "qwen" => (out.to_string(), String::new(), 0),
342 "copilot" => (
343 line(json!({
344 "permissionDecision": "deny",
345 "permissionDecisionReason": reason,
346 })),
347 String::new(),
348 0,
349 ),
350 "gemini" => {
351 let mut v = json!({ "decision": "deny", "reason": reason });
352 if !context.is_empty() {
353 v["hookSpecificOutput"] =
354 json!({ "hookEventName": event, "additionalContext": context });
355 }
356 (line(v), String::new(), 0)
357 }
358 "openhands" => (
359 line(json!({ "decision": "deny", "reason": reason })),
360 reason_stderr(reason),
361 2,
362 ),
363 "auggie" => (
364 line(json!({ "hookSpecificOutput": {
365 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
366 "permissionDecision": "deny",
367 "permissionDecisionReason": reason,
368 }})),
369 String::new(),
370 0,
371 ),
372 "cline" => (
373 line(json!({ "cancel": true, "errorMessage": reason })),
374 String::new(),
375 0,
376 ),
377 "crush" => (
378 line(json!({ "decision": "deny", "reason": reason })),
379 String::new(),
380 0,
381 ),
382 _ => (String::new(), reason_stderr(reason), 2),
384 };
385 if denies_by_exit(runner) {
386 rendered.2 = 2;
387 if rendered.1.trim().is_empty() {
388 rendered.1 = reason_stderr(reason);
389 }
390 }
391 rendered
392}
393
394#[must_use]
400pub fn answer(runner: &str, event: &str, out: &str) -> (String, String, i32) {
401 if let Some((reason, context)) = denial(event, out) {
402 return denied(runner, event, out, &reason, &context);
403 }
404 let context = read_answer(out);
405 let line = |v: Value| v.to_string() + "\n";
406 let named = if event.is_empty() {
407 "PreToolUse"
408 } else {
409 event
410 };
411 let printed = match runner {
412 "gemini" if context.is_empty() => line(json!({})),
413 "cline" if context.is_empty() => line(json!({ "cancel": false })),
414 _ if context.is_empty() => String::new(),
415 "gemini" | "factory" | "qwen" | "auggie" => line(json!({ "hookSpecificOutput": {
416 "hookEventName": named,
417 "additionalContext": context,
418 }})),
419 "openhands" => line(json!({ "additionalContext": context })),
420 "kiro" => format!("{context}\n"),
421 "cline" => line(json!({ "cancel": false, "contextModification": context })),
422 "crush" => line(json!({ "context": context })),
423 _ => String::new(),
424 };
425 let warned = if matches!(runner, "copilot" | "windsurf" | "goose" | "amp") {
428 context
429 .lines()
430 .filter(|l| l.starts_with("warning:"))
431 .map(|l| format!("{l}\n"))
432 .collect()
433 } else {
434 String::new()
435 };
436 (printed, warned, 0)
437}
438
439#[must_use]
441pub fn raw_event(input: &str) -> String {
442 serde_json::from_str::<Value>(input.trim())
443 .ok()
444 .and_then(|v| {
445 [
446 "hook_event_name",
447 "agent_action_name",
448 "hookName",
449 "event",
450 "event_type",
451 ]
452 .iter()
453 .find_map(|k| v[*k].as_str().map(str::to_string))
454 })
455 .unwrap_or_default()
456}
457
458pub const INNER_TIMEOUT: std::time::Duration = crate::GUARD_TIMEOUT;
463
464fn seat_event(event: &str) -> &str {
469 match event {
470 ""
471 | "BeforeTool"
472 | "pre_run_command"
473 | "tool_call"
474 | "pre_tool_use"
475 | "preToolUse"
476 | "beforeShellExecution" => "PreToolUse",
477 "BeforeAgent" | "pre_user_prompt" | "beforeSubmitPrompt" | "pre_llm_call" => {
478 "UserPromptSubmit"
479 }
480 "AfterTool" | "post_run_command" | "post_tool_use" | "postToolUse" => "PostToolUse",
481 other => other,
482 }
483}
484
485fn gate_event(event: &str) -> bool {
486 crate::is_gate_event(seat_event(event))
487}
488
489#[must_use]
494pub fn relay_timeout(event: &str) -> std::time::Duration {
495 if gate_event(event) {
496 crate::guard_timeout()
497 } else {
498 std::time::Duration::from_millis(crate::HOOK_DEADLINE_MS.saturating_add(1_000))
499 }
500}
501
502fn fault_output(event: &str, what: &str) -> String {
507 json!({
508 "hookSpecificOutput": {
509 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
510 "additionalContext": crate::unhealthy_note(what),
511 }
512 })
513 .to_string()
514}
515
516#[must_use]
519pub fn relay_fault_answer(runner: &str, event: &str, what: &str) -> (String, String) {
520 let what: String = what
521 .chars()
522 .map(|c| if c.is_control() { ' ' } else { c })
523 .take(240)
524 .collect();
525 let (out, err, _) = answer(runner, event, &fault_output(event, &what));
526 (out, err)
527}
528
529#[must_use]
534pub fn run_inner(
535 mut cmd: std::process::Command,
536 stdin: &str,
537 event: &str,
538 timeout: std::time::Duration,
539) -> String {
540 use std::io::{Read, Write};
541 let spawned = cmd
542 .stdin(std::process::Stdio::piped())
543 .stdout(std::process::Stdio::piped())
544 .spawn();
545 let mut child = match spawned {
546 Ok(child) => child,
547 Err(e) => return fault_output(event, &format!("did not start ({e})")),
548 };
549 let input = stdin.to_string();
552 if let Some(mut pipe) = child.stdin.take() {
555 std::thread::spawn(move || {
556 let _ = pipe.write_all(input.as_bytes());
557 });
558 }
559 let reader = child.stdout.take().map(|mut pipe| {
560 std::thread::spawn(move || {
561 let mut buf = Vec::new();
562 let _ = pipe.read_to_end(&mut buf);
563 buf
564 })
565 });
566 let deadline = std::time::Instant::now() + timeout;
567 let status = loop {
568 match child.try_wait() {
569 Ok(Some(status)) => break Ok(status),
570 Ok(None) if std::time::Instant::now() >= deadline => {
571 let _ = child.kill();
572 let _ = child.wait();
573 break Err(format!("gave no answer within {} s", timeout.as_secs_f32()));
574 }
575 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
576 Err(e) => break Err(format!("could not be waited on ({e})")),
577 }
578 };
579 let status = match status {
580 Ok(status) => status,
581 Err(what) => return fault_output(event, &what),
584 };
585 let out = reader
586 .and_then(|r| r.join().ok())
587 .map(|b| String::from_utf8_lossy(&b).into_owned())
588 .unwrap_or_default();
589 if status.success() {
590 return out;
591 }
592 let what = match status.code() {
593 Some(code) => format!("exited with status {code}"),
594 None => "was killed by a signal".to_string(),
595 };
596 fault_output(event, &what)
597}
598
599pub fn relay(runner: &str, input: &str, limit: usize, event: Option<&str>) -> i32 {
606 let normalized = normalize_input(runner, input);
607 let raw = raw_event(input);
608 let text = match std::env::current_exe() {
609 Ok(exe) => {
610 let mut cmd = std::process::Command::new(exe);
611 cmd.arg("hook").arg("--limit").arg(limit.to_string());
612 cmd.env(crate::rewrite::RELAY_VAR, runner);
613 if let Some(e) = event {
614 cmd.arg("--event").arg(e);
615 }
616 run_inner(cmd, &normalized, &raw, relay_timeout(&raw))
617 }
618 Err(e) => fault_output(&raw, &format!("could not find its own binary ({e})")),
619 };
620 let (mut out, err, code) = answer(runner, &raw, &text);
621 if let Some(line) = first_prompt_start_line(runner, &raw, input) {
622 out = with_start_line(&out, &line);
623 }
624 print!("{out}");
625 eprint!("{err}");
626 code
627}
628
629fn first_prompt_start_line(runner: &str, raw: &str, input: &str) -> Option<String> {
633 if runner != "openhands" || raw != "UserPromptSubmit" {
634 return None;
635 }
636 let v: Value = serde_json::from_str(input.trim()).ok()?;
637 let sid: String = v["session_id"]
638 .as_str()?
639 .chars()
640 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
641 .collect();
642 if sid.is_empty() {
643 return None;
644 }
645 let state = std::env::var_os("XDG_STATE_HOME")
646 .map(std::path::PathBuf::from)
647 .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/state")))?;
648 let mark = state.join("ljos/openhands-started").join(&sid);
649 if mark.exists() {
650 return None;
651 }
652 let _ = mark.parent().map(std::fs::create_dir_all);
653 let _ = std::fs::write(&mark, "");
654 Some(crate::session_start_for(Some(&sid)))
655}
656
657fn with_start_line(out: &str, line: &str) -> String {
659 let mut v: Value = serde_json::from_str(out.trim()).unwrap_or_else(|_| json!({}));
660 let rest = v["additionalContext"].as_str().unwrap_or("").to_string();
661 v["additionalContext"] = json!(if rest.is_empty() {
662 line.to_string()
663 } else {
664 format!("{line}\n\n{rest}")
665 });
666 v.to_string() + "\n"
667}
668
669#[must_use]
671pub fn runner_command(base: &str, runner: &str) -> String {
672 format!("{base} --runner {runner}")
673}
674
675fn ours(v: &Value, runner: &str) -> bool {
676 let t = v.to_string();
677 t.contains(&format!("--runner {runner}")) || t.contains(&format!("ljos-guard-{runner}.sh"))
678}
679
680fn ensure_in(obj: &mut Map<String, Value>, key: &str, item: Value, runner: &str) -> bool {
683 let list = obj.entry(key.to_string()).or_insert_with(|| json!([]));
684 let Some(list) = list.as_array_mut() else {
685 return false;
686 };
687 if list.iter().any(|g| ours(g, runner)) {
688 return false;
689 }
690 list.push(item);
691 true
692}
693
694fn group(matcher: Option<&str>, command: &str, timeout: u64, runner: &str) -> Value {
695 let mut hook = json!({ "type": "command", "command": command, "timeout": timeout });
696 if runner == "gemini" {
697 hook["name"] = json!("ljos");
698 }
699 let mut g = json!({ "hooks": [hook] });
700 if let Some(m) = matcher {
701 g["matcher"] = json!(m);
702 }
703 g
704}
705
706fn hooks_of(obj: &mut Map<String, Value>) -> Result<&mut Map<String, Value>, String> {
707 obj.entry("hooks")
708 .or_insert_with(|| json!({}))
709 .as_object_mut()
710 .ok_or_else(|| "hooks is not an object".to_string())
711}
712
713fn spec(runner: &str, root: &mut Value, command: &str) -> Result<bool, String> {
717 let obj = root
718 .as_object_mut()
719 .ok_or_else(|| "not a JSON object".to_string())?;
720 let changed = match runner {
721 "copilot" => {
722 let want = json!({
723 "version": 1,
724 "hooks": { "PreToolUse": [{
725 "type": "command", "matcher": "Bash", "bash": command, "timeoutSec": 20
726 }]}
727 });
728 let same = Value::Object(obj.clone()) == want;
729 *obj = want.as_object().cloned().unwrap_or_default();
730 !same
731 }
732 "kiro" => {
733 let want = json!({
734 "version": "v1",
735 "hooks": [{
736 "name": "ljos guard",
737 "description": "The seat's guard on shell commands; written by ljos onboard.",
738 "trigger": "PreToolUse",
739 "matcher": "shell",
740 "action": { "type": "command", "command": command },
741 "timeout": 20
742 }]
743 });
744 let same = Value::Object(obj.clone()) == want;
745 *obj = want.as_object().cloned().unwrap_or_default();
746 !same
747 }
748 "gemini" => {
749 let hooks = hooks_of(obj)?;
750 let a = ensure_in(
751 hooks,
752 "BeforeTool",
753 group(Some("run_shell_command"), command, 20_000, runner),
754 runner,
755 );
756 let b = ensure_in(
757 hooks,
758 "BeforeAgent",
759 group(None, command, 15_000, runner),
760 runner,
761 );
762 a || b
763 }
764 "qwen" => {
765 let hooks = hooks_of(obj)?;
766 let a = ensure_in(
767 hooks,
768 "PreToolUse",
769 group(Some("^run_shell_command$"), command, 20, runner),
770 runner,
771 );
772 let b = ensure_in(
773 hooks,
774 "UserPromptSubmit",
775 group(None, command, 15, runner),
776 runner,
777 );
778 a || b
779 }
780 "factory" => {
781 let a = ensure_in(
783 obj,
784 "PreToolUse",
785 group(Some("Execute"), command, 20, runner),
786 runner,
787 );
788 let b = ensure_in(
789 obj,
790 "UserPromptSubmit",
791 group(None, command, 15, runner),
792 runner,
793 );
794 a || b
795 }
796 "windsurf" => {
797 let hooks = hooks_of(obj)?;
798 ensure_in(
799 hooks,
800 "pre_run_command",
801 json!({ "command": command, "show_output": false }),
802 runner,
803 )
804 }
805 "openhands" => {
806 let hooks = hooks_of(obj)?;
807 let a = ensure_in(
808 hooks,
809 "PreToolUse",
810 group(Some("terminal"), command, 20, runner),
811 runner,
812 );
813 let b = ensure_in(
814 hooks,
815 "UserPromptSubmit",
816 group(None, command, 15, runner),
817 runner,
818 );
819 a || b
820 }
821 "auggie" => {
822 let hooks = hooks_of(obj)?;
823 ensure_in(
824 hooks,
825 "PreToolUse",
826 group(Some("launch-process"), command, 20_000, runner),
827 runner,
828 )
829 }
830 "goose" => {
831 let want = json!({ "hooks": {
833 "PreToolUse": [{ "matcher": "shell", "hooks": [{
834 "type": "command", "command": command, "timeout": 20
835 }]}],
836 "UserPromptSubmit": [{ "hooks": [{
837 "type": "command", "command": command, "timeout": 15
838 }]}]
839 }});
840 let same = Value::Object(obj.clone()) == want;
841 *obj = want.as_object().cloned().unwrap_or_default();
842 !same
843 }
844 "amp" => {
845 let list = obj
846 .entry("amp.permissions".to_string())
847 .or_insert_with(|| json!([]));
848 let Some(list) = list.as_array_mut() else {
849 return Err("amp.permissions is not a list".into());
850 };
851 if list.iter().any(|r| ours(r, runner)) {
852 false
853 } else {
854 list.insert(
856 0,
857 json!({ "tool": "Bash", "action": "delegate", "to": command }),
858 );
859 true
860 }
861 }
862 "crush" => {
863 let hooks = hooks_of(obj)?;
864 ensure_in(
865 hooks,
866 "PreToolUse",
867 json!({ "name": "ljos", "matcher": "^bash$", "command": command, "timeout": 20 }),
868 runner,
869 )
870 }
871 other => return Err(format!("no hook shape for {other}")),
872 };
873 Ok(changed)
874}
875
876#[must_use]
878pub fn cline_script(command: &str) -> String {
879 format!("#!/bin/sh\n# Written by ljos onboard: the seat's hook for Cline.\nexec {command}\n")
880}
881
882#[must_use]
884pub fn hook_step(runner: &str, file: &Path, base_command: &str, dry: bool) -> Step {
885 let what = "hook".to_string();
886 let mut command = runner_command(base_command, runner);
887 if runner == "cline" {
888 return cline_step(file, &command, dry);
889 }
890 let script = needs_script(runner).then(|| script_path(runner, file));
891 let script_text = format!(
892 "#!/bin/sh\n# Written by ljos onboard: the seat's guard for {runner}.\nexec {command}\n"
893 );
894 let script_stale = script
895 .as_ref()
896 .is_some_and(|p| std::fs::read_to_string(p).ok().as_deref() != Some(script_text.as_str()));
897 if let Some(p) = &script {
898 command = p.display().to_string();
899 }
900 let mut root: Value = match std::fs::read_to_string(file) {
901 Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
902 Ok(v) => v,
903 Err(e) => {
904 return Step {
905 what,
906 detail: format!("{}: not JSON: {e}", file.display()),
907 ok: false,
908 }
909 }
910 },
911 _ => json!({}),
912 };
913 let changed = match spec(runner, &mut root, &command) {
914 Ok(c) => c,
915 Err(e) => {
916 return Step {
917 what,
918 detail: format!("{}: {e}", file.display()),
919 ok: false,
920 }
921 }
922 };
923 let manifest = (runner == "goose")
924 .then(|| {
925 file.parent()
926 .and_then(Path::parent)
927 .map(|d| d.join("plugin.json"))
928 })
929 .flatten();
930 let manifest_missing = manifest.as_ref().is_some_and(|m| !m.is_file());
931 if !changed && !script_stale && !manifest_missing {
932 return Step {
933 what,
934 detail: format!("{} carries the seat's {runner} hook", file.display()),
935 ok: true,
936 };
937 }
938 if dry {
939 return Step {
940 what,
941 detail: format!("would write the seat's {runner} hook to {}", file.display()),
942 ok: true,
943 };
944 }
945 let written = file
946 .parent()
947 .map_or(Ok(()), std::fs::create_dir_all)
948 .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
949 .and_then(|text| std::fs::write(file, text + "\n"))
950 .and_then(|()| match &script {
951 Some(p) => std::fs::write(p, &script_text).and_then(|()| set_executable(p)),
952 None => Ok(()),
953 })
954 .and_then(|()| match &manifest {
955 Some(m) if manifest_missing => std::fs::write(
956 m,
957 "{\n \"name\": \"ljos\",\n \"version\": \"1.0.0\",\n \"description\": \
958 \"The ljos seat's memory and guard, written by ljos onboard\"\n}\n",
959 ),
960 _ => Ok(()),
961 });
962 match written {
963 Ok(()) => Step {
964 what,
965 detail: format!("wrote the seat's {runner} hook to {}", file.display()),
966 ok: true,
967 },
968 Err(e) => Step {
969 what,
970 detail: format!("{}: {e}", file.display()),
971 ok: false,
972 },
973 }
974}
975
976fn cline_step(dir: &Path, command: &str, dry: bool) -> Step {
979 let what = "hook".to_string();
980 let text = cline_script(command);
981 let mut todo = Vec::new();
982 for event in ["PreToolUse", "UserPromptSubmit"] {
983 let path = dir.join(event);
984 match std::fs::read_to_string(&path) {
985 Ok(t) if t == text => {}
986 Ok(t) if !t.contains("ljos onboard") => {
987 return Step {
988 what,
989 detail: format!(
990 "{} is someone else's hook; add `{command}` to it",
991 path.display()
992 ),
993 ok: false,
994 }
995 }
996 _ => todo.push(path),
997 }
998 }
999 if todo.is_empty() {
1000 return Step {
1001 what,
1002 detail: format!("{} carries the seat's cline hooks", dir.display()),
1003 ok: true,
1004 };
1005 }
1006 if dry {
1007 return Step {
1008 what,
1009 detail: format!("would write the seat's cline hooks to {}", dir.display()),
1010 ok: true,
1011 };
1012 }
1013 for path in &todo {
1014 let written = std::fs::create_dir_all(dir)
1015 .and_then(|()| std::fs::write(path, &text))
1016 .and_then(|()| set_executable(path));
1017 if let Err(e) = written {
1018 return Step {
1019 what,
1020 detail: format!("{}: {e}", path.display()),
1021 ok: false,
1022 };
1023 }
1024 }
1025 Step {
1026 what,
1027 detail: format!("wrote the seat's cline hooks to {}", dir.display()),
1028 ok: true,
1029 }
1030}
1031
1032#[cfg(unix)]
1033fn set_executable(path: &Path) -> std::io::Result<()> {
1034 use std::os::unix::fs::PermissionsExt;
1035 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
1036}
1037
1038#[cfg(not(unix))]
1039fn set_executable(_path: &Path) -> std::io::Result<()> {
1040 Ok(())
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046
1047 const WARNED: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"warning: no force push (ljos rule `git-force-push`)"}}"#;
1048 const DENY: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no force push (ljos rule `ljos-policyd`)"}}"#;
1049 const OLD_ASK: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"a push"}}"#;
1050 const OLD_BLOCK: &str = r#"{"decision":"block","reason":"no rm -rf"}"#;
1051 const NOTE: &str = r#"{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"lesson: run the tests"}}"#;
1052
1053 fn decides(stdout: &str, stderr: &str) -> bool {
1056 let body = format!("{stdout}{stderr}");
1057 [
1058 "\"deny\"",
1059 "\"ask\"",
1060 "\"block\"",
1061 "\"cancel\":true",
1062 "permissionDecision\"",
1063 ]
1064 .iter()
1065 .any(|word| body.contains(word))
1066 }
1067
1068 fn payload(runner: &str, input: Value) -> Value {
1069 serde_json::from_str(&normalize_input(runner, &input.to_string())).unwrap()
1070 }
1071
1072 #[test]
1073 fn each_runner_payload_reads_as_the_command_it_runs() {
1074 let cases = [
1075 (
1076 "windsurf",
1077 json!({"agent_action_name": "pre_run_command", "trajectory_id": "task-0001",
1078 "tool_info": {"command_line": "git push -f", "cwd": "/tmp"}}),
1079 ),
1080 (
1081 "cline",
1082 json!({"hookName": "PreToolUse", "taskId": "task-0001", "workspaceRoots": ["/tmp"],
1083 "preToolUse": {"toolName": "execute_command", "parameters": {"command": "git push -f"}}}),
1084 ),
1085 (
1086 "cline",
1087 json!({"hookName": "tool_call", "taskId": "task-0001", "workspaceRoots": ["/tmp"],
1088 "tool_call": {"id": "c", "name": "run_commands", "input": {"commands": ["git push -f"]}}}),
1089 ),
1090 (
1091 "copilot",
1092 json!({"sessionId": "task-0001", "timestamp": 1, "cwd": "/tmp", "toolName": "bash",
1093 "toolArgs": "{\"command\": \"git push -f\"}"}),
1094 ),
1095 (
1096 "copilot",
1097 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1098 "tool_name": "Bash", "tool_input": {"command": "git push -f"}}),
1099 ),
1100 (
1101 "gemini",
1102 json!({"hook_event_name": "BeforeTool", "session_id": "task-0001", "cwd": "/tmp",
1103 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
1104 ),
1105 (
1106 "crush",
1107 json!({"event": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1108 "tool_name": "bash", "tool_input": {"command": "git push -f"}}),
1109 ),
1110 (
1111 "kiro",
1112 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1113 "tool_name": "shell", "tool_input": {"command": "git push -f"}}),
1114 ),
1115 (
1116 "factory",
1117 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1118 "tool_name": "Execute", "tool_input": {"command": "git push -f"}}),
1119 ),
1120 (
1121 "qwen",
1122 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1123 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
1124 ),
1125 ];
1126 for (runner, input) in cases {
1127 let v = payload(runner, input.clone());
1128 let call = crate::hook_call(&v.to_string());
1129 assert_eq!(call.event, "PreToolUse", "{runner}: {v}");
1130 assert_eq!(call.cue, "git push -f", "{runner}: {v}");
1131 assert_eq!(call.session.as_deref(), Some("task-0001"), "{runner}: {v}");
1132 assert_eq!(
1133 crate::hook_directory(&v.to_string()).unwrap(),
1134 std::fs::canonicalize("/tmp").unwrap(),
1135 "{runner}"
1136 );
1137 }
1138 let prompt = payload(
1139 "gemini",
1140 json!({"hook_event_name": "BeforeAgent", "session_id": "t", "prompt": "fix the build"}),
1141 );
1142 let call = crate::hook_call(&prompt.to_string());
1143 assert_eq!(
1144 (call.event.as_str(), call.cue.as_str()),
1145 ("UserPromptSubmit", "fix the build")
1146 );
1147 let prompt = payload(
1148 "cline",
1149 json!({"hookName": "UserPromptSubmit", "taskId": "t", "userPromptSubmit": {"prompt": "fix it"}}),
1150 );
1151 assert_eq!(
1152 crate::hook_call(&prompt.to_string()).event,
1153 "UserPromptSubmit"
1154 );
1155 }
1156
1157 #[test]
1158 fn a_rule_reaches_every_runner_as_an_allow_with_its_warning() {
1159 for runner in RUNNERS {
1160 let (out, err, _) = answer(runner, "PreToolUse", WARNED);
1161 assert!(!decides(&out, &err), "{runner}: {out}{err}");
1162 assert!(
1163 format!("{out}{err}").contains("warning: no force push"),
1164 "{runner}: {out}{err}"
1165 );
1166 }
1167 let (out, _, _) = answer("gemini", "BeforeTool", WARNED);
1168 let v: Value = serde_json::from_str(&out).unwrap();
1169 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "BeforeTool");
1170 let (out, _, _) = answer("cline", "PreToolUse", WARNED);
1171 let v: Value = serde_json::from_str(&out).unwrap();
1172 assert_eq!(v["cancel"], json!(false));
1173 assert!(v["contextModification"]
1174 .as_str()
1175 .unwrap()
1176 .contains("git-force-push"));
1177 }
1178
1179 #[test]
1180 fn a_deny_reaches_each_runner_in_its_own_contract() {
1181 let (out, _, code) = answer("copilot", "PreToolUse", DENY);
1182 let v: Value = serde_json::from_str(&out).unwrap();
1183 assert_eq!((v["permissionDecision"].as_str(), code), (Some("deny"), 0));
1184 assert!(v["permissionDecisionReason"]
1185 .as_str()
1186 .unwrap()
1187 .contains("force push"));
1188
1189 let (out, _, code) = answer("gemini", "BeforeTool", DENY);
1190 let v: Value = serde_json::from_str(&out).unwrap();
1191 assert_eq!((v["decision"].as_str(), code), (Some("deny"), 0));
1192 assert!(v["reason"].as_str().unwrap().contains("force push"));
1193
1194 for runner in ["windsurf", "kiro"] {
1195 let (out, err, code) = answer(runner, "PreToolUse", DENY);
1196 assert_eq!((out.as_str(), code), ("", 2), "{runner}");
1197 assert!(err.contains("force push"), "{runner}");
1198 }
1199
1200 for runner in ["factory", "qwen"] {
1201 let (out, err, code) = answer(runner, "PreToolUse", DENY);
1202 assert_eq!(out.as_str(), DENY, "{runner}");
1203 assert_eq!(code, 2, "{runner}");
1204 assert!(err.contains("force push"), "{runner}: {err}");
1205 }
1206
1207 let (out, _, _) = answer("cline", "PreToolUse", DENY);
1208 let v: Value = serde_json::from_str(&out).unwrap();
1209 assert_eq!(v["cancel"], json!(true));
1210 assert!(v["errorMessage"].as_str().unwrap().contains("force push"));
1211
1212 let (out, err, code) = answer("crush", "PreToolUse", DENY);
1213 let v: Value = serde_json::from_str(&out).unwrap();
1214 assert_eq!(v["decision"], json!("deny"));
1215 assert_eq!(code, 2);
1216 assert!(err.contains("force push"));
1217 }
1218
1219 #[test]
1223 fn a_tool_call_deny_is_each_runners_deny_and_nothing_else_is() {
1224 for runner in RUNNERS {
1225 for (refused, why) in [(DENY, "no force push"), (OLD_BLOCK, "no rm -rf")] {
1226 let (out, err, code) = answer(runner, "PreToolUse", refused);
1227 assert!(decides(&out, &err) || code == 2, "{runner}: {out}{err}");
1228 assert!(format!("{out}{err}").contains(why), "{runner}: {out}{err}");
1229 }
1230 for (old, event, why) in [
1231 (OLD_ASK, "PreToolUse", "warning: a push"),
1232 (OLD_BLOCK, "Stop", "warning: no rm -rf"),
1233 ] {
1234 let (out, err, code) = answer(runner, event, old);
1235 assert!(!decides(&out, &err) && code == 0, "{runner}: {out}{err}");
1236 assert!(format!("{out}{err}").contains(why), "{runner}: {out}{err}");
1237 }
1238 }
1239 }
1240
1241 #[test]
1242 fn an_allow_says_nothing_and_a_note_reaches_runners_that_take_one() {
1243 for runner in ["copilot", "windsurf", "kiro", "crush"] {
1244 assert_eq!(
1245 answer(runner, "PreToolUse", ""),
1246 (String::new(), String::new(), 0)
1247 );
1248 }
1249 let (out, _, _) = answer("cline", "PreToolUse", "");
1250 assert_eq!(
1251 serde_json::from_str::<Value>(&out).unwrap(),
1252 json!({"cancel": false})
1253 );
1254 assert_eq!(answer("goose", "UserPromptSubmit", NOTE).1, "");
1255 let (out, _, _) = answer("gemini", "BeforeAgent", NOTE);
1256 let v: Value = serde_json::from_str(&out).unwrap();
1257 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "BeforeAgent");
1258 assert_eq!(
1259 v["hookSpecificOutput"]["additionalContext"],
1260 "lesson: run the tests"
1261 );
1262 let (out, _, _) = answer("cline", "UserPromptSubmit", NOTE);
1263 assert!(out.contains("contextModification"));
1264 }
1265
1266 #[cfg(unix)]
1267 fn sh(script: &str) -> std::process::Command {
1268 let mut cmd = std::process::Command::new("sh");
1269 cmd.arg("-c").arg(script);
1270 cmd
1271 }
1272
1273 #[cfg(unix)]
1274 fn inner(script: &str, timeout_ms: u64) -> String {
1275 run_inner(
1276 sh(script),
1277 r#"{"tool_name":"Bash"}"#,
1278 "PreToolUse",
1279 std::time::Duration::from_millis(timeout_ms),
1280 )
1281 }
1282
1283 #[test]
1284 fn a_relay_fault_allows_and_warns_in_the_runners_contract() {
1285 for event in ["", "BeforeTool", "UserPromptSubmit", "BeforeAgent"] {
1286 for runner in RUNNERS {
1287 let (stdout, stderr) = relay_fault_answer(runner, event, "stdin is not utf-8");
1288 let body = format!("{stdout}{stderr}");
1289 assert!(!decides(&stdout, &stderr), "{runner} {event}: {body}");
1290 if shows_context(runner) {
1291 assert!(body.contains("unhealthy"), "{runner}: {body}");
1292 assert!(body.contains("stdin is not utf-8"), "{runner}: {body}");
1293 }
1294 }
1295 }
1296 }
1297
1298 fn shows_context(runner: &str) -> bool {
1299 matches!(
1300 runner,
1301 "gemini" | "openhands" | "auggie" | "kiro" | "cline" | "crush" | "factory" | "qwen"
1302 )
1303 }
1304
1305 fn allows_fault(out: &str, why: &str) {
1306 let said = read_answer(out);
1307 assert!(said.contains("unhealthy"), "{out}");
1308 assert!(said.contains(why), "{out}");
1309 for runner in RUNNERS {
1310 let (stdout, stderr, _) = answer(runner, "PreToolUse", out);
1311 let body = format!("{stdout}{stderr}");
1312 assert!(!decides(&stdout, &stderr), "{runner}: {body}");
1313 if shows_context(runner) {
1314 assert!(body.contains("unhealthy"), "{runner}: {body}");
1315 assert!(body.contains(why), "{runner}: {body}");
1316 }
1317 }
1318 }
1319
1320 #[cfg(unix)]
1321 #[test]
1322 fn an_inner_hook_fault_allows_in_every_runner_shape() {
1323 let timeout = std::time::Duration::from_secs(5);
1327 let injected = [
1328 format!("gave no answer within {} s", timeout.as_secs_f32()),
1329 "exited with status 3".to_string(),
1330 "was killed by a signal".to_string(),
1331 ];
1332 for why in &injected {
1333 allows_fault(&fault_output("PreToolUse", why), why);
1334 }
1335 allows_fault(&inner("exit 3", 30_000), "exited with status 3");
1336 let missing = run_inner(
1337 std::process::Command::new("/nonexistent/ljos"),
1338 "{}",
1339 "PreToolUse",
1340 std::time::Duration::from_secs(1),
1341 );
1342 allows_fault(&missing, "did not start");
1343 assert_eq!(INNER_TIMEOUT, crate::GUARD_TIMEOUT);
1344 }
1345
1346 #[test]
1351 fn a_prompt_fault_is_not_a_deny() {
1352 let _lock = crate::test_env_lock();
1353 let why = format!(
1354 "gave no answer within {} s",
1355 crate::GUARD_TIMEOUT.as_secs_f32()
1356 );
1357 let prompts = [
1358 ("gemini", "BeforeAgent"),
1359 ("qwen", "UserPromptSubmit"),
1360 ("factory", "UserPromptSubmit"),
1361 ("openhands", "UserPromptSubmit"),
1362 ("goose", "UserPromptSubmit"),
1363 ("cline", "UserPromptSubmit"),
1364 ];
1365 for (runner, event) in prompts {
1366 let fault = fault_output(event, &why);
1367 assert!(
1368 !fault.contains("while checking this command"),
1369 "{runner}: {fault}"
1370 );
1371 assert!(
1372 read_answer(&fault).contains("unhealthy"),
1373 "{runner}: {fault}"
1374 );
1375 let (stdout, stderr, _) = answer(runner, event, &fault);
1376 let body = format!("{stdout}{stderr}");
1377 assert!(!decides(&stdout, &stderr), "{runner}: {body}");
1378 assert!(
1379 !body.contains("while checking this command"),
1380 "{runner}: {body}"
1381 );
1382 }
1383 allows_fault(&fault_output("PreToolUse", &why), &why);
1384 allows_fault(&fault_output("BeforeTool", &why), &why);
1385 let deadline = std::time::Duration::from_millis(crate::HOOK_DEADLINE_MS);
1386 assert!(relay_timeout("UserPromptSubmit") >= deadline);
1387 assert!(relay_timeout("BeforeAgent") >= deadline);
1388 assert!(relay_timeout("pre_user_prompt") >= deadline);
1389 assert_eq!(relay_timeout("PreToolUse"), crate::guard_timeout());
1390 assert_eq!(relay_timeout("BeforeTool"), crate::guard_timeout());
1391 assert_eq!(relay_timeout(""), crate::guard_timeout());
1392 }
1393
1394 #[cfg(unix)]
1395 #[test]
1396 fn a_prompt_hook_that_exits_early_is_not_denied() {
1397 let out = run_inner(
1398 sh("exit 3"),
1399 r#"{"hook_event_name":"UserPromptSubmit","prompt":"hello"}"#,
1400 "UserPromptSubmit",
1401 std::time::Duration::from_secs(2),
1402 );
1403 assert!(!out.contains("while checking this command"), "{out}");
1404 assert!(read_answer(&out).contains("unhealthy"), "{out}");
1405 }
1406
1407 #[cfg(unix)]
1410 #[test]
1411 fn an_unhealthy_note_is_not_a_second_deny() {
1412 let why = "did not answer in time";
1413 let exact = json!({
1414 "hookSpecificOutput": {
1415 "hookEventName": "PreToolUse",
1416 "additionalContext": crate::unhealthy_note(why),
1417 }
1418 })
1419 .to_string();
1420 let merged = json!({
1421 "hookSpecificOutput": {
1422 "hookEventName": "PreToolUse",
1423 "additionalContext": format!(
1424 "{}\nthe tip has no passing test record",
1425 crate::unhealthy_note(why)
1426 ),
1427 }
1428 })
1429 .to_string();
1430 for body in [exact, merged] {
1431 let out = inner(&format!("printf '%s' '{body}'"), 30_000);
1432 allows_fault(&out, why);
1433 }
1434 }
1435
1436 #[cfg(unix)]
1437 #[test]
1438 fn an_inner_hook_that_answers_keeps_its_answer() {
1439 assert_eq!(inner("true", 30_000), "", "a clean allow");
1440 assert_eq!(inner(&format!("printf '%s' '{WARNED}'"), 30_000), WARNED);
1441 let note = inner(&format!("printf '%s' '{WARNED}'; exit 1"), 30_000);
1442 allows_fault(¬e, "exited with status 1");
1443 }
1444
1445 #[test]
1446 fn hook_files_are_written_in_each_runner_shape_and_never_twice() {
1447 let tmp = tempfile::tempdir().unwrap();
1448 let dir = tmp.path().to_path_buf();
1449 let base = "/opt/bin/ljos hook";
1450 for runner in RUNNERS {
1451 let file = if *runner == "cline" {
1452 dir.join("cline-hooks")
1453 } else {
1454 dir.join(format!("{runner}.json"))
1455 };
1456 if *runner == "gemini" {
1457 std::fs::write(&file, r#"{"mcpServers":{"x":{}},"hooks":{"BeforeTool":[{"matcher":"x","hooks":[]}]}}"#).unwrap();
1459 }
1460 let first = hook_step(runner, &file, base, false);
1461 assert!(first.ok, "{runner}: {}", first.detail);
1462 assert!(first.detail.contains("wrote"), "{runner}: {}", first.detail);
1463 let again = hook_step(runner, &file, base, false);
1464 assert!(
1465 again.ok && again.detail.contains("carries"),
1466 "{runner}: {}",
1467 again.detail
1468 );
1469 let command = format!("{base} --runner {runner}");
1470 if *runner == "cline" {
1471 for event in ["PreToolUse", "UserPromptSubmit"] {
1472 let text = std::fs::read_to_string(file.join(event)).unwrap();
1473 assert!(text.contains(&command));
1474 }
1475 continue;
1476 }
1477 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1478 let found = |p: &str| v.pointer(p).cloned().unwrap_or(Value::Null);
1479 match *runner {
1480 "copilot" => {
1481 assert_eq!(found("/version"), json!(1));
1482 assert_eq!(found("/hooks/PreToolUse/0/bash"), json!(command));
1483 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("Bash"));
1484 }
1485 "gemini" => {
1486 assert_eq!(found("/mcpServers/x"), json!({}));
1487 assert_eq!(found("/hooks/BeforeTool/0/matcher"), json!("x"));
1488 assert_eq!(
1489 found("/hooks/BeforeTool/1/matcher"),
1490 json!("run_shell_command")
1491 );
1492 assert_eq!(
1493 found("/hooks/BeforeTool/1/hooks/0/timeout"),
1494 json!(20_000),
1495 "milliseconds"
1496 );
1497 assert_eq!(
1498 found("/hooks/BeforeAgent/0/hooks/0/command"),
1499 json!(command)
1500 );
1501 }
1502 "qwen" => {
1503 assert_eq!(
1504 found("/hooks/PreToolUse/0/matcher"),
1505 json!("^run_shell_command$")
1506 );
1507 assert_eq!(
1508 found("/hooks/PreToolUse/0/hooks/0/timeout"),
1509 json!(20),
1510 "seconds"
1511 );
1512 }
1513 "factory" => {
1514 assert_eq!(found("/PreToolUse/0/matcher"), json!("Execute"));
1515 assert_eq!(found("/PreToolUse/0/hooks/0/command"), json!(command));
1516 }
1517 "windsurf" => {
1518 assert_eq!(found("/hooks/pre_run_command/0/command"), json!(command));
1519 }
1520 "kiro" => {
1521 assert_eq!(found("/version"), json!("v1"));
1522 assert_eq!(found("/hooks/0/trigger"), json!("PreToolUse"));
1523 assert_eq!(found("/hooks/0/action/command"), json!(command));
1524 }
1525 "crush" => {
1526 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("^bash$"));
1527 assert_eq!(found("/hooks/PreToolUse/0/command"), json!(command));
1528 }
1529 "goose" => {
1530 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(command));
1531 }
1532 "auggie" => {
1533 assert_eq!(
1534 found("/hooks/PreToolUse/0/matcher"),
1535 json!("launch-process")
1536 );
1537 let script = script_path("auggie", &file).display().to_string();
1538 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(script));
1539 }
1540 "amp" => {
1541 assert_eq!(found("/amp.permissions/0/action"), json!("delegate"));
1542 }
1543 "openhands" => {
1544 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("terminal"));
1545 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(command));
1546 }
1547 _ => unreachable!(),
1548 }
1549 }
1550 let foreign = dir.join("foreign-cline");
1551 std::fs::create_dir_all(&foreign).unwrap();
1552 std::fs::write(foreign.join("PreToolUse"), "#!/bin/sh\necho mine\n").unwrap();
1553 let step = hook_step("cline", &foreign, base, false);
1554 assert!(
1555 !step.ok && step.detail.contains("someone else's"),
1556 "{}",
1557 step.detail
1558 );
1559 }
1560
1561 #[test]
1562 fn goose_payload_reaches_the_seat_and_a_rule_is_a_warning_on_stderr() {
1563 let n = normalize_input(
1564 "goose",
1565 r#"{"event":"PreToolUse","session_id":"g-1","tool_name":"shell","tool_input":{"command":"rm -rf /"},"working_dir":"/r"}"#,
1566 );
1567 let v: Value = serde_json::from_str(&n).unwrap();
1568 assert_eq!(v["hook_event_name"], "PreToolUse");
1569 assert_eq!(v["tool_input"]["command"], "rm -rf /");
1570 assert_eq!(v["cwd"], "/r");
1571 assert_eq!(
1572 answer("goose", "PreToolUse", WARNED),
1573 (
1574 String::new(),
1575 "warning: no force push (ljos rule `git-force-push`)\n".into(),
1576 0
1577 )
1578 );
1579 assert_eq!(
1580 answer("goose", "PreToolUse", "{}"),
1581 (String::new(), String::new(), 0)
1582 );
1583 }
1584
1585 #[test]
1586 fn amp_reads_cmd_and_a_rule_is_a_warning_on_stderr() {
1587 let n = normalize_input("amp", r#"{"cmd":"git push --force"}"#);
1588 let v: Value = serde_json::from_str(&n).unwrap();
1589 assert_eq!(v["tool_input"]["command"], "git push --force");
1590 let (out, err, _) = answer("amp", "", WARNED);
1591 assert_eq!(out, "");
1592 assert!(err.starts_with("warning: no force push"), "{err}");
1593 assert_eq!(answer("amp", "", ""), (String::new(), String::new(), 0));
1594 }
1595
1596 #[test]
1597 fn auggie_launch_process_is_the_shell_and_its_warning_is_claude_shaped() {
1598 let n = normalize_input(
1599 "auggie",
1600 r#"{"hook_event_name":"PreToolUse","tool_name":"launch-process","tool_input":{"command":"ls"}}"#,
1601 );
1602 let v: Value = serde_json::from_str(&n).unwrap();
1603 assert_eq!(v["tool_name"], "Bash");
1604 let (out, _, _) = answer("auggie", "PreToolUse", WARNED);
1605 let o: Value = serde_json::from_str(&out).unwrap();
1606 assert_eq!(o["hookSpecificOutput"]["permissionDecision"], Value::Null);
1607 assert!(o["hookSpecificOutput"]["additionalContext"]
1608 .as_str()
1609 .unwrap()
1610 .starts_with("warning: no force push"));
1611 }
1612
1613 #[test]
1614 fn auggie_and_amp_hooks_name_a_script_and_are_written_once() {
1615 for (runner, name) in [("auggie", "settings.json"), ("amp", "settings.json")] {
1616 let dir = tempfile::tempdir().unwrap();
1617 let file = dir.path().join(name);
1618 std::fs::write(
1619 &file,
1620 r#"{"keep": 1, "amp.permissions": [{"tool": "Read", "action": "allow"}]}"#,
1621 )
1622 .unwrap();
1623 let step = hook_step(runner, &file, "/bin/ljos hook", false);
1624 assert!(step.ok, "{}", step.detail);
1625 let script = script_path(runner, &file);
1626 let text = std::fs::read_to_string(&script).unwrap();
1627 assert!(text.contains(&format!("/bin/ljos hook --runner {runner}")));
1628 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1629 assert_eq!(v["keep"], 1);
1630 assert!(v.to_string().contains(&script.display().to_string()));
1631 if runner == "amp" {
1632 assert_eq!(v["amp.permissions"][0]["action"], "delegate");
1633 assert_eq!(v["amp.permissions"][1]["tool"], "Read");
1634 }
1635 let again = hook_step(runner, &file, "/bin/ljos hook", false);
1636 assert!(again.detail.contains("carries"), "{}", again.detail);
1637 }
1638 }
1639
1640 #[test]
1641 fn goose_hook_is_a_plugin_whose_failure_does_not_block() {
1642 let dir = tempfile::tempdir().unwrap();
1643 let file = dir.path().join("ljos/hooks/hooks.json");
1644 let step = hook_step("goose", &file, "/bin/ljos hook", false);
1645 assert!(step.ok, "{}", step.detail);
1646 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1647 assert_eq!(
1648 v["hooks"]["PreToolUse"][0]["hooks"][0]["on_failure"],
1649 Value::Null
1650 );
1651 assert!(dir.path().join("ljos/plugin.json").is_file());
1652 assert!(hook_step("goose", &file, "/bin/ljos hook", false)
1653 .detail
1654 .contains("carries"));
1655 }
1656
1657 #[test]
1658 fn openhands_terminal_is_the_shell_and_a_rule_is_context() {
1659 let n = normalize_input(
1660 "openhands",
1661 r#"{"event_type":"PreToolUse","tool_name":"terminal","tool_input":{"command":"rm -rf ~"},"working_dir":"/w"}"#,
1662 );
1663 let v: Value = serde_json::from_str(&n).unwrap();
1664 assert_eq!(v["tool_name"], "Bash");
1665 assert_eq!(v["cwd"], "/w");
1666 let (out, _, _) = answer("openhands", "PreToolUse", WARNED);
1667 let o: Value = serde_json::from_str(&out).unwrap();
1668 assert_eq!(o["decision"], Value::Null);
1669 assert!(o["additionalContext"]
1670 .as_str()
1671 .unwrap()
1672 .starts_with("warning: no force push"));
1673 }
1674
1675 #[test]
1676 fn the_start_line_leads_an_openhands_prompt_note() {
1677 let out = with_start_line("{\"additionalContext\":\"lesson\"}\n", "ljos is on: x");
1678 let v: Value = serde_json::from_str(&out).unwrap();
1679 assert_eq!(v["additionalContext"], "ljos is on: x\n\nlesson");
1680 assert_eq!(
1681 with_start_line("", "ljos is on").trim(),
1682 r#"{"additionalContext":"ljos is on"}"#
1683 );
1684 }
1685}