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 seat_tool_event(event: &str) -> &str {
449 match event {
450 "" | "BeforeTool" | "pre_run_command" => "PreToolUse",
451 other => other,
452 }
453}
454
455fn early_answer(normalized: &str, event: &str) -> Option<String> {
456 let line = tool_command(normalized);
457 let parsed = serde_json::from_str::<Value>(normalized.trim()).ok();
458 let cwd = parsed
459 .as_ref()
460 .and_then(|v| v["cwd"].as_str().map(std::path::PathBuf::from));
461 let rule = crate::static_guard_in(&line, cwd.as_deref())?;
462 let named = parsed
463 .as_ref()
464 .and_then(|v| v["hook_event_name"].as_str())
465 .filter(|name| !name.is_empty());
466 let event = seat_tool_event(named.unwrap_or(event));
467 let call = crate::HookCall {
468 event: event.to_string(),
469 cue: line,
470 session: None,
471 shape: crate::HookShape::CamelCase,
472 };
473 Some(crate::hook_output_ruled(&call, "", Some(&rule)))
474}
475
476#[must_use]
481pub fn run_inner(
482 mut cmd: std::process::Command,
483 stdin: &str,
484 event: &str,
485 timeout: std::time::Duration,
486) -> String {
487 use std::io::{Read, Write};
488 let spawned = cmd
489 .stdin(std::process::Stdio::piped())
490 .stdout(std::process::Stdio::piped())
491 .spawn();
492 let mut child = match spawned {
493 Ok(child) => child,
494 Err(e) => return fault_output(event, &format!("did not start ({e})")),
495 };
496 let input = stdin.to_string();
499 if let Some(mut pipe) = child.stdin.take() {
502 std::thread::spawn(move || {
503 let _ = pipe.write_all(input.as_bytes());
504 });
505 }
506 let reader = child.stdout.take().map(|mut pipe| {
507 std::thread::spawn(move || {
508 let mut buf = Vec::new();
509 let _ = pipe.read_to_end(&mut buf);
510 buf
511 })
512 });
513 let deadline = std::time::Instant::now() + timeout;
514 let status = loop {
515 match child.try_wait() {
516 Ok(Some(status)) => break Ok(status),
517 Ok(None) if std::time::Instant::now() >= deadline => {
518 let _ = child.kill();
519 let _ = child.wait();
520 break Err(format!("gave no answer within {} s", timeout.as_secs_f32()));
521 }
522 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
523 Err(e) => break Err(format!("could not be waited on ({e})")),
524 }
525 };
526 let status = match status {
527 Ok(status) => status,
528 Err(what) => return fault_output(event, &what),
531 };
532 let out = reader
533 .and_then(|r| r.join().ok())
534 .map(|b| String::from_utf8_lossy(&b).into_owned())
535 .unwrap_or_default();
536 if status.success() || read_answer(&out).decision.is_some() {
537 return out;
538 }
539 let what = match status.code() {
540 Some(code) => format!("exited with status {code}"),
541 None => "was killed by a signal".to_string(),
542 };
543 fault_output(event, &what)
544}
545
546pub fn relay(runner: &str, input: &str, limit: usize, event: Option<&str>) -> anyhow::Result<i32> {
553 let normalized = normalize_input(runner, input);
554 let raw = raw_event(input);
555 let text = match early_answer(&normalized, &raw) {
556 Some(text) => text,
557 None => match std::env::current_exe() {
558 Ok(exe) => {
559 let mut cmd = std::process::Command::new(exe);
560 cmd.arg("hook").arg("--limit").arg(limit.to_string());
561 if let Some(e) = event {
562 cmd.arg("--event").arg(e);
563 }
564 run_inner(cmd, &normalized, &raw, crate::guard_timeout())
565 }
566 Err(e) => fault_output(&raw, &format!("could not find its own binary ({e})")),
567 },
568 };
569 let (mut out, err, code) = answer(runner, &raw, &text);
570 if let Some(line) = first_prompt_start_line(runner, &raw, input) {
571 out = with_start_line(&out, &line);
572 }
573 print!("{out}");
574 eprint!("{err}");
575 Ok(code)
576}
577
578fn first_prompt_start_line(runner: &str, raw: &str, input: &str) -> Option<String> {
582 if runner != "openhands" || raw != "UserPromptSubmit" {
583 return None;
584 }
585 let v: Value = serde_json::from_str(input.trim()).ok()?;
586 let sid: String = v["session_id"]
587 .as_str()?
588 .chars()
589 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
590 .collect();
591 if sid.is_empty() {
592 return None;
593 }
594 let state = std::env::var_os("XDG_STATE_HOME")
595 .map(std::path::PathBuf::from)
596 .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/state")))?;
597 let mark = state.join("ljos/openhands-started").join(&sid);
598 if mark.exists() {
599 return None;
600 }
601 let _ = mark.parent().map(std::fs::create_dir_all);
602 let _ = std::fs::write(&mark, "");
603 Some(crate::session_start_for(Some(&sid)))
604}
605
606fn with_start_line(out: &str, line: &str) -> String {
608 let mut v: Value = serde_json::from_str(out.trim()).unwrap_or_else(|_| json!({}));
609 if v.get("decision").is_some() {
610 return out.to_string();
611 }
612 let rest = v["additionalContext"].as_str().unwrap_or("").to_string();
613 v["additionalContext"] = json!(if rest.is_empty() {
614 line.to_string()
615 } else {
616 format!("{line}\n\n{rest}")
617 });
618 v.to_string() + "\n"
619}
620
621#[must_use]
623pub fn runner_command(base: &str, runner: &str) -> String {
624 format!("{base} --runner {runner}")
625}
626
627fn ours(v: &Value, runner: &str) -> bool {
628 let t = v.to_string();
629 t.contains(&format!("--runner {runner}")) || t.contains(&format!("ljos-guard-{runner}.sh"))
630}
631
632fn ensure_in(obj: &mut Map<String, Value>, key: &str, item: Value, runner: &str) -> bool {
635 let list = obj.entry(key.to_string()).or_insert_with(|| json!([]));
636 let Some(list) = list.as_array_mut() else {
637 return false;
638 };
639 if list.iter().any(|g| ours(g, runner)) {
640 return false;
641 }
642 list.push(item);
643 true
644}
645
646fn group(matcher: Option<&str>, command: &str, timeout: u64, runner: &str) -> Value {
647 let mut hook = json!({ "type": "command", "command": command, "timeout": timeout });
648 if runner == "gemini" {
649 hook["name"] = json!("ljos");
650 }
651 let mut g = json!({ "hooks": [hook] });
652 if let Some(m) = matcher {
653 g["matcher"] = json!(m);
654 }
655 g
656}
657
658fn hooks_of(obj: &mut Map<String, Value>) -> Result<&mut Map<String, Value>, String> {
659 obj.entry("hooks")
660 .or_insert_with(|| json!({}))
661 .as_object_mut()
662 .ok_or_else(|| "hooks is not an object".to_string())
663}
664
665fn spec(runner: &str, root: &mut Value, command: &str) -> Result<bool, String> {
669 let obj = root
670 .as_object_mut()
671 .ok_or_else(|| "not a JSON object".to_string())?;
672 let changed = match runner {
673 "copilot" => {
674 let want = json!({
675 "version": 1,
676 "hooks": { "PreToolUse": [{
677 "type": "command", "matcher": "Bash", "bash": command, "timeoutSec": 20
678 }]}
679 });
680 let same = Value::Object(obj.clone()) == want;
681 *obj = want.as_object().cloned().unwrap_or_default();
682 !same
683 }
684 "kiro" => {
685 let want = json!({
686 "version": "v1",
687 "hooks": [{
688 "name": "ljos guard",
689 "description": "The seat's guard on shell commands; written by ljos onboard.",
690 "trigger": "PreToolUse",
691 "matcher": "shell",
692 "action": { "type": "command", "command": command },
693 "timeout": 20
694 }]
695 });
696 let same = Value::Object(obj.clone()) == want;
697 *obj = want.as_object().cloned().unwrap_or_default();
698 !same
699 }
700 "gemini" => {
701 let hooks = hooks_of(obj)?;
702 let a = ensure_in(
703 hooks,
704 "BeforeTool",
705 group(Some("run_shell_command"), command, 20_000, runner),
706 runner,
707 );
708 let b = ensure_in(
709 hooks,
710 "BeforeAgent",
711 group(None, command, 15_000, runner),
712 runner,
713 );
714 a || b
715 }
716 "qwen" => {
717 let hooks = hooks_of(obj)?;
718 let a = ensure_in(
719 hooks,
720 "PreToolUse",
721 group(Some("^run_shell_command$"), command, 20, runner),
722 runner,
723 );
724 let b = ensure_in(
725 hooks,
726 "UserPromptSubmit",
727 group(None, command, 15, runner),
728 runner,
729 );
730 a || b
731 }
732 "factory" => {
733 let a = ensure_in(
735 obj,
736 "PreToolUse",
737 group(Some("Execute"), command, 20, runner),
738 runner,
739 );
740 let b = ensure_in(
741 obj,
742 "UserPromptSubmit",
743 group(None, command, 15, runner),
744 runner,
745 );
746 a || b
747 }
748 "windsurf" => {
749 let hooks = hooks_of(obj)?;
750 ensure_in(
751 hooks,
752 "pre_run_command",
753 json!({ "command": command, "show_output": false }),
754 runner,
755 )
756 }
757 "openhands" => {
758 let hooks = hooks_of(obj)?;
759 let a = ensure_in(
760 hooks,
761 "PreToolUse",
762 group(Some("terminal"), command, 20, runner),
763 runner,
764 );
765 let b = ensure_in(
766 hooks,
767 "UserPromptSubmit",
768 group(None, command, 15, runner),
769 runner,
770 );
771 a || b
772 }
773 "auggie" => {
774 let hooks = hooks_of(obj)?;
775 ensure_in(
776 hooks,
777 "PreToolUse",
778 group(Some("launch-process"), command, 20_000, runner),
779 runner,
780 )
781 }
782 "goose" => {
783 let want = json!({ "hooks": {
786 "PreToolUse": [{ "matcher": "shell", "hooks": [{
787 "type": "command", "command": command, "timeout": 20, "on_failure": "block"
788 }]}],
789 "UserPromptSubmit": [{ "hooks": [{
790 "type": "command", "command": command, "timeout": 15
791 }]}]
792 }});
793 let same = Value::Object(obj.clone()) == want;
794 *obj = want.as_object().cloned().unwrap_or_default();
795 !same
796 }
797 "amp" => {
798 let list = obj
799 .entry("amp.permissions".to_string())
800 .or_insert_with(|| json!([]));
801 let Some(list) = list.as_array_mut() else {
802 return Err("amp.permissions is not a list".into());
803 };
804 if list.iter().any(|r| ours(r, runner)) {
805 false
806 } else {
807 list.insert(
809 0,
810 json!({ "tool": "Bash", "action": "delegate", "to": command }),
811 );
812 true
813 }
814 }
815 "crush" => {
816 let hooks = hooks_of(obj)?;
817 ensure_in(
818 hooks,
819 "PreToolUse",
820 json!({ "name": "ljos", "matcher": "^bash$", "command": command, "timeout": 20 }),
821 runner,
822 )
823 }
824 other => return Err(format!("no hook shape for {other}")),
825 };
826 Ok(changed)
827}
828
829#[must_use]
831pub fn cline_script(command: &str) -> String {
832 format!("#!/bin/sh\n# Written by ljos onboard: the seat's hook for Cline.\nexec {command}\n")
833}
834
835#[must_use]
837pub fn hook_step(runner: &str, file: &Path, base_command: &str, dry: bool) -> Step {
838 let what = "hook".to_string();
839 let mut command = runner_command(base_command, runner);
840 if runner == "cline" {
841 return cline_step(file, &command, dry);
842 }
843 let script = needs_script(runner).then(|| script_path(runner, file));
844 let script_text = format!(
845 "#!/bin/sh\n# Written by ljos onboard: the seat's guard for {runner}.\nexec {command}\n"
846 );
847 let script_stale = script
848 .as_ref()
849 .is_some_and(|p| std::fs::read_to_string(p).ok().as_deref() != Some(script_text.as_str()));
850 if let Some(p) = &script {
851 command = p.display().to_string();
852 }
853 let mut root: Value = match std::fs::read_to_string(file) {
854 Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
855 Ok(v) => v,
856 Err(e) => {
857 return Step {
858 what,
859 detail: format!("{}: not JSON: {e}", file.display()),
860 ok: false,
861 }
862 }
863 },
864 _ => json!({}),
865 };
866 let changed = match spec(runner, &mut root, &command) {
867 Ok(c) => c,
868 Err(e) => {
869 return Step {
870 what,
871 detail: format!("{}: {e}", file.display()),
872 ok: false,
873 }
874 }
875 };
876 let manifest = (runner == "goose")
877 .then(|| {
878 file.parent()
879 .and_then(Path::parent)
880 .map(|d| d.join("plugin.json"))
881 })
882 .flatten();
883 let manifest_missing = manifest.as_ref().is_some_and(|m| !m.is_file());
884 if !changed && !script_stale && !manifest_missing {
885 return Step {
886 what,
887 detail: format!("{} carries the seat's {runner} hook", file.display()),
888 ok: true,
889 };
890 }
891 if dry {
892 return Step {
893 what,
894 detail: format!("would write the seat's {runner} hook to {}", file.display()),
895 ok: true,
896 };
897 }
898 let written = file
899 .parent()
900 .map_or(Ok(()), std::fs::create_dir_all)
901 .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
902 .and_then(|text| std::fs::write(file, text + "\n"))
903 .and_then(|()| match &script {
904 Some(p) => std::fs::write(p, &script_text).and_then(|()| set_executable(p)),
905 None => Ok(()),
906 })
907 .and_then(|()| match &manifest {
908 Some(m) if manifest_missing => std::fs::write(
909 m,
910 "{\n \"name\": \"ljos\",\n \"version\": \"1.0.0\",\n \"description\": \
911 \"The ljos seat's memory and guard, written by ljos onboard\"\n}\n",
912 ),
913 _ => Ok(()),
914 });
915 match written {
916 Ok(()) => Step {
917 what,
918 detail: format!("wrote the seat's {runner} hook to {}", file.display()),
919 ok: true,
920 },
921 Err(e) => Step {
922 what,
923 detail: format!("{}: {e}", file.display()),
924 ok: false,
925 },
926 }
927}
928
929fn cline_step(dir: &Path, command: &str, dry: bool) -> Step {
932 let what = "hook".to_string();
933 let text = cline_script(command);
934 let mut todo = Vec::new();
935 for event in ["PreToolUse", "UserPromptSubmit"] {
936 let path = dir.join(event);
937 match std::fs::read_to_string(&path) {
938 Ok(t) if t == text => {}
939 Ok(t) if !t.contains("ljos onboard") => {
940 return Step {
941 what,
942 detail: format!(
943 "{} is someone else's hook; add `{command}` to it",
944 path.display()
945 ),
946 ok: false,
947 }
948 }
949 _ => todo.push(path),
950 }
951 }
952 if todo.is_empty() {
953 return Step {
954 what,
955 detail: format!("{} carries the seat's cline hooks", dir.display()),
956 ok: true,
957 };
958 }
959 if dry {
960 return Step {
961 what,
962 detail: format!("would write the seat's cline hooks to {}", dir.display()),
963 ok: true,
964 };
965 }
966 for path in &todo {
967 let written = std::fs::create_dir_all(dir)
968 .and_then(|()| std::fs::write(path, &text))
969 .and_then(|()| set_executable(path));
970 if let Err(e) = written {
971 return Step {
972 what,
973 detail: format!("{}: {e}", path.display()),
974 ok: false,
975 };
976 }
977 }
978 Step {
979 what,
980 detail: format!("wrote the seat's cline hooks to {}", dir.display()),
981 ok: true,
982 }
983}
984
985#[cfg(unix)]
986fn set_executable(path: &Path) -> std::io::Result<()> {
987 use std::os::unix::fs::PermissionsExt;
988 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
989}
990
991#[cfg(not(unix))]
992fn set_executable(_path: &Path) -> std::io::Result<()> {
993 Ok(())
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 const DENY: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no force push (ljos rule `ljos-policyd`)"}}"#;
1001 const ASK: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"a push"}}"#;
1002 const NOTE: &str = r#"{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"lesson: run the tests"}}"#;
1003
1004 fn payload(runner: &str, input: Value) -> Value {
1005 serde_json::from_str(&normalize_input(runner, &input.to_string())).unwrap()
1006 }
1007
1008 #[test]
1009 fn each_runner_payload_reads_as_the_command_it_runs() {
1010 let cases = [
1011 (
1012 "windsurf",
1013 json!({"agent_action_name": "pre_run_command", "trajectory_id": "t1",
1014 "tool_info": {"command_line": "git push -f", "cwd": "/tmp"}}),
1015 ),
1016 (
1017 "cline",
1018 json!({"hookName": "PreToolUse", "taskId": "t1", "workspaceRoots": ["/tmp"],
1019 "preToolUse": {"toolName": "execute_command", "parameters": {"command": "git push -f"}}}),
1020 ),
1021 (
1022 "cline",
1023 json!({"hookName": "tool_call", "taskId": "t1", "workspaceRoots": ["/tmp"],
1024 "tool_call": {"id": "c", "name": "run_commands", "input": {"commands": ["git push -f"]}}}),
1025 ),
1026 (
1027 "copilot",
1028 json!({"sessionId": "t1", "timestamp": 1, "cwd": "/tmp", "toolName": "bash",
1029 "toolArgs": "{\"command\": \"git push -f\"}"}),
1030 ),
1031 (
1032 "copilot",
1033 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
1034 "tool_name": "Bash", "tool_input": {"command": "git push -f"}}),
1035 ),
1036 (
1037 "gemini",
1038 json!({"hook_event_name": "BeforeTool", "session_id": "t1", "cwd": "/tmp",
1039 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
1040 ),
1041 (
1042 "crush",
1043 json!({"event": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
1044 "tool_name": "bash", "tool_input": {"command": "git push -f"}}),
1045 ),
1046 (
1047 "kiro",
1048 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
1049 "tool_name": "shell", "tool_input": {"command": "git push -f"}}),
1050 ),
1051 (
1052 "factory",
1053 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
1054 "tool_name": "Execute", "tool_input": {"command": "git push -f"}}),
1055 ),
1056 (
1057 "qwen",
1058 json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
1059 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
1060 ),
1061 ];
1062 for (runner, input) in cases {
1063 let v = payload(runner, input.clone());
1064 let call = crate::hook_call(&v.to_string());
1065 assert_eq!(call.event, "PreToolUse", "{runner}: {v}");
1066 assert_eq!(call.cue, "git push -f", "{runner}: {v}");
1067 assert_eq!(call.session.as_deref(), Some("t1"), "{runner}: {v}");
1068 assert_eq!(
1069 crate::hook_directory(&v.to_string()).unwrap(),
1070 std::fs::canonicalize("/tmp").unwrap(),
1071 "{runner}"
1072 );
1073 }
1074 let prompt = payload(
1075 "gemini",
1076 json!({"hook_event_name": "BeforeAgent", "session_id": "t", "prompt": "fix the build"}),
1077 );
1078 let call = crate::hook_call(&prompt.to_string());
1079 assert_eq!(
1080 (call.event.as_str(), call.cue.as_str()),
1081 ("UserPromptSubmit", "fix the build")
1082 );
1083 let prompt = payload(
1084 "cline",
1085 json!({"hookName": "UserPromptSubmit", "taskId": "t", "userPromptSubmit": {"prompt": "fix it"}}),
1086 );
1087 assert_eq!(
1088 crate::hook_call(&prompt.to_string()).event,
1089 "UserPromptSubmit"
1090 );
1091 }
1092
1093 #[test]
1094 fn a_deny_reaches_each_runner_in_its_own_contract() {
1095 let (out, _, code) = answer("copilot", "PreToolUse", DENY);
1096 let v: Value = serde_json::from_str(&out).unwrap();
1097 assert_eq!((v["permissionDecision"].as_str(), code), (Some("deny"), 0));
1098 assert!(v["permissionDecisionReason"]
1099 .as_str()
1100 .unwrap()
1101 .contains("force push"));
1102
1103 let (out, _, code) = answer("gemini", "BeforeTool", DENY);
1104 let v: Value = serde_json::from_str(&out).unwrap();
1105 assert_eq!((v["decision"].as_str(), code), (Some("deny"), 0));
1106 assert!(v["reason"].as_str().unwrap().contains("force push"));
1107
1108 for runner in ["windsurf", "kiro"] {
1109 let (out, err, code) = answer(runner, "PreToolUse", DENY);
1110 assert_eq!((out.as_str(), code), ("", 2), "{runner}");
1111 assert!(err.contains("force push"), "{runner}");
1112 }
1113
1114 let (out, _, _) = answer("cline", "PreToolUse", DENY);
1115 let v: Value = serde_json::from_str(&out).unwrap();
1116 assert_eq!(v["cancel"], json!(true));
1117 assert!(v["errorMessage"].as_str().unwrap().contains("force push"));
1118
1119 let (out, _, _) = answer("crush", "PreToolUse", DENY);
1120 let v: Value = serde_json::from_str(&out).unwrap();
1121 assert_eq!(v["decision"], json!("deny"));
1122
1123 for runner in ["factory", "qwen"] {
1124 let (out, _, code) = answer(runner, "PreToolUse", DENY);
1125 assert_eq!(
1126 (out.as_str(), code),
1127 (DENY, 0),
1128 "{runner} takes Claude Code's shape"
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn an_ask_is_a_deny_where_the_runner_cannot_ask() {
1135 let (out, _, _) = answer("copilot", "PreToolUse", ASK);
1136 assert!(out.contains("\"ask\""));
1137 for runner in ["gemini", "cline", "crush"] {
1138 let (out, _, _) = answer(runner, "PreToolUse", ASK);
1139 assert!(!out.contains("\"ask\""), "{runner}: {out}");
1140 assert!(out.contains("cannot ask"), "{runner}: {out}");
1141 }
1142 let (_, err, code) = answer("windsurf", "PreToolUse", ASK);
1143 assert_eq!(code, 2);
1144 assert!(err.contains("ask the person"));
1145 }
1146
1147 #[test]
1148 fn an_allow_says_nothing_and_a_note_reaches_runners_that_take_one() {
1149 for runner in ["copilot", "windsurf", "kiro", "crush"] {
1150 assert_eq!(
1151 answer(runner, "PreToolUse", ""),
1152 (String::new(), String::new(), 0)
1153 );
1154 }
1155 let (out, _, code) = answer("cline", "PreToolUse", "");
1156 assert_eq!(
1157 serde_json::from_str::<Value>(&out).unwrap(),
1158 json!({"cancel": false})
1159 );
1160 assert_eq!(code, 0);
1161 let (out, _, _) = answer("gemini", "BeforeAgent", NOTE);
1162 let v: Value = serde_json::from_str(&out).unwrap();
1163 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "BeforeAgent");
1164 assert_eq!(
1165 v["hookSpecificOutput"]["additionalContext"],
1166 "lesson: run the tests"
1167 );
1168 let (out, _, _) = answer("cline", "UserPromptSubmit", NOTE);
1169 assert!(out.contains("contextModification"));
1170 }
1171
1172 #[cfg(unix)]
1173 fn sh(script: &str) -> std::process::Command {
1174 let mut cmd = std::process::Command::new("sh");
1175 cmd.arg("-c").arg(script);
1176 cmd
1177 }
1178
1179 #[cfg(unix)]
1180 fn inner(script: &str, timeout_ms: u64) -> String {
1181 run_inner(
1182 sh(script),
1183 r#"{"tool_name":"Bash"}"#,
1184 "PreToolUse",
1185 std::time::Duration::from_millis(timeout_ms),
1186 )
1187 }
1188
1189 fn shows_context(runner: &str) -> bool {
1192 matches!(
1193 runner,
1194 "gemini" | "openhands" | "auggie" | "kiro" | "cline" | "crush" | "factory" | "qwen"
1195 )
1196 }
1197
1198 fn allows_unhealthy(out: &str, why: &str) {
1199 let said = read_answer(out);
1200 assert!(said.decision.is_none(), "{out}");
1201 assert!(out.contains("unhealthy"), "{out}");
1202 assert!(out.contains(why), "{out}");
1203 assert!(!out.contains("refused unchecked"), "{out}");
1204 for runner in RUNNERS {
1205 let (stdout, stderr, code) = answer(runner, "PreToolUse", out);
1206 let body = format!("{stdout}{stderr}");
1207 assert_eq!(code, 0, "{runner}: {body}");
1208 assert!(
1209 !body.contains("\"deny\"") && !body.contains("\"cancel\":true"),
1210 "{runner}: {body}"
1211 );
1212 if shows_context(runner) {
1213 assert!(body.contains("unhealthy"), "{runner}: {body}");
1214 assert!(body.contains(why), "{runner}: {body}");
1215 }
1216 }
1217 }
1218
1219 #[cfg(unix)]
1220 #[test]
1221 fn an_inner_hook_that_fails_allows_and_says_the_guard_is_unhealthy() {
1222 let timeout = std::time::Duration::from_secs(5);
1226 let injected = [
1227 format!("gave no answer within {} s", timeout.as_secs_f32()),
1228 "exited with status 3".to_string(),
1229 "was killed by a signal".to_string(),
1230 ];
1231 for why in &injected {
1232 allows_unhealthy(&fault_output("PreToolUse", why), why);
1233 }
1234 allows_unhealthy(&inner("exit 3", 30_000), "exited with status 3");
1235 let missing = run_inner(
1236 std::process::Command::new("/nonexistent/ljos"),
1237 "{}",
1238 "PreToolUse",
1239 std::time::Duration::from_secs(1),
1240 );
1241 allows_unhealthy(&missing, "did not start");
1242 assert_eq!(INNER_TIMEOUT, crate::GUARD_TIMEOUT);
1243 }
1244
1245 #[cfg(unix)]
1246 #[test]
1247 fn an_inner_hook_that_answers_keeps_its_answer() {
1248 assert_eq!(inner("true", 30_000), "", "a clean allow");
1249 assert_eq!(inner(&format!("printf '%s' '{DENY}'"), 30_000), DENY);
1250 assert_eq!(
1251 inner(&format!("printf '%s' '{DENY}'; exit 1"), 30_000),
1252 DENY,
1253 "a deny printed before a non-zero exit stands"
1254 );
1255 let note = inner(&format!("printf '%s' '{NOTE}'; exit 1"), 30_000);
1256 assert!(read_answer(¬e).decision.is_none(), "{note}");
1257 assert!(note.contains("unhealthy"), "{note}");
1258 assert!(note.contains("exited with status 1"), "{note}");
1259 }
1260
1261 #[test]
1262 fn a_known_dangerous_command_is_judged_before_the_inner_hook() {
1263 use std::os::unix::fs::PermissionsExt;
1264 let _lock = crate::test_env_lock();
1265 let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1266 .join("../target")
1267 .join(format!("ljos-early-{}", std::process::id()));
1268 let _ = std::fs::remove_dir_all(&dir);
1269 std::fs::create_dir_all(&dir).unwrap();
1270 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1271 let keys = [
1272 "HOME",
1273 "XDG_DATA_HOME",
1274 "LJOS_OFF",
1275 "POLICYD_BIN",
1276 "PACKSET_WORKSPACE",
1277 ];
1278 struct RestoreEnv(Vec<(String, Option<std::ffi::OsString>)>);
1279 impl Drop for RestoreEnv {
1280 fn drop(&mut self) {
1281 for (k, v) in &self.0 {
1282 unsafe {
1283 match v {
1284 Some(v) => std::env::set_var(k, v),
1285 None => std::env::remove_var(k),
1286 }
1287 }
1288 }
1289 }
1290 }
1291 let saved = RestoreEnv(
1292 keys.iter()
1293 .map(|k| ((*k).to_string(), std::env::var_os(k)))
1294 .collect(),
1295 );
1296 let data = dir.join("data");
1297 unsafe {
1298 std::env::set_var("HOME", &dir);
1299 std::env::set_var("XDG_DATA_HOME", &data);
1300 std::env::set_var("LJOS_OFF", "0");
1301 std::env::set_var("POLICYD_BIN", dir.join("does-not-run"));
1302 std::env::set_var("PACKSET_WORKSPACE", "early-guard");
1303 }
1304 let force = json!({"tool_input": {"command": "git push --force origin main"}}).to_string();
1305 let started = std::time::Instant::now();
1306 let refused =
1307 early_answer(&force, "PreToolUse").expect("a push without evidence is refused");
1308 let plain = json!({"tool_input": {"command": "git status"}}).to_string();
1309 assert!(
1310 early_answer(&plain, "").is_none(),
1311 "git status is not dangerous"
1312 );
1313 let rm = json!({"tool_input": {"command": "rm -rf /home/u/proj"}}).to_string();
1314 let denied = early_answer(&rm, "PreToolUse").expect("rm -rf denies");
1315 assert!(
1316 started.elapsed() < std::time::Duration::from_secs(5),
1317 "the static check waited on the guard"
1318 );
1319 assert_eq!(
1320 read_answer(&refused).decision.as_deref(),
1321 Some("deny"),
1322 "{refused}"
1323 );
1324 assert!(refused.contains("git-push-evidence"), "{refused}");
1325 assert_eq!(
1326 read_answer(&denied).decision.as_deref(),
1327 Some("deny"),
1328 "{denied}"
1329 );
1330 assert!(denied.contains("rm-rf-outside-tmp"), "{denied}");
1331 let before = early_answer(&force, "BeforeTool").expect("BeforeTool is still a gate");
1335 assert_eq!(
1336 read_answer(&before).decision.as_deref(),
1337 Some("deny"),
1338 "{before}"
1339 );
1340 let (out, _, code) = answer("gemini", "BeforeTool", &before);
1341 assert_eq!(code, 0);
1342 assert!(out.contains("\"deny\""), "{out}");
1343 assert!(!out.contains("\"ask\""), "{out}");
1344 let wind =
1345 early_answer(&force, "pre_run_command").expect("pre_run_command is still a gate");
1346 assert_eq!(
1347 read_answer(&wind).decision.as_deref(),
1348 Some("deny"),
1349 "{wind}"
1350 );
1351 drop(saved);
1352 let _ = std::fs::remove_dir_all(&dir);
1353 }
1354
1355 #[test]
1356 fn hook_files_are_written_in_each_runner_shape_and_never_twice() {
1357 let tmp = tempfile::tempdir().unwrap();
1358 let dir = tmp.path().to_path_buf();
1359 let base = "/opt/bin/ljos hook";
1360 for runner in RUNNERS {
1361 let file = if *runner == "cline" {
1362 dir.join("cline-hooks")
1363 } else {
1364 dir.join(format!("{runner}.json"))
1365 };
1366 if *runner == "gemini" {
1367 std::fs::write(&file, r#"{"mcpServers":{"x":{}},"hooks":{"BeforeTool":[{"matcher":"x","hooks":[]}]}}"#).unwrap();
1369 }
1370 let first = hook_step(runner, &file, base, false);
1371 assert!(first.ok, "{runner}: {}", first.detail);
1372 assert!(first.detail.contains("wrote"), "{runner}: {}", first.detail);
1373 let again = hook_step(runner, &file, base, false);
1374 assert!(
1375 again.ok && again.detail.contains("carries"),
1376 "{runner}: {}",
1377 again.detail
1378 );
1379 let command = format!("{base} --runner {runner}");
1380 if *runner == "cline" {
1381 for event in ["PreToolUse", "UserPromptSubmit"] {
1382 let text = std::fs::read_to_string(file.join(event)).unwrap();
1383 assert!(text.contains(&command));
1384 }
1385 continue;
1386 }
1387 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1388 let found = |p: &str| v.pointer(p).cloned().unwrap_or(Value::Null);
1389 match *runner {
1390 "copilot" => {
1391 assert_eq!(found("/version"), json!(1));
1392 assert_eq!(found("/hooks/PreToolUse/0/bash"), json!(command));
1393 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("Bash"));
1394 }
1395 "gemini" => {
1396 assert_eq!(found("/mcpServers/x"), json!({}));
1397 assert_eq!(found("/hooks/BeforeTool/0/matcher"), json!("x"));
1398 assert_eq!(
1399 found("/hooks/BeforeTool/1/matcher"),
1400 json!("run_shell_command")
1401 );
1402 assert_eq!(
1403 found("/hooks/BeforeTool/1/hooks/0/timeout"),
1404 json!(20_000),
1405 "milliseconds"
1406 );
1407 assert_eq!(
1408 found("/hooks/BeforeAgent/0/hooks/0/command"),
1409 json!(command)
1410 );
1411 }
1412 "qwen" => {
1413 assert_eq!(
1414 found("/hooks/PreToolUse/0/matcher"),
1415 json!("^run_shell_command$")
1416 );
1417 assert_eq!(
1418 found("/hooks/PreToolUse/0/hooks/0/timeout"),
1419 json!(20),
1420 "seconds"
1421 );
1422 }
1423 "factory" => {
1424 assert_eq!(found("/PreToolUse/0/matcher"), json!("Execute"));
1425 assert_eq!(found("/PreToolUse/0/hooks/0/command"), json!(command));
1426 }
1427 "windsurf" => {
1428 assert_eq!(found("/hooks/pre_run_command/0/command"), json!(command));
1429 }
1430 "kiro" => {
1431 assert_eq!(found("/version"), json!("v1"));
1432 assert_eq!(found("/hooks/0/trigger"), json!("PreToolUse"));
1433 assert_eq!(found("/hooks/0/action/command"), json!(command));
1434 }
1435 "crush" => {
1436 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("^bash$"));
1437 assert_eq!(found("/hooks/PreToolUse/0/command"), json!(command));
1438 }
1439 "goose" => {
1440 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(command));
1441 }
1442 "auggie" => {
1443 assert_eq!(
1444 found("/hooks/PreToolUse/0/matcher"),
1445 json!("launch-process")
1446 );
1447 let script = script_path("auggie", &file).display().to_string();
1448 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(script));
1449 }
1450 "amp" => {
1451 assert_eq!(found("/amp.permissions/0/action"), json!("delegate"));
1452 }
1453 "openhands" => {
1454 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("terminal"));
1455 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(command));
1456 }
1457 _ => unreachable!(),
1458 }
1459 }
1460 let foreign = dir.join("foreign-cline");
1461 std::fs::create_dir_all(&foreign).unwrap();
1462 std::fs::write(foreign.join("PreToolUse"), "#!/bin/sh\necho mine\n").unwrap();
1463 let step = hook_step("cline", &foreign, base, false);
1464 assert!(
1465 !step.ok && step.detail.contains("someone else's"),
1466 "{}",
1467 step.detail
1468 );
1469 }
1470
1471 #[test]
1472 fn goose_payload_reaches_the_seat_and_a_deny_exits_2() {
1473 let n = normalize_input(
1474 "goose",
1475 r#"{"event":"PreToolUse","session_id":"g-1","tool_name":"shell","tool_input":{"command":"rm -rf /"},"working_dir":"/r"}"#,
1476 );
1477 let v: Value = serde_json::from_str(&n).unwrap();
1478 assert_eq!(v["hook_event_name"], "PreToolUse");
1479 assert_eq!(v["tool_input"]["command"], "rm -rf /");
1480 assert_eq!(v["cwd"], "/r");
1481 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1482 assert_eq!(
1483 answer("goose", "PreToolUse", deny),
1484 (String::new(), "no\n".into(), 2)
1485 );
1486 assert_eq!(answer("goose", "PreToolUse", "{}").2, 0);
1487 }
1488
1489 #[test]
1490 fn amp_reads_cmd_and_rejects_with_2_and_asks_with_1() {
1491 let n = normalize_input("amp", r#"{"cmd":"git push --force"}"#);
1492 let v: Value = serde_json::from_str(&n).unwrap();
1493 assert_eq!(v["tool_input"]["command"], "git push --force");
1494 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1495 assert_eq!(answer("amp", "", deny).2, 2);
1496 let ask = r#"{"hookSpecificOutput":{"permissionDecision":"ask","permissionDecisionReason":"sure?"}}"#;
1497 assert_eq!(answer("amp", "", ask).2, 1);
1498 assert_eq!(answer("amp", "", "").2, 0);
1499 }
1500
1501 #[test]
1502 fn auggie_launch_process_is_the_shell_and_its_deny_is_claude_shaped() {
1503 let n = normalize_input(
1504 "auggie",
1505 r#"{"hook_event_name":"PreToolUse","tool_name":"launch-process","tool_input":{"command":"ls"}}"#,
1506 );
1507 let v: Value = serde_json::from_str(&n).unwrap();
1508 assert_eq!(v["tool_name"], "Bash");
1509 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1510 let (out, _, code) = answer("auggie", "PreToolUse", deny);
1511 let o: Value = serde_json::from_str(&out).unwrap();
1512 assert_eq!(o["hookSpecificOutput"]["permissionDecision"], "deny");
1513 assert_eq!(code, 0);
1514 }
1515
1516 #[test]
1517 fn auggie_and_amp_hooks_name_a_script_and_are_written_once() {
1518 for (runner, name) in [("auggie", "settings.json"), ("amp", "settings.json")] {
1519 let dir = tempfile::tempdir().unwrap();
1520 let file = dir.path().join(name);
1521 std::fs::write(
1522 &file,
1523 r#"{"keep": 1, "amp.permissions": [{"tool": "Read", "action": "allow"}]}"#,
1524 )
1525 .unwrap();
1526 let step = hook_step(runner, &file, "/bin/ljos hook", false);
1527 assert!(step.ok, "{}", step.detail);
1528 let script = script_path(runner, &file);
1529 let text = std::fs::read_to_string(&script).unwrap();
1530 assert!(text.contains(&format!("/bin/ljos hook --runner {runner}")));
1531 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1532 assert_eq!(v["keep"], 1);
1533 assert!(v.to_string().contains(&script.display().to_string()));
1534 if runner == "amp" {
1535 assert_eq!(v["amp.permissions"][0]["action"], "delegate");
1536 assert_eq!(v["amp.permissions"][1]["tool"], "Read");
1537 }
1538 let again = hook_step(runner, &file, "/bin/ljos hook", false);
1539 assert!(again.detail.contains("carries"), "{}", again.detail);
1540 }
1541 }
1542
1543 #[test]
1544 fn goose_hook_is_a_plugin_that_fails_closed() {
1545 let dir = tempfile::tempdir().unwrap();
1546 let file = dir.path().join("ljos/hooks/hooks.json");
1547 let step = hook_step("goose", &file, "/bin/ljos hook", false);
1548 assert!(step.ok, "{}", step.detail);
1549 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1550 assert_eq!(
1551 v["hooks"]["PreToolUse"][0]["hooks"][0]["on_failure"],
1552 "block"
1553 );
1554 assert!(dir.path().join("ljos/plugin.json").is_file());
1555 assert!(hook_step("goose", &file, "/bin/ljos hook", false)
1556 .detail
1557 .contains("carries"));
1558 }
1559
1560 #[test]
1561 fn openhands_terminal_is_the_shell_and_a_deny_exits_2() {
1562 let n = normalize_input(
1563 "openhands",
1564 r#"{"event_type":"PreToolUse","tool_name":"terminal","tool_input":{"command":"rm -rf ~"},"working_dir":"/w"}"#,
1565 );
1566 let v: Value = serde_json::from_str(&n).unwrap();
1567 assert_eq!(v["tool_name"], "Bash");
1568 assert_eq!(v["cwd"], "/w");
1569 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1570 let (out, _, code) = answer("openhands", "PreToolUse", deny);
1571 assert!(out.contains("\"decision\":\"deny\""));
1572 assert_eq!(code, 2);
1573 }
1574
1575 #[test]
1576 fn the_start_line_leads_an_openhands_prompt_note() {
1577 let out = with_start_line("{\"additionalContext\":\"lesson\"}\n", "ljos is on: x");
1578 let v: Value = serde_json::from_str(&out).unwrap();
1579 assert_eq!(v["additionalContext"], "ljos is on: x\n\nlesson");
1580 assert_eq!(
1581 with_start_line("", "ljos is on").trim(),
1582 r#"{"additionalContext":"ljos is on"}"#
1583 );
1584 }
1585}