Skip to main content

coding_agent_hooks/
protocol.rs

1//! Hook protocol abstraction for multi-agent support.
2//!
3//! Each coding agent sends/receives hook JSON in a different format.
4//! The [`HookProtocol`] trait encapsulates these differences so the
5//! core permission logic works identically regardless of which agent
6//! is calling.
7//!
8//! Most methods have default implementations that handle the common case.
9//! Adding a new agent typically requires overriding only [`HookProtocol::agent`]
10//! and [`HookProtocol::parse_tool_use`]. Override format methods only if
11//! the agent uses a non-standard output format.
12
13use anyhow::Result;
14use serde_json::Value;
15
16use crate::agents::{AgentKind, resolve_permission_mode};
17use crate::input::{SessionStartHookInput, StopHookInput, ToolUseHookInput};
18
19/// Abstraction over agent-specific hook JSON formats.
20///
21/// Each agent (Claude Code, Gemini CLI, etc.) implements this trait to handle:
22/// - Parsing its native JSON stdin into normalized internal types
23/// - Formatting decisions back into the agent's expected JSON output
24/// - Rewriting tool inputs for sandbox enforcement
25///
26/// # Adding a New Agent
27///
28/// Only two methods are required: [`agent`](HookProtocol::agent) and
29/// [`parse_tool_use`](HookProtocol::parse_tool_use). All other methods
30/// have sensible defaults. Override them only when your agent's protocol
31/// diverges from the common JSON format.
32pub trait HookProtocol {
33    /// Which agent this protocol handles. **Required.**
34    fn agent(&self) -> AgentKind;
35
36    /// Parse the agent's PreToolUse JSON into a `ToolUseHookInput`. **Required.**
37    ///
38    /// The returned `tool_name` MUST be the internal (Claude-style) name,
39    /// translated via [`resolve_tool_name`](crate::agents::resolve_tool_name).
40    /// The original agent-native name is preserved in `original_tool_name`.
41    fn parse_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput>;
42
43    /// Parse the agent's PostToolUse JSON into a `ToolUseHookInput`.
44    ///
45    /// Default: delegates to `parse_tool_use`.
46    fn parse_post_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput> {
47        self.parse_tool_use(raw)
48    }
49
50    /// Parse the agent's SessionStart JSON.
51    ///
52    /// Default: extracts common fields (session_id, cwd, source, model).
53    fn parse_session_start(&self, raw: &Value) -> Result<SessionStartHookInput> {
54        Ok(SessionStartHookInput {
55            session_id: json_str(raw, "session_id").to_string(),
56            transcript_path: json_str(raw, "transcript_path").to_string(),
57            cwd: json_str(raw, "cwd").to_string(),
58            permission_mode: raw
59                .get("permission_mode")
60                .and_then(|v| v.as_str())
61                .map(|m| resolve_permission_mode(self.agent(), m).to_string()),
62            hook_event_name: json_str_or(raw, "hook_event_name", "SessionStart").to_string(),
63            source: raw.get("source").and_then(|v| v.as_str()).map(String::from),
64            model: raw.get("model").and_then(|v| v.as_str()).map(String::from),
65        })
66    }
67
68    /// Parse the agent's Stop JSON.
69    ///
70    /// Default: extracts common fields (session_id, cwd).
71    fn parse_stop(&self, raw: &Value) -> Result<StopHookInput> {
72        Ok(StopHookInput {
73            session_id: json_str(raw, "session_id").to_string(),
74            transcript_path: json_str(raw, "transcript_path").to_string(),
75            cwd: json_str(raw, "cwd").to_string(),
76            hook_event_name: json_str_or(raw, "hook_event_name", "Stop").to_string(),
77        })
78    }
79
80    /// Format an "allow" decision in the agent's expected output format.
81    ///
82    /// Default: `{ "decision": "allow", "reason": "..." }`
83    fn format_allow(
84        &self,
85        reason: Option<&str>,
86        _context: Option<&str>,
87        updated_input: Option<Value>,
88    ) -> Value {
89        let mut output = serde_json::json!({ "decision": "allow" });
90        if let Some(r) = reason {
91            output["reason"] = Value::String(r.to_string());
92        }
93        if let Some(ui) = updated_input {
94            output["updated_input"] = ui;
95        }
96        output
97    }
98
99    /// Format a "deny" decision in the agent's expected output format.
100    ///
101    /// Default: `{ "decision": "deny", "reason": "..." }`
102    fn format_deny(&self, reason: &str, _context: Option<&str>) -> Value {
103        serde_json::json!({
104            "decision": "deny",
105            "reason": reason
106        })
107    }
108
109    /// Format an "ask" decision (fall through to agent's native prompt).
110    ///
111    /// Default: `{ "continue": true }` (passthrough to agent's native UI).
112    fn format_ask(&self, _reason: Option<&str>, _context: Option<&str>) -> Value {
113        serde_json::json!({ "continue": true })
114    }
115
116    /// Format a session-start response with optional context injection.
117    ///
118    /// Default: `{ "decision": "allow", "additional_context": "..." }`
119    fn format_session_start(&self, context: Option<&str>) -> Value {
120        let mut output = serde_json::json!({ "decision": "allow" });
121        if let Some(ctx) = context {
122            output["additional_context"] = Value::String(ctx.to_string());
123        }
124        output
125    }
126
127    /// Rewrite a shell command's tool_input to run through a sandbox.
128    ///
129    /// Default: rewrites the `command` field for tools with internal name "Bash".
130    fn rewrite_for_sandbox(&self, input: &ToolUseHookInput, sandbox_cmd: &str) -> Option<Value> {
131        if input.tool_name != "Bash" {
132            return None;
133        }
134        let command = input.tool_input.get("command")?.as_str()?;
135        let sandboxed = format!(
136            "{} shell --cwd {} -c {}",
137            shell_escape(sandbox_cmd),
138            shell_escape(&input.cwd),
139            shell_escape(command),
140        );
141        let mut updated = input.tool_input.clone();
142        updated
143            .as_object_mut()?
144            .insert("command".into(), Value::String(sandboxed));
145        Some(updated)
146    }
147
148    /// Context string injected into the agent's session at startup.
149    ///
150    /// Default: generic hook-active context.
151    fn session_context(&self) -> &str {
152        "Agent hooks are active and enforcing policy on this session."
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Helpers
158// ---------------------------------------------------------------------------
159
160/// Extract a string field from JSON, returning "" if missing.
161pub fn json_str<'a>(raw: &'a Value, field: &str) -> &'a str {
162    raw.get(field).and_then(|v| v.as_str()).unwrap_or("")
163}
164
165/// Extract a string field from JSON with a default value.
166pub fn json_str_or<'a>(raw: &'a Value, field: &str, default: &'a str) -> &'a str {
167    raw.get(field).and_then(|v| v.as_str()).unwrap_or(default)
168}
169
170/// Extract a string from one of several possible field names.
171pub fn json_str_any<'a>(raw: &'a Value, fields: &[&str]) -> &'a str {
172    for field in fields {
173        if let Some(s) = raw.get(*field).and_then(|v| v.as_str()) {
174            return s;
175        }
176    }
177    ""
178}
179
180/// Extract an optional Value from one of several possible field names.
181pub fn json_value_any(raw: &Value, fields: &[&str]) -> Option<Value> {
182    for field in fields {
183        if let Some(v) = raw.get(*field) {
184            return Some(v.clone());
185        }
186    }
187    None
188}
189
190/// Shell-escape a string for safe inclusion in a shell command.
191pub fn shell_escape(s: &str) -> String {
192    format!("'{}'", s.replace('\'', "'\\''"))
193}
194
195/// Construct the appropriate protocol implementation for an agent.
196pub fn get_protocol(agent: AgentKind) -> Box<dyn HookProtocol> {
197    match agent {
198        AgentKind::Claude => Box::new(crate::agents::claude::ClaudeProtocol),
199        AgentKind::Gemini => Box::new(crate::agents::gemini::GeminiProtocol),
200        AgentKind::Codex => Box::new(crate::agents::codex::CodexProtocol),
201        AgentKind::AmazonQ => Box::new(crate::agents::amazonq::AmazonQProtocol),
202        AgentKind::OpenCode => Box::new(crate::agents::opencode::OpenCodeProtocol),
203        AgentKind::Copilot => Box::new(crate::agents::copilot::CopilotProtocol),
204    }
205}