safe_chains/targets/
copilot.rs1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target, allow_reason};
7use crate::verdict::Verdict;
8
9pub struct CopilotTarget;
10
11impl Target for CopilotTarget {
12 fn name(&self) -> &'static str {
13 "copilot"
14 }
15
16 fn display_name(&self) -> &'static str {
17 "GitHub Copilot CLI"
18 }
19
20 fn shell_tool_name(&self) -> &'static str {
21 "bash" }
23
24 #[cfg(test)]
25 fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
26 Some(format!(r#"{{"toolName":"{tool}","toolArgs":"{{\"command\":\"{command}\"}}"}}"#))
28 }
29
30 fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
31 vec![home.join(".copilot").join("hooks")]
38 }
39
40 fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
41 let dir = home.join(".copilot").join("hooks");
42 if let Err(e) = std::fs::create_dir_all(&dir) {
43 return Err(format!("Could not create {}: {e}", dir.display()));
44 }
45
46 let path = dir.join("safe-chains.json");
47
48 if path.exists() {
49 let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
50 let settings: Value = serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
51 if has_safe_chains_hook(&settings) {
52 return Ok(InstallOutcome::AlreadyConfigured { path });
53 }
54 }
55
56 let settings = build_settings();
57 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
58 std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
59 Ok(InstallOutcome::Installed { path })
60 }
61
62 fn hook_format(&self) -> Option<&dyn HookFormat> {
63 Some(&CopilotHookFormat)
64 }
65}
66
67struct CopilotHookFormat;
68
69#[derive(Deserialize)]
70struct CopilotHookEnvelope {
71 #[serde(default)]
72 #[serde(rename = "toolName")]
73 tool_name: Option<String>,
74 #[serde(default)]
75 #[serde(rename = "toolArgs")]
76 tool_args: Option<String>,
77 #[serde(default)]
78 cwd: Option<String>,
79}
80
81#[derive(Deserialize)]
82struct CopilotToolArgs {
83 #[serde(default)]
84 command: Option<String>,
85}
86
87impl HookFormat for CopilotHookFormat {
88 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
89 let envelope: CopilotHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
93
94 let is_bash_tool = envelope.tool_name.as_deref().is_some_and(|n| n == "bash");
99 if !is_bash_tool {
100 return Err(ParseError { message: format!("not a bash tool: {:?}", envelope.tool_name.as_deref().unwrap_or("<missing>")) });
101 }
102
103 let raw_args = envelope.tool_args.unwrap_or_default();
104 let inner: CopilotToolArgs =
105 serde_json::from_str(&raw_args).map_err(|e| ParseError { message: format!("toolArgs not a parseable JSON string: {e}") })?;
106 Ok(HookInput {
107 command: inner.command.unwrap_or_default(),
108 cwd: envelope.cwd,
109 root: None, session_id: None,
112 })
113 }
114
115 fn decision_pointer(&self) -> &'static str {
116 "/permissionDecision" }
118
119 fn render_response(&self, verdict: Verdict) -> HookResponse {
120 if verdict.is_allowed() {
127 let reason = allow_reason(verdict);
128 let body = json!({
129 "permissionDecision": "allow",
130 "permissionDecisionReason": reason,
131 });
132 HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
133 } else {
134 HookResponse { stdout: String::new(), exit_code: 0 }
135 }
136 }
137}
138
139fn build_settings() -> Value {
140 let resolved = std::env::current_exe()
144 .ok()
145 .and_then(|p| p.canonicalize().ok())
146 .map(|p| format!("{} hook copilot", p.display()))
147 .unwrap_or_else(|| "safe-chains hook copilot".to_string());
148 json!({
149 "version": 1,
150 "hooks": {
151 "preToolUse": [
152 {
153 "type": "command",
154 "bash": resolved,
155 "comment": "safe-chains: validate every Bash tool call before it runs.",
156 "timeoutSec": 60,
157 }
158 ]
159 }
160 })
161}
162
163fn has_safe_chains_hook(settings: &Value) -> bool {
164 settings.pointer("/hooks/preToolUse").and_then(|arr| arr.as_array()).is_some_and(|entries| {
165 entries
166 .iter()
167 .any(|entry| entry.get("bash").and_then(|c| c.as_str()).is_some_and(|cmd| cmd.contains("safe-chains")))
168 })
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::verdict::SafetyLevel;
175
176 fn target() -> CopilotTarget {
177 CopilotTarget
178 }
179
180 const COPILOT_DOCS_SAMPLE: &str = r#"{
184 "timestamp": 1704614600000,
185 "cwd": "/path/to/project",
186 "toolName": "bash",
187 "toolArgs": "{\"command\":\"ls -la\",\"description\":\"list files\"}"
188 }"#;
189
190 #[test]
191 fn install_creates_hooks_file() {
192 let dir = tempfile::tempdir().unwrap();
193 let outcome = target().install(dir.path()).unwrap();
194 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
195 let path = dir.path().join(".copilot/hooks/safe-chains.json");
196 assert!(path.exists());
197 let contents = std::fs::read_to_string(&path).unwrap();
198 let settings: Value = serde_json::from_str(&contents).unwrap();
199 assert!(has_safe_chains_hook(&settings));
200 }
201
202 #[test]
203 fn install_uses_bash_field_not_command() {
204 let dir = tempfile::tempdir().unwrap();
207 target().install(dir.path()).unwrap();
208 let contents = std::fs::read_to_string(dir.path().join(".copilot/hooks/safe-chains.json")).unwrap();
209 let settings: Value = serde_json::from_str(&contents).unwrap();
210 let entry = settings.pointer("/hooks/preToolUse/0").unwrap();
211 assert!(entry.get("bash").is_some(), "must use `bash` key");
212 assert!(entry.get("command").is_none(), "must NOT use `command` key");
213 }
214
215 #[test]
216 fn install_uses_subcommand_invocation() {
217 let dir = tempfile::tempdir().unwrap();
218 target().install(dir.path()).unwrap();
219 let contents = std::fs::read_to_string(dir.path().join(".copilot/hooks/safe-chains.json")).unwrap();
220 assert!(contents.contains("hook copilot"));
221 }
222
223 #[test]
224 fn install_idempotent() {
225 let dir = tempfile::tempdir().unwrap();
226 target().install(dir.path()).unwrap();
227 let outcome = target().install(dir.path()).unwrap();
228 assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
229 }
230
231 #[test]
232 fn parse_input_double_decodes_tool_args() {
233 let parsed = CopilotHookFormat.parse_input(COPILOT_DOCS_SAMPLE).unwrap();
237 assert_eq!(parsed.command, "ls -la");
238 assert_eq!(parsed.cwd.as_deref(), Some("/path/to/project"));
239 }
240
241 #[test]
242 fn parse_input_skips_non_bash_tools() {
243 let stdin = r#"{
247 "timestamp": 1,
248 "cwd": "/p",
249 "toolName": "edit",
250 "toolArgs": "{\"path\":\"x\"}"
251 }"#;
252 assert!(CopilotHookFormat.parse_input(stdin).is_err());
253 }
254
255 #[test]
256 fn parse_input_rejects_garbage() {
257 assert!(CopilotHookFormat.parse_input("not json").is_err());
258 }
259
260 #[test]
261 fn parse_input_rejects_unparseable_tool_args() {
262 let stdin = r#"{"toolName": "bash", "toolArgs": "not-json"}"#;
263 let result = CopilotHookFormat.parse_input(stdin);
264 assert!(result.is_err());
265 }
266
267 #[test]
268 fn render_response_emits_flat_object_no_wrapper() {
269 let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
273 let v: Value = serde_json::from_str(&r.stdout).unwrap();
274 assert_eq!(v.get("permissionDecision").and_then(|s| s.as_str()), Some("allow"),);
275 assert!(v.get("hookSpecificOutput").is_none(), "must NOT wrap in hookSpecificOutput",);
276 }
277
278 #[test]
279 fn render_response_includes_reason() {
280 let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
281 let v: Value = serde_json::from_str(&r.stdout).unwrap();
282 assert!(v.get("permissionDecisionReason").and_then(|s| s.as_str()).is_some());
283 }
284
285 #[test]
286 fn render_response_deny_emits_empty_body() {
287 let r = CopilotHookFormat.render_response(Verdict::Denied);
288 assert_eq!(r.stdout, "");
289 }
290}