Skip to main content

kintsugi_intercept/
hook.rs

1//! Agent-CLI hook adapter.
2//!
3//! Many AI coding CLIs can run a command before they execute a tool, hand it a
4//! JSON description of the call, and read a decision back. This adapter bridges
5//! that payload to a [`ProposedCommand`], asks the daemon, and maps the
6//! [`Verdict`] to the CLI's decision protocol.
7//!
8//! The per-CLI wire formats live in [`crate::dialect`]; this module owns the
9//! shared *policy*: the daemon round-trip, the fail-closed-catastrophic
10//! backstop, and the Allow/Deny/Hold → decision mapping. Selecting a dialect is
11//! one `--agent <id>` flag, so a single `kintsugi-hook` binary serves every CLI.
12//!
13//! Fail-open: a malformed payload, a non-shell tool, or an unreachable daemon
14//! never blocks the agent — *except* a catastrophic command with the daemon
15//! down (denied fail-closed), or when `KINTSUGI_FAIL_CLOSED=1`.
16
17use kintsugi_core::{shell, Class, Decision, ProposedCommand};
18use kintsugi_daemon::Client;
19
20pub use crate::dialect::HookOutcome;
21
22use crate::dialect::{self, Dialect, Parsed, Resolved};
23
24/// Handle one hook payload for a given dialect, performing the daemon round-trip.
25pub fn handle_with(dialect: Dialect, input: &str) -> HookOutcome {
26    let parsed = match dialect.parse(input) {
27        Parsed::Shell(s) => s,
28        Parsed::NotShell => return HookOutcome::silent(),
29        Parsed::Bad(e) => {
30            // Never block the agent on a payload we couldn't parse.
31            eprintln!(
32                "kintsugi-hook: could not parse {} payload: {e}",
33                dialect.agent_id()
34            );
35            return HookOutcome::silent();
36        }
37    };
38
39    let argv = shell::split(&parsed.command);
40    let proposed = ProposedCommand::new(dialect.agent_id(), parsed.cwd, argv, parsed.command)
41        .with_session(parsed.session_id);
42
43    match Client::send(&proposed) {
44        Ok(verdict) => {
45            let resolved = match dialect::resolve(&verdict) {
46                // A catastrophic command is held in Kintsugi's queue but denied to
47                // the agent: an in-agent "allow" would run it with no snapshot,
48                // voiding reversibility. The agent can't offer that approval, so
49                // tell the human where it lives — otherwise the agent just sees a
50                // bare deny and silently works around it.
51                Resolved::Deny(reason)
52                    if verdict.decision == Decision::Hold
53                        && verdict.class == Class::Catastrophic =>
54                {
55                    Resolved::Deny(held_for_approval(&reason, &proposed.id.to_string()))
56                }
57                other => other,
58            };
59            dialect.format(&resolved)
60        }
61        Err(e) => {
62            // Daemon down: locally classify so a catastrophic command is still
63            // denied (fail-closed for the hard floor); non-catastrophic honors
64            // the fail-open default.
65            if kintsugi_core::classify(&proposed).class == Class::Catastrophic {
66                eprintln!(
67                    "kintsugi-hook: daemon unreachable; denying catastrophic (fail-closed): {e}"
68                );
69                dialect.format(&dialect::Resolved::Deny(
70                    "Kintsugi daemon unreachable; catastrophic command blocked (fail-closed)"
71                        .into(),
72                ))
73            } else if fail_closed() {
74                eprintln!("kintsugi-hook: daemon unreachable; denying (fail-closed): {e}");
75                dialect.format(&dialect::Resolved::Deny(
76                    "Kintsugi daemon unreachable (fail-closed)".into(),
77                ))
78            } else {
79                eprintln!("kintsugi-hook: warning: daemon unreachable; allowing unguarded: {e}");
80                dialect.pass()
81            }
82        }
83    }
84}
85
86/// Backwards-compatible Claude Code entry point.
87pub fn handle(input: &str) -> HookOutcome {
88    handle_with(Dialect::Claude, input)
89}
90
91/// Augment a catastrophic deny with the guarded way to run it yourself.
92///
93/// A hook is one-shot: by the time you see this, the agent already got the deny.
94/// The agent must never run a catastrophic itself, but the human can — via
95/// `kintsugi run <id>`, which snapshots first (so `kintsugi undo` works), runs the
96/// command in its original directory, and is gated on a real-terminal keypress
97/// (so an agent shelling out to it still can't self-approve). The queue id is
98/// the command's id, so we surface its short prefix here.
99fn held_for_approval(reason: &str, id: &str) -> String {
100    let short = id.get(..8).unwrap_or(id);
101    format!(
102        "{reason} Kintsugi blocked it; the agent will not run it. To run it yourself: \
103         `kintsugi run {short}` — it snapshots the affected files first (so `kintsugi undo` \
104         can roll them back) and confirms with a code typed at your terminal."
105    )
106}
107
108/// True if the admin-set fail-closed marker is present (an agent can't unset a
109/// root-owned marker) OR the `KINTSUGI_FAIL_CLOSED` env var opts in. The marker
110/// wins, so `KINTSUGI_FAIL_CLOSED=0` can't re-open the gate.
111fn fail_closed() -> bool {
112    kintsugi_daemon::is_fail_closed_marked()
113        || matches!(
114            std::env::var("KINTSUGI_FAIL_CLOSED").ok().as_deref(),
115            Some("1") | Some("true") | Some("yes")
116        )
117}
118
119/// Parse the `--agent <id>` flag from argv, defaulting to Claude Code.
120///
121/// Unknown ids fall back to Claude with a warning rather than failing — a
122/// misconfigured hook should still guard, not crash the agent.
123pub fn dialect_from_args<I: IntoIterator<Item = String>>(args: I) -> Dialect {
124    let mut it = args.into_iter();
125    while let Some(a) = it.next() {
126        let value = if let Some(v) = a.strip_prefix("--agent=") {
127            Some(v.to_string())
128        } else if a == "--agent" {
129            it.next()
130        } else {
131            None
132        };
133        if let Some(v) = value {
134            match Dialect::from_agent(&v) {
135                Some(d) => return d,
136                None => {
137                    eprintln!("kintsugi-hook: unknown --agent '{v}', defaulting to claude-code");
138                    return Dialect::Claude;
139                }
140            }
141        }
142    }
143    Dialect::Claude
144}
145
146/// Read the hook payload from stdin and emit the outcome, picking the dialect
147/// from the process arguments.
148pub fn run() -> i32 {
149    let dialect = dialect_from_args(std::env::args().skip(1));
150    let stdin = std::io::stdin();
151    let stdout = std::io::stdout();
152    run_io(dialect, stdin.lock(), stdout.lock())
153}
154
155/// The hook over arbitrary reader/writer for a given dialect (testable).
156pub fn run_io<R: std::io::Read, W: std::io::Write>(
157    dialect: Dialect,
158    mut reader: R,
159    mut writer: W,
160) -> i32 {
161    let mut input = String::new();
162    if let Err(e) = reader.read_to_string(&mut input) {
163        eprintln!("kintsugi-hook: failed to read stdin: {e}");
164        return 0; // fail-open
165    }
166    let outcome = handle_with(dialect, &input);
167    if let Some(out) = outcome.stdout {
168        let _ = writeln!(writer, "{out}");
169    }
170    outcome.exit_code
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn non_shell_tool_is_allowed_silently() {
179        let payload = r#"{"tool_name":"Edit","tool_input":{"file_path":"x"}}"#;
180        assert_eq!(handle(payload), HookOutcome::silent());
181    }
182
183    #[test]
184    fn held_for_approval_points_at_kintsugi_run_with_short_id() {
185        let msg = held_for_approval("recursively deletes files.", "abcd1234-5678-90ab-cdef");
186        assert!(msg.contains("recursively deletes files."));
187        assert!(
188            msg.contains("will not run"),
189            "must say the agent won't run it"
190        );
191        assert!(
192            msg.contains("kintsugi run abcd1234"),
193            "should give the guarded run command"
194        );
195        assert!(msg.contains("undo"), "should mention reversibility");
196    }
197
198    #[test]
199    fn malformed_payload_is_allowed_silently() {
200        assert_eq!(handle("not json"), HookOutcome::silent());
201    }
202
203    #[test]
204    fn empty_command_is_allowed_silently() {
205        let payload = r#"{"tool_name":"Bash","tool_input":{"command":"   "}}"#;
206        assert_eq!(handle(payload), HookOutcome::silent());
207    }
208
209    #[test]
210    fn run_io_allows_non_shell_tool_silently() {
211        let input = br#"{"tool_name":"Edit","tool_input":{"file_path":"x"}}"#;
212        let mut out = Vec::new();
213        let code = run_io(Dialect::Claude, &input[..], &mut out);
214        assert_eq!(code, 0);
215        assert!(out.is_empty(), "allow-silent writes nothing");
216    }
217
218    #[test]
219    fn dialect_from_args_reads_flag_forms() {
220        assert_eq!(
221            dialect_from_args(["--agent".to_string(), "cursor".to_string()]),
222            Dialect::Cursor
223        );
224        assert_eq!(
225            dialect_from_args(["--agent=qwen".to_string()]),
226            Dialect::Qwen
227        );
228        // No flag → Claude (backwards compatible).
229        assert_eq!(dialect_from_args(Vec::<String>::new()), Dialect::Claude);
230        // Unknown → Claude fallback.
231        assert_eq!(
232            dialect_from_args(["--agent=banana".to_string()]),
233            Dialect::Claude
234        );
235    }
236}