diff --git a/AGENTS.md b/AGENTS.md index 62c8643..6ebe160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,8 @@ ## Layout -* *clash* Clash binary + library (includes `src/agents/` for multi-agent protocol adapters, `src/policy_gen/` for shared policy generation definitions) +* *clash* Clash binary + library (includes `src/policy_gen/` for shared policy generation definitions) +* *coding-agent-hooks* Agent-agnostic hook protocol types, agent adapters (Claude, Gemini, Codex, AmazonQ, OpenCode, Copilot), and tool name normalization * *clash-policy* Policy language: parsing, IR, compilation, and evaluation (extracted from `clash` to break circular dep with `clash-lsp`) * *clash_starlark* Starlark policy evaluator — compiles `.star` files to JSON policy format * *clash-plugin* Claude Code plugin (hooks.json, .claude-plugin definitions) diff --git a/Cargo.lock b/Cargo.lock index 6423c21..1ff1836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "clash_notify", "clash_starlark", "claude_settings", + "coding-agent-hooks", "console", "crossterm", "dialoguer", @@ -999,6 +1000,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "coding-agent-hooks" +version = "0.7.1" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "color-print" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index d71aae9..bf514d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "claude_settings", "clash_starlark", "clash-lsp", + "coding-agent-hooks", "clester", "clash-brush-parser", "clash-brush-core", @@ -160,3 +161,4 @@ clash-policy = { path = "clash-policy", version = "0.7.1" } clash_notify = { path = "clash_notify", version = "0.7.1" } clash_starlark = { path = "clash_starlark", version = "0.7.1" } claude_settings = { path = "claude_settings", version = "0.7.1" } +coding-agent-hooks = { path = "coding-agent-hooks", version = "0.7.1", features = ["clap"] } diff --git a/clash/Cargo.toml b/clash/Cargo.toml index b0d0df0..95ab51d 100644 --- a/clash/Cargo.toml +++ b/clash/Cargo.toml @@ -53,6 +53,7 @@ clash-policy = { workspace = true } clash_notify = { workspace = true } clash_starlark = { workspace = true } claude_settings = { workspace = true } +coding-agent-hooks = { workspace = true } clash-brush-builtins = { workspace = true } clash-brush-core = { workspace = true } clash-brush-interactive = { workspace = true } diff --git a/clash/src/agents/mod.rs b/clash/src/agents/mod.rs index 1a0ee1b..dc91191 100644 --- a/clash/src/agents/mod.rs +++ b/clash/src/agents/mod.rs @@ -1,50 +1,12 @@ //! Multi-agent support for Clash. //! -//! Clash supports multiple coding agents (Claude Code, Gemini CLI, Codex CLI, -//! Amazon Q CLI, OpenCode, Copilot CLI). This module provides: -//! -//! - [`AgentKind`] — enum identifying each supported agent -//! - Canonical tool alias table — maps agent-native tool names to internal names -//! - [`protocol::HookProtocol`] — trait abstracting agent-specific hook JSON formats - -pub mod amazonq; -pub mod claude; -pub mod codex; -pub mod copilot; -pub mod gemini; -pub mod opencode; -pub mod protocol; - -use std::fmt; -use std::str::FromStr; - -use clap::ValueEnum; - -/// Identifies which coding agent is calling Clash. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] -pub enum AgentKind { - Claude, - Gemini, - Codex, - #[value(name = "amazonq")] - AmazonQ, - #[value(name = "opencode")] - OpenCode, - Copilot, -} +//! This module re-exports the core agent types and protocol implementations +//! from [`coding_agent_hooks`] and adds clash-specific extensions (dialog UI +//! traits). -impl fmt::Display for AgentKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AgentKind::Claude => write!(f, "claude"), - AgentKind::Gemini => write!(f, "gemini"), - AgentKind::Codex => write!(f, "codex"), - AgentKind::AmazonQ => write!(f, "amazonq"), - AgentKind::OpenCode => write!(f, "opencode"), - AgentKind::Copilot => write!(f, "copilot"), - } - } -} +// Re-export everything from the shared crate. +pub use coding_agent_hooks::agents::*; +pub use coding_agent_hooks::protocol; impl crate::dialog::SelectItem for AgentKind { fn label(&self) -> &str { @@ -80,443 +42,3 @@ impl crate::dialog::SelectItem for AgentKind { ] } } - -impl FromStr for AgentKind { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "claude" => Ok(AgentKind::Claude), - "gemini" => Ok(AgentKind::Gemini), - "codex" => Ok(AgentKind::Codex), - "amazonq" | "amazon-q" | "amazon_q" => Ok(AgentKind::AmazonQ), - "opencode" | "open-code" => Ok(AgentKind::OpenCode), - "copilot" => Ok(AgentKind::Copilot), - _ => Err(format!("unknown agent: {s}")), - } - } -} - -// --------------------------------------------------------------------------- -// Canonical permission mode table -// --------------------------------------------------------------------------- - -/// Maps agent-specific permission mode strings to canonical Clash modes. -/// -/// Canonical modes: "default", "plan", "edit", "unrestricted". -/// Unknown modes pass through as-is so agent-specific extensions still work. -struct ModeAlias { - /// Clash's canonical mode name. - canonical: &'static str, - /// Agent-specific names that map to this mode. - agent_names: &'static [(AgentKind, &'static str)], -} - -const MODE_ALIASES: &[ModeAlias] = &[ - ModeAlias { - canonical: "default", - agent_names: &[(AgentKind::Claude, "default")], - }, - ModeAlias { - canonical: "plan", - agent_names: &[(AgentKind::Claude, "plan")], - }, - ModeAlias { - canonical: "edit", - agent_names: &[(AgentKind::Claude, "edit")], - }, - ModeAlias { - canonical: "unrestricted", - agent_names: &[(AgentKind::Claude, "dangerously_skip_permissions")], - }, -]; - -/// Given an agent's native permission mode string, return the canonical mode. -/// -/// Case-insensitive. Returns the original string unchanged if no mapping exists. -pub fn resolve_permission_mode<'a>(agent: AgentKind, native_mode: &'a str) -> &'a str { - let lower = native_mode.to_lowercase(); - for alias in MODE_ALIASES { - for &(a, name) in alias.agent_names { - if a == agent && name.to_lowercase() == lower { - // Safety: canonical strings are 'static, but we return 'a to - // keep the API simple — leak the static lifetime down. - return alias.canonical; - } - } - } - native_mode -} - -// --------------------------------------------------------------------------- -// Canonical tool alias table -// --------------------------------------------------------------------------- - -/// Maps a Clash canonical name and agent-native names to a single internal name. -/// -/// Internal names use Claude Code's tool names (e.g. "Bash", "Read") since -/// they are already embedded throughout the policy engine, match tree, and -/// permission logic. This avoids a flag-day rename. -struct ToolAlias { - /// Clash's user-facing canonical name (e.g. "shell", "read"). - canonical: &'static str, - /// Internal name used by the policy engine (Claude-style, e.g. "Bash"). - internal: &'static str, - /// Agent-specific native names that map to this tool. - agent_names: &'static [(AgentKind, &'static str)], -} - -/// The ONE source of truth for tool name mappings across all agents. -/// -/// Each entry is curated case-by-case. Tools that don't have a clean -/// cross-agent equivalent are NOT in this table — they stay agent-specific. -const TOOL_ALIASES: &[ToolAlias] = &[ - ToolAlias { - canonical: "shell", - internal: "Bash", - agent_names: &[ - (AgentKind::Claude, "Bash"), - (AgentKind::Gemini, "run_shell_command"), - (AgentKind::Codex, "shell"), - (AgentKind::AmazonQ, "execute_bash"), - (AgentKind::OpenCode, "bash"), - (AgentKind::Copilot, "bash"), - ], - }, - ToolAlias { - canonical: "read", - internal: "Read", - agent_names: &[ - (AgentKind::Claude, "Read"), - (AgentKind::Gemini, "read_file"), - (AgentKind::AmazonQ, "fs_read"), - (AgentKind::OpenCode, "read"), - (AgentKind::Copilot, "view"), - ], - }, - ToolAlias { - canonical: "write", - internal: "Write", - agent_names: &[ - (AgentKind::Claude, "Write"), - (AgentKind::Gemini, "write_file"), - (AgentKind::AmazonQ, "fs_write"), - (AgentKind::OpenCode, "write"), - ], - }, - ToolAlias { - canonical: "edit", - internal: "Edit", - agent_names: &[ - (AgentKind::Claude, "Edit"), - (AgentKind::Gemini, "replace"), - (AgentKind::OpenCode, "edit"), - (AgentKind::Copilot, "edit"), - ], - }, - ToolAlias { - canonical: "glob", - internal: "Glob", - agent_names: &[ - (AgentKind::Claude, "Glob"), - (AgentKind::Gemini, "glob"), - (AgentKind::OpenCode, "glob"), - ], - }, - ToolAlias { - canonical: "grep", - internal: "Grep", - agent_names: &[ - (AgentKind::Claude, "Grep"), - (AgentKind::Gemini, "grep_search"), - (AgentKind::OpenCode, "grep"), - ], - }, - ToolAlias { - canonical: "web_fetch", - internal: "WebFetch", - agent_names: &[ - (AgentKind::Claude, "WebFetch"), - (AgentKind::Gemini, "web_fetch"), - (AgentKind::OpenCode, "webfetch"), - ], - }, - ToolAlias { - canonical: "web_search", - internal: "WebSearch", - agent_names: &[ - (AgentKind::Claude, "WebSearch"), - (AgentKind::Gemini, "google_web_search"), - (AgentKind::Codex, "web_search"), - (AgentKind::OpenCode, "websearch"), - ], - }, -]; - -/// Given an agent's native tool name, return the internal (Claude-style) name. -/// -/// Case-insensitive. Returns the original name unchanged if no mapping exists -/// (unknown/agent-specific tools pass through as-is). -pub fn resolve_tool_name(agent: AgentKind, native_name: &str) -> &str { - let lower = native_name.to_lowercase(); - for alias in TOOL_ALIASES { - for &(a, name) in alias.agent_names { - if a == agent && name.to_lowercase() == lower { - return alias.internal; - } - } - } - // No mapping found — return the original name. - // This is intentionally a leak-free identity: the caller's &str is returned. - // For unknown tools, the policy engine matches on the native name. - native_name -} - -/// Given a Clash canonical name (e.g. "shell"), return the internal name (e.g. "Bash"). -/// -/// Case-insensitive. Used by the policy compiler to resolve user-facing aliases. -pub fn canonical_to_internal(clash_name: &str) -> Option<&'static str> { - let lower = clash_name.to_lowercase(); - TOOL_ALIASES - .iter() - .find(|a| a.canonical.to_lowercase() == lower) - .map(|a| a.internal) -} - -/// Resolve any tool name to its internal form. -/// -/// Accepts canonical names ("shell"), internal names ("Bash"), or any -/// agent-native name ("run_shell_command"). Case-insensitive. -/// Returns the internal name if found, or None if unrecognized. -pub fn resolve_any_to_internal(name: &str) -> Option<&'static str> { - let lower = name.to_lowercase(); - for alias in TOOL_ALIASES { - // Match canonical name - if alias.canonical.to_lowercase() == lower { - return Some(alias.internal); - } - // Match internal name - if alias.internal.to_lowercase() == lower { - return Some(alias.internal); - } - // Match any agent-native name - for &(_, agent_name) in alias.agent_names { - if agent_name.to_lowercase() == lower { - return Some(alias.internal); - } - } - } - None -} - -/// Given an internal name (e.g. "Bash"), return the Clash canonical name (e.g. "shell"). -/// -/// Case-insensitive. -pub fn internal_to_canonical(internal_name: &str) -> Option<&'static str> { - let lower = internal_name.to_lowercase(); - TOOL_ALIASES - .iter() - .find(|a| a.internal.to_lowercase() == lower) - .map(|a| a.canonical) -} - -/// Return the best user-facing display name for a tool. -/// -/// Prefers the canonical name ("shell") if one exists, otherwise returns -/// the internal name as-is. Used for stderr messages, deny reasons, and -/// other user-visible output. -pub fn display_name(internal_name: &str) -> &str { - internal_to_canonical(internal_name).unwrap_or(internal_name) -} - -/// Given an internal name and target agent, return the agent's native tool name. -/// -/// Used for formatting output in the agent's expected vocabulary. -pub fn internal_to_agent(agent: AgentKind, internal_name: &str) -> Option<&'static str> { - let lower = internal_name.to_lowercase(); - for alias in TOOL_ALIASES { - if alias.internal.to_lowercase() == lower { - for &(a, name) in alias.agent_names { - if a == agent { - return Some(name); - } - } - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_claude_bash() { - assert_eq!(resolve_tool_name(AgentKind::Claude, "Bash"), "Bash"); - } - - #[test] - fn resolve_gemini_shell() { - assert_eq!( - resolve_tool_name(AgentKind::Gemini, "run_shell_command"), - "Bash" - ); - } - - #[test] - fn resolve_codex_shell() { - assert_eq!(resolve_tool_name(AgentKind::Codex, "shell"), "Bash"); - } - - #[test] - fn resolve_amazonq_bash() { - assert_eq!( - resolve_tool_name(AgentKind::AmazonQ, "execute_bash"), - "Bash" - ); - } - - #[test] - fn resolve_case_insensitive() { - assert_eq!(resolve_tool_name(AgentKind::Claude, "bash"), "Bash"); - assert_eq!(resolve_tool_name(AgentKind::Claude, "BASH"), "Bash"); - assert_eq!( - resolve_tool_name(AgentKind::Gemini, "RUN_SHELL_COMMAND"), - "Bash" - ); - } - - #[test] - fn resolve_unknown_passthrough() { - assert_eq!( - resolve_tool_name(AgentKind::Claude, "SomeCustomTool"), - "SomeCustomTool" - ); - } - - #[test] - fn canonical_to_internal_works() { - assert_eq!(canonical_to_internal("shell"), Some("Bash")); - assert_eq!(canonical_to_internal("read"), Some("Read")); - assert_eq!(canonical_to_internal("SHELL"), Some("Bash")); - assert_eq!(canonical_to_internal("unknown"), None); - } - - #[test] - fn internal_to_canonical_works() { - assert_eq!(internal_to_canonical("Bash"), Some("shell")); - assert_eq!(internal_to_canonical("Read"), Some("read")); - assert_eq!(internal_to_canonical("UnknownTool"), None); - } - - #[test] - fn internal_to_agent_works() { - assert_eq!( - internal_to_agent(AgentKind::Gemini, "Bash"), - Some("run_shell_command") - ); - assert_eq!( - internal_to_agent(AgentKind::AmazonQ, "Read"), - Some("fs_read") - ); - assert_eq!(internal_to_agent(AgentKind::Codex, "Glob"), None); - } - - #[test] - fn resolve_any_canonical() { - assert_eq!(resolve_any_to_internal("shell"), Some("Bash")); - assert_eq!(resolve_any_to_internal("read"), Some("Read")); - } - - #[test] - fn resolve_any_internal() { - assert_eq!(resolve_any_to_internal("Bash"), Some("Bash")); - assert_eq!(resolve_any_to_internal("bash"), Some("Bash")); - assert_eq!(resolve_any_to_internal("BASH"), Some("Bash")); - } - - #[test] - fn resolve_any_agent_native() { - assert_eq!(resolve_any_to_internal("run_shell_command"), Some("Bash")); - assert_eq!(resolve_any_to_internal("execute_bash"), Some("Bash")); - assert_eq!(resolve_any_to_internal("fs_read"), Some("Read")); - } - - #[test] - fn resolve_any_unknown() { - assert_eq!(resolve_any_to_internal("CustomTool"), None); - } - - // --- resolve_permission_mode tests --- - - #[test] - fn resolve_mode_claude_default() { - assert_eq!( - resolve_permission_mode(AgentKind::Claude, "default"), - "default" - ); - } - - #[test] - fn resolve_mode_claude_plan() { - assert_eq!(resolve_permission_mode(AgentKind::Claude, "plan"), "plan"); - } - - #[test] - fn resolve_mode_claude_dangerously_skip() { - assert_eq!( - resolve_permission_mode(AgentKind::Claude, "dangerously_skip_permissions"), - "unrestricted" - ); - } - - #[test] - fn resolve_mode_case_insensitive() { - assert_eq!( - resolve_permission_mode(AgentKind::Claude, "DANGEROUSLY_SKIP_PERMISSIONS"), - "unrestricted" - ); - } - - #[test] - fn resolve_mode_unknown_passthrough() { - assert_eq!( - resolve_permission_mode(AgentKind::Claude, "custom_mode"), - "custom_mode" - ); - } - - #[test] - fn resolve_mode_other_agents_default() { - assert_eq!( - resolve_permission_mode(AgentKind::Gemini, "default"), - "default" - ); - assert_eq!( - resolve_permission_mode(AgentKind::Codex, "default"), - "default" - ); - } - - #[test] - fn all_aliases_have_consistent_internal_names() { - // Every internal name in the alias table should be a known Claude tool - let claude_names: Vec<&str> = TOOL_ALIASES - .iter() - .flat_map(|a| { - a.agent_names - .iter() - .filter(|(ak, _)| *ak == AgentKind::Claude) - }) - .map(|(_, name)| *name) - .collect(); - for alias in TOOL_ALIASES { - assert!( - claude_names.contains(&alias.internal), - "internal name '{}' for canonical '{}' is not a Claude tool name", - alias.internal, - alias.canonical - ); - } - } -} diff --git a/clash/src/cmd/hooks.rs b/clash/src/cmd/hooks.rs index 22ba2a3..f4d8d8a 100644 --- a/clash/src/cmd/hooks.rs +++ b/clash/src/cmd/hooks.rs @@ -8,11 +8,13 @@ use crate::agents::protocol::{HookProtocol, get_protocol}; use crate::cli::{HookCmd, HookSubcommand}; use crate::hooks::{HookOutput, HookSpecificOutput, ToolUseHookInput, is_interactive_tool}; use crate::permissions::check_permission; -use crate::policy::Effect; use crate::session_policy; use crate::settings::{ClashSettings, HookContext}; use crate::trace; +// Re-import the hook Effect (from coding-agent-hooks) and the policy Effect. +use coding_agent_hooks::output::Effect as HookEffect; + /// Generate a fallback session ID when the agent doesn't provide one. /// /// Uses the parent PID as a stable identifier — every `clash hook` invocation @@ -22,8 +24,6 @@ fn fallback_session_id(agent: AgentKind) -> String { format!("{agent}-{ppid}") } -use claude_settings::PermissionRule; - impl HookCmd { /// Handle hook when clash is disabled — drain stdin and return pass-through. fn run_disabled(&self) -> Result<()> { @@ -98,7 +98,7 @@ impl HookCmd { } else { // Update session stats for the status line (only here, not in // log_decision, to avoid double-counting PermissionRequest). - if let Some(effect) = extract_effect(&output) { + if let Some(effect) = hook_effect_to_policy(output.effect()) { crate::audit::update_session_stats( &input.session_id, &input.tool_name, @@ -124,7 +124,7 @@ impl HookCmd { // Sync trace with the policy decision for this tool use. let decision = input.tool_use_id.as_ref().and_then(|id| { - let effect = extract_effect(&output)?; + let effect = hook_effect_to_policy(output.effect())?; Some(trace::PolicyDecision { tool_use_id: id.clone(), tool_name: Some(input.tool_name.clone()), @@ -300,24 +300,22 @@ impl HookCmd { } } -fn extract_effect(output: &HookOutput) -> Option { - match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => match pre.permission_decision { - Some(PermissionRule::Allow) => Some(Effect::Allow), - Some(PermissionRule::Deny) => Some(Effect::Deny), - Some(PermissionRule::Ask) => Some(Effect::Ask), - Some(PermissionRule::Unset) | None => None, - }, - _ => None, +/// Convert a hook-level Effect to a policy-level Effect. +fn hook_effect_to_policy(effect: Option) -> Option { + match effect { + Some(HookEffect::Allow) => Some(crate::policy::Effect::Allow), + Some(HookEffect::Deny) => Some(crate::policy::Effect::Deny), + Some(HookEffect::Ask) => Some(crate::policy::Effect::Ask), + None => None, } } fn is_ask_decision(output: &HookOutput) -> bool { - matches!(extract_effect(output), Some(Effect::Ask)) + matches!(output.effect(), Some(HookEffect::Ask)) } fn is_deny_decision(output: &HookOutput) -> bool { - matches!(extract_effect(output), Some(Effect::Deny)) + matches!(output.effect(), Some(HookEffect::Deny)) } /// Convert a Claude-format HookOutput into the agent's protocol format. @@ -342,10 +340,10 @@ fn hook_output_to_protocol(protocol: &dyn HookProtocol, output: &HookOutput) -> _ => (None, None, None), }; - match extract_effect(output) { - Some(Effect::Allow) => protocol.format_allow(reason, context, updated_input), - Some(Effect::Deny) => protocol.format_deny(reason.unwrap_or("policy: denied"), context), - Some(Effect::Ask) => protocol.format_ask(reason, context), + match output.effect() { + Some(HookEffect::Allow) => protocol.format_allow(reason, context, updated_input), + Some(HookEffect::Deny) => protocol.format_deny(reason.unwrap_or("policy: denied"), context), + Some(HookEffect::Ask) => protocol.format_ask(reason, context), None => { // No decision (e.g., continue_execution) — allow passthrough protocol.format_allow(None, None, None) diff --git a/clash/src/handlers.rs b/clash/src/handlers.rs index 6ee06c7..07f285c 100644 --- a/clash/src/handlers.rs +++ b/clash/src/handlers.rs @@ -7,14 +7,13 @@ use tracing::{Level, info, instrument, warn}; use crate::hooks::{ - HookOutput, HookSpecificOutput, SessionStartHookInput, ToolUseHookInput, is_interactive_tool, + Effect, HookOutput, HookSpecificOutput, SessionStartHookInput, ToolUseHookInput, + is_interactive_tool, }; use crate::notifications; use crate::permissions::check_permission; use crate::settings::ClashSettings; -use claude_settings::PermissionRule; - /// Handle a permission request — decide whether to approve or deny on behalf of user. /// /// When the policy evaluates to "ask" and a Zulip bot is configured, the request @@ -34,12 +33,12 @@ pub fn handle_permission_request( let is_deny = matches!( pre_tool_result.hook_specific_output, Some(HookSpecificOutput::PreToolUse(ref pre)) - if matches!(pre.permission_decision, Some(PermissionRule::Deny)) + if matches!(pre.permission_decision, Some(Effect::Deny)) ); let is_allow = matches!( pre_tool_result.hook_specific_output, Some(HookSpecificOutput::PreToolUse(ref pre)) - if matches!(pre.permission_decision, Some(PermissionRule::Allow)) + if matches!(pre.permission_decision, Some(Effect::Allow)) ); if is_deny { let reason = match &pre_tool_result.hook_specific_output { @@ -65,8 +64,8 @@ pub fn handle_permission_request( // Claude Code validates that hookEventName matches the event type. Ok(match pre_tool_result.hook_specific_output { Some(HookSpecificOutput::PreToolUse(ref pre)) => match pre.permission_decision { - Some(PermissionRule::Allow) => HookOutput::approve_permission(None), - Some(PermissionRule::Deny) => { + Some(Effect::Allow) => HookOutput::approve_permission(None), + Some(Effect::Deny) => { let reason = pre .permission_decision_reason .clone() diff --git a/clash/src/hooks.rs b/clash/src/hooks.rs index 9468692..6c4f9ee 100644 --- a/clash/src/hooks.rs +++ b/clash/src/hooks.rs @@ -1,556 +1,35 @@ -use std::io::{Read, Write}; - -use claude_settings::PermissionRule; -use serde::{Deserialize, Serialize}; -use tracing::{Level, instrument}; - -/// The complete hook input received from Claude Code via stdin -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum HookInput { - /// PreToolUse, PostToolUse, PermissionRequest events - ToolUse(ToolUseHookInput), - /// SessionStart events - SessionStart(SessionStartHookInput), -} - -/// Hook input for tool-related events (PreToolUse, PostToolUse, PermissionRequest) -/// -/// The `tool_name` field carries the internal (Claude-style) name after protocol -/// normalization. The original agent-native name is preserved in `original_tool_name`. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct ToolUseHookInput { - pub session_id: String, - pub transcript_path: String, - pub cwd: String, - pub permission_mode: String, - pub hook_event_name: String, - pub tool_name: String, - pub tool_input: serde_json::Value, - pub tool_use_id: Option, - /// Present in PostToolUse events - #[serde(default)] - pub tool_response: Option, - - // -- Multi-agent fields (not deserialized from JSON, set by protocol layer) -- - /// Which agent sent this hook input. - #[serde(skip)] - pub agent: Option, - /// The agent's original tool name before normalization (e.g. "run_shell_command"). - /// For Claude, this is the same as `tool_name`. - #[serde(skip)] - pub original_tool_name: Option, -} - -/// Hook input for SessionStart events -#[derive(Debug, Clone, Deserialize, Default)] -pub struct SessionStartHookInput { - #[serde(default)] - pub session_id: String, - #[serde(default)] - pub transcript_path: String, - #[serde(default)] - pub cwd: String, - #[serde(default)] - pub permission_mode: Option, - #[serde(default)] - pub hook_event_name: String, - #[serde(default)] - pub source: Option, - #[serde(default)] - pub model: Option, -} - -impl SessionStartHookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } -} - -/// Hook input for Stop events (conversation turn ended without a tool call) -#[derive(Debug, Clone, Deserialize, Default)] -pub struct StopHookInput { - #[serde(default)] - pub session_id: String, - #[serde(default)] - pub transcript_path: String, - #[serde(default)] - pub cwd: String, - #[serde(default)] - pub hook_event_name: String, -} - -impl StopHookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } -} - -impl HookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } - - /// Parse from stdin (convenience wrapper for production) - #[instrument(level = Level::TRACE)] - pub fn from_stdin() -> anyhow::Result { - Self::from_reader(std::io::stdin().lock()) - } - - /// Get the hook event name - pub fn hook_event_name(&self) -> &str { - match self { - HookInput::ToolUse(input) => &input.hook_event_name, - HookInput::SessionStart(input) => &input.hook_event_name, - } - } - - /// Get the session ID - pub fn session_id(&self) -> &str { - match self { - HookInput::ToolUse(input) => &input.session_id, - HookInput::SessionStart(input) => &input.session_id, - } - } - - /// Check if this is a tool use event - pub fn as_tool_use(&self) -> Option<&ToolUseHookInput> { - match self { - HookInput::ToolUse(input) => Some(input), - _ => None, - } - } - - /// Check if this is a session start event - pub fn as_session_start(&self) -> Option<&SessionStartHookInput> { - match self { - HookInput::SessionStart(input) => Some(input), - _ => None, - } - } -} - -impl ToolUseHookInput { - /// Parse from any reader (for testability) - #[instrument(level = Level::TRACE, skip(reader))] - pub fn from_reader(reader: impl Read) -> anyhow::Result { - Ok(serde_json::from_reader(reader)?) - } +//! Hook input/output types for the agent hook protocol. +//! +//! This module re-exports the core types from [`coding_agent_hooks`] and adds +//! clash-specific extensions (typed tool input parsing, interactive tool +//! classification). + +// Re-export everything from the shared crate so existing `use crate::hooks::*` +// paths continue to work. +pub use coding_agent_hooks::input::*; +pub use coding_agent_hooks::output::*; + +// Clash-specific typed tool input parsing. +pub use crate::claude::tools::{BashInput, EditInput, ReadInput, ToolInput, WriteInput}; +/// Extension trait adding clash-specific methods to [`ToolUseHookInput`]. +pub trait ToolUseHookInputExt { /// Get typed tool input based on tool_name. /// /// Delegates to [`crate::claude::tools::ToolInput::parse`] for the - /// canonical tool-name → typed-struct mapping. - #[instrument(level = Level::TRACE, skip(self))] - pub fn typed_tool_input(&self) -> crate::claude::tools::ToolInput { - crate::claude::tools::ToolInput::parse(&self.tool_name, self.tool_input.clone()) - } -} - -// Typed tool input structs live in crate::claude::tools. -// Re-export for backwards compatibility. -pub use crate::claude::tools::{BashInput, EditInput, ReadInput, ToolInput, WriteInput}; - -/// Hook-specific output for PreToolUse -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PreToolUseOutput { - pub hook_event_name: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub permission_decision: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub permission_decision_reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub updated_input: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub additional_context: Option, -} - -/// Decision behavior for PermissionRequest responses -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum PermissionBehavior { - Allow, - Deny, -} - -/// Decision structure for PermissionRequest responses -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PermissionDecision { - pub behavior: PermissionBehavior, - #[serde(skip_serializing_if = "Option::is_none")] - pub updated_input: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub interrupt: Option, + /// canonical tool-name -> typed-struct mapping. + fn typed_tool_input(&self) -> crate::claude::tools::ToolInput; } -/// Hook-specific output for PermissionRequest -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PermissionRequestOutput { - pub hook_event_name: &'static str, - pub decision: PermissionDecision, -} - -/// Hook-specific output for SessionStart -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SessionStartOutput { - pub hook_event_name: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub additional_context: Option, -} - -/// Output for PostToolUse hooks — provides advisory context back to Claude. -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PostToolUseOutput { - pub hook_event_name: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - pub additional_context: Option, -} - -/// Hook-specific output variants -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(untagged)] -pub enum HookSpecificOutput { - PreToolUse(PreToolUseOutput), - PostToolUse(PostToolUseOutput), - PermissionRequest(PermissionRequestOutput), - SessionStart(SessionStartOutput), -} - -/// The complete hook output sent to Claude Code via stdout -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct HookOutput { - #[serde(rename = "continue")] - pub should_continue: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub hook_specific_output: Option, -} - -impl HookOutput { - /// Private helper to construct a PreToolUse response with the given decision. - fn pretooluse_output( - decision: PermissionRule, - reason: Option, - context: Option, - updated_input: Option, - ) -> Self { - Self { - should_continue: true, - hook_specific_output: Some(HookSpecificOutput::PreToolUse(PreToolUseOutput { - hook_event_name: "PreToolUse", - permission_decision: Some(decision), - permission_decision_reason: reason, - updated_input, - additional_context: context, - })), - } - } - - /// Create an "allow" response for PreToolUse - bypasses permission system. - #[instrument(level = Level::TRACE)] - pub fn allow(reason: Option, context: Option) -> Self { - Self::pretooluse_output(PermissionRule::Allow, reason, context, None) - } - - /// Create a "deny" response for PreToolUse - prevents tool execution. - #[instrument(level = Level::TRACE)] - pub fn deny(reason: String, context: Option) -> Self { - Self::pretooluse_output(PermissionRule::Deny, Some(reason), context, None) - } - - /// Create an "ask" response for PreToolUse - prompts user for confirmation. - #[instrument(level = Level::TRACE)] - pub fn ask(reason: Option, context: Option) -> Self { - Self::pretooluse_output(PermissionRule::Ask, reason, context, None) - } - - /// Approve a permission request on behalf of the user - #[instrument(level = Level::TRACE)] - pub fn approve_permission(updated_input: Option) -> Self { - Self { - should_continue: true, - hook_specific_output: Some(HookSpecificOutput::PermissionRequest( - PermissionRequestOutput { - hook_event_name: "PermissionRequest", - decision: PermissionDecision { - behavior: PermissionBehavior::Allow, - updated_input, - message: None, - interrupt: None, - }, - }, - )), - } - } - - /// Deny a permission request on behalf of the user - #[instrument(level = Level::TRACE)] - pub fn deny_permission(message: String, interrupt: bool) -> Self { - Self { - should_continue: true, - hook_specific_output: Some(HookSpecificOutput::PermissionRequest( - PermissionRequestOutput { - hook_event_name: "PermissionRequest", - decision: PermissionDecision { - behavior: PermissionBehavior::Deny, - updated_input: None, - message: Some(message), - interrupt: Some(interrupt), - }, - }, - )), - } - } - - /// Set the updated_input field on a PreToolUse response. - /// This rewrites the tool input before Claude Code executes it. - #[instrument(level = Level::TRACE, skip(self))] - pub fn set_updated_input(&mut self, updated_input: serde_json::Value) { - if let Some(HookSpecificOutput::PreToolUse(ref mut pre)) = self.hook_specific_output { - pre.updated_input = Some(updated_input); - } - } - - /// Create a SessionStart response with optional context about the session setup. - #[instrument(level = Level::TRACE)] - pub fn session_start(additional_context: Option) -> Self { - Self { - should_continue: true, - hook_specific_output: Some(HookSpecificOutput::SessionStart(SessionStartOutput { - hook_event_name: "SessionStart", - additional_context, - })), - } - } - - /// Create a PostToolUse response with optional advisory context. - #[instrument(level = Level::TRACE)] - pub fn post_tool_use(additional_context: Option) -> Self { - match additional_context { - Some(ctx) => Self { - should_continue: true, - hook_specific_output: Some(HookSpecificOutput::PostToolUse(PostToolUseOutput { - hook_event_name: "PostToolUse", - additional_context: Some(ctx), - })), - }, - None => Self::continue_execution(), - } - } - - /// Continue execution without making a decision (for informational hooks) - #[instrument(level = Level::TRACE)] - pub fn continue_execution() -> Self { - Self { - should_continue: true, - hook_specific_output: None, - } - } - - /// Write response to any writer (for testability) - #[instrument(level = Level::TRACE, skip(self, writer))] - pub fn write_to(&self, mut writer: impl Write) -> anyhow::Result<()> { - serde_json::to_writer(&mut writer, self)?; - writeln!(writer)?; - Ok(()) - } - - /// Write response to stdout (convenience wrapper for production) - #[instrument(level = Level::TRACE, skip(self))] - pub fn write_stdout(&self) -> anyhow::Result<()> { - self.write_to(std::io::stdout().lock()) +impl ToolUseHookInputExt for ToolUseHookInput { + fn typed_tool_input(&self) -> crate::claude::tools::ToolInput { + crate::claude::tools::ToolInput::parse(&self.tool_name, self.tool_input.clone()) } } -/// Tools that require user interaction via Claude Code's native UI. +/// Tools that require user interaction via the agent's native UI. /// Auto-approving these would skip the interaction, so non-deny /// decisions are converted to passthrough. pub fn is_interactive_tool(tool_name: &str) -> bool { crate::claude::tools::is_interactive(tool_name) } - -/// Exit codes for hook responses -pub mod exit_code { - /// Success - response written to stdout - pub const SUCCESS: i32 = 0; - /// Blocking error - stderr message fed to Claude - pub const BLOCKING_ERROR: i32 = 2; -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_tool_use_json() -> &'static str { - r#"{ - "session_id": "test-session", - "transcript_path": "/tmp/transcript.jsonl", - "cwd": "/home/user/project", - "permission_mode": "default", - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": {"command": "git status", "timeout": 120000}, - "tool_use_id": "toolu_01ABC" - }"# - } - - #[test] - fn test_parse_tool_use_input() { - let input = HookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap(); - assert_eq!(input.session_id(), "test-session"); - assert_eq!(input.hook_event_name(), "PreToolUse"); - - let tool_use = input.as_tool_use().expect("Should be ToolUse variant"); - assert_eq!(tool_use.tool_name, "Bash"); - } - - #[test] - fn test_typed_bash_input() { - let input = ToolUseHookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap(); - match input.typed_tool_input() { - ToolInput::Bash(bash) => { - assert_eq!(bash.command, "git status"); - assert_eq!(bash.timeout, Some(120000)); - } - other => panic!("Expected Bash input, got {:?}", other), - } - } - - #[test] - fn test_typed_write_input() { - let json = r#"{ - "session_id": "test", - "transcript_path": "/tmp/t.jsonl", - "cwd": "/tmp", - "permission_mode": "default", - "hook_event_name": "PreToolUse", - "tool_name": "Write", - "tool_input": {"file_path": "/tmp/test.txt", "content": "hello world"}, - "tool_use_id": "toolu_02" - }"#; - let input = ToolUseHookInput::from_reader(json.as_bytes()).unwrap(); - match input.typed_tool_input() { - ToolInput::Write(write) => { - assert_eq!(write.file_path, "/tmp/test.txt"); - assert_eq!(write.content, "hello world"); - } - other => panic!("Expected Write input, got {:?}", other), - } - } - - #[test] - fn test_output_allow() { - let output = HookOutput::allow(Some("Safe command".into()), None); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow"); - assert_eq!( - json["hookSpecificOutput"]["permissionDecisionReason"], - "Safe command" - ); - } - - #[test] - fn test_output_deny() { - let output = HookOutput::deny("Dangerous command".into(), None); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); - assert_eq!( - json["hookSpecificOutput"]["permissionDecisionReason"], - "Dangerous command" - ); - } - - #[test] - fn test_output_ask() { - let output = HookOutput::ask(None, None); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "ask"); - assert!(json["hookSpecificOutput"]["permissionDecisionReason"].is_null()); - } - - #[test] - fn test_approve_permission() { - let output = HookOutput::approve_permission(None); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!( - json["hookSpecificOutput"]["hookEventName"], - "PermissionRequest" - ); - assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); - assert!(json["hookSpecificOutput"]["decision"]["updatedInput"].is_null()); - } - - #[test] - fn test_approve_permission_with_updated_input() { - let updated = serde_json::json!({"command": "ls -la"}); - let output = HookOutput::approve_permission(Some(updated.clone())); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!( - json["hookSpecificOutput"]["hookEventName"], - "PermissionRequest" - ); - assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); - assert_eq!( - json["hookSpecificOutput"]["decision"]["updatedInput"], - updated - ); - } - - #[test] - fn test_deny_permission() { - let output = HookOutput::deny_permission("Not allowed".into(), true); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!( - json["hookSpecificOutput"]["hookEventName"], - "PermissionRequest" - ); - assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); - assert_eq!( - json["hookSpecificOutput"]["decision"]["message"], - "Not allowed" - ); - assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], true); - } - - #[test] - fn test_deny_permission_no_interrupt() { - let output = HookOutput::deny_permission("Try again".into(), false); - let mut buf = Vec::new(); - output.write_to(&mut buf).unwrap(); - - let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); - assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); - assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], false); - } -} diff --git a/clash/src/permissions.rs b/clash/src/permissions.rs index e9e19d9..9f5ec83 100644 --- a/clash/src/permissions.rs +++ b/clash/src/permissions.rs @@ -1,7 +1,7 @@ use crate::policy::Effect; use tracing::{Level, info, instrument, warn}; -use crate::hooks::{HookOutput, ToolInput, ToolUseHookInput}; +use crate::hooks::{HookOutput, ToolInput, ToolUseHookInput, ToolUseHookInputExt}; use crate::settings::ClashSettings; /// Check if a tool invocation should be allowed, denied, or require user confirmation. diff --git a/clash/src/test_utils.rs b/clash/src/test_utils.rs index 8c9b474..80031b8 100644 --- a/clash/src/test_utils.rs +++ b/clash/src/test_utils.rs @@ -240,18 +240,12 @@ impl TestEnvironment { /// Extract the permission decision [`Effect`] from a [`HookOutput`]. pub fn get_effect(output: &HookOutput) -> Option { - match &output.hook_specific_output { - Some(HookSpecificOutput::PreToolUse(pre)) => { - pre.permission_decision - .as_ref() - .and_then(|rule| match rule { - claude_settings::PermissionRule::Allow => Some(Effect::Allow), - claude_settings::PermissionRule::Deny => Some(Effect::Deny), - claude_settings::PermissionRule::Ask => Some(Effect::Ask), - _ => None, - }) - } - _ => None, + use coding_agent_hooks::output::Effect as HookEffect; + match output.effect() { + Some(HookEffect::Allow) => Some(Effect::Allow), + Some(HookEffect::Deny) => Some(Effect::Deny), + Some(HookEffect::Ask) => Some(Effect::Ask), + None => None, } } diff --git a/coding-agent-hooks/Cargo.toml b/coding-agent-hooks/Cargo.toml new file mode 100644 index 0000000..baa954f --- /dev/null +++ b/coding-agent-hooks/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "coding-agent-hooks" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Agent-agnostic hook protocol types and adapters for AI coding agents" +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +include = ["src/**/*", "Cargo.toml"] + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } + +[features] +default = [] +clap = ["dep:clap"] diff --git a/clash/src/agents/amazonq.rs b/coding-agent-hooks/src/agents/amazonq.rs similarity index 97% rename from clash/src/agents/amazonq.rs rename to coding-agent-hooks/src/agents/amazonq.rs index 00fb984..2fc8452 100644 --- a/clash/src/agents/amazonq.rs +++ b/coding-agent-hooks/src/agents/amazonq.rs @@ -5,9 +5,9 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str}; use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use crate::input::ToolUseHookInput; +use crate::protocol::{HookProtocol, json_str}; pub struct AmazonQProtocol; diff --git a/clash/src/agents/claude.rs b/coding-agent-hooks/src/agents/claude.rs similarity index 95% rename from clash/src/agents/claude.rs rename to coding-agent-hooks/src/agents/claude.rs index 79fb686..31dadef 100644 --- a/clash/src/agents/claude.rs +++ b/coding-agent-hooks/src/agents/claude.rs @@ -7,9 +7,10 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::HookProtocol; use super::{AgentKind, resolve_permission_mode, resolve_tool_name}; -use crate::hooks::{HookOutput, SessionStartHookInput, StopHookInput, ToolUseHookInput}; +use crate::input::{SessionStartHookInput, StopHookInput, ToolUseHookInput}; +use crate::output::HookOutput; +use crate::protocol::HookProtocol; pub struct ClaudeProtocol; @@ -72,10 +73,6 @@ impl HookProtocol for ClaudeProtocol { serde_json::to_value(output).expect("HookOutput serialization cannot fail") } - fn session_context(&self) -> &str { - include_str!("../../docs/session-context.md") - } - // parse_post_tool_use — uses default (delegates to parse_tool_use) // rewrite_for_sandbox — uses default (rewrites "Bash" command field) } diff --git a/clash/src/agents/codex.rs b/coding-agent-hooks/src/agents/codex.rs similarity index 97% rename from clash/src/agents/codex.rs rename to coding-agent-hooks/src/agents/codex.rs index 88ef064..b1df0c6 100644 --- a/clash/src/agents/codex.rs +++ b/coding-agent-hooks/src/agents/codex.rs @@ -5,9 +5,9 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str, json_str_any, json_value_any}; use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use crate::input::ToolUseHookInput; +use crate::protocol::{HookProtocol, json_str, json_str_any, json_value_any}; pub struct CodexProtocol; diff --git a/clash/src/agents/copilot.rs b/coding-agent-hooks/src/agents/copilot.rs similarity index 97% rename from clash/src/agents/copilot.rs rename to coding-agent-hooks/src/agents/copilot.rs index f17f2df..755d04b 100644 --- a/clash/src/agents/copilot.rs +++ b/coding-agent-hooks/src/agents/copilot.rs @@ -5,9 +5,9 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str}; use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use crate::input::ToolUseHookInput; +use crate::protocol::{HookProtocol, json_str}; pub struct CopilotProtocol; diff --git a/clash/src/agents/gemini.rs b/coding-agent-hooks/src/agents/gemini.rs similarity index 98% rename from clash/src/agents/gemini.rs rename to coding-agent-hooks/src/agents/gemini.rs index 19be5a1..3b2a0da 100644 --- a/clash/src/agents/gemini.rs +++ b/coding-agent-hooks/src/agents/gemini.rs @@ -7,9 +7,9 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str}; use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use crate::input::ToolUseHookInput; +use crate::protocol::{HookProtocol, json_str}; pub struct GeminiProtocol; diff --git a/coding-agent-hooks/src/agents/mod.rs b/coding-agent-hooks/src/agents/mod.rs new file mode 100644 index 0000000..7dd2858 --- /dev/null +++ b/coding-agent-hooks/src/agents/mod.rs @@ -0,0 +1,471 @@ +//! Multi-agent support — agent identification, tool name normalization, and +//! protocol implementations. +//! +//! This module provides: +//! +//! - [`AgentKind`] — enum identifying each supported agent +//! - Canonical tool alias table — maps agent-native tool names to internal names +//! - Permission mode resolution — normalizes agent-specific mode strings +//! - Per-agent [`HookProtocol`](crate::protocol::HookProtocol) implementations + +pub mod amazonq; +pub mod claude; +pub mod codex; +pub mod copilot; +pub mod gemini; +pub mod opencode; + +use std::fmt; +use std::str::FromStr; + +/// Identifies which coding agent is calling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +pub enum AgentKind { + Claude, + Gemini, + Codex, + #[cfg_attr(feature = "clap", value(name = "amazonq"))] + AmazonQ, + #[cfg_attr(feature = "clap", value(name = "opencode"))] + OpenCode, + Copilot, +} + +impl fmt::Display for AgentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AgentKind::Claude => write!(f, "claude"), + AgentKind::Gemini => write!(f, "gemini"), + AgentKind::Codex => write!(f, "codex"), + AgentKind::AmazonQ => write!(f, "amazonq"), + AgentKind::OpenCode => write!(f, "opencode"), + AgentKind::Copilot => write!(f, "copilot"), + } + } +} + +impl FromStr for AgentKind { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "claude" => Ok(AgentKind::Claude), + "gemini" => Ok(AgentKind::Gemini), + "codex" => Ok(AgentKind::Codex), + "amazonq" | "amazon-q" | "amazon_q" => Ok(AgentKind::AmazonQ), + "opencode" | "open-code" => Ok(AgentKind::OpenCode), + "copilot" => Ok(AgentKind::Copilot), + _ => Err(format!("unknown agent: {s}")), + } + } +} + +// --------------------------------------------------------------------------- +// Canonical permission mode table +// --------------------------------------------------------------------------- + +/// Maps agent-specific permission mode strings to canonical modes. +/// +/// Canonical modes: "default", "plan", "edit", "unrestricted". +/// Unknown modes pass through as-is so agent-specific extensions still work. +struct ModeAlias { + canonical: &'static str, + agent_names: &'static [(AgentKind, &'static str)], +} + +const MODE_ALIASES: &[ModeAlias] = &[ + ModeAlias { + canonical: "default", + agent_names: &[(AgentKind::Claude, "default")], + }, + ModeAlias { + canonical: "plan", + agent_names: &[(AgentKind::Claude, "plan")], + }, + ModeAlias { + canonical: "edit", + agent_names: &[(AgentKind::Claude, "edit")], + }, + ModeAlias { + canonical: "unrestricted", + agent_names: &[(AgentKind::Claude, "dangerously_skip_permissions")], + }, +]; + +/// Given an agent's native permission mode string, return the canonical mode. +/// +/// Case-insensitive. Returns the original string unchanged if no mapping exists. +pub fn resolve_permission_mode<'a>(agent: AgentKind, native_mode: &'a str) -> &'a str { + let lower = native_mode.to_lowercase(); + for alias in MODE_ALIASES { + for &(a, name) in alias.agent_names { + if a == agent && name.to_lowercase() == lower { + return alias.canonical; + } + } + } + native_mode +} + +// --------------------------------------------------------------------------- +// Canonical tool alias table +// --------------------------------------------------------------------------- + +/// Maps a canonical name and agent-native names to a single internal name. +/// +/// Internal names use Claude Code's tool names (e.g. "Bash", "Read") since +/// they are already embedded throughout policy engines and permission logic. +struct ToolAlias { + /// User-facing canonical name (e.g. "shell", "read"). + canonical: &'static str, + /// Internal name used by the policy engine (Claude-style, e.g. "Bash"). + internal: &'static str, + /// Agent-specific native names that map to this tool. + agent_names: &'static [(AgentKind, &'static str)], +} + +/// The ONE source of truth for tool name mappings across all agents. +/// +/// Each entry is curated case-by-case. Tools that don't have a clean +/// cross-agent equivalent are NOT in this table — they stay agent-specific. +const TOOL_ALIASES: &[ToolAlias] = &[ + ToolAlias { + canonical: "shell", + internal: "Bash", + agent_names: &[ + (AgentKind::Claude, "Bash"), + (AgentKind::Gemini, "run_shell_command"), + (AgentKind::Codex, "shell"), + (AgentKind::AmazonQ, "execute_bash"), + (AgentKind::OpenCode, "bash"), + (AgentKind::Copilot, "bash"), + ], + }, + ToolAlias { + canonical: "read", + internal: "Read", + agent_names: &[ + (AgentKind::Claude, "Read"), + (AgentKind::Gemini, "read_file"), + (AgentKind::AmazonQ, "fs_read"), + (AgentKind::OpenCode, "read"), + (AgentKind::Copilot, "view"), + ], + }, + ToolAlias { + canonical: "write", + internal: "Write", + agent_names: &[ + (AgentKind::Claude, "Write"), + (AgentKind::Gemini, "write_file"), + (AgentKind::AmazonQ, "fs_write"), + (AgentKind::OpenCode, "write"), + ], + }, + ToolAlias { + canonical: "edit", + internal: "Edit", + agent_names: &[ + (AgentKind::Claude, "Edit"), + (AgentKind::Gemini, "replace"), + (AgentKind::OpenCode, "edit"), + (AgentKind::Copilot, "edit"), + ], + }, + ToolAlias { + canonical: "glob", + internal: "Glob", + agent_names: &[ + (AgentKind::Claude, "Glob"), + (AgentKind::Gemini, "glob"), + (AgentKind::OpenCode, "glob"), + ], + }, + ToolAlias { + canonical: "grep", + internal: "Grep", + agent_names: &[ + (AgentKind::Claude, "Grep"), + (AgentKind::Gemini, "grep_search"), + (AgentKind::OpenCode, "grep"), + ], + }, + ToolAlias { + canonical: "web_fetch", + internal: "WebFetch", + agent_names: &[ + (AgentKind::Claude, "WebFetch"), + (AgentKind::Gemini, "web_fetch"), + (AgentKind::OpenCode, "webfetch"), + ], + }, + ToolAlias { + canonical: "web_search", + internal: "WebSearch", + agent_names: &[ + (AgentKind::Claude, "WebSearch"), + (AgentKind::Gemini, "google_web_search"), + (AgentKind::Codex, "web_search"), + (AgentKind::OpenCode, "websearch"), + ], + }, +]; + +/// Given an agent's native tool name, return the internal (Claude-style) name. +/// +/// Case-insensitive. Returns the original name unchanged if no mapping exists +/// (unknown/agent-specific tools pass through as-is). +pub fn resolve_tool_name(agent: AgentKind, native_name: &str) -> &str { + let lower = native_name.to_lowercase(); + for alias in TOOL_ALIASES { + for &(a, name) in alias.agent_names { + if a == agent && name.to_lowercase() == lower { + return alias.internal; + } + } + } + native_name +} + +/// Given a canonical name (e.g. "shell"), return the internal name (e.g. "Bash"). +/// +/// Case-insensitive. Used by the policy compiler to resolve user-facing aliases. +pub fn canonical_to_internal(clash_name: &str) -> Option<&'static str> { + let lower = clash_name.to_lowercase(); + TOOL_ALIASES + .iter() + .find(|a| a.canonical.to_lowercase() == lower) + .map(|a| a.internal) +} + +/// Resolve any tool name to its internal form. +/// +/// Accepts canonical names ("shell"), internal names ("Bash"), or any +/// agent-native name ("run_shell_command"). Case-insensitive. +/// Returns the internal name if found, or None if unrecognized. +pub fn resolve_any_to_internal(name: &str) -> Option<&'static str> { + let lower = name.to_lowercase(); + for alias in TOOL_ALIASES { + if alias.canonical.to_lowercase() == lower { + return Some(alias.internal); + } + if alias.internal.to_lowercase() == lower { + return Some(alias.internal); + } + for &(_, agent_name) in alias.agent_names { + if agent_name.to_lowercase() == lower { + return Some(alias.internal); + } + } + } + None +} + +/// Given an internal name (e.g. "Bash"), return the canonical name (e.g. "shell"). +/// +/// Case-insensitive. +pub fn internal_to_canonical(internal_name: &str) -> Option<&'static str> { + let lower = internal_name.to_lowercase(); + TOOL_ALIASES + .iter() + .find(|a| a.internal.to_lowercase() == lower) + .map(|a| a.canonical) +} + +/// Return the best user-facing display name for a tool. +/// +/// Prefers the canonical name ("shell") if one exists, otherwise returns +/// the internal name as-is. +pub fn display_name(internal_name: &str) -> &str { + internal_to_canonical(internal_name).unwrap_or(internal_name) +} + +/// Given an internal name and target agent, return the agent's native tool name. +/// +/// Used for formatting output in the agent's expected vocabulary. +pub fn internal_to_agent(agent: AgentKind, internal_name: &str) -> Option<&'static str> { + let lower = internal_name.to_lowercase(); + for alias in TOOL_ALIASES { + if alias.internal.to_lowercase() == lower { + for &(a, name) in alias.agent_names { + if a == agent { + return Some(name); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_claude_bash() { + assert_eq!(resolve_tool_name(AgentKind::Claude, "Bash"), "Bash"); + } + + #[test] + fn resolve_gemini_shell() { + assert_eq!( + resolve_tool_name(AgentKind::Gemini, "run_shell_command"), + "Bash" + ); + } + + #[test] + fn resolve_codex_shell() { + assert_eq!(resolve_tool_name(AgentKind::Codex, "shell"), "Bash"); + } + + #[test] + fn resolve_amazonq_bash() { + assert_eq!( + resolve_tool_name(AgentKind::AmazonQ, "execute_bash"), + "Bash" + ); + } + + #[test] + fn resolve_case_insensitive() { + assert_eq!(resolve_tool_name(AgentKind::Claude, "bash"), "Bash"); + assert_eq!(resolve_tool_name(AgentKind::Claude, "BASH"), "Bash"); + assert_eq!( + resolve_tool_name(AgentKind::Gemini, "RUN_SHELL_COMMAND"), + "Bash" + ); + } + + #[test] + fn resolve_unknown_passthrough() { + assert_eq!( + resolve_tool_name(AgentKind::Claude, "SomeCustomTool"), + "SomeCustomTool" + ); + } + + #[test] + fn canonical_to_internal_works() { + assert_eq!(canonical_to_internal("shell"), Some("Bash")); + assert_eq!(canonical_to_internal("read"), Some("Read")); + assert_eq!(canonical_to_internal("SHELL"), Some("Bash")); + assert_eq!(canonical_to_internal("unknown"), None); + } + + #[test] + fn internal_to_canonical_works() { + assert_eq!(internal_to_canonical("Bash"), Some("shell")); + assert_eq!(internal_to_canonical("Read"), Some("read")); + assert_eq!(internal_to_canonical("UnknownTool"), None); + } + + #[test] + fn internal_to_agent_works() { + assert_eq!( + internal_to_agent(AgentKind::Gemini, "Bash"), + Some("run_shell_command") + ); + assert_eq!( + internal_to_agent(AgentKind::AmazonQ, "Read"), + Some("fs_read") + ); + assert_eq!(internal_to_agent(AgentKind::Codex, "Glob"), None); + } + + #[test] + fn resolve_any_canonical() { + assert_eq!(resolve_any_to_internal("shell"), Some("Bash")); + assert_eq!(resolve_any_to_internal("read"), Some("Read")); + } + + #[test] + fn resolve_any_internal() { + assert_eq!(resolve_any_to_internal("Bash"), Some("Bash")); + assert_eq!(resolve_any_to_internal("bash"), Some("Bash")); + assert_eq!(resolve_any_to_internal("BASH"), Some("Bash")); + } + + #[test] + fn resolve_any_agent_native() { + assert_eq!(resolve_any_to_internal("run_shell_command"), Some("Bash")); + assert_eq!(resolve_any_to_internal("execute_bash"), Some("Bash")); + assert_eq!(resolve_any_to_internal("fs_read"), Some("Read")); + } + + #[test] + fn resolve_any_unknown() { + assert_eq!(resolve_any_to_internal("CustomTool"), None); + } + + #[test] + fn resolve_mode_claude_default() { + assert_eq!( + resolve_permission_mode(AgentKind::Claude, "default"), + "default" + ); + } + + #[test] + fn resolve_mode_claude_plan() { + assert_eq!(resolve_permission_mode(AgentKind::Claude, "plan"), "plan"); + } + + #[test] + fn resolve_mode_claude_dangerously_skip() { + assert_eq!( + resolve_permission_mode(AgentKind::Claude, "dangerously_skip_permissions"), + "unrestricted" + ); + } + + #[test] + fn resolve_mode_case_insensitive() { + assert_eq!( + resolve_permission_mode(AgentKind::Claude, "DANGEROUSLY_SKIP_PERMISSIONS"), + "unrestricted" + ); + } + + #[test] + fn resolve_mode_unknown_passthrough() { + assert_eq!( + resolve_permission_mode(AgentKind::Claude, "custom_mode"), + "custom_mode" + ); + } + + #[test] + fn resolve_mode_other_agents_default() { + assert_eq!( + resolve_permission_mode(AgentKind::Gemini, "default"), + "default" + ); + assert_eq!( + resolve_permission_mode(AgentKind::Codex, "default"), + "default" + ); + } + + #[test] + fn all_aliases_have_consistent_internal_names() { + let claude_names: Vec<&str> = TOOL_ALIASES + .iter() + .flat_map(|a| { + a.agent_names + .iter() + .filter(|(ak, _)| *ak == AgentKind::Claude) + }) + .map(|(_, name)| *name) + .collect(); + for alias in TOOL_ALIASES { + assert!( + claude_names.contains(&alias.internal), + "internal name '{}' for canonical '{}' is not a Claude tool name", + alias.internal, + alias.canonical + ); + } + } +} diff --git a/clash/src/agents/opencode.rs b/coding-agent-hooks/src/agents/opencode.rs similarity index 96% rename from clash/src/agents/opencode.rs rename to coding-agent-hooks/src/agents/opencode.rs index 80036e2..394c2b4 100644 --- a/clash/src/agents/opencode.rs +++ b/coding-agent-hooks/src/agents/opencode.rs @@ -6,9 +6,9 @@ use anyhow::Result; use serde_json::Value; -use super::protocol::{HookProtocol, json_str_any, json_value_any}; use super::{AgentKind, resolve_tool_name}; -use crate::hooks::ToolUseHookInput; +use crate::input::ToolUseHookInput; +use crate::protocol::{HookProtocol, json_str_any, json_value_any}; pub struct OpenCodeProtocol; diff --git a/coding-agent-hooks/src/input.rs b/coding-agent-hooks/src/input.rs new file mode 100644 index 0000000..a9537f3 --- /dev/null +++ b/coding-agent-hooks/src/input.rs @@ -0,0 +1,188 @@ +//! Hook input types — normalized representations of what agents send via stdin. +//! +//! These types are deserialized from JSON and represent the agent's hook event. +//! Agent-specific protocol adapters normalize native JSON into these types via +//! the [`HookProtocol`](crate::protocol::HookProtocol) trait. + +use std::io::Read; + +use serde::Deserialize; +use tracing::{Level, instrument}; + +use crate::AgentKind; + +/// The complete hook input received from an agent via stdin. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum HookInput { + /// PreToolUse, PostToolUse, PermissionRequest events + ToolUse(ToolUseHookInput), + /// SessionStart events + SessionStart(SessionStartHookInput), +} + +/// Hook input for tool-related events (PreToolUse, PostToolUse, PermissionRequest). +/// +/// The `tool_name` field carries the internal (Claude-style) name after protocol +/// normalization. The original agent-native name is preserved in `original_tool_name`. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ToolUseHookInput { + pub session_id: String, + pub transcript_path: String, + pub cwd: String, + pub permission_mode: String, + pub hook_event_name: String, + pub tool_name: String, + pub tool_input: serde_json::Value, + pub tool_use_id: Option, + /// Present in PostToolUse events + #[serde(default)] + pub tool_response: Option, + + // -- Multi-agent fields (not deserialized from JSON, set by protocol layer) -- + /// Which agent sent this hook input. + #[serde(skip)] + pub agent: Option, + /// The agent's original tool name before normalization (e.g. "run_shell_command"). + /// For Claude, this is the same as `tool_name`. + #[serde(skip)] + pub original_tool_name: Option, +} + +/// Hook input for SessionStart events. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct SessionStartHookInput { + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub transcript_path: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub permission_mode: Option, + #[serde(default)] + pub hook_event_name: String, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub model: Option, +} + +impl SessionStartHookInput { + /// Parse from any reader (for testability). + #[instrument(level = Level::TRACE, skip(reader))] + pub fn from_reader(reader: impl Read) -> anyhow::Result { + Ok(serde_json::from_reader(reader)?) + } +} + +/// Hook input for Stop events (conversation turn ended without a tool call). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct StopHookInput { + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub transcript_path: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub hook_event_name: String, +} + +impl StopHookInput { + /// Parse from any reader (for testability). + #[instrument(level = Level::TRACE, skip(reader))] + pub fn from_reader(reader: impl Read) -> anyhow::Result { + Ok(serde_json::from_reader(reader)?) + } +} + +impl HookInput { + /// Parse from any reader (for testability). + #[instrument(level = Level::TRACE, skip(reader))] + pub fn from_reader(reader: impl Read) -> anyhow::Result { + Ok(serde_json::from_reader(reader)?) + } + + /// Parse from stdin (convenience wrapper for production). + #[instrument(level = Level::TRACE)] + pub fn from_stdin() -> anyhow::Result { + Self::from_reader(std::io::stdin().lock()) + } + + /// Get the hook event name. + pub fn hook_event_name(&self) -> &str { + match self { + HookInput::ToolUse(input) => &input.hook_event_name, + HookInput::SessionStart(input) => &input.hook_event_name, + } + } + + /// Get the session ID. + pub fn session_id(&self) -> &str { + match self { + HookInput::ToolUse(input) => &input.session_id, + HookInput::SessionStart(input) => &input.session_id, + } + } + + /// Check if this is a tool use event. + pub fn as_tool_use(&self) -> Option<&ToolUseHookInput> { + match self { + HookInput::ToolUse(input) => Some(input), + _ => None, + } + } + + /// Check if this is a session start event. + pub fn as_session_start(&self) -> Option<&SessionStartHookInput> { + match self { + HookInput::SessionStart(input) => Some(input), + _ => None, + } + } +} + +impl ToolUseHookInput { + /// Parse from any reader (for testability). + #[instrument(level = Level::TRACE, skip(reader))] + pub fn from_reader(reader: impl Read) -> anyhow::Result { + Ok(serde_json::from_reader(reader)?) + } +} + +/// Exit codes for hook responses. +pub mod exit_code { + /// Success - response written to stdout. + pub const SUCCESS: i32 = 0; + /// Blocking error - stderr message fed to agent. + pub const BLOCKING_ERROR: i32 = 2; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_tool_use_json() -> &'static str { + r#"{ + "session_id": "test-session", + "transcript_path": "/tmp/transcript.jsonl", + "cwd": "/home/user/project", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "git status", "timeout": 120000}, + "tool_use_id": "toolu_01ABC" + }"# + } + + #[test] + fn test_parse_tool_use_input() { + let input = HookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap(); + assert_eq!(input.session_id(), "test-session"); + assert_eq!(input.hook_event_name(), "PreToolUse"); + + let tool_use = input.as_tool_use().expect("Should be ToolUse variant"); + assert_eq!(tool_use.tool_name, "Bash"); + } +} diff --git a/coding-agent-hooks/src/lib.rs b/coding-agent-hooks/src/lib.rs new file mode 100644 index 0000000..090ea26 --- /dev/null +++ b/coding-agent-hooks/src/lib.rs @@ -0,0 +1,35 @@ +//! Agent-agnostic hook protocol types and adapters for AI coding agents. +//! +//! This crate provides the building blocks for writing hook-based middleware +//! that works with any AI coding agent (Claude Code, Gemini CLI, Codex CLI, +//! Amazon Q, OpenCode, Copilot CLI, etc.). +//! +//! # Core Concepts +//! +//! - [`AgentKind`] identifies which agent is calling. +//! - [`HookProtocol`](protocol::HookProtocol) abstracts agent-specific JSON formats. +//! - [`ToolUseHookInput`](input::ToolUseHookInput) and friends are the normalized +//! input types parsed from any agent's hook JSON. +//! - [`HookOutput`](output::HookOutput) is a structured response type (used +//! directly by Claude Code; other agents convert via protocol methods). +//! - Tool and permission-mode name resolution normalizes across agents. +//! +//! # Adding a New Agent +//! +//! Implement [`HookProtocol`](protocol::HookProtocol) — only two methods are +//! required (`agent` and `parse_tool_use`). Add entries to the tool and mode +//! alias tables in [`agents`] so policy evaluation uses consistent names. + +pub mod agents; +pub mod input; +pub mod output; +pub mod protocol; + +// Re-export core types at the crate root for convenience. +pub use agents::AgentKind; +pub use input::{HookInput, SessionStartHookInput, StopHookInput, ToolUseHookInput}; +pub use output::{ + Effect, HookOutput, HookSpecificOutput, PermissionBehavior, PermissionDecision, + PermissionRequestOutput, PostToolUseOutput, PreToolUseOutput, SessionStartOutput, +}; +pub use protocol::HookProtocol; diff --git a/coding-agent-hooks/src/output.rs b/coding-agent-hooks/src/output.rs new file mode 100644 index 0000000..568ebd5 --- /dev/null +++ b/coding-agent-hooks/src/output.rs @@ -0,0 +1,371 @@ +//! Hook output types — structured responses sent back to agents via stdout. +//! +//! [`HookOutput`] is the complete response type. It is used directly by Claude +//! Code (serialized as JSON). Other agents convert it to their protocol format +//! via the [`HookProtocol`](crate::protocol::HookProtocol) trait methods. + +use std::io::Write; + +use serde::{Deserialize, Serialize}; +use tracing::{Level, instrument}; + +/// The effect of a policy decision on a tool invocation. +/// +/// This is the agent-agnostic representation of a permission decision. +/// Individual agents may use different vocabulary (e.g. "proceed"/"block", +/// "approve"/"deny"), but they all map to these three effects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Effect { + /// Permission is granted — tool execution proceeds. + Allow, + /// Permission requires user confirmation via the agent's native UI. + Ask, + /// Permission is denied — tool execution is blocked. + Deny, +} + +/// Hook-specific output for PreToolUse. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PreToolUseOutput { + pub hook_event_name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_decision_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_context: Option, +} + +/// Decision behavior for PermissionRequest responses. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum PermissionBehavior { + Allow, + Deny, +} + +/// Decision structure for PermissionRequest responses. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecision { + pub behavior: PermissionBehavior, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interrupt: Option, +} + +/// Hook-specific output for PermissionRequest. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequestOutput { + pub hook_event_name: &'static str, + pub decision: PermissionDecision, +} + +/// Hook-specific output for SessionStart. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SessionStartOutput { + pub hook_event_name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_context: Option, +} + +/// Output for PostToolUse hooks — provides advisory context back to the agent. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PostToolUseOutput { + pub hook_event_name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_context: Option, +} + +/// Hook-specific output variants. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum HookSpecificOutput { + PreToolUse(PreToolUseOutput), + PostToolUse(PostToolUseOutput), + PermissionRequest(PermissionRequestOutput), + SessionStart(SessionStartOutput), +} + +/// The complete hook output sent to an agent via stdout. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct HookOutput { + #[serde(rename = "continue")] + pub should_continue: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub hook_specific_output: Option, +} + +impl HookOutput { + /// Private helper to construct a PreToolUse response with the given decision. + fn pretooluse_output( + decision: Effect, + reason: Option, + context: Option, + updated_input: Option, + ) -> Self { + Self { + should_continue: true, + hook_specific_output: Some(HookSpecificOutput::PreToolUse(PreToolUseOutput { + hook_event_name: "PreToolUse", + permission_decision: Some(decision), + permission_decision_reason: reason, + updated_input, + additional_context: context, + })), + } + } + + /// Create an "allow" response for PreToolUse - bypasses permission system. + #[instrument(level = Level::TRACE)] + pub fn allow(reason: Option, context: Option) -> Self { + Self::pretooluse_output(Effect::Allow, reason, context, None) + } + + /// Create a "deny" response for PreToolUse - prevents tool execution. + #[instrument(level = Level::TRACE)] + pub fn deny(reason: String, context: Option) -> Self { + Self::pretooluse_output(Effect::Deny, Some(reason), context, None) + } + + /// Create an "ask" response for PreToolUse - prompts user for confirmation. + #[instrument(level = Level::TRACE)] + pub fn ask(reason: Option, context: Option) -> Self { + Self::pretooluse_output(Effect::Ask, reason, context, None) + } + + /// Approve a permission request on behalf of the user. + #[instrument(level = Level::TRACE)] + pub fn approve_permission(updated_input: Option) -> Self { + Self { + should_continue: true, + hook_specific_output: Some(HookSpecificOutput::PermissionRequest( + PermissionRequestOutput { + hook_event_name: "PermissionRequest", + decision: PermissionDecision { + behavior: PermissionBehavior::Allow, + updated_input, + message: None, + interrupt: None, + }, + }, + )), + } + } + + /// Deny a permission request on behalf of the user. + #[instrument(level = Level::TRACE)] + pub fn deny_permission(message: String, interrupt: bool) -> Self { + Self { + should_continue: true, + hook_specific_output: Some(HookSpecificOutput::PermissionRequest( + PermissionRequestOutput { + hook_event_name: "PermissionRequest", + decision: PermissionDecision { + behavior: PermissionBehavior::Deny, + updated_input: None, + message: Some(message), + interrupt: Some(interrupt), + }, + }, + )), + } + } + + /// Set the updated_input field on a PreToolUse response. + /// This rewrites the tool input before the agent executes it. + #[instrument(level = Level::TRACE, skip(self))] + pub fn set_updated_input(&mut self, updated_input: serde_json::Value) { + if let Some(HookSpecificOutput::PreToolUse(ref mut pre)) = self.hook_specific_output { + pre.updated_input = Some(updated_input); + } + } + + /// Create a SessionStart response with optional context about the session setup. + #[instrument(level = Level::TRACE)] + pub fn session_start(additional_context: Option) -> Self { + Self { + should_continue: true, + hook_specific_output: Some(HookSpecificOutput::SessionStart(SessionStartOutput { + hook_event_name: "SessionStart", + additional_context, + })), + } + } + + /// Create a PostToolUse response with optional advisory context. + #[instrument(level = Level::TRACE)] + pub fn post_tool_use(additional_context: Option) -> Self { + match additional_context { + Some(ctx) => Self { + should_continue: true, + hook_specific_output: Some(HookSpecificOutput::PostToolUse(PostToolUseOutput { + hook_event_name: "PostToolUse", + additional_context: Some(ctx), + })), + }, + None => Self::continue_execution(), + } + } + + /// Continue execution without making a decision (for informational hooks). + #[instrument(level = Level::TRACE)] + pub fn continue_execution() -> Self { + Self { + should_continue: true, + hook_specific_output: None, + } + } + + /// Write response to any writer (for testability). + #[instrument(level = Level::TRACE, skip(self, writer))] + pub fn write_to(&self, mut writer: impl Write) -> anyhow::Result<()> { + serde_json::to_writer(&mut writer, self)?; + writeln!(writer)?; + Ok(()) + } + + /// Write response to stdout (convenience wrapper for production). + #[instrument(level = Level::TRACE, skip(self))] + pub fn write_stdout(&self) -> anyhow::Result<()> { + self.write_to(std::io::stdout().lock()) + } + + /// Extract the [`Effect`] from this output, if it contains a PreToolUse decision. + pub fn effect(&self) -> Option { + match &self.hook_specific_output { + Some(HookSpecificOutput::PreToolUse(pre)) => pre.permission_decision, + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_output_allow() { + let output = HookOutput::allow(Some("Safe command".into()), None); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow"); + assert_eq!( + json["hookSpecificOutput"]["permissionDecisionReason"], + "Safe command" + ); + } + + #[test] + fn test_output_deny() { + let output = HookOutput::deny("Dangerous command".into(), None); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + json["hookSpecificOutput"]["permissionDecisionReason"], + "Dangerous command" + ); + } + + #[test] + fn test_output_ask() { + let output = HookOutput::ask(None, None); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "ask"); + assert!(json["hookSpecificOutput"]["permissionDecisionReason"].is_null()); + } + + #[test] + fn test_approve_permission() { + let output = HookOutput::approve_permission(None); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PermissionRequest" + ); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); + assert!(json["hookSpecificOutput"]["decision"]["updatedInput"].is_null()); + } + + #[test] + fn test_approve_permission_with_updated_input() { + let updated = serde_json::json!({"command": "ls -la"}); + let output = HookOutput::approve_permission(Some(updated.clone())); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PermissionRequest" + ); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "allow"); + assert_eq!( + json["hookSpecificOutput"]["decision"]["updatedInput"], + updated + ); + } + + #[test] + fn test_deny_permission() { + let output = HookOutput::deny_permission("Not allowed".into(), true); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PermissionRequest" + ); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); + assert_eq!( + json["hookSpecificOutput"]["decision"]["message"], + "Not allowed" + ); + assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], true); + } + + #[test] + fn test_deny_permission_no_interrupt() { + let output = HookOutput::deny_permission("Try again".into(), false); + let mut buf = Vec::new(); + output.write_to(&mut buf).unwrap(); + + let json: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(json["hookSpecificOutput"]["decision"]["behavior"], "deny"); + assert_eq!(json["hookSpecificOutput"]["decision"]["interrupt"], false); + } + + #[test] + fn test_effect_extraction() { + assert_eq!(HookOutput::allow(None, None).effect(), Some(Effect::Allow)); + assert_eq!( + HookOutput::deny("x".into(), None).effect(), + Some(Effect::Deny) + ); + assert_eq!(HookOutput::ask(None, None).effect(), Some(Effect::Ask)); + assert_eq!(HookOutput::continue_execution().effect(), None); + } +} diff --git a/clash/src/agents/protocol.rs b/coding-agent-hooks/src/protocol.rs similarity index 80% rename from clash/src/agents/protocol.rs rename to coding-agent-hooks/src/protocol.rs index 893a85e..009873a 100644 --- a/clash/src/agents/protocol.rs +++ b/coding-agent-hooks/src/protocol.rs @@ -3,7 +3,7 @@ //! Each coding agent sends/receives hook JSON in a different format. //! The [`HookProtocol`] trait encapsulates these differences so the //! core permission logic works identically regardless of which agent -//! is calling Clash. +//! is calling. //! //! Most methods have default implementations that handle the common case. //! Adding a new agent typically requires overriding only [`HookProtocol::agent`] @@ -13,14 +13,14 @@ use anyhow::Result; use serde_json::Value; -use super::AgentKind; -use crate::hooks::{SessionStartHookInput, StopHookInput, ToolUseHookInput}; +use crate::agents::{AgentKind, resolve_permission_mode}; +use crate::input::{SessionStartHookInput, StopHookInput, ToolUseHookInput}; /// Abstraction over agent-specific hook JSON formats. /// /// Each agent (Claude Code, Gemini CLI, etc.) implements this trait to handle: -/// - Parsing its native JSON stdin into Clash's internal types -/// - Formatting Clash decisions back into the agent's expected JSON output +/// - Parsing its native JSON stdin into normalized internal types +/// - Formatting decisions back into the agent's expected JSON output /// - Rewriting tool inputs for sandbox enforcement /// /// # Adding a New Agent @@ -36,8 +36,8 @@ pub trait HookProtocol { /// Parse the agent's PreToolUse JSON into a `ToolUseHookInput`. **Required.** /// /// The returned `tool_name` MUST be the internal (Claude-style) name, - /// translated via [`super::resolve_tool_name`]. The original agent-native - /// name is preserved in `original_tool_name`. + /// translated via [`resolve_tool_name`](crate::agents::resolve_tool_name). + /// The original agent-native name is preserved in `original_tool_name`. fn parse_tool_use(&self, raw: &Value) -> Result; /// Parse the agent's PostToolUse JSON into a `ToolUseHookInput`. @@ -58,7 +58,7 @@ pub trait HookProtocol { permission_mode: raw .get("permission_mode") .and_then(|v| v.as_str()) - .map(|m| super::resolve_permission_mode(self.agent(), m).to_string()), + .map(|m| resolve_permission_mode(self.agent(), m).to_string()), hook_event_name: json_str_or(raw, "hook_event_name", "SessionStart").to_string(), source: raw.get("source").and_then(|v| v.as_str()).map(String::from), model: raw.get("model").and_then(|v| v.as_str()).map(String::from), @@ -124,7 +124,7 @@ pub trait HookProtocol { output } - /// Rewrite a shell command's tool_input to run through `clash shell`. + /// Rewrite a shell command's tool_input to run through a sandbox. /// /// Default: rewrites the `command` field for tools with internal name "Bash". fn rewrite_for_sandbox(&self, input: &ToolUseHookInput, sandbox_cmd: &str) -> Option { @@ -147,10 +147,9 @@ pub trait HookProtocol { /// Context string injected into the agent's session at startup. /// - /// Default: standard Clash session context. + /// Default: generic hook-active context. fn session_context(&self) -> &str { - "Clash is active and enforcing policy on this session.\n\ - Run `clash commands` to see the full command hierarchy for managing policies, sandboxes, and debugging." + "Agent hooks are active and enforcing policy on this session." } } @@ -159,17 +158,17 @@ pub trait HookProtocol { // --------------------------------------------------------------------------- /// Extract a string field from JSON, returning "" if missing. -pub(crate) fn json_str<'a>(raw: &'a Value, field: &str) -> &'a str { +pub fn json_str<'a>(raw: &'a Value, field: &str) -> &'a str { raw.get(field).and_then(|v| v.as_str()).unwrap_or("") } /// Extract a string field from JSON with a default value. -pub(crate) fn json_str_or<'a>(raw: &'a Value, field: &str, default: &'a str) -> &'a str { +pub fn json_str_or<'a>(raw: &'a Value, field: &str, default: &'a str) -> &'a str { raw.get(field).and_then(|v| v.as_str()).unwrap_or(default) } /// Extract a string from one of several possible field names. -pub(crate) fn json_str_any<'a>(raw: &'a Value, fields: &[&str]) -> &'a str { +pub fn json_str_any<'a>(raw: &'a Value, fields: &[&str]) -> &'a str { for field in fields { if let Some(s) = raw.get(*field).and_then(|v| v.as_str()) { return s; @@ -179,7 +178,7 @@ pub(crate) fn json_str_any<'a>(raw: &'a Value, fields: &[&str]) -> &'a str { } /// Extract an optional Value from one of several possible field names. -pub(crate) fn json_value_any(raw: &Value, fields: &[&str]) -> Option { +pub fn json_value_any(raw: &Value, fields: &[&str]) -> Option { for field in fields { if let Some(v) = raw.get(*field) { return Some(v.clone()); @@ -188,18 +187,19 @@ pub(crate) fn json_value_any(raw: &Value, fields: &[&str]) -> Option { None } -pub(crate) fn shell_escape(s: &str) -> String { +/// Shell-escape a string for safe inclusion in a shell command. +pub fn shell_escape(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } /// Construct the appropriate protocol implementation for an agent. pub fn get_protocol(agent: AgentKind) -> Box { match agent { - AgentKind::Claude => Box::new(super::claude::ClaudeProtocol), - AgentKind::Gemini => Box::new(super::gemini::GeminiProtocol), - AgentKind::Codex => Box::new(super::codex::CodexProtocol), - AgentKind::AmazonQ => Box::new(super::amazonq::AmazonQProtocol), - AgentKind::OpenCode => Box::new(super::opencode::OpenCodeProtocol), - AgentKind::Copilot => Box::new(super::copilot::CopilotProtocol), + AgentKind::Claude => Box::new(crate::agents::claude::ClaudeProtocol), + AgentKind::Gemini => Box::new(crate::agents::gemini::GeminiProtocol), + AgentKind::Codex => Box::new(crate::agents::codex::CodexProtocol), + AgentKind::AmazonQ => Box::new(crate::agents::amazonq::AmazonQProtocol), + AgentKind::OpenCode => Box::new(crate::agents::opencode::OpenCodeProtocol), + AgentKind::Copilot => Box::new(crate::agents::copilot::CopilotProtocol), } }