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!(r#"{{"toolName":"{tool}","toolArgs":"{{\"command\":\"{command}\"}}"}}"#))
28    }
29
30    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
31        // Copilot reads hooks from per-repo `<repo>/.github/hooks/*.json` OR the user-global
32        // `~/.copilot/hooks/` (or `$COPILOT_HOME/hooks/`); upstream's hooks-configuration docs say
33        // both are loaded and all entries run. We install to the user-global dir; a per-repo install
34        // would need a project path. The live drive (v1.0.71) fired the per-repo `.github/hooks/`
35        // config and confirmed `~/.copilot/` is the real config root — the earlier `~/.github/hooks/`
36        // guess was wrong and never fired.
37        vec![home.join(".copilot").join("hooks")]
38    }
39
40    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
41        let exe = std::env::current_exe().ok().and_then(|p| p.canonicalize().ok());
42        self.install_with(home, exe.as_deref())
43    }
44
45    fn hook_format(&self) -> Option<&dyn HookFormat> {
46        Some(&CopilotHookFormat)
47    }
48}
49
50impl CopilotTarget {
51    /// `install`'s body with the hook binary's path injected: the real install passes the
52    /// canonicalized `current_exe` (falling back to the bare invocation when discovery fails),
53    /// while the tests pass a fixed path — under `cargo test` the running binary is
54    /// `safe_chains-<hash>`, which `has_safe_chains_hook` only recognizes when some directory in
55    /// the checkout's path is literally named "safe-chains".
56    fn install_with(&self, home: &Path, exe: Option<&Path>) -> Result<InstallOutcome, String> {
57        let dir = home.join(".copilot").join("hooks");
58        if let Err(e) = std::fs::create_dir_all(&dir) {
59            return Err(format!("Could not create {}: {e}", dir.display()));
60        }
61
62        let path = dir.join("safe-chains.json");
63
64        if path.exists() {
65            let contents = std::fs::read_to_string(&path).map_err(|e| format!("Could not read {}: {e}", path.display()))?;
66            let settings: Value = serde_json::from_str(&contents).map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
67            if has_safe_chains_hook(&settings) {
68                return Ok(InstallOutcome::AlreadyConfigured { path });
69            }
70        }
71
72        let settings = build_settings(exe);
73        let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
74        std::fs::write(&path, format!("{output}\n")).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
75        Ok(InstallOutcome::Installed { path })
76    }
77}
78
79struct CopilotHookFormat;
80
81#[derive(Deserialize)]
82struct CopilotHookEnvelope {
83    #[serde(default)]
84    #[serde(rename = "toolName")]
85    tool_name: Option<String>,
86    #[serde(default)]
87    #[serde(rename = "toolArgs")]
88    tool_args: Option<String>,
89    #[serde(default)]
90    cwd: Option<String>,
91}
92
93#[derive(Deserialize)]
94struct CopilotToolArgs {
95    #[serde(default)]
96    command: Option<String>,
97}
98
99impl HookFormat for CopilotHookFormat {
100    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
101        // Copilot's quirk: toolArgs is a JSON-encoded *string*, not a
102        // nested object. We must parse the outer envelope, then parse
103        // the toolArgs string a second time to recover {command}.
104        let envelope: CopilotHookEnvelope = serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
105
106        // The hook fires for every tool by default — Copilot's config
107        // has no matcher. Self-filter to the bash tool here; for other
108        // tools, return Err so the runtime exits silently and Copilot
109        // falls back to its own permission rules.
110        let is_bash_tool = envelope.tool_name.as_deref().is_some_and(|n| n == "bash");
111        if !is_bash_tool {
112            return Err(ParseError { message: format!("not a bash tool: {:?}", envelope.tool_name.as_deref().unwrap_or("<missing>")) });
113        }
114
115        let raw_args = envelope.tool_args.unwrap_or_default();
116        let inner: CopilotToolArgs =
117            serde_json::from_str(&raw_args).map_err(|e| ParseError { message: format!("toolArgs not a parseable JSON string: {e}") })?;
118        Ok(HookInput {
119            command: inner.command.unwrap_or_default(),
120            cwd: envelope.cwd,
121            root: None, // copilot sends cwd but no documented project root
122            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
123            session_id: None,
124        })
125    }
126
127    fn decision_pointer(&self) -> &'static str {
128        "/permissionDecision" // FLAT, not nested — same leaf name as Claude
129    }
130
131    fn render_response(&self, verdict: Verdict) -> HookResponse {
132        // Copilot honors BOTH `allow` and `deny` — VERIFIED LIVE (v1.0.71): a hook `allow` auto-runs
133        // the command and even suppresses copilot's own directory-access prompt; a hook `deny` blocks
134        // it and surfaces our `permissionDecisionReason`. (The earlier "only deny is processed" note
135        // is stale.) So a safe command gets `allow` (a real grant) and a gated one ABSTAINS (empty),
136        // deferring to copilot's own per-command prompt — copilot has interactive review, so we don't
137        // hard-deny. Copilot's response is a FLAT object (no `hookSpecificOutput` wrapper).
138        if verdict.is_allowed() {
139            let reason = allow_reason(verdict);
140            let body = json!({
141                "permissionDecision": "allow",
142                "permissionDecisionReason": reason,
143            });
144            HookResponse { stdout: serde_json::to_string(&body).unwrap_or_default(), exit_code: 0 }
145        } else {
146            HookResponse { stdout: String::new(), exit_code: 0 }
147        }
148    }
149}
150
151fn build_settings(exe: Option<&Path>) -> Value {
152    // Copilot's config: flat object with `version` and `hooks.preToolUse[]`.
153    // No `matcher` in entries — script self-filters on `toolName`. Field
154    // name is `bash` (the script path), NOT `command`. `None` means the
155    // binary's path is unknown — the bare invocation Copilot resolves itself.
156    let resolved = exe
157        .map(|p| format!("{} hook copilot", p.display()))
158        .unwrap_or_else(|| "safe-chains hook copilot".to_string());
159    json!({
160        "version": 1,
161        "hooks": {
162            "preToolUse": [
163                {
164                    "type": "command",
165                    "bash": resolved,
166                    "comment": "safe-chains: validate every Bash tool call before it runs.",
167                    "timeoutSec": 60,
168                }
169            ]
170        }
171    })
172}
173
174fn has_safe_chains_hook(settings: &Value) -> bool {
175    settings.pointer("/hooks/preToolUse").and_then(|arr| arr.as_array()).is_some_and(|entries| {
176        entries
177            .iter()
178            .any(|entry| entry.get("bash").and_then(|c| c.as_str()).is_some_and(|cmd| cmd.contains("safe-chains")))
179    })
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::verdict::SafetyLevel;
186
187    fn target() -> CopilotTarget {
188        CopilotTarget
189    }
190
191    /// Fixed hook-binary path for the install tests, so they pass wherever the checkout lives:
192    /// under `cargo test` the running binary is `safe_chains-<hash>`, which `has_safe_chains_hook`
193    /// only recognizes when some directory in the checkout's path is literally named
194    /// "safe-chains".
195    const EXE: &str = "/opt/homebrew/bin/safe-chains";
196
197    /// Verbatim shape from docs.github.com/en/copilot/reference/
198    /// hooks-configuration. The `toolArgs` field is a JSON-encoded
199    /// STRING, not a nested object — must be parsed twice.
200    const COPILOT_DOCS_SAMPLE: &str = r#"{
201        "timestamp": 1704614600000,
202        "cwd": "/path/to/project",
203        "toolName": "bash",
204        "toolArgs": "{\"command\":\"ls -la\",\"description\":\"list files\"}"
205    }"#;
206
207    #[test]
208    fn install_creates_hooks_file() {
209        let dir = tempfile::tempdir().unwrap();
210        let outcome = target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
211        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
212        let path = dir.path().join(".copilot/hooks/safe-chains.json");
213        assert!(path.exists());
214        let contents = std::fs::read_to_string(&path).unwrap();
215        let settings: Value = serde_json::from_str(&contents).unwrap();
216        assert!(has_safe_chains_hook(&settings));
217    }
218
219    #[test]
220    fn install_uses_bash_field_not_command() {
221        // Copilot's script-path field is `bash`, not `command`. Wiring
222        // this to `command` would silently mis-configure the hook.
223        let dir = tempfile::tempdir().unwrap();
224        target().install(dir.path()).unwrap();
225        let contents = std::fs::read_to_string(dir.path().join(".copilot/hooks/safe-chains.json")).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(dir.path().join(".copilot/hooks/safe-chains.json")).unwrap();
237        assert!(contents.contains("hook copilot"));
238    }
239
240    #[test]
241    fn install_idempotent() {
242        let dir = tempfile::tempdir().unwrap();
243        target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
244        let outcome = target().install_with(dir.path(), Some(Path::new(EXE))).unwrap();
245        assert!(matches!(outcome, InstallOutcome::AlreadyConfigured { .. }));
246    }
247
248    #[test]
249    fn install_idempotent_outside_a_safe_chains_directory() {
250        // The regression this file's fix is for: detection must survive a binary path in which no
251        // DIRECTORY is named "safe-chains" — the FILE name alone carries it. Under `cargo test`
252        // the running binary is `safe_chains-<hash>` (underscore), so the real install only
253        // round-tripped when the checkout itself sat inside a "safe-chains" directory.
254        let dir = tempfile::tempdir().unwrap();
255        let exe = Path::new("/usr/local/bin/safe-chains");
256        let first = target().install_with(dir.path(), Some(exe)).unwrap();
257        assert!(matches!(first, InstallOutcome::Installed { .. }));
258        let second = target().install_with(dir.path(), Some(exe)).unwrap();
259        assert!(matches!(second, InstallOutcome::AlreadyConfigured { .. }));
260    }
261
262    #[test]
263    fn parse_input_double_decodes_tool_args() {
264        // The headline quirk: outer JSON, then `toolArgs` is itself a
265        // JSON-encoded string that must be parsed again to recover the
266        // bash command. Tested with the verbatim docs payload.
267        let parsed = CopilotHookFormat.parse_input(COPILOT_DOCS_SAMPLE).unwrap();
268        assert_eq!(parsed.command, "ls -la");
269        assert_eq!(parsed.cwd.as_deref(), Some("/path/to/project"));
270    }
271
272    #[test]
273    fn parse_input_skips_non_bash_tools() {
274        // No matcher in Copilot's config — every tool dispatches
275        // through. Self-filter to "bash"; for others, return Err so
276        // the runtime exits silently and Copilot's own perms apply.
277        let stdin = r#"{
278            "timestamp": 1,
279            "cwd": "/p",
280            "toolName": "edit",
281            "toolArgs": "{\"path\":\"x\"}"
282        }"#;
283        assert!(CopilotHookFormat.parse_input(stdin).is_err());
284    }
285
286    #[test]
287    fn parse_input_rejects_garbage() {
288        assert!(CopilotHookFormat.parse_input("not json").is_err());
289    }
290
291    #[test]
292    fn parse_input_rejects_unparseable_tool_args() {
293        let stdin = r#"{"toolName": "bash", "toolArgs": "not-json"}"#;
294        let result = CopilotHookFormat.parse_input(stdin);
295        assert!(result.is_err());
296    }
297
298    #[test]
299    fn render_response_emits_flat_object_no_wrapper() {
300        // Copilot uses a FLAT response object — no
301        // hookSpecificOutput wrapper key, unlike Claude/Codex/Qwen/
302        // Droid. Wrapping would be silently rejected.
303        let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
304        let v: Value = serde_json::from_str(&r.stdout).unwrap();
305        assert_eq!(v.get("permissionDecision").and_then(|s| s.as_str()), Some("allow"),);
306        assert!(v.get("hookSpecificOutput").is_none(), "must NOT wrap in hookSpecificOutput",);
307    }
308
309    #[test]
310    fn render_response_includes_reason() {
311        let r = CopilotHookFormat.render_response(Verdict::Allowed(SafetyLevel::SafeWrite));
312        let v: Value = serde_json::from_str(&r.stdout).unwrap();
313        assert!(v.get("permissionDecisionReason").and_then(|s| s.as_str()).is_some());
314    }
315
316    #[test]
317    fn render_response_deny_emits_empty_body() {
318        let r = CopilotHookFormat.render_response(Verdict::Denied);
319        assert_eq!(r.stdout, "");
320    }
321}