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 and Crush.
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", "gemini", "windsurf", "factory", "kiro", "cline", "qwen", "crush",
20];
21
22/// Whether `name` is a runner this module writes hooks for.
23#[must_use]
24pub fn is_runner(name: &str) -> bool {
25    RUNNERS.contains(&name)
26}
27
28/// Whether the runner shows the person a prompt on an `ask`.
29fn asks(runner: &str) -> bool {
30    matches!(runner, "copilot" | "factory" | "qwen")
31}
32
33/// The event names Gemini CLI uses, as the seat's.
34fn gemini_event(raw: &str) -> &str {
35    match raw {
36        "BeforeTool" => "PreToolUse",
37        "AfterTool" => "PostToolUse",
38        "BeforeAgent" => "UserPromptSubmit",
39        other => other,
40    }
41}
42
43/// A command line out of a tool's arguments: `command`, or a list of
44/// `commands`, one to a line.
45fn command_of(args: &Value) -> Option<String> {
46    if let Some(c) = args["command"].as_str() {
47        return Some(c.to_string());
48    }
49    args["commands"].as_array().map(|list| {
50        list.iter()
51            .filter_map(Value::as_str)
52            .collect::<Vec<_>>()
53            .join("\n")
54    })
55}
56
57/// The runner's stdin as the Claude Code payload `ljos hook` reads:
58/// `hook_event_name`, `session_id`, `cwd`, and `tool_name` with
59/// `tool_input`, or `prompt`. A payload that is not JSON, or a runner
60/// that already sends this shape, is returned as it came.
61#[must_use]
62pub fn normalize_input(runner: &str, input: &str) -> String {
63    let Ok(v) = serde_json::from_str::<Value>(input.trim()) else {
64        return input.to_string();
65    };
66    let mut out = Map::new();
67    let put = |out: &mut Map<String, Value>, k: &str, val: Option<&str>| {
68        if let Some(s) = val.filter(|s| !s.is_empty()) {
69            out.insert(k.into(), Value::String(s.to_string()));
70        }
71    };
72    match runner {
73        "windsurf" => {
74            let action = v["agent_action_name"].as_str().unwrap_or("");
75            let info = &v["tool_info"];
76            let event = match action {
77                "pre_run_command" => "PreToolUse",
78                "post_run_command" => "PostToolUse",
79                "pre_user_prompt" => "UserPromptSubmit",
80                other => other,
81            };
82            out.insert("hook_event_name".into(), json!(event));
83            put(&mut out, "session_id", v["trajectory_id"].as_str());
84            put(&mut out, "cwd", info["cwd"].as_str());
85            if let Some(line) = info["command_line"].as_str() {
86                out.insert("tool_name".into(), json!("Bash"));
87                out.insert("tool_input".into(), json!({ "command": line }));
88            }
89            put(&mut out, "prompt", info["user_prompt"].as_str());
90        }
91        "cline" => {
92            put(&mut out, "session_id", v["taskId"].as_str());
93            put(&mut out, "cwd", v["workspaceRoots"][0].as_str());
94            if let Some(pre) = v.get("preToolUse").filter(|p| p.is_object()) {
95                let tool = pre["toolName"].as_str().or_else(|| pre["tool"].as_str());
96                out.insert("hook_event_name".into(), json!("PreToolUse"));
97                put(&mut out, "tool_name", tool);
98                let args = &pre["parameters"];
99                match command_of(args) {
100                    Some(c) => {
101                        out.insert("tool_input".into(), json!({ "command": c }));
102                    }
103                    None => {
104                        out.insert("tool_input".into(), args.clone());
105                    }
106                }
107            } else if let Some(call) = v.get("tool_call").filter(|p| p.is_object()) {
108                out.insert("hook_event_name".into(), json!("PreToolUse"));
109                put(&mut out, "tool_name", call["name"].as_str());
110                let args = &call["input"];
111                match command_of(args) {
112                    Some(c) => {
113                        out.insert("tool_input".into(), json!({ "command": c }));
114                    }
115                    None => {
116                        out.insert("tool_input".into(), args.clone());
117                    }
118                }
119            } else if let Some(p) = v["userPromptSubmit"]["prompt"].as_str() {
120                out.insert("hook_event_name".into(), json!("UserPromptSubmit"));
121                out.insert("prompt".into(), json!(p));
122            } else {
123                return input.to_string();
124            }
125        }
126        "copilot" if v.get("toolName").is_some() || v.get("sessionId").is_some() => {
127            // The camelCase contract: `toolArgs` may arrive as JSON text.
128            let event = if v.get("toolName").is_some() {
129                "PreToolUse"
130            } else if v.get("prompt").is_some() {
131                "UserPromptSubmit"
132            } else {
133                "SessionStart"
134            };
135            out.insert("hook_event_name".into(), json!(event));
136            put(&mut out, "session_id", v["sessionId"].as_str());
137            put(&mut out, "cwd", v["cwd"].as_str());
138            put(&mut out, "tool_name", v["toolName"].as_str());
139            put(&mut out, "prompt", v["prompt"].as_str());
140            let args = match &v["toolArgs"] {
141                Value::String(s) => serde_json::from_str(s).unwrap_or(json!({ "command": s })),
142                other => other.clone(),
143            };
144            if !args.is_null() {
145                out.insert("tool_input".into(), args);
146            }
147        }
148        "gemini" => {
149            let mut v = v;
150            if let Some(e) = v["hook_event_name"].as_str() {
151                let seat = gemini_event(e).to_string();
152                v["hook_event_name"] = Value::String(seat);
153            }
154            return v.to_string();
155        }
156        "crush" if v.get("hook_event_name").is_none() => {
157            let mut v = v;
158            let event = v["event"].as_str().unwrap_or("PreToolUse").to_string();
159            v["hook_event_name"] = Value::String(event);
160            return v.to_string();
161        }
162        _ => return input.to_string(),
163    }
164    Value::Object(out).to_string()
165}
166
167/// What the seat's answer says, whatever shape it came in.
168#[derive(Debug, Default, PartialEq, Eq)]
169struct Said {
170    decision: Option<String>,
171    reason: String,
172    context: String,
173}
174
175fn read_answer(out: &str) -> Said {
176    let trimmed = out.trim();
177    let Ok(v) = serde_json::from_str::<Value>(trimmed) else {
178        // A plain-text answer is an argv line; a deny says so first.
179        if trimmed.starts_with("deny:") {
180            return Said {
181                decision: Some("deny".into()),
182                reason: trimmed.trim_start_matches("deny:").trim().to_string(),
183                context: String::new(),
184            };
185        }
186        return Said {
187            context: trimmed.to_string(),
188            ..Said::default()
189        };
190    };
191    let specific = &v["hookSpecificOutput"];
192    let decision = specific["permissionDecision"]
193        .as_str()
194        .or_else(|| v["decision"].as_str())
195        .map(|d| if d == "block" { "deny" } else { d })
196        .filter(|d| matches!(*d, "deny" | "ask"))
197        .map(str::to_string);
198    let reason = specific["permissionDecisionReason"]
199        .as_str()
200        .or_else(|| v["reason"].as_str())
201        .unwrap_or("")
202        .to_string();
203    let context = specific["additionalContext"]
204        .as_str()
205        .or_else(|| v["context"].as_str())
206        .unwrap_or("")
207        .to_string();
208    Said {
209        decision,
210        reason,
211        context,
212    }
213}
214
215/// The seat's answer (the Claude Code JSON `ljos hook` prints) in the
216/// runner's contract: what to print, what to write to stderr, and the
217/// exit code. `event` is the event name the runner sent.
218#[must_use]
219pub fn answer(runner: &str, event: &str, out: &str) -> (String, String, i32) {
220    let mut said = read_answer(out);
221    if said.decision.as_deref() == Some("ask") && !asks(runner) {
222        said.decision = Some("deny".into());
223        said.reason = format!(
224            "ask the person before running this: {}. This runner cannot ask, so retrying \
225             returns this same refusal: stop, tell the person the exact command, and leave \
226             it for them to run.",
227            said.reason
228        );
229    }
230    let line = |v: Value| v.to_string() + "\n";
231    match (runner, said.decision.as_deref()) {
232        ("factory" | "qwen", _) => (out.to_string(), String::new(), 0),
233        ("copilot", Some(d)) => (
234            line(json!({
235                "permissionDecision": d,
236                "permissionDecisionReason": said.reason,
237            })),
238            String::new(),
239            0,
240        ),
241        ("copilot", None) => (String::new(), String::new(), 0),
242        ("gemini", d) => {
243            let mut v = Map::new();
244            if let Some(d) = d {
245                v.insert("decision".into(), json!(d));
246                v.insert("reason".into(), json!(said.reason));
247            }
248            if !said.context.is_empty() {
249                v.insert(
250                    "hookSpecificOutput".into(),
251                    json!({ "hookEventName": event, "additionalContext": said.context }),
252                );
253            }
254            (line(Value::Object(v)), String::new(), 0)
255        }
256        ("windsurf" | "kiro", Some(_)) => (String::new(), said.reason + "\n", 2),
257        ("kiro", None) if !said.context.is_empty() => (said.context + "\n", String::new(), 0),
258        ("windsurf" | "kiro", None) => (String::new(), String::new(), 0),
259        ("cline", Some(_)) => (
260            line(json!({ "cancel": true, "errorMessage": said.reason })),
261            String::new(),
262            0,
263        ),
264        ("cline", None) => {
265            let mut v = json!({ "cancel": false });
266            if !said.context.is_empty() {
267                v["contextModification"] = json!(said.context);
268            }
269            (line(v), String::new(), 0)
270        }
271        ("crush", Some(d)) => (
272            line(json!({ "decision": d, "reason": said.reason })),
273            String::new(),
274            0,
275        ),
276        ("crush", None) if !said.context.is_empty() => {
277            (line(json!({ "context": said.context })), String::new(), 0)
278        }
279        _ => (String::new(), String::new(), 0),
280    }
281}
282
283/// The event name in a runner's stdin, as the runner spelled it.
284#[must_use]
285pub fn raw_event(input: &str) -> String {
286    serde_json::from_str::<Value>(input.trim())
287        .ok()
288        .and_then(|v| {
289            ["hook_event_name", "agent_action_name", "hookName", "event"]
290                .iter()
291                .find_map(|k| v[*k].as_str().map(str::to_string))
292        })
293        .unwrap_or_default()
294}
295
296/// How long the inner `ljos hook` may run before the relay kills it and
297/// denies. Each runner's own timeout is 15 or 20 seconds, and several of
298/// them let the call run when it lapses, so the relay answers first.
299pub const INNER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(12);
300
301/// A deny in the Claude Code shape the inner hook prints, for an inner
302/// hook that gave no answer: `what` says how it failed.
303fn failed_closed(event: &str, what: &str) -> String {
304    json!({
305        "hookSpecificOutput": {
306            "hookEventName": if event.is_empty() { "PreToolUse" } else { event },
307            "permissionDecision": "deny",
308            "permissionDecisionReason": format!(
309                "the ljos guard hook {what}, so this call is refused unchecked. \
310                 Run `ljos doctor` and fix the hook; retrying returns this same refusal."
311            ),
312        }
313    })
314    .to_string()
315}
316
317/// Run `cmd` as the inner hook with `stdin` as its input, and give back
318/// what it printed, or a deny when it did not finish cleanly: a spawn
319/// error, a kill by signal, a non-zero exit, or no exit within `timeout`.
320/// A non-zero exit that still printed a deny or an ask keeps it.
321#[must_use]
322pub fn run_inner(
323    mut cmd: std::process::Command,
324    stdin: &str,
325    event: &str,
326    timeout: std::time::Duration,
327) -> String {
328    use std::io::{Read, Write};
329    let spawned = cmd
330        .stdin(std::process::Stdio::piped())
331        .stdout(std::process::Stdio::piped())
332        .spawn();
333    let mut child = match spawned {
334        Ok(child) => child,
335        Err(e) => return failed_closed(event, &format!("did not start ({e})")),
336    };
337    // Feed and drain on their own threads, so a hook that reads nothing or
338    // prints a lot cannot stall the clock below.
339    let input = stdin.to_string();
340    // The writer is never joined: a child that left a grandchild holding
341    // its stdin could block it, and the process exits soon after.
342    if let Some(mut pipe) = child.stdin.take() {
343        std::thread::spawn(move || {
344            let _ = pipe.write_all(input.as_bytes());
345        });
346    }
347    let reader = child.stdout.take().map(|mut pipe| {
348        std::thread::spawn(move || {
349            let mut buf = Vec::new();
350            let _ = pipe.read_to_end(&mut buf);
351            buf
352        })
353    });
354    let deadline = std::time::Instant::now() + timeout;
355    let status = loop {
356        match child.try_wait() {
357            Ok(Some(status)) => break Ok(status),
358            Ok(None) if std::time::Instant::now() >= deadline => {
359                let _ = child.kill();
360                let _ = child.wait();
361                break Err(format!("gave no answer within {} s", timeout.as_secs_f32()));
362            }
363            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
364            Err(e) => break Err(format!("could not be waited on ({e})")),
365        }
366    };
367    let status = match status {
368        Ok(status) => status,
369        // A killed hook's children may still hold the pipe; do not wait
370        // for the reader.
371        Err(what) => return failed_closed(event, &what),
372    };
373    let out = reader
374        .and_then(|r| r.join().ok())
375        .map(|b| String::from_utf8_lossy(&b).into_owned())
376        .unwrap_or_default();
377    if status.success() || read_answer(&out).decision.is_some() {
378        return out;
379    }
380    let what = match status.code() {
381        Some(code) => format!("exited with status {code}"),
382        None => "was killed by a signal".to_string(),
383    };
384    failed_closed(event, &what)
385}
386
387/// Run the seat's hook for a runner: `ljos hook` on the normalized
388/// payload, its answer put in the runner's contract. Returns the exit
389/// code the runner reads. An inner hook that crashes, exits non-zero
390/// without a decision, or runs past [`INNER_TIMEOUT`] is a deny.
391pub fn relay(runner: &str, input: &str, limit: usize, event: Option<&str>) -> anyhow::Result<i32> {
392    let normalized = normalize_input(runner, input);
393    let raw = raw_event(input);
394    let text = match std::env::current_exe() {
395        Ok(exe) => {
396            let mut cmd = std::process::Command::new(exe);
397            cmd.arg("hook").arg("--limit").arg(limit.to_string());
398            if let Some(e) = event {
399                cmd.arg("--event").arg(e);
400            }
401            run_inner(cmd, &normalized, &raw, INNER_TIMEOUT)
402        }
403        Err(e) => failed_closed(&raw, &format!("could not find its own binary ({e})")),
404    };
405    let (out, err, code) = answer(runner, &raw, &text);
406    print!("{out}");
407    eprint!("{err}");
408    Ok(code)
409}
410
411/// The hook command a runner's file names.
412#[must_use]
413pub fn runner_command(base: &str, runner: &str) -> String {
414    format!("{base} --runner {runner}")
415}
416
417fn ours(v: &Value, runner: &str) -> bool {
418    v.to_string().contains(&format!("--runner {runner}"))
419}
420
421/// Push `item` onto the array at `key` of `obj` unless one already names
422/// the seat's command. True when it was added.
423fn ensure_in(obj: &mut Map<String, Value>, key: &str, item: Value, runner: &str) -> bool {
424    let list = obj.entry(key.to_string()).or_insert_with(|| json!([]));
425    let Some(list) = list.as_array_mut() else {
426        return false;
427    };
428    if list.iter().any(|g| ours(g, runner)) {
429        return false;
430    }
431    list.push(item);
432    true
433}
434
435fn group(matcher: Option<&str>, command: &str, timeout: u64, runner: &str) -> Value {
436    let mut hook = json!({ "type": "command", "command": command, "timeout": timeout });
437    if runner == "gemini" {
438        hook["name"] = json!("ljos");
439    }
440    let mut g = json!({ "hooks": [hook] });
441    if let Some(m) = matcher {
442        g["matcher"] = json!(m);
443    }
444    g
445}
446
447fn hooks_of(obj: &mut Map<String, Value>) -> Result<&mut Map<String, Value>, String> {
448    obj.entry("hooks")
449        .or_insert_with(|| json!({}))
450        .as_object_mut()
451        .ok_or_else(|| "hooks is not an object".to_string())
452}
453
454/// Merge or write the runner's hook file so it runs `command`. A file
455/// the seat owns whole (Copilot's and Kiro's) is rewritten; a shared
456/// settings file keeps every entry that is not the seat's.
457fn spec(runner: &str, root: &mut Value, command: &str) -> Result<bool, String> {
458    let obj = root
459        .as_object_mut()
460        .ok_or_else(|| "not a JSON object".to_string())?;
461    let changed = match runner {
462        "copilot" => {
463            let want = json!({
464                "version": 1,
465                "hooks": { "PreToolUse": [{
466                    "type": "command", "matcher": "Bash", "bash": command, "timeoutSec": 20
467                }]}
468            });
469            let same = Value::Object(obj.clone()) == want;
470            *obj = want.as_object().cloned().unwrap_or_default();
471            !same
472        }
473        "kiro" => {
474            let want = json!({
475                "version": "v1",
476                "hooks": [{
477                    "name": "ljos guard",
478                    "description": "The seat's guard on shell commands; written by ljos onboard.",
479                    "trigger": "PreToolUse",
480                    "matcher": "shell",
481                    "action": { "type": "command", "command": command },
482                    "timeout": 20
483                }]
484            });
485            let same = Value::Object(obj.clone()) == want;
486            *obj = want.as_object().cloned().unwrap_or_default();
487            !same
488        }
489        "gemini" => {
490            let hooks = hooks_of(obj)?;
491            let a = ensure_in(
492                hooks,
493                "BeforeTool",
494                group(Some("run_shell_command"), command, 20_000, runner),
495                runner,
496            );
497            let b = ensure_in(
498                hooks,
499                "BeforeAgent",
500                group(None, command, 15_000, runner),
501                runner,
502            );
503            a || b
504        }
505        "qwen" => {
506            let hooks = hooks_of(obj)?;
507            let a = ensure_in(
508                hooks,
509                "PreToolUse",
510                group(Some("^run_shell_command$"), command, 20, runner),
511                runner,
512            );
513            let b = ensure_in(
514                hooks,
515                "UserPromptSubmit",
516                group(None, command, 15, runner),
517                runner,
518            );
519            a || b
520        }
521        "factory" => {
522            // hooks.json is keyed by event at the top level.
523            let a = ensure_in(
524                obj,
525                "PreToolUse",
526                group(Some("Execute"), command, 20, runner),
527                runner,
528            );
529            let b = ensure_in(
530                obj,
531                "UserPromptSubmit",
532                group(None, command, 15, runner),
533                runner,
534            );
535            a || b
536        }
537        "windsurf" => {
538            let hooks = hooks_of(obj)?;
539            ensure_in(
540                hooks,
541                "pre_run_command",
542                json!({ "command": command, "show_output": false }),
543                runner,
544            )
545        }
546        "crush" => {
547            let hooks = hooks_of(obj)?;
548            ensure_in(
549                hooks,
550                "PreToolUse",
551                json!({ "name": "ljos", "matcher": "^bash$", "command": command, "timeout": 20 }),
552                runner,
553            )
554        }
555        other => return Err(format!("no hook shape for {other}")),
556    };
557    Ok(changed)
558}
559
560/// The text of a Cline hook script that runs the seat's hook.
561#[must_use]
562pub fn cline_script(command: &str) -> String {
563    format!("#!/bin/sh\n# Written by ljos onboard: the seat's hook for Cline.\nexec {command}\n")
564}
565
566/// Write the runner's hooks at `file` (for Cline, the hooks directory).
567#[must_use]
568pub fn hook_step(runner: &str, file: &Path, base_command: &str, dry: bool) -> Step {
569    let what = "hook".to_string();
570    let command = runner_command(base_command, runner);
571    if runner == "cline" {
572        return cline_step(file, &command, dry);
573    }
574    let mut root: Value = match std::fs::read_to_string(file) {
575        Ok(text) if !text.trim().is_empty() => match serde_json::from_str(&text) {
576            Ok(v) => v,
577            Err(e) => {
578                return Step {
579                    what,
580                    detail: format!("{}: not JSON: {e}", file.display()),
581                    ok: false,
582                }
583            }
584        },
585        _ => json!({}),
586    };
587    let changed = match spec(runner, &mut root, &command) {
588        Ok(c) => c,
589        Err(e) => {
590            return Step {
591                what,
592                detail: format!("{}: {e}", file.display()),
593                ok: false,
594            }
595        }
596    };
597    if !changed {
598        return Step {
599            what,
600            detail: format!("{} carries the seat's {runner} hook", file.display()),
601            ok: true,
602        };
603    }
604    if dry {
605        return Step {
606            what,
607            detail: format!("would write the seat's {runner} hook to {}", file.display()),
608            ok: true,
609        };
610    }
611    let written = file
612        .parent()
613        .map_or(Ok(()), std::fs::create_dir_all)
614        .and_then(|()| serde_json::to_string_pretty(&root).map_err(std::io::Error::other))
615        .and_then(|text| std::fs::write(file, text + "\n"));
616    match written {
617        Ok(()) => Step {
618            what,
619            detail: format!("wrote the seat's {runner} hook to {}", file.display()),
620            ok: true,
621        },
622        Err(e) => Step {
623            what,
624            detail: format!("{}: {e}", file.display()),
625            ok: false,
626        },
627    }
628}
629
630/// Cline runs an executable named after the event from its hooks
631/// directory. A script there that is not the seat's is left alone.
632fn cline_step(dir: &Path, command: &str, dry: bool) -> Step {
633    let what = "hook".to_string();
634    let text = cline_script(command);
635    let mut todo = Vec::new();
636    for event in ["PreToolUse", "UserPromptSubmit"] {
637        let path = dir.join(event);
638        match std::fs::read_to_string(&path) {
639            Ok(t) if t == text => {}
640            Ok(t) if !t.contains("ljos onboard") => {
641                return Step {
642                    what,
643                    detail: format!(
644                        "{} is someone else's hook; add `{command}` to it",
645                        path.display()
646                    ),
647                    ok: false,
648                }
649            }
650            _ => todo.push(path),
651        }
652    }
653    if todo.is_empty() {
654        return Step {
655            what,
656            detail: format!("{} carries the seat's cline hooks", dir.display()),
657            ok: true,
658        };
659    }
660    if dry {
661        return Step {
662            what,
663            detail: format!("would write the seat's cline hooks to {}", dir.display()),
664            ok: true,
665        };
666    }
667    for path in &todo {
668        let written = std::fs::create_dir_all(dir)
669            .and_then(|()| std::fs::write(path, &text))
670            .and_then(|()| set_executable(path));
671        if let Err(e) = written {
672            return Step {
673                what,
674                detail: format!("{}: {e}", path.display()),
675                ok: false,
676            };
677        }
678    }
679    Step {
680        what,
681        detail: format!("wrote the seat's cline hooks to {}", dir.display()),
682        ok: true,
683    }
684}
685
686#[cfg(unix)]
687fn set_executable(path: &Path) -> std::io::Result<()> {
688    use std::os::unix::fs::PermissionsExt;
689    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
690}
691
692#[cfg(not(unix))]
693fn set_executable(_path: &Path) -> std::io::Result<()> {
694    Ok(())
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    const DENY: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"no force push (seat rule `ljos-policyd`)"}}"#;
702    const ASK: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"a push"}}"#;
703    const NOTE: &str = r#"{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"lesson: run the tests"}}"#;
704
705    fn payload(runner: &str, input: Value) -> Value {
706        serde_json::from_str(&normalize_input(runner, &input.to_string())).unwrap()
707    }
708
709    #[test]
710    fn each_runner_payload_reads_as_the_command_it_runs() {
711        let cases = [
712            (
713                "windsurf",
714                json!({"agent_action_name": "pre_run_command", "trajectory_id": "t1",
715                       "tool_info": {"command_line": "git push -f", "cwd": "/tmp"}}),
716            ),
717            (
718                "cline",
719                json!({"hookName": "PreToolUse", "taskId": "t1", "workspaceRoots": ["/tmp"],
720                       "preToolUse": {"toolName": "execute_command", "parameters": {"command": "git push -f"}}}),
721            ),
722            (
723                "cline",
724                json!({"hookName": "tool_call", "taskId": "t1", "workspaceRoots": ["/tmp"],
725                       "tool_call": {"id": "c", "name": "run_commands", "input": {"commands": ["git push -f"]}}}),
726            ),
727            (
728                "copilot",
729                json!({"sessionId": "t1", "timestamp": 1, "cwd": "/tmp", "toolName": "bash",
730                       "toolArgs": "{\"command\": \"git push -f\"}"}),
731            ),
732            (
733                "copilot",
734                json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
735                       "tool_name": "Bash", "tool_input": {"command": "git push -f"}}),
736            ),
737            (
738                "gemini",
739                json!({"hook_event_name": "BeforeTool", "session_id": "t1", "cwd": "/tmp",
740                       "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
741            ),
742            (
743                "crush",
744                json!({"event": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
745                       "tool_name": "bash", "tool_input": {"command": "git push -f"}}),
746            ),
747            (
748                "kiro",
749                json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
750                       "tool_name": "shell", "tool_input": {"command": "git push -f"}}),
751            ),
752            (
753                "factory",
754                json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
755                       "tool_name": "Execute", "tool_input": {"command": "git push -f"}}),
756            ),
757            (
758                "qwen",
759                json!({"hook_event_name": "PreToolUse", "session_id": "t1", "cwd": "/tmp",
760                       "tool_name": "run_shell_command", "tool_input": {"command": "git push -f"}}),
761            ),
762        ];
763        for (runner, input) in cases {
764            let v = payload(runner, input.clone());
765            let call = crate::hook_call(&v.to_string());
766            assert_eq!(call.event, "PreToolUse", "{runner}: {v}");
767            assert_eq!(call.cue, "git push -f", "{runner}: {v}");
768            assert_eq!(call.session.as_deref(), Some("t1"), "{runner}: {v}");
769            assert_eq!(
770                crate::hook_directory(&v.to_string()).unwrap(),
771                std::fs::canonicalize("/tmp").unwrap(),
772                "{runner}"
773            );
774        }
775        let prompt = payload(
776            "gemini",
777            json!({"hook_event_name": "BeforeAgent", "session_id": "t", "prompt": "fix the build"}),
778        );
779        let call = crate::hook_call(&prompt.to_string());
780        assert_eq!(
781            (call.event.as_str(), call.cue.as_str()),
782            ("UserPromptSubmit", "fix the build")
783        );
784        let prompt = payload(
785            "cline",
786            json!({"hookName": "UserPromptSubmit", "taskId": "t", "userPromptSubmit": {"prompt": "fix it"}}),
787        );
788        assert_eq!(
789            crate::hook_call(&prompt.to_string()).event,
790            "UserPromptSubmit"
791        );
792    }
793
794    #[test]
795    fn a_deny_reaches_each_runner_in_its_own_contract() {
796        let (out, _, code) = answer("copilot", "PreToolUse", DENY);
797        let v: Value = serde_json::from_str(&out).unwrap();
798        assert_eq!((v["permissionDecision"].as_str(), code), (Some("deny"), 0));
799        assert!(v["permissionDecisionReason"]
800            .as_str()
801            .unwrap()
802            .contains("force push"));
803
804        let (out, _, code) = answer("gemini", "BeforeTool", DENY);
805        let v: Value = serde_json::from_str(&out).unwrap();
806        assert_eq!((v["decision"].as_str(), code), (Some("deny"), 0));
807        assert!(v["reason"].as_str().unwrap().contains("force push"));
808
809        for runner in ["windsurf", "kiro"] {
810            let (out, err, code) = answer(runner, "PreToolUse", DENY);
811            assert_eq!((out.as_str(), code), ("", 2), "{runner}");
812            assert!(err.contains("force push"), "{runner}");
813        }
814
815        let (out, _, _) = answer("cline", "PreToolUse", DENY);
816        let v: Value = serde_json::from_str(&out).unwrap();
817        assert_eq!(v["cancel"], json!(true));
818        assert!(v["errorMessage"].as_str().unwrap().contains("force push"));
819
820        let (out, _, _) = answer("crush", "PreToolUse", DENY);
821        let v: Value = serde_json::from_str(&out).unwrap();
822        assert_eq!(v["decision"], json!("deny"));
823
824        for runner in ["factory", "qwen"] {
825            let (out, _, code) = answer(runner, "PreToolUse", DENY);
826            assert_eq!(
827                (out.as_str(), code),
828                (DENY, 0),
829                "{runner} takes Claude Code's shape"
830            );
831        }
832    }
833
834    #[test]
835    fn an_ask_is_a_deny_where_the_runner_cannot_ask() {
836        let (out, _, _) = answer("copilot", "PreToolUse", ASK);
837        assert!(out.contains("\"ask\""));
838        for runner in ["gemini", "cline", "crush"] {
839            let (out, _, _) = answer(runner, "PreToolUse", ASK);
840            assert!(!out.contains("\"ask\""), "{runner}: {out}");
841            assert!(out.contains("cannot ask"), "{runner}: {out}");
842        }
843        let (_, err, code) = answer("windsurf", "PreToolUse", ASK);
844        assert_eq!(code, 2);
845        assert!(err.contains("ask the person"));
846    }
847
848    #[test]
849    fn an_allow_says_nothing_and_a_note_reaches_runners_that_take_one() {
850        for runner in ["copilot", "windsurf", "kiro", "crush"] {
851            assert_eq!(
852                answer(runner, "PreToolUse", ""),
853                (String::new(), String::new(), 0)
854            );
855        }
856        let (out, _, code) = answer("cline", "PreToolUse", "");
857        assert_eq!(
858            serde_json::from_str::<Value>(&out).unwrap(),
859            json!({"cancel": false})
860        );
861        assert_eq!(code, 0);
862        let (out, _, _) = answer("gemini", "BeforeAgent", NOTE);
863        let v: Value = serde_json::from_str(&out).unwrap();
864        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "BeforeAgent");
865        assert_eq!(
866            v["hookSpecificOutput"]["additionalContext"],
867            "lesson: run the tests"
868        );
869        let (out, _, _) = answer("cline", "UserPromptSubmit", NOTE);
870        assert!(out.contains("contextModification"));
871    }
872
873    #[cfg(unix)]
874    fn sh(script: &str) -> std::process::Command {
875        let mut cmd = std::process::Command::new("sh");
876        cmd.arg("-c").arg(script);
877        cmd
878    }
879
880    #[cfg(unix)]
881    fn inner(script: &str, timeout_ms: u64) -> String {
882        run_inner(
883            sh(script),
884            r#"{"tool_name":"Bash"}"#,
885            "PreToolUse",
886            std::time::Duration::from_millis(timeout_ms),
887        )
888    }
889
890    #[cfg(unix)]
891    #[test]
892    fn an_inner_hook_that_fails_is_a_deny_in_every_runner() {
893        let cases = [
894            ("cat >/dev/null; exit 3", "exited with status 3"),
895            ("cat >/dev/null; kill -SEGV $$", "killed by a signal"),
896            ("sleep 5", "no answer within"),
897        ];
898        for (script, why) in cases {
899            let out = inner(script, 300);
900            assert_eq!(
901                read_answer(&out).decision.as_deref(),
902                Some("deny"),
903                "{script}"
904            );
905            assert!(out.contains(why), "{script}: {out}");
906            for runner in RUNNERS {
907                let (stdout, stderr, code) = answer(runner, "PreToolUse", &out);
908                let said = format!("{stdout}{stderr}");
909                assert!(
910                    said.contains("refused unchecked")
911                        && (code == 2 || said.contains("deny") || said.contains("\"cancel\":true")),
912                    "{runner} on {script}: {said} exit {code}"
913                );
914            }
915        }
916        let missing = run_inner(
917            std::process::Command::new("/nonexistent/ljos"),
918            "{}",
919            "PreToolUse",
920            std::time::Duration::from_secs(1),
921        );
922        assert!(missing.contains("did not start"), "{missing}");
923    }
924
925    #[cfg(unix)]
926    #[test]
927    fn an_inner_hook_that_answers_keeps_its_answer() {
928        assert_eq!(inner("cat >/dev/null", 2_000), "", "a clean allow");
929        assert_eq!(
930            inner(&format!("cat >/dev/null; printf '%s' '{DENY}'"), 2_000),
931            DENY
932        );
933        assert_eq!(
934            inner(
935                &format!("cat >/dev/null; printf '%s' '{DENY}'; exit 1"),
936                2_000
937            ),
938            DENY,
939            "a deny printed before a non-zero exit stands"
940        );
941        let note = inner(
942            &format!("cat >/dev/null; printf '%s' '{NOTE}'; exit 1"),
943            2_000,
944        );
945        assert_eq!(read_answer(&note).decision.as_deref(), Some("deny"));
946    }
947
948    #[test]
949    fn hook_files_are_written_in_each_runner_shape_and_never_twice() {
950        let tmp = tempfile::tempdir().unwrap();
951        let dir = tmp.path().to_path_buf();
952        let base = "/opt/bin/ljos hook";
953        for runner in RUNNERS {
954            let file = if *runner == "cline" {
955                dir.join("cline-hooks")
956            } else {
957                dir.join(format!("{runner}.json"))
958            };
959            if *runner == "gemini" {
960                // A settings file with entries of its own keeps them.
961                std::fs::write(&file, r#"{"mcpServers":{"x":{}},"hooks":{"BeforeTool":[{"matcher":"x","hooks":[]}]}}"#).unwrap();
962            }
963            let first = hook_step(runner, &file, base, false);
964            assert!(first.ok, "{runner}: {}", first.detail);
965            assert!(first.detail.contains("wrote"), "{runner}: {}", first.detail);
966            let again = hook_step(runner, &file, base, false);
967            assert!(
968                again.ok && again.detail.contains("carries"),
969                "{runner}: {}",
970                again.detail
971            );
972            let command = format!("{base} --runner {runner}");
973            if *runner == "cline" {
974                for event in ["PreToolUse", "UserPromptSubmit"] {
975                    let text = std::fs::read_to_string(file.join(event)).unwrap();
976                    assert!(text.contains(&command));
977                }
978                continue;
979            }
980            let v: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
981            let found = |p: &str| v.pointer(p).cloned().unwrap_or(Value::Null);
982            match *runner {
983                "copilot" => {
984                    assert_eq!(found("/version"), json!(1));
985                    assert_eq!(found("/hooks/PreToolUse/0/bash"), json!(command));
986                    assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("Bash"));
987                }
988                "gemini" => {
989                    assert_eq!(found("/mcpServers/x"), json!({}));
990                    assert_eq!(found("/hooks/BeforeTool/0/matcher"), json!("x"));
991                    assert_eq!(
992                        found("/hooks/BeforeTool/1/matcher"),
993                        json!("run_shell_command")
994                    );
995                    assert_eq!(
996                        found("/hooks/BeforeTool/1/hooks/0/timeout"),
997                        json!(20_000),
998                        "milliseconds"
999                    );
1000                    assert_eq!(
1001                        found("/hooks/BeforeAgent/0/hooks/0/command"),
1002                        json!(command)
1003                    );
1004                }
1005                "qwen" => {
1006                    assert_eq!(
1007                        found("/hooks/PreToolUse/0/matcher"),
1008                        json!("^run_shell_command$")
1009                    );
1010                    assert_eq!(
1011                        found("/hooks/PreToolUse/0/hooks/0/timeout"),
1012                        json!(20),
1013                        "seconds"
1014                    );
1015                }
1016                "factory" => {
1017                    assert_eq!(found("/PreToolUse/0/matcher"), json!("Execute"));
1018                    assert_eq!(found("/PreToolUse/0/hooks/0/command"), json!(command));
1019                }
1020                "windsurf" => {
1021                    assert_eq!(found("/hooks/pre_run_command/0/command"), json!(command));
1022                }
1023                "kiro" => {
1024                    assert_eq!(found("/version"), json!("v1"));
1025                    assert_eq!(found("/hooks/0/trigger"), json!("PreToolUse"));
1026                    assert_eq!(found("/hooks/0/action/command"), json!(command));
1027                }
1028                "crush" => {
1029                    assert_eq!(found("/hooks/PreToolUse/0/matcher"), json!("^bash$"));
1030                    assert_eq!(found("/hooks/PreToolUse/0/command"), json!(command));
1031                }
1032                _ => unreachable!(),
1033            }
1034        }
1035        let foreign = dir.join("foreign-cline");
1036        std::fs::create_dir_all(&foreign).unwrap();
1037        std::fs::write(foreign.join("PreToolUse"), "#!/bin/sh\necho mine\n").unwrap();
1038        let step = hook_step("cline", &foreign, base, false);
1039        assert!(
1040            !step.ok && step.detail.contains("someone else's"),
1041            "{}",
1042            step.detail
1043        );
1044    }
1045}