Skip to main content

ljos_cli/
runner_hooks.rs

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