Skip to main content

ljos_cli/
runner_hooks.rs

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