use std::path::Path;
use std::process::{Command, Stdio};
struct Out {
stdout: String,
stderr: String,
code: i32,
}
fn termaxa(home: &Path, cwd: &Path, args: &[&str], stdin: &str) -> Out {
use std::io::Write as _;
let mut child = Command::new(env!("CARGO_BIN_EXE_termaxa"))
.args(args)
.current_dir(cwd)
.env("TERMAXA_HOME", home)
.env("NO_COLOR", "1")
.env_remove("TERMAXA_HOOK_DEBUG")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary must be runnable");
child
.stdin
.take()
.expect("stdin is piped")
.write_all(stdin.as_bytes())
.expect("stdin must be writable");
let out = child.wait_with_output().expect("the binary must finish");
Out {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
code: out.status.code().unwrap_or(-1),
}
}
fn scratch(tag: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("termaxa-codex-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("home")).expect("scratch root must be creatable");
base
}
fn project(root: &Path) -> std::path::PathBuf {
let proj = root.join("proj");
std::fs::create_dir_all(proj.join(".termaxa")).expect("project dir must be creatable");
std::fs::write(
proj.join(".termaxa").join("policy.yaml"),
"version: 1\ndefault: ask\nrules:\n - match: \"rm -rf *\"\n action: deny\n - match: \"echo *\"\n action: allow\n",
)
.expect("policy must be writable");
proj
}
fn codex_payload(cwd: &Path, command: &str) -> String {
let transcript = Path::new(".codex")
.join("sessions")
.join("2026")
.join("09")
.join("06")
.join("rollout-2026-09-06T00-17-37-01a072e5-c9e6-7a42-97e9-df793d788aa9.jsonl");
serde_json::json!({
"session_id": "01a072e5-c9e6-7a42-97e9-df793d788aa9",
"turn_id": "01a07361-cac3-7c61-ae64-517f324918d4",
"transcript_path": transcript.display().to_string(),
"cwd": cwd.display().to_string(),
"hook_event_name": "PreToolUse",
"model": "gpt-5.6-terra",
"permission_mode": "default",
"tool_name": "Bash",
"tool_input": { "command": command },
"tool_use_id": "exec-e8bb082d-094f-4e7b-b222-f852e21a06fd"
})
.to_string()
}
fn copilot_payload(cwd: &Path, command: &str) -> String {
serde_json::json!({
"sessionId": "85a696df-84c3-4b99-95cd-4e076f2529d9",
"timestamp": 1788992290306u64,
"cwd": cwd.display().to_string(),
"toolName": "powershell",
"toolArgs": {
"command": command,
"description": "captured shape",
"mode": "sync",
"initial_wait": 10
}
})
.to_string()
}
fn claude_payload(cwd: &Path, command: &str) -> String {
serde_json::json!({
"session_id": "s-claude",
"cwd": cwd.display().to_string(),
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": command }
})
.to_string()
}
#[test]
fn a_matched_allow_is_silence_for_codex_and_an_explicit_allow_for_claude_code() {
let tmp = scratch("allow");
let (home, proj) = (tmp.join("home"), project(&tmp));
let out = termaxa(&home, &proj, &["hook"], &codex_payload(&proj, "echo hi"));
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.trim().is_empty(),
"Codex rejects an explicit allow; silence is the allow: {}",
out.stdout
);
let out = termaxa(&home, &proj, &["hook"], &claude_payload(&proj, "echo hi"));
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("\"permissionDecision\":\"allow\""),
"Claude Code still gets the explicit allow for a matched rule: {}",
out.stdout
);
}
#[test]
fn an_ask_is_rendered_as_a_deny_that_says_the_gate_asked() {
let tmp = scratch("ask");
let (home, proj) = (tmp.join("home"), project(&tmp));
let out = termaxa(&home, &proj, &["hook"], &codex_payload(&proj, "make lint"));
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("\"permissionDecision\":\"deny\""),
"{}",
out.stdout
);
assert!(
out.stdout.contains("asks:"),
"the reason says the gate asked: {}",
out.stdout
);
assert!(
out.stdout.contains("allow rule"),
"the reason says how to proceed: {}",
out.stdout
);
let log = termaxa(&home, &proj, &["log"], "");
assert!(log.stdout.contains("make lint"), "{}", log.stdout);
assert!(
log.stdout
.contains("no rule matched; policy default is `ask`"),
"{}",
log.stdout
);
}
#[test]
fn a_deny_exits_zero_for_codex_and_two_for_claude_code() {
let tmp = scratch("deny");
let (home, proj) = (tmp.join("home"), project(&tmp));
std::fs::create_dir_all(proj.join("scratch")).unwrap();
std::fs::write(proj.join("scratch").join("a.txt"), "x").unwrap();
let out = termaxa(
&home,
&proj,
&["hook"],
&codex_payload(&proj, "rm -rf ./scratch"),
);
assert_eq!(
out.code, 0,
"the wrapper Codex runs hooks through flattens a non-zero exit to 1, which Codex reads as a failed hook: {}",
out.stderr
);
assert!(
out.stdout.contains("\"permissionDecision\":\"deny\""),
"{}",
out.stdout
);
assert!(
proj.join("scratch").join("a.txt").exists(),
"a hook decides; it does not execute"
);
let out = termaxa(
&home,
&proj,
&["hook"],
&claude_payload(&proj, "rm -rf ./scratch"),
);
assert_eq!(
out.code, 2,
"Claude Code keeps the belt under the JSON: {}",
out.stderr
);
assert!(
out.stdout.contains("\"permissionDecision\":\"deny\""),
"{}",
out.stdout
);
}
#[test]
fn a_copilot_deny_exits_zero_in_copilots_own_shape() {
let tmp = scratch("copilot-deny");
let (home, proj) = (tmp.join("home"), project(&tmp));
std::fs::create_dir_all(proj.join("scratch")).unwrap();
std::fs::write(proj.join("scratch").join("a.txt"), "x").unwrap();
let out = termaxa(
&home,
&proj,
&["hook"],
&copilot_payload(&proj, "rm -rf ./scratch"),
);
assert_eq!(
out.code, 0,
"Copilot reads a non-zero exit as a hook error, not a deny: {}",
out.stderr
);
assert!(
out.stdout.contains("\"permissionDecision\":\"deny\"")
&& !out.stdout.contains("hookSpecificOutput"),
"{}",
out.stdout
);
assert!(proj.join("scratch").join("a.txt").exists());
}
#[test]
fn copilots_powershell_tool_is_read_and_an_allow_is_answered() {
let tmp = scratch("copilot-allow");
let (home, proj) = (tmp.join("home"), project(&tmp));
let out = termaxa(&home, &proj, &["hook"], &copilot_payload(&proj, "echo hi"));
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("\"permissionDecision\":\"allow\""),
"an explicit allow for a matched rule: {}",
out.stdout
);
}
#[test]
fn a_copilot_deny_through_repo_settings_exits_zero_in_claudes_shape() {
let tmp = scratch("copilot-repo-settings");
let (home, proj) = (tmp.join("home"), project(&tmp));
let payload = serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "a98e1665-3dab-4281-8c73-25659c0fbab7",
"timestamp": "2026-09-09T22:52:28.560Z",
"cwd": proj.display().to_string(),
"tool_name": "Bash",
"tool_input": { "command": "rm -rf ./scratch", "description": "Delete the scratch directory" }
})
.to_string();
let out = termaxa(&home, &proj, &["hook"], &payload);
assert_eq!(
out.code, 0,
"Copilot reads exit 2 as an error on this path too: {}",
out.stderr
);
assert!(
out.stdout.contains("hookSpecificOutput")
&& out.stdout.contains("\"permissionDecision\":\"deny\""),
"{}",
out.stdout
);
let log = termaxa(&home, &proj, &["log"], "");
assert!(log.stdout.contains("rm -rf ./scratch"), "{}", log.stdout);
}
#[test]
fn init_writes_the_hooks_file_codex_reads() {
let tmp = scratch("init");
let home = tmp.join("home");
let proj = tmp.join("proj");
std::fs::create_dir_all(&proj).unwrap();
let out = termaxa(&home, &proj, &["init", "--codex"], "");
assert_eq!(out.code, 0, "{}{}", out.stdout, out.stderr);
let path = proj.join(".codex").join("hooks.json");
let bytes = std::fs::read(&path).expect("init --codex writes .codex/hooks.json");
assert_eq!(
bytes[0], b'{',
"no byte-order mark: Codex refuses a file that starts with one"
);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let group = &v["hooks"]["PreToolUse"][0];
assert_eq!(group["matcher"], "Bash", "{v}");
let hook = &group["hooks"][0];
assert_eq!(hook["type"], "command", "{v}");
assert_eq!(hook["command"], "termaxa hook", "{v}");
assert!(hook["timeout"].is_number(), "timeout is in seconds: {v}");
assert!(
v.get("version").is_none(),
"the flat pre-Sep-2026 shape with a top-level version was never one Codex read: {v}"
);
}