Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 143 additions & 187 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
members = [
"clash",
"clash_hooks",
"clash_notify",
"claude_settings",
"clash_starlark",
Expand Down Expand Up @@ -152,6 +153,7 @@ brush-parser = { path = "clash-brush-parser", version = "0.6.2", package = "clas
brush-core = { path = "clash-brush-core", version = "0.6.2", package = "clash-brush-core" }

# Workspace crates (internal only)
clash_hooks = { path = "clash_hooks", version = "0.6.2" }
clash_notify = { path = "clash_notify", version = "0.6.2" }
clash_starlark = { path = "clash_starlark", version = "0.6.2" }
claude_settings = { path = "claude_settings", version = "0.6.2" }
1 change: 1 addition & 0 deletions clash/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ crossterm = { workspace = true }
similar = { workspace = true }

# from the workspace
clash_hooks = { workspace = true }
clash_notify = { workspace = true }
clash_starlark = { workspace = true }
claude_settings = { workspace = true }
Expand Down
65 changes: 37 additions & 28 deletions clash/src/agents/amazonq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,44 @@
use anyhow::Result;
use serde_json::Value;

use super::protocol::{HookProtocol, json_str};
use super::{AgentKind, resolve_tool_name};
use crate::hooks::ToolUseHookInput;
use super::AgentKind;
use super::protocol::HookProtocol;

pub struct AmazonQProtocol;

impl AmazonQProtocol {
/// Normalize Amazon Q JSON to Claude convention for `recv_from_value`.
fn normalize(raw: &Value) -> Value {
let mut normalized = serde_json::Map::new();

if let Some(obj) = raw.as_object() {
for (k, v) in obj {
normalized.insert(k.clone(), v.clone());
}
}

// Amazon Q may use camelCase hook_event_name
if let Some(name) = normalized.get("hook_event_name").and_then(|v| v.as_str()) {
let pascal = super::copilot::normalize_event_name(name);
normalized.insert("hook_event_name".into(), Value::String(pascal));
}

// Default transcript_path if missing
if !normalized.contains_key("transcript_path") {
normalized.insert("transcript_path".into(), Value::String(String::new()));
}

Value::Object(normalized)
}
}

impl HookProtocol for AmazonQProtocol {
fn agent(&self) -> AgentKind {
AgentKind::AmazonQ
}

fn parse_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput> {
let tool_name = json_str(raw, "tool_name").to_string();
let original = tool_name.clone();
let resolved = resolve_tool_name(AgentKind::AmazonQ, &tool_name).to_string();

Ok(ToolUseHookInput {
session_id: json_str(raw, "session_id").to_string(),
transcript_path: json_str(raw, "transcript_path").to_string(),
cwd: json_str(raw, "cwd").to_string(),
permission_mode: "default".to_string(),
hook_event_name: json_str(raw, "hook_event_name").to_string(),
tool_name: resolved,
tool_input: raw
.get("tool_input")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new())),
tool_use_id: None,
tool_response: raw.get("tool_response").cloned(),
agent: Some(AgentKind::AmazonQ),
original_tool_name: Some(original),
})
fn parse_event(&self, raw: &Value) -> Result<clash_hooks::HookEvent> {
Ok(clash_hooks::recv_from_value(Self::normalize(raw))?)
}

// Amazon Q uses "action" instead of "decision"
Expand Down Expand Up @@ -65,19 +71,22 @@ impl HookProtocol for AmazonQProtocol {
#[cfg(test)]
mod tests {
use super::*;
use clash_hooks::ToolEvent;

#[test]
fn parse_amazonq_execute_bash() {
fn parse_event_amazonq_execute_bash() {
let raw = serde_json::json!({
"session_id": "q-123",
"cwd": "/home/user",
"hook_event_name": "preToolUse",
"tool_name": "execute_bash",
"tool_input": {"command": "npm test"}
});
let input = AmazonQProtocol.parse_tool_use(&raw).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.original_tool_name.as_deref(), Some("execute_bash"));
let event = AmazonQProtocol.parse_event(&raw).unwrap();
let clash_hooks::HookEvent::PreToolUse(e) = event else {
panic!("expected PreToolUse");
};
assert_eq!(e.tool_name(), "execute_bash");
}

#[test]
Expand Down
164 changes: 88 additions & 76 deletions clash/src/agents/claude.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
//! Claude Code hook protocol implementation.
//!
//! Claude Code has a unique output format using `HookOutput` structs with
//! `permissionDecision`, `hookSpecificOutput`, etc. This requires custom
//! format methods — the defaults don't apply here.
//! Claude uses the nested `{ continue, hookSpecificOutput: { hookEventName, ... } }`
//! JSON format defined by [`HookOutput`]. All `format_*` methods serialize through
//! `HookOutput`'s serde implementation so the output is always wire-correct.
//!
//! `parse_event` uses the default implementation (`recv_from_value`), which
//! works because Claude's JSON is already in the canonical format.

use anyhow::Result;
use serde_json::Value;

use super::AgentKind;
use super::protocol::HookProtocol;
use super::{AgentKind, resolve_tool_name};
use crate::hooks::{HookOutput, SessionStartHookInput, StopHookInput, ToolUseHookInput};
use crate::hooks::HookOutput;
use crate::policy_decision::PolicyDecision;

pub struct ClaudeProtocol;

Expand All @@ -18,67 +21,77 @@ impl HookProtocol for ClaudeProtocol {
AgentKind::Claude
}

fn parse_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput> {
let mut input: ToolUseHookInput = serde_json::from_value(raw.clone())?;
let original = input.tool_name.clone();
input.tool_name = resolve_tool_name(AgentKind::Claude, &original).to_string();
input.original_tool_name = Some(original);
input.agent = Some(AgentKind::Claude);
Ok(input)
fn session_context(&self) -> &str {
include_str!("../../docs/session-context.md")
}

fn parse_session_start(&self, raw: &Value) -> Result<SessionStartHookInput> {
Ok(serde_json::from_value(raw.clone())?)
}
// parse_event — uses default (recv_from_value), Claude JSON is canonical

fn parse_stop(&self, raw: &Value) -> Result<StopHookInput> {
Ok(serde_json::from_value(raw.clone())?)
fn format_decision(&self, decision: &PolicyDecision) -> Value {
serde_json::to_value(to_hook_output(decision)).unwrap()
}

// Claude has a unique output format — must override all format methods.

fn format_allow(
&self,
reason: Option<&str>,
context: Option<&str>,
updated_input: Option<Value>,
) -> Value {
let mut output = HookOutput::allow(reason.map(String::from), context.map(String::from));
if let Some(ui) = updated_input {
output.set_updated_input(ui);
}
serde_json::to_value(output).expect("HookOutput serialization cannot fail")
fn format_session_start(&self, context: Option<&str>) -> Value {
serde_json::to_value(HookOutput::session_start(context.map(String::from))).unwrap()
}

fn format_deny(&self, reason: &str, context: Option<&str>) -> Value {
let output = HookOutput::deny(reason.to_string(), context.map(String::from));
serde_json::to_value(output).expect("HookOutput serialization cannot fail")
fn format_post_tool_use(&self, context: Option<&str>) -> Value {
serde_json::to_value(HookOutput::post_tool_use(context.map(String::from))).unwrap()
}

fn format_ask(&self, reason: Option<&str>, context: Option<&str>) -> Value {
let output = HookOutput::ask(reason.map(String::from), context.map(String::from));
serde_json::to_value(output).expect("HookOutput serialization cannot fail")
fn format_continue(&self) -> Value {
serde_json::to_value(HookOutput::continue_execution()).unwrap()
}

fn format_session_start(&self, context: Option<&str>) -> Value {
let output = HookOutput::session_start(context.map(String::from));
serde_json::to_value(output).expect("HookOutput serialization cannot fail")
fn format_permission_response(
&self,
behavior: &str,
message: Option<&str>,
updated_input: Option<&Value>,
interrupt: Option<bool>,
) -> Value {
let output = match behavior {
"allow" => HookOutput::approve_permission(updated_input.cloned()),
_ => HookOutput::deny_permission(
message.unwrap_or("denied").to_string(),
interrupt.unwrap_or(false),
),
};
serde_json::to_value(&output).unwrap()
}
}

fn session_context(&self) -> &str {
include_str!("../../docs/session-context.md")
/// Convert a [`PolicyDecision`] into a Claude-format [`HookOutput`].
fn to_hook_output(decision: &PolicyDecision) -> HookOutput {
match decision {
PolicyDecision::Allow {
reason,
context,
updated_input,
} => {
let mut output = HookOutput::allow(Some(reason.clone()), context.clone());
if let Some(ui) = updated_input {
output.set_updated_input(ui.clone());
}
output
}
PolicyDecision::Deny { reason, context } => {
HookOutput::deny(reason.clone(), context.clone())
}
PolicyDecision::Ask { reason, context } => {
HookOutput::ask(Some(reason.clone()), context.clone())
}
PolicyDecision::Pass => HookOutput::continue_execution(),
}

// parse_post_tool_use — uses default (delegates to parse_tool_use)
// rewrite_for_sandbox — uses default (rewrites "Bash" command field)
}

#[cfg(test)]
mod tests {
use super::*;
use clash_hooks::ToolEvent;

#[test]
fn parse_tool_use_normalizes_name() {
fn parse_event_claude_pre_tool_use() {
let raw = serde_json::json!({
"session_id": "test",
"transcript_path": "/tmp/t.jsonl",
Expand All @@ -89,44 +102,43 @@ mod tests {
"tool_input": {"command": "ls"},
"tool_use_id": "toolu_01"
});
let input = ClaudeProtocol.parse_tool_use(&raw).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.original_tool_name.as_deref(), Some("Bash"));
assert_eq!(input.agent, Some(AgentKind::Claude));
}

#[test]
fn format_allow_matches_existing_format() {
let output = ClaudeProtocol.format_allow(Some("safe"), None, None);
assert_eq!(output["continue"], true);
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "allow");
let event = ClaudeProtocol.parse_event(&raw).unwrap();
let clash_hooks::HookEvent::PreToolUse(e) = event else {
panic!("expected PreToolUse");
};
assert_eq!(e.tool_name(), "Bash");
}

#[test]
fn format_deny_matches_existing_format() {
let output = ClaudeProtocol.format_deny("blocked", Some("context"));
assert_eq!(output["continue"], true);
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
fn format_decision_round_trip() {
let decision = PolicyDecision::Allow {
reason: "policy: allowed".into(),
context: None,
updated_input: None,
};
let json = ClaudeProtocol.format_decision(&decision);
let expected =
serde_json::to_value(&HookOutput::allow(Some("policy: allowed".into()), None)).unwrap();
assert_eq!(json, expected);
}

#[test]
fn format_ask_matches_existing_format() {
let output = ClaudeProtocol.format_ask(None, None);
assert_eq!(output["continue"], true);
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "ask");
fn format_decision_deny_round_trip() {
let decision = PolicyDecision::Deny {
reason: "blocked".into(),
context: Some("ctx".into()),
};
let json = ClaudeProtocol.format_decision(&decision);
let expected =
serde_json::to_value(&HookOutput::deny("blocked".into(), Some("ctx".into()))).unwrap();
assert_eq!(json, expected);
}

#[test]
fn rewrite_for_sandbox_uses_default() {
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: serde_json::json!({"command": "ls -la"}),
cwd: "/home/user".into(),
..Default::default()
};
let result = ClaudeProtocol
.rewrite_for_sandbox(&input, "/usr/bin/clash")
.unwrap();
assert!(result["command"].as_str().unwrap().contains("clash"));
fn format_decision_pass_round_trip() {
let decision = PolicyDecision::Pass;
let json = ClaudeProtocol.format_decision(&decision);
let expected = serde_json::to_value(&HookOutput::continue_execution()).unwrap();
assert_eq!(json, expected);
}
}
Loading
Loading