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