Skip to main content

safe_chains/targets/
grok.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde_json::{Value, json};
5
6use super::{HookFormat, HookInput, HookResponse, InstallOutcome, ParseError, Target};
7use crate::verdict::Verdict;
8
9pub struct GrokTarget;
10
11impl Target for GrokTarget {
12    fn name(&self) -> &'static str {
13        "grok"
14    }
15
16    fn display_name(&self) -> &'static str {
17        "Grok CLI (xAI)"
18    }
19
20    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
21        vec![home.join(".grok")]
22    }
23
24    /// Grok discovers hooks from every `~/.grok/hooks/*.json` (globally trusted, no folder-trust
25    /// needed), so we own a DEDICATED `safe-chains.json` rather than editing a shared file — no risk
26    /// of clobbering the user's other hook files, and idempotency is trivial.
27    fn install(&self, home: &Path) -> Result<InstallOutcome, String> {
28        let dir = home.join(".grok");
29        if !dir.exists() {
30            return Ok(InstallOutcome::Skipped {
31                reason: format!("~/.grok not found at {} (Grok CLI not installed)", dir.display()),
32            });
33        }
34
35        let hooks_dir = dir.join("hooks");
36        let path = hooks_dir.join("safe-chains.json");
37        let binary = "safe-chains hook grok";
38
39        if path.exists()
40            && let Ok(contents) = std::fs::read_to_string(&path)
41            && let Ok(value) = serde_json::from_str::<Value>(&contents)
42            && has_safe_chains_hook(&value)
43        {
44            return Ok(InstallOutcome::AlreadyConfigured { path });
45        }
46
47        std::fs::create_dir_all(&hooks_dir)
48            .map_err(|e| format!("Could not create {}: {e}", hooks_dir.display()))?;
49        let output = serde_json::to_string_pretty(&hook_file(binary)).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(&GrokHookFormat)
57    }
58}
59
60struct GrokHookFormat;
61
62#[derive(Deserialize)]
63#[serde(rename_all = "camelCase")]
64struct GrokToolInput {
65    command: String,
66}
67
68#[derive(Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct GrokHookEnvelope {
71    tool_input: GrokToolInput,
72    #[serde(default)]
73    cwd: Option<String>,
74    #[serde(default)]
75    workspace_root: Option<String>,
76}
77
78impl HookFormat for GrokHookFormat {
79    /// Grok's PreToolUse envelope is camelCase (`toolInput.command`, `workspaceRoot`) — unlike
80    /// Claude/Codex snake_case. Getting the casing wrong parses to nothing and fails OPEN, so it is
81    /// pinned by `parse_input_rejects_snake_case_envelope` below.
82    fn parse_input(&self, stdin: &str) -> Result<HookInput, ParseError> {
83        let envelope: GrokHookEnvelope =
84            serde_json::from_str(stdin).map_err(|e| ParseError { message: e.to_string() })?;
85        Ok(HookInput {
86            command: envelope.tool_input.command,
87            cwd: envelope.cwd,
88            // The project root arrives in the payload as `workspaceRoot`; grok also exports it as
89            // `GROK_WORKSPACE_ROOT` and (for Claude compat) `CLAUDE_PROJECT_DIR`.
90            root: envelope
91                .workspace_root
92                .or_else(|| super::env_root("GROK_WORKSPACE_ROOT"))
93                .or_else(|| super::env_root("CLAUDE_PROJECT_DIR")),
94            // No scratchpad layout researched for this harness yet (see docs/design/agent-scratchpad.md).
95            session_id: None,
96        })
97    }
98
99    fn decision_pointer(&self) -> &'static str {
100        "/decision" // top level, camelCase envelope in
101    }
102
103    fn render_response(&self, verdict: Verdict) -> HookResponse {
104        // A safe command → `allow`. Grok treats a hook `allow` as "declines to deny", NOT a grant:
105        // the command still runs grok's own permission gauntlet and may prompt (so safe-chains cannot
106        // auto-approve on grok — same as Cursor/Codex). Emitting it is honest and harmless, and
107        // becomes a real grant if grok ever promotes `allow`. `decision` is the top-level field grok
108        // reads (NOT Claude's `hookSpecificOutput.permissionDecision`). render_response is only
109        // called for ALLOWED verdicts; the Denied branch is defensive — it must stay empty (never
110        // emit allow) so a stray call can't fail open.
111        if verdict.is_allowed() {
112            HookResponse {
113                stdout: json!({ "decision": "allow" }).to_string(),
114                exit_code: 0,
115            }
116        } else {
117            HookResponse {
118                stdout: String::new(),
119                exit_code: 0,
120            }
121        }
122    }
123
124    /// Grok, like Codex/Cursor, has no hook `grant` and no hook `ask`: a hook can only DENY. A gated
125    /// command must therefore be vetoed — otherwise in `bypassPermissions`/`dontAsk` mode grok would
126    /// run it (the hook's `allow` only "declines to deny"). Deny protects in every mode; the escape
127    /// valve is a `~/.config/safe-chains.toml` grant or a grok `--allow` rule.
128    fn gated_policy(&self) -> super::GatedPolicy {
129        super::GatedPolicy::Deny
130    }
131
132    fn render_deny(&self, reason: &str) -> HookResponse {
133        // Both signals say deny: the top-level `decision` (honored regardless of exit code) and exit
134        // 2 (grok's deny code; any OTHER non-zero fails OPEN, so it must be exactly 2).
135        HookResponse {
136            stdout: json!({ "decision": "deny", "reason": reason }).to_string(),
137            exit_code: 2,
138        }
139    }
140}
141
142fn hook_file(binary: &str) -> Value {
143    json!({
144        "hooks": {
145            "PreToolUse": [{
146                "matcher": "Bash",
147                "hooks": [{
148                    "type": "command",
149                    "command": binary,
150                    "timeout": 10,
151                }]
152            }]
153        }
154    })
155}
156
157fn has_safe_chains_hook(settings: &Value) -> bool {
158    settings
159        .get("hooks")
160        .and_then(|h| h.get("PreToolUse"))
161        .and_then(|arr| arr.as_array())
162        .is_some_and(|entries| {
163            entries.iter().any(|entry| {
164                entry
165                    .get("hooks")
166                    .and_then(|h| h.as_array())
167                    .is_some_and(|hooks| {
168                        hooks.iter().any(|hook| {
169                            hook.get("command")
170                                .and_then(|c| c.as_str())
171                                .is_some_and(|cmd| cmd.contains("safe-chains"))
172                        })
173                    })
174            })
175        })
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::verdict::SafetyLevel;
182
183    fn target() -> GrokTarget {
184        GrokTarget
185    }
186
187    #[test]
188    fn install_no_grok_dir_skips() {
189        let dir = tempfile::tempdir().unwrap();
190        assert!(matches!(target().install(dir.path()).unwrap(), InstallOutcome::Skipped { .. }));
191    }
192
193    #[test]
194    fn install_creates_dedicated_hook_file() {
195        let dir = tempfile::tempdir().unwrap();
196        std::fs::create_dir(dir.path().join(".grok")).unwrap();
197        let outcome = target().install(dir.path()).unwrap();
198        assert!(matches!(outcome, InstallOutcome::Installed { .. }));
199        let path = dir.path().join(".grok/hooks/safe-chains.json");
200        assert!(path.is_file(), "must write ~/.grok/hooks/safe-chains.json");
201        let settings: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
202        assert!(has_safe_chains_hook(&settings));
203        // Nested under a top-level `hooks` object with a `PreToolUse` array (grok/Codex shape), never
204        // a flat top-level `PreToolUse` key.
205        assert!(settings.pointer("/hooks/PreToolUse").and_then(|a| a.as_array()).is_some());
206        assert!(settings.get("PreToolUse").is_none());
207        assert_eq!(settings.pointer("/hooks/PreToolUse/0/matcher").and_then(|m| m.as_str()), Some("Bash"));
208    }
209
210    #[test]
211    fn install_uses_subcommand_invocation() {
212        let dir = tempfile::tempdir().unwrap();
213        std::fs::create_dir(dir.path().join(".grok")).unwrap();
214        target().install(dir.path()).unwrap();
215        let contents = std::fs::read_to_string(dir.path().join(".grok/hooks/safe-chains.json")).unwrap();
216        assert!(contents.contains("safe-chains hook grok"));
217    }
218
219    #[test]
220    fn install_idempotent() {
221        let dir = tempfile::tempdir().unwrap();
222        std::fs::create_dir(dir.path().join(".grok")).unwrap();
223        target().install(dir.path()).unwrap();
224        assert!(matches!(target().install(dir.path()).unwrap(), InstallOutcome::AlreadyConfigured { .. }));
225    }
226
227    // The verbatim PreToolUse envelope from ~/.grok/docs/user-guide/10-hooks.md — camelCase, with the
228    // command nested at toolInput.command and the project root at workspaceRoot.
229    const GROK_DOCS_SAMPLE: &str = r#"{
230        "hookEventName": "pre_tool_use",
231        "sessionId": "abc-123",
232        "cwd": "/Users/me/project/sub",
233        "workspaceRoot": "/Users/me/project",
234        "toolName": "run_terminal_command",
235        "toolInput": {"command": "npm test"},
236        "timestamp": "2026-07-22T00:00:00Z"
237    }"#;
238
239    #[test]
240    fn parse_input_extracts_camelcase_command_and_root() {
241        let parsed = GrokHookFormat.parse_input(GROK_DOCS_SAMPLE).unwrap();
242        assert_eq!(parsed.command, "npm test");
243        assert_eq!(parsed.cwd.as_deref(), Some("/Users/me/project/sub"));
244        assert_eq!(parsed.root.as_deref(), Some("/Users/me/project"));
245    }
246
247    #[test]
248    fn parse_input_rejects_snake_case_envelope() {
249        // The Claude/Codex snake_case shape must NOT parse — if it did, grok's camelCase payload would
250        // silently fail to parse and fail OPEN. This is the casing tripwire.
251        let snake = r#"{"tool_input": {"command": "ls"}, "workspace_root": "/p"}"#;
252        assert!(GrokHookFormat.parse_input(snake).is_err());
253    }
254
255    #[test]
256    fn parse_input_rejects_garbage() {
257        assert!(GrokHookFormat.parse_input("not json").is_err());
258        assert!(GrokHookFormat.parse_input("{}").is_err());
259    }
260
261    #[test]
262    fn grok_is_a_deny_harness() {
263        assert_eq!(GrokHookFormat.gated_policy(), super::super::GatedPolicy::Deny);
264    }
265
266    #[test]
267    fn render_response_uses_top_level_decision_allow() {
268        // Grok reads a TOP-LEVEL `decision`, not Claude's `hookSpecificOutput.permissionDecision` nor
269        // Cursor's `permission`. Wiring this wrong fails open — pinned here.
270        let r = GrokHookFormat.render_response(Verdict::Allowed(SafetyLevel::Inert));
271        let v: Value = serde_json::from_str(&r.stdout).unwrap();
272        assert_eq!(v.get("decision").and_then(|d| d.as_str()), Some("allow"));
273        assert!(v.get("permissionDecision").is_none());
274        assert!(v.get("permission").is_none());
275        assert_eq!(r.exit_code, 0);
276    }
277
278    #[test]
279    fn render_response_denied_is_empty_fail_safe() {
280        // render_response is only called for ALLOWED verdicts; a defensive call with Denied must NOT
281        // emit an allow (else a stray call fails open). Pinned by the cross-target contract test too.
282        let r = GrokHookFormat.render_response(Verdict::Denied);
283        assert_eq!(r.stdout, "");
284    }
285
286    #[test]
287    fn render_deny_uses_decision_deny_and_exit_2() {
288        let r = GrokHookFormat.render_deny("blocked: not on the allowlist");
289        let v: Value = serde_json::from_str(&r.stdout).unwrap();
290        assert_eq!(v.get("decision").and_then(|d| d.as_str()), Some("deny"));
291        assert_eq!(v.get("reason").and_then(|d| d.as_str()), Some("blocked: not on the allowlist"));
292        assert!(v.get("permissionDecision").is_none());
293        // Exit 2 is grok's deny code; any OTHER non-zero fails OPEN, so this must be exactly 2.
294        assert_eq!(r.exit_code, 2);
295    }
296
297    #[test]
298    fn render_context_defaults_to_abstain() {
299        // Grok's PreToolUse output has no additionalContext channel, so context injection keeps the
300        // safe default: emit nothing.
301        let r = GrokHookFormat.render_context("anything");
302        assert_eq!(r.stdout, "");
303        assert_eq!(r.exit_code, 0);
304    }
305}