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 shell_tool_name(&self) -> &'static str {
21 GrokHookFormat::SHELL_TOOL
22 }
23
24 #[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 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 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 #[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 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 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 root: envelope
119 .workspace_root
120 .or_else(|| super::env_root("GROK_WORKSPACE_ROOT"))
121 .or_else(|| super::env_root("CLAUDE_PROJECT_DIR")),
122 session_id: None,
124 })
125 }
126
127 fn decision_pointer(&self) -> &'static str {
128 "/decision" }
130
131 fn render_response(&self, verdict: Verdict) -> HookResponse {
132 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 fn gated_policy(&self) -> super::GatedPolicy {
151 super::GatedPolicy::Deny
152 }
153
154 fn render_deny(&self, reason: &str) -> HookResponse {
155 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 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 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 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 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 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 assert_eq!(r.exit_code, 2);
309 }
310
311 #[test]
312 fn render_context_defaults_to_abstain() {
313 let r = GrokHookFormat.render_context("anything");
316 assert_eq!(r.stdout, "");
317 assert_eq!(r.exit_code, 0);
318 }
319}