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