use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[cfg(unix)]
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
#[cfg(unix)]
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
fn mati_bin() -> PathBuf {
if let Ok(p) = std::env::var("CARGO_BIN_EXE_MATI") {
return PathBuf::from(p);
}
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
PathBuf::from(manifest)
.join("target")
.join("debug")
.join("mati")
}
fn run(bin: &Path, repo: &Path, home: &Path, args: &[&str]) -> RunResult {
let out = Command::new(bin)
.args(args)
.current_dir(repo)
.env("HOME", home)
.env("MATI_HOME", home)
.env("NO_COLOR", "1")
.output()
.expect("failed to run mati");
RunResult {
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
code: out.status.code().unwrap_or(-1),
}
}
fn run_with_stdin(
bin: &Path,
repo: &Path,
home: &Path,
args: &[&str],
stdin_data: &str,
) -> RunResult {
let mut child = Command::new(bin)
.args(args)
.current_dir(repo)
.env("HOME", home)
.env("MATI_HOME", home)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn mati");
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(stdin_data.as_bytes())
.expect("failed to write stdin");
}
let out = child
.wait_with_output()
.expect("failed to wait for mati process");
RunResult {
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
code: out.status.code().unwrap_or(-1),
}
}
#[cfg(unix)]
fn run_with_mati_home(
bin: &Path,
repo: &Path,
home: &Path,
mati_home: &Path,
args: &[&str],
) -> RunResult {
let out = Command::new(bin)
.args(args)
.current_dir(repo)
.env("HOME", home)
.env("MATI_HOME", mati_home)
.env("NO_COLOR", "1")
.output()
.expect("failed to run mati");
RunResult {
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
code: out.status.code().unwrap_or(-1),
}
}
struct RunResult {
stdout: String,
stderr: String,
code: i32,
}
fn setup_repo() -> (TempDir, TempDir) {
let repo_dir = TempDir::new().expect("create repo dir");
let home_dir = TempDir::new().expect("create home dir");
let repo = repo_dir.path();
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.expect("git init");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.expect("git config name");
std::fs::create_dir_all(repo.join("src")).expect("mkdir src");
std::fs::write(
repo.join("src/test.rs"),
r#"fn authenticate(token: &str) -> bool {
// TODO: validate token properly
!token.is_empty()
}
"#,
)
.expect("write test.rs");
std::fs::write(
repo.join("Cargo.toml"),
r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#,
)
.expect("write Cargo.toml");
Command::new("git")
.args(["add", "-A"])
.current_dir(repo)
.output()
.expect("git add");
Command::new("git")
.args(["commit", "-m", "initial commit"])
.current_dir(repo)
.output()
.expect("git commit");
(repo_dir, home_dir)
}
fn wait_for_daemon(bin: &Path, repo: &Path, home: &Path, timeout: Duration) -> bool {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
while start.elapsed() < timeout {
let r = run(bin, repo, home, &["ping", "--daemon-only"]);
if r.code == 0 {
return true;
}
std::thread::sleep(poll_interval);
}
false
}
struct ChildGuard(std::process::Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(unix)]
struct ShortHome(PathBuf);
#[cfg(unix)]
impl ShortHome {
fn new(tag: &str) -> Self {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_nanos();
let path = PathBuf::from(format!("/tmp/m9-{tag}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&path).expect("create short MATI_HOME");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
#[cfg(unix)]
impl Drop for ShortHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
#[ignore]
fn hook_decide_deny_then_allow_after_consultation() {
let bin = mati_bin();
let (repo_dir, home_dir) = setup_repo();
let repo = repo_dir.path();
let home = home_dir.path();
let r = run(&bin, repo, home, &["init", "--no-hooks"]);
assert_eq!(
r.code, 0,
"mati init failed (exit {}):\nstdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
let r = run(&bin, repo, home, &["ping"]);
assert_eq!(r.code, 0, "ping after init failed");
eprintln!("[hook-decide] init complete");
let r = run(
&bin,
repo,
home,
&[
"gotcha",
"add",
"src/test.rs",
"-r",
"Never bypass auth token validation",
"-m",
"Skipping validation allows unauthorized access to protected endpoints",
],
);
assert_eq!(
r.code, 0,
"mati gotcha add failed (exit {}):\nstdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
assert!(
r.stdout.contains("Created gotcha:"),
"expected 'Created gotcha:' in output, got: {}",
r.stdout,
);
eprintln!("[hook-decide] gotcha added: {}", r.stdout.trim());
let r = run(&bin, repo, home, &["ping"]);
assert_eq!(r.code, 0, "ping after gotcha add failed");
let daemon = Command::new(&bin)
.args(["daemon", "start"])
.current_dir(repo)
.env("HOME", home)
.env("MATI_HOME", home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn daemon");
let _guard = ChildGuard(daemon);
assert!(
wait_for_daemon(&bin, repo, home, Duration::from_secs(5)),
"daemon did not become reachable within 5 seconds",
);
eprintln!("[hook-decide] daemon ready");
let stdin_json = r#"{"tool_input":{"command":"cat src/test.rs"}}"#;
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "codex-pre-bash"],
stdin_json,
);
assert_eq!(
r.code, 2,
"expected exit code 2 (deny) on first hook-decide call, got {}.\n\
stdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
assert!(
r.stderr.contains("mem_get"),
"deny stderr should instruct the agent to call mem_get.\nstderr: {}",
r.stderr,
);
eprintln!("[hook-decide] first call: exit 2 (deny) -- correct");
let r = run(&bin, repo, home, &["explain", "src/test.rs"]);
assert_eq!(
r.code, 0,
"mati explain failed (exit {}):\nstdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
eprintln!("[hook-decide] explain (consultation receipt written)");
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "codex-pre-bash"],
stdin_json,
);
assert_eq!(
r.code, 0,
"expected exit code 0 (allow) after consultation, got {}.\n\
stdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
eprintln!("[hook-decide] second call: exit 0 (allow) -- correct");
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "codex-pre-bash"],
r#"{"tool_input":{"command":"ls -la"}}"#,
);
assert_eq!(
r.code, 0,
"non-file command should always exit 0, got {}.\n\
stdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
eprintln!("[hook-decide] non-file command: exit 0 -- correct");
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "claude-pre-read"],
r#"{"tool_input":{"file_path":"src/test.rs"}}"#,
);
assert_eq!(
r.code, 0,
"claude-pre-read should always exit 0, got {}.\n\
stdout: {}\nstderr: {}",
r.code, r.stdout, r.stderr,
);
let response: serde_json::Value = serde_json::from_str(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"claude-pre-read stdout is not valid JSON: {e}\n{}",
r.stdout
)
});
let permission = response
.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
permission, "allow",
"claude-pre-read should allow after consultation.\nJSON: {}",
r.stdout,
);
eprintln!("[hook-decide] claude-pre-read: allow with context -- correct");
eprintln!("[hook-decide] all assertions passed");
}
#[cfg(unix)]
#[test]
#[ignore]
fn hook_decide_denies_gotcha_through_symlink() {
let bin = mati_bin();
let (repo_dir, home_dir) = setup_repo();
let repo = repo_dir.path();
let home = home_dir.path();
std::fs::write(repo.join("src/safe.rs"), "pub fn ok() -> bool { true }\n")
.expect("write safe.rs");
std::os::unix::fs::symlink(repo.join("src/test.rs"), repo.join("link.rs"))
.expect("symlink link.rs");
std::os::unix::fs::symlink(repo.join("src/safe.rs"), repo.join("safe_link.rs"))
.expect("symlink safe_link.rs");
let r = run(&bin, repo, home, &["init", "--no-hooks"]);
assert_eq!(r.code, 0, "mati init failed:\n{}\n{}", r.stdout, r.stderr);
let r = run(&bin, repo, home, &["ping"]);
assert_eq!(r.code, 0, "ping after init failed");
let r = run(
&bin,
repo,
home,
&[
"gotcha",
"add",
"src/test.rs",
"-r",
"Never bypass auth token validation",
"-m",
"Skipping validation allows unauthorized access to protected endpoints",
],
);
assert_eq!(
r.code, 0,
"mati gotcha add failed:\n{}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("Created gotcha:"),
"expected 'Created gotcha:', got: {}",
r.stdout
);
let r = run(&bin, repo, home, &["ping"]);
assert_eq!(r.code, 0, "ping after gotcha add failed");
let daemon = Command::new(&bin)
.args(["daemon", "start"])
.current_dir(repo)
.env("HOME", home)
.env("MATI_HOME", home)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn daemon");
let _guard = ChildGuard(daemon);
assert!(
wait_for_daemon(&bin, repo, home, Duration::from_secs(5)),
"daemon did not become reachable within 5 seconds",
);
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "codex-pre-bash"],
r#"{"tool_input":{"command":"cat link.rs"}}"#,
);
assert_eq!(
r.code, 2,
"symlink to a gotcha'd file must DENY (exit 2) — the bypass must be closed.\n\
stdout: {}\nstderr: {}",
r.stdout, r.stderr,
);
assert!(
r.stderr.contains("mem_get"),
"deny stderr should instruct mem_get.\nstderr: {}",
r.stderr,
);
eprintln!("[wi20] cat through symlink: exit 2 (deny) -- bypass closed");
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "codex-pre-bash"],
r#"{"tool_input":{"command":"cat safe_link.rs"}}"#,
);
assert_eq!(
r.code, 0,
"symlink to a non-gotcha'd file must ALLOW (exit 0) — no false positive.\n\
stdout: {}\nstderr: {}",
r.stdout, r.stderr,
);
eprintln!("[wi20] cat safe symlink: exit 0 (allow) -- no false positive");
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "codex-pre-bash"],
r#"{"tool_input":{"command":"cat src/test.rs"}}"#,
);
assert_eq!(
r.code, 2,
"direct access of the gotcha'd file must still DENY (exit 2).\n\
stdout: {}\nstderr: {}",
r.stdout, r.stderr,
);
eprintln!("[wi20] direct cat: exit 2 (deny) -- lexical gate unchanged");
let r = run_with_stdin(
&bin,
repo,
home,
&["hook-decide", "claude-pre-read"],
&format!(
r#"{{"tool_input":{{"file_path":"{}"}}}}"#,
repo.join("link.rs").display()
),
);
assert_eq!(r.code, 0, "claude-pre-read always exits 0");
let response: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("claude-pre-read stdout not JSON: {e}\n{}", r.stdout));
let permission = response
.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
permission, "deny",
"claude-pre-read through a symlink to a gotcha'd file must deny.\nJSON: {}",
r.stdout,
);
eprintln!("[wi20] claude-pre-read through symlink: deny -- correct");
eprintln!("[wi20] all symlink-bypass assertions passed");
}
#[test]
fn hook_deadline_terminates_the_process() {
const DEADLINE_MS: u64 = 2500;
const UPPER_BOUND_MS: u64 = 8000;
let bin = mati_bin();
let repo_dir = TempDir::new().expect("create repo dir");
let home_dir = TempDir::new().expect("create home dir");
let mut child = Command::new(&bin)
.args(["hook-decide", "claude-pre-read"])
.current_dir(repo_dir.path())
.env("HOME", home_dir.path())
.env("MATI_HOME", home_dir.path())
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn mati hook-decide");
let mut stdin = child.stdin.take().expect("child stdin");
stdin
.write_all(br#"{"tool_name":"Read","tool_input":{"file_path":"src/main.rs"}}"#)
.expect("write hook payload");
stdin.flush().expect("flush hook payload");
let start = std::time::Instant::now();
let status = loop {
if let Some(status) = child.try_wait().expect("poll child") {
break status;
}
if start.elapsed() > Duration::from_millis(UPPER_BOUND_MS) {
let _ = child.kill();
let _ = child.wait();
panic!(
"hook-decide did not exit within {UPPER_BOUND_MS}ms with stdin held open. \
The internal deadline decided but the process kept running until EOF — \
the failure HookRunOutcome::Terminate fixes."
);
}
std::thread::sleep(Duration::from_millis(25));
};
let elapsed = start.elapsed();
drop(stdin);
assert!(
elapsed >= Duration::from_millis(DEADLINE_MS - 500),
"exited after {elapsed:?}, before the {DEADLINE_MS}ms deadline could fire — \
the process died early rather than failing open on schedule"
);
assert_eq!(
status.code(),
Some(0),
"the deadline must fail open: exit 0 is allow for both hook protocols, \
and exit 2 would turn a timeout into a deny"
);
let mut stdout = String::new();
if let Some(mut out) = child.stdout.take() {
use std::io::Read as _;
out.read_to_string(&mut stdout).expect("read child stdout");
}
let json: serde_json::Value =
serde_json::from_str(stdout.trim()).unwrap_or(serde_json::json!({}));
assert_eq!(
json.pointer("/hookSpecificOutput/permissionDecision")
.and_then(|v| v.as_str()),
Some("allow"),
"the deadline must emit its allow before exiting, not just exit.\nstdout: {stdout}"
);
eprintln!("[deadline] exited at {elapsed:?} with stdin still open -- correct");
}
#[cfg(unix)]
#[test]
fn session_harvest_deadline_terminates_the_process() {
const DEADLINE_MS: u64 = 2500;
const UPPER_BOUND_MS: u64 = 7000;
const SCAFFOLD_CEILING_MS: u64 = 4000;
let bin = mati_bin();
let (repo_dir, home_dir) = setup_repo();
let repo = repo_dir.path();
let home_guard = ShortHome::new("sh");
let mati_home = home_guard.path().to_path_buf();
let init = run_with_mati_home(
&bin,
repo,
home_dir.path(),
&mati_home,
&["init", "--no-hooks"],
);
assert_eq!(init.code, 0, "mati init failed: {}", init.stderr);
let project_root = std::fs::read_dir(&mati_home)
.expect("read MATI_HOME")
.filter_map(Result::ok)
.map(|entry| entry.path())
.find(|path| path.is_dir())
.expect("init did not create a project store");
let socket_path = project_root.join("mati.sock");
let listener =
std::os::unix::net::UnixListener::bind(&socket_path).expect("bind stalled daemon socket");
let accepted = Arc::new(AtomicBool::new(false));
let stop_listener = Arc::new(AtomicBool::new(false));
let accepted_for_thread = Arc::clone(&accepted);
let stop_for_thread = Arc::clone(&stop_listener);
let listener_thread = thread::spawn(move || {
listener
.set_nonblocking(true)
.expect("set listener nonblocking");
while !stop_for_thread.load(Ordering::Acquire) {
match listener.accept() {
Ok((_stream, _)) => {
accepted_for_thread.store(true, Ordering::Release);
while !stop_for_thread.load(Ordering::Acquire) {
thread::sleep(Duration::from_millis(25));
}
return;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("stalled daemon listener failed: {error}"),
}
}
});
let start = std::time::Instant::now();
let mut child = Command::new(&bin)
.args(["session-harvest"])
.current_dir(repo)
.env("HOME", home_dir.path())
.env("MATI_HOME", &mati_home)
.env("NO_COLOR", "1")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn mati session-harvest");
let status = loop {
if let Some(status) = child.try_wait().expect("poll session-harvest") {
break status;
}
if start.elapsed() > Duration::from_millis(UPPER_BOUND_MS) {
let _ = child.kill();
let _ = child.wait();
stop_listener.store(true, Ordering::Release);
let _ = listener_thread.join();
panic!(
"session-harvest did not exit within {UPPER_BOUND_MS}ms; stderr: {}",
child
.stderr
.take()
.and_then(|mut stderr| {
let mut text = String::new();
std::io::Read::read_to_string(&mut stderr, &mut text).ok()?;
Some(text)
})
.unwrap_or_default()
);
}
thread::sleep(Duration::from_millis(25));
};
let elapsed = start.elapsed();
stop_listener.store(true, Ordering::Release);
listener_thread.join().expect("listener thread");
assert!(
accepted.load(Ordering::Acquire),
"the child never reached the stalled socket"
);
assert!(
elapsed >= Duration::from_millis(DEADLINE_MS - 500),
"exited after {elapsed:?}, before the internal deadline could fire"
);
assert!(
elapsed < Duration::from_millis(SCAFFOLD_CEILING_MS),
"took {elapsed:?}, at or past the {SCAFFOLD_CEILING_MS}ms scaffold ceiling — \
the host would kill the process before the deadline could fail open, \
so no fail_open.log entry would ever be written"
);
assert_eq!(status.code(), Some(0), "timeout must fail open");
let log = std::fs::read_to_string(mati_home.join("fail_open.log"))
.expect("SessionEnd timeout did not write fail_open.log");
assert!(
log.lines().any(|line| {
line.contains("hook=session-end")
&& line.contains("file=<session-harvest>")
&& line.contains("session harvest exceeded internal deadline")
}),
"missing SessionEnd timeout evidence in fail_open.log: {log}"
);
eprintln!("[session-harvest deadline] exited at {elapsed:?} with daemon response withheld");
}
#[cfg(unix)]
#[test]
fn blocking_lifecycle_hooks_terminate_and_log_against_stalled_daemon() {
let cases = [
(
"session-flush",
"session-flush",
"<session-flush>",
"session flush exceeded internal deadline",
5000_u64,
7000_u64,
),
(
"session-clear-consults",
"post-compact",
"<session-clear-consults>",
"post-compact receipt cleanup exceeded internal deadline",
3000_u64,
4500_u64,
),
(
"subagent-context",
"subagent-start",
"<subagent-context>",
"subagent context exceeded internal deadline",
3000_u64,
4500_u64,
),
];
for (command, hook, rel_path, reason, deadline_ms, upper_bound_ms) in cases {
let bin = mati_bin();
let (repo_dir, home_dir) = setup_repo();
let repo = repo_dir.path();
let home_guard = ShortHome::new("bl");
let mati_home = home_guard.path().to_path_buf();
let init = run_with_mati_home(
&bin,
repo,
home_dir.path(),
&mati_home,
&["init", "--no-hooks"],
);
assert_eq!(init.code, 0, "mati init failed: {}", init.stderr);
let project_root = std::fs::read_dir(&mati_home)
.expect("read MATI_HOME")
.filter_map(Result::ok)
.map(|entry| entry.path())
.find(|path| path.is_dir())
.expect("init did not create a project store");
let socket_path = project_root.join("mati.sock");
let listener = std::os::unix::net::UnixListener::bind(&socket_path)
.expect("bind stalled daemon socket");
let accepted = Arc::new(AtomicBool::new(false));
let stop_listener = Arc::new(AtomicBool::new(false));
let accepted_for_thread = Arc::clone(&accepted);
let stop_for_thread = Arc::clone(&stop_listener);
let listener_thread = thread::spawn(move || {
listener
.set_nonblocking(true)
.expect("set listener nonblocking");
while !stop_for_thread.load(Ordering::Acquire) {
match listener.accept() {
Ok((_stream, _)) => {
accepted_for_thread.store(true, Ordering::Release);
while !stop_for_thread.load(Ordering::Acquire) {
thread::sleep(Duration::from_millis(25));
}
return;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("stalled daemon listener failed: {error}"),
}
}
});
let start = std::time::Instant::now();
let mut child = Command::new(&bin)
.args([command])
.current_dir(repo)
.env("HOME", home_dir.path())
.env("MATI_HOME", &mati_home)
.env("MATI_DISABLE_AUTO_SPAWN", "1")
.env("NO_COLOR", "1")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn lifecycle hook");
let status = loop {
if let Some(status) = child.try_wait().expect("poll lifecycle hook") {
break status;
}
if start.elapsed() > Duration::from_millis(upper_bound_ms) {
let _ = child.kill();
let output = child
.wait_with_output()
.expect("wait for timed-out lifecycle hook");
stop_listener.store(true, Ordering::Release);
let _ = listener_thread.join();
panic!(
"{command} did not exit within {upper_bound_ms}ms; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
thread::sleep(Duration::from_millis(25));
};
let elapsed = start.elapsed();
child
.wait_with_output()
.expect("collect lifecycle hook output");
stop_listener.store(true, Ordering::Release);
listener_thread.join().expect("listener thread");
assert!(
accepted.load(Ordering::Acquire),
"{command} never reached the stalled socket"
);
assert!(
elapsed >= Duration::from_millis(deadline_ms - 500),
"{command} exited after {elapsed:?}, before its internal deadline could fire"
);
assert!(
elapsed < Duration::from_millis(upper_bound_ms),
"{command} exceeded its fail-open bound: {elapsed:?}"
);
assert_eq!(status.code(), Some(0), "{command} timeout must fail open");
let log =
std::fs::read_to_string(mati_home.join("fail_open.log")).unwrap_or_else(|error| {
panic!("{command} timeout did not write fail_open.log: {error}")
});
assert!(
log.lines().any(|line| {
line.contains(&format!("hook={hook}"))
&& line.contains(&format!("file={rel_path}"))
&& line.contains(reason)
}),
"missing {hook} timeout evidence in fail_open.log: {log}"
);
eprintln!("[{hook} deadline] exited at {elapsed:?} with daemon response withheld");
}
}