1use std::path::PathBuf;
31
32use kintsugi_core::{Class, Decision, Verdict};
33use serde::Deserialize;
34
35#[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#[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#[derive(Debug, PartialEq, Eq)]
58pub enum Parsed {
59 Shell(Shell),
61 NotShell,
63 Bad(String),
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Resolved {
70 Allow,
71 Deny(String),
72 Ask(String),
73}
74
75#[derive(Debug, PartialEq, Eq)]
77pub struct HookOutcome {
78 pub stdout: Option<String>,
79 pub exit_code: i32,
80}
81
82impl HookOutcome {
83 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 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 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 fn supports_ask(self) -> bool {
133 !matches!(self, Dialect::Gemini | Dialect::Antigravity)
136 }
137
138 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 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 fn is_shell_tool(self, name: &str) -> bool {
174 match self {
175 Dialect::Claude => matches!(name, "Bash" | "Shell" | "bash" | "shell"),
177 Dialect::Qwen => matches!(
180 name,
181 "run_shell_command" | "Bash" | "Shell" | "ShellTool" | "bash" | "shell"
182 ),
183 Dialect::Gemini => matches!(name, "run_shell_command" | "Shell" | "shell"),
185 Dialect::Codex => matches!(name, "Bash" | "Shell" | "bash" | "shell"),
187 _ => false,
188 }
189 }
190
191 pub fn format(self, resolved: &Resolved) -> HookOutcome {
193 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 pub fn pass(self) -> HookOutcome {
212 match self {
213 Dialect::Cursor => format_cursor(&Resolved::Allow),
214 Dialect::Antigravity => format_antigravity(&Resolved::Allow),
217 _ => HookOutcome::silent(),
218 }
219 }
220}
221
222pub 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#[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 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#[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 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
379fn 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
397fn 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 Resolved::Ask(r) => HookOutcome::json(serde_json::json!({
409 "decision": "deny",
410 "reason": r,
411 })),
412 }
413}
414
415fn 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
428fn 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
454fn 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
465fn format_antigravity(resolved: &Resolved) -> HookOutcome {
468 let (decision, reason) = match resolved {
469 Resolved::Allow => ("allow", None),
470 Resolved::Deny(r) => ("deny", Some(r)),
471 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 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 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 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}