Skip to main content

safe_chains/targets/
copilot.rs

1use 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" // lowercase — Copilot's tool name, not Claude's `Bash`
22    }
23
24    #[cfg(test)]
25    fn sample_envelope(&self, tool: &str, command: &str) -> Option<String> {
26        // `toolArgs` is a nested JSON STRING, not an object.
27        Some(format!(
28            r#"{{"toolName":"{tool}","toolArgs":"{{\"command\":\"{command}\"}}"}}"#
29        ))
30    }
31
32    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
33        // Copilot reads hooks from per-repo `<repo>/.github/hooks/*.json` OR the user-global
34        // `~/.copilot/hooks/` (or `$COPILOT_HOME/hooks/`); upstream's hooks-configuration docs say
35        // both are loaded and all entries run. We install to the user-global dir; a per-repo install
36        // would need a project path. The live drive (v1.0.71) fired the per-repo `.github/hooks/`
37        // config and confirmed `~/.copilot/` is the real config root — the earlier `~/.github/hooks/`
38        // guess was wrong and never fired.
39        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        // Copilot's quirk: toolArgs is a JSON-encoded *string*, not a
95        // nested object. We must parse the outer envelope, then parse
96        // the toolArgs string a second time to recover {command}.
97        let envelope: CopilotHookEnvelope =
98            serde_json::from_str(stdin).map_err(|e| ParseError {
99                message: e.to_string(),
100            })?;
101
102        // The hook fires for every tool by default — Copilot's config
103        // has no matcher. Self-filter to the bash tool here; for other
104        // tools, return Err so the runtime exits silently and Copilot
105        // falls back to its own permission rules.
106        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, // copilot sends cwd but no documented project root
128            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
129            session_id: None,
130        })
131    }
132
133    fn decision_pointer(&self) -> &'static str {
134        "/permissionDecision" // FLAT, not nested — same leaf name as Claude
135    }
136
137    fn render_response(&self, verdict: Verdict) -> HookResponse {
138        // Copilot honors BOTH `allow` and `deny` — VERIFIED LIVE (v1.0.71): a hook `allow` auto-runs
139        // the command and even suppresses copilot's own directory-access prompt; a hook `deny` blocks
140        // it and surfaces our `permissionDecisionReason`. (The earlier "only deny is processed" note
141        // is stale.) So a safe command gets `allow` (a real grant) and a gated one ABSTAINS (empty),
142        // deferring to copilot's own per-command prompt — copilot has interactive review, so we don't
143        // hard-deny. Copilot's response is a FLAT object (no `hookSpecificOutput` wrapper).
144        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    // Copilot's config: flat object with `version` and `hooks.preToolUse[]`.
165    // No `matcher` in entries — script self-filters on `toolName`. Field
166    // name is `bash` (the script path), NOT `command`.
167    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    /// Verbatim shape from docs.github.com/en/copilot/reference/
211    /// hooks-configuration. The `toolArgs` field is a JSON-encoded
212    /// STRING, not a nested object — must be parsed twice.
213    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        // Copilot's script-path field is `bash`, not `command`. Wiring
235        // this to `command` would silently mis-configure the hook.
236        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        // The headline quirk: outer JSON, then `toolArgs` is itself a
270        // JSON-encoded string that must be parsed again to recover the
271        // bash command. Tested with the verbatim docs payload.
272        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        // No matcher in Copilot's config — every tool dispatches
280        // through. Self-filter to "bash"; for others, return Err so
281        // the runtime exits silently and Copilot's own perms apply.
282        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        // Copilot uses a FLAT response object — no
306        // hookSpecificOutput wrapper key, unlike Claude/Codex/Qwen/
307        // Droid. Wrapping would be silently rejected.
308        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}