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
33#[derive(Clone, Copy)]
34struct RunnerContract {
35 asks: bool,
36 deny_by_exit: bool,
37}
38
39const RUNNER_CONTRACTS: &[(&str, RunnerContract)] = &[
40 (
41 "copilot",
42 RunnerContract {
43 asks: true,
44 deny_by_exit: false,
45 },
46 ),
47 (
48 "gemini",
49 RunnerContract {
50 asks: false,
51 deny_by_exit: false,
52 },
53 ),
54 (
55 "windsurf",
56 RunnerContract {
57 asks: false,
58 deny_by_exit: true,
59 },
60 ),
61 (
62 "factory",
63 RunnerContract {
64 asks: true,
65 deny_by_exit: true,
66 },
67 ),
68 (
69 "kiro",
70 RunnerContract {
71 asks: false,
72 deny_by_exit: true,
73 },
74 ),
75 (
76 "cline",
77 RunnerContract {
78 asks: false,
79 deny_by_exit: false,
80 },
81 ),
82 (
83 "qwen",
84 RunnerContract {
85 asks: true,
86 deny_by_exit: true,
87 },
88 ),
89 (
90 "crush",
91 RunnerContract {
92 asks: false,
93 deny_by_exit: true,
94 },
95 ),
96 (
97 "auggie",
98 RunnerContract {
99 asks: false,
100 deny_by_exit: false,
101 },
102 ),
103 (
104 "goose",
105 RunnerContract {
106 asks: false,
107 deny_by_exit: true,
108 },
109 ),
110 (
111 "amp",
112 RunnerContract {
113 asks: true,
114 deny_by_exit: true,
115 },
116 ),
117 (
118 "openhands",
119 RunnerContract {
120 asks: false,
121 deny_by_exit: true,
122 },
123 ),
124];
125
126fn contract(runner: &str) -> RunnerContract {
127 RUNNER_CONTRACTS
128 .iter()
129 .find_map(|(name, contract)| (*name == runner).then_some(*contract))
130 .unwrap_or(RunnerContract {
131 asks: false,
132 deny_by_exit: false,
133 })
134}
135
136fn needs_script(runner: &str) -> bool {
140 matches!(runner, "auggie" | "amp")
141}
142
143#[must_use]
145pub fn script_path(runner: &str, file: &Path) -> std::path::PathBuf {
146 file.parent()
147 .unwrap_or_else(|| Path::new("."))
148 .join(format!("ljos-guard-{runner}.sh"))
149}
150
151#[must_use]
153pub fn is_runner(name: &str) -> bool {
154 RUNNERS.contains(&name)
155}
156
157fn asks(runner: &str) -> bool {
159 contract(runner).asks
160}
161
162fn reason_stderr(reason: &str) -> String {
163 if reason.trim().is_empty() {
164 "ljos denied this command.\n".to_string()
165 } else {
166 format!("{reason}\n")
167 }
168}
169
170fn gemini_event(raw: &str) -> &str {
172 match raw {
173 "BeforeTool" => "PreToolUse",
174 "AfterTool" => "PostToolUse",
175 "BeforeAgent" => "UserPromptSubmit",
176 other => other,
177 }
178}
179
180fn command_of(args: &Value) -> Option<String> {
183 if let Some(c) = args["command"].as_str() {
184 return Some(c.to_string());
185 }
186 args["commands"].as_array().map(|list| {
187 list.iter()
188 .filter_map(Value::as_str)
189 .collect::<Vec<_>>()
190 .join("\n")
191 })
192}
193
194#[must_use]
199pub fn normalize_input(runner: &str, input: &str) -> String {
200 let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
201 return input.to_string();
202 };
203 let mut out = Map::new();
204 let put = |out: &mut Map<String, Value>, k: &str, val: Option<&str>| {
205 if let Some(s) = val.filter(|s| !s.is_empty()) {
206 out.insert(k.into(), Value::String(s.to_string()));
207 }
208 };
209 match runner {
210 "windsurf" => {
211 let action = v["agent_action_name"].as_str().unwrap_or("");
212 let info = &v["tool_info"];
213 let event = match action {
214 "pre_run_command" => "PreToolUse",
215 "post_run_command" => "PostToolUse",
216 "pre_user_prompt" => "UserPromptSubmit",
217 other => other,
218 };
219 out.insert("hook_event_name".into(), json!(event));
220 put(&mut out, "session_id", v["trajectory_id"].as_str());
221 put(&mut out, "cwd", info["cwd"].as_str());
222 if let Some(line) = info["command_line"].as_str() {
223 out.insert("tool_name".into(), json!("Bash"));
224 out.insert("tool_input".into(), json!({ "command": line }));
225 }
226 put(&mut out, "prompt", info["user_prompt"].as_str());
227 }
228 "cline" => {
229 put(&mut out, "session_id", v["taskId"].as_str());
230 put(&mut out, "cwd", v["workspaceRoots"][0].as_str());
231 if let Some(pre) = v.get("preToolUse").filter(|p| p.is_object()) {
232 let tool = pre["toolName"].as_str().or_else(|| pre["tool"].as_str());
233 out.insert("hook_event_name".into(), json!("PreToolUse"));
234 put(&mut out, "tool_name", tool);
235 let args = &pre["parameters"];
236 match command_of(args) {
237 Some(c) => {
238 out.insert("tool_input".into(), json!({ "command": c }));
239 }
240 None => {
241 out.insert("tool_input".into(), args.clone());
242 }
243 }
244 } else if let Some(call) = v.get("tool_call").filter(|p| p.is_object()) {
245 out.insert("hook_event_name".into(), json!("PreToolUse"));
246 put(&mut out, "tool_name", call["name"].as_str());
247 let args = &call["input"];
248 match command_of(args) {
249 Some(c) => {
250 out.insert("tool_input".into(), json!({ "command": c }));
251 }
252 None => {
253 out.insert("tool_input".into(), args.clone());
254 }
255 }
256 } else if let Some(p) = v["userPromptSubmit"]["prompt"].as_str() {
257 out.insert("hook_event_name".into(), json!("UserPromptSubmit"));
258 out.insert("prompt".into(), json!(p));
259 } else {
260 return input.to_string();
261 }
262 }
263 "copilot" if v.get("toolName").is_some() || v.get("sessionId").is_some() => {
264 let event = if v.get("toolName").is_some() {
266 "PreToolUse"
267 } else if v.get("prompt").is_some() {
268 "UserPromptSubmit"
269 } else {
270 "SessionStart"
271 };
272 out.insert("hook_event_name".into(), json!(event));
273 put(&mut out, "session_id", v["sessionId"].as_str());
274 put(&mut out, "cwd", v["cwd"].as_str());
275 put(&mut out, "tool_name", v["toolName"].as_str());
276 put(&mut out, "prompt", v["prompt"].as_str());
277 let args = match &v["toolArgs"] {
278 Value::String(s) => serde_json::from_str(s).unwrap_or(json!({ "command": s })),
279 other => other.clone(),
280 };
281 if !args.is_null() {
282 out.insert("tool_input".into(), args);
283 }
284 }
285 "gemini" => {
286 let mut v = v;
287 if let Some(e) = v["hook_event_name"].as_str() {
288 let seat = gemini_event(e).to_string();
289 v["hook_event_name"] = Value::String(seat);
290 }
291 return v.to_string();
292 }
293 "goose" => {
294 let event = v["event"].as_str().unwrap_or("PreToolUse");
295 out.insert("hook_event_name".into(), json!(event));
296 put(&mut out, "session_id", v["session_id"].as_str());
297 put(&mut out, "cwd", v["working_dir"].as_str());
298 put(&mut out, "tool_name", v["tool_name"].as_str());
299 put(&mut out, "prompt", v["message"].as_str());
300 if !v["tool_input"].is_null() {
301 out.insert("tool_input".into(), v["tool_input"].clone());
302 }
303 }
304 "openhands" => {
305 let event = v["event_type"].as_str().unwrap_or("PreToolUse");
306 out.insert("hook_event_name".into(), json!(event));
307 put(&mut out, "session_id", v["session_id"].as_str());
308 put(&mut out, "cwd", v["working_dir"].as_str());
309 put(&mut out, "prompt", v["message"].as_str());
310 let tool = v["tool_name"]
311 .as_str()
312 .map(|t| if t == "terminal" { "Bash" } else { t });
313 put(&mut out, "tool_name", tool);
314 if !v["tool_input"].is_null() {
315 out.insert("tool_input".into(), v["tool_input"].clone());
316 }
317 }
318 "auggie" => {
319 let mut v = v;
320 if v["tool_name"].as_str() == Some("launch-process") {
321 v["tool_name"] = json!("Bash");
322 }
323 return v.to_string();
324 }
325 "amp" => {
326 let tool = std::env::var("AGENT_TOOL_NAME").unwrap_or_else(|_| "Bash".into());
329 out.insert("hook_event_name".into(), json!("PreToolUse"));
330 out.insert("tool_name".into(), json!(tool));
331 if let Ok(t) = std::env::var("AMP_THREAD_ID") {
332 put(&mut out, "session_id", Some(&t));
333 }
334 put(&mut out, "cwd", v["cwd"].as_str());
335 match v["cmd"]
336 .as_str()
337 .map(str::to_string)
338 .or_else(|| command_of(&v))
339 {
340 Some(c) => {
341 out.insert("tool_input".into(), json!({ "command": c }));
342 }
343 None => {
344 out.insert("tool_input".into(), v.clone());
345 }
346 }
347 }
348 "crush" if v.get("hook_event_name").is_none() => {
349 let mut v = v;
350 let event = v["event"].as_str().unwrap_or("PreToolUse").to_string();
351 v["hook_event_name"] = Value::String(event);
352 return v.to_string();
353 }
354 _ => return input.to_string(),
355 }
356 Value::Object(out).to_string()
357}
358
359#[derive(Debug, Default, PartialEq, Eq)]
361struct Said {
362 decision: Option<String>,
363 reason: String,
364 context: String,
365}
366
367fn read_answer(out: &str) -> Said {
368 let trimmed = out.trim();
369 let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
370 if trimmed.starts_with("deny:") {
372 return Said {
373 decision: Some("deny".into()),
374 reason: trimmed.trim_start_matches("deny:").trim().to_string(),
375 context: String::new(),
376 };
377 }
378 return Said {
379 context: trimmed.to_string(),
380 ..Said::default()
381 };
382 };
383 let specific = &v["hookSpecificOutput"];
384 let decision = specific["permissionDecision"]
385 .as_str()
386 .or_else(|| v["decision"].as_str())
387 .map(|d| if d == "block" { "deny" } else { d })
388 .filter(|d| matches!(*d, "deny" | "ask"))
389 .map(str::to_string);
390 let reason = specific["permissionDecisionReason"]
391 .as_str()
392 .or_else(|| v["reason"].as_str())
393 .unwrap_or("")
394 .to_string();
395 let context = specific["additionalContext"]
396 .as_str()
397 .or_else(|| v["context"].as_str())
398 .unwrap_or("")
399 .to_string();
400 Said {
401 decision,
402 reason,
403 context,
404 }
405}
406
407#[must_use]
411pub fn answer(runner: &str, event: &str, out: &str) -> (String, String, i32) {
412 let mut said = read_answer(out);
413 if said.decision.as_deref() == Some("ask") && !asks(runner) {
414 said.decision = Some("deny".into());
415 said.reason = format!(
416 "ask the person before running this: {}. This runner cannot ask, so retrying \
417 returns this same refusal: stop, tell the person the exact command, and leave \
418 it for them to run.",
419 said.reason
420 );
421 }
422 let line = |v: Value| v.to_string() + "\n";
423 let reason = said.reason.clone();
424 let context = said.context.clone();
425 let mut rendered = match (runner, said.decision.as_deref()) {
426 ("factory" | "qwen", _) => (out.to_string(), String::new(), 0),
427 ("copilot", Some(d)) => (
428 line(json!({
429 "permissionDecision": d,
430 "permissionDecisionReason": reason.clone(),
431 })),
432 String::new(),
433 0,
434 ),
435 ("copilot", None) => (String::new(), String::new(), 0),
436 ("gemini", d) => {
437 let mut v = Map::new();
438 if let Some(d) = d {
439 v.insert("decision".into(), json!(d));
440 v.insert("reason".into(), json!(reason.clone()));
441 }
442 if !context.is_empty() {
443 v.insert(
444 "hookSpecificOutput".into(),
445 json!({ "hookEventName": event, "additionalContext": context }),
446 );
447 }
448 (line(Value::Object(v)), String::new(), 0)
449 }
450 ("windsurf" | "kiro" | "goose", Some(_)) => (String::new(), reason_stderr(&reason), 2),
451 ("goose", None) => (String::new(), String::new(), 0),
452 ("openhands", Some(_)) => (
453 line(json!({ "decision": "deny", "reason": reason.clone() })),
454 reason_stderr(&reason),
455 2,
456 ),
457 ("openhands", None) if !context.is_empty() => (
458 line(json!({ "additionalContext": context })),
459 String::new(),
460 0,
461 ),
462 ("amp", Some("ask")) => (String::new(), reason_stderr(&reason), 1),
463 ("amp", Some(_)) => (String::new(), reason_stderr(&reason), 2),
464 ("amp", None) => (String::new(), String::new(), 0),
465 ("auggie", Some(d)) => (
466 line(json!({ "hookSpecificOutput": {
467 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
468 "permissionDecision": d,
469 "permissionDecisionReason": reason.clone(),
470 }})),
471 String::new(),
472 0,
473 ),
474 ("auggie", None) if !context.is_empty() => (
475 line(json!({ "hookSpecificOutput": {
476 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
477 "additionalContext": context,
478 }})),
479 String::new(),
480 0,
481 ),
482 ("kiro", None) if !context.is_empty() => (context + "\n", String::new(), 0),
483 ("windsurf" | "kiro", None) => (String::new(), String::new(), 0),
484 ("cline", Some(_)) => (
485 line(json!({ "cancel": true, "errorMessage": reason.clone() })),
486 String::new(),
487 0,
488 ),
489 ("cline", None) => {
490 let mut v = json!({ "cancel": false });
491 if !context.is_empty() {
492 v["contextModification"] = json!(context);
493 }
494 (line(v), String::new(), 0)
495 }
496 ("crush", Some(d)) => (
497 line(json!({ "decision": d, "reason": reason.clone() })),
498 String::new(),
499 0,
500 ),
501 ("crush", None) if !context.is_empty() => {
502 (line(json!({ "context": context })), String::new(), 0)
503 }
504 _ => (String::new(), String::new(), 0),
505 };
506 if said.decision.as_deref() == Some("deny") && contract(runner).deny_by_exit {
507 rendered.2 = 2;
508 if rendered.1.trim().is_empty() {
509 rendered.1 = reason_stderr(&reason);
510 }
511 }
512 rendered
513}
514
515#[must_use]
517pub fn raw_event(input: &str) -> String {
518 serde_json::from_str::<Value>(input.trim())
519 .ok()
520 .and_then(|v| {
521 [
522 "hook_event_name",
523 "agent_action_name",
524 "hookName",
525 "event",
526 "event_type",
527 ]
528 .iter()
529 .find_map(|k| v[*k].as_str().map(str::to_string))
530 })
531 .unwrap_or_default()
532}
533
534pub const INNER_TIMEOUT: std::time::Duration = crate::GUARD_TIMEOUT;
539
540fn seat_event(event: &str) -> &str {
545 match event {
546 ""
547 | "BeforeTool"
548 | "pre_run_command"
549 | "tool_call"
550 | "pre_tool_use"
551 | "preToolUse"
552 | "beforeShellExecution" => "PreToolUse",
553 "BeforeAgent" | "pre_user_prompt" | "beforeSubmitPrompt" | "pre_llm_call" => {
554 "UserPromptSubmit"
555 }
556 "AfterTool" | "post_run_command" | "post_tool_use" | "postToolUse" => "PostToolUse",
557 other => other,
558 }
559}
560
561fn gate_event(event: &str) -> bool {
562 crate::is_gate_event(seat_event(event))
563}
564
565#[must_use]
571pub fn relay_timeout(event: &str) -> std::time::Duration {
572 if gate_event(event) {
573 crate::guard_timeout()
574 } else {
575 std::time::Duration::from_millis(crate::HOOK_DEADLINE_MS.saturating_add(1_000))
576 }
577}
578
579fn fault_output(event: &str, what: &str) -> String {
584 if !gate_event(event) || crate::guard_fail_open() {
585 return json!({
586 "hookSpecificOutput": {
587 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
588 "additionalContext": crate::unhealthy_note(what),
589 }
590 })
591 .to_string();
592 }
593 let reason = crate::guard_fault_reason(what);
594 json!({
595 "hookSpecificOutput": {
596 "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
597 "permissionDecision": "deny",
598 "permissionDecisionReason": reason,
599 }
600 })
601 .to_string()
602}
603
604#[must_use]
608pub fn relay_event_is_gate(event: Option<&str>) -> bool {
609 match event {
610 Some(name) if !name.is_empty() => gate_event(name),
611 _ => true,
612 }
613}
614
615#[must_use]
618pub fn relay_input_is_gate(input: &str, event: Option<&str>) -> bool {
619 let raw = raw_event(input);
620 if raw.is_empty() {
621 relay_event_is_gate(event)
622 } else {
623 gate_event(&raw)
624 }
625}
626
627#[must_use]
633pub fn relay_fault_deny(runner: &str, event: &str, what: &str) -> Option<(String, String, i32)> {
634 if !event.is_empty() && !gate_event(event) {
635 return None;
636 }
637 let what: String = what
638 .chars()
639 .map(|c| if c.is_control() { ' ' } else { c })
640 .take(240)
641 .collect();
642 let text = fault_output(event, &what);
643 Some(answer(runner, event, &text))
644}
645
646fn tool_command(normalized: &str) -> String {
648 serde_json::from_str::<Value>(normalized.trim())
649 .ok()
650 .and_then(|v| command_of(&v["tool_input"]))
651 .unwrap_or_default()
652}
653
654fn seat_tool_event(event: &str) -> &str {
662 match event {
663 "" | "BeforeTool" | "pre_run_command" => "PreToolUse",
664 other => other,
665 }
666}
667
668fn early_answer(normalized: &str, event: &str) -> Option<String> {
669 let line = tool_command(normalized);
670 let parsed = serde_json::from_str::<Value>(normalized.trim()).ok();
671 let cwd = parsed
672 .as_ref()
673 .and_then(|v| v["cwd"].as_str().map(std::path::PathBuf::from));
674 let rule = crate::static_guard_in(&line, cwd.as_deref())?;
675 let named = parsed
676 .as_ref()
677 .and_then(|v| v["hook_event_name"].as_str())
678 .filter(|name| !name.is_empty());
679 let event = seat_tool_event(named.unwrap_or(event));
680 let call = crate::HookCall {
681 event: event.to_string(),
682 cue: line,
683 session: None,
684 shape: crate::HookShape::CamelCase,
685 };
686 Some(crate::hook_output_ruled(&call, "", Some(&rule)))
687}
688
689#[must_use]
699pub fn run_inner(
700 mut cmd: std::process::Command,
701 stdin: &str,
702 event: &str,
703 timeout: std::time::Duration,
704) -> String {
705 use std::io::{Read, Write};
706 let spawned = cmd
707 .stdin(std::process::Stdio::piped())
708 .stdout(std::process::Stdio::piped())
709 .spawn();
710 let mut child = match spawned {
711 Ok(child) => child,
712 Err(e) => return fault_output(event, &format!("did not start ({e})")),
713 };
714 let input = stdin.to_string();
717 if let Some(mut pipe) = child.stdin.take() {
720 std::thread::spawn(move || {
721 let _ = pipe.write_all(input.as_bytes());
722 });
723 }
724 let reader = child.stdout.take().map(|mut pipe| {
725 std::thread::spawn(move || {
726 let mut buf = Vec::new();
727 let _ = pipe.read_to_end(&mut buf);
728 buf
729 })
730 });
731 let deadline = std::time::Instant::now() + timeout;
732 let status = loop {
733 match child.try_wait() {
734 Ok(Some(status)) => break Ok(status),
735 Ok(None) if std::time::Instant::now() >= deadline => {
736 let _ = child.kill();
737 let _ = child.wait();
738 break Err(format!("gave no answer within {} s", timeout.as_secs_f32()));
739 }
740 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
741 Err(e) => break Err(format!("could not be waited on ({e})")),
742 }
743 };
744 let status = match status {
745 Ok(status) => status,
746 Err(what) => return fault_output(event, &what),
749 };
750 let out = reader
751 .and_then(|r| r.join().ok())
752 .map(|b| String::from_utf8_lossy(&b).into_owned())
753 .unwrap_or_default();
754 let said = read_answer(&out);
755 if said.decision.is_some() || status.success() {
756 return out;
757 }
758 let what = match status.code() {
759 Some(code) => format!("exited with status {code}"),
760 None => "was killed by a signal".to_string(),
761 };
762 fault_output(event, &what)
763}
764
765pub fn relay(runner: &str, input: &str, limit: usize, event: Option<&str>) -> anyhow::Result<i32> {
774 let normalized = normalize_input(runner, input);
775 let raw = raw_event(input);
776 let text = match early_answer(&normalized, &raw) {
777 Some(text) => text,
778 None => match std::env::current_exe() {
779 Ok(exe) => {
780 let mut cmd = std::process::Command::new(exe);
781 cmd.arg("hook").arg("--limit").arg(limit.to_string());
782 if let Some(e) = event {
783 cmd.arg("--event").arg(e);
784 }
785 run_inner(cmd, &normalized, &raw, relay_timeout(&raw))
786 }
787 Err(e) => fault_output(&raw, &format!("could not find its own binary ({e})")),
788 },
789 };
790 let (mut out, err, code) = answer(runner, &raw, &text);
791 if let Some(line) = first_prompt_start_line(runner, &raw, input) {
792 out = with_start_line(&out, &line);
793 }
794 print!("{out}");
795 eprint!("{err}");
796 Ok(code)
797}
798
799fn first_prompt_start_line(runner: &str, raw: &str, input: &str) -> Option<String> {
803 if runner != "openhands" || raw != "UserPromptSubmit" {
804 return None;
805 }
806 let v: Value = serde_json::from_str(input.trim()).ok()?;
807 let sid: String = v["session_id"]
808 .as_str()?
809 .chars()
810 .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
811 .collect();
812 if sid.is_empty() {
813 return None;
814 }
815 let state = std::env::var_os("XDG_STATE_HOME")
816 .map(std::path::PathBuf::from)
817 .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/state")))?;
818 let mark = state.join("ljos/openhands-started").join(&sid);
819 if mark.exists() {
820 return None;
821 }
822 let _ = mark.parent().map(std::fs::create_dir_all);
823 let _ = std::fs::write(&mark, "");
824 Some(crate::session_start_for(Some(&sid)))
825}
826
827fn with_start_line(out: &str, line: &str) -> String {
829 let mut v: Value = serde_json::from_str(out.trim()).unwrap_or_else(|_| json!({}));
830 if v.get("decision").is_some() {
831 return out.to_string();
832 }
833 let rest = v["additionalContext"].as_str().unwrap_or("").to_string();
834 v["additionalContext"] = json!(if rest.is_empty() {
835 line.to_string()
836 } else {
837 format!("{line}\n\n{rest}")
838 });
839 v.to_string() + "\n"
840}
841
842#[must_use]
844pub fn runner_command(base: &str, runner: &str) -> String {
845 format!("{base} --runner {runner}")
846}
847
848fn ours(v: &Value, runner: &str) -> bool {
849 let t = v.to_string();
850 t.contains(&format!("--runner {runner}")) || t.contains(&format!("ljos-guard-{runner}.sh"))
851}
852
853fn ensure_in(obj: &mut Map<String, Value>, key: &str, item: Value, runner: &str) -> bool {
856 let list = obj.entry(key.to_string()).or_insert_with(|| json!([]));
857 let Some(list) = list.as_array_mut() else {
858 return false;
859 };
860 if list.iter().any(|g| ours(g, runner)) {
861 return false;
862 }
863 list.push(item);
864 true
865}
866
867fn group(matcher: Option<&str>, command: &str, timeout: u64, runner: &str) -> Value {
868 let mut hook = json!({ "type": "command", "command": command, "timeout": timeout });
869 if runner == "gemini" {
870 hook["name"] = json!("ljos");
871 }
872 let mut g = json!({ "hooks": [hook] });
873 if let Some(m) = matcher {
874 g["matcher"] = json!(m);
875 }
876 g
877}
878
879fn hooks_of(obj: &mut Map<String, Value>) -> Result<&mut Map<String, Value>, String> {
880 obj.entry("hooks")
881 .or_insert_with(|| json!({}))
882 .as_object_mut()
883 .ok_or_else(|| "hooks is not an object".to_string())
884}
885
886fn spec(runner: &str, root: &mut Value, command: &str) -> Result<bool, String> {
890 let obj = root
891 .as_object_mut()
892 .ok_or_else(|| "not a JSON object".to_string())?;
893 let changed = match runner {
894 "copilot" => {
895 let want = json!({
896 "version": 1,
897 "hooks": { "PreToolUse": [{
898 "type": "command", "matcher": "Bash", "bash": command, "timeoutSec": 20
899 }]}
900 });
901 let same = Value::Object(obj.clone()) == want;
902 *obj = want.as_object().cloned().unwrap_or_default();
903 !same
904 }
905 "kiro" => {
906 let want = json!({
907 "version": "v1",
908 "hooks": [{
909 "name": "ljos guard",
910 "description": "The seat's guard on shell commands; written by ljos onboard.",
911 "trigger": "PreToolUse",
912 "matcher": "shell",
913 "action": { "type": "command", "command": command },
914 "timeout": 20
915 }]
916 });
917 let same = Value::Object(obj.clone()) == want;
918 *obj = want.as_object().cloned().unwrap_or_default();
919 !same
920 }
921 "gemini" => {
922 let hooks = hooks_of(obj)?;
923 let a = ensure_in(
924 hooks,
925 "BeforeTool",
926 group(Some("run_shell_command"), command, 20_000, runner),
927 runner,
928 );
929 let b = ensure_in(
930 hooks,
931 "BeforeAgent",
932 group(None, command, 15_000, runner),
933 runner,
934 );
935 a || b
936 }
937 "qwen" => {
938 let hooks = hooks_of(obj)?;
939 let a = ensure_in(
940 hooks,
941 "PreToolUse",
942 group(Some("^run_shell_command$"), command, 20, runner),
943 runner,
944 );
945 let b = ensure_in(
946 hooks,
947 "UserPromptSubmit",
948 group(None, command, 15, runner),
949 runner,
950 );
951 a || b
952 }
953 "factory" => {
954 let a = ensure_in(
956 obj,
957 "PreToolUse",
958 group(Some("Execute"), command, 20, runner),
959 runner,
960 );
961 let b = ensure_in(
962 obj,
963 "UserPromptSubmit",
964 group(None, command, 15, runner),
965 runner,
966 );
967 a || b
968 }
969 "windsurf" => {
970 let hooks = hooks_of(obj)?;
971 ensure_in(
972 hooks,
973 "pre_run_command",
974 json!({ "command": command, "show_output": false }),
975 runner,
976 )
977 }
978 "openhands" => {
979 let hooks = hooks_of(obj)?;
980 let a = ensure_in(
981 hooks,
982 "PreToolUse",
983 group(Some("terminal"), command, 20, runner),
984 runner,
985 );
986 let b = ensure_in(
987 hooks,
988 "UserPromptSubmit",
989 group(None, command, 15, runner),
990 runner,
991 );
992 a || b
993 }
994 "auggie" => {
995 let hooks = hooks_of(obj)?;
996 ensure_in(
997 hooks,
998 "PreToolUse",
999 group(Some("launch-process"), command, 20_000, runner),
1000 runner,
1001 )
1002 }
1003 "goose" => {
1004 let want = json!({ "hooks": {
1007 "PreToolUse": [{ "matcher": "shell", "hooks": [{
1008 "type": "command", "command": command, "timeout": 20, "on_failure": "block"
1009 }]}],
1010 "UserPromptSubmit": [{ "hooks": [{
1011 "type": "command", "command": command, "timeout": 15
1012 }]}]
1013 }});
1014 let same = Value::Object(obj.clone()) == want;
1015 *obj = want.as_object().cloned().unwrap_or_default();
1016 !same
1017 }
1018 "amp" => {
1019 let list = obj
1020 .entry("amp.permissions".to_string())
1021 .or_insert_with(|| json!([]));
1022 let Some(list) = list.as_array_mut() else {
1023 return Err("amp.permissions is not a list".into());
1024 };
1025 if list.iter().any(|r| ours(r, runner)) {
1026 false
1027 } else {
1028 list.insert(
1030 0,
1031 json!({ "tool": "Bash", "action": "delegate", "to": command }),
1032 );
1033 true
1034 }
1035 }
1036 "crush" => {
1037 let hooks = hooks_of(obj)?;
1038 ensure_in(
1039 hooks,
1040 "PreToolUse",
1041 json!({ "name": "ljos", "matcher": "^bash$", "command": command, "timeout": 20 }),
1042 runner,
1043 )
1044 }
1045 other => return Err(format!("no hook shape for {other}")),
1046 };
1047 Ok(changed)
1048}
1049
1050#[must_use]
1052pub fn cline_script(command: &str) -> String {
1053 format!("#!/bin/sh\n# Written by ljos onboard: the seat's hook for Cline.\nexec {command}\n")
1054}
1055
1056#[must_use]
1058pub fn hook_step(runner: &str, file: &Path, base_command: &str, dry: bool) -> Step {
1059 let what = "hook".to_string();
1060 let mut command = runner_command(base_command, runner);
1061 if runner == "cline" {
1062 return cline_step(file, &command, dry);
1063 }
1064 let script = needs_script(runner).then(|| script_path(runner, file));
1065 let script_text = format!(
1066 "#!/bin/sh\n# Written by ljos onboard: the seat's guard for {runner}.\nexec {command}\n"
1067 );
1068 let script_stale = script
1069 .as_ref()
1070 .is_some_and(|p| std::fs::read_to_string(p).ok().as_deref() != Some(script_text.as_str()));
1071 if let Some(p) = &script {
1072 command = p.display().to_string();
1073 }
1074 let mut root: Value = match std::fs::read_to_string(file) {
1075 Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
1076 Ok(v) => v,
1077 Err(e) => {
1078 return Step {
1079 what,
1080 detail: format!("{}: not JSON: {e}", file.display()),
1081 ok: false,
1082 }
1083 }
1084 },
1085 _ => json!({}),
1086 };
1087 let changed = match spec(runner, &mut root, &command) {
1088 Ok(c) => c,
1089 Err(e) => {
1090 return Step {
1091 what,
1092 detail: format!("{}: {e}", file.display()),
1093 ok: false,
1094 }
1095 }
1096 };
1097 let manifest = (runner == "goose")
1098 .then(|| {
1099 file.parent()
1100 .and_then(Path::parent)
1101 .map(|d| d.join("plugin.json"))
1102 })
1103 .flatten();
1104 let manifest_missing = manifest.as_ref().is_some_and(|m| !m.is_file());
1105 if !changed && !script_stale && !manifest_missing {
1106 return Step {
1107 what,
1108 detail: format!("{} carries the seat's {runner} hook", file.display()),
1109 ok: true,
1110 };
1111 }
1112 if dry {
1113 return Step {
1114 what,
1115 detail: format!("would write the seat's {runner} hook to {}", file.display()),
1116 ok: true,
1117 };
1118 }
1119 let written = file
1120 .parent()
1121 .map_or(Ok(()), std::fs::create_dir_all)
1122 .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
1123 .and_then(|text| std::fs::write(file, text + "\n"))
1124 .and_then(|()| match &script {
1125 Some(p) => std::fs::write(p, &script_text).and_then(|()| set_executable(p)),
1126 None => Ok(()),
1127 })
1128 .and_then(|()| match &manifest {
1129 Some(m) if manifest_missing => std::fs::write(
1130 m,
1131 "{\n \"name\": \"ljos\",\n \"version\": \"1.0.0\",\n \"description\": \
1132 \"The ljos seat's memory and guard, written by ljos onboard\"\n}\n",
1133 ),
1134 _ => Ok(()),
1135 });
1136 match written {
1137 Ok(()) => Step {
1138 what,
1139 detail: format!("wrote the seat's {runner} hook to {}", file.display()),
1140 ok: true,
1141 },
1142 Err(e) => Step {
1143 what,
1144 detail: format!("{}: {e}", file.display()),
1145 ok: false,
1146 },
1147 }
1148}
1149
1150fn cline_step(dir: &Path, command: &str, dry: bool) -> Step {
1153 let what = "hook".to_string();
1154 let text = cline_script(command);
1155 let mut todo = Vec::new();
1156 for event in ["PreToolUse", "UserPromptSubmit"] {
1157 let path = dir.join(event);
1158 match std::fs::read_to_string(&path) {
1159 Ok(t) if t == text => {}
1160 Ok(t) if !t.contains("ljos onboard") => {
1161 return Step {
1162 what,
1163 detail: format!(
1164 "{} is someone else's hook; add `{command}` to it",
1165 path.display()
1166 ),
1167 ok: false,
1168 }
1169 }
1170 _ => todo.push(path),
1171 }
1172 }
1173 if todo.is_empty() {
1174 return Step {
1175 what,
1176 detail: format!("{} carries the seat's cline hooks", dir.display()),
1177 ok: true,
1178 };
1179 }
1180 if dry {
1181 return Step {
1182 what,
1183 detail: format!("would write the seat's cline hooks to {}", dir.display()),
1184 ok: true,
1185 };
1186 }
1187 for path in &todo {
1188 let written = std::fs::create_dir_all(dir)
1189 .and_then(|()| std::fs::write(path, &text))
1190 .and_then(|()| set_executable(path));
1191 if let Err(e) = written {
1192 return Step {
1193 what,
1194 detail: format!("{}: {e}", path.display()),
1195 ok: false,
1196 };
1197 }
1198 }
1199 Step {
1200 what,
1201 detail: format!("wrote the seat's cline hooks to {}", dir.display()),
1202 ok: true,
1203 }
1204}
1205
1206#[cfg(unix)]
1207fn set_executable(path: &Path) -> std::io::Result<()> {
1208 use std::os::unix::fs::PermissionsExt;
1209 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
1210}
1211
1212#[cfg(not(unix))]
1213fn set_executable(_path: &Path) -> std::io::Result<()> {
1214 Ok(())
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220
1221 const DENY: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no force push (ljos rule `ljos-policyd`)"}}"#;
1222 const ASK: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"a push"}}"#;
1223 const NOTE: &str = r#"{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"lesson: run the tests"}}"#;
1224
1225 #[test]
1226 fn each_runner_has_one_contract_row() {
1227 for runner in RUNNERS {
1228 assert_eq!(
1229 RUNNER_CONTRACTS
1230 .iter()
1231 .filter(|(name, _)| name == runner)
1232 .count(),
1233 1,
1234 "{runner}"
1235 );
1236 }
1237 }
1238
1239 fn payload(runner: &str, input: Value) -> Value {
1240 serde_json::from_str(&normalize_input(runner, &input.to_string())).unwrap()
1241 }
1242
1243 #[test]
1244 fn each_runner_payload_reads_as_the_command_it_runs() {
1245 let cases = [
1246 (
1247 "windsurf",
1248 json!({"agent_action_name": "pre_run_command", "trajectory_id": "task-0001",
1249 "tool_info": {"command_line": "git push -f", "cwd": "/tmp"}}),
1250 ),
1251 (
1252 "cline",
1253 json!({"hookName": "PreToolUse", "taskId": "task-0001", "workspaceRoots": ["/tmp"],
1254 "preToolUse": {"toolName": "execute_command", "parameters": {"command": "git push -f"}}}),
1255 ),
1256 (
1257 "cline",
1258 json!({"hookName": "tool_call", "taskId": "task-0001", "workspaceRoots": ["/tmp"],
1259 "tool_call": {"id": "c", "name": "run_commands", "input": {"commands": ["git push -f"]}}}),
1260 ),
1261 (
1262 "copilot",
1263 json!({"sessionId": "task-0001", "timestamp": 1, "cwd": "/tmp", "toolName": "bash",
1264 "toolArgs": "{\"command\": \"git push -f\"}"}),
1265 ),
1266 (
1267 "copilot",
1268 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1269 "tool_name": "Bash", "tool_input": {"command": "git push -f"}}),
1270 ),
1271 (
1272 "gemini",
1273 json!({"hook_event_name": "BeforeTool", "session_id": "task-0001", "cwd": "/tmp",
1274 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
1275 ),
1276 (
1277 "crush",
1278 json!({"event": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1279 "tool_name": "bash", "tool_input": {"command": "git push -f"}}),
1280 ),
1281 (
1282 "kiro",
1283 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1284 "tool_name": "shell", "tool_input": {"command": "git push -f"}}),
1285 ),
1286 (
1287 "factory",
1288 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1289 "tool_name": "Execute", "tool_input": {"command": "git push -f"}}),
1290 ),
1291 (
1292 "qwen",
1293 json!({"hook_event_name": "PreToolUse", "session_id": "task-0001", "cwd": "/tmp",
1294 "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
1295 ),
1296 ];
1297 for (runner, input) in cases {
1298 let v = payload(runner, input.clone());
1299 let call = crate::hook_call(&v.to_string());
1300 assert_eq!(call.event, "PreToolUse", "{runner}: {v}");
1301 assert_eq!(call.cue, "git push -f", "{runner}: {v}");
1302 assert_eq!(call.session.as_deref(), Some("task-0001"), "{runner}: {v}");
1303 assert_eq!(
1304 crate::hook_directory(&v.to_string()).unwrap(),
1305 std::fs::canonicalize("/tmp").unwrap(),
1306 "{runner}"
1307 );
1308 }
1309 let prompt = payload(
1310 "gemini",
1311 json!({"hook_event_name": "BeforeAgent", "session_id": "t", "prompt": "fix the build"}),
1312 );
1313 let call = crate::hook_call(&prompt.to_string());
1314 assert_eq!(
1315 (call.event.as_str(), call.cue.as_str()),
1316 ("UserPromptSubmit", "fix the build")
1317 );
1318 let prompt = payload(
1319 "cline",
1320 json!({"hookName": "UserPromptSubmit", "taskId": "t", "userPromptSubmit": {"prompt": "fix it"}}),
1321 );
1322 assert_eq!(
1323 crate::hook_call(&prompt.to_string()).event,
1324 "UserPromptSubmit"
1325 );
1326 }
1327
1328 #[test]
1329 fn a_deny_reaches_each_runner_in_its_own_contract() {
1330 let (out, _, code) = answer("copilot", "PreToolUse", DENY);
1331 let v: Value = serde_json::from_str(&out).unwrap();
1332 assert_eq!((v["permissionDecision"].as_str(), code), (Some("deny"), 0));
1333 assert!(v["permissionDecisionReason"]
1334 .as_str()
1335 .unwrap()
1336 .contains("force push"));
1337
1338 let (out, _, code) = answer("gemini", "BeforeTool", DENY);
1339 let v: Value = serde_json::from_str(&out).unwrap();
1340 assert_eq!((v["decision"].as_str(), code), (Some("deny"), 0));
1341 assert!(v["reason"].as_str().unwrap().contains("force push"));
1342
1343 for runner in ["windsurf", "kiro"] {
1344 let (out, err, code) = answer(runner, "PreToolUse", DENY);
1345 assert_eq!((out.as_str(), code), ("", 2), "{runner}");
1346 assert!(err.contains("force push"), "{runner}");
1347 }
1348
1349 for runner in ["factory", "qwen"] {
1350 let (out, err, code) = answer(runner, "PreToolUse", DENY);
1351 assert_eq!(out.as_str(), DENY, "{runner}");
1352 assert_eq!(code, 2, "{runner}");
1353 assert!(err.contains("force push"), "{runner}: {err}");
1354 }
1355
1356 let (out, _, _) = answer("cline", "PreToolUse", DENY);
1357 let v: Value = serde_json::from_str(&out).unwrap();
1358 assert_eq!(v["cancel"], json!(true));
1359 assert!(v["errorMessage"].as_str().unwrap().contains("force push"));
1360
1361 let (out, err, code) = answer("crush", "PreToolUse", DENY);
1362 let v: Value = serde_json::from_str(&out).unwrap();
1363 assert_eq!(v["decision"], json!("deny"));
1364 assert_eq!(code, 2);
1365 assert!(err.contains("force push"));
1366 }
1367
1368 #[test]
1369 fn an_ask_is_a_deny_where_the_runner_cannot_ask() {
1370 let (out, _, _) = answer("copilot", "PreToolUse", ASK);
1371 assert!(out.contains("\"ask\""));
1372 for runner in ["gemini", "cline", "crush"] {
1373 let (out, _, _) = answer(runner, "PreToolUse", ASK);
1374 assert!(!out.contains("\"ask\""), "{runner}: {out}");
1375 assert!(out.contains("cannot ask"), "{runner}: {out}");
1376 }
1377 let (_, err, code) = answer("windsurf", "PreToolUse", ASK);
1378 assert_eq!(code, 2);
1379 assert!(err.contains("ask the person"));
1380 }
1381
1382 #[test]
1383 fn an_allow_says_nothing_and_a_note_reaches_runners_that_take_one() {
1384 for runner in ["copilot", "windsurf", "kiro", "crush"] {
1385 assert_eq!(
1386 answer(runner, "PreToolUse", ""),
1387 (String::new(), String::new(), 0)
1388 );
1389 }
1390 let (out, _, code) = answer("cline", "PreToolUse", "");
1391 assert_eq!(
1392 serde_json::from_str::<Value>(&out).unwrap(),
1393 json!({"cancel": false})
1394 );
1395 assert_eq!(code, 0);
1396 let (out, _, _) = answer("gemini", "BeforeAgent", NOTE);
1397 let v: Value = serde_json::from_str(&out).unwrap();
1398 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "BeforeAgent");
1399 assert_eq!(
1400 v["hookSpecificOutput"]["additionalContext"],
1401 "lesson: run the tests"
1402 );
1403 let (out, _, _) = answer("cline", "UserPromptSubmit", NOTE);
1404 assert!(out.contains("contextModification"));
1405 }
1406
1407 #[cfg(unix)]
1408 fn sh(script: &str) -> std::process::Command {
1409 let mut cmd = std::process::Command::new("sh");
1410 cmd.arg("-c").arg(script);
1411 cmd
1412 }
1413
1414 #[cfg(unix)]
1415 fn inner(script: &str, timeout_ms: u64) -> String {
1416 run_inner(
1417 sh(script),
1418 r#"{"tool_name":"Bash"}"#,
1419 "PreToolUse",
1420 std::time::Duration::from_millis(timeout_ms),
1421 )
1422 }
1423
1424 fn runner_denied(runner: &str, stdout: &str, _stderr: &str, code: i32) -> bool {
1425 match runner {
1426 "copilot" => code == 0 && stdout.contains(r#""permissionDecision":"deny""#),
1427 "gemini" => code == 0 && stdout.contains(r#""decision":"deny""#),
1428 "windsurf" | "factory" | "kiro" | "qwen" | "crush" | "goose" | "amp" | "openhands" => {
1429 code == 2
1430 }
1431 "cline" => code == 0 && stdout.contains(r#""cancel":true"#),
1432 "auggie" => code == 0 && stdout.contains(r#""permissionDecision":"deny""#),
1433 _ => false,
1434 }
1435 }
1436
1437 #[test]
1438 fn a_relay_fault_denies_in_the_runners_contract() {
1439 for runner in RUNNERS {
1440 let (stdout, stderr, code) =
1441 relay_fault_deny(runner, "", "stdin is not utf-8").expect(runner);
1442 let body = format!("{stdout}{stderr}");
1443 assert!(
1444 runner_denied(runner, &stdout, &stderr, code),
1445 "{runner}: code {code} {body}"
1446 );
1447 assert!(body.contains("stdin is not utf-8"), "{runner}: {body}");
1448 assert!(body.contains("run `ljos doctor`"), "{runner}: {body}");
1449 }
1450 for runner in ["gemini", "copilot", "cline", "auggie"] {
1451 let (stdout, _, code) =
1452 relay_fault_deny(runner, "BeforeTool", "stdin is not utf-8").expect(runner);
1453 assert_eq!(code, 0, "{runner} exit 2 alone would allow the command");
1454 assert!(
1455 stdout.contains("deny") || stdout.contains("cancel"),
1456 "{runner}: {stdout}"
1457 );
1458 }
1459 assert!(relay_fault_deny("windsurf", "UserPromptSubmit", "stdin is not utf-8").is_none());
1460 assert!(relay_fault_deny("gemini", "BeforeAgent", "stdin is not utf-8").is_none());
1461 assert!(relay_event_is_gate(None));
1462 assert!(relay_event_is_gate(Some("")));
1463 assert!(!relay_event_is_gate(Some("BeforeAgent")));
1464 assert!(relay_input_is_gate(
1465 r#"{"hook_event_name":"PreToolUse"}"#,
1466 Some("UserPromptSubmit")
1467 ));
1468 assert!(!relay_input_is_gate(
1469 r#"{"hook_event_name":"UserPromptSubmit","prompt":"hi"}"#,
1470 None
1471 ));
1472 }
1473
1474 fn denies_fault(out: &str, why: &str) {
1475 let said = read_answer(out);
1476 assert_eq!(said.decision.as_deref(), Some("deny"), "{out}");
1477 assert!(said.reason.contains(why), "{out}");
1478 assert!(said.reason.contains("run `ljos doctor`"), "{out}");
1479 for runner in RUNNERS {
1480 let (stdout, stderr, code) = answer(runner, "PreToolUse", out);
1481 let body = format!("{stdout}{stderr}");
1482 assert!(
1483 runner_denied(runner, &stdout, &stderr, code),
1484 "{runner}: {body}"
1485 );
1486 }
1487 }
1488
1489 fn shows_context(runner: &str) -> bool {
1490 matches!(
1491 runner,
1492 "gemini" | "openhands" | "auggie" | "kiro" | "cline" | "crush" | "factory" | "qwen"
1493 )
1494 }
1495
1496 fn allows_fault(out: &str, why: &str) {
1497 let said = read_answer(out);
1498 assert!(said.decision.is_none(), "{out}");
1499 assert!(said.context.contains("unhealthy"), "{out}");
1500 assert!(said.context.contains(why), "{out}");
1501 for runner in RUNNERS {
1502 let (stdout, stderr, code) = answer(runner, "PreToolUse", out);
1503 let body = format!("{stdout}{stderr}");
1504 assert_eq!(code, 0, "{runner}: {body}");
1505 assert!(
1506 !runner_denied(runner, &stdout, &stderr, code),
1507 "{runner}: {body}"
1508 );
1509 if shows_context(runner) {
1510 assert!(body.contains("unhealthy"), "{runner}: {body}");
1511 assert!(body.contains(why), "{runner}: {body}");
1512 }
1513 }
1514 }
1515
1516 #[cfg(unix)]
1517 #[test]
1518 fn an_inner_hook_fault_denies_in_every_runner_shape() {
1519 let _lock = crate::test_env_lock();
1520 unsafe { std::env::remove_var("LJOS_GUARD_FAIL_OPEN") };
1521 let timeout = std::time::Duration::from_secs(5);
1525 let injected = [
1526 format!("gave no answer within {} s", timeout.as_secs_f32()),
1527 "exited with status 3".to_string(),
1528 "was killed by a signal".to_string(),
1529 ];
1530 for why in &injected {
1531 denies_fault(&fault_output("PreToolUse", why), why);
1532 }
1533 denies_fault(&inner("exit 3", 30_000), "exited with status 3");
1534 let missing = run_inner(
1535 std::process::Command::new("/nonexistent/ljos"),
1536 "{}",
1537 "PreToolUse",
1538 std::time::Duration::from_secs(1),
1539 );
1540 denies_fault(&missing, "did not start");
1541 assert_eq!(INNER_TIMEOUT, crate::GUARD_TIMEOUT);
1542 }
1543
1544 #[test]
1549 fn a_prompt_fault_is_not_a_deny() {
1550 let _lock = crate::test_env_lock();
1551 unsafe { std::env::remove_var("LJOS_GUARD_FAIL_OPEN") };
1552 let why = format!(
1553 "gave no answer within {} s",
1554 crate::GUARD_TIMEOUT.as_secs_f32()
1555 );
1556 let prompts = [
1557 ("gemini", "BeforeAgent"),
1558 ("qwen", "UserPromptSubmit"),
1559 ("factory", "UserPromptSubmit"),
1560 ("openhands", "UserPromptSubmit"),
1561 ("goose", "UserPromptSubmit"),
1562 ("cline", "UserPromptSubmit"),
1563 ];
1564 for (runner, event) in prompts {
1565 let fault = fault_output(event, &why);
1566 let said = read_answer(&fault);
1567 assert!(said.decision.is_none(), "{runner}: {fault}");
1568 assert!(
1569 !fault.contains("while checking this command"),
1570 "{runner}: {fault}"
1571 );
1572 assert!(said.context.contains("unhealthy"), "{runner}: {fault}");
1573 let (stdout, stderr, code) = answer(runner, event, &fault);
1574 let body = format!("{stdout}{stderr}");
1575 assert_eq!(code, 0, "{runner}: {body}");
1576 assert!(
1577 !runner_denied(runner, &stdout, &stderr, code),
1578 "{runner}: {body}"
1579 );
1580 assert!(
1581 !body.contains("while checking this command"),
1582 "{runner}: {body}"
1583 );
1584 }
1585 denies_fault(&fault_output("PreToolUse", &why), &why);
1586 denies_fault(&fault_output("BeforeTool", &why), &why);
1587 let deadline = std::time::Duration::from_millis(crate::HOOK_DEADLINE_MS);
1588 assert!(relay_timeout("UserPromptSubmit") >= deadline);
1589 assert!(relay_timeout("BeforeAgent") >= deadline);
1590 assert!(relay_timeout("pre_user_prompt") >= deadline);
1591 assert_eq!(relay_timeout("PreToolUse"), crate::guard_timeout());
1592 assert_eq!(relay_timeout("BeforeTool"), crate::guard_timeout());
1593 assert_eq!(relay_timeout(""), crate::guard_timeout());
1594 }
1595
1596 #[cfg(unix)]
1597 #[test]
1598 fn a_prompt_hook_that_exits_early_is_not_denied() {
1599 let _lock = crate::test_env_lock();
1600 unsafe { std::env::remove_var("LJOS_GUARD_FAIL_OPEN") };
1601 let out = run_inner(
1602 sh("exit 3"),
1603 r#"{"hook_event_name":"UserPromptSubmit","prompt":"hello"}"#,
1604 "UserPromptSubmit",
1605 std::time::Duration::from_secs(2),
1606 );
1607 let said = read_answer(&out);
1608 assert!(said.decision.is_none(), "{out}");
1609 assert!(!out.contains("while checking this command"), "{out}");
1610 assert!(said.context.contains("unhealthy"), "{out}");
1611 }
1612
1613 #[cfg(unix)]
1619 #[test]
1620 fn an_unhealthy_note_is_not_a_second_deny() {
1621 let _lock = crate::test_env_lock();
1622 unsafe { std::env::remove_var("LJOS_GUARD_FAIL_OPEN") };
1623 let why = "did not answer in time";
1624 let exact = json!({
1625 "hookSpecificOutput": {
1626 "hookEventName": "PreToolUse",
1627 "additionalContext": crate::unhealthy_note(why),
1628 }
1629 })
1630 .to_string();
1631 let merged = json!({
1632 "hookSpecificOutput": {
1633 "hookEventName": "PreToolUse",
1634 "additionalContext": format!(
1635 "{}\nthe tip has no passing test record",
1636 crate::unhealthy_note(why)
1637 ),
1638 }
1639 })
1640 .to_string();
1641 for body in [exact, merged] {
1642 let out = inner(&format!("printf '%s' '{body}'"), 30_000);
1643 allows_fault(&out, why);
1644 }
1645 }
1646
1647 #[cfg(unix)]
1648 #[test]
1649 fn an_inner_hook_that_answers_keeps_its_answer() {
1650 let _lock = crate::test_env_lock();
1651 unsafe { std::env::remove_var("LJOS_GUARD_FAIL_OPEN") };
1652 assert_eq!(inner("true", 30_000), "", "a clean allow");
1653 assert_eq!(inner(&format!("printf '%s' '{DENY}'"), 30_000), DENY);
1654 assert_eq!(
1655 inner(&format!("printf '%s' '{DENY}'; exit 1"), 30_000),
1656 DENY,
1657 "a deny printed before a non-zero exit stands"
1658 );
1659 let note = inner(&format!("printf '%s' '{NOTE}'; exit 1"), 30_000);
1660 let said = read_answer(¬e);
1661 assert_eq!(said.decision.as_deref(), Some("deny"), "{note}");
1662 assert!(said.reason.contains("exited with status 1"), "{note}");
1663 }
1664
1665 #[cfg(unix)]
1666 #[test]
1667 fn guard_fail_open_restores_allow_with_note() {
1668 let _lock = crate::test_env_lock();
1669 unsafe { std::env::set_var("LJOS_GUARD_FAIL_OPEN", "1") };
1670 allows_fault(
1671 &fault_output("PreToolUse", "exited with status 3"),
1672 "exited with status 3",
1673 );
1674 allows_fault(&inner("exit 3", 30_000), "exited with status 3");
1675 let unhealthy = json!({
1676 "hookSpecificOutput": {
1677 "hookEventName": "PreToolUse",
1678 "additionalContext": crate::unhealthy_note("did not answer in time"),
1679 }
1680 })
1681 .to_string();
1682 let from_policyd = inner(&format!("printf '%s' '{unhealthy}'"), 30_000);
1683 allows_fault(&from_policyd, "did not answer in time");
1684 }
1685
1686 #[test]
1687 fn a_known_dangerous_command_is_judged_before_the_inner_hook() {
1688 use std::os::unix::fs::PermissionsExt;
1689 let _lock = crate::test_env_lock();
1690 let dir = crate::test_seat_root::seat_root(crate::bookkeep::path_is_temp)
1691 .join(format!("ljos-early-{}", std::process::id()));
1692 let _ = std::fs::remove_dir_all(&dir);
1693 std::fs::create_dir_all(&dir).unwrap();
1694 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1695 let data = dir.join("data");
1696 unsafe {
1697 std::env::set_var("HOME", &dir);
1698 std::env::set_var("XDG_DATA_HOME", &data);
1699 std::env::set_var("LJOS_OFF", "0");
1700 std::env::set_var("POLICYD_BIN", dir.join("does-not-run"));
1701 std::env::set_var("PACKSET_WORKSPACE", "early-guard");
1702 }
1703 let force = json!({"tool_input": {"command": "git push --force origin main"}}).to_string();
1704 let started = std::time::Instant::now();
1705 let asked = early_answer(&force, "PreToolUse").expect("a force push asks");
1706 let plain = json!({"tool_input": {"command": "git status"}}).to_string();
1707 assert!(
1708 early_answer(&plain, "").is_none(),
1709 "git status is not dangerous"
1710 );
1711 let rm = json!({"tool_input": {"command": "rm -rf /home/u/proj"}}).to_string();
1712 let denied = early_answer(&rm, "PreToolUse").expect("rm -rf denies");
1713 assert!(
1714 started.elapsed() < std::time::Duration::from_secs(5),
1715 "the static check waited on the guard"
1716 );
1717 assert_eq!(
1718 read_answer(&asked).decision.as_deref(),
1719 Some("ask"),
1720 "{asked}"
1721 );
1722 assert!(asked.contains("git-force-push"), "{asked}");
1723 assert!(!asked.contains("git-push-evidence"), "{asked}");
1724 assert_eq!(
1725 read_answer(&denied).decision.as_deref(),
1726 Some("deny"),
1727 "{denied}"
1728 );
1729 assert!(denied.contains("rm-rf-outside-tmp"), "{denied}");
1730 let before = early_answer(&rm, "BeforeTool").expect("BeforeTool is still a gate");
1734 assert_eq!(
1735 read_answer(&before).decision.as_deref(),
1736 Some("deny"),
1737 "{before}"
1738 );
1739 let (out, _, code) = answer("gemini", "BeforeTool", &before);
1740 assert_eq!(code, 0);
1741 assert!(out.contains("\"deny\""), "{out}");
1742 assert!(!out.contains("\"ask\""), "{out}");
1743 let wind = early_answer(&rm, "pre_run_command").expect("pre_run_command is still a gate");
1744 assert_eq!(
1745 read_answer(&wind).decision.as_deref(),
1746 Some("deny"),
1747 "{wind}"
1748 );
1749 let _ = std::fs::remove_dir_all(&dir);
1750 }
1751
1752 #[test]
1753 fn hook_files_are_written_in_each_runner_shape_and_never_twice() {
1754 let tmp = tempfile::tempdir().unwrap();
1755 let dir = tmp.path().to_path_buf();
1756 let base = "/opt/bin/ljos hook";
1757 for runner in RUNNERS {
1758 let file = if *runner == "cline" {
1759 dir.join("cline-hooks")
1760 } else {
1761 dir.join(format!("{runner}.json"))
1762 };
1763 if *runner == "gemini" {
1764 std::fs::write(&file, r#"{"mcpServers":{"x":{}},"hooks":{"BeforeTool":[{"matcher":"x","hooks":[]}]}}"#).unwrap();
1766 }
1767 let first = hook_step(runner, &file, base, false);
1768 assert!(first.ok, "{runner}: {}", first.detail);
1769 assert!(first.detail.contains("wrote"), "{runner}: {}", first.detail);
1770 let again = hook_step(runner, &file, base, false);
1771 assert!(
1772 again.ok && again.detail.contains("carries"),
1773 "{runner}: {}",
1774 again.detail
1775 );
1776 let command = format!("{base} --runner {runner}");
1777 if *runner == "cline" {
1778 for event in ["PreToolUse", "UserPromptSubmit"] {
1779 let text = std::fs::read_to_string(file.join(event)).unwrap();
1780 assert!(text.contains(&command));
1781 }
1782 continue;
1783 }
1784 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1785 let found = |p: &str| v.pointer(p).cloned().unwrap_or(Value::Null);
1786 match *runner {
1787 "copilot" => {
1788 assert_eq!(found("/version"), json!(1));
1789 assert_eq!(found("/hooks/PreToolUse/0/bash"), json!(command));
1790 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("Bash"));
1791 }
1792 "gemini" => {
1793 assert_eq!(found("/mcpServers/x"), json!({}));
1794 assert_eq!(found("/hooks/BeforeTool/0/matcher"), json!("x"));
1795 assert_eq!(
1796 found("/hooks/BeforeTool/1/matcher"),
1797 json!("run_shell_command")
1798 );
1799 assert_eq!(
1800 found("/hooks/BeforeTool/1/hooks/0/timeout"),
1801 json!(20_000),
1802 "milliseconds"
1803 );
1804 assert_eq!(
1805 found("/hooks/BeforeAgent/0/hooks/0/command"),
1806 json!(command)
1807 );
1808 }
1809 "qwen" => {
1810 assert_eq!(
1811 found("/hooks/PreToolUse/0/matcher"),
1812 json!("^run_shell_command$")
1813 );
1814 assert_eq!(
1815 found("/hooks/PreToolUse/0/hooks/0/timeout"),
1816 json!(20),
1817 "seconds"
1818 );
1819 }
1820 "factory" => {
1821 assert_eq!(found("/PreToolUse/0/matcher"), json!("Execute"));
1822 assert_eq!(found("/PreToolUse/0/hooks/0/command"), json!(command));
1823 }
1824 "windsurf" => {
1825 assert_eq!(found("/hooks/pre_run_command/0/command"), json!(command));
1826 }
1827 "kiro" => {
1828 assert_eq!(found("/version"), json!("v1"));
1829 assert_eq!(found("/hooks/0/trigger"), json!("PreToolUse"));
1830 assert_eq!(found("/hooks/0/action/command"), json!(command));
1831 }
1832 "crush" => {
1833 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("^bash$"));
1834 assert_eq!(found("/hooks/PreToolUse/0/command"), json!(command));
1835 }
1836 "goose" => {
1837 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(command));
1838 }
1839 "auggie" => {
1840 assert_eq!(
1841 found("/hooks/PreToolUse/0/matcher"),
1842 json!("launch-process")
1843 );
1844 let script = script_path("auggie", &file).display().to_string();
1845 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(script));
1846 }
1847 "amp" => {
1848 assert_eq!(found("/amp.permissions/0/action"), json!("delegate"));
1849 }
1850 "openhands" => {
1851 assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("terminal"));
1852 assert_eq!(found("/hooks/PreToolUse/0/hooks/0/command"), json!(command));
1853 }
1854 _ => unreachable!(),
1855 }
1856 }
1857 let foreign = dir.join("foreign-cline");
1858 std::fs::create_dir_all(&foreign).unwrap();
1859 std::fs::write(foreign.join("PreToolUse"), "#!/bin/sh\necho mine\n").unwrap();
1860 let step = hook_step("cline", &foreign, base, false);
1861 assert!(
1862 !step.ok && step.detail.contains("someone else's"),
1863 "{}",
1864 step.detail
1865 );
1866 }
1867
1868 #[test]
1869 fn goose_payload_reaches_the_seat_and_a_deny_exits_2() {
1870 let n = normalize_input(
1871 "goose",
1872 r#"{"event":"PreToolUse","session_id":"g-1","tool_name":"shell","tool_input":{"command":"rm -rf /"},"working_dir":"/r"}"#,
1873 );
1874 let v: Value = serde_json::from_str(&n).unwrap();
1875 assert_eq!(v["hook_event_name"], "PreToolUse");
1876 assert_eq!(v["tool_input"]["command"], "rm -rf /");
1877 assert_eq!(v["cwd"], "/r");
1878 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1879 assert_eq!(
1880 answer("goose", "PreToolUse", deny),
1881 (String::new(), "no\n".into(), 2)
1882 );
1883 assert_eq!(answer("goose", "PreToolUse", "{}").2, 0);
1884 }
1885
1886 #[test]
1887 fn amp_reads_cmd_and_rejects_with_2_and_asks_with_1() {
1888 let n = normalize_input("amp", r#"{"cmd":"git push --force"}"#);
1889 let v: Value = serde_json::from_str(&n).unwrap();
1890 assert_eq!(v["tool_input"]["command"], "git push --force");
1891 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1892 assert_eq!(answer("amp", "", deny).2, 2);
1893 let ask = r#"{"hookSpecificOutput":{"permissionDecision":"ask","permissionDecisionReason":"sure?"}}"#;
1894 assert_eq!(answer("amp", "", ask).2, 1);
1895 assert_eq!(answer("amp", "", "").2, 0);
1896 }
1897
1898 #[test]
1899 fn auggie_launch_process_is_the_shell_and_its_deny_is_claude_shaped() {
1900 let n = normalize_input(
1901 "auggie",
1902 r#"{"hook_event_name":"PreToolUse","tool_name":"launch-process","tool_input":{"command":"ls"}}"#,
1903 );
1904 let v: Value = serde_json::from_str(&n).unwrap();
1905 assert_eq!(v["tool_name"], "Bash");
1906 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1907 let (out, _, code) = answer("auggie", "PreToolUse", deny);
1908 let o: Value = serde_json::from_str(&out).unwrap();
1909 assert_eq!(o["hookSpecificOutput"]["permissionDecision"], "deny");
1910 assert_eq!(code, 0);
1911 }
1912
1913 #[test]
1914 fn auggie_and_amp_hooks_name_a_script_and_are_written_once() {
1915 for (runner, name) in [("auggie", "settings.json"), ("amp", "settings.json")] {
1916 let dir = tempfile::tempdir().unwrap();
1917 let file = dir.path().join(name);
1918 std::fs::write(
1919 &file,
1920 r#"{"keep": 1, "amp.permissions": [{"tool": "Read", "action": "allow"}]}"#,
1921 )
1922 .unwrap();
1923 let step = hook_step(runner, &file, "/bin/ljos hook", false);
1924 assert!(step.ok, "{}", step.detail);
1925 let script = script_path(runner, &file);
1926 let text = std::fs::read_to_string(&script).unwrap();
1927 assert!(text.contains(&format!("/bin/ljos hook --runner {runner}")));
1928 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1929 assert_eq!(v["keep"], 1);
1930 assert!(v.to_string().contains(&script.display().to_string()));
1931 if runner == "amp" {
1932 assert_eq!(v["amp.permissions"][0]["action"], "delegate");
1933 assert_eq!(v["amp.permissions"][1]["tool"], "Read");
1934 }
1935 let again = hook_step(runner, &file, "/bin/ljos hook", false);
1936 assert!(again.detail.contains("carries"), "{}", again.detail);
1937 }
1938 }
1939
1940 #[test]
1941 fn goose_hook_is_a_plugin_that_fails_closed() {
1942 let dir = tempfile::tempdir().unwrap();
1943 let file = dir.path().join("ljos/hooks/hooks.json");
1944 let step = hook_step("goose", &file, "/bin/ljos hook", false);
1945 assert!(step.ok, "{}", step.detail);
1946 let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
1947 assert_eq!(
1948 v["hooks"]["PreToolUse"][0]["hooks"][0]["on_failure"],
1949 "block"
1950 );
1951 assert!(dir.path().join("ljos/plugin.json").is_file());
1952 assert!(hook_step("goose", &file, "/bin/ljos hook", false)
1953 .detail
1954 .contains("carries"));
1955 }
1956
1957 #[test]
1958 fn openhands_terminal_is_the_shell_and_a_deny_exits_2() {
1959 let n = normalize_input(
1960 "openhands",
1961 r#"{"event_type":"PreToolUse","tool_name":"terminal","tool_input":{"command":"rm -rf ~"},"working_dir":"/w"}"#,
1962 );
1963 let v: Value = serde_json::from_str(&n).unwrap();
1964 assert_eq!(v["tool_name"], "Bash");
1965 assert_eq!(v["cwd"], "/w");
1966 let deny = r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"no"}}"#;
1967 let (out, _, code) = answer("openhands", "PreToolUse", deny);
1968 assert!(out.contains("\"decision\":\"deny\""));
1969 assert_eq!(code, 2);
1970 }
1971
1972 #[test]
1973 fn the_start_line_leads_an_openhands_prompt_note() {
1974 let out = with_start_line("{\"additionalContext\":\"lesson\"}\n", "ljos is on: x");
1975 let v: Value = serde_json::from_str(&out).unwrap();
1976 assert_eq!(v["additionalContext"], "ljos is on: x\n\nlesson");
1977 assert_eq!(
1978 with_start_line("", "ljos is on").trim(),
1979 r#"{"additionalContext":"ljos is on"}"#
1980 );
1981 }
1982}