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's canonical hook location is per-repo
22        // (`<repo>/.github/hooks/*.json`), but the docs also document a
23        // user-global path at `~/.github/hooks/`. Probe the user-global
24        // dir for detection; per-repo install would need a project path.
25        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        // Copilot's quirk: toolArgs is a JSON-encoded *string*, not a
81        // nested object. We must parse the outer envelope, then parse
82        // the toolArgs string a second time to recover {command}.
83        let envelope: CopilotHookEnvelope =
84            serde_json::from_str(stdin).map_err(|e| ParseError {
85                message: e.to_string(),
86            })?;
87
88        // The hook fires for every tool by default — Copilot's config
89        // has no matcher. Self-filter to the bash tool here; for other
90        // tools, return Err so the runtime exits silently and Copilot
91        // falls back to its own permission rules.
92        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        // Copilot's effective decision space is *only* "deny" right
118        // now (per docs: "only `deny` is currently processed"). For
119        // allowed commands, the right answer is "no opinion" — empty
120        // body, exit 0 — letting Copilot's own permission system fall
121        // through to its allow-by-default for safe tools.
122        //
123        // We DO emit an allow-shaped envelope anyway so future Copilot
124        // releases that honor allow/ask see our reasoning. Today it's
125        // a no-op; tomorrow it's free upgrade.
126        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    // Copilot's config: flat object with `version` and `hooks.preToolUse[]`.
147    // No `matcher` in entries — script self-filters on `toolName`. Field
148    // name is `bash` (the script path), NOT `command`.
149    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    /// Verbatim shape from docs.github.com/en/copilot/reference/
193    /// hooks-configuration. The `toolArgs` field is a JSON-encoded
194    /// STRING, not a nested object — must be parsed twice.
195    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        // Copilot's script-path field is `bash`, not `command`. Wiring
217        // this to `command` would silently mis-configure the hook.
218        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        // The headline quirk: outer JSON, then `toolArgs` is itself a
252        // JSON-encoded string that must be parsed again to recover the
253        // bash command. Tested with the verbatim docs payload.
254        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        // No matcher in Copilot's config — every tool dispatches
262        // through. Self-filter to "bash"; for others, return Err so
263        // the runtime exits silently and Copilot's own perms apply.
264        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        // Copilot uses a FLAT response object — no
288        // hookSpecificOutput wrapper key, unlike Claude/Codex/Qwen/
289        // Droid. Wrapping would be silently rejected.
290        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}