use super::{
HOOK_MAX_STDIN_BYTES, HOOK_SCHEMA, HOOK_SCHEMA_VERSION, HookAction, HookContextInjectionRecord,
HookContextInjectionRecordInput, HookContextInjectionStatus, HookDiagnostic,
HookFailureCategory, HookLifecycleStatus, HookPathPolicies, HookPhase, HookRuntime,
MAX_AFFECTED_PATHS, affected_paths_for_call, build_payload, cleanup_payload_ref_path,
hook_activity_metadata, parse_provider_context_stdout, payload_ref_root,
};
use crate::providers::{ChatMessage, ProviderConversationItem, ToolCall};
use crate::tools::{
ToolResult, ToolResultDisplay, ToolSettings,
process::{recv_pipe_reader_with_timeout, spawn_bounded_pipe_reader},
};
use crate::{
agent::cancellation::AgentCancellation,
config::{HookDefinition, HookFailurePolicy, HookPayloadMode, HookSettings},
output::{
ActivityEvent, ActivityId, ActivityKind, ActivitySender, ActivityStatus,
HookContextMetadata, InvocationMode, ToolDispatchContext,
},
};
use serde_json::{Value, json};
use std::io::{self, Read};
use std::{
fs,
path::{Component, Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
fn call(name: &str, arguments: Value) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments,
}
}
fn affected_path_values(
cwd: &Path,
tool_name: &str,
path: &Path,
policies: HookPathPolicies,
) -> Value {
serde_json::to_value(affected_paths_for_call(
cwd,
&call(tool_name, json!({"path": path.display().to_string()})),
policies,
))
.unwrap()
}
fn affected_paths_from_args(
cwd: &Path,
tool_name: &str,
arguments: Value,
policies: HookPathPolicies,
) -> Value {
serde_json::to_value(affected_paths_for_call(
cwd,
&call(tool_name, arguments),
policies,
))
.unwrap()
}
fn collect_activity_context() -> (
ToolDispatchContext,
Arc<std::sync::Mutex<Vec<ActivityEvent>>>,
) {
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
let sender: ActivitySender = Arc::new(move |event| {
captured.lock().unwrap().push(event);
});
(
ToolDispatchContext::new(Some(ActivityId::new("tool-parent")), Some(sender)),
events,
)
}
fn hook_event_count(events: &Arc<std::sync::Mutex<Vec<ActivityEvent>>>) -> usize {
events.lock().unwrap().len()
}
fn result() -> ToolResult {
ToolResult {
tool_name: "read".to_string(),
success: true,
content: "tool output".to_string(),
metadata: json!({}),
display: ToolResultDisplay::default(),
}
}
fn write_stdout_hook(temp: &tempfile::TempDir, stdout: &str) {
fs::write(temp.path().join("hook.out"), stdout).unwrap();
}
#[cfg(unix)]
#[test]
fn hook_shell_does_not_inherit_credential_env() {
let temp = tempfile::TempDir::new().unwrap();
let key = "MAGI_CODE_TEST_ENV_PROBE_HOOK";
unsafe { std::env::set_var(key, "leaked") };
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
command: format!("test \"${key}\" = leaked && exit 9 || exit 0"),
failure_policy: Some(HookFailurePolicy::Fail),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = runtime.run_before(&call("read", json!({})));
unsafe { std::env::remove_var(key) };
assert!(matches!(outcome.action, HookAction::Continue));
assert!(outcome.diagnostics.is_empty(), "{:?}", outcome.diagnostics);
}
#[cfg(unix)]
#[test]
fn payload_ref_rejects_symlink_root() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let target = temp.path().join("target");
fs::create_dir(&target).unwrap();
let root = temp.path().join("magi-code-hook-payloads");
symlink(&target, &root).unwrap();
let runtime = HookRuntime::new(temp.path(), HookSettings::default(), true).unwrap();
let error = runtime
.write_payload_ref_for_test("{}", root)
.unwrap_err()
.to_string();
assert!(error.contains("symlink"), "{error}");
}
#[cfg(unix)]
#[test]
fn provider_context_injection_parses_enabled_after_hook_stdout() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"memory note"},{"role":"user","content":"second"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
after_tool: vec![HookDefinition {
label: Some("memory".into()),
command: "cat hook.out".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("call_1")), None);
let outcome = runtime.run_after_with_activity(&call("read", json!({})), &result(), &context);
assert_eq!(outcome.context_items.len(), 2);
assert_eq!(
outcome.context_injection_records[0].status.as_str(),
"success"
);
assert!(matches!(
&outcome.context_items[0],
ProviderConversationItem::Message(message)
if message.role == crate::providers::MessageRole::User
&& message.content == "memory note"
));
}
#[cfg(unix)]
#[test]
fn provider_context_injection_after_hook_fail_policy_records_hook_failed_without_context() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"must not inject"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
after_tool: vec![HookDefinition {
label: Some("strict-memory".into()),
command: "cat hook.out; exit 9".into(),
failure_policy: Some(HookFailurePolicy::Fail),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("call_1")), None);
let outcome = runtime.run_after_with_activity(&call("read", json!({})), &result(), &context);
assert!(matches!(outcome.action, HookAction::Fail(_)));
assert!(outcome.context_items.is_empty());
assert_eq!(outcome.context_injection_records.len(), 1);
assert_eq!(
outcome.context_injection_records[0].status.as_str(),
"hook_failed"
);
}
#[cfg(unix)]
#[test]
fn provider_context_injection_after_hook_preflight_records_hook_failed_without_context() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
after_tool: vec![HookDefinition {
label: Some("escape".into()),
command: "printf '{\"context_items\":[{\"role\":\"user\",\"content\":\"must not inject\"}]}' > ../outside".into(),
failure_policy: Some(HookFailurePolicy::Fail),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("call_1")), None);
let outcome = runtime.run_after_with_activity(&call("read", json!({})), &result(), &context);
assert!(matches!(outcome.action, HookAction::Fail(_)));
assert!(outcome.context_items.is_empty());
assert_eq!(outcome.context_injection_records.len(), 1);
assert_eq!(
outcome.context_injection_records[0].status.as_str(),
"hook_failed"
);
}
#[cfg(unix)]
#[test]
fn provider_context_injection_respects_default_and_configured_byte_limits() {
let exact_default = "x".repeat(4096);
let default_json =
serde_json::json!({"context_items":[{"role":"user","content":exact_default}]}).to_string();
let parsed_default = parse_provider_context_stdout(&default_json, 4096);
assert_eq!(parsed_default.status.as_str(), "success");
assert_eq!(parsed_default.byte_count, 4096);
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(&temp, &default_json);
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
stdout_max_bytes: 8192,
after_tool: vec![HookDefinition {
command: "cat hook.out".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("call_1")), None);
let outcome = runtime.run_after_with_activity(&call("read", json!({})), &result(), &context);
assert_eq!(outcome.context_items.len(), 1);
assert_eq!(outcome.context_injection_records[0].byte_count, 4096);
let exact_configured = "y".repeat(16384);
let configured_json =
serde_json::json!({"context_items":[{"role":"user","content":exact_configured}]})
.to_string();
let parsed_configured = parse_provider_context_stdout(&configured_json, 16384);
assert_eq!(parsed_configured.status.as_str(), "success");
assert_eq!(parsed_configured.byte_count, 16384);
write_stdout_hook(&temp, &configured_json);
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: true,
provider_context_max_bytes: 16384,
stdout_max_bytes: 32768,
after_tool: vec![HookDefinition {
command: "cat hook.out".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = runtime.run_after_with_activity(&call("read", json!({})), &result(), &context);
assert_eq!(outcome.context_items.len(), 1);
assert_eq!(outcome.context_injection_records[0].byte_count, 16384);
}
#[cfg(unix)]
#[test]
fn provider_context_injection_ignores_disabled_and_before_hooks() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"hidden"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
provider_context_injection: false,
before_tool: vec![HookDefinition {
command: "cat hook.out".into(),
provider_context_injection: Some(true),
..HookDefinition::default()
}],
after_tool: vec![HookDefinition {
command: "cat hook.out".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("call_1")), None);
let before = runtime.run_before(&call("read", json!({})));
let after = runtime.run_after_with_activity(&call("read", json!({})), &result(), &context);
assert!(before.context_items.is_empty());
assert!(before.context_injection_records.is_empty());
assert!(after.context_items.is_empty());
assert!(after.context_injection_records.is_empty());
}
#[test]
fn apply_policy_preserves_context_items_for_continue_policies_only() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = HookRuntime::new(temp.path(), HookSettings::default(), true).unwrap();
let context_items = vec![ProviderConversationItem::Message(ChatMessage::user(
"keep me",
))];
let base = HookDiagnostic {
phase: HookPhase::After,
tool_name: "read".into(),
label: "warn".into(),
category: HookFailureCategory::Runner,
policy: HookFailurePolicy::Warn,
target_ran: true,
message: "warn".into(),
};
let warn = runtime.apply_policy(
base.clone(),
Vec::new(),
Vec::new(),
context_items.clone(),
Vec::new(),
);
assert!(matches!(warn.action, HookAction::Continue));
assert_eq!(warn.context_items.len(), 1);
let mut block_diagnostic = base;
block_diagnostic.policy = HookFailurePolicy::Block;
let block = runtime.apply_policy(
block_diagnostic,
Vec::new(),
Vec::new(),
context_items,
Vec::new(),
);
assert!(matches!(block.action, HookAction::Block(_)));
assert!(block.context_items.is_empty());
}
#[test]
fn provider_context_parser_rejects_invalid_shapes_all_or_nothing() {
for (stdout, status) in [
("not json", "invalid_json"),
(r#"{"context_items":{}}"#, "invalid_shape"),
(
r#"{"context_items":[{"role":"assistant","content":"x"}]}"#,
"unsupported_role",
),
(
r#"{"context_items":[{"role":"user","content":""}]}"#,
"empty_content",
),
(
r#"{"context_items":[{"role":"user","content":"ok"},{"role":"user","content":7}]}"#,
"invalid_shape",
),
] {
let parsed = parse_provider_context_stdout(stdout, 100);
assert_eq!(parsed.status.as_str(), status);
assert!(parsed.items.is_empty());
}
let over =
parse_provider_context_stdout(r#"{"context_items":[{"role":"user","content":"éé"}]}"#, 3);
assert_eq!(over.status.as_str(), "over_limit");
assert_eq!(over.byte_count, 4);
}
#[test]
fn context_injection_audit_payload_uses_allowlisted_keys_only() {
let call = call("bash", json!({"command":"echo sk-secret123456789"}));
let hook = HookDefinition {
label: Some("secret sk-secret123456789".into()),
command: "cat hook.out".into(),
..HookDefinition::default()
};
let record = HookContextInjectionRecord::new(HookContextInjectionRecordInput {
phase: HookPhase::After,
call: &call,
hook: &hook,
hook_index: 0,
status: HookContextInjectionStatus::Success,
item_count: 1,
byte_count: 4,
max_bytes: 4096,
});
let payload = record.to_session_payload();
let mut keys = payload
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
keys.sort();
assert_eq!(
keys,
vec![
"byte_count",
"hook_index",
"injection_id",
"item_count",
"label",
"max_bytes",
"phase",
"schema_version",
"status",
"target_tool",
"tool_call_id",
]
);
assert!(!payload.to_string().contains("sk-secret"));
assert_eq!(payload["tool_call_id"], "call_1");
assert_eq!(payload["injection_id"], "after_tool:call_1:0");
}
#[test]
fn hook_activity_metadata_is_typed_sanitized_and_excludes_payloads() {
let secret = "sk-hookSecret123456";
let call = call(
"bash",
json!({
"command": format!("echo {secret}"),
"stdout": "hidden stdout",
"stderr": "hidden stderr",
"api_key": secret
}),
);
let metadata = hook_activity_metadata(
HookPhase::Before,
&call,
&format!("audit {secret}"),
ActivityStatus::Failed,
Some("exit"),
HookFailurePolicy::Warn,
);
let debug = format!("{metadata:?}");
assert!(metadata.label.contains("before_tool hook"));
assert!(
metadata
.fields
.iter()
.any(|(key, value)| key == "phase" && value == "before_tool")
);
assert!(
metadata
.fields
.iter()
.any(|(key, value)| key == "target_tool" && value == "bash")
);
assert!(
metadata
.fields
.iter()
.any(|(key, value)| key == "category" && value == "exit")
);
assert!(debug.contains("<redacted>"));
assert!(!debug.contains(secret));
assert!(!debug.contains("hidden stdout"));
assert!(!debug.contains("hidden stderr"));
assert!(!debug.contains("echo"));
}
#[test]
fn versioned_payload_includes_context_and_affected_paths() {
let settings = HookSettings::default();
let hook = HookDefinition {
label: Some("audit".into()),
command: "true".into(),
..HookDefinition::default()
};
let payload = build_payload(
Path::new("/tmp"),
HookPhase::Before,
&hook,
&call("write", json!({"path":"notes.txt","content":"hello"})),
None,
&settings,
);
assert_eq!(payload["schema"], HOOK_SCHEMA);
assert_eq!(payload["schema_version"], HOOK_SCHEMA_VERSION);
assert_eq!(payload["context"]["invocation_mode"], "print");
assert_eq!(payload["context"]["subagent"], false);
assert_eq!(payload["affected_paths"][0]["path"], "notes.txt");
assert_eq!(payload["affected_paths"][0]["kind"], "write_target");
assert_eq!(payload["affected_paths"][0]["source"], "request.path");
assert_eq!(payload["payload"]["status"], "inline");
assert!(payload["payload_ref"].is_null());
assert_eq!(payload["request"]["content"]["redacted"], true);
}
#[test]
fn affected_paths_include_read_paths_array_and_cap_entries() {
let temp = tempfile::TempDir::new().unwrap();
let cwd = temp.path();
for name in ["a.txt", "b.txt"] {
fs::write(cwd.join(name), name).unwrap();
}
let paths = affected_paths_from_args(
cwd,
"read",
json!({"paths":["a.txt", 7, "b.txt"]}),
HookPathPolicies::default(),
);
assert_eq!(paths.as_array().unwrap().len(), 2);
assert_eq!(paths[0]["path"], "a.txt");
assert_eq!(paths[0]["source"], "request.paths");
assert_eq!(paths[1]["path"], "b.txt");
for index in 0..(MAX_AFFECTED_PATHS + 3) {
fs::write(cwd.join(format!("{index}.txt")), "x").unwrap();
}
let many = (0..(MAX_AFFECTED_PATHS + 3))
.map(|index| json!(format!("{index}.txt")))
.collect::<Vec<_>>();
let capped = affected_paths_from_args(
cwd,
"read",
json!({"paths": many}),
HookPathPolicies::default(),
);
assert_eq!(capped.as_array().unwrap().len(), MAX_AFFECTED_PATHS);
}
#[test]
fn affected_paths_read_path_fallback_still_works() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(temp.path().join("single.txt"), "x").unwrap();
let paths = affected_paths_from_args(
temp.path(),
"read",
json!({"path":"single.txt"}),
HookPathPolicies::default(),
);
assert_eq!(paths.as_array().unwrap().len(), 1);
assert_eq!(paths[0]["path"], "single.txt");
assert_eq!(paths[0]["source"], "request.paths");
}
#[test]
fn affected_paths_are_allowlisted_and_do_not_parse_bash() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(temp.path().join("file.txt"), "hello").unwrap();
let settings = HookSettings::default();
let hook = HookDefinition {
label: Some("audit".into()),
command: "true".into(),
..HookDefinition::default()
};
let read_payload = build_payload(
temp.path(),
HookPhase::Before,
&hook,
&call("read", json!({"path":"file.txt"})),
None,
&settings,
);
assert_eq!(read_payload["affected_paths"][0]["kind"], "read_target");
let fffind_payload = build_payload(
temp.path(),
HookPhase::Before,
&hook,
&call("fffind", json!({"query":"file", "path":"."})),
None,
&settings,
);
assert_eq!(fffind_payload["affected_paths"][0]["kind"], "read_target");
assert_eq!(
fffind_payload["affected_paths"][0]["source"],
"request.path"
);
let list_files_payload = build_payload(
temp.path(),
HookPhase::Before,
&hook,
&call("list_files", json!({"path":"."})),
None,
&settings,
);
assert_eq!(
list_files_payload["affected_paths"][0]["kind"],
"read_target"
);
assert_eq!(
list_files_payload["affected_paths"][0]["source"],
"request.path"
);
let bash_payload = build_payload(
temp.path(),
HookPhase::Before,
&hook,
&call("bash", json!({"command":"cat file.txt"})),
None,
&settings,
);
assert!(
bash_payload["affected_paths"]
.as_array()
.unwrap()
.is_empty()
);
let escape_payload = build_payload(
temp.path(),
HookPhase::Before,
&hook,
&call("write", json!({"path":"../outside.txt","content":"x"})),
None,
&settings,
);
assert!(
escape_payload["affected_paths"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn affected_paths_include_allowed_absolute_tool_paths() {
let temp = tempfile::TempDir::new().unwrap();
let cwd = temp.path().join("cwd");
let outside = temp.path().join("outside");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&outside).unwrap();
let read_target = outside.join("read.txt");
fs::write(&read_target, "read").unwrap();
let policies = HookPathPolicies::default();
let read_paths = affected_path_values(&cwd, "read", &read_target, policies);
let ffgrep_paths = affected_path_values(&cwd, "ffgrep", &outside, policies);
let fffind_paths = affected_path_values(&cwd, "fffind", &outside, policies);
let list_files_paths = affected_path_values(&cwd, "list_files", &outside, policies);
let write_paths = affected_path_values(&cwd, "write", &outside.join("new.txt"), policies);
assert_eq!(
read_paths[0]["path"],
read_target.canonicalize().unwrap().display().to_string()
);
assert_eq!(
ffgrep_paths[0]["path"],
outside.canonicalize().unwrap().display().to_string()
);
assert_eq!(
fffind_paths[0]["path"],
outside.canonicalize().unwrap().display().to_string()
);
assert_eq!(
list_files_paths[0]["path"],
outside.canonicalize().unwrap().display().to_string()
);
assert_eq!(list_files_paths[0]["kind"], "read_target");
assert_eq!(list_files_paths[0]["source"], "request.path");
assert_eq!(
write_paths[0]["path"],
outside.join("new.txt").display().to_string()
);
assert_eq!(write_paths[0]["kind"], "write_target");
}
#[test]
fn affected_paths_omit_disallowed_absolute_tool_paths() {
let temp = tempfile::TempDir::new().unwrap();
let cwd = temp.path().join("cwd");
let outside = temp.path().join("outside");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&outside).unwrap();
let read_target = outside.join("read.txt");
fs::write(&read_target, "read").unwrap();
let policies = HookPathPolicies {
read_absolute_paths: false,
write_absolute_paths: false,
grep_absolute_paths: false,
find_absolute_paths: false,
list_files_absolute_paths: false,
};
assert!(affected_path_values(&cwd, "read", &read_target, policies)[0].is_null());
assert!(affected_path_values(&cwd, "ffgrep", &outside, policies)[0].is_null());
assert!(affected_path_values(&cwd, "fffind", &outside, policies)[0].is_null());
assert!(affected_path_values(&cwd, "list_files", &outside, policies)[0].is_null());
assert!(affected_path_values(&cwd, "write", &outside.join("new.txt"), policies)[0].is_null());
}
#[cfg(unix)]
#[test]
fn affected_paths_omit_targets_under_symlink_parent_escape() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let cwd = temp.path().join("cwd");
let outside = temp.path().join("outside");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&outside).unwrap();
symlink(&outside, cwd.join("link")).unwrap();
let policies = HookPathPolicies {
write_absolute_paths: false,
..HookPathPolicies::default()
};
let write_paths = affected_path_values(&cwd, "write", Path::new("link/new.txt"), policies);
assert!(write_paths.as_array().unwrap().is_empty());
}
#[test]
fn hook_runtime_uses_caller_tool_path_options_for_affected_paths() {
let temp = tempfile::TempDir::new().unwrap();
let cwd = temp.path().join("cwd");
let outside = temp.path().join("outside");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&outside).unwrap();
let target = outside.join("file.txt");
fs::write(&target, "outside").unwrap();
let settings = HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("capture".into()),
command: "cat > hook.json".into(),
include_tools: vec!["read".into()],
..HookDefinition::default()
}],
..HookSettings::default()
};
let mut tool_settings = ToolSettings::default();
tool_settings.read.absolute_paths = false;
let runtime = HookRuntime::new_with_tool_settings(&cwd, settings, &tool_settings).unwrap();
let outcome = runtime.run_before(&call("read", json!({"path": target.display().to_string()})));
assert!(matches!(outcome.action, HookAction::Continue));
let written = fs::read_to_string(cwd.join("hook.json")).unwrap();
let payload: Value = serde_json::from_str(&written).unwrap();
assert!(payload["affected_paths"].as_array().unwrap().is_empty());
}
#[test]
fn explicit_hook_context_serializes_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("capture".into()),
command: "cat > hook.json".into(),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let mut hook_context =
crate::output::HookContextMetadata::new(crate::output::InvocationMode::MissionControl);
hook_context.session_id = Some("session-1".into());
hook_context.session_path = Some(temp.path().join("sessions/session-1.jsonl"));
hook_context.provider_id = Some("provider-a".into());
hook_context.model_id = Some("model-a".into());
hook_context.agent_id = Some("tars".into());
hook_context.turn_id = Some("turn-7".into());
hook_context.message_id = Some("message-9".into());
let context = ToolDispatchContext::new_with_hook_context(
Some(ActivityId::new("tool-parent")),
None,
hook_context,
);
let outcome =
runtime.run_before_with_activity(&call("read", json!({"path":"file.txt"})), &context);
assert!(matches!(outcome.action, HookAction::Continue));
let written = fs::read_to_string(temp.path().join("hook.json")).unwrap();
let payload: Value = serde_json::from_str(&written).unwrap();
assert_eq!(payload["context"]["session_id"], "session-1");
assert_eq!(payload["context"]["provider_id"], "provider-a");
assert_eq!(payload["context"]["model_id"], "model-a");
assert_eq!(payload["context"]["agent_id"], "tars");
assert_eq!(payload["context"]["invocation_mode"], "mission_control");
assert_eq!(payload["context"]["turn_id"], "turn-7");
assert_eq!(payload["context"]["message_id"], "message-9");
}
#[test]
fn payload_ref_root_rejects_invalid_session_id_segment() {
let temp = tempfile::TempDir::new().unwrap();
let mut hook_context =
crate::output::HookContextMetadata::new(crate::output::InvocationMode::Print);
hook_context.session_id = Some("../escape".into());
hook_context.session_path = Some(temp.path().join("sessions/session.jsonl"));
let context = ToolDispatchContext::new_with_hook_context(None, None, hook_context);
let root = payload_ref_root(Some(&context)).unwrap();
assert!(root.starts_with(temp.path().join("sessions/hook-payloads")));
assert!(root.ends_with("no-session"));
assert!(
!root
.components()
.any(|component| matches!(component, Component::ParentDir))
);
}
#[test]
fn payload_ref_root_accepts_valid_uuid_session_id() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = uuid::Uuid::new_v4().to_string();
let mut hook_context =
crate::output::HookContextMetadata::new(crate::output::InvocationMode::Print);
hook_context.session_id = Some(session_id.clone());
hook_context.session_path = Some(temp.path().join("sessions/session.jsonl"));
let context = ToolDispatchContext::new_with_hook_context(None, None, hook_context);
let root = payload_ref_root(Some(&context)).unwrap();
assert_eq!(
root,
temp.path().join("sessions/hook-payloads").join(session_id)
);
}
#[test]
fn redacted_payload_omits_sensitive_fields_and_command_bodies() {
let settings = HookSettings::default();
let hook = HookDefinition {
label: Some("audit".into()),
command: "true".into(),
..HookDefinition::default()
};
let call = call(
"bash",
json!({
"command":"echo sk-secret Authorization: Bearer abcdefghijklmnop",
"api_key":"sk-test-secret",
"accountId":"acct-secret"
}),
);
let payload = build_payload(
Path::new("/tmp"),
HookPhase::Before,
&hook,
&call,
None,
&settings,
);
let text = payload.to_string();
assert!(text.contains("<redacted>"));
assert!(text.contains("redacted"));
assert!(!text.contains("sk-test-secret"));
assert!(!text.contains("acct-secret"));
assert!(!text.contains("Bearer abcdefghijklmnop"));
assert!(!text.contains("echo sk-secret"));
}
#[test]
fn full_payload_opt_in_includes_original_arguments() {
let settings = HookSettings {
payload: HookPayloadMode::Redacted,
..HookSettings::default()
};
let hook = HookDefinition {
label: Some("trusted".into()),
command: "true".into(),
payload: Some(HookPayloadMode::Full),
..HookDefinition::default()
};
let call = call(
"write",
json!({"path":"a.txt","content":"secret file content"}),
);
let payload = build_payload(
Path::new("/tmp"),
HookPhase::Before,
&hook,
&call,
None,
&settings,
);
assert_eq!(payload["request"]["content"], "secret file content");
}
#[cfg(unix)]
#[test]
fn hook_runtime_sends_json_on_stdin_and_respects_cwd() {
let temp = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("capture".into()),
command: "cat > hook.json".into(),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let outcome = runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Continue));
let written = fs::read_to_string(temp.path().join("hook.json")).unwrap();
assert!(written.contains("before_tool"));
assert!(written.contains("read"));
}
#[cfg(unix)]
#[test]
fn cloned_hook_runtime_uses_child_cwd_not_parent_cwd() {
let parent = tempfile::TempDir::new().unwrap();
let child = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("capture".into()),
command: "cat > hook.json".into(),
..HookDefinition::default()
}],
..HookSettings::default()
};
let parent_runtime = HookRuntime::new(parent.path(), settings, true).unwrap();
let child_runtime = parent_runtime.clone_for_cwd(child.path()).unwrap();
let outcome = child_runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Continue));
assert!(!parent.path().join("hook.json").exists());
let written = fs::read_to_string(child.path().join("hook.json")).unwrap();
assert!(written.contains(&child.path().canonicalize().unwrap().display().to_string()));
assert!(!written.contains(&parent.path().canonicalize().unwrap().display().to_string()));
}
#[cfg(unix)]
#[test]
fn cloned_hook_runtime_preserves_bash_absolute_path_policy() {
let parent = tempfile::TempDir::new().unwrap();
let child = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("absolute".into()),
command: "printf nope > /tmp/magi-code-hook-absolute-policy-marker".into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(parent.path(), settings, false)
.unwrap()
.clone_for_cwd(child.path())
.unwrap();
let outcome = runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.diagnostics.len(), 1);
assert_eq!(outcome.diagnostics[0].category, HookFailureCategory::Runner);
assert!(outcome.diagnostics[0].message.contains("absolute path"));
}
#[cfg(unix)]
#[test]
fn cloned_inert_hook_runtime_produces_no_records() {
let parent = tempfile::TempDir::new().unwrap();
let child = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: false,
before_tool: vec![HookDefinition {
label: Some("disabled".into()),
command: "cat > should-not-exist".into(),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(parent.path(), settings, true)
.unwrap()
.clone_for_cwd(child.path())
.unwrap();
let outcome = runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Continue));
assert!(outcome.diagnostics.is_empty());
assert!(outcome.lifecycle_records.is_empty());
assert!(!child.path().join("should-not-exist").exists());
}
#[cfg(unix)]
#[test]
fn hook_runtime_rejects_cwd_escape_before_spawn() {
let temp = tempfile::TempDir::new().unwrap();
let marker_name = format!(
"{}-hook-escape-marker",
temp.path().file_name().unwrap().to_string_lossy()
);
let marker = temp.path().parent().unwrap().join(&marker_name);
let _ = fs::remove_file(&marker);
let settings = HookSettings {
enabled: true,
before_tool: vec![HookDefinition {
label: Some("escape".into()),
command: format!("printf spawned > ../{marker_name}"),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let outcome = runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.diagnostics[0].category, HookFailureCategory::Runner);
assert!(
outcome.diagnostics[0]
.message
.contains("cwd-scope preflight"),
"{}",
outcome.diagnostics[0].message
);
assert!(!marker.exists());
}
#[cfg(unix)]
#[test]
fn hook_output_truncation_is_classified_as_failure() {
let temp = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
stdout_max_bytes: 4,
before_tool: vec![HookDefinition {
label: Some("loud".into()),
command: "printf 123456789".into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let outcome = runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(
outcome.diagnostics[0].category,
HookFailureCategory::OutputLimit
);
assert!(!outcome.diagnostics[0].message.contains("123456789"));
}
#[cfg(unix)]
#[test]
fn hook_timeout_uses_failure_policy() {
let temp = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
timeout_seconds: 1,
before_tool: vec![HookDefinition {
label: Some("slow".into()),
command: "sleep 5".into(),
failure_policy: Some(HookFailurePolicy::Block),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let outcome = runtime.run_before(&call("read", json!({"path":"file.txt"})));
assert!(matches!(outcome.action, HookAction::Block(_)));
}
#[cfg(unix)]
#[test]
fn large_full_payload_to_non_reading_hook_times_out_instead_of_blocking_stdin() {
let temp = tempfile::TempDir::new().unwrap();
let script = temp.path().join("hold.sh");
fs::write(&script, "#!/bin/sh\nsleep 5\n").unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
fs::set_permissions(&script, permissions).unwrap();
let settings = HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
timeout_seconds: 1,
before_tool: vec![HookDefinition {
label: Some("non-reader".into()),
command: "./hold.sh".into(),
failure_policy: Some(HookFailurePolicy::Block),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let start = Instant::now();
let outcome = runtime.run_before(&call(
"write",
json!({"path":"file.txt","content":"x".repeat(256 * 1024)}),
));
assert!(start.elapsed() < Duration::from_secs(3));
match outcome.action {
HookAction::Block(diagnostic) => {
assert!(
matches!(
diagnostic.category,
HookFailureCategory::Timeout | HookFailureCategory::Stdin
),
"unexpected category: {:?}",
diagnostic.category
);
assert!(!diagnostic.message.contains(&"x".repeat(128)));
}
other => panic!("expected blocking timeout, got {other:?}"),
}
}
#[test]
fn stdin_error_message_ignores_broken_pipe_and_preserves_other_errors() {
assert_eq!(
super::stdin_error_message(io::Error::new(io::ErrorKind::BrokenPipe, "closed")),
None
);
assert_eq!(
super::stdin_error_message(io::Error::new(io::ErrorKind::TimedOut, "stalled")),
Some("stalled".to_string())
);
}
#[cfg(unix)]
#[test]
fn fast_non_reading_hooks_classify_exit_and_output_without_stdin_failure() {
for (command, expected_category) in [
("exit 0", None),
("exit 9", Some(HookFailureCategory::Exit)),
(
"printf '%*s' 9000 ''",
Some(HookFailureCategory::OutputLimit),
),
] {
let temp = tempfile::TempDir::new().unwrap();
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
timeout_seconds: 1,
stdout_max_bytes: 1024,
before_tool: vec![HookDefinition {
command: command.into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = runtime.run_before(&call(
"write",
json!({"path":"file.txt","content":"x".repeat(256 * 1024)}),
));
match expected_category {
None => assert!(
outcome.diagnostics.is_empty(),
"{command}: {:?}",
outcome.diagnostics
),
Some(category) => assert_eq!(outcome.diagnostics[0].category, category, "{command}"),
}
}
}
#[cfg(unix)]
#[test]
fn non_reading_hook_with_large_stdin_returns_without_join_hang() {
let temp = tempfile::TempDir::new().unwrap();
let script = temp.path().join("hold.sh");
fs::write(&script, "#!/bin/sh\nsleep 5\n").unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
timeout_seconds: 1,
before_tool: vec![HookDefinition {
command: "./hold.sh".into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let start = Instant::now();
let outcome = runtime.run_before(&call(
"write",
json!({"path":"file.txt","content":"x".repeat(256 * 1024)}),
));
assert!(start.elapsed() < Duration::from_secs(3));
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.diagnostics.len(), 1);
assert!(matches!(
outcome.diagnostics[0].category,
HookFailureCategory::Timeout | HookFailureCategory::Stdin
));
assert!(!outcome.diagnostics[0].message.contains(&"x".repeat(128)));
}
#[cfg(unix)]
#[test]
fn oversized_full_payload_uses_payload_ref_and_spawns_hook() {
let temp = tempfile::TempDir::new().unwrap();
let settings = HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
before_tool: vec![HookDefinition {
label: Some("large".into()),
command: "cat > hook.json".into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let large = "x".repeat(HOOK_MAX_STDIN_BYTES + 1);
let outcome = runtime.run_before(&call("write", json!({"path":"file.txt","content":large})));
assert!(matches!(outcome.action, HookAction::Continue));
assert!(outcome.diagnostics.is_empty());
let written = fs::read_to_string(temp.path().join("hook.json")).unwrap();
assert!(written.len() < HOOK_MAX_STDIN_BYTES);
assert!(!written.contains(&"x".repeat(128)));
let payload: Value = serde_json::from_str(&written).unwrap();
assert_eq!(payload["payload"]["status"], "referenced");
let ref_path = payload["payload_ref"]["path"].as_str().unwrap();
assert!(ref_path.contains("magi-code-hook-payloads"));
assert!(!Path::new(ref_path).exists());
assert_eq!(payload["request"]["status"], "moved_to_payload_ref");
assert!(payload["request"]["original_bytes"].as_u64().unwrap() > HOOK_MAX_STDIN_BYTES as u64);
}
#[cfg(unix)]
#[test]
fn oversized_full_payload_ref_envelope_keeps_digest_and_shape() {
let temp = tempfile::TempDir::new().unwrap();
let command = "python3 -c 'import hashlib, json, sys; p=json.load(sys.stdin); b=open(p[\"payload_ref\"][\"path\"], \"rb\").read(); json.dump({\"payload_status\":p[\"payload\"][\"status\"],\"request_status\":p[\"request\"][\"status\"],\"bytes\":len(b),\"ref_bytes\":p[\"payload_ref\"][\"bytes\"],\"digest\":\"sha256:\"+hashlib.sha256(b).hexdigest(),\"ref_digest\":p[\"payload_ref\"][\"digest_sha256\"]}, open(\"inspect.json\", \"w\"))'";
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
before_tool: vec![HookDefinition {
command: command.into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = runtime.run_before(&call(
"write",
json!({"path":"file.txt","content":"x".repeat(HOOK_MAX_STDIN_BYTES + 1)}),
));
assert!(matches!(outcome.action, HookAction::Continue));
assert!(outcome.diagnostics.is_empty());
let inspected: Value =
serde_json::from_str(&fs::read_to_string(temp.path().join("inspect.json")).unwrap())
.unwrap();
assert_eq!(inspected["payload_status"], "referenced");
assert_eq!(inspected["request_status"], "moved_to_payload_ref");
assert_eq!(inspected["bytes"], inspected["ref_bytes"]);
assert_eq!(inspected["digest"], inspected["ref_digest"]);
}
#[cfg(unix)]
#[test]
fn payload_ref_cleanup_failure_surfaces_warning_without_failing_hook() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("sessions")).unwrap();
let settings = HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
before_tool: vec![HookDefinition {
label: Some("cleanup".into()),
command: "python3 -c 'import json, os, sys; p=json.load(sys.stdin)[\"payload_ref\"][\"path\"]; open(\"ref_path\", \"w\").write(p); os.chmod(os.path.dirname(p), 0o500)'".into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
};
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
let mut hook_context =
crate::output::HookContextMetadata::new(crate::output::InvocationMode::Print);
hook_context.session_id = Some(uuid::Uuid::new_v4().to_string());
hook_context.session_path = Some(temp.path().join("sessions/session.jsonl"));
let context = ToolDispatchContext::new_with_hook_context(None, None, hook_context);
let outcome = runtime.run_before_with_activity(
&call(
"write",
json!({"path":"file.txt","content":"x".repeat(HOOK_MAX_STDIN_BYTES + 1)}),
),
&context,
);
let ref_path = temp.path().join("ref_path");
if let Ok(path) = fs::read_to_string(&ref_path) {
let payload_path = PathBuf::from(path);
if let Some(parent) = payload_path.parent() {
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
let _ = cleanup_payload_ref_path(&payload_path);
}
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.diagnostics.len(), 1);
assert_eq!(outcome.diagnostics[0].category, HookFailureCategory::Runner);
assert_eq!(outcome.diagnostics[0].policy, HookFailurePolicy::Warn);
assert!(
outcome.diagnostics[0]
.message
.contains("hook payload ref cleanup incomplete"),
"{}",
outcome.diagnostics[0].message
);
assert!(
!outcome.diagnostics[0]
.message
.contains(temp.path().to_string_lossy().as_ref())
);
}
#[cfg(unix)]
#[test]
fn hook_activity_is_quiet_when_disabled_or_hidden() {
let temp = tempfile::TempDir::new().unwrap();
let (context, events) = collect_activity_context();
let call = call("read", json!({"path":"file.txt"}));
let hook = HookDefinition {
label: Some("quiet".into()),
command: "true".into(),
..HookDefinition::default()
};
let hidden = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
show_in_tui: false,
before_tool: vec![hook.clone()],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = hidden.run_before_with_activity(&call, &context);
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(hook_event_count(&events), 0);
let disabled = HookRuntime::new(
temp.path(),
HookSettings {
enabled: false,
show_in_tui: true,
before_tool: vec![hook],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = disabled.run_before_with_activity(&call, &context);
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(hook_event_count(&events), 0);
}
#[cfg(unix)]
#[test]
fn visible_successful_hooks_emit_start_and_success_without_diagnostic() {
let temp = tempfile::TempDir::new().unwrap();
let (context, events) = collect_activity_context();
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
show_in_tui: true,
before_tool: vec![HookDefinition {
label: Some("audit-before".into()),
command: "true".into(),
..HookDefinition::default()
}],
after_tool: vec![HookDefinition {
label: Some("audit-after".into()),
command: "true".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let call = call("read", json!({"path":"file.txt"}));
let result = ToolResult {
tool_name: "read".to_string(),
success: true,
content: "ok".to_string(),
metadata: json!({}),
display: crate::tools::ToolResultDisplay::default(),
};
let before = runtime.run_before_with_activity(&call, &context);
let after = runtime.run_after_with_activity(&call, &result, &context);
assert!(before.diagnostics.is_empty());
assert!(after.diagnostics.is_empty());
assert_eq!(before.lifecycle_records.len(), 2);
assert_eq!(
before.lifecycle_records[0].status,
HookLifecycleStatus::Started
);
assert_eq!(
before.lifecycle_records[1].status,
HookLifecycleStatus::Success
);
assert_eq!(after.lifecycle_records.len(), 2);
assert_eq!(
after.lifecycle_records[0].status,
HookLifecycleStatus::Started
);
assert_eq!(
after.lifecycle_records[1].status,
HookLifecycleStatus::Success
);
let events = events.lock().unwrap();
assert_eq!(events.len(), 4);
assert!(matches!(
&events[0],
ActivityEvent::Started {
parent_id: Some(parent_id),
kind: ActivityKind::Hook,
status: ActivityStatus::Running,
metadata,
..
} if parent_id.as_str() == "tool-parent"
&& metadata.fields.iter().any(|(key, value)| key == "phase" && value == "before_tool")
));
assert!(matches!(
&events[1],
ActivityEvent::Finished {
status: ActivityStatus::Success,
metadata: Some(metadata),
..
} if metadata.fields.iter().any(|(key, value)| key == "status" && value == "success")
));
assert!(matches!(
&events[2],
ActivityEvent::Started { metadata, .. }
if metadata.fields.iter().any(|(key, value)| key == "phase" && value == "after_tool")
));
assert!(matches!(
&events[3],
ActivityEvent::Finished {
status: ActivityStatus::Success,
..
}
));
}
#[cfg(unix)]
#[test]
fn hidden_successful_hooks_emit_lifecycle_without_activity_or_diagnostic() {
let temp = tempfile::TempDir::new().unwrap();
let (context, events) = collect_activity_context();
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
show_in_tui: false,
before_tool: vec![HookDefinition {
label: Some("audit-before".into()),
command: "true".into(),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome =
runtime.run_before_with_activity(&call("read", json!({"path":"file.txt"})), &context);
assert!(matches!(outcome.action, HookAction::Continue));
assert!(outcome.diagnostics.is_empty());
assert_eq!(hook_event_count(&events), 0);
assert_eq!(outcome.lifecycle_records.len(), 2);
assert_eq!(
outcome.lifecycle_records[0].status,
HookLifecycleStatus::Started
);
assert_eq!(
outcome.lifecycle_records[1].status,
HookLifecycleStatus::Success
);
assert_eq!(outcome.lifecycle_records[1].label, "audit-before");
assert_eq!(outcome.lifecycle_records[1].target_tool, "read");
}
#[cfg(unix)]
#[test]
fn failed_hook_lifecycle_records_policy_and_sanitized_message() {
let temp = tempfile::TempDir::new().unwrap();
let secret = "sk-hookSecret123456";
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
show_in_tui: false,
before_tool: vec![HookDefinition {
label: Some(format!("audit {secret}")),
command: "exit 9".into(),
failure_policy: Some(HookFailurePolicy::Ignore),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome = runtime.run_before(&call("bash", json!({"command": format!("echo {secret}")})));
assert!(matches!(outcome.action, HookAction::Continue));
assert!(outcome.diagnostics.is_empty());
assert_eq!(outcome.lifecycle_records.len(), 2);
let failed = &outcome.lifecycle_records[1];
assert_eq!(failed.status, HookLifecycleStatus::Failed);
assert_eq!(failed.policy, HookFailurePolicy::Ignore);
assert_eq!(failed.category, Some(HookFailureCategory::Exit));
let payload = failed.to_session_payload();
let payload_text = payload.to_string();
assert!(payload_text.contains("<redacted>"));
assert!(!payload_text.contains(secret));
assert!(payload.get("command").is_none());
assert!(payload.get("stdout").is_none());
assert!(payload.get("stderr").is_none());
assert!(payload.get("request").is_none());
assert!(payload.get("result").is_none());
}
#[cfg(unix)]
#[test]
fn visible_failed_hook_emits_failed_activity_with_category_and_policy() {
let temp = tempfile::TempDir::new().unwrap();
let (context, events) = collect_activity_context();
let runtime = HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
show_in_tui: true,
before_tool: vec![HookDefinition {
label: Some("failer".into()),
command: "exit 7".into(),
failure_policy: Some(HookFailurePolicy::Warn),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap();
let outcome =
runtime.run_before_with_activity(&call("read", json!({"path":"file.txt"})), &context);
assert_eq!(outcome.diagnostics.len(), 1);
assert!(matches!(outcome.action, HookAction::Continue));
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
assert!(matches!(
events[0],
ActivityEvent::Started {
kind: ActivityKind::Hook,
..
}
));
assert!(matches!(
&events[1],
ActivityEvent::Finished {
status: ActivityStatus::Failed,
metadata: Some(metadata),
..
} if metadata.fields.iter().any(|(key, value)| key == "category" && value == "exit")
&& metadata.fields.iter().any(|(key, value)| key == "policy" && value == "warn")
));
}
fn message_hook_settings(
phase: &str,
command: &str,
injection: bool,
failure_policy: Option<HookFailurePolicy>,
) -> HookSettings {
let mut settings = HookSettings {
enabled: true,
provider_context_injection: injection,
..HookSettings::default()
};
let hook = HookDefinition {
label: Some(format!("{phase}-memory")),
command: command.to_string(),
failure_policy,
..HookDefinition::default()
};
match phase {
"after_assistant" => settings.after_assistant = vec![hook],
"after_reasoning" => settings.after_reasoning = vec![hook],
_ => {}
}
settings
}
#[cfg(unix)]
#[test]
fn after_assistant_hook_fires_and_records_lifecycle() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = HookRuntime::new(
temp.path(),
message_hook_settings("after_assistant", "true", false, None),
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("msg_1")), None);
let outcome = runtime.run_after_assistant("msg_1", "hello world", &context);
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.lifecycle_records.len(), 2);
assert_eq!(outcome.lifecycle_records[0].status.as_str(), "started");
assert_eq!(outcome.lifecycle_records[1].status.as_str(), "success");
assert_eq!(
outcome.lifecycle_records[0].phase.as_str(),
"after_assistant"
);
assert_eq!(outcome.lifecycle_records[0].target_tool, "assistant");
assert!(outcome.lifecycle_records[0].target_ran);
assert!(outcome.context_items.is_empty());
}
#[cfg(unix)]
#[test]
fn after_reasoning_hook_fires_and_records_lifecycle() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = HookRuntime::new(
temp.path(),
message_hook_settings("after_reasoning", "true", false, None),
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("reasoning_1")), None);
let outcome = runtime.run_after_reasoning("reasoning_1", "thinking...", &context);
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.lifecycle_records.len(), 2);
assert_eq!(
outcome.lifecycle_records[0].phase.as_str(),
"after_reasoning"
);
assert_eq!(outcome.lifecycle_records[0].target_tool, "reasoning");
assert!(outcome.lifecycle_records[0].target_ran);
}
#[cfg(unix)]
#[test]
fn after_assistant_hook_injects_provider_context() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"memory note"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
message_hook_settings("after_assistant", "cat hook.out", true, None),
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("msg_1")), None);
let outcome = runtime.run_after_assistant("msg_1", "assistant text", &context);
assert_eq!(outcome.context_items.len(), 1);
assert_eq!(
outcome.context_injection_records[0].status.as_str(),
"success"
);
assert!(matches!(
&outcome.context_items[0],
ProviderConversationItem::Message(message)
if message.content == "memory note"
));
}
#[cfg(unix)]
#[test]
fn after_reasoning_hook_injects_provider_context() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"steer here"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
message_hook_settings("after_reasoning", "cat hook.out", true, None),
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("reasoning_1")), None);
let outcome = runtime.run_after_reasoning("reasoning_1", "thinking", &context);
assert_eq!(outcome.context_items.len(), 1);
assert_eq!(
outcome.context_injection_records[0].status.as_str(),
"success"
);
}
#[cfg(unix)]
#[test]
fn after_assistant_hook_without_injection_backward_compat() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"not injected"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
message_hook_settings("after_assistant", "cat hook.out", false, None),
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("msg_1")), None);
let outcome = runtime.run_after_assistant("msg_1", "text", &context);
assert!(outcome.context_items.is_empty());
assert!(outcome.context_injection_records.is_empty());
}
#[cfg(unix)]
#[test]
fn after_assistant_hook_fail_policy_fails_and_records_hook_failed() {
let temp = tempfile::TempDir::new().unwrap();
write_stdout_hook(
&temp,
r#"{"context_items":[{"role":"user","content":"must not inject"}]}"#,
);
let runtime = HookRuntime::new(
temp.path(),
message_hook_settings(
"after_assistant",
"cat hook.out; exit 9",
true,
Some(HookFailurePolicy::Fail),
),
true,
)
.unwrap();
let context = ToolDispatchContext::new(Some(ActivityId::new("msg_1")), None);
let outcome = runtime.run_after_assistant("msg_1", "text", &context);
assert!(matches!(outcome.action, HookAction::Fail(_)));
assert!(outcome.context_items.is_empty());
assert_eq!(
outcome.context_injection_records[0].status.as_str(),
"hook_failed"
);
}
#[test]
fn message_phase_hooks_ignore_include_tools_filter() {
let settings = HookSettings {
enabled: true,
after_assistant: vec![HookDefinition {
label: Some("no-filter".into()),
command: "true".into(),
include_tools: vec!["irrelevant".into()],
..HookDefinition::default()
}],
..HookSettings::default()
};
assert!(!HookPhase::AfterAssistant.uses_tool_filter());
assert!(!HookPhase::AfterReasoning.uses_tool_filter());
assert!(HookPhase::Before.uses_tool_filter());
assert!(HookPhase::After.uses_tool_filter());
assert!(!settings.is_default());
}
#[test]
fn config_rejects_block_failure_policy_for_after_assistant() {
let json =
r#"{"enabled":true,"after_assistant":[{"command":"true","failure_policy":"block"}]}"#;
let result: Result<HookSettings, _> = serde_json::from_str(json);
let err = result.unwrap_err().to_string();
assert!(err.contains("block"));
}
#[test]
fn config_rejects_block_failure_policy_for_after_reasoning() {
let json =
r#"{"enabled":true,"after_reasoning":[{"command":"true","failure_policy":"block"}]}"#;
let result: Result<HookSettings, _> = serde_json::from_str(json);
let err = result.unwrap_err().to_string();
assert!(err.contains("block"));
}
#[test]
fn config_accepts_after_assistant_and_after_reasoning_arrays() {
let json = r#"{"enabled":true,"after_assistant":[{"command":"true"}],"after_reasoning":[{"command":"true","provider_context_injection":true}]}"#;
let settings: HookSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.after_assistant.len(), 1);
assert_eq!(settings.after_reasoning.len(), 1);
assert!(
settings.after_reasoning[0]
.provider_context_injection
.unwrap_or(false)
);
}
#[test]
fn is_inert_false_when_only_message_phase_hooks_configured() {
let settings = HookSettings {
enabled: true,
after_assistant: vec![HookDefinition {
command: "true".into(),
..HookDefinition::default()
}],
..HookSettings::default()
};
let temp = tempfile::TempDir::new().unwrap();
let runtime = HookRuntime::new(temp.path(), settings, true).unwrap();
assert!(!runtime.is_inert());
}
#[cfg(unix)]
#[test]
fn canceled_hook_phase_cleans_payload_and_records_sanitized_cancellation() {
let temp = tempfile::TempDir::new().unwrap();
let session_id = "abc320";
let session_path = temp.path().join("sessions/session.jsonl");
fs::create_dir_all(session_path.parent().unwrap()).unwrap();
let secret = "hook-secret-320";
let runtime = Arc::new(
HookRuntime::new(
temp.path(),
HookSettings {
enabled: true,
payload: HookPayloadMode::Full,
before_tool: vec![HookDefinition {
command: "printf started > hook-started; tail -f /dev/null".into(),
payload: Some(HookPayloadMode::Full),
timeout_seconds: Some(30),
..HookDefinition::default()
}],
..HookSettings::default()
},
true,
)
.unwrap(),
);
let canceled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancellation = AgentCancellation::new(Arc::clone(&canceled));
let mut hook_context = HookContextMetadata::new(InvocationMode::Print);
hook_context.session_id = Some(session_id.into());
hook_context.session_path = Some(session_path);
let context = ToolDispatchContext::new_with_hook_context_and_cancellation(
Some(ActivityId::new("hook-parent")),
None,
hook_context,
cancellation,
);
let call = call(
"write",
json!({"path":"file.txt","content":format!("{secret}{}", "x".repeat(HOOK_MAX_STDIN_BYTES + 1))}),
);
let run_runtime = Arc::clone(&runtime);
let handle = std::thread::spawn(move || run_runtime.run_before_with_activity(&call, &context));
let started = temp.path().join("hook-started");
let payload_root = temp.path().join("sessions/hook-payloads").join(session_id);
let deadline = Instant::now() + Duration::from_secs(2);
while (!started.exists()
|| !payload_root
.read_dir()
.map(|mut entries| entries.next().is_some())
.unwrap_or(false))
&& Instant::now() < deadline
{
std::thread::yield_now();
}
assert!(started.exists(), "hook process did not start");
let payload_dir = payload_root
.read_dir()
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let payload_path = payload_dir.join("payload.json");
assert!(payload_path.is_file());
canceled.store(true, std::sync::atomic::Ordering::SeqCst);
let cancel_return = Instant::now();
let outcome = handle.join().unwrap();
assert!(cancel_return.elapsed() < Duration::from_secs(2));
assert!(!payload_path.exists(), "payload ref survived cancellation");
assert!(outcome.canceled);
assert!(matches!(outcome.action, HookAction::Continue));
assert_eq!(outcome.lifecycle_records.len(), 2);
assert_eq!(
outcome.lifecycle_records[1].status,
HookLifecycleStatus::Failed
);
assert_eq!(
outcome.lifecycle_records[1].category,
Some(HookFailureCategory::Runner)
);
assert!(
!outcome.diagnostics.is_empty(),
"records={:?}",
outcome.lifecycle_records
);
let text = format!("{:?}", outcome.diagnostics);
assert!(text.to_lowercase().contains("cancel"), "{text}");
assert!(!text.contains(secret), "raw secret leaked: {text}");
assert!(!text.contains("hook-secret"), "raw secret leaked: {text}");
let lifecycle_text = format!("{:?}", outcome.lifecycle_records);
assert!(
!lifecycle_text.contains(secret),
"raw secret leaked: {lifecycle_text}"
);
assert!(
!lifecycle_text.contains("hook-secret"),
"raw secret leaked: {lifecycle_text}"
);
}
#[cfg(unix)]
#[test]
fn canceled_hook_phase_bounds_blocked_stdin_stdout_and_stderr_cleanup() {
struct BlockingRead(std::sync::mpsc::Receiver<()>);
impl Read for BlockingRead {
fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
let _ = self.0.recv();
Ok(0)
}
}
let (stdout_release, stdout_block) = std::sync::mpsc::channel();
let (stderr_release, stderr_block) = std::sync::mpsc::channel();
let stdout_reader = spawn_bounded_pipe_reader(
BlockingRead(stdout_block),
64,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
let stderr_reader = spawn_bounded_pipe_reader(
BlockingRead(stderr_block),
64,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
let (stdin_release, stdin_block) = std::sync::mpsc::channel();
let stdin_handle = std::thread::spawn(move || stdin_block.recv().map_err(|_| "stdin blocked"));
let start = Instant::now();
let stdout = recv_pipe_reader_with_timeout(stdout_reader);
let stderr = recv_pipe_reader_with_timeout(stderr_reader);
assert!(stdout.timed_out && stdout.join_timed_out);
assert!(stderr.timed_out && stderr.join_timed_out);
assert!(start.elapsed() < Duration::from_secs(4));
assert!(!stdin_handle.is_finished());
stdout_release.send(()).unwrap();
stderr_release.send(()).unwrap();
stdin_release.send(()).unwrap();
assert!(stdin_handle.join().unwrap().is_ok());
}