Skip to main content

kintsugi_intercept/
dialect.rs

1//! Per-CLI hook dialects.
2//!
3//! Every supported agent CLI exposes a "run a command before the tool executes,
4//! read a decision back" hook — but each speaks a slightly different protocol on
5//! stdin and stdout. A [`Dialect`] knows how to (a) parse one CLI's PreTool
6//! payload into a normalized [`Shell`] command and (b) serialize the daemon's
7//! [`Verdict`] back into that CLI's decision protocol.
8//!
9//! The decision *policy* (what Allow/Deny/Hold means, and that a catastrophic
10//! hold becomes a deny) lives in [`crate::hook`] and is identical for every
11//! dialect — only the wire format differs here. This keeps the security spine in
12//! one place and the per-CLI glue mechanical.
13//!
14//! Protocols, as researched against each CLI's docs:
15//! - Claude Code / Qwen Code / Codex CLI: `{tool_name, tool_input.command}` in;
16//!   `{hookSpecificOutput:{permissionDecision: allow|deny|ask, …}}` out.
17//! - Gemini CLI: `{tool_name, tool_input.command}` in; `{decision: allow|deny}`
18//!   out (no native "ask" — an ambiguous hold is mapped to deny).
19//! - GitHub Copilot CLI: `{toolName, toolArgs.command}` in (camelCase);
20//!   `{permissionDecision: allow|deny|ask, permissionDecisionReason}` out.
21//! - Cursor CLI: `{command, cwd}` in (beforeShellExecution); `{permission:
22//!   allow|deny|ask, userMessage, agentMessage}` out.
23//! - OpenCode: no external-command hook — a bundled JS plugin bridges to us with
24//!   a simple `{command, cwd}` in / `{decision: allow|deny|ask, reason}` out.
25
26use std::path::PathBuf;
27
28use kintsugi_core::{Class, Decision, Verdict};
29use serde::Deserialize;
30
31/// Which agent CLI's hook protocol to speak.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Dialect {
34    Claude,
35    Qwen,
36    Gemini,
37    Copilot,
38    Cursor,
39    OpenCode,
40    Codex,
41}
42
43/// A normalized shell command extracted from a hook payload.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Shell {
46    pub command: String,
47    pub cwd: PathBuf,
48    pub session_id: Option<String>,
49}
50
51/// Result of parsing a hook payload.
52#[derive(Debug, PartialEq, Eq)]
53pub enum Parsed {
54    /// A shell command to send to the daemon.
55    Shell(Shell),
56    /// A well-formed payload that isn't a shell tool call — out of scope, pass.
57    NotShell,
58    /// An unparseable payload — fail open (never block on our own parse bug).
59    Bad(String),
60}
61
62/// The resolved, dialect-independent decision the daemon's verdict maps to.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Resolved {
65    Allow,
66    Deny(String),
67    Ask(String),
68}
69
70/// What the adapter prints on stdout and exits with for one payload.
71#[derive(Debug, PartialEq, Eq)]
72pub struct HookOutcome {
73    pub stdout: Option<String>,
74    pub exit_code: i32,
75}
76
77impl HookOutcome {
78    /// Emit nothing, exit 0 — "Kintsugi has no opinion, use your default flow."
79    pub fn silent() -> Self {
80        Self {
81            stdout: None,
82            exit_code: 0,
83        }
84    }
85    fn json(value: serde_json::Value) -> Self {
86        Self {
87            stdout: Some(value.to_string()),
88            exit_code: 0,
89        }
90    }
91}
92
93impl Dialect {
94    /// Map an `--agent` value to a dialect. Accepts the CLI's stable id.
95    pub fn from_agent(s: &str) -> Option<Self> {
96        Some(match s {
97            "claude" | "claude-code" => Dialect::Claude,
98            "qwen" => Dialect::Qwen,
99            "gemini" => Dialect::Gemini,
100            "copilot" => Dialect::Copilot,
101            "cursor" => Dialect::Cursor,
102            "opencode" => Dialect::OpenCode,
103            "codex" => Dialect::Codex,
104            _ => return None,
105        })
106    }
107
108    /// The `agent` tag stamped onto the [`kintsugi_core::ProposedCommand`] so the
109    /// log and TUI attribute the command to the right CLI.
110    pub fn agent_id(self) -> &'static str {
111        match self {
112            Dialect::Claude => "claude-code",
113            Dialect::Qwen => "qwen",
114            Dialect::Gemini => "gemini",
115            Dialect::Copilot => "copilot",
116            Dialect::Cursor => "cursor",
117            Dialect::OpenCode => "opencode",
118            Dialect::Codex => "codex",
119        }
120    }
121
122    /// Does this CLI have a native "ask the user" decision? If not, an ambiguous
123    /// hold is mapped to deny — safe per the monotonic-caution rule (the model
124    /// may only add caution, never remove it).
125    fn supports_ask(self) -> bool {
126        // Gemini's decision enum is allow/deny/block — no interactive ask.
127        !matches!(self, Dialect::Gemini)
128    }
129
130    /// Parse one CLI's hook payload into a normalized command.
131    pub fn parse(self, input: &str) -> Parsed {
132        match self {
133            Dialect::Claude | Dialect::Qwen | Dialect::Gemini | Dialect::Codex => {
134                self.parse_tool_style(input)
135            }
136            Dialect::Copilot => parse_copilot(input),
137            Dialect::Cursor | Dialect::OpenCode => parse_flat(input),
138        }
139    }
140
141    /// Claude/Qwen/Gemini share `{tool_name, tool_input:{command}}`.
142    fn parse_tool_style(self, input: &str) -> Parsed {
143        let p: ToolStyle = match serde_json::from_str(input) {
144            Ok(p) => p,
145            Err(e) => return Parsed::Bad(e.to_string()),
146        };
147        let tool = p.tool_name.as_deref().unwrap_or_default();
148        if !self.is_shell_tool(tool) {
149            return Parsed::NotShell;
150        }
151        match p.tool_input.and_then(|t| t.command) {
152            Some(c) if !c.trim().is_empty() => Parsed::Shell(Shell {
153                command: c,
154                cwd: cwd_or_current(p.cwd),
155                session_id: p.session_id,
156            }),
157            _ => Parsed::NotShell,
158        }
159    }
160
161    /// Is `name` this dialect's shell tool? The canonical name delivered in the
162    /// payload differs per CLI; we also accept the cross-CLI aliases so a
163    /// version that reports a different label still matches.
164    fn is_shell_tool(self, name: &str) -> bool {
165        match self {
166            // Claude reports Bash; accept the lowercase/Shell variants too.
167            Dialect::Claude => matches!(name, "Bash" | "Shell" | "bash" | "shell"),
168            // Qwen's canonical name is run_shell_command, with Claude-compat
169            // aliases Bash/Shell/ShellTool.
170            Dialect::Qwen => matches!(
171                name,
172                "run_shell_command" | "Bash" | "Shell" | "ShellTool" | "bash" | "shell"
173            ),
174            // Gemini only ever reports run_shell_command.
175            Dialect::Gemini => matches!(name, "run_shell_command" | "Shell" | "shell"),
176            // Codex modeled its hooks on Claude Code; its shell tool is Bash.
177            Dialect::Codex => matches!(name, "Bash" | "Shell" | "bash" | "shell"),
178            _ => false,
179        }
180    }
181
182    /// Serialize a resolved decision into this CLI's stdout protocol.
183    pub fn format(self, resolved: &Resolved) -> HookOutcome {
184        // Downgrade Ask -> Deny for dialects without a native ask.
185        let resolved = match (resolved, self.supports_ask()) {
186            (Resolved::Ask(reason), false) => &Resolved::Deny(reason.clone()),
187            (other, _) => other,
188        };
189        match self {
190            Dialect::Claude | Dialect::Qwen | Dialect::Codex => format_claude_style(resolved),
191            Dialect::Gemini => format_gemini(resolved),
192            Dialect::Copilot => format_copilot(resolved),
193            Dialect::Cursor => format_cursor(resolved),
194            Dialect::OpenCode => format_opencode(resolved),
195        }
196    }
197
198    /// The "allow / no opinion" output used on the fail-open path and for SAFE
199    /// commands. Most CLIs treat empty output as "proceed normally"; Cursor's
200    /// beforeShellExecution gate is answered with an explicit allow.
201    pub fn pass(self) -> HookOutcome {
202        match self {
203            Dialect::Cursor => format_cursor(&Resolved::Allow),
204            _ => HookOutcome::silent(),
205        }
206    }
207}
208
209/// Map a daemon verdict to the dialect-independent decision.
210///
211/// A catastrophic *hold* becomes a deny, not an ask: letting the CLI's own UI
212/// one-click "allow" it would run it with no Kintsugi snapshot, voiding the
213/// reversibility guarantee. Only ambiguous holds become an ask.
214pub fn resolve(verdict: &Verdict) -> Resolved {
215    match verdict.decision {
216        Decision::Allow => Resolved::Allow,
217        Decision::Deny => Resolved::Deny(verdict.reason.clone()),
218        Decision::Hold if verdict.class == Class::Catastrophic => {
219            Resolved::Deny(verdict.reason.clone())
220        }
221        Decision::Hold => Resolved::Ask(verdict.reason.clone()),
222    }
223}
224
225// ----- payload structs -------------------------------------------------------
226
227#[derive(Debug, Deserialize)]
228struct ToolStyle {
229    #[serde(default)]
230    cwd: Option<String>,
231    #[serde(default)]
232    session_id: Option<String>,
233    #[serde(default)]
234    tool_name: Option<String>,
235    #[serde(default)]
236    tool_input: Option<CmdInput>,
237}
238
239#[derive(Debug, Deserialize)]
240struct CmdInput {
241    #[serde(default)]
242    command: Option<String>,
243}
244
245#[derive(Debug, Deserialize)]
246struct CopilotStyle {
247    #[serde(default)]
248    cwd: Option<String>,
249    #[serde(default, rename = "sessionId")]
250    session_id: Option<String>,
251    #[serde(default, rename = "toolName")]
252    tool_name: Option<String>,
253    #[serde(default, rename = "toolArgs")]
254    tool_args: Option<CmdInput>,
255}
256
257#[derive(Debug, Deserialize)]
258struct FlatStyle {
259    #[serde(default)]
260    command: Option<String>,
261    #[serde(default)]
262    cwd: Option<String>,
263    #[serde(default)]
264    conversation_id: Option<String>,
265    #[serde(default)]
266    session_id: Option<String>,
267}
268
269fn parse_copilot(input: &str) -> Parsed {
270    let p: CopilotStyle = match serde_json::from_str(input) {
271        Ok(p) => p,
272        Err(e) => return Parsed::Bad(e.to_string()),
273    };
274    // Copilot's shell tool is named "bash" (and "powershell" on Windows).
275    let tool = p.tool_name.as_deref().unwrap_or_default();
276    if !matches!(tool, "bash" | "shell") {
277        return Parsed::NotShell;
278    }
279    match p.tool_args.and_then(|t| t.command) {
280        Some(c) if !c.trim().is_empty() => Parsed::Shell(Shell {
281            command: c,
282            cwd: cwd_or_current(p.cwd),
283            session_id: p.session_id,
284        }),
285        _ => Parsed::NotShell,
286    }
287}
288
289fn parse_flat(input: &str) -> Parsed {
290    let p: FlatStyle = match serde_json::from_str(input) {
291        Ok(p) => p,
292        Err(e) => return Parsed::Bad(e.to_string()),
293    };
294    match p.command {
295        Some(c) if !c.trim().is_empty() => Parsed::Shell(Shell {
296            command: c,
297            cwd: cwd_or_current(p.cwd),
298            session_id: p.session_id.or(p.conversation_id),
299        }),
300        _ => Parsed::NotShell,
301    }
302}
303
304fn cwd_or_current(cwd: Option<String>) -> PathBuf {
305    cwd.filter(|s| !s.is_empty())
306        .map(PathBuf::from)
307        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
308}
309
310// ----- output formats --------------------------------------------------------
311
312/// Claude Code & Qwen Code: `hookSpecificOutput.permissionDecision`.
313fn format_claude_style(resolved: &Resolved) -> HookOutcome {
314    let (decision, reason) = match resolved {
315        Resolved::Allow => return HookOutcome::silent(),
316        Resolved::Deny(r) => ("deny", r),
317        Resolved::Ask(r) => ("ask", r),
318    };
319    HookOutcome::json(serde_json::json!({
320        "hookSpecificOutput": {
321            "hookEventName": "PreToolUse",
322            "permissionDecision": decision,
323            "permissionDecisionReason": reason,
324        }
325    }))
326}
327
328/// Gemini CLI: `{decision: allow|deny, reason}` (no ask — already downgraded).
329fn format_gemini(resolved: &Resolved) -> HookOutcome {
330    match resolved {
331        Resolved::Allow => HookOutcome::silent(),
332        Resolved::Deny(r) => HookOutcome::json(serde_json::json!({
333            "decision": "deny",
334            "reason": r,
335            "systemMessage": format!("Kintsugi: {r}"),
336        })),
337        // Unreachable: Gemini doesn't support ask, so resolve→format downgrades
338        // it to Deny before we get here. Treat defensively as a deny.
339        Resolved::Ask(r) => HookOutcome::json(serde_json::json!({
340            "decision": "deny",
341            "reason": r,
342        })),
343    }
344}
345
346/// GitHub Copilot CLI: flat `{permissionDecision, permissionDecisionReason}`.
347fn format_copilot(resolved: &Resolved) -> HookOutcome {
348    let (decision, reason) = match resolved {
349        Resolved::Allow => return HookOutcome::silent(),
350        Resolved::Deny(r) => ("deny", r),
351        Resolved::Ask(r) => ("ask", r),
352    };
353    HookOutcome::json(serde_json::json!({
354        "permissionDecision": decision,
355        "permissionDecisionReason": reason,
356    }))
357}
358
359/// Cursor CLI: `{permission, userMessage, agentMessage}`. We emit both camelCase
360/// and snake_case message keys because Cursor's docs are inconsistent across
361/// versions about which it reads; `permission` is the only load-bearing field.
362fn format_cursor(resolved: &Resolved) -> HookOutcome {
363    let (permission, reason) = match resolved {
364        Resolved::Allow => ("allow", None),
365        Resolved::Deny(r) => ("deny", Some(r)),
366        Resolved::Ask(r) => ("ask", Some(r)),
367    };
368    let mut obj = serde_json::json!({ "permission": permission });
369    if let Some(r) = reason {
370        let map = obj.as_object_mut().unwrap();
371        map.insert(
372            "userMessage".into(),
373            serde_json::json!(format!("Kintsugi: {r}")),
374        );
375        map.insert("agentMessage".into(), serde_json::json!(r));
376        map.insert(
377            "user_message".into(),
378            serde_json::json!(format!("Kintsugi: {r}")),
379        );
380        map.insert("agent_message".into(), serde_json::json!(r));
381    }
382    HookOutcome::json(obj)
383}
384
385/// OpenCode bridge: `{decision: allow|deny|ask, reason}`. The bundled JS plugin
386/// reads this and throws (aborting the tool call) on deny/ask.
387fn format_opencode(resolved: &Resolved) -> HookOutcome {
388    let (decision, reason) = match resolved {
389        Resolved::Allow => ("allow", String::new()),
390        Resolved::Deny(r) => ("deny", r.clone()),
391        Resolved::Ask(r) => ("ask", r.clone()),
392    };
393    HookOutcome::json(serde_json::json!({ "decision": decision, "reason": reason }))
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn shell(cmd: &str) -> Parsed {
401        Parsed::Shell(Shell {
402            command: cmd.into(),
403            cwd: std::env::current_dir().unwrap_or_default(),
404            session_id: None,
405        })
406    }
407
408    #[test]
409    fn from_agent_accepts_known_ids() {
410        assert_eq!(Dialect::from_agent("claude"), Some(Dialect::Claude));
411        assert_eq!(Dialect::from_agent("claude-code"), Some(Dialect::Claude));
412        assert_eq!(Dialect::from_agent("qwen"), Some(Dialect::Qwen));
413        assert_eq!(Dialect::from_agent("gemini"), Some(Dialect::Gemini));
414        assert_eq!(Dialect::from_agent("copilot"), Some(Dialect::Copilot));
415        assert_eq!(Dialect::from_agent("cursor"), Some(Dialect::Cursor));
416        assert_eq!(Dialect::from_agent("opencode"), Some(Dialect::OpenCode));
417        assert_eq!(Dialect::from_agent("codex"), Some(Dialect::Codex));
418        assert_eq!(Dialect::from_agent("nope"), None);
419    }
420
421    #[test]
422    fn codex_parses_bash_and_formats_claude_style() {
423        let p = Dialect::Codex.parse(r#"{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}"#);
424        assert_eq!(p, shell("rm -rf /"));
425        let out = Dialect::Codex.format(&Resolved::Deny("boom".into()));
426        let v: serde_json::Value = serde_json::from_str(&out.stdout.unwrap()).unwrap();
427        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
428    }
429
430    #[test]
431    fn claude_parses_bash_command() {
432        let p = Dialect::Claude.parse(r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#);
433        match p {
434            Parsed::Shell(s) => assert_eq!(s.command, "ls"),
435            other => panic!("expected shell, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn claude_non_shell_tool_is_not_shell() {
441        let p = Dialect::Claude.parse(r#"{"tool_name":"Edit","tool_input":{"file_path":"x"}}"#);
442        assert_eq!(p, Parsed::NotShell);
443    }
444
445    #[test]
446    fn qwen_parses_run_shell_command_canonical_name() {
447        let p = Dialect::Qwen
448            .parse(r#"{"tool_name":"run_shell_command","tool_input":{"command":"rm -rf x"}}"#);
449        assert_eq!(p, shell("rm -rf x"));
450    }
451
452    #[test]
453    fn gemini_parses_run_shell_command() {
454        let p = Dialect::Gemini
455            .parse(r#"{"tool_name":"run_shell_command","tool_input":{"command":"git push"}}"#);
456        assert_eq!(p, shell("git push"));
457    }
458
459    #[test]
460    fn gemini_ignores_bash_alias() {
461        // Gemini never emits "Bash"; treat it as not-our-shell-tool.
462        let p = Dialect::Gemini.parse(r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#);
463        assert_eq!(p, Parsed::NotShell);
464    }
465
466    #[test]
467    fn copilot_parses_camelcase_toolargs() {
468        let p = Dialect::Copilot
469            .parse(r#"{"toolName":"bash","toolArgs":{"command":"sudo rm"},"sessionId":"s1"}"#);
470        match p {
471            Parsed::Shell(s) => {
472                assert_eq!(s.command, "sudo rm");
473                assert_eq!(s.session_id.as_deref(), Some("s1"));
474            }
475            other => panic!("expected shell, got {other:?}"),
476        }
477    }
478
479    #[test]
480    fn cursor_parses_flat_command() {
481        let p = Dialect::Cursor.parse(
482            r#"{"command":"git status","cwd":"/tmp","hook_event_name":"beforeShellExecution","conversation_id":"c1"}"#,
483        );
484        match p {
485            Parsed::Shell(s) => {
486                assert_eq!(s.command, "git status");
487                assert_eq!(s.cwd, PathBuf::from("/tmp"));
488                assert_eq!(s.session_id.as_deref(), Some("c1"));
489            }
490            other => panic!("expected shell, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn opencode_bridge_parses_flat_command() {
496        let p = Dialect::OpenCode.parse(r#"{"command":"dd if=/dev/zero","cwd":"/work"}"#);
497        assert_eq!(
498            p,
499            Parsed::Shell(Shell {
500                command: "dd if=/dev/zero".into(),
501                cwd: PathBuf::from("/work"),
502                session_id: None,
503            })
504        );
505    }
506
507    #[test]
508    fn bad_payload_is_bad_for_every_dialect() {
509        for d in [
510            Dialect::Claude,
511            Dialect::Qwen,
512            Dialect::Gemini,
513            Dialect::Copilot,
514            Dialect::Cursor,
515            Dialect::OpenCode,
516            Dialect::Codex,
517        ] {
518            assert!(matches!(d.parse("not json"), Parsed::Bad(_)), "{d:?}");
519        }
520    }
521
522    #[test]
523    fn claude_style_allow_is_silent_deny_is_json() {
524        assert_eq!(
525            Dialect::Claude.format(&Resolved::Allow),
526            HookOutcome::silent()
527        );
528        let out = Dialect::Claude.format(&Resolved::Deny("nope".into()));
529        let v: serde_json::Value = serde_json::from_str(&out.stdout.unwrap()).unwrap();
530        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
531        assert_eq!(v["hookSpecificOutput"]["permissionDecisionReason"], "nope");
532        assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
533    }
534
535    #[test]
536    fn qwen_ask_round_trips() {
537        let out = Dialect::Qwen.format(&Resolved::Ask("held".into()));
538        let v: serde_json::Value = serde_json::from_str(&out.stdout.unwrap()).unwrap();
539        assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
540    }
541
542    #[test]
543    fn gemini_downgrades_ask_to_deny() {
544        let out = Dialect::Gemini.format(&Resolved::Ask("held".into()));
545        let v: serde_json::Value = serde_json::from_str(&out.stdout.unwrap()).unwrap();
546        assert_eq!(v["decision"], "deny", "gemini has no ask; must deny");
547    }
548
549    #[test]
550    fn copilot_flat_decision_shape() {
551        let out = Dialect::Copilot.format(&Resolved::Deny("x".into()));
552        let v: serde_json::Value = serde_json::from_str(&out.stdout.unwrap()).unwrap();
553        assert_eq!(v["permissionDecision"], "deny");
554        assert_eq!(v["permissionDecisionReason"], "x");
555    }
556
557    #[test]
558    fn cursor_allow_is_explicit_and_deny_has_both_message_cases() {
559        let allow = Dialect::Cursor.format(&Resolved::Allow);
560        let v: serde_json::Value = serde_json::from_str(&allow.stdout.unwrap()).unwrap();
561        assert_eq!(v["permission"], "allow");
562
563        let deny = Dialect::Cursor.format(&Resolved::Deny("bad".into()));
564        let v: serde_json::Value = serde_json::from_str(&deny.stdout.unwrap()).unwrap();
565        assert_eq!(v["permission"], "deny");
566        assert_eq!(v["agentMessage"], "bad");
567        assert_eq!(v["agent_message"], "bad");
568    }
569
570    #[test]
571    fn opencode_decision_shape() {
572        let out = Dialect::OpenCode.format(&Resolved::Ask("hold".into()));
573        let v: serde_json::Value = serde_json::from_str(&out.stdout.unwrap()).unwrap();
574        assert_eq!(v["decision"], "ask");
575        assert_eq!(v["reason"], "hold");
576    }
577
578    #[test]
579    fn cursor_pass_is_explicit_allow_others_silent() {
580        assert_eq!(
581            Dialect::Cursor.pass(),
582            format_cursor(&Resolved::Allow),
583            "cursor must answer its gate with an explicit allow"
584        );
585        assert_eq!(Dialect::Claude.pass(), HookOutcome::silent());
586        assert_eq!(Dialect::Gemini.pass(), HookOutcome::silent());
587    }
588
589    #[test]
590    fn resolve_maps_catastrophic_hold_to_deny() {
591        use kintsugi_core::Verdict;
592        let v = Verdict::rules(Class::Catastrophic, Decision::Hold, "boom");
593        assert_eq!(resolve(&v), Resolved::Deny("boom".into()));
594    }
595
596    #[test]
597    fn resolve_maps_ambiguous_hold_to_ask() {
598        use kintsugi_core::Verdict;
599        let v = Verdict::rules(Class::Ambiguous, Decision::Hold, "maybe");
600        assert_eq!(resolve(&v), Resolved::Ask("maybe".into()));
601    }
602}