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