#![cfg(unix)]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
struct Output {
stdout: String,
stderr: String,
code: i32,
}
fn termaxa(home: &Path, cwd: &Path, args: &[&str], stdin: &str) -> Output {
run_termaxa(home, cwd, args, stdin, None)
}
fn termaxa_tty(home: &Path, cwd: &Path, args: &[&str], answer: &str) -> Output {
use std::os::unix::io::FromRawFd;
let (mut master, mut slave) = (0i32, 0i32);
let rc = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
assert_eq!(rc, 0, "openpty must succeed on a unix test machine");
let slave_file = unsafe { std::fs::File::from_raw_fd(slave) };
let mut master_file = unsafe { std::fs::File::from_raw_fd(master) };
let mut cmd = Command::new(env!("CARGO_BIN_EXE_termaxa"));
cmd.args(args)
.current_dir(cwd)
.env("TERMAXA_HOME", home)
.env("NO_COLOR", "1")
.stdin(Stdio::from(slave_file))
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = cmd.spawn().expect("the binary must be runnable");
drop(cmd); let _ = master_file.write_all(answer.as_bytes());
let out = child.wait_with_output().expect("the child must exit");
drop(master_file);
Output {
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 run_termaxa(
home: &Path,
cwd: &Path,
args: &[&str],
stdin: &str,
extra_path: Option<&Path>,
) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_termaxa"));
cmd.args(args)
.current_dir(cwd)
.env("TERMAXA_HOME", home)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = extra_path {
let inherited = std::env::var("PATH").unwrap_or_default();
cmd.env("PATH", format!("{}:{}", dir.display(), inherited));
}
let mut child = cmd.spawn().expect("the binary must be runnable");
let _ = child
.stdin
.take()
.expect("stdin must be piped")
.write_all(stdin.as_bytes());
let out = child.wait_with_output().expect("the child must exit");
Output {
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 termaxa_within(home: &Path, cwd: &Path, args: &[&str], stdin: &str, secs: u64) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_termaxa"));
cmd.args(args)
.current_dir(cwd)
.env("TERMAXA_HOME", home)
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("the binary must be runnable");
{
let mut pipe = child.stdin.take().expect("stdin must be piped");
let _ = pipe.write_all(stdin.as_bytes());
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
let mut timed_out = false;
while child
.try_wait()
.expect("the child must be pollable")
.is_none()
{
if std::time::Instant::now() > deadline {
let _ = child.kill();
timed_out = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let out = child.wait_with_output().expect("the child must exit");
assert!(
!timed_out,
"termaxa {args:?} did not finish within {secs}s — the wrapper is recursing again (#65)"
);
Output {
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) -> PathBuf {
let base = std::env::temp_dir().join(format!("termaxa-cli-{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) -> 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: \"sh*\"\n action: allow\n - match: \"exit*\"\n action: allow\n",
)
.expect("policy must be writable");
proj
}
fn project_gating_terraform(root: &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: \"terraform destroy*\"\n action: deny\n",
)
.expect("policy must be writable");
proj
}
fn stub_terraform(bin_dir: &Path, marker: &Path) {
std::fs::create_dir_all(bin_dir).expect("stub dir must be creatable");
let path = bin_dir.join("terraform");
std::fs::write(
&path,
format!(
"#!/bin/sh\ntouch '{}'\necho 'Plan: 0 to add, 0 to change, 1 to destroy.'\n",
marker.display()
),
)
.expect("stub must be writable");
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("stub must be executable");
}
#[test]
fn a_denied_command_never_causes_the_preview_to_run_anything() {
let tmp = scratch("deny-inert");
let (home, proj) = (tmp.join("home"), project_gating_terraform(&tmp));
let bin_dir = tmp.join("bin");
let marker = tmp.join("plan-was-run");
stub_terraform(&bin_dir, &marker);
let denied = run_termaxa(
&home,
&proj,
&["check", "terraform destroy -auto-approve"],
"",
Some(&bin_dir),
);
assert!(
denied.stdout.contains("deny"),
"the fixture must actually deny: {:?}",
denied.stdout
);
assert!(
!marker.exists(),
"a denied command must not cause a subprocess"
);
run_termaxa(
&home,
&proj,
&["check", "terraform apply"],
"",
Some(&bin_dir),
);
assert!(
marker.exists(),
"an undenied command should still be previewed live"
);
}
#[test]
fn log_filters_by_decision_and_by_source() {
let tmp = scratch("log-filter");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-fixture"],
"",
);
termaxa(&home, &proj, &["check", "cat notes.txt"], "");
termaxa(&home, &proj, &["run", "--", "sh", "-c", "exit 0"], "");
let all = termaxa(&home, &proj, &["log", "-n", "50"], "").stdout;
assert!(all.contains("rm -rf /"), "{all:?}");
assert!(all.contains("cat notes.txt"), "{all:?}");
let denied = termaxa(&home, &proj, &["log", "--decision", "deny"], "").stdout;
assert!(denied.contains("rm -rf /"), "{denied:?}");
assert!(
!denied.contains("cat notes.txt"),
"a filter that keeps everything is not a filter: {denied:?}"
);
let from_run = termaxa(&home, &proj, &["log", "--source", "run"], "").stdout;
assert!(from_run.contains("exit 0"), "{from_run:?}");
assert!(
!from_run.contains("cat notes.txt"),
"`check` entries are not `run` entries: {from_run:?}"
);
}
#[test]
fn the_log_says_what_became_of_each_command() {
let tmp = scratch("log-outcome");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(&home, &proj, &["run", "--", "sh", "-c", "exit 3"], "");
termaxa_tty(&home, &proj, &["run", "--", "echo", "yes-please"], "y\n");
termaxa(&home, &proj, &["run", "--", "echo", "no-thanks"], "n\n");
let log = termaxa(&home, &proj, &["log", "-n", "50"], "").stdout;
assert!(log.contains("→ exit 3"), "{log:?}");
assert!(log.contains("→ approved, exit 0"), "{log:?}");
assert!(log.contains("→ not run"), "{log:?}");
}
#[test]
fn stats_ranks_the_commands_that_were_denied() {
let tmp = scratch("stats");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-fixture"],
"",
);
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-fixture"],
"",
);
termaxa(
&home,
&proj,
&["check", "rm -rf /nonexistent-tmx-other"],
"",
);
termaxa(&home, &proj, &["check", "cat notes.txt"], "");
let all = termaxa(&home, &proj, &["log", "-n", "50"], "").stdout;
assert!(all.contains("cat notes.txt"), "{all:?}");
let stats = termaxa(&home, &proj, &["stats"], "").stdout;
assert!(stats.contains("top denied"), "{stats:?}");
assert!(
stats.contains("2× rm -rf /"),
"the same denial twice is a count of two: {stats:?}"
);
assert!(
!stats.contains("cat notes.txt"),
"what was allowed is not a denial: {stats:?}"
);
}
#[test]
fn stats_stays_quiet_about_denials_when_there_are_none() {
let tmp = scratch("stats-quiet");
let (home, proj) = (tmp.join("home"), project(&tmp));
termaxa(&home, &proj, &["check", "cat notes.txt"], "");
let stats = termaxa(&home, &proj, &["stats"], "").stdout;
assert!(
!stats.contains("top denied"),
"an empty ranking is a heading with nothing under it: {stats:?}"
);
}
fn take_a_backup(home: &Path, proj: &Path) -> String {
std::fs::write(proj.join("doomed.txt"), "precious\n").expect("file must be writable");
let deleted = termaxa_tty(home, proj, &["run", "--", "rm", "doomed.txt"], "y\n");
assert_eq!(
deleted.code, 0,
"the delete itself must succeed.\nstdout: {}\nstderr: {}",
deleted.stdout, deleted.stderr
);
let listed = termaxa(home, proj, &["backups"], "").stdout;
let id = listed
.lines()
.next()
.unwrap_or_default()
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
assert!(
!id.is_empty() && !listed.contains("no backups yet"),
"the delete should have been insured.\nbackups: {listed:?}\n\
run stdout: {}\nrun stderr: {}",
deleted.stdout,
deleted.stderr
);
id
}
#[test]
fn rollback_refuses_an_id_it_does_not_have() {
let tmp = scratch("rollback-unknown");
let (home, proj) = (tmp.join("home"), project(&tmp));
take_a_backup(&home, &proj);
let out = termaxa(&home, &proj, &["rollback", "definitely-not-an-id"], "y\n");
assert_eq!(
out.code,
2,
"an unknown id is an error: {out:?}",
out = out.stderr
);
assert!(
out.stderr.contains("no backup with id"),
"and says so: {:?}",
out.stderr
);
}
#[test]
fn rollback_restores_nothing_unless_it_is_confirmed() {
let tmp = scratch("rollback-declined");
let (home, proj) = (tmp.join("home"), project(&tmp));
let id = take_a_backup(&home, &proj);
let out = termaxa(&home, &proj, &["rollback", &id], "n\n");
assert_eq!(out.code, 1, "a decline is not a success");
assert!(out.stderr.contains("rollback declined"), "{:?}", out.stderr);
assert!(
!proj.join("doomed.txt").exists(),
"declining must leave the file deleted, not restore it"
);
let out = termaxa(&home, &proj, &["rollback", &id], "y\n");
assert_eq!(
out.code, 0,
"a confirmed rollback succeeds: {:?}",
out.stderr
);
assert!(
proj.join("doomed.txt").exists(),
"the insured file must come back"
);
assert!(
out.stdout.contains("✓ 1 path(s) restored"),
"the count is the report of what happened: {:?}",
out.stdout
);
}
#[test]
fn wrap_executes_what_it_allows() {
let tmp = scratch("wrap-allow");
let (home, proj) = (tmp.join("home"), project(&tmp));
let out = termaxa_within(&home, &proj, &["wrap", "--", "sh", "-c", "exit 3"], "", 30);
assert_eq!(
out.code, 3,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(
!out.stderr.contains("not interactive"),
"the gate must not ask twice: {}",
out.stderr
);
let out = termaxa_within(
&home,
&proj,
&["wrap", "--", "sh", "-c", "sh -c 'exit 5'"],
"",
30,
);
assert_eq!(
out.code, 5,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
}
#[test]
fn wrap_refuses_an_ask_when_nobody_is_at_a_terminal() {
let tmp = scratch("wrap-unattended");
let (home, proj) = (tmp.join("home"), project(&tmp));
std::fs::write(proj.join("doomed.txt"), "precious").unwrap();
let started = std::time::Instant::now();
let out = termaxa_within(
&home,
&proj,
&["wrap", "--", "sh", "-c", "rm -rf ./doomed.txt"],
"y\n",
30,
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"an unanswerable ask must not wait for an answer"
);
assert!(
proj.join("doomed.txt").exists(),
"a `y` on a pipe is not a person: the delete must not have run\nstdout: {}\nstderr: {}",
out.stdout,
out.stderr
);
assert!(
out.stderr.contains("needs a human at a terminal"),
"the refusal says why: {}",
out.stderr
);
assert!(
!out.stdout.contains("Proceed?") && !out.stderr.contains("Proceed?"),
"no prompt is printed where nobody can answer it"
);
}
#[test]
fn wrap_reads_a_c_wherever_the_shell_would() {
let tmp = scratch("wrap-cluster");
let (home, proj) = (tmp.join("home"), project(&tmp));
for spelling in [
vec!["-c"],
vec!["-lc"],
vec!["-e", "-c"],
vec!["--norc", "-c"],
vec!["-o", "pipefail", "-c"],
] {
let mut args = vec!["wrap", "--", "bash"];
args.extend(spelling.iter().copied());
args.push("exit 3");
let out = termaxa_within(&home, &proj, &args, "", 30);
let asks = out.stdout.matches("decision").count() + out.stderr.matches("decision").count();
assert_eq!(
out.code, 3,
"{spelling:?}: stdout {}\nstderr {}",
out.stdout, out.stderr
);
assert_eq!(
asks, 1,
"{spelling:?}: gated exactly once\n{}{}",
out.stdout, out.stderr
);
}
std::fs::write(proj.join("s.sh"), "exit 7\n").unwrap();
let out = termaxa_within(&home, &proj, &["wrap", "--", "sh", "s.sh"], "", 30);
assert_eq!(
out.code, 7,
"a script file is not read: {}{}",
out.stdout, out.stderr
);
assert!(
!out.stdout.contains("decision") && !out.stderr.contains("decision"),
"no gate for a script file: {}{}",
out.stdout,
out.stderr
);
}
#[test]
fn wrap_hands_the_agent_the_shim_for_its_own_shell_by_name() {
let tmp = scratch("wrap-agent-shell");
let (home, proj) = (tmp.join("home"), project(&tmp));
std::fs::write(
proj.join(".termaxa").join("policy.yaml"),
"version: 1\ndefault: ask\nrules:\n - match: \"sh*\"\n action: allow\n \
- match: \"echo*\"\n action: allow\n",
)
.unwrap();
let out = termaxa_within(
&home,
&proj,
&[
"wrap",
"--",
"sh",
"-c",
"echo shell=$SHELL claude=$CLAUDE_CODE_SHELL",
],
"",
30,
);
let shims = home.join("shims");
let shim = if shims.join("zsh").is_file() {
shims.join("zsh")
} else {
shims.join("bash")
};
let want = format!("shell={} claude={}", shim.display(), shim.display());
assert!(
out.stdout.contains(&want),
"want `{want}`\nstdout: {}\nstderr: {}",
out.stdout,
out.stderr
);
assert!(
!out.stderr.contains("CLAUDE_CODE_SHELL was"),
"nothing to override, nothing said: {}",
out.stderr
);
let run = |value: &str| {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_termaxa"));
let out = cmd
.args(["wrap", "--", "sh", "-c", "echo claude=$CLAUDE_CODE_SHELL"])
.current_dir(&proj)
.env("TERMAXA_HOME", &home)
.env("NO_COLOR", "1")
.env("CLAUDE_CODE_SHELL", value)
.stdin(Stdio::null())
.output()
.expect("the binary must be runnable");
(
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
};
let (stdout, stderr) = run("/opt/homebrew/bin/bash");
assert!(
stdout.contains(&format!("claude={}", shim.display())),
"an outside value is overridden: {stdout}"
);
assert!(
stderr.contains("CLAUDE_CODE_SHELL was /opt/homebrew/bin/bash; set to"),
"and the override is said: {stderr}"
);
let own = shims.join("sh");
let (stdout, stderr) = run(&own.display().to_string());
assert!(
stdout.contains(&format!("claude={}", own.display())),
"the operator's own shim is kept: {stdout}"
);
assert!(
!stderr.contains("CLAUDE_CODE_SHELL was"),
"and nothing is said: {stderr}"
);
}
#[test]
fn an_unrecognised_shell_event_passes_through_by_default_and_is_refused_by_policy() {
let tmp = scratch("unrecognised");
let (home, proj) = (tmp.join("home"), project(&tmp));
let renamed = format!(
r#"{{"cwd": {}, "event": "beforeShellRun", "tool": {{"name": "shell", "args": {{"command": "rm -rf /"}}}}}}"#,
serde_json::to_string(&proj.display().to_string()).unwrap()
);
let unrelated = format!(
r#"{{"cwd": {}, "event": "fileRead", "path": "src/main.rs"}}"#,
serde_json::to_string(&proj.display().to_string()).unwrap()
);
let readable = format!(
r#"{{"cwd": {}, "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {{"command": "exit 3"}}}}"#,
serde_json::to_string(&proj.display().to_string()).unwrap()
);
let out = termaxa(&home, &proj, &["hook"], &renamed);
assert_eq!(
out.code, 0,
"default: pass through\nstdout {}\nstderr {}",
out.stdout, out.stderr
);
assert!(
out.stdout.trim().is_empty(),
"default: no decision rendered: {}",
out.stdout
);
let policy = proj.join(".termaxa").join("policy.yaml");
let mut text = std::fs::read_to_string(&policy).unwrap();
text.push_str("unrecognised: deny\n");
std::fs::write(&policy, text).unwrap();
let out = termaxa(&home, &proj, &["hook"], &renamed);
assert_eq!(
out.code, 2,
"deny: refused\nstdout {}\nstderr {}",
out.stdout, out.stderr
);
assert!(
out.stdout.contains("\"deny\""),
"deny rendered: {}",
out.stdout
);
assert!(
out.stdout.contains("not recognised"),
"the reason says why: {}",
out.stdout
);
let out = termaxa(&home, &proj, &["hook"], &unrelated);
assert_eq!(
out.code, 0,
"an unrelated event is not a shell call: {}",
out.stdout
);
assert!(out.stdout.trim().is_empty(), "{}", out.stdout);
let out = termaxa(&home, &proj, &["hook"], &readable);
assert_eq!(
out.code, 0,
"a readable payload is judged as before: {}{}",
out.stdout, out.stderr
);
assert!(
out.stdout.contains("\"allow\""),
"exit* is allowed by the fixture: {}",
out.stdout
);
}
#[cfg(unix)]
#[test]
fn a_failed_backup_proceeds_by_default_and_is_refused_by_policy() {
let tmp = scratch("backup-failure");
let (home, proj) = (tmp.join("home"), project(&tmp));
let make_target = || {
let junk = proj.join("junk");
let _ = std::fs::remove_dir_all(&junk);
std::fs::create_dir_all(&junk).unwrap();
std::fs::write(junk.join("keep.txt"), "x").unwrap();
let sock = std::os::unix::net::UnixListener::bind(junk.join("sock"))
.expect("the fixture needs a socket");
std::mem::forget(sock);
junk
};
let junk = make_target();
let out = termaxa_tty(&home, &proj, &["run", "--", "rm", "-rf", "./junk"], "y\n");
assert!(
!junk.exists(),
"default: the approved delete ran uninsured\n{}{}",
out.stdout,
out.stderr
);
assert!(
out.stderr.contains("proceeding"),
"default: the failure is reported: {}",
out.stderr
);
let policy = proj.join(".termaxa").join("policy.yaml");
let mut text = std::fs::read_to_string(&policy).unwrap();
text.push_str("backup_failure: deny\n");
std::fs::write(&policy, text).unwrap();
let junk = make_target();
let out = termaxa_tty(&home, &proj, &["run", "--", "rm", "-rf", "./junk"], "y\n");
assert!(
junk.exists(),
"deny: the uninsured delete did not run\n{}{}",
out.stdout,
out.stderr
);
assert!(
out.stderr.contains("backup_failure: deny"),
"the refusal names the knob: {}",
out.stderr
);
}