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!(
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 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 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 #[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 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 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 root: envelope
126 .workspace_root
127 .or_else(|| super::env_root("GROK_WORKSPACE_ROOT"))
128 .or_else(|| super::env_root("CLAUDE_PROJECT_DIR")),
129 session_id: None,
131 })
132 }
133
134 fn decision_pointer(&self) -> &'static str {
135 "/decision" }
137
138 fn render_response(&self, verdict: Verdict) -> HookResponse {
139 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 fn gated_policy(&self) -> super::GatedPolicy {
164 super::GatedPolicy::Deny
165 }
166
167 fn render_deny(&self, reason: &str) -> HookResponse {
168 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 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 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 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 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 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 assert_eq!(r.exit_code, 2);
330 }
331
332 #[test]
333 fn render_context_defaults_to_abstain() {
334 let r = GrokHookFormat.render_context("anything");
337 assert_eq!(r.stdout, "");
338 assert_eq!(r.exit_code, 0);
339 }
340}