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