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 detect_paths(&self, home: &Path) -> Vec<PathBuf> {
21        // Copilot reads hooks from per-repo `<repo>/.github/hooks/*.json` OR the user-global
22        // `~/.copilot/hooks/` (or `$COPILOT_HOME/hooks/`); upstream's hooks-configuration docs say
23        // both are loaded and all entries run. We install to the user-global dir; a per-repo install
24        // would need a project path. The live drive (v1.0.71) fired the per-repo `.github/hooks/`
25        // config and confirmed `~/.copilot/` is the real config root — the earlier `~/.github/hooks/`
26        // guess was wrong and never fired.
27        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        // Copilot's quirk: toolArgs is a JSON-encoded *string*, not a
83        // nested object. We must parse the outer envelope, then parse
84        // the toolArgs string a second time to recover {command}.
85        let envelope: CopilotHookEnvelope =
86            serde_json::from_str(stdin).map_err(|e| ParseError {
87                message: e.to_string(),
88            })?;
89
90        // The hook fires for every tool by default — Copilot's config
91        // has no matcher. Self-filter to the bash tool here; for other
92        // tools, return Err so the runtime exits silently and Copilot
93        // falls back to its own permission rules.
94        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, // copilot sends cwd but no documented project root
116            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
117            session_id: None,
118        })
119    }
120
121    fn render_response(&self, verdict: Verdict) -> HookResponse {
122        // Copilot honors BOTH `allow` and `deny` — VERIFIED LIVE (v1.0.71): a hook `allow` auto-runs
123        // the command and even suppresses copilot's own directory-access prompt; a hook `deny` blocks
124        // it and surfaces our `permissionDecisionReason`. (The earlier "only deny is processed" note
125        // is stale.) So a safe command gets `allow` (a real grant) and a gated one ABSTAINS (empty),
126        // deferring to copilot's own per-command prompt — copilot has interactive review, so we don't
127        // hard-deny. Copilot's response is a FLAT object (no `hookSpecificOutput` wrapper).
128        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    // Copilot's config: flat object with `version` and `hooks.preToolUse[]`.
149    // No `matcher` in entries — script self-filters on `toolName`. Field
150    // name is `bash` (the script path), NOT `command`.
151    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    /// Verbatim shape from docs.github.com/en/copilot/reference/
195    /// hooks-configuration. The `toolArgs` field is a JSON-encoded
196    /// STRING, not a nested object — must be parsed twice.
197    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        // Copilot's script-path field is `bash`, not `command`. Wiring
219        // this to `command` would silently mis-configure the hook.
220        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        // The headline quirk: outer JSON, then `toolArgs` is itself a
254        // JSON-encoded string that must be parsed again to recover the
255        // bash command. Tested with the verbatim docs payload.
256        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        // No matcher in Copilot's config — every tool dispatches
264        // through. Self-filter to "bash"; for others, return Err so
265        // the runtime exits silently and Copilot's own perms apply.
266        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        // Copilot uses a FLAT response object — no
290        // hookSpecificOutput wrapper key, unlike Claude/Codex/Qwen/
291        // Droid. Wrapping would be silently rejected.
292        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}