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