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