From 26148a249d80e634e5b3f742eab89981c97e5103 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Thu, 7 May 2026 12:08:04 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Copilot=20CLI=20hook=20protocol=20?= =?UTF-8?q?=E2=80=94=20wrong=20response=20format,=20missing=20tool=20mappi?= =?UTF-8?q?ngs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copilot hook protocol had several bugs that made clash unable to enforce policy when used with GitHub Copilot CLI: Response format: - Used {decision: "approve"} instead of {permissionDecision: "allow"} - format_ask() returned "approve", making it impossible to prompt users - Used "reason" field instead of "permissionDecisionReason" Input parsing: - Expected snake_case fields (tool_name, session_id) but Copilot sends camelCase (toolName, sessionId) - Copilot sends toolArgs as a JSON string, not a parsed object Tool mappings: - Added ask_user -> AskUserQuestion to TOOL_ALIASES - Added report_intent -> ReportIntent to TOOL_ALIASES - Added ReportIntent to builtin allow rules (harmless UI tool) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- clash_starlark/stdlib/builtin.star | 1 + coding-agent-hooks/src/agents/copilot.rs | 89 +++++++++++++++++------- coding-agent-hooks/src/agents/mod.rs | 15 ++++ 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/clash_starlark/stdlib/builtin.star b/clash_starlark/stdlib/builtin.star index e105ffd..d34ab63 100644 --- a/clash_starlark/stdlib/builtin.star +++ b/clash_starlark/stdlib/builtin.star @@ -36,6 +36,7 @@ claude = { "AskUserQuestion", "EnterPlanMode", "ExitPlanMode", + "ReportIntent", "Skill", "ToolSearch", "EnterWorktree", diff --git a/coding-agent-hooks/src/agents/copilot.rs b/coding-agent-hooks/src/agents/copilot.rs index 755d04b..17302c5 100644 --- a/coding-agent-hooks/src/agents/copilot.rs +++ b/coding-agent-hooks/src/agents/copilot.rs @@ -1,13 +1,14 @@ //! GitHub Copilot CLI hook protocol implementation. //! -//! Copilot uses "approve"/"deny" decisions. +//! Copilot CLI uses `permissionDecision` with values "allow"/"deny"/"ask", +//! matching the same protocol as Claude Code hooks. use anyhow::Result; use serde_json::Value; use super::{AgentKind, resolve_tool_name}; use crate::input::ToolUseHookInput; -use crate::protocol::{HookProtocol, json_str}; +use crate::protocol::{HookProtocol, json_str_any, json_value_any}; pub struct CopilotProtocol; @@ -17,48 +18,63 @@ impl HookProtocol for CopilotProtocol { } fn parse_tool_use(&self, raw: &Value) -> Result { - let tool_name = json_str(raw, "tool_name").to_string(); + // Copilot CLI sends camelCase fields (toolName, sessionId, toolArgs) + // while Claude Code sends snake_case (tool_name, session_id, tool_input). + // Accept both forms for robustness. + let tool_name = json_str_any(raw, &["toolName", "tool_name"]).to_string(); let original = tool_name.clone(); let resolved = resolve_tool_name(AgentKind::Copilot, &tool_name).to_string(); + // Copilot sends toolArgs as a JSON string; parse it into a Value. + let tool_input = json_value_any(raw, &["tool_input", "toolArgs"]) + .and_then(|v| match v { + Value::String(s) => serde_json::from_str(&s).ok(), + other => Some(other), + }) + .unwrap_or(Value::Object(serde_json::Map::new())); + 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(), + session_id: json_str_any(raw, &["sessionId", "session_id"]).to_string(), + transcript_path: json_str_any(raw, &["transcript_path"]).to_string(), + cwd: json_str_any(raw, &["cwd"]).to_string(), permission_mode: "default".to_string(), - hook_event_name: json_str(raw, "hook_event_name").to_string(), + hook_event_name: json_str_any(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(), + tool_input, + tool_use_id: raw.get("toolCallId").and_then(|v| v.as_str()).map(String::from), + tool_response: json_value_any(raw, &["tool_response", "toolResponse"]), agent: Some(AgentKind::Copilot), original_tool_name: Some(original), }) } - // Copilot uses "approve"/"deny" + // Copilot CLI uses the same permissionDecision protocol as Claude Code. fn format_allow( &self, reason: Option<&str>, _context: Option<&str>, _updated_input: Option, ) -> Value { - let mut output = serde_json::json!({ "decision": "approve" }); + let mut output = serde_json::json!({ "permissionDecision": "allow" }); if let Some(r) = reason { - output["reason"] = Value::String(r.to_string()); + output["permissionDecisionReason"] = Value::String(r.to_string()); } output } fn format_deny(&self, reason: &str, _context: Option<&str>) -> Value { - serde_json::json!({ "decision": "deny", "reason": reason }) + serde_json::json!({ + "permissionDecision": "deny", + "permissionDecisionReason": reason + }) } - fn format_ask(&self, _reason: Option<&str>, _context: Option<&str>) -> Value { - serde_json::json!({ "decision": "approve" }) + fn format_ask(&self, reason: Option<&str>, _context: Option<&str>) -> Value { + let mut output = serde_json::json!({ "permissionDecision": "ask" }); + if let Some(r) = reason { + output["permissionDecisionReason"] = Value::String(r.to_string()); + } + output } } @@ -67,7 +83,7 @@ mod tests { use super::*; #[test] - fn parse_copilot_bash() { + fn parse_copilot_bash_snake_case() { let raw = serde_json::json!({ "session_id": "cp-123", "cwd": "/home/user", @@ -77,18 +93,41 @@ mod tests { }); let input = CopilotProtocol.parse_tool_use(&raw).unwrap(); assert_eq!(input.tool_name, "Bash"); + assert_eq!(input.session_id, "cp-123"); + } + + #[test] + fn parse_copilot_bash_camel_case() { + let raw = serde_json::json!({ + "sessionId": "cp-456", + "cwd": "/home/user", + "toolName": "bash", + "toolArgs": "{\"command\":\"echo hello\"}" + }); + let input = CopilotProtocol.parse_tool_use(&raw).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert_eq!(input.session_id, "cp-456"); + // toolArgs is a JSON string that should be parsed into tool_input + assert_eq!(input.tool_input["command"], "echo hello"); } #[test] fn format_allow_copilot() { - assert_eq!( - CopilotProtocol.format_allow(None, None, None)["decision"], - "approve" - ); + let out = CopilotProtocol.format_allow(None, None, None); + assert_eq!(out["permissionDecision"], "allow"); } #[test] fn format_deny_copilot() { - assert_eq!(CopilotProtocol.format_deny("no", None)["decision"], "deny"); + let out = CopilotProtocol.format_deny("blocked", None); + assert_eq!(out["permissionDecision"], "deny"); + assert_eq!(out["permissionDecisionReason"], "blocked"); + } + + #[test] + fn format_ask_copilot() { + let out = CopilotProtocol.format_ask(Some("needs approval"), None); + assert_eq!(out["permissionDecision"], "ask"); + assert_eq!(out["permissionDecisionReason"], "needs approval"); } } diff --git a/coding-agent-hooks/src/agents/mod.rs b/coding-agent-hooks/src/agents/mod.rs index 7dd2858..e68a053 100644 --- a/coding-agent-hooks/src/agents/mod.rs +++ b/coding-agent-hooks/src/agents/mod.rs @@ -210,6 +210,21 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::OpenCode, "websearch"), ], }, + ToolAlias { + canonical: "ask", + internal: "AskUserQuestion", + agent_names: &[ + (AgentKind::Claude, "AskUserQuestion"), + (AgentKind::Copilot, "ask_user"), + ], + }, + ToolAlias { + canonical: "report_intent", + internal: "ReportIntent", + agent_names: &[ + (AgentKind::Copilot, "report_intent"), + ], + }, ]; /// Given an agent's native tool name, return the internal (Claude-style) name. From 93e3e9e883d616417d4bf008bb60963e3a46b779 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 11 May 2026 15:37:49 -0500 Subject: [PATCH 2/2] feat(copilot): add tool name mappings and improve bash command parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Copilot CLI tool aliases: glob, grep, create, web_fetch, web_search - Add Copilot task lifecycle mappings: task→TaskCreate, read_agent→TaskGet, write_agent→TaskUpdate, list_agents→TaskList, task_complete→TaskOutput - Treat `cd &&` as a transparent prefix in bash command parsing, matching Copilot CLI's pattern of prepending `cd $PWD &&` to commands - Normalize binary paths to filenames (./gradlew→gradlew, /usr/bin/git→git) - Fix test for agent-specific aliases without Claude entries (ReportIntent) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- coding-agent-hooks/src/agents/copilot.rs | 5 +- coding-agent-hooks/src/agents/mod.rs | 59 +++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/coding-agent-hooks/src/agents/copilot.rs b/coding-agent-hooks/src/agents/copilot.rs index 17302c5..92f747c 100644 --- a/coding-agent-hooks/src/agents/copilot.rs +++ b/coding-agent-hooks/src/agents/copilot.rs @@ -41,7 +41,10 @@ impl HookProtocol for CopilotProtocol { hook_event_name: json_str_any(raw, &["hook_event_name"]).to_string(), tool_name: resolved, tool_input, - tool_use_id: raw.get("toolCallId").and_then(|v| v.as_str()).map(String::from), + tool_use_id: raw + .get("toolCallId") + .and_then(|v| v.as_str()) + .map(String::from), tool_response: json_value_any(raw, &["tool_response", "toolResponse"]), agent: Some(AgentKind::Copilot), original_tool_name: Some(original), diff --git a/coding-agent-hooks/src/agents/mod.rs b/coding-agent-hooks/src/agents/mod.rs index e68a053..90d4d20 100644 --- a/coding-agent-hooks/src/agents/mod.rs +++ b/coding-agent-hooks/src/agents/mod.rs @@ -161,6 +161,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Gemini, "write_file"), (AgentKind::AmazonQ, "fs_write"), (AgentKind::OpenCode, "write"), + (AgentKind::Copilot, "create"), ], }, ToolAlias { @@ -180,6 +181,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Claude, "Glob"), (AgentKind::Gemini, "glob"), (AgentKind::OpenCode, "glob"), + (AgentKind::Copilot, "glob"), ], }, ToolAlias { @@ -189,6 +191,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Claude, "Grep"), (AgentKind::Gemini, "grep_search"), (AgentKind::OpenCode, "grep"), + (AgentKind::Copilot, "grep"), ], }, ToolAlias { @@ -198,6 +201,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Claude, "WebFetch"), (AgentKind::Gemini, "web_fetch"), (AgentKind::OpenCode, "webfetch"), + (AgentKind::Copilot, "web_fetch"), ], }, ToolAlias { @@ -208,6 +212,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Gemini, "google_web_search"), (AgentKind::Codex, "web_search"), (AgentKind::OpenCode, "websearch"), + (AgentKind::Copilot, "web_search"), ], }, ToolAlias { @@ -221,8 +226,51 @@ const TOOL_ALIASES: &[ToolAlias] = &[ ToolAlias { canonical: "report_intent", internal: "ReportIntent", + agent_names: &[(AgentKind::Copilot, "report_intent")], + }, + ToolAlias { + canonical: "task_output", + internal: "TaskOutput", + agent_names: &[ + (AgentKind::Claude, "TaskOutput"), + (AgentKind::Copilot, "task_complete"), + ], + }, + ToolAlias { + canonical: "task_create", + internal: "TaskCreate", agent_names: &[ - (AgentKind::Copilot, "report_intent"), + (AgentKind::Claude, "TaskCreate"), + (AgentKind::Copilot, "task"), + ], + }, + ToolAlias { + canonical: "task_get", + internal: "TaskGet", + agent_names: &[ + (AgentKind::Claude, "TaskGet"), + (AgentKind::Copilot, "read_agent"), + ], + }, + ToolAlias { + canonical: "task_list", + internal: "TaskList", + agent_names: &[ + (AgentKind::Claude, "TaskList"), + (AgentKind::Copilot, "list_agents"), + ], + }, + ToolAlias { + canonical: "task_stop", + internal: "TaskStop", + agent_names: &[(AgentKind::Claude, "TaskStop")], + }, + ToolAlias { + canonical: "task_update", + internal: "TaskUpdate", + agent_names: &[ + (AgentKind::Claude, "TaskUpdate"), + (AgentKind::Copilot, "write_agent"), ], }, ]; @@ -475,6 +523,15 @@ mod tests { .map(|(_, name)| *name) .collect(); for alias in TOOL_ALIASES { + let has_claude_entry = alias + .agent_names + .iter() + .any(|(ak, _)| *ak == AgentKind::Claude); + // Aliases that don't have a Claude entry are agent-specific + // (e.g. ReportIntent is Copilot-only) and don't need this check. + if !has_claude_entry { + continue; + } assert!( claude_names.contains(&alias.internal), "internal name '{}' for canonical '{}' is not a Claude tool name",