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