use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
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("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("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),
}
}
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();
}
}
#[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)
.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");
}