use std::time::{Duration, Instant}; use serde::Deserialize; use tokio::io::AsyncWriteExt; use crate::config::HookSpec; use crate::event::HookEventEnvelope; use crate::result::HookDecision; use super::{HookRunnerResult, RunContext}; /// Maximum bytes to capture from hook stdout or stderr (64 KB). const MAX_OUTPUT_BYTES: usize = 64 * 1024; /// Exit code that a blocking hook uses to signal an explicit deny. const DENY_EXIT_CODE: i32 = 2; /// The JSON result structure expected from blocking hooks. #[derive(Debug, Deserialize)] struct HookOutput { decision: String, #[serde(default)] reason: Option, } /// Run a single hook command. /// /// Spawns the command as a child process, writes the envelope JSON on stdin, /// reads stdout/stderr with buffer limits, enforces the timeout, and parses /// the result. pub async fn run_command_hook( spec: &HookSpec, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, is_blocking: bool, ) -> (HookRunnerResult, Duration) { let start = Instant::now(); let Some(ref command) = spec.command else { return ( HookRunnerResult::Failed("command hook has no 'command' field".into()), start.elapsed(), ); }; let command_str = command.to_string_lossy(); // Serialize envelope to JSON. let stdin_json = match serde_json::to_string(envelope) { Ok(j) => j, Err(e) => { let elapsed = start.elapsed(); return ( HookRunnerResult::Failed(format!("failed to serialize envelope: {e}")), elapsed, ); } }; // Check opt-in debug logging. let debug_payloads = std::env::var("GROK_HOOK_DEBUG").is_ok_and(|v| v == "1"); if debug_payloads { tracing::trace!( hook_name = %spec.name, stdin_bytes = stdin_json.len(), "hook stdin payload" ); } // Determine how to spawn the command. // // If the command contains shell metacharacters (spaces, pipes, &&, ||, // redirects, semicolons, env-var refs) or starts with `~` (tilde // expansion), run it through `sh -c` so that shell command strings // from compatible configs work correctly. // // Otherwise, treat it as a direct executable path (resolve relative // paths from the hook file's directory). let is_shell_command = command_str.contains(' ') || command_str.contains('|') || command_str.contains('&') || command_str.contains(';') || command_str.contains('>') || command_str.contains('<') || command_str.contains('$') || command_str.starts_with('~'); let mut cmd = if is_shell_command { // Refuse to spawn when the command interpolates an env var that // we can't resolve from any of: the runner's always-set vars, the // per-hook extra_env (plugin vars), or Grok's own process env. The // alternative is letting sh expand the var to empty -- which then // produces a broken command, exits 127, and (for PreToolUse hooks) // fails closed with an opaque "exit code 127" reason. Catching it // here gives the model a clear actionable error and skips the // wasted fork+exec. // // Vars with a parameter-expansion modifier (`${VAR:-default}`, // `${VAR-x}`, `${VAR:=x}`, `${VAR:?msg}`, `${VAR:+x}`, etc.) are // NOT flagged: the user has explicitly handled the unset case. let unresolved = find_unresolved_env_vars(&command_str, &spec.extra_env); if !unresolved.is_empty() { let elapsed = start.elapsed(); let list = unresolved .iter() .map(|v| format!("${{{v}}}")) .collect::>() .join(", "); return ( HookRunnerResult::Failed(format!( "hook not executed: required env var(s) not set: {list}" )), elapsed, ); } #[cfg(unix)] { let mut c = tokio::process::Command::new("sh"); c.arg("-c").arg(command_str.as_ref()); c } #[cfg(not(unix))] { let inv = xai_grok_config::shell::shell_command_argv(&command_str); let mut c = tokio::process::Command::new(&inv.program); c.args(&inv.args).envs(inv.env); c } } else { // Direct executable: resolve relative paths from source_dir. let command_path = if command.is_absolute() { command.clone() } else { spec.source_dir.join(command) }; if !command_path.exists() { let elapsed = start.elapsed(); return ( HookRunnerResult::Failed(format!("command not found: {}", command_path.display())), elapsed, ); } tokio::process::Command::new(command_path) }; // Detach from controlling terminal so child processes (e.g. GPG pinentry) // cannot open /dev/tty and corrupt the TUI display. Delegates to // `xai_grok_tools::util::detach_command`: Unix uses the same setsid / // EPERM→setpgid pre_exec path as before; Windows sets CREATE_NO_WINDOW only // (DETACHED_PROCESS is intentionally omitted — it breaks stdio inheritance). xai_grok_tools::util::detach_command(&mut cmd); // Spawn the child process. // // SECURITY: env-var precedence at spawn time. `Command::envs(&map)` runs // AFTER any preceding `.env(...)` calls and silently overrides them, so // the order matters: we MUST apply user/plugin `extra_env` FIRST and // the runner-injected vars LAST. Otherwise a user JSON hook (or a // plugin) can spoof `GROK_HOOK_EVENT`, `GROK_HOOK_NAME`, `GROK_SESSION_ID`, // `GROK_WORKSPACE_ROOT`, or `CLAUDE_PROJECT_DIR` -- which are the // identity/event signals a hook script consumes for policy and audit. // See the `runner_injected_vars_override_extra_env_at_spawn` // regression test in `tests/integration.rs` and the rustdoc on // `HookSpec::extra_env`. let mut child = match cmd .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .current_dir(ctx.workspace_root) // 1. user/plugin extra_env first (lowest precedence). .envs(&spec.extra_env) // 2. runner-injected vars last (highest precedence -- always win). .env("GROK_HOOK_EVENT", envelope.hook_event_name.to_string()) .env("GROK_HOOK_NAME", &spec.name) .env("GROK_SESSION_ID", ctx.session_id) .env("GROK_WORKSPACE_ROOT", ctx.workspace_root) // Compatibility alias for external hooks that read this env name. // Same value as `GROK_WORKSPACE_ROOT`; native `.grok` hooks should use // `GROK_WORKSPACE_ROOT`. .env("CLAUDE_PROJECT_DIR", ctx.workspace_root) .kill_on_drop(true) .spawn() { Ok(c) => c, Err(e) => { let elapsed = start.elapsed(); return ( HookRunnerResult::Failed(format!("failed to spawn command: {e}")), elapsed, ); } }; // Write stdin and close. if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(stdin_json.as_bytes()).await; drop(stdin); } // Wait with timeout. let timeout = Duration::from_millis(spec.timeout_ms); let result = tokio::time::timeout(timeout, child.wait_with_output()).await; let elapsed = start.elapsed(); match result { Err(_) => { // Timeout — kill_on_drop handles cleanup. ( HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)), elapsed, ) } Ok(Err(e)) => ( HookRunnerResult::Failed(format!("command execution failed: {e}")), elapsed, ), Ok(Ok(output)) => { let exit_code = output.status.code().unwrap_or(-1); // Truncate stdout/stderr to buffer limits. let stdout = truncate_output(&output.stdout); let stderr = truncate_output(&output.stderr); if !stderr.is_empty() { tracing::debug!( hook_name = %spec.name, stderr_bytes = stderr.len(), "hook stderr output captured" ); } if debug_payloads { tracing::trace!( hook_name = %spec.name, stdout_bytes = stdout.len(), "hook stdout payload" ); } tracing::debug!( hook_name = %spec.name, exit_code, stdout_bytes = stdout.len(), stderr_bytes = stderr.len(), elapsed_ms = elapsed.as_millis() as u64, "hook command completed" ); if !is_blocking { if exit_code == 0 { return (HookRunnerResult::Success, elapsed); } return ( HookRunnerResult::Failed(format!("exit code {exit_code}")), elapsed, ); } // Blocking hook: parse decision from stdout. parse_blocking_result(&stdout, exit_code, &spec.name, elapsed) } } } /// Env vars the runner sets unconditionally on every spawned hook. /// /// Used by: /// /// * [`find_unresolved_env_vars`] to avoid flagging vars that *are* set /// by the runner itself, /// * [`crate::config::parse_hook_file`] and the plugin adapter to strip /// user-supplied attempts to override these keys via the JSON `env` /// map (those attempts would be silently ignored by the spawn-time /// precedence ordering anyway, but stripping them at load time gives /// users a clear "ignored, reserved key" warning). pub(crate) const RUNNER_ALWAYS_SET_ENV: &[&str] = &[ "GROK_HOOK_EVENT", "GROK_HOOK_NAME", "GROK_SESSION_ID", "GROK_WORKSPACE_ROOT", "CLAUDE_PROJECT_DIR", ]; /// Parse `command_str` for `${VAR}` and `$VAR` references and return the /// names that aren't resolvable from any of: /// /// * the runner's always-set env vars (see [`RUNNER_ALWAYS_SET_ENV`]), /// * the per-hook `extra_env` map (set by the plugin adapter for plugin /// hooks), /// * the Grok process's own environment (which is inherited by the child), /// * local shell assignments inside the command itself (e.g. an /// `INPUT=$(cat)` earlier in the string defines `INPUT` for the rest of /// the command). /// /// Names that appear inside a parameter-expansion form with a default, /// fallback, or substitution modifier (`${VAR:-x}`, `${VAR-x}`, `${VAR:=x}`, /// `${VAR:?msg}`, `${VAR:+x}`, `${VAR%pat}`, `${VAR#pat}`, `${VAR/pat/repl}`, /// `${VAR:offset}`) are deliberately NOT flagged: the user has explicitly /// handled the unset case in the shell expression, so the runner shouldn't /// second-guess them. /// /// The returned list is sorted and de-duplicated. Names are bare (no `$` or /// `{}`). fn find_unresolved_env_vars( command_str: &str, extra_env: &std::collections::HashMap, ) -> Vec { let locally_assigned = find_local_shell_assignments(command_str); let mut out: Vec = Vec::new(); // Delegate the byte-walking parser to the shared // `iter_env_var_references` helper in `env_expand`. We only need to // consider references where the user did NOT supply a parameter- // expansion modifier (those explicitly handle the unset case). for r in crate::env_expand::iter_env_var_references(command_str) { if r.name.is_empty() || r.has_modifier { continue; } if RUNNER_ALWAYS_SET_ENV.contains(&r.name) { continue; } if extra_env.contains_key(r.name) { continue; } if std::env::var_os(r.name).is_some() { continue; } if locally_assigned.contains(r.name) { continue; } out.push(r.name.to_string()); } out.sort(); out.dedup(); out } /// Find shell variable assignments within `command_str` so that subsequent /// `${VAR}` references to those names aren't flagged as undefined. /// /// Detects two patterns common in inline hook commands: /// /// * Plain assignments at the start of a command position: `VAR=value`, /// `VAR=$(cmd)`, `VAR="..."`. The identifier must follow either the /// start of the string, whitespace, or a statement separator (`;`, `&`, /// `|`, `\n`). /// * `read VAR1 VAR2 ...` statements (very common pattern for consuming /// stdin in hooks). /// /// This is a deliberately small heuristic, not a full shell parser. It /// errs on the side of treating an identifier as locally set; the /// consequence of a false negative here is a false positive in /// [`find_unresolved_env_vars`] (which is precisely what we're trying to /// avoid). Callers who need to be sure can always use the parameter- /// expansion default form (`${VAR:-}`). fn find_local_shell_assignments(command_str: &str) -> std::collections::HashSet { let mut names = std::collections::HashSet::new(); let bytes = command_str.as_bytes(); let mut i = 0; let is_statement_start = |idx: usize| -> bool { if idx == 0 { return true; } // Walk left over whitespace. let mut j = idx; while j > 0 { let c = bytes[j - 1]; if c == b' ' || c == b'\t' { j -= 1; continue; } return matches!(c, b';' | b'&' | b'|' | b'\n' | b'(' | b'{'); } true }; while i < bytes.len() { let c = bytes[i]; if !(c.is_ascii_alphabetic() || c == b'_') { i += 1; continue; } // Read the identifier. let start = i; while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } let ident = std::str::from_utf8(&bytes[start..i]).unwrap_or(""); if ident.is_empty() { continue; } // VAR= (assignment): identifier must be at a statement-start // boundary and immediately followed by '=' (no space). if i < bytes.len() && bytes[i] == b'=' && is_statement_start(start) { names.insert(ident.to_string()); continue; } // `read VAR1 VAR2 ...`: collect every whitespace-separated bare // identifier following a `read` keyword on the same statement. if ident == "read" && is_statement_start(start) { // Skip whitespace, then read identifiers until we hit a // statement separator or end of string. while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; } while i < bytes.len() { let c2 = bytes[i]; if matches!(c2, b';' | b'&' | b'|' | b'\n' | b'<' | b'>') { break; } if c2 == b' ' || c2 == b'\t' { i += 1; continue; } if c2 == b'-' { // `read -r VAR` etc.: skip the option flag. while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'\t' { i += 1; } continue; } if !(c2.is_ascii_alphabetic() || c2 == b'_') { break; } let s = i; while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } let read_ident = std::str::from_utf8(&bytes[s..i]).unwrap_or(""); if !read_ident.is_empty() { names.insert(read_ident.to_string()); } } } } names } /// Parse the result of a blocking hook from stdout and exit code. fn parse_blocking_result( stdout: &str, exit_code: i32, hook_name: &str, elapsed: Duration, ) -> (HookRunnerResult, Duration) { // Try to parse JSON output first. let json_decision = if !stdout.trim().is_empty() { serde_json::from_str::(stdout.trim()).ok() } else { None }; // If we have valid JSON with a deny, prefer that over exit code. if let Some(ref output) = json_decision { if output.decision == "deny" { let reason = output .reason .clone() .unwrap_or_else(|| format!("denied by hook '{hook_name}'")); if exit_code != DENY_EXIT_CODE && exit_code != 0 { tracing::warn!( hook_name, exit_code, "JSON decision is 'deny' but exit code is not 0 or 2 — using JSON decision" ); } return ( HookRunnerResult::Decision(HookDecision::Deny { reason, hook_name: hook_name.to_string(), }), elapsed, ); } if output.decision == "allow" { if exit_code == DENY_EXIT_CODE { tracing::warn!( hook_name, "JSON decision is 'allow' but exit code is 2 — using JSON decision" ); } return (HookRunnerResult::Decision(HookDecision::Allow), elapsed); } // Unknown decision value — treat as failure. return ( HookRunnerResult::Failed(format!( "unknown decision value '{}' from hook '{hook_name}'", output.decision )), elapsed, ); } // No valid JSON — fall back to exit code. match exit_code { 0 => (HookRunnerResult::Decision(HookDecision::Allow), elapsed), DENY_EXIT_CODE => ( HookRunnerResult::Decision(HookDecision::Deny { reason: format!("denied by hook '{hook_name}' (exit code {DENY_EXIT_CODE})"), hook_name: hook_name.to_string(), }), elapsed, ), _ => ( HookRunnerResult::Failed(format!( "hook '{hook_name}' failed with exit code {exit_code}" )), elapsed, ), } } /// Truncate output bytes to MAX_OUTPUT_BYTES and convert to a lossy UTF-8 string. fn truncate_output(bytes: &[u8]) -> String { if bytes.len() <= MAX_OUTPUT_BYTES { String::from_utf8_lossy(bytes).into_owned() } else { let mut truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_BYTES]).into_owned(); truncated.push_str(" [truncated]"); tracing::warn!( total_bytes = bytes.len(), max_bytes = MAX_OUTPUT_BYTES, "hook output truncated" ); truncated } } /// Resolve the absolute command path for a hook spec. /// /// Returns `None` for non-command handler types. pub fn resolve_command_path(spec: &HookSpec) -> Option { let command = spec.command.as_ref()?; if command.is_absolute() { Some(command.clone()) } else { Some(spec.source_dir.join(command)) } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_allow_json() { let (result, _) = parse_blocking_result(r#"{"decision":"allow"}"#, 0, "test", Duration::ZERO); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn parse_deny_json() { let (result, _) = parse_blocking_result( r#"{"decision":"deny","reason":"bad command"}"#, 2, "test", Duration::ZERO, ); match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert_eq!(reason, "bad command"); } other => panic!("expected Deny, got {other:?}"), } } #[test] fn parse_deny_json_without_reason() { let (result, _) = parse_blocking_result(r#"{"decision":"deny"}"#, 2, "my-hook", Duration::ZERO); match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { assert!(reason.contains("my-hook")); } other => panic!("expected Deny, got {other:?}"), } } #[test] fn fallback_to_exit_code_zero() { let (result, _) = parse_blocking_result("", 0, "test", Duration::ZERO); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn fallback_to_exit_code_deny() { let (result, _) = parse_blocking_result("", 2, "test", Duration::ZERO); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Deny { .. }) )); } #[test] fn fallback_to_exit_code_failure() { let (result, _) = parse_blocking_result("", 1, "test", Duration::ZERO); assert!(matches!(result, HookRunnerResult::Failed(_))); } #[test] fn json_deny_overrides_exit_code_zero() { // JSON says deny, exit code says success — prefer JSON deny. let (result, _) = parse_blocking_result( r#"{"decision":"deny","reason":"nope"}"#, 0, "test", Duration::ZERO, ); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Deny { .. }) )); } #[test] fn invalid_json_falls_back_to_exit_code() { let (result, _) = parse_blocking_result("not json at all", 0, "test", Duration::ZERO); assert!(matches!( result, HookRunnerResult::Decision(HookDecision::Allow) )); } #[test] fn unknown_decision_value() { let (result, _) = parse_blocking_result(r#"{"decision":"maybe"}"#, 0, "test", Duration::ZERO); assert!(matches!(result, HookRunnerResult::Failed(_))); } #[test] fn truncate_small_output() { let small = "hello world".as_bytes(); let result = truncate_output(small); assert_eq!(result, "hello world"); } #[test] fn truncate_large_output() { let large = vec![b'x'; MAX_OUTPUT_BYTES + 1000]; let result = truncate_output(&large); assert!(result.ends_with(" [truncated]")); assert!(result.len() > MAX_OUTPUT_BYTES); // marker appended } #[test] fn resolve_absolute_path() { let spec = HookSpec { name: "test".into(), event: crate::event::HookEventName::PreToolUse, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, command: Some(std::path::PathBuf::from("/usr/bin/hook")), command_raw: Some("/usr/bin/hook".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::path::PathBuf::from("/some/dir"), extra_env: std::collections::HashMap::new(), }; assert_eq!( resolve_command_path(&spec), Some(std::path::PathBuf::from("/usr/bin/hook")) ); } #[test] fn resolve_relative_path() { let spec = HookSpec { name: "test".into(), event: crate::event::HookEventName::PreToolUse, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, command: Some(std::path::PathBuf::from("bin/check.sh")), command_raw: Some("bin/check.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::path::PathBuf::from("/project/.grok/hooks"), extra_env: std::collections::HashMap::new(), }; assert_eq!( resolve_command_path(&spec), Some(std::path::PathBuf::from( "/project/.grok/hooks/bin/check.sh" )) ); } #[test] fn resolve_no_command_for_http() { let spec = HookSpec { name: "test".into(), event: crate::event::HookEventName::PreToolUse, handler_type: "http".into(), configured_matcher: None, matcher: None, enabled: true, command: None, command_raw: None, url: Some("https://hooks.example.com/check".into()), url_raw: Some("https://hooks.example.com/check".into()), timeout_ms: 5000, source_dir: std::path::PathBuf::from("/project"), extra_env: std::collections::HashMap::new(), }; assert_eq!(resolve_command_path(&spec), None); } /// Helper to build a HookSpec that runs a shell command. fn make_shell_spec(command: &str) -> HookSpec { HookSpec { name: "test-hook".into(), event: crate::event::HookEventName::Stop, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, command: Some(command.into()), command_raw: Some(command.to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::env::temp_dir(), extra_env: std::collections::HashMap::new(), } } fn make_envelope() -> HookEventEnvelope { use crate::event::HookPayload; HookEventEnvelope { hook_event_name: crate::event::HookEventName::Stop, session_id: "test-session".into(), cwd: "/tmp".into(), workspace_root: "/tmp".into(), timestamp: "2026-01-01T00:00:00Z".into(), transcript_path: None, client_identifier: None, prompt_id: None, payload: HookPayload::Stop { reason: "test".into(), }, } } fn make_ctx() -> RunContext<'static> { RunContext { session_id: "test-session", workspace_root: "/tmp", } } /// Verify that setsid() prevents hook child processes from opening /// `/dev/tty`. This is the core fix for GPG pinentry corruption. /// /// The hook tries `exec 3>/dev/tty` — if detached, this fails and the /// shell exits 1 (caught by `||`), making the overall command exit 0. /// If NOT detached, the open succeeds and the command exits 1. #[tokio::test] #[cfg(unix)] async fn test_hook_child_cannot_open_dev_tty() { // Skip in CI / environments without a controlling terminal — // setsid() gets EPERM when already a session leader and the // setpgid fallback doesn't detach /dev/tty. if std::fs::OpenOptions::new() .write(true) .open("/dev/tty") .is_err() { eprintln!("skipping: no controlling terminal"); return; } // exit 0 if /dev/tty is inaccessible (DETACHED), exit 1 if accessible let spec = make_shell_spec("exec 3>/dev/tty 2>/dev/null && exit 1 || exit 0"); let envelope = make_envelope(); let ctx = make_ctx(); let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, false).await; assert!( matches!(result, HookRunnerResult::Success), "hook child should not be able to open /dev/tty after setsid(), got {:?}", result ); } /// Regression: hook commands still execute successfully. #[tokio::test] async fn test_hook_basic_execution() { let spec = make_shell_spec("exit 0"); let envelope = make_envelope(); let ctx = make_ctx(); let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, false).await; assert!( matches!(result, HookRunnerResult::Success), "hook should succeed, got {:?}", result ); } /// Regression: blocking hooks still parse JSON decisions correctly. #[tokio::test] async fn test_hook_blocking_allow() { let spec = make_shell_spec(r#"echo '{"decision":"allow"}'"#); let envelope = make_envelope(); let ctx = make_ctx(); let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, true).await; assert!( matches!(result, HookRunnerResult::Decision(HookDecision::Allow)), "blocking hook should return Allow, got {:?}", result ); } #[test] fn shell_command_detection() { // Commands with shell metacharacters should be detected. assert!("echo hello".contains(' ')); // space assert!("a || b".contains('|')); // pipe/or assert!("a && b".contains('&')); // and assert!("a; b".contains(';')); // semicolon assert!("a > out".contains('>')); // redirect // Env-var interpolation must also force the sh -c branch so that // commands like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` get expanded // by the shell rather than treated as a literal executable path. assert!("${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh".contains('$')); assert!("$HOME/bin/hook".contains('$')); // Tilde at the start must also force the sh -c branch so that // `~/.claude/hook.sh` gets expanded to the user's home directory // rather than being joined to source_dir as a literal path. assert!("~/.claude/hook.sh".starts_with('~')); assert!("~/bin/hook".starts_with('~')); // Simple executable paths should not be detected. assert!(!"bin/check.sh".contains(' ')); assert!(!"/usr/bin/hook".contains(' ')); assert!(!"bin/check.sh".contains('$')); assert!(!"bin/check.sh".starts_with('~')); } /// Regression: a hook command that uses `${VAR}` interpolation /// without any other shell metacharacters must still be invoked via /// `sh -c` so that the env var supplied via `extra_env` is expanded. /// Previously the runner treated `${...}` as part of a literal path /// and `command_path.exists()` failed; the hook silently never ran. /// Now the env-var pre-spawn check refuses with a clear reason when /// the var is unset (and the dispatcher fail-opens, so the tool call /// itself is not blocked). #[tokio::test] async fn test_env_var_interpolation_runs_via_shell() { let tmp = tempfile::tempdir().unwrap(); let script = tmp.path().join("hook.sh"); std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).unwrap(); } let mut extra_env = std::collections::HashMap::new(); extra_env.insert( "GB1183_PLUGIN_ROOT".to_string(), tmp.path().to_string_lossy().into_owned(), ); let spec = HookSpec { name: "test-env-interp".into(), event: crate::event::HookEventName::Stop, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, command: Some(std::path::PathBuf::from("${GB1183_PLUGIN_ROOT}/hook.sh")), command_raw: Some("${GB1183_PLUGIN_ROOT}/hook.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: tmp.path().to_path_buf(), extra_env, }; let envelope = make_envelope(); let ctx = make_ctx(); let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; assert!( matches!(result, HookRunnerResult::Success), "hook with ${{VAR}} interpolation should be expanded via sh -c, got {:?}", result ); } /// `CLAUDE_PROJECT_DIR` is part of the external hook contract -- it points /// to the workspace/project root and is set for ALL hooks (not just /// plugin-scoped ones). Plugin hooks frequently reference it as /// `"$CLAUDE_PROJECT_DIR/.claude/hooks/foo.sh"`. The runner must export /// it on the spawned child so shell expansion via the `sh -c` branch /// resolves correctly; otherwise such hooks fail to find the /// command. #[tokio::test] async fn test_claude_project_dir_is_exported() { let tmp = tempfile::tempdir().unwrap(); let script = tmp.path().join("hook.sh"); // Exit 0 only if CLAUDE_PROJECT_DIR matches the workspace root. let workspace = tmp.path().to_string_lossy().into_owned(); std::fs::write( &script, format!( "#!/bin/sh\ntest \"${{CLAUDE_PROJECT_DIR}}\" = \"{workspace}\"\n", workspace = workspace ), ) .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).unwrap(); } let spec = HookSpec { name: "test-claude-project-dir".into(), event: crate::event::HookEventName::Stop, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, // Use ${CLAUDE_PROJECT_DIR} in the path itself so this also exercises // the `$` -> sh -c routing. command: Some(std::path::PathBuf::from("${CLAUDE_PROJECT_DIR}/hook.sh")), command_raw: Some("${CLAUDE_PROJECT_DIR}/hook.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: tmp.path().to_path_buf(), extra_env: std::collections::HashMap::new(), }; let envelope = make_envelope(); let ctx = RunContext { session_id: "test-session", workspace_root: &workspace, }; let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; assert!( matches!(result, HookRunnerResult::Success), "hook should see CLAUDE_PROJECT_DIR set to the workspace root, got {:?}", result ); } /// Unit tests for the `find_unresolved_env_vars` parser. We seed /// `extra_env` to control what's "set" without depending on the test /// process's real environment (other than excluding common vars). #[test] fn find_unresolved_braced_form() { let mut env = std::collections::HashMap::new(); env.insert("KNOWN".to_string(), "x".to_string()); let v = find_unresolved_env_vars("${KNOWN}/${SOME_GB1183_UNSET_VAR}/foo", &env); assert_eq!(v, vec!["SOME_GB1183_UNSET_VAR".to_string()]); } #[test] fn find_unresolved_bare_form() { let env = std::collections::HashMap::new(); let v = find_unresolved_env_vars("$SOME_GB1183_BARE_UNSET/foo", &env); assert_eq!(v, vec!["SOME_GB1183_BARE_UNSET".to_string()]); } #[test] fn find_unresolved_skips_runner_vars() { let env = std::collections::HashMap::new(); let v = find_unresolved_env_vars( "${GROK_HOOK_EVENT}/${CLAUDE_PROJECT_DIR}/${GROK_SESSION_ID}/foo", &env, ); assert!( v.is_empty(), "runner-set vars should never be flagged, got {v:?}" ); } #[test] fn find_unresolved_skips_extra_env() { let mut env = std::collections::HashMap::new(); env.insert("CLAUDE_PLUGIN_ROOT".to_string(), "/plugins/foo".to_string()); let v = find_unresolved_env_vars("${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh", &env); assert!( v.is_empty(), "vars present in extra_env should not be flagged, got {v:?}" ); } #[test] fn find_unresolved_dedups() { let env = std::collections::HashMap::new(); let v = find_unresolved_env_vars( "${MISSING_GB1183_DUP} && ${MISSING_GB1183_DUP}/foo $MISSING_GB1183_DUP", &env, ); assert_eq!(v, vec!["MISSING_GB1183_DUP".to_string()]); } #[test] fn find_unresolved_skips_non_var_dollars() { let env = std::collections::HashMap::new(); // $1 (positional), $$ (pid), $(...) (cmd subst), $? (exit code), $#. // None of these are env vars; none should be flagged. let v = find_unresolved_env_vars("echo $1 $$ $? $# $(date)", &env); assert!( v.is_empty(), "shell special params should not be flagged, got {v:?}" ); } #[test] fn find_unresolved_skips_locally_assigned_vars() { let env = std::collections::HashMap::new(); // INPUT is set locally inside the same command. The check must // recognize that and not flag the subsequent ${INPUT} references. let cmd = r#"INPUT=$(cat); echo "$INPUT" | grep -q foo"#; let v = find_unresolved_env_vars(cmd, &env); assert!( v.is_empty(), "locally-assigned vars must not be flagged, got {v:?}" ); } #[test] fn find_unresolved_skips_read_assigned_vars() { let env = std::collections::HashMap::new(); let cmd = "read -r LINE; echo $LINE"; let v = find_unresolved_env_vars(cmd, &env); assert!( v.is_empty(), "vars assigned via `read` must not be flagged, got {v:?}" ); } #[test] fn find_unresolved_skips_assignments_after_separators() { let env = std::collections::HashMap::new(); let cmd = "echo first; X=hello && echo $X | cat"; let v = find_unresolved_env_vars(cmd, &env); assert!( v.is_empty(), "assignment after `;` should be recognized, got {v:?}" ); } #[test] fn find_unresolved_skips_parameter_expansion_modifiers() { let env = std::collections::HashMap::new(); // All of these explicitly handle the unset case; the runner must // not flag them, otherwise we reject hooks that the user wrote // correctly. let cases = [ "${MISSING_GB1183_MOD:-/default/path.sh}", "${MISSING_GB1183_MOD-/default/path.sh}", "${MISSING_GB1183_MOD:=/assigned/path.sh}", "${MISSING_GB1183_MOD:?msg here}", "${MISSING_GB1183_MOD:+/used/if/set.sh}", "${MISSING_GB1183_MOD%.sh}", "${MISSING_GB1183_MOD#prefix/}", "${MISSING_GB1183_MOD/foo/bar}", "${MISSING_GB1183_MOD:0:5}", ]; for case in cases { let v = find_unresolved_env_vars(case, &env); assert!( v.is_empty(), "parameter-expansion form `{case}` should not be flagged, got {v:?}" ); } } /// Regression follow-up: when a hook command references /// an env var that isn't set anywhere we know about, the runner must /// refuse to spawn entirely (no fork+exec, no opaque "exit code 127") /// and surface a clear failure reason naming the missing var(s). #[tokio::test] async fn test_undefined_env_var_refuses_to_spawn() { let mut extra_env = std::collections::HashMap::new(); // Intentionally do NOT set NEVER_SET_GB1183 anywhere. extra_env.insert("UNRELATED_GB1183".to_string(), "/tmp".to_string()); let spec = HookSpec { name: "test-undef".into(), event: crate::event::HookEventName::Stop, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, command: Some(std::path::PathBuf::from( "${NEVER_SET_GB1183}/does/not/exist.sh", )), command_raw: Some("${NEVER_SET_GB1183}/does/not/exist.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::env::temp_dir(), extra_env, }; let envelope = make_envelope(); let ctx = make_ctx(); let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; match result { HookRunnerResult::Failed(reason) => { assert!( reason.contains("NEVER_SET_GB1183"), "failure reason should name the undefined env var, got: {reason}" ); assert!( reason.contains("hook not executed"), "failure reason should make clear the hook did not run, got: {reason}" ); assert!( !reason.contains("exit code"), "failure reason should not reference an exit code (we never spawned), got: {reason}" ); } other => panic!("expected Failed, got {other:?}"), } } /// Regression: a hook command starting with `~` must be /// routed through `sh -c` so the shell expands `~` to `$HOME`. /// Previously `~/.claude/hook.sh` was treated as a relative path and /// joined to `source_dir`, producing a broken path. /// /// The test injects `HOME` via `extra_env` so it works in sandboxed /// CI environments where `HOME` is not set (e.g. hermetic remote exec). #[tokio::test] #[cfg(unix)] async fn test_tilde_expansion_runs_via_shell() { let tmp = tempfile::tempdir().unwrap(); // Create the script at /.grok-test-hooks-gb856/tilde-test.sh let hook_dir = tmp.path().join(".grok-test-hooks-gb856"); std::fs::create_dir_all(&hook_dir).unwrap(); let script = hook_dir.join("tilde-test.sh"); std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).unwrap(); } // Inject HOME via extra_env so `sh -c "~/.grok-test-hooks-gb856/..."` // expands `~` to the temp dir. This avoids depending on the system // HOME, which is absent in hermetic sandboxed test runners. let mut extra_env = std::collections::HashMap::new(); extra_env.insert( "HOME".to_string(), tmp.path().to_string_lossy().into_owned(), ); let spec = HookSpec { name: "test-tilde".into(), event: crate::event::HookEventName::Stop, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, command: Some(std::path::PathBuf::from( "~/.grok-test-hooks-gb856/tilde-test.sh", )), command_raw: Some("~/.grok-test-hooks-gb856/tilde-test.sh".to_string()), url: None, url_raw: None, timeout_ms: 5000, source_dir: std::env::temp_dir(), extra_env, }; let envelope = make_envelope(); let ctx = make_ctx(); // Freshly writing the script and exec'ing it via `sh -c` can transiently // fail with ETXTBSY ("Text file busy" -> exit 126) when a sibling test in // this multi-threaded binary forks while our write fd is still open and // its child inherits it. Retry ONLY that exact transient; a real tilde- // routing break surfaces as a different result (127/spawn error), so the // assertion below keeps its diagnostic power. let mut result = run_command_hook(&spec, &envelope, &ctx, false).await.0; for _ in 0..8 { if !matches!(&result, HookRunnerResult::Failed(msg) if msg == "exit code 126") { break; } result = run_command_hook(&spec, &envelope, &ctx, false).await.0; } assert!( matches!(result, HookRunnerResult::Success), "hook with ~/... path should be expanded via sh -c, got {:?}", result ); } /// Hooks that explicitly handle the unset case via parameter expansion /// (e.g. `${VAR:-/some/default}`) must NOT be refused -- the user has /// expressed intent for what should happen when the var is unset. #[tokio::test] async fn test_parameter_expansion_default_is_not_refused() { let tmp = tempfile::tempdir().unwrap(); let script = tmp.path().join("default.sh"); std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&script).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).unwrap(); } let spec = HookSpec { name: "test-default".into(), event: crate::event::HookEventName::Stop, handler_type: "command".into(), configured_matcher: None, matcher: None, enabled: true, // `MISSING_GB1183_DEFAULT` is intentionally unset; the `:-` // modifier supplies a fallback that points at the real script. command: Some(std::path::PathBuf::from(format!( "${{MISSING_GB1183_DEFAULT:-{}}}", script.display() ))), command_raw: Some(format!("${{MISSING_GB1183_DEFAULT:-{}}}", script.display())), url: None, url_raw: None, timeout_ms: 5000, source_dir: tmp.path().to_path_buf(), extra_env: std::collections::HashMap::new(), }; let envelope = make_envelope(); let ctx = make_ctx(); let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await; assert!( matches!(result, HookRunnerResult::Success), "hook with parameter-expansion default must run, got {:?}", result ); } }