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 exe = std::env::current_exe().ok().and_then(|p| p.canonicalize().ok());
42 self.install_with(home, exe.as_deref())
43 }
44
45 fn hook_format(&self) -> Option<&dyn HookFormat> {
46 Some(&CopilotHookFormat)
47 }
48}
49
50impl CopilotTarget {
51 fn install_with(&self, home: &Path, exe: Option<&Path>) -> Result<InstallOutcome, String> {
57 let dir = home.join(".copilot").join("hooks");
58 if let Err(e) = std::fs::create_dir_all(&dir) {
59 return Err(format!("Could not create {}: {e}", dir.display()));
60 }
61
62 let path = dir.join("safe-chains.json");
63
64 if path.exists() {
65 let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
66 let settings: Value = serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
67 if has_safe_chains_hook(&settings) {
68 return Ok(InstallOutcome::AlreadyConfigured { path });
69 }
70 }
71
72 let settings = build_settings(exe);
73 let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
74 std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
75 Ok(InstallOutcome::Installed { path })
76 }
77}
78
79struct CopilotHookFormat;
80
81#[derive(Deserialize)]
82struct CopilotHookEnvelope {
83 #[serde(default)]
84 #[serde(rename = "toolName")]
85 tool_name: Option<String>,
86 #[serde(default)]
87 #[serde(rename = "toolArgs")]
88 tool_args: Option<String>,
89 #[serde(default)]
90 cwd: Option<String>,
91}
92
93#[derive(Deserialize)]
94struct CopilotToolArgs {
95 #[serde(default)]
96 command: Option<String>,
97}
98
99impl HookFormat for CopilotHookFormat {
100 fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
101 let envelope: CopilotHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
105
106 let is_bash_tool = envelope.tool_name.as_deref().is_some_and(|n| n == "bash");
111 if !is_bash_tool {
112 return Err(ParseError { message: format!("not a bash tool: {:?}", envelope.tool_name.as_deref().unwrap_or("<missing>")) });
113 }
114
115 let raw_args = envelope.tool_args.unwrap_or_default();
116 let inner: CopilotToolArgs =
117 serde_json::from_str(&raw_args).map_err(|e| ParseError { message: format!("toolArgs not a parseable JSON string: {e}") })?;
118 Ok(HookInput {
119 command: inner.command.unwrap_or_default(),
120 cwd: envelope.cwd,
121 root: None, session_id: None,
124 })
125 }
126
127 fn decision_pointer(&self) -> &'static str {
128 "/permissionDecision" }
130
131 fn render_response(&self, verdict: Verdict) -> HookResponse {
132 if verdict.is_allowed() {
139 let reason = allow_reason(verdict);
140 let body = json!({
141 "permissionDecision": "allow",
142 "permissionDecisionReason": reason,
143 });
144 HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
145 } else {
146 HookResponse { stdout: String::new(), exit_code: 0 }
147 }
148 }
149}
150
151fn build_settings(exe: Option<&Path>) -> Value {
152 let resolved = exe
157 .map(|p| format!("{} hook copilot", p.display()))
158 .unwrap_or_else(|| "safe-chains hook copilot".to_string());
159 json!({
160 "version": 1,
161 "hooks": {
162 "preToolUse": [
163 {
164 "type": "command",
165 "bash": resolved,
166 "comment": "safe-chains: validate every Bash tool call before it runs.",
167 "timeoutSec": 60,
168 }
169 ]
170 }
171 })
172}
173
174fn has_safe_chains_hook(settings: &Value) -> bool {
175 settings.pointer("/hooks/preToolUse").and_then(|arr| arr.as_array()).is_some_and(|entries| {
176 entries
177 .iter()
178 .any(|entry| entry.get("bash").and_then(|c| c.as_str()).is_some_and(|cmd| cmd.contains("safe-chains")))
179 })
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::verdict::SafetyLevel;
186
187 fn target() -> CopilotTarget {
188 CopilotTarget
189 }
190
191 const EXE: &str = "/opt/homebrew/bin/safe-chains";
196
197 const COPILOT_DOCS_SAMPLE: &str = r#"{
201 "timestamp": 1704614600000,
202 "cwd": "/path/to/project",
203 "toolName": "bash",
204 "toolArgs": "{\"command\":\"ls -la\",\"description\":\"list files\"}"
205 }"#;
206
207 #[test]
208 fn install_creates_hooks_file() {
209 let dir = tempfile::tempdir().unwrap();
210 let outcome = target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
211 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
212 let path = dir.path().join(".copilot/hooks/safe-chains.json");
213 assert!(path.exists());
214 let contents = std::fs::read_to_string(&path).unwrap();
215 let settings: Value = serde_json::from_str(&contents).unwrap();
216 assert!(has_safe_chains_hook(&settings));
217 }
218
219 #[test]
220 fn install_uses_bash_field_not_command() {
221 let dir = tempfile::tempdir().unwrap();
224 target().install(dir.path()).unwrap();
225 let contents = std::fs::read_to_string(dir.path().join(".copilot/hooks/safe-chains.json")).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(dir.path().join(".copilot/hooks/safe-chains.json")).unwrap();
237 assert!(contents.contains("hook copilot"));
238 }
239
240 #[test]
241 fn install_idempotent() {
242 let dir = tempfile::tempdir().unwrap();
243 target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
244 let outcome = target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
245 assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
246 }
247
248 #[test]
249 fn install_idempotent_outside_a_safe_chains_directory() {
250 let dir = tempfile::tempdir().unwrap();
255 let exe = Path::new("/usr/local/bin/safe-chains");
256 let first = target().install_with(dir.path(), Some(exe)).unwrap();
257 assert!(matches!(first, InstallOutcome::Installed { .. }));
258 let second = target().install_with(dir.path(), Some(exe)).unwrap();
259 assert!(matches!(second, InstallOutcome::AlreadyConfigured { .. }));
260 }
261
262 #[test]
263 fn parse_input_double_decodes_tool_args() {
264 let parsed = CopilotHookFormat.parse_input(COPILOT_DOCS_SAMPLE).unwrap();
268 assert_eq!(parsed.command, "ls -la");
269 assert_eq!(parsed.cwd.as_deref(), Some("/path/to/project"));
270 }
271
272 #[test]
273 fn parse_input_skips_non_bash_tools() {
274 let stdin = r#"{
278 "timestamp": 1,
279 "cwd": "/p",
280 "toolName": "edit",
281 "toolArgs": "{\"path\":\"x\"}"
282 }"#;
283 assert!(CopilotHookFormat.parse_input(stdin).is_err());
284 }
285
286 #[test]
287 fn parse_input_rejects_garbage() {
288 assert!(CopilotHookFormat.parse_input("not json").is_err());
289 }
290
291 #[test]
292 fn parse_input_rejects_unparseable_tool_args() {
293 let stdin = r#"{"toolName": "bash", "toolArgs": "not-json"}"#;
294 let result = CopilotHookFormat.parse_input(stdin);
295 assert!(result.is_err());
296 }
297
298 #[test]
299 fn render_response_emits_flat_object_no_wrapper() {
300 let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
304 let v: Value = serde_json::from_str(&r.stdout).unwrap();
305 assert_eq!(v.get("permissionDecision").and_then(|s| s.as_str()), Some("allow"),);
306 assert!(v.get("hookSpecificOutput").is_none(), "must NOT wrap in hookSpecificOutput",);
307 }
308
309 #[test]
310 fn render_response_includes_reason() {
311 let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
312 let v: Value = serde_json::from_str(&r.stdout).unwrap();
313 assert!(v.get("permissionDecisionReason").and_then(|s| s.as_str()).is_some());
314 }
315
316 #[test]
317 fn render_response_deny_emits_empty_body() {
318 let r = CopilotHookFormat.render_response(Verdict::Denied);
319 assert_eq!(r.stdout, "");
320 }
321}