safe_chains/targets/
grok.rs1use 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 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 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 root: envelope
91 .workspace_root
92 .or_else(|| super::env_root("GROK_WORKSPACE_ROOT"))
93 .or_else(|| super::env_root("CLAUDE_PROJECT_DIR")),
94 session_id: None,
96 })
97 }
98
99 fn decision_pointer(&self) -> &'static str {
100 "/decision" }
102
103 fn render_response(&self, verdict: Verdict) -> HookResponse {
104 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 fn gated_policy(&self) -> super::GatedPolicy {
129 super::GatedPolicy::Deny
130 }
131
132 fn render_deny(&self, reason: &str) -> HookResponse {
133 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 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 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 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 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 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 assert_eq!(r.exit_code, 2);
295 }
296
297 #[test]
298 fn render_context_defaults_to_abstain() {
299 let r = GrokHookFormat.render_context("anything");
302 assert_eq!(r.stdout, "");
303 assert_eq!(r.exit_code, 0);
304 }
305}