905 lines
30 KiB
Rust
905 lines
30 KiB
Rust
//! Integration tests for xai-grok-hooks.
|
|
//!
|
|
//! These tests use inline shell command strings (routed via `sh -c`) to verify
|
|
//! the full dispatch pipeline: discovery → matching → execution → result.
|
|
//!
|
|
//! All hook commands are inline strings (containing spaces/pipes/semicolons)
|
|
//! rather than standalone executable script files. This avoids `noexec` tmpdir
|
|
//! issues in hermetic CI sandboxes where `chmod +x` on temp files may not work.
|
|
|
|
use std::path::Path;
|
|
|
|
use xai_grok_hooks::discovery::load_hooks;
|
|
use xai_grok_hooks::dispatcher;
|
|
use xai_grok_hooks::event::*;
|
|
use xai_grok_hooks::result::HookDecision;
|
|
use xai_grok_hooks::runner::RunContext;
|
|
|
|
/// Helper: write a JSON hook file.
|
|
fn write_hook(dir: &Path, filename: &str, content: &str) {
|
|
std::fs::write(dir.join(filename), content).unwrap();
|
|
}
|
|
|
|
/// Helper: create a pre_tool_use envelope.
|
|
fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope {
|
|
HookEventEnvelope {
|
|
hook_event_name: HookEventName::PreToolUse,
|
|
session_id: "test-session".into(),
|
|
cwd: "/tmp".into(),
|
|
workspace_root: "/tmp".into(),
|
|
timestamp: "2025-01-01T00:00:00Z".into(),
|
|
transcript_path: None,
|
|
client_identifier: None,
|
|
prompt_id: None,
|
|
payload: HookPayload::PreToolUse {
|
|
tool_name: tool_name.into(),
|
|
tool_use_id: "call-1".into(),
|
|
tool_input: serde_json::json!({"command": "echo hello"}),
|
|
tool_input_truncated: false,
|
|
permission_mode: None,
|
|
subagent_type: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Helper: create a session_start envelope.
|
|
fn session_start_envelope() -> HookEventEnvelope {
|
|
HookEventEnvelope {
|
|
hook_event_name: HookEventName::SessionStart,
|
|
session_id: "test-session".into(),
|
|
cwd: "/tmp".into(),
|
|
workspace_root: "/tmp".into(),
|
|
timestamp: "2025-01-01T00:00:00Z".into(),
|
|
transcript_path: None,
|
|
client_identifier: None,
|
|
prompt_id: None,
|
|
payload: HookPayload::SessionStart {
|
|
source: "new".into(),
|
|
model_id: None,
|
|
agent_type: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_allows_via_json() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
write_hook(
|
|
dir.path(),
|
|
"safety.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo '{\"decision\":\"allow\"}'","timeout":3}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty(), "errors: {errors:?}");
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let result = dispatcher::dispatch_pre_tool_use(
|
|
®istry,
|
|
&pre_tool_use_envelope("run_terminal_cmd"),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_denies_via_json() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Inline command: echo deny JSON and exit 2.
|
|
write_hook(
|
|
dir.path(),
|
|
"safety.json",
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"run_terminal_cmd","hooks":[{"type":"command","command":"echo '{\"decision\":\"deny\",\"reason\":\"dangerous\"}'; exit 2","timeout":3}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let result = dispatcher::dispatch_pre_tool_use(
|
|
®istry,
|
|
&pre_tool_use_envelope("run_terminal_cmd"),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
match result.decision {
|
|
HookDecision::Deny { reason, .. } => {
|
|
assert_eq!(reason, "dangerous");
|
|
}
|
|
other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_deny_via_exit_code_only() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Inline command: no JSON output, just exit code 2.
|
|
write_hook(
|
|
dir.path(),
|
|
"safety.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"exit 2"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
match pre_result.decision {
|
|
HookDecision::Deny { reason, .. } => {
|
|
assert!(reason.contains("exit code 2") || reason.contains("denied by hook"));
|
|
}
|
|
other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_fail_open_on_crash() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Inline command: exit code 1 = hook failure. Under the fail-open
|
|
// policy the tool call must NOT be blocked; the failure is recorded
|
|
// for the UI but only an explicit `deny` decision blocks.
|
|
write_hook(
|
|
dir.path(),
|
|
"safety.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"exit 1"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(
|
|
pre_result.decision,
|
|
HookDecision::Allow,
|
|
"fail-open: a crashing hook must not block the tool call"
|
|
);
|
|
assert_eq!(
|
|
pre_result.results.len(),
|
|
1,
|
|
"the failure must still appear in run_results for UI scrollback"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_fail_open_on_timeout() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Inline command: sleep longer than the timeout. Under fail-open
|
|
// the timeout must not block the tool call.
|
|
write_hook(
|
|
dir.path(),
|
|
"safety.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"sleep 10","timeout":1}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(
|
|
pre_result.decision,
|
|
HookDecision::Allow,
|
|
"fail-open: a timing-out hook must not block the tool call"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn matcher_filters_tool_name() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
write_hook(
|
|
dir.path(),
|
|
"safety.json",
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"run_terminal_cmd","hooks":[{"type":"command","command":"echo '{\"decision\":\"deny\",\"reason\":\"blocked\"}'; exit 2"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
// Matching tool name → denied.
|
|
let pre_result = dispatcher::dispatch_pre_tool_use(
|
|
®istry,
|
|
&pre_tool_use_envelope("run_terminal_cmd"),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
assert!(matches!(pre_result.decision, HookDecision::Deny { .. }));
|
|
|
|
// Non-matching tool name → allowed (hook doesn't run).
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(pre_result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_blocking_dispatch() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
write_hook(
|
|
dir.path(),
|
|
"lifecycle.json",
|
|
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo session started"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let results = dispatcher::dispatch_non_blocking(
|
|
®istry,
|
|
HookEventName::SessionStart,
|
|
&session_start_envelope(),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(results.len(), 1);
|
|
assert!(matches!(
|
|
&results[0],
|
|
xai_grok_hooks::result::HookRunResult::Success { .. }
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_deny_stops_chain() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Two files: first denies, second allows. Second should never run.
|
|
write_hook(
|
|
dir.path(),
|
|
"01-deny.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo '{\"decision\":\"deny\",\"reason\":\"first-deny\"}'; exit 2"}]}]}}"#,
|
|
);
|
|
write_hook(
|
|
dir.path(),
|
|
"02-allow.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"echo '{\"decision\":\"allow\"}'"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result = dispatcher::dispatch_pre_tool_use(
|
|
®istry,
|
|
&pre_tool_use_envelope("run_terminal_cmd"),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
match pre_result.decision {
|
|
HookDecision::Deny { reason, .. } => {
|
|
assert_eq!(reason, "first-deny");
|
|
}
|
|
other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_receives_stdin_envelope() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Inline command: read stdin, check it contains expected fields, allow if valid.
|
|
write_hook(
|
|
dir.path(),
|
|
"check.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"INPUT=$(cat); echo \"$INPUT\" | grep -q '\"hookEventName\"' && echo \"$INPUT\" | grep -q '\"toolName\"' && echo \"$INPUT\" | grep -q '\"sessionId\"' && echo '{\"decision\":\"allow\"}' || echo '{\"decision\":\"deny\",\"reason\":\"missing fields\"}'"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test-sess-123",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(pre_result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_receives_env_vars() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Inline command: check env vars and write results to a file.
|
|
let output_file = dir.path().join("env_output.txt");
|
|
let cmd = format!(
|
|
r#"echo "EVENT=$GROK_HOOK_EVENT" > {f}; echo "NAME=$GROK_HOOK_NAME" >> {f}; echo "SESSION=$GROK_SESSION_ID" >> {f}; echo '{{"decision":"allow"}}'"#,
|
|
f = output_file.display(),
|
|
);
|
|
let hook_json = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{ "hooks": [{ "type": "command", "command": cmd }] }
|
|
]
|
|
}
|
|
});
|
|
write_hook(dir.path(), "env.json", &hook_json.to_string());
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "sess-456",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(pre_result.decision, HookDecision::Allow);
|
|
|
|
// Verify env vars were received.
|
|
let output = std::fs::read_to_string(&output_file).unwrap();
|
|
assert!(output.contains("EVENT=pre_tool_use"), "output: {output}");
|
|
assert!(output.contains("NAME="), "output: {output}"); // auto-generated name
|
|
assert!(output.contains("SESSION=sess-456"), "output: {output}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shell_pipe_command_works() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Shell command with a pipe -- must go through sh -c.
|
|
write_hook(
|
|
dir.path(),
|
|
"pipe.json",
|
|
r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"cat | echo '{\"decision\":\"allow\"}'"}]}]}}"#,
|
|
);
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty());
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(pre_result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
fn make_envelope(event: HookEventName, payload: HookPayload) -> HookEventEnvelope {
|
|
HookEventEnvelope {
|
|
hook_event_name: event,
|
|
session_id: "test-session".into(),
|
|
cwd: "/tmp".into(),
|
|
workspace_root: "/tmp".into(),
|
|
timestamp: "2025-01-01T00:00:00Z".into(),
|
|
transcript_path: None,
|
|
client_identifier: None,
|
|
prompt_id: None,
|
|
payload,
|
|
}
|
|
}
|
|
|
|
/// Each new event type: write hook file → load → dispatch → verify the
|
|
/// command fires and receives the correct JSON envelope on stdin.
|
|
#[tokio::test]
|
|
async fn new_event_types_fire_and_receive_correct_envelope() {
|
|
struct Case {
|
|
event_name: HookEventName,
|
|
json_key: &'static str,
|
|
payload: HookPayload,
|
|
assertions: Vec<(&'static str, serde_json::Value)>,
|
|
}
|
|
|
|
let cases = vec![
|
|
Case {
|
|
event_name: HookEventName::PostToolUseFailure,
|
|
json_key: "PostToolUseFailure",
|
|
payload: HookPayload::PostToolUseFailure {
|
|
tool_name: "run_terminal_cmd".into(),
|
|
tool_use_id: "call-1".into(),
|
|
tool_input: serde_json::json!({"command": "bad_cmd"}),
|
|
tool_input_truncated: false,
|
|
error: "command not found".into(),
|
|
subagent_type: None,
|
|
},
|
|
assertions: vec![
|
|
("hookEventName", "post_tool_use_failure".into()),
|
|
("toolName", "run_terminal_cmd".into()),
|
|
("error", "command not found".into()),
|
|
],
|
|
},
|
|
Case {
|
|
event_name: HookEventName::PermissionDenied,
|
|
json_key: "PermissionDenied",
|
|
payload: HookPayload::PermissionDenied {
|
|
tool_name: "run_terminal_cmd".into(),
|
|
tool_use_id: "call-2".into(),
|
|
tool_input: serde_json::json!({"command": "rm -rf /"}),
|
|
tool_input_truncated: false,
|
|
},
|
|
assertions: vec![
|
|
("hookEventName", "permission_denied".into()),
|
|
("toolName", "run_terminal_cmd".into()),
|
|
],
|
|
},
|
|
Case {
|
|
event_name: HookEventName::PreCompact,
|
|
json_key: "PreCompact",
|
|
payload: HookPayload::PreCompact {
|
|
source: "auto".into(),
|
|
},
|
|
assertions: vec![
|
|
("hookEventName", "pre_compact".into()),
|
|
("source", "auto".into()),
|
|
],
|
|
},
|
|
Case {
|
|
event_name: HookEventName::PostCompact,
|
|
json_key: "PostCompact",
|
|
payload: HookPayload::PostCompact {
|
|
source: "manual".into(),
|
|
},
|
|
assertions: vec![
|
|
("hookEventName", "post_compact".into()),
|
|
("source", "manual".into()),
|
|
],
|
|
},
|
|
Case {
|
|
event_name: HookEventName::StopFailure,
|
|
json_key: "StopFailure",
|
|
payload: HookPayload::StopFailure {
|
|
error: "rate_limit".into(),
|
|
},
|
|
assertions: vec![
|
|
("hookEventName", "stop_failure".into()),
|
|
("error", "rate_limit".into()),
|
|
],
|
|
},
|
|
];
|
|
|
|
for case in &cases {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let output_file = dir.path().join("output.json");
|
|
|
|
let cmd = format!("cat > {}", output_file.display());
|
|
let hook_json = serde_json::json!({
|
|
"hooks": {
|
|
(case.json_key): [
|
|
{ "hooks": [{ "type": "command", "command": cmd }] }
|
|
]
|
|
}
|
|
});
|
|
write_hook(dir.path(), "hook.json", &hook_json.to_string());
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(
|
|
errors.is_empty(),
|
|
"{}: load errors: {errors:?}",
|
|
case.json_key
|
|
);
|
|
assert!(!registry.is_empty(), "{}: registry empty", case.json_key);
|
|
|
|
let envelope = make_envelope(case.event_name, case.payload.clone());
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
|
|
let results =
|
|
dispatcher::dispatch_non_blocking(®istry, case.event_name, &envelope, &ctx).await;
|
|
|
|
assert_eq!(
|
|
results.len(),
|
|
1,
|
|
"{}: expected 1 result, got {}",
|
|
case.json_key,
|
|
results.len()
|
|
);
|
|
assert!(
|
|
matches!(
|
|
&results[0],
|
|
xai_grok_hooks::result::HookRunResult::Success { .. }
|
|
),
|
|
"{}: hook did not succeed: {:?}",
|
|
case.json_key,
|
|
results[0]
|
|
);
|
|
|
|
let raw = std::fs::read_to_string(&output_file)
|
|
.unwrap_or_else(|e| panic!("{}: hook didn't fire: {e}", case.json_key));
|
|
let captured: serde_json::Value = serde_json::from_str(&raw)
|
|
.unwrap_or_else(|e| panic!("{}: bad JSON: {e}\nraw: {raw}", case.json_key));
|
|
|
|
for (field, expected) in &case.assertions {
|
|
assert_eq!(
|
|
&captured[field], expected,
|
|
"{}: field '{}' mismatch.\nExpected: {expected}\nGot: {}\nFull: {captured}",
|
|
case.json_key, field, captured[field]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Regression: a user JSON hook that declares `env` values for
|
|
/// runner-reserved keys (`GROK_HOOK_EVENT`, `GROK_HOOK_NAME`,
|
|
/// `GROK_SESSION_ID`, `GROK_WORKSPACE_ROOT`, `CLAUDE_PROJECT_DIR`)
|
|
/// must NOT spoof those values inside the spawned child. The
|
|
/// runner-injected vars always win at spawn time. This test
|
|
/// constructs the spoof JSON, dispatches a hook that writes `printenv`
|
|
/// for each key, and asserts the captured values are the runner's
|
|
/// authentic ones.
|
|
#[tokio::test]
|
|
async fn runner_injected_vars_override_extra_env_at_spawn() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let output_file = dir.path().join("envcap.txt");
|
|
|
|
// The hook writes the values it sees for each reserved key.
|
|
let cmd = format!(
|
|
r#"echo "EVENT=$GROK_HOOK_EVENT" > {f}; echo "NAME=$GROK_HOOK_NAME" >> {f}; echo "SESSION=$GROK_SESSION_ID" >> {f}; echo "ROOT=$GROK_WORKSPACE_ROOT" >> {f}; echo "PROJ=$CLAUDE_PROJECT_DIR" >> {f}; echo "USER_KEY=$USER_KEY" >> {f}; echo '{{"decision":"allow"}}'"#,
|
|
f = output_file.display(),
|
|
);
|
|
|
|
let hook_json = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": cmd,
|
|
// Spoof every reserved key + add a non-reserved one
|
|
// that should be preserved.
|
|
"env": {
|
|
"GROK_HOOK_EVENT": "spoofed_event",
|
|
"GROK_HOOK_NAME": "spoofed_name",
|
|
"GROK_SESSION_ID": "spoofed_session",
|
|
"GROK_WORKSPACE_ROOT": "/spoofed/root",
|
|
"CLAUDE_PROJECT_DIR": "/spoofed/project",
|
|
"USER_KEY": "user_value_kept"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
write_hook(dir.path(), "spoof.json", &hook_json.to_string());
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty(), "errors: {errors:?}");
|
|
|
|
let real_session = "auth-session-xyz";
|
|
let real_workspace = dir.path().to_str().unwrap();
|
|
let ctx = RunContext {
|
|
session_id: real_session,
|
|
workspace_root: real_workspace,
|
|
};
|
|
|
|
let result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
|
|
let captured = std::fs::read_to_string(&output_file).unwrap();
|
|
// Reserved keys: runner values must win (NOT the spoofed values).
|
|
assert!(
|
|
captured.contains("EVENT=pre_tool_use"),
|
|
"GROK_HOOK_EVENT must reflect the real event, got:\n{captured}"
|
|
);
|
|
assert!(
|
|
!captured.contains("EVENT=spoofed_event"),
|
|
"spoofed GROK_HOOK_EVENT must NOT leak through, got:\n{captured}"
|
|
);
|
|
assert!(
|
|
captured.contains(&format!("SESSION={real_session}")),
|
|
"GROK_SESSION_ID must reflect the real session, got:\n{captured}"
|
|
);
|
|
assert!(
|
|
!captured.contains("SESSION=spoofed_session"),
|
|
"spoofed GROK_SESSION_ID must NOT leak through"
|
|
);
|
|
assert!(
|
|
captured.contains(&format!("ROOT={real_workspace}")),
|
|
"GROK_WORKSPACE_ROOT must reflect the real workspace root, got:\n{captured}"
|
|
);
|
|
assert!(
|
|
!captured.contains("ROOT=/spoofed/root"),
|
|
"spoofed GROK_WORKSPACE_ROOT must NOT leak through"
|
|
);
|
|
assert!(
|
|
captured.contains(&format!("PROJ={real_workspace}")),
|
|
"CLAUDE_PROJECT_DIR must reflect the real workspace root, got:\n{captured}"
|
|
);
|
|
assert!(
|
|
!captured.contains("PROJ=/spoofed/project"),
|
|
"spoofed CLAUDE_PROJECT_DIR must NOT leak through"
|
|
);
|
|
// Non-reserved key: user value passes through.
|
|
assert!(
|
|
captured.contains("USER_KEY=user_value_kept"),
|
|
"non-reserved user-declared env keys must pass through, got:\n{captured}"
|
|
);
|
|
}
|
|
|
|
/// Regression: a user JSON hook with `command:
|
|
/// "${VAR}/script.sh"` (no other shell metachars) should resolve at
|
|
/// load time to the substituted path and then take the **direct-exec**
|
|
/// branch in the runner. This proves the load-time -> direct-exec
|
|
/// path works end-to-end through `load_hooks` -> `dispatcher::dispatch_*`.
|
|
#[tokio::test]
|
|
async fn direct_exec_command_with_env_var_resolves_at_load_time() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Build an inline shell script via the env map: the resolved
|
|
// command path will be `<tmpdir>/check.sh`. We use the per-hook
|
|
// `env` map (rather than the process env) so this test doesn't
|
|
// need to mutate global state.
|
|
let tmpdir_str = dir.path().to_string_lossy().into_owned();
|
|
|
|
// Create the script in tempdir with executable bit.
|
|
let script = dir.path().join("check.sh");
|
|
std::fs::write(
|
|
&script,
|
|
"#!/bin/sh\necho '{\"decision\":\"allow\"}'\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 hook_json = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
// No shell metachars apart from `${...}`. The
|
|
// load-time pass resolves `${ROOT}` to the
|
|
// tmpdir path, leaving "/tmp.../check.sh"
|
|
// with NO `$` -- the runner picks the
|
|
// direct-exec branch.
|
|
"command": "${ROOT}/check.sh",
|
|
"env": { "ROOT": tmpdir_str }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
write_hook(dir.path(), "exec.json", &hook_json.to_string());
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty(), "errors: {errors:?}");
|
|
|
|
// Sanity: the loaded spec already has the resolved path with no `$`.
|
|
let specs: Vec<_> = registry
|
|
.hooks_for(HookEventName::PreToolUse)
|
|
.iter()
|
|
.collect();
|
|
assert_eq!(specs.len(), 1);
|
|
let cmd = specs[0]
|
|
.command
|
|
.as_ref()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
assert!(
|
|
!cmd.contains('$'),
|
|
"command must be fully resolved at load time, got: {cmd}"
|
|
);
|
|
assert!(cmd.ends_with("/check.sh"), "got: {cmd}");
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
let result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
assert_eq!(
|
|
result.decision,
|
|
HookDecision::Allow,
|
|
"direct-exec hook with env-var-resolved path must run, got {:?}",
|
|
result.decision
|
|
);
|
|
}
|
|
|
|
/// Regression: an HTTP hook whose `url` references `${VAR}`
|
|
/// resolved via the per-hook `env` map must reach the HTTP runner with
|
|
/// the post-expansion URL. We can't make a real network call from CI,
|
|
/// but we can prove the runner sees the expanded URL by pointing at a
|
|
/// blocked private IP and verifying the SSRF block message references
|
|
/// the post-expansion address. Pairs with the unit test
|
|
/// `run_http_hook_uses_post_expansion_url_for_ssrf`.
|
|
#[tokio::test]
|
|
async fn http_hook_url_env_expansion_end_to_end() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let hook_json = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "http",
|
|
// `${INTERNAL}` is in the per-hook env map
|
|
// and resolves to a private RFC1918 IP. The
|
|
// HTTP runner expands the URL, then SSRF
|
|
// validation rejects 10.0.0.1.
|
|
"url": "https://${INTERNAL}/check",
|
|
"env": { "INTERNAL": "10.0.0.1" }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
write_hook(dir.path(), "http.json", &hook_json.to_string());
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty(), "errors: {errors:?}");
|
|
|
|
// Sanity: load-time expansion already substituted `${INTERNAL}`,
|
|
// because `INTERNAL` is in the per-hook env map (which feeds
|
|
// load-time expansion).
|
|
let specs: Vec<_> = registry
|
|
.hooks_for(HookEventName::PreToolUse)
|
|
.iter()
|
|
.collect();
|
|
assert_eq!(specs.len(), 1);
|
|
assert_eq!(
|
|
specs[0].url.as_deref(),
|
|
Some("https://10.0.0.1/check"),
|
|
"load-time expansion should have already substituted ${{INTERNAL}}"
|
|
);
|
|
// `url_raw` preserves the source string for display surfaces.
|
|
assert_eq!(
|
|
specs[0].url_raw.as_deref(),
|
|
Some("https://${INTERNAL}/check")
|
|
);
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
let pre_result =
|
|
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
|
.await;
|
|
// Fail-open: SSRF block is a hook failure, not a deny. The tool
|
|
// call is allowed; the failure is recorded for scrollback.
|
|
assert_eq!(
|
|
pre_result.decision,
|
|
HookDecision::Allow,
|
|
"fail-open: SSRF-blocked HTTP hook must NOT block the tool call"
|
|
);
|
|
assert_eq!(pre_result.results.len(), 1);
|
|
let run = &pre_result.results[0];
|
|
use xai_grok_hooks::result::HookRunResult;
|
|
let info = match run {
|
|
HookRunResult::Failed {
|
|
http_info, error, ..
|
|
} => {
|
|
assert!(
|
|
error.contains("blocked") || error.contains("SSRF"),
|
|
"expected SSRF block message, got: {error}"
|
|
);
|
|
http_info.as_ref().expect("HttpInfo should be present")
|
|
}
|
|
other => panic!("expected Failed run result, got {other:?}"),
|
|
};
|
|
assert_eq!(
|
|
info.url, "https://10.0.0.1/check",
|
|
"HttpInfo.url must reflect the post-expansion URL"
|
|
);
|
|
// raw_url mirrors the source string so wire-DTO
|
|
// consumers can prefer it over the post-expansion `url` for any
|
|
// user-facing display.
|
|
assert_eq!(
|
|
info.raw_url.as_deref(),
|
|
Some("https://${INTERNAL}/check"),
|
|
"HttpInfo.raw_url must mirror HookSpec::url_raw"
|
|
);
|
|
}
|
|
|
|
/// Mixed known + unknown events: known ones load and dispatch, unknown ones are skipped.
|
|
#[tokio::test]
|
|
async fn lenient_parsing_with_mixed_claude_events() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let hook_json = serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{ "matcher": "run_terminal_cmd", "hooks": [{ "type": "command", "command": "echo '{\"decision\":\"allow\"}'" }] }
|
|
],
|
|
"PostToolUseFailure": [
|
|
{ "hooks": [{ "type": "command", "command": "echo fail-hook" }] }
|
|
],
|
|
"PreCompact": [
|
|
{ "hooks": [{ "type": "command", "command": "echo compact" }] }
|
|
],
|
|
// Unknown external-only events — must not break the above.
|
|
"PermissionRequest": [
|
|
{ "hooks": [{ "type": "command", "command": "echo perm-req" }] }
|
|
],
|
|
"TaskCreated": [
|
|
{ "hooks": [{ "type": "command", "command": "echo task" }] }
|
|
],
|
|
"FileChanged": [
|
|
{ "matcher": ".envrc", "hooks": [{ "type": "command", "command": "echo envrc" }] }
|
|
]
|
|
}
|
|
});
|
|
write_hook(dir.path(), "mixed.json", &hook_json.to_string());
|
|
|
|
let (registry, errors) = load_hooks(Some(dir.path()), None);
|
|
assert!(errors.is_empty(), "errors: {errors:?}");
|
|
assert_eq!(registry.hooks_for(HookEventName::PreToolUse).len(), 1);
|
|
assert_eq!(
|
|
registry.hooks_for(HookEventName::PostToolUseFailure).len(),
|
|
1
|
|
);
|
|
assert_eq!(registry.hooks_for(HookEventName::PreCompact).len(), 1);
|
|
|
|
let ctx = RunContext {
|
|
session_id: "test",
|
|
workspace_root: dir.path().to_str().unwrap(),
|
|
};
|
|
let result = dispatcher::dispatch_pre_tool_use(
|
|
®istry,
|
|
&pre_tool_use_envelope("run_terminal_cmd"),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|