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 detect_paths(&self, home: &Path) -> Vec<PathBuf> {
21 vec![home.join(".copilot").join("hooks")]
28 }
29
30 fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
31 let dir = home.join(".copilot").join("hooks");
32 if let Err(e) = std::fs::create_dir_all(&dir) {
33 return Err(format!("Could not create {}: {e}", dir.display()));
34 }
35
36 let path = dir.join("safe-chains.json");
37
38 if path.exists() {
39 let contents = std::fs::read_to_string(&path)
40 .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
41 let settings: Value = serde_json::from_str(&contents)
42 .map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
43 if has_safe_chains_hook(&settings) {
44 return Ok(InstallOutcome::AlreadyConfigured { path });
45 }
46 }
47
48 let settings = build_settings();
49 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
50 std::fs::write(&path, format!("{output}\n"))
51 .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
52 Ok(InstallOutcome::Installed { path })
53 }
54
55 fn hook_format(&self) -> Option<&dyn HookFormat> {
56 Some(&CopilotHookFormat)
57 }
58}
59
60struct CopilotHookFormat;
61
62#[derive(Deserialize)]
63struct CopilotHookEnvelope {
64 #[serde(default)]
65 #[serde(rename = "toolName")]
66 tool_name: Option<String>,
67 #[serde(default)]
68 #[serde(rename = "toolArgs")]
69 tool_args: Option<String>,
70 #[serde(default)]
71 cwd: Option<String>,
72}
73
74#[derive(Deserialize)]
75struct CopilotToolArgs {
76 #[serde(default)]
77 command: Option<String>,
78}
79
80impl HookFormat for CopilotHookFormat {
81 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
82 let envelope: CopilotHookEnvelope =
86 serde_json::from_str(stdin).map_err(|e| ParseError {
87 message: e.to_string(),
88 })?;
89
90 let is_bash_tool = envelope
95 .tool_name
96 .as_deref()
97 .is_some_and(|n| n == "bash");
98 if !is_bash_tool {
99 return Err(ParseError {
100 message: format!(
101 "not a bash tool: {:?}",
102 envelope.tool_name.as_deref().unwrap_or("<missing>")
103 ),
104 });
105 }
106
107 let raw_args = envelope.tool_args.unwrap_or_default();
108 let inner: CopilotToolArgs =
109 serde_json::from_str(&raw_args).map_err(|e| ParseError {
110 message: format!("toolArgs not a parseable JSON string: {e}"),
111 })?;
112 Ok(HookInput {
113 command: inner.command.unwrap_or_default(),
114 cwd: envelope.cwd,
115 root: None, session_id: None,
118 })
119 }
120
121 fn render_response(&self, verdict: Verdict) -> HookResponse {
122 if verdict.is_allowed() {
129 let reason = allow_reason(verdict);
130 let body = json!({
131 "permissionDecision": "allow",
132 "permissionDecisionReason": reason,
133 });
134 HookResponse {
135 stdout: serde_json::to_string(&body).unwrap_or_default(),
136 exit_code: 0,
137 }
138 } else {
139 HookResponse {
140 stdout: String::new(),
141 exit_code: 0,
142 }
143 }
144 }
145}
146
147fn build_settings() -> Value {
148 let resolved = std::env::current_exe()
152 .ok()
153 .and_then(|p| p.canonicalize().ok())
154 .map(|p| format!("{} hook copilot", p.display()))
155 .unwrap_or_else(|| "safe-chains hook copilot".to_string());
156 json!({
157 "version": 1,
158 "hooks": {
159 "preToolUse": [
160 {
161 "type": "command",
162 "bash": resolved,
163 "comment": "safe-chains: validate every Bash tool call before it runs.",
164 "timeoutSec": 60,
165 }
166 ]
167 }
168 })
169}
170
171fn has_safe_chains_hook(settings: &Value) -> bool {
172 settings
173 .pointer("/hooks/preToolUse")
174 .and_then(|arr| arr.as_array())
175 .is_some_and(|entries| {
176 entries.iter().any(|entry| {
177 entry
178 .get("bash")
179 .and_then(|c| c.as_str())
180 .is_some_and(|cmd| cmd.contains("safe-chains"))
181 })
182 })
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::verdict::SafetyLevel;
189
190 fn target() -> CopilotTarget {
191 CopilotTarget
192 }
193
194 const COPILOT_DOCS_SAMPLE: &str = r#"{
198 "timestamp": 1704614600000,
199 "cwd": "/path/to/project",
200 "toolName": "bash",
201 "toolArgs": "{\"command\":\"ls -la\",\"description\":\"list files\"}"
202 }"#;
203
204 #[test]
205 fn install_creates_hooks_file() {
206 let dir = tempfile::tempdir().unwrap();
207 let outcome = target().install(dir.path()).unwrap();
208 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
209 let path = dir.path().join(".copilot/hooks/safe-chains.json");
210 assert!(path.exists());
211 let contents = std::fs::read_to_string(&path).unwrap();
212 let settings: Value = serde_json::from_str(&contents).unwrap();
213 assert!(has_safe_chains_hook(&settings));
214 }
215
216 #[test]
217 fn install_uses_bash_field_not_command() {
218 let dir = tempfile::tempdir().unwrap();
221 target().install(dir.path()).unwrap();
222 let contents = std::fs::read_to_string(
223 dir.path().join(".copilot/hooks/safe-chains.json"),
224 )
225 .unwrap();
226 let settings: Value = serde_json::from_str(&contents).unwrap();
227 let entry = settings.pointer("/hooks/preToolUse/0").unwrap();
228 assert!(entry.get("bash").is_some(), "must use `bash` key");
229 assert!(entry.get("command").is_none(), "must NOT use `command` key");
230 }
231
232 #[test]
233 fn install_uses_subcommand_invocation() {
234 let dir = tempfile::tempdir().unwrap();
235 target().install(dir.path()).unwrap();
236 let contents = std::fs::read_to_string(
237 dir.path().join(".copilot/hooks/safe-chains.json"),
238 )
239 .unwrap();
240 assert!(contents.contains("hook copilot"));
241 }
242
243 #[test]
244 fn install_idempotent() {
245 let dir = tempfile::tempdir().unwrap();
246 target().install(dir.path()).unwrap();
247 let outcome = target().install(dir.path()).unwrap();
248 assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
249 }
250
251 #[test]
252 fn parse_input_double_decodes_tool_args() {
253 let parsed = CopilotHookFormat.parse_input(COPILOT_DOCS_SAMPLE).unwrap();
257 assert_eq!(parsed.command, "ls -la");
258 assert_eq!(parsed.cwd.as_deref(), Some("/path/to/project"));
259 }
260
261 #[test]
262 fn parse_input_skips_non_bash_tools() {
263 let stdin = r#"{
267 "timestamp": 1,
268 "cwd": "/p",
269 "toolName": "edit",
270 "toolArgs": "{\"path\":\"x\"}"
271 }"#;
272 assert!(CopilotHookFormat.parse_input(stdin).is_err());
273 }
274
275 #[test]
276 fn parse_input_rejects_garbage() {
277 assert!(CopilotHookFormat.parse_input("not json").is_err());
278 }
279
280 #[test]
281 fn parse_input_rejects_unparseable_tool_args() {
282 let stdin = r#"{"toolName": "bash", "toolArgs": "not-json"}"#;
283 let result = CopilotHookFormat.parse_input(stdin);
284 assert!(result.is_err());
285 }
286
287 #[test]
288 fn render_response_emits_flat_object_no_wrapper() {
289 let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
293 let v: Value = serde_json::from_str(&r.stdout).unwrap();
294 assert_eq!(
295 v.get("permissionDecision").and_then(|s| s.as_str()),
296 Some("allow"),
297 );
298 assert!(
299 v.get("hookSpecificOutput").is_none(),
300 "must NOT wrap in hookSpecificOutput",
301 );
302 }
303
304 #[test]
305 fn render_response_includes_reason() {
306 let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
307 let v: Value = serde_json::from_str(&r.stdout).unwrap();
308 assert!(
309 v.get("permissionDecisionReason")
310 .and_then(|s| s.as_str())
311 .is_some()
312 );
313 }
314
315 #[test]
316 fn render_response_deny_emits_empty_body() {
317 let r = CopilotHookFormat.render_response(Verdict::Denied);
318 assert_eq!(r.stdout, "");
319 }
320}