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