1use std::path::PathBuf;
27
28use kintsugi_core::{Class, Decision, Verdict};
29use serde::Deserialize;
30
31#[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#[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#[derive(Debug, PartialEq, Eq)]
53pub enum Parsed {
54 Shell(Shell),
56 NotShell,
58 Bad(String),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Resolved {
65 Allow,
66 Deny(String),
67 Ask(String),
68}
69
70#[derive(Debug, PartialEq, Eq)]
72pub struct HookOutcome {
73 pub stdout: Option<String>,
74 pub exit_code: i32,
75}
76
77impl HookOutcome {
78 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 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 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 fn supports_ask(self) -> bool {
126 !matches!(self, Dialect::Gemini)
128 }
129
130 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 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 fn is_shell_tool(self, name: &str) -> bool {
165 match self {
166 Dialect::Claude => matches!(name, "Bash" | "Shell" | "bash" | "shell"),
168 Dialect::Qwen => matches!(
171 name,
172 "run_shell_command" | "Bash" | "Shell" | "ShellTool" | "bash" | "shell"
173 ),
174 Dialect::Gemini => matches!(name, "run_shell_command" | "Shell" | "shell"),
176 Dialect::Codex => matches!(name, "Bash" | "Shell" | "bash" | "shell"),
178 _ => false,
179 }
180 }
181
182 pub fn format(self, resolved: &Resolved) -> HookOutcome {
184 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 pub fn pass(self) -> HookOutcome {
202 match self {
203 Dialect::Cursor => format_cursor(&Resolved::Allow),
204 _ => HookOutcome::silent(),
205 }
206 }
207}
208
209pub 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#[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 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
310fn 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
328fn 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 Resolved::Ask(r) => HookOutcome::json(serde_json::json!({
340 "decision": "deny",
341 "reason": r,
342 })),
343 }
344}
345
346fn 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
359fn 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
385fn 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 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}