use std::collections::HashMap;
use std::path::PathBuf;
use super::write_json_stdout;
#[allow(clippy::too_many_arguments)]
pub fn run(
json: bool,
reset_bash_safe_mode: bool,
fix: bool,
yes: bool,
simulate_enter: bool,
compat: bool,
bundle: bool,
quick: bool,
) -> i32 {
if reset_bash_safe_mode {
return reset_safe_mode();
}
if simulate_enter {
return run_simulate_enter();
}
if fix {
return run_fix(yes);
}
if compat {
return run_compat(json);
}
if bundle {
return run_bundle(json);
}
if quick {
return run_quick(json);
}
#[cfg(unix)]
if !json && crate::cli::init::detect_shell() == "bash" {
let _ = crate::cli::bash_capability::run_and_cache();
}
let info = gather_info();
if json {
match serde_json::to_string_pretty(&info) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
return 1;
}
}
} else {
print_human(&info);
}
0
}
fn confirm(prompt: &str, yes: bool) -> bool {
crate::cli::confirm(prompt, yes)
}
fn run_fix(yes: bool) -> i32 {
let mut fixed = 0;
let mut failed = 0;
if !hooks_installed() {
println!("Fix: Install shell hooks");
if confirm(" Install hooks?", yes) {
let rc = crate::cli::init::run(None, false);
if rc == 0 {
println!(" Hooks installed.");
fixed += 1;
} else {
eprintln!(" Hook installation failed (exit code {rc}).");
failed += 1;
}
}
}
if hooks_stale() {
println!("Fix: Re-materialize stale hook assets");
if confirm(" Re-materialize hooks?", yes) {
match crate::cli::init::find_hook_dir() {
Some(dir) => {
println!(" Hooks materialized to {}.", dir.display());
fixed += 1;
}
None => {
eprintln!(" Failed to materialize hooks.");
failed += 1;
}
}
}
}
if policy_missing() {
println!("Fix: Create starter policy");
if confirm(" Create .tirith/policy.yaml?", yes) {
match create_default_policy() {
Ok(path) => {
println!(" Created {}", path.display());
fixed += 1;
}
Err(e) => {
eprintln!(" Failed to create policy: {e}");
failed += 1;
}
}
}
}
{
let tools: Vec<_> = detect_ai_tools()
.into_iter()
.filter(|tool| tool.hook_state != HookInstallState::Effective)
.collect();
if !tools.is_empty() {
println!("Fix: Configure tirith for AI coding tools");
for tool in &tools {
let repair = tool.hook_state == HookInstallState::Broken;
let verb = if repair { "Repair" } else { "Configure" };
if confirm(&format!(" {verb} tirith for {}?", tool.name), yes) {
let rc = crate::cli::setup::run(
tool.name,
tool.configured_scope,
false,
false,
false,
repair,
repair,
);
if rc == 0 {
let completed = if repair { "Repaired" } else { "Configured" };
println!(" {completed} {}.", tool.name);
fixed += 1;
} else {
eprintln!(" Failed to {} {}.", verb.to_ascii_lowercase(), tool.name);
failed += 1;
}
}
}
}
}
if let Some(tdb) = gather_threat_db_info() {
if !tdb.installed || tdb.stale || tdb.signature_valid == Some(false) || tdb.error.is_some()
{
let reason = if !tdb.installed {
"not installed"
} else if tdb.signature_valid == Some(false) {
"invalid signature"
} else if tdb.error.is_some() {
"load error"
} else {
"stale"
};
println!("Fix: Download threat DB ({reason})");
if confirm(" Download threat DB?", yes) {
let force = tdb.signature_valid == Some(false) || tdb.error.is_some();
let rc = crate::cli::threatdb_cmd::update(force, false);
if rc == 0 {
println!(" Threat DB downloaded.");
fixed += 1;
} else {
eprintln!(" Threat DB download failed.");
failed += 1;
}
}
}
}
if bash_safe_mode_active() {
println!("Fix: Clear bash safe-mode flag");
if confirm(" Clear safe-mode?", yes) {
if reset_safe_mode() == 0 {
fixed += 1;
}
}
}
let shadows = crate::cli::find_shadow_binaries();
if !shadows.is_empty() {
println!("Manual step: other 'tirith' binaries shadow this one:");
for shadow in &shadows {
println!(" - {shadow}");
}
println!(" Remove them or fix PATH order so this binary wins.");
println!(
" Run `{}` to inspect.",
crate::cli::tirith_path_lookup_command()
);
}
if fixed == 0 && failed == 0 {
if shadows.is_empty() {
println!("tirith: no issues to fix");
} else {
println!("tirith: no auto-fixable issues (see manual steps above)");
}
} else {
println!("tirith: fixed {fixed} issue(s), {failed} failed");
}
if failed > 0 {
1
} else {
0
}
}
fn hooks_installed() -> bool {
let shell = crate::cli::init::detect_shell().to_string();
let (_profile, configured) = check_shell_profile(&shell, "tirith: doctor:");
configured
}
fn hooks_stale() -> bool {
let hook_dir = match crate::cli::init::find_hook_dir_readonly() {
Some(d) => d,
None => return false, };
let data_dir = match tirith_core::policy::data_dir() {
Some(d) => d,
None => return false,
};
if !hook_dir.starts_with(&data_dir) {
return false; }
let version_path = hook_dir.join(".hooks-version");
let current_version = env!("CARGO_PKG_VERSION");
match std::fs::read_to_string(&version_path) {
Ok(v) => v.trim() != current_version,
Err(_) => true, }
}
fn policy_missing() -> bool {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
tirith_core::policy::discover_local_policy_path(cwd.as_deref()).is_none()
}
fn collect_policy_paths(cwd: Option<&str>) -> Vec<String> {
let mut paths: Vec<String> = Vec::new();
if let Some(active) = tirith_core::policy::discover_local_policy_path(cwd) {
paths.push(active.display().to_string());
}
if let Some(config) = tirith_core::policy::config_dir() {
for ext in &["policy.yaml", "policy.yml"] {
let p = config.join(ext);
if p.exists() {
let s = p.display().to_string();
if !paths.contains(&s) {
paths.push(s);
}
break;
}
}
}
if let Ok(root) = std::env::var("TIRITH_POLICY_ROOT") {
let tirith_dir = PathBuf::from(&root).join(".tirith");
for ext in &["policy.yaml", "policy.yml"] {
let p = tirith_dir.join(ext);
if p.exists() {
let s = p.display().to_string();
if !paths.contains(&s) {
paths.push(s);
}
break;
}
}
}
paths
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HookInstallState {
Absent,
Broken,
Effective,
}
#[derive(Debug, PartialEq, Eq)]
struct DetectedTool {
name: &'static str,
configured_scope: Option<&'static str>,
hook_state: HookInstallState,
}
type BlockingHookCandidate = (&'static str, std::path::PathBuf, std::path::PathBuf);
type BlockingToolCandidates = (&'static str, Vec<BlockingHookCandidate>);
fn detect_ai_tools() -> Vec<DetectedTool> {
let home = match home::home_dir() {
Some(h) => h,
None => return Vec::new(),
};
let cwd = std::env::current_dir().ok();
detect_ai_tools_with(&home, cwd.as_deref())
}
fn detect_ai_tools_with(
home: &std::path::Path,
cwd: Option<&std::path::Path>,
) -> Vec<DetectedTool> {
let mut tools = Vec::new();
if home.join(".claude").exists() {
tools.push(DetectedTool {
name: "claude-code",
configured_scope: None,
hook_state: HookInstallState::Absent,
});
}
if home.join(".cursor").exists() {
tools.push(DetectedTool {
name: "cursor",
configured_scope: None,
hook_state: HookInstallState::Absent,
});
}
if home.join(".vscode").exists() {
tools.push(DetectedTool {
name: "vscode",
configured_scope: None,
hook_state: HookInstallState::Absent,
});
}
if home.join(".codeium").exists() {
tools.push(DetectedTool {
name: "windsurf",
configured_scope: None,
hook_state: HookInstallState::Absent,
});
}
if tirith_core::policy::find_repo_root(None)
.map(|r| r.join(".github/hooks/tirith-security.json").exists())
.unwrap_or(false)
{
tools.push(DetectedTool {
name: "copilot-cli",
configured_scope: Some("project"),
hook_state: HookInstallState::Effective,
});
}
#[cfg(unix)]
let openhands_work_dir = match std::env::var_os("OPENHANDS_WORK_DIR") {
Some(value) if !value.is_empty() => Some(std::path::PathBuf::from(value)),
_ => cwd.map(std::path::Path::to_path_buf),
};
#[cfg(unix)]
let grok_repo_root = tirith_core::policy::find_repo_root(None);
let pi_user = crate::cli::setup::pi_cli_user_guard_path(home).ok();
let prime_user = crate::cli::setup::prime_agent_user_guard_path(home).ok();
let omp_user = crate::cli::setup::omp_user_guard_path(home).ok();
let cline_user = cline_hook_artifact(home);
let mut blocking_candidates: Vec<BlockingToolCandidates> = Vec::new();
let mut pi = Vec::new();
if let Some(cwd) = cwd {
pi.push((
"project",
cwd.join(".pi/extensions/tirith-guard.ts"),
cwd.join(".pi"),
));
}
if let Some(path) = pi_user {
let marker = path
.parent()
.and_then(std::path::Path::parent)
.unwrap_or(home)
.to_path_buf();
pi.push(("user", path, marker));
}
blocking_candidates.push(("pi-cli", pi));
if let Some(path) = prime_user {
let marker = path
.parent()
.and_then(std::path::Path::parent)
.unwrap_or(home)
.to_path_buf();
blocking_candidates.push(("prime-agent", vec![("user", path, marker)]));
}
if let Some(path) = omp_user {
let marker = path.ancestors().nth(3).unwrap_or(home).to_path_buf();
blocking_candidates.push(("omp", vec![("user", path, marker)]));
}
let cline_hooks_dir = cline_user.parent().unwrap_or(home);
let cline_marker =
if std::fs::symlink_metadata(cline_hooks_dir).is_ok_and(|metadata| metadata.is_dir()) {
cline_hooks_dir.to_path_buf()
} else {
home.join(".cline")
};
blocking_candidates.push(("cline", vec![("user", cline_user, cline_marker)]));
#[cfg(unix)]
{
let mut grok = Vec::new();
if let Some(root) = grok_repo_root {
grok.push((
"project",
root.join(".grok/hooks/tirith.json"),
root.join(".grok"),
));
}
grok.push((
"user",
home.join(".grok/hooks/tirith.json"),
home.join(".grok"),
));
blocking_candidates.push(("grok-build", grok));
let mut openhands = Vec::new();
if let Some(work_dir) = openhands_work_dir {
openhands.push((
"project",
work_dir.join(".openhands/hooks.json"),
work_dir.join(".openhands"),
));
}
openhands.push((
"user",
home.join(".openhands/hooks.json"),
home.join(".openhands"),
));
blocking_candidates.push(("openhands", openhands));
}
for (name, candidates) in blocking_candidates {
if let Some((scope, hook_state)) =
candidates
.into_iter()
.find_map(|(scope, artifact, marker)| {
classify_hook_candidate(name, &artifact, &marker).map(|state| (scope, state))
})
{
tools.push(DetectedTool {
name,
configured_scope: Some(scope),
hook_state,
});
}
}
let project_kiro_dir = cwd.and_then(tirith_core::policy::find_workspace_kiro_dir);
let project_managed = project_kiro_dir
.as_ref()
.map(|d| d.join(".kiro/agents/tirith-security.json").exists())
.unwrap_or(false);
let user_managed = home.join(".kiro/agents/tirith-security.json").exists();
let user_kiro = home.join(".kiro").exists();
if project_managed {
tools.push(DetectedTool {
name: "kiro",
configured_scope: Some("project"),
hook_state: HookInstallState::Effective,
});
} else if user_managed {
tools.push(DetectedTool {
name: "kiro",
configured_scope: Some("user"),
hook_state: HookInstallState::Effective,
});
} else if project_kiro_dir.is_some() || user_kiro {
tools.push(DetectedTool {
name: "kiro",
configured_scope: None,
hook_state: HookInstallState::Absent,
});
}
tools
}
fn cline_hook_artifact(home: &std::path::Path) -> std::path::PathBuf {
let dir = crate::cli::setup::cline_hooks_dir(home);
#[cfg(windows)]
{
dir.join("PreToolUse.ps1")
}
#[cfg(not(windows))]
{
dir.join("PreToolUse")
}
}
const MAX_DOCTOR_HOOK_BYTES: u64 = 1024 * 1024;
fn read_hook_text(path: &std::path::Path) -> Option<String> {
let bytes = tirith_core::util::read_text_no_follow_capped(path, MAX_DOCTOR_HOOK_BYTES).ok()?;
String::from_utf8(bytes).ok()
}
fn hook_adapter_is_current(path: &std::path::Path) -> bool {
tirith_core::util::read_text_no_follow_capped(path, MAX_DOCTOR_HOOK_BYTES)
.is_ok_and(|bytes| bytes == crate::assets::TIRITH_CHECK_PY.as_bytes())
}
fn rendered_const_string(content: &str, name: &str) -> Option<String> {
let prefix = format!("const {name} = ");
let value = content
.lines()
.find_map(|line| line.trim().strip_prefix(&prefix))?
.strip_suffix(';')?;
serde_json::from_str(value).ok()
}
fn pi_family_guard_is_effective(name: &str, content: &str) -> bool {
content.contains("tool_call")
&& rendered_const_string(content, "TIRITH_BIN")
.is_some_and(|binary| std::path::Path::new(&binary).is_absolute())
&& rendered_const_string(content, "TIRITH_INTEGRATION").as_deref() == Some(name)
}
fn grok_hook_is_effective(path: &std::path::Path, content: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
return false;
};
let protocol_ok = value
.pointer("/hooks/PreToolUse/0/hooks/0/env/TIRITH_HOOK_PROTOCOL")
.and_then(serde_json::Value::as_str)
== Some("grok-build");
let Some(command) = value
.pointer("/hooks/PreToolUse/0/hooks/0/command")
.and_then(serde_json::Value::as_str)
else {
return false;
};
let Some(adapter) = path.parent().map(|dir| dir.join("tirith-check.py")) else {
return false;
};
let Some(adapter_text) = adapter.to_str() else {
return false;
};
let expected_adapter = crate::cli::setup::shell_quote(adapter_text, "bash");
let interpreter = command
.strip_suffix(&expected_adapter)
.and_then(|prefix| prefix.strip_suffix(' '));
let interpreter_is_absolute = interpreter.is_some_and(|python| {
python.starts_with('/') || (python.starts_with("'/") && python.ends_with('\''))
});
protocol_ok && interpreter_is_absolute && hook_adapter_is_current(&adapter)
}
fn classify_hook_candidate(
name: &str,
artifact: &std::path::Path,
host_marker: &std::path::Path,
) -> Option<HookInstallState> {
if hook_artifact_is_effective(name, artifact) {
return Some(HookInstallState::Effective);
}
if std::fs::symlink_metadata(artifact).is_ok() {
return Some(HookInstallState::Broken);
}
if std::fs::symlink_metadata(host_marker).is_ok_and(|metadata| metadata.is_dir()) {
return Some(HookInstallState::Absent);
}
None
}
#[cfg(windows)]
fn hook_artifact_is_effective(name: &str, path: &std::path::Path) -> bool {
let Some(content) = read_hook_text(path) else {
return false;
};
match name {
"pi-cli" | "prime-agent" | "omp" => pi_family_guard_is_effective(name, &content),
"cline" => {
let single_quoted_assignment = |prefix: &str| {
content.lines().find_map(|line| {
line.trim()
.strip_prefix(prefix)
.and_then(|value| value.strip_suffix('\''))
.map(|value| value.replace("''", "'"))
})
};
let pinned_python = single_quoted_assignment("$pythonPath = '");
let pinned_tirith = single_quoted_assignment("$env:TIRITH_BIN = '");
let Some(adapter) = path.parent().map(|dir| dir.join("tirith-check.py")) else {
return false;
};
let Some(adapter_text) = adapter.to_str() else {
return false;
};
let adapter_literal = format!("'{}'", adapter_text.replace('\'', "''"));
content
.lines()
.any(|line| line.trim() == "$env:TIRITH_HOOK_PROTOCOL = 'cline'")
&& content.lines().any(|line| {
line.trim() == format!("$decision = $raw | & $pythonPath {adapter_literal}")
})
&& !content.contains("__TIRITH_BIN__")
&& !content.contains("__PYTHON_BIN__")
&& !content.contains("__ADAPTER_PATH__")
&& pinned_python
.as_deref()
.is_some_and(|python| std::path::Path::new(python).is_absolute())
&& pinned_tirith
.as_deref()
.is_some_and(|tirith| std::path::Path::new(tirith).is_absolute())
&& hook_adapter_is_current(&adapter)
}
"grok-build" => grok_hook_is_effective(path, &content),
_ => false,
}
}
#[cfg(unix)]
fn posix_wrapper_execs_pinned_python(path: &std::path::Path, protocol: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = std::fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
let Some(content) = read_hook_text(path) else {
return false;
};
let Some(adapter) = path.parent().map(|dir| dir.join("tirith-check.py")) else {
return false;
};
let Some(adapter_text) = adapter.to_str() else {
return false;
};
let expected_adapter = crate::cli::setup::shell_quote(adapter_text, "bash");
let lines: Vec<_> = content.lines().map(str::trim_start).collect();
let pinned_binary = lines.iter().find_map(|line| {
line.strip_prefix("TIRITH_BIN=")
.and_then(|value| value.strip_suffix(" \\"))
.and_then(setup_shell_path)
});
let protocol_assignment = format!(
"TIRITH_HOOK_PROTOCOL={} \\",
crate::cli::setup::shell_quote(protocol, "bash")
);
let protocol_reaches_exec = lines
.windows(2)
.any(|pair| pair[0] == protocol_assignment && pair[1].starts_with("exec "));
let pinned_exec = lines.iter().any(|line| {
let Some(command) = line.trim_start().strip_prefix("exec ") else {
return false;
};
command
.strip_suffix(&expected_adapter)
.and_then(|python| python.strip_suffix(' '))
.and_then(setup_shell_path)
.is_some()
});
executable
&& pinned_exec
&& pinned_binary.is_some()
&& protocol_reaches_exec
&& !content.contains("__TIRITH_PYTHON__")
&& hook_adapter_is_current(&adapter)
}
#[cfg(unix)]
fn setup_shell_path(command: &str) -> Option<std::path::PathBuf> {
let decoded = if command.starts_with('\'') && command.ends_with('\'') && command.len() >= 2 {
command[1..command.len() - 1].replace("'\\''", "'")
} else {
command.to_string()
};
if crate::cli::setup::shell_quote(&decoded, "bash") != command {
return None;
}
let path = std::path::PathBuf::from(decoded);
path.is_absolute().then_some(path)
}
#[cfg(unix)]
fn hook_artifact_is_effective(name: &str, path: &std::path::Path) -> bool {
let Some(content) = read_hook_text(path) else {
return false;
};
match name {
"pi-cli" | "prime-agent" | "omp" => pi_family_guard_is_effective(name, &content),
"cline" => posix_wrapper_execs_pinned_python(path, "cline"),
"grok-build" => grok_hook_is_effective(path, &content),
"openhands" => {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
let Some(entries) = value
.get("pre_tool_use")
.and_then(serde_json::Value::as_array)
else {
return false;
};
entries.iter().any(|entry| {
entry
.get("hooks")
.and_then(serde_json::Value::as_array)
.is_some_and(|hooks| {
hooks.iter().any(|hook| {
hook.get("command")
.and_then(serde_json::Value::as_str)
.and_then(setup_shell_path)
.filter(|wrapper| {
wrapper.file_name().and_then(|name| name.to_str())
== Some("tirith-pre-tool-use")
})
.map(|wrapper| {
posix_wrapper_execs_pinned_python(&wrapper, "openhands")
})
.unwrap_or(false)
})
})
})
}
_ => false,
}
}
fn bash_safe_mode_active() -> bool {
tirith_core::policy::state_dir()
.map(|d| d.join("bash-safe-mode").exists())
.unwrap_or(false)
}
fn env_is_truthy(s: &str) -> bool {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn bash_blocking_remediation_needed(
requested_mode: Option<&str>,
effective_mode: Option<&str>,
enter_capability: Option<&str>,
enter_capability_fresh: Option<bool>,
) -> bool {
let preexec_selected = requested_mode
.into_iter()
.chain(effective_mode)
.any(|mode| mode.eq_ignore_ascii_case("preexec"));
let enter_is_proven = matches!(
(enter_capability, enter_capability_fresh),
(Some("works"), Some(true))
);
preexec_selected || !enter_is_proven
}
fn safe_mode_overridden_by_env(bash_safe_mode: bool, requested_mode: Option<&str>) -> bool {
bash_safe_mode && requested_mode == Some("enter")
}
fn create_default_policy() -> Result<PathBuf, String> {
let content = "\
# tirith policy — see https://github.com/sheeki03/tirith for options
fail_mode: open
allow_bypass_env: true
paranoia: 1
strict_warn: false
allowlist: []
blocklist: []
";
if let Some(repo_root) = tirith_core::policy::find_repo_root(None) {
let path = repo_root.join(".tirith").join("policy.yaml");
match create_policy_contained(&repo_root, &path, content) {
Ok(()) => return Ok(path),
Err(e) => return Err(e),
}
}
if let Some(config) = tirith_core::policy::config_dir() {
let path = config.join("policy.yaml");
match create_policy_contained(&config, &path, content) {
Ok(()) => return Ok(path),
Err(e) => return Err(e),
}
}
Err("could not determine a location for policy file".to_string())
}
pub(super) fn create_policy_contained(
root: &std::path::Path,
path: &std::path::Path,
content: &str,
) -> Result<(), String> {
match tirith_core::util::ContainedAtomicFile::prepare(root, path, false) {
Ok(destination) => match destination.read_capped(0) {
Ok(_)
| Err(tirith_core::util::OpenRegularError::TooLarge)
| Err(tirith_core::util::OpenRegularError::NotRegularFile) => {
return Err(format!(
"policy already exists at {} — not overwriting",
path.display()
));
}
Err(tirith_core::util::OpenRegularError::NotFound) => {}
Err(tirith_core::util::OpenRegularError::Io(error)) => {
return Err(format!("failed to inspect {}: {error}", path.display()));
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("failed to inspect {}: {error}", path.display())),
}
let policy = tirith_core::policy::Policy::discover_local_only(root.to_str());
super::write_config_file_permitted_with_parent_creation(
root,
path,
content.as_bytes(),
false,
&policy,
true,
true,
)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
format!(
"policy already exists at {} — not overwriting",
path.display()
)
} else {
format!("failed to write {}: {e}", path.display())
}
})
}
#[derive(Debug, Clone, serde::Serialize)]
struct DetectionGapInfo {
total_commands: usize,
blocked: usize,
warned: usize,
records_analyzed: usize,
records_with_raw: usize,
records_without_raw: usize,
total_findings: usize,
raw_total_findings: usize,
hidden_findings: usize,
hidden_top_rules: Vec<(String, usize)>,
current_paranoia: u8,
}
fn check_detection_gaps() -> Option<DetectionGapInfo> {
let data_dir = tirith_core::policy::data_dir()?;
let log_path = data_dir.join("log.jsonl");
if !log_path.exists() {
return None;
}
let read_result = match tirith_core::audit_aggregator::read_log_tail(&log_path, 10_000) {
Ok(r) => r,
Err(e) => {
eprintln!(
"tirith: doctor: cannot read audit log {}: {e}",
log_path.display()
);
return None;
}
};
if read_result.records.is_empty() {
return None;
}
let seven_days_ago = chrono::Utc::now() - chrono::Duration::days(7);
let since_str = seven_days_ago.to_rfc3339();
let filter = tirith_core::audit_aggregator::AuditFilter {
since: Some(since_str),
entry_type: Some("verdict".to_string()),
..Default::default()
};
let verdicts = tirith_core::audit_aggregator::filter_records(&read_result.records, &filter);
if verdicts.is_empty() {
return None;
}
let total_commands = verdicts.len();
let blocked = verdicts
.iter()
.filter(|r| r.action.eq_ignore_ascii_case("Block"))
.count();
let warned = verdicts
.iter()
.filter(|r| {
r.action.eq_ignore_ascii_case("Warn") || r.action.eq_ignore_ascii_case("WarnAck")
})
.count();
let mut records_with_raw = 0usize;
let mut records_without_raw = 0usize;
let mut total_findings = 0usize;
let mut raw_total_findings_analyzed = 0usize;
let mut hidden_findings = 0usize;
let mut hidden_rule_counts: HashMap<String, usize> = HashMap::new();
for record in &verdicts {
total_findings += record.rule_ids.len();
match record.raw_rule_ids {
Some(ref raw_ids) => {
records_with_raw += 1;
raw_total_findings_analyzed += raw_ids.len();
let mut effective_counts: HashMap<&str, u32> = HashMap::new();
for rid in &record.rule_ids {
*effective_counts.entry(rid.as_str()).or_insert(0) += 1;
}
for raw_rid in raw_ids {
match effective_counts.get_mut(raw_rid.as_str()) {
Some(count) if *count > 0 => {
*count -= 1;
}
_ => {
hidden_findings += 1;
*hidden_rule_counts.entry(raw_rid.clone()).or_insert(0) += 1;
}
}
}
}
None => {
records_without_raw += 1;
}
}
}
let records_analyzed = records_with_raw;
let mut hidden_top_rules: Vec<(String, usize)> = hidden_rule_counts.into_iter().collect();
hidden_top_rules.sort_by_key(|r| std::cmp::Reverse(r.1));
hidden_top_rules.truncate(5);
let cwd = std::env::current_dir().ok();
let cwd_str = cwd.as_ref().and_then(|p| p.to_str());
let policy = tirith_core::policy::Policy::discover_partial(cwd_str);
let current_paranoia = policy.paranoia;
Some(DetectionGapInfo {
total_commands,
blocked,
warned,
records_analyzed,
records_with_raw,
records_without_raw,
total_findings,
raw_total_findings: raw_total_findings_analyzed,
hidden_findings,
hidden_top_rules,
current_paranoia,
})
}
#[derive(serde::Serialize)]
struct DoctorInfo {
version: String,
binary_path: String,
detected_shell: String,
interactive: bool,
hook_dir: Option<String>,
hooks_materialized: bool,
shell_profile: Option<String>,
hook_configured: bool,
bash_safe_mode: bool,
#[serde(skip_serializing_if = "Option::is_none")]
bash_requested_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_requested_enforce: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_requested_require_enter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_effective_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_effective_protection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tirith_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_enter_capability: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_enter_capability_fresh: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_enter_capability_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_enter_capability_tirith_version: Option<String>,
policy_paths: Vec<String>,
policy_root_env: Option<String>,
data_dir: Option<String>,
log_path: Option<String>,
last_trigger_path: Option<String>,
cloaking_available: bool,
webhooks_available: bool,
shadow_binaries: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
detection_gaps: Option<DetectionGapInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
threat_db: Option<ThreatDbDoctorInfo>,
baseline: BaselineDoctorInfo,
capsule: crate::cli::capsule::CapsuleDoctorInfo,
}
#[derive(Debug, Clone, serde::Serialize)]
struct BaselineDoctorInfo {
enabled: bool,
total_observations: usize,
early_baseline_mode: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
struct ThreatDbDoctorInfo {
installed: bool,
path: Option<String>,
age_hours: Option<f64>,
total_entries: Option<u32>,
signature_valid: Option<bool>,
stale: bool,
error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct QuickDoctorInfo {
pub(crate) schema_version: u32,
pub(crate) protection_mode: String,
pub(crate) policy_path_used: Option<String>,
pub(crate) hook_configured: bool,
}
pub(crate) fn gather_quick_info() -> QuickDoctorInfo {
let detected_shell = crate::cli::init::detect_shell().to_string();
let (_profile, hook_configured) = check_shell_profile(&detected_shell, "tirith: doctor:");
let live_protection = std::env::var("TIRITH_BASH_EFFECTIVE_PROTECTION")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
std::env::var("TIRITH_STATUS")
.ok()
.filter(|s| !s.is_empty())
});
let protection_mode =
crate::cli::prompt_status::protection_mode_from_status(live_protection.as_deref());
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let policy_path_used = tirith_core::policy::discover_local_policy_path(cwd.as_deref())
.map(|p| p.display().to_string());
QuickDoctorInfo {
schema_version: 1,
protection_mode,
policy_path_used,
hook_configured,
}
}
fn run_quick(json: bool) -> i32 {
let info = gather_quick_info();
if json {
if !write_json_stdout(&info, "tirith doctor: failed to write JSON output") {
return 1;
}
} else {
print_quick_human(&info);
}
0
}
pub(crate) fn print_quick_human(info: &QuickDoctorInfo) {
println!(" protection: {}", info.protection_mode);
println!(
" hook: {}",
if info.hook_configured {
"configured"
} else {
"NOT CONFIGURED"
}
);
println!(
" policy: {}",
info.policy_path_used.as_deref().unwrap_or("(none found)")
);
}
fn gather_info() -> DoctorInfo {
let binary_path = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let detected_shell = crate::cli::init::detect_shell().to_string();
let interactive = is_terminal::is_terminal(std::io::stderr());
let hook_dir = crate::cli::init::find_hook_dir_readonly();
let hooks_materialized = hook_dir
.as_ref()
.map(|d| {
if let Some(data) = tirith_core::policy::data_dir() {
d.starts_with(&data)
} else {
false
}
})
.unwrap_or(false);
let (shell_profile, hook_configured) = check_shell_profile(&detected_shell, "tirith: doctor:");
let bash_safe_mode = tirith_core::policy::state_dir()
.map(|d| d.join("bash-safe-mode").exists())
.unwrap_or(false);
let bash_requested_mode = std::env::var("TIRITH_BASH_MODE")
.ok()
.filter(|s| !s.is_empty());
let bash_requested_enforce = std::env::var("TIRITH_BASH_PREEXEC_ENFORCE")
.ok()
.filter(|s| !s.is_empty());
let bash_requested_require_enter = std::env::var("TIRITH_BASH_REQUIRE_ENTER")
.ok()
.filter(|s| !s.is_empty());
let bash_effective_mode = std::env::var("TIRITH_BASH_EFFECTIVE_MODE")
.ok()
.filter(|s| !s.is_empty());
let bash_effective_protection = std::env::var("TIRITH_BASH_EFFECTIVE_PROTECTION")
.ok()
.filter(|s| !s.is_empty());
let tirith_status = std::env::var("TIRITH_STATUS")
.ok()
.filter(|s| !s.is_empty());
let data_dir = tirith_core::policy::data_dir();
let log_path = data_dir.as_ref().map(|d| d.join("log.jsonl"));
let last_trigger_path = data_dir.as_ref().map(|d| d.join("last_trigger.json"));
let policy_cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let policy_paths = collect_policy_paths(policy_cwd.as_deref());
let policy_root_env = std::env::var("TIRITH_POLICY_ROOT").ok();
let shadow_binaries = super::find_shadow_binaries();
let detection_gaps = check_detection_gaps();
let threat_db = gather_threat_db_info();
let baseline = gather_baseline_info();
let (
bash_enter_capability,
bash_enter_capability_fresh,
bash_enter_capability_reason,
bash_enter_capability_tirith_version,
) = {
#[cfg(unix)]
{
match crate::cli::bash_capability::read_cache() {
Some(decision) => {
let token = match decision.capability {
crate::cli::bash_capability::EnterCapability::Works => "works",
crate::cli::bash_capability::EnterCapability::Broken => "broken",
crate::cli::bash_capability::EnterCapability::Inconclusive => {
"inconclusive"
}
};
let fresh = crate::cli::bash_capability::decision_is_fresh(&decision);
let reason = if decision.reason.is_empty() {
None
} else {
Some(decision.reason.clone())
};
let writer = if decision.tirith_version.is_empty() {
None
} else {
Some(decision.tirith_version.clone())
};
(Some(token.to_string()), Some(fresh), reason, writer)
}
None => (None, None, None, None),
}
}
#[cfg(not(unix))]
{
(None, None, None, None)
}
};
DoctorInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
binary_path,
detected_shell,
interactive,
hook_dir: hook_dir.map(|d| d.display().to_string()),
hooks_materialized,
shell_profile: shell_profile.map(|p| p.display().to_string()),
hook_configured,
bash_safe_mode,
bash_requested_mode,
bash_requested_enforce,
bash_requested_require_enter,
bash_effective_mode,
bash_effective_protection,
tirith_status,
bash_enter_capability,
bash_enter_capability_fresh,
bash_enter_capability_reason,
bash_enter_capability_tirith_version,
policy_paths,
policy_root_env,
data_dir: data_dir.map(|d| d.display().to_string()),
log_path: log_path.map(|p| p.display().to_string()),
last_trigger_path: last_trigger_path.map(|p| p.display().to_string()),
cloaking_available: cfg!(unix),
webhooks_available: cfg!(unix),
shadow_binaries,
detection_gaps,
threat_db,
baseline,
capsule: crate::cli::capsule::gather_doctor_info(),
}
}
fn gather_baseline_info() -> BaselineDoctorInfo {
let enabled = tirith_core::policy::Policy::discover_partial(None).baseline_enabled;
let total = tirith_core::baseline::entry_count();
BaselineDoctorInfo {
enabled,
total_observations: total,
early_baseline_mode: total < tirith_core::baseline::EARLY_BASELINE_ENTRIES,
}
}
fn gather_threat_db_info() -> Option<ThreatDbDoctorInfo> {
use tirith_core::threatdb::ThreatDb;
let db_path = ThreatDb::default_path()?;
if !db_path.exists() {
return Some(ThreatDbDoctorInfo {
installed: false,
path: Some(db_path.display().to_string()),
age_hours: None,
total_entries: None,
signature_valid: None,
stale: true,
error: None,
});
}
match ThreatDb::load_from_path(&db_path, 0) {
Ok(db) => {
let sig_valid = db.verify_signature().is_ok();
let stats = db.stats();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_secs = now.saturating_sub(stats.build_timestamp);
let age_hours = age_secs as f64 / 3600.0;
let total = stats.package_count
+ stats.hostname_count
+ stats.ip_count
+ stats.typosquat_count
+ stats.popular_count;
let policy = tirith_core::policy::Policy::discover(None);
let stale_hours = policy.threat_intel.auto_update_hours;
let is_stale = if stale_hours == 0 {
false
} else {
age_hours > (stale_hours as f64 * 2.0)
};
Some(ThreatDbDoctorInfo {
installed: true,
path: Some(db_path.display().to_string()),
age_hours: Some(age_hours),
total_entries: Some(total),
signature_valid: Some(sig_valid),
stale: is_stale,
error: None,
})
}
Err(e) => Some(ThreatDbDoctorInfo {
installed: true,
path: Some(db_path.display().to_string()),
age_hours: None,
total_entries: None,
signature_valid: None,
stale: true,
error: Some(format!("{e}")),
}),
}
}
#[derive(Debug, Clone, serde::Serialize)]
struct ShellToolPresence {
name: &'static str,
on_path: bool,
in_profile: bool,
note: &'static str,
}
const KNOWN_SHELL_TOOLS: &[(&str, &str)] = &[
(
"atuin",
"rebinds Enter / Up and installs preexec hooks (history)",
),
("starship", "installs precmd/preexec prompt hooks"),
("fzf", "installs key bindings and a completion widget"),
("zoxide", "installs a chpwd/precmd hook (directory jumping)"),
("direnv", "installs a precmd/chpwd hook (per-dir env)"),
("mise", "installs a precmd/chpwd hook (runtime/env manager)"),
(
"asdf",
"sources shims and shell functions (version manager)",
),
];
fn tool_on_path(binary: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
tool_on_path_from(binary, &path)
}
fn tool_on_path_from(binary: &str, path: &std::ffi::OsStr) -> bool {
!tirith_core::path_audit::which_all_os(binary, path).is_empty()
}
fn tool_in_profile(tool: &str, profile: Option<&std::path::Path>) -> bool {
let profile = match profile {
Some(p) => p,
None => return false,
};
match std::fs::read_to_string(profile) {
Ok(contents) => contents.contains(tool),
Err(_) => false,
}
}
fn detect_shell_tool_conflicts(profile: Option<&std::path::Path>) -> Vec<ShellToolPresence> {
KNOWN_SHELL_TOOLS
.iter()
.filter_map(|(name, note)| {
let on_path = tool_on_path(name);
let in_profile = tool_in_profile(name, profile);
if on_path || in_profile {
Some(ShellToolPresence {
name,
on_path,
in_profile,
note,
})
} else {
None
}
})
.collect()
}
#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)]
struct PsCompatInfo {
binary: String,
#[serde(skip_serializing_if = "Option::is_none")]
psreadline_available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
hook_version_match: Option<bool>,
}
#[derive(serde::Serialize)]
struct CompatReport {
version: String,
binary_path: String,
detected_shell: String,
interactive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
bash_requested_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_effective_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_effective_protection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tirith_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_enter_capability: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
bash_enter_capability_fresh: Option<bool>,
bash_safe_mode: bool,
hook_dir: Option<String>,
hooks_materialized: bool,
hooks_stale: bool,
shell_profile: Option<String>,
hook_configured: bool,
shadow_binaries: Vec<String>,
policy_paths: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
threat_db: Option<ThreatDbDoctorInfo>,
shell_tools: Vec<ShellToolPresence>,
#[serde(skip_serializing_if = "Option::is_none")]
powershell_compat: Option<PsCompatInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
visual_audit: Option<VisualAuditCompatInfo>,
}
#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)]
struct VisualAuditCompatInfo {
audited_at: String,
terminal: String,
pairs_total: usize,
distinguishable: usize,
indistinguishable: usize,
skipped: usize,
}
fn gather_compat() -> CompatReport {
let info = gather_info();
let profile = info.shell_profile.as_ref().map(std::path::PathBuf::from);
let shell_tools = detect_shell_tool_conflicts(profile.as_deref());
let powershell_compat = gather_ps_compat(&info.detected_shell);
let visual_audit = gather_visual_audit_compat();
CompatReport {
version: info.version,
binary_path: info.binary_path,
detected_shell: info.detected_shell,
interactive: info.interactive,
bash_requested_mode: info.bash_requested_mode,
bash_effective_mode: info.bash_effective_mode,
bash_effective_protection: info.bash_effective_protection,
tirith_status: info.tirith_status,
bash_enter_capability: info.bash_enter_capability,
bash_enter_capability_fresh: info.bash_enter_capability_fresh,
bash_safe_mode: info.bash_safe_mode,
hook_dir: info.hook_dir,
hooks_materialized: info.hooks_materialized,
hooks_stale: hooks_stale(),
shell_profile: info.shell_profile,
hook_configured: info.hook_configured,
shadow_binaries: info.shadow_binaries,
policy_paths: info.policy_paths,
threat_db: info.threat_db,
shell_tools,
powershell_compat,
visual_audit,
}
}
fn gather_visual_audit_compat() -> Option<VisualAuditCompatInfo> {
let path = tirith_core::policy::config_dir()?.join("visual-audit-result.json");
let bytes = tirith_core::util::read_regular_capped(&path, 64 * 1024).ok()?;
let result: crate::cli::visual_audit::VisualAuditResult =
serde_json::from_slice(&bytes).ok()?;
Some(VisualAuditCompatInfo {
audited_at: result.audited_at,
terminal: result.terminal,
pairs_total: result.pairs_total,
distinguishable: result.distinguishable,
indistinguishable: result.indistinguishable,
skipped: result.skipped,
})
}
fn gather_ps_compat(detected_shell: &str) -> Option<PsCompatInfo> {
let binary = detect_powershell_binary(detected_shell)?;
let psreadline_available = probe_psreadline_available(&binary);
Some(PsCompatInfo {
binary: binary
.path()
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("powershell")
.to_string(),
psreadline_available,
hook_version_match: None,
})
}
fn detect_powershell_binary(
detected_shell: &str,
) -> Option<tirith_core::trusted_child::TrustedExecutable> {
let candidates: [&'static str; 2] = match detected_shell {
"powershell" => ["powershell", "pwsh"],
_ => ["pwsh", "powershell"],
};
candidates
.into_iter()
.filter_map(|candidate| tirith_core::trusted_child::resolve_system_helper(candidate).ok())
.find(probe_command_available)
}
fn run_powershell(
binary: &tirith_core::trusted_child::TrustedExecutable,
body: &str,
) -> Option<std::process::Output> {
use tirith_core::trusted_child::{ChildLimits, ChildOutcome, ChildSpec};
let mut spec = ChildSpec::new(
["-NoProfile", "-NonInteractive", "-Command", body],
ChildLimits::new(std::time::Duration::from_secs(3), 1024 * 1024, 1024 * 1024),
)
.inherit_env(&[
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"SystemRoot",
"WINDIR",
"PSModulePath",
]);
if let Some(path) = tirith_core::trusted_child::sanitized_ambient_path() {
spec = spec.env("PATH", path);
}
match tirith_core::trusted_child::run(binary, &spec) {
ChildOutcome::Completed {
status,
stdout,
stderr,
} => Some(std::process::Output {
status,
stdout,
stderr,
}),
_ => None,
}
}
fn probe_command_available(binary: &tirith_core::trusted_child::TrustedExecutable) -> bool {
run_powershell(binary, "exit 0")
.map(|o| o.status.success())
.unwrap_or(false)
}
fn probe_psreadline_available(
binary: &tirith_core::trusted_child::TrustedExecutable,
) -> Option<bool> {
let output = run_powershell(
binary,
"if (Get-Module -ListAvailable PSReadLine) { 'yes' } else { 'no' }",
)?;
let stdout = String::from_utf8_lossy(&output.stdout);
Some(stdout.trim().eq_ignore_ascii_case("yes"))
}
fn run_compat(json: bool) -> i32 {
let report = gather_compat();
if json {
match serde_json::to_string_pretty(&report) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
return 1;
}
}
} else {
print_compat_human(&report);
}
0
}
const BUNDLE_ENV_ALLOWLIST: &[&str] = &[
"SHELL",
"TERM",
"TERM_PROGRAM",
"COLORTERM",
"SSH_CONNECTION",
"SSH_TTY",
"SSH_CLIENT",
"TIRITH_BASH_MODE",
"TIRITH_BASH_PREEXEC_ENFORCE",
"TIRITH_BASH_REQUIRE_ENTER",
"TIRITH_BASH_EFFECTIVE_MODE",
"TIRITH_BASH_EFFECTIVE_PROTECTION",
"TIRITH_STATUS",
"TIRITH_OFFLINE",
"TIRITH_OUTPUT",
"TIRITH_SHELL_DIR",
"TIRITH_POLICY_ROOT",
"TIRITH_LOG",
"TIRITH_SESSION_ID",
"XDG_STATE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"HISTCONTROL",
"HISTIGNORE",
];
fn redact_home_path(text: &str, home: Option<&std::path::Path>) -> String {
let home = match home {
Some(h) => h.to_string_lossy().into_owned(),
None => return text.to_string(),
};
if home.is_empty() || home == "/" {
return text.to_string();
}
let trimmed = home.trim_end_matches(['/', '\\']);
if trimmed.is_empty() {
return text.to_string();
}
text.replace(trimmed, "~")
}
fn redact_secrets(line: &str) -> String {
let (key, sep, value) = if let Some(idx) = line.find('=') {
(&line[..idx], '=', &line[idx + 1..])
} else if let Some(idx) = line.find(": ") {
(&line[..idx], ':', &line[idx + 2..])
} else {
return line.to_string();
};
let key_l = key.to_ascii_lowercase();
let key_signals_secret = ["token", "secret", "password", "passwd", "api_key", "apikey"]
.iter()
.any(|m| key_l.contains(m))
|| key_l.ends_with("key")
|| key_l.ends_with("_key")
|| key_l.ends_with("-key");
let trimmed_value = value.trim();
let value_signals_secret = looks_like_secret(trimmed_value);
if key_signals_secret || value_signals_secret {
if sep == ':' {
format!("{key}: <redacted>")
} else {
format!("{key}=<redacted>")
}
} else {
line.to_string()
}
}
fn looks_like_secret(value: &str) -> bool {
if value.is_empty() || value == "<redacted>" {
return false;
}
const SECRET_PREFIXES: &[&str] = &[
"sk-",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"github_pat_",
"xox",
"AKIA",
"ASIA",
"AIza",
"ya29.",
"eyJ", ];
if SECRET_PREFIXES.iter().any(|p| value.starts_with(p)) {
return true;
}
if value.len() >= 24
&& !value.contains([' ', '/', '\\'])
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-' | '=' | '.'))
&& value.chars().any(|c| c.is_ascii_digit())
&& value.chars().any(|c| c.is_ascii_alphabetic())
{
return true;
}
false
}
fn build_bundle_text(home: Option<&std::path::Path>) -> String {
let info = gather_info();
let compat = gather_compat();
let now = chrono::Utc::now().to_rfc3339();
let mut out = String::new();
let mut line = |s: String| {
out.push_str(&s);
out.push('\n');
};
line("tirith diagnostic bundle".to_string());
line(format!("generated: {now}"));
line(
"This report is redacted: secrets, tokens, and your home-directory path \
have been masked."
.to_string(),
);
line("Safe to attach to a bug report. Review it before sharing if unsure.".to_string());
line(String::new());
line("== tirith ==".to_string());
line(format!("version: {}", info.version));
line(format!("binary: {}", info.binary_path));
line(format!("os: {}", std::env::consts::OS));
line(format!("arch: {}", std::env::consts::ARCH));
if info.shadow_binaries.is_empty() {
line("shadow binaries: none on PATH".to_string());
} else {
line(format!(
"shadow binaries: {} on PATH (may shadow this binary)",
info.shadow_binaries.len()
));
for s in &info.shadow_binaries {
line(format!(" - {s}"));
}
}
line(String::new());
line("== shell & protection ==".to_string());
line(format!("detected shell: {}", info.detected_shell));
line(format!("interactive: {}", info.interactive));
line(format!(
"live status: {} (TIRITH_STATUS)",
info.tirith_status.as_deref().unwrap_or("(hook not loaded)")
));
line(format!(
"requested mode: {}",
info.bash_requested_mode.as_deref().unwrap_or("(default)")
));
line(format!(
"effective mode: {}",
info.bash_effective_mode
.as_deref()
.unwrap_or("(hook not loaded)")
));
line(format!(
"protection: {}",
info.bash_effective_protection
.as_deref()
.unwrap_or("(hook not loaded)")
));
line(format!("bash safe mode: {}", info.bash_safe_mode));
line(format!(
"enter capability: {} (fresh: {})",
info.bash_enter_capability
.as_deref()
.unwrap_or("not tested"),
info.bash_enter_capability_fresh
.map(|b| b.to_string())
.unwrap_or_else(|| "n/a".to_string()),
));
if let Some(reason) = info.bash_enter_capability_reason.as_deref() {
line(format!(" capability reason: {reason}"));
}
line(String::new());
line("== hook chain ==".to_string());
line(format!(
"hook dir: {}",
info.hook_dir.as_deref().unwrap_or("not found")
));
line(format!("materialized: {}", info.hooks_materialized));
line(format!("hooks stale: {}", compat.hooks_stale));
line(format!(
"shell profile: {}",
info.shell_profile.as_deref().unwrap_or("not found")
));
line(format!("profile wired: {}", info.hook_configured));
if compat.shell_tools.is_empty() {
line("co-installed hook tools: none detected".to_string());
} else {
line("co-installed hook tools (may interleave with tirith's hook):".to_string());
for t in &compat.shell_tools {
let signals = match (t.on_path, t.in_profile) {
(true, true) => "on PATH, in profile",
(true, false) => "on PATH",
(false, true) => "in profile",
(false, false) => "detected",
};
line(format!(" - {} ({})", t.name, signals));
}
}
line(String::new());
line("== policy ==".to_string());
if info.policy_paths.is_empty() {
line("policy discovery: no policy found (built-in defaults apply)".to_string());
} else {
line("policy discovery:".to_string());
for p in &info.policy_paths {
line(format!(" - {p}"));
}
}
if let Some(root) = info.policy_root_env.as_deref() {
line(format!("TIRITH_POLICY_ROOT: {root}"));
}
line(format!(
"data dir: {}",
info.data_dir.as_deref().unwrap_or("not found")
));
line(String::new());
line("== threat database ==".to_string());
match &info.threat_db {
None => line("threat DB: not available on this platform".to_string()),
Some(tdb) if !tdb.installed => line("threat DB: not installed".to_string()),
Some(tdb) => {
line(format!("installed: {}", tdb.installed));
if let Some(age) = tdb.age_hours {
line(format!("age: {age:.1}h"));
}
if let Some(total) = tdb.total_entries {
line(format!("entries: {total}"));
}
line(format!(
"signature: {}",
match tdb.signature_valid {
Some(true) => "valid",
Some(false) => "INVALID",
None => "unknown",
}
));
line(format!("stale: {}", tdb.stale));
if let Some(err) = tdb.error.as_deref() {
line(format!("error: {err}"));
}
}
}
line(String::new());
line("== environment (curated, redacted) ==".to_string());
line("Only tirith-relevant variables are listed; values are secret-scrubbed.".to_string());
let mut any_env = false;
for name in BUNDLE_ENV_ALLOWLIST {
if let Ok(value) = std::env::var(name) {
if value.is_empty() {
continue;
}
any_env = true;
line(redact_secrets(&format!("{name}={value}")));
}
}
if !any_env {
line("(none of the curated variables are set)".to_string());
}
line(String::new());
line("== end of bundle ==".to_string());
redact_home_path(&out, home)
}
fn write_bundle_file(dir: &std::path::Path, text: &str) -> std::io::Result<PathBuf> {
let mut tmp = tempfile::Builder::new()
.prefix("tirith-bundle-")
.suffix(".txt")
.tempfile_in(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
use std::io::Write;
tmp.write_all(text.as_bytes())?;
tmp.flush()?;
let (_file, path) = tmp.keep().map_err(|e| e.error)?;
Ok(path)
}
fn run_bundle(json: bool) -> i32 {
let home = home::home_dir();
let text = build_bundle_text(home.as_deref());
let dir = tirith_core::policy::state_dir().unwrap_or_else(std::env::temp_dir);
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("tirith: could not create {}: {e}", dir.display());
return 1;
}
let path = match write_bundle_file(&dir, &text) {
Ok(p) => p,
Err(e) => {
eprintln!("tirith: could not write bundle into {}: {e}", dir.display());
return 1;
}
};
if json {
let shown = redact_home_path(&path.display().to_string(), home.as_deref());
match serde_json::to_string_pretty(&serde_json::json!({ "bundle_path": shown })) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("tirith: JSON serialization failed: {e}");
return 1;
}
}
} else {
println!("tirith: diagnostic bundle written to:");
println!(" {}", path.display());
println!();
println!("The bundle is redacted (secrets, tokens, and your home-directory path");
println!("are masked) and safe to attach to a bug report. Review it before");
println!("sharing if you want to be sure.");
}
0
}
fn compat_protection_status_line(status: Option<&str>) -> Option<String> {
match status {
Some("degraded") => Some(
" protection status: DEGRADED (downgraded to warn-only this session)".to_string(),
),
Some(status) => Some(format!(" protection status: {status}")),
None => None,
}
}
fn format_compat_human(r: &CompatReport) -> String {
let mut out = String::new();
let mut line = |s: &str| {
out.push_str(s);
out.push('\n');
};
line("tirith compatibility report");
line("");
line(&format!("tirith {}", r.version));
line(&format!(" binary: {}", r.binary_path));
line(&format!(" shell: {}", r.detected_shell));
line(&format!(" interactive: {}", r.interactive));
line("");
line("Shell hook mode");
if let Some(status_line) = compat_protection_status_line(r.tirith_status.as_deref()) {
line(&status_line);
}
if r.detected_shell == "bash"
|| r.bash_requested_mode.is_some()
|| r.bash_effective_mode.is_some()
{
let requested = r.bash_requested_mode.as_deref().unwrap_or("(default)");
line(&format!(" requested bash mode: {requested}"));
match (
r.bash_effective_mode.as_deref(),
r.bash_effective_protection.as_deref(),
) {
(Some(mode), Some(protection)) => {
line(&format!(" effective bash mode: {mode}"));
line(&format!(" effective protection: {protection}"));
}
_ => {
line(" effective bash mode: hook not loaded in this process");
}
}
match r.bash_enter_capability.as_deref() {
Some(verdict) => {
let fresh = r.bash_enter_capability_fresh.unwrap_or(false);
if fresh {
line(&format!(
" enter capability: {verdict} (self-test verdict)"
));
} else {
line(&format!(
" enter capability: {verdict} (STALE — measured on a different bash)"
));
}
}
None => {
line(" enter capability: not tested — run tirith doctor --simulate-enter");
}
}
line(&format!(
" bash safe mode: {}",
if r.bash_safe_mode { "on" } else { "off" }
));
} else {
line(&format!(
" (no bash-specific mode state — detected shell is {})",
r.detected_shell
));
}
line("");
line("Install checks");
let shadow_status = if r.shadow_binaries.is_empty() {
"no shadowing tirith binaries on PATH".to_string()
} else {
format!(
"{} shadowing binary/binaries on PATH",
r.shadow_binaries.len()
)
};
line(&format!(" PATH shadowing: {shadow_status}"));
for shadow in &r.shadow_binaries {
line(&format!(" - {shadow}"));
}
line(&format!(
" shell profile: {}",
r.shell_profile.as_deref().unwrap_or("not found")
));
line(&format!(
" profile wiring: {}",
if r.hook_configured {
"tirith hook configured"
} else {
"NOT configured — commands are not intercepted"
}
));
line(&format!(
" hook dir: {}",
r.hook_dir.as_deref().unwrap_or("not found")
));
let hook_freshness = if !r.hooks_materialized {
"system/packaged hooks (not materialized)"
} else if r.hooks_stale {
"STALE — re-run tirith init (or tirith doctor --fix)"
} else {
"materialized, up to date"
};
line(&format!(" materialized hooks: {hook_freshness}"));
if r.policy_paths.is_empty() {
line(" policy discovery: no policy found (built-in defaults apply)");
} else {
for (i, p) in r.policy_paths.iter().enumerate() {
if i == 0 {
line(&format!(" policy discovery: {p}"));
} else {
line(&format!(" {p}"));
}
}
}
match &r.threat_db {
Some(tdb) if !tdb.installed => {
line(" threat DB: not installed");
}
Some(tdb) if tdb.error.is_some() => {
line(&format!(
" threat DB: error: {}",
tdb.error.as_deref().unwrap_or("unknown")
));
}
Some(tdb) if tdb.signature_valid == Some(false) => {
line(" threat DB: invalid signature");
}
Some(tdb) if tdb.stale => {
line(" threat DB: installed but stale");
}
Some(_) => {
line(" threat DB: installed, current");
}
None => {
line(" threat DB: not available");
}
}
line("");
line("Shell tool detection");
if r.shell_tools.is_empty() {
line(" no known hook-interacting shell tools detected");
} else {
line(" detected co-installed shell tools that interact with shell hooks.");
line(" presence does not necessarily mean a conflict — tirith's hooks are");
line(" designed to coexist. Listed for awareness:");
for tool in &r.shell_tools {
let signals = match (tool.on_path, tool.in_profile) {
(true, true) => "on PATH, in shell profile",
(true, false) => "on PATH",
(false, true) => "in shell profile",
(false, false) => "detected",
};
line(&format!(" - {} ({})", tool.name, signals));
line(&format!(" {}", tool.note));
}
}
if let Some(ps) = &r.powershell_compat {
line("");
line("--- PowerShell compat ---");
line(&format!(" binary: {}", ps.binary));
match ps.psreadline_available {
Some(true) => line(" PSReadLine module: yes"),
Some(false) => line(" PSReadLine module: no (key binding will not work)"),
None => line(" PSReadLine module: unknown (probe failed or timed out)"),
}
}
if let Some(va) = &r.visual_audit {
line("");
line("--- Visual audit (local terminal/font) ---");
let term = if va.terminal.is_empty() {
"(unset)"
} else {
va.terminal.as_str()
};
line(&format!(" recorded TERM: {term}"));
line(&format!(" audited at: {}", va.audited_at));
line(&format!(" pairs presented: {}", va.pairs_total));
line(&format!(" distinguishable: {}", va.distinguishable));
line(&format!(
" indistinguishable: {}{}",
va.indistinguishable,
if va.indistinguishable > 0 {
" (local rendering risk)"
} else {
""
}
));
line(&format!(" skipped: {}", va.skipped));
line(" NOTE: describes this terminal + font only; not portable.");
}
out
}
fn print_compat_human(r: &CompatReport) {
print!("{}", format_compat_human(r));
}
fn print_protection_status(status: Option<&str>) {
match status {
Some("blocks") => {
println!(" protection: blocks (a dangerous command is stopped before it runs)");
}
Some("warn-only") => {
println!(" protection: warn-only (commands are checked but NOT blocked)");
}
Some("degraded") => {
println!(" protection: DEGRADED — downgraded to warn-only this session");
println!();
println!(" WARNING: tirith protection was downgraded mid-session.");
println!(" Commands are still checked, but a dangerous one is NO LONGER blocked.");
println!(" Restart your shell to recover full protection. See the bash section");
println!(" below (and 'tirith doctor --bundle' for a full diagnostic report).");
println!();
}
Some("off") => {
println!(" protection: off (the tirith hook installed nothing in this shell)");
}
Some(other) => {
println!(" protection: {other}");
}
None => {
}
}
}
fn print_human(info: &DoctorInfo) {
println!("tirith {}", info.version);
println!(" binary: {}", info.binary_path);
if !info.shadow_binaries.is_empty() && !crate::cli::is_quiet() {
println!();
println!(" WARNING: other 'tirith' binaries found on PATH:");
for shadow in &info.shadow_binaries {
println!(" - {shadow}");
}
println!(" These may shadow this binary and cause unexpected behavior.");
println!(" Check with: {}", super::tirith_path_lookup_command());
println!();
}
println!(" shell: {}", info.detected_shell);
println!(" interactive: {}", info.interactive);
println!(
" hook dir: {}",
info.hook_dir.as_deref().unwrap_or("not found")
);
println!(" materialized: {}", info.hooks_materialized);
println!(
" profile: {}",
info.shell_profile.as_deref().unwrap_or("not found")
);
if info.hook_configured {
println!(" hook status: configured");
} else {
println!(" hook status: NOT CONFIGURED");
println!();
println!(" WARNING: tirith shell hook is not configured!");
println!(" Commands will NOT be intercepted until you add to your shell profile:");
println!();
match info.detected_shell.as_str() {
"zsh" => {
println!(" echo 'eval \"$(tirith init --shell zsh)\"' >> ~/.zshrc");
println!(" source ~/.zshrc");
}
"bash" => {
println!(" echo 'eval \"$(tirith init --shell bash)\"' >> ~/.bashrc");
println!(" source ~/.bashrc");
}
"fish" => {
println!(
" echo 'tirith init --shell fish | source' >> ~/.config/fish/config.fish"
);
println!(" source ~/.config/fish/config.fish");
}
"nushell" => {
println!(" # First, materialize hooks:");
println!(" tirith init --shell nushell");
println!(" # Then add to ~/.config/nushell/config.nu:");
if let Some(ref dir) = info.hook_dir {
let escaped = dir.replace('\\', r"\\").replace('"', r#"\""#);
println!(r#" source "{escaped}/lib/nushell-hook.nu""#);
} else {
println!(" source <hook-dir>/lib/nushell-hook.nu");
println!(
" # (run 'tirith init --shell nushell' first to determine the path)"
);
}
}
_ => {
println!(" eval \"$(tirith init)\"");
}
}
println!();
}
print_protection_status(info.tirith_status.as_deref());
let has_any_bash_env = info.bash_requested_mode.is_some()
|| info.bash_requested_enforce.is_some()
|| info.bash_requested_require_enter.is_some()
|| info.bash_effective_mode.is_some()
|| info.bash_effective_protection.is_some();
if info.detected_shell == "bash" || has_any_bash_env {
let requested_mode = info.bash_requested_mode.as_deref().unwrap_or("(default)");
let requested_enforce = if info
.bash_requested_enforce
.as_deref()
.map(env_is_truthy)
.unwrap_or(false)
{
"on"
} else {
"off"
};
let require_enter = if info
.bash_requested_require_enter
.as_deref()
.map(env_is_truthy)
.unwrap_or(false)
{
"on"
} else {
"off"
};
println!(" requested mode: {requested_mode}");
println!(" requested enforce: {requested_enforce}");
if require_enter == "on" {
println!(" require-enter: on (reserved; not enforced by this version)");
} else {
println!(" require-enter: {require_enter}");
}
match (
info.bash_effective_mode.as_deref(),
info.bash_effective_protection.as_deref(),
) {
(Some(mode), Some(protection)) => {
println!(" bash mode: {mode}");
println!(" effective protection: {protection}");
}
_ => {
println!(" bash hook: not loaded in this process");
}
}
if info.bash_safe_mode {
println!(" safe mode: on (previous enter-mode failure)");
println!(" Reset: tirith doctor --reset-bash-safe-mode");
} else {
println!(" safe mode: off");
}
match info.bash_enter_capability.as_deref() {
Some(verdict) => {
let fresh = info.bash_enter_capability_fresh.unwrap_or(false);
let writer = info
.bash_enter_capability_tirith_version
.as_deref()
.map(|v| format!(", tirith {v}"))
.unwrap_or_default();
if fresh {
println!(" enter capability: {verdict} (self-test verdict{writer})");
} else {
println!(
" enter capability: {verdict} (STALE — measured on a different bash{writer})"
);
println!(" Re-test: tirith doctor --simulate-enter");
}
if let Some(reason) = info.bash_enter_capability_reason.as_deref() {
println!(" {reason}");
}
}
None => {
println!(" enter capability: not tested — run tirith doctor --simulate-enter");
}
}
let blocking_remediation_needed = bash_blocking_remediation_needed(
info.bash_requested_mode.as_deref(),
info.bash_effective_mode.as_deref(),
info.bash_enter_capability.as_deref(),
info.bash_enter_capability_fresh,
);
if blocking_remediation_needed {
let forced_enter = info
.bash_requested_mode
.as_deref()
.map(|mode| mode.eq_ignore_ascii_case("enter"))
.unwrap_or(false);
let enforce_armed = info
.bash_requested_enforce
.as_deref()
.map(env_is_truthy)
.unwrap_or(false);
let live_enforcement_degraded = enforce_armed
&& matches!(
info.bash_effective_protection.as_deref(),
Some(protection) if !protection.eq_ignore_ascii_case("blocks")
);
if forced_enter || !enforce_armed {
println!(" to block on bash: export TIRITH_BASH_PREEXEC_ENFORCE=1 before the");
println!(
" 'eval \"$(tirith init --shell bash)\"' line, then"
);
println!(" start a new shell.");
} else if live_enforcement_degraded {
println!(" to restore blocking: TIRITH_BASH_PREEXEC_ENFORCE is set, but this");
println!(
" shell is not blocking. Resolve any hook warning,"
);
println!(" then start a new shell.");
}
if forced_enter {
println!(" Also remove 'export TIRITH_BASH_MODE=enter': it");
println!(" forces the broken enter mode, and preexec");
println!(" enforcement never arms in a forced-enter shell.");
}
}
if safe_mode_overridden_by_env(info.bash_safe_mode, info.bash_requested_mode.as_deref()) {
println!(
" warning: TIRITH_BASH_MODE=enter overrides the safe-mode flag —"
);
println!(
" enter mode is re-attempted (and may keep failing) on"
);
println!(
" every new shell. Unset TIRITH_BASH_MODE to honor the"
);
println!(" recorded failure and stay in preexec.");
}
} else if info.bash_safe_mode {
println!(" bash safe mode: on (Reset: tirith doctor --reset-bash-safe-mode)");
}
if info.policy_paths.is_empty() {
println!(" policies: (none found)");
} else {
for (i, p) in info.policy_paths.iter().enumerate() {
if i == 0 {
println!(" policies: {p}");
} else {
println!(" {p}");
}
}
}
if let Some(ref root) = info.policy_root_env {
println!(" policy root: {root} (TIRITH_POLICY_ROOT)");
}
println!(
" data dir: {}",
info.data_dir.as_deref().unwrap_or("not found")
);
println!(
" log path: {}",
info.log_path.as_deref().unwrap_or("not found")
);
println!(
" last trigger: {}",
info.last_trigger_path.as_deref().unwrap_or("not found")
);
println!(
" cloaking: {}",
if info.cloaking_available {
"available"
} else {
"not available (Unix-only)"
}
);
println!(
" webhooks: {}",
if info.webhooks_available {
"available"
} else {
"not available (Unix-only)"
}
);
if let Some(ref tdb) = info.threat_db {
if !tdb.installed {
println!(" threat DB: not installed — run 'tirith threat-db update'");
} else if let Some(ref err) = tdb.error {
println!(" threat DB: ERROR: {err}");
println!(" re-download with 'tirith threat-db update --force'");
} else if tdb.signature_valid == Some(false) {
println!(
" threat DB: INVALID SIGNATURE — re-download with 'tirith threat-db update --force'"
);
} else if tdb.stale {
let age_str = match tdb.age_hours {
Some(h) if h < 48.0 => format!("{:.0}h old", h),
Some(h) => format!("{:.0}d old", h / 24.0),
None => "unknown age".to_string(),
};
println!(" threat DB: STALE ({age_str}) — run 'tirith threat-db update'");
} else {
let path = tdb.path.as_deref().unwrap_or("unknown");
let age_str = match tdb.age_hours {
Some(h) if h < 1.0 => format!("{:.0}m old", h * 60.0),
Some(h) if h < 48.0 => format!("{:.0}h old", h),
Some(h) => format!("{:.0}d old", h / 24.0),
None => "unknown age".to_string(),
};
let total = tdb.total_entries.unwrap_or(0);
let sig = if tdb.signature_valid == Some(true) {
"signature ok"
} else {
"signature unknown"
};
println!(" threat DB: {path} ({age_str}, {total} entries, {sig})");
}
} else {
println!(" threat DB: not available");
}
if info.baseline.enabled {
if info.baseline.early_baseline_mode {
println!(
" anomaly base: ON — early-baseline mode ({} obs; signals not yet meaningful until {})",
info.baseline.total_observations,
tirith_core::baseline::EARLY_BASELINE_ENTRIES,
);
} else {
println!(
" anomaly base: ON ({} observations in window)",
info.baseline.total_observations
);
}
} else {
println!(" anomaly base: off (opt-in — enable with 'tirith baseline learn')");
}
{
let c = &info.capsule;
if c.deny_all_enforceable {
println!(
" capsule: backend '{}' — deny-all containment ENFORCEABLE \
(fs={}, exec={}, raw-net-deny={}, rlimits={}, env={}, handles={})",
c.backend_id,
c.fs_read_enforced && c.fs_write_enforced,
c.exec_limited,
c.network_raw_denied,
c.resource_limits_enforced,
c.env_isolated,
c.handles_isolated,
);
} else {
println!(
" capsule: backend '{}' — containment NOT fully enforceable on this host; \
enforcing surfaces fail closed",
c.backend_id
);
}
println!(
" domain-egress enforceable: {}{}",
c.domain_egress_enforceable,
if c.external_helpers.is_empty() {
String::new()
} else {
let names: Vec<&str> = c.external_helpers.iter().map(|h| h.name).collect();
format!(
" | external helper(s) detected on PATH: {}",
names.join(", ")
)
}
);
}
if let Some(ref gaps) = info.detection_gaps {
println!();
println!("Detection coverage (last 7 days)");
println!(
" {} commands scanned, {} blocked, {} warned",
gaps.total_commands, gaps.blocked, gaps.warned
);
println!(
" {} of {} records have full detection data{}",
gaps.records_with_raw,
gaps.total_commands,
if gaps.records_without_raw > 0 {
format!(" ({} legacy, pre-upgrade)", gaps.records_without_raw)
} else {
String::new()
}
);
if gaps.hidden_findings == 0 && gaps.records_with_raw > 0 {
println!(
" No hidden findings — detection coverage is complete at current paranoia level"
);
} else if gaps.hidden_findings == 0 && gaps.records_with_raw == 0 {
println!(
" No raw detection data available (all records are pre-upgrade). Cannot assess hidden findings."
);
} else {
let pct = if gaps.raw_total_findings > 0 {
(gaps.hidden_findings as f64 / gaps.raw_total_findings as f64 * 100.0) as usize
} else {
0
};
println!(
" Hidden: {} findings detected but not surfaced ({}% of raw detections in {} analyzed records)",
gaps.hidden_findings, pct, gaps.records_analyzed
);
if !gaps.hidden_top_rules.is_empty() {
let top_str: Vec<String> = gaps
.hidden_top_rules
.iter()
.map(|(rule, count)| format!("{rule} ({count})"))
.collect();
println!(" Top hidden: {}", top_str.join(", "));
}
}
println!();
println!(" Paranoia levels:");
let levels: [(u8, &str, &str); 3] = [
(1, "1-2", "Medium+ only — hides Low and Info findings"),
(3, "3", "Low+ — shows low-severity security patterns"),
(4, "4", "All — full detection visibility"),
];
for (threshold, label, desc) in &levels {
let marker = if (*threshold <= 2 && gaps.current_paranoia <= 2)
|| (*threshold > 2 && gaps.current_paranoia == *threshold)
{
" (current)"
} else {
""
};
println!(" {label}{marker}: {desc}");
}
if gaps.hidden_findings > 0 {
let next_level = match gaps.current_paranoia {
1 | 2 => 3,
3 => 4,
_ => gaps.current_paranoia,
};
println!();
if next_level > gaps.current_paranoia {
println!(
" \u{2192} Set 'paranoia: {}' in .tirith/policy.yaml to surface these detections",
next_level
);
}
}
}
use tirith_core::license::KeyFormatStatus;
match tirith_core::license::key_format_status() {
KeyFormatStatus::LegacyUnsigned => {
println!(
" license key: WARNING: Using unsigned legacy license key. Official v0.3.0+ releases require signed tokens, so this key is ignored for tier verification."
);
}
KeyFormatStatus::LegacyInvalid => {
println!(" license key: WARNING: Invalid legacy license format. Key will not be recognized.");
}
KeyFormatStatus::Malformed => {
println!(" license key: WARNING: License key appears malformed (bad signed token structure).");
}
KeyFormatStatus::SignedStructural => {
println!(" license key: signed (structural check passed)");
}
KeyFormatStatus::NoKey => {
println!(" license key: not found");
}
}
}
fn unreadable_profile_msg(
command_label: &str,
profile: &std::path::Path,
err: &std::io::Error,
) -> String {
format!(
"{command_label} cannot read profile {}: {err}",
profile.display()
)
}
pub(crate) fn check_shell_profile(shell: &str, command_label: &str) -> (Option<PathBuf>, bool) {
let home = match home::home_dir() {
Some(h) => h,
None => return (None, false),
};
let profile_candidates: Vec<PathBuf> = match shell {
"zsh" => vec![
home.join(".zshrc"),
home.join(".zshenv"),
home.join(".zprofile"),
],
"bash" => vec![
home.join(".bashrc"),
home.join(".bash_profile"),
home.join(".profile"),
],
"fish" => {
let mut candidates = vec![home.join(".config/fish/config.fish")];
let conf_d = home.join(".config/fish/conf.d");
if let Ok(entries) = std::fs::read_dir(&conf_d) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("fish") {
candidates.push(path);
}
}
}
candidates
}
"powershell" | "pwsh" => {
let docs = home.join("Documents");
vec![
docs.join("PowerShell/Microsoft.PowerShell_profile.ps1"),
docs.join("WindowsPowerShell/Microsoft.PowerShell_profile.ps1"),
home.join(".config/powershell/Microsoft.PowerShell_profile.ps1"),
]
}
"nushell" | "nu" => {
let xdg = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
vec![xdg.join("nushell/config.nu")]
}
_ => return (None, false),
};
let mut first_existing = None;
for profile in &profile_candidates {
if profile.exists() {
if first_existing.is_none() {
first_existing = Some(profile.clone());
}
match std::fs::read_to_string(profile) {
Ok(contents) => {
let configured = contents.contains("tirith init")
|| contents.contains("tirith-hook")
|| contents.contains("_tirith_");
if configured {
return (Some(profile.clone()), true);
}
}
Err(e) => {
eprintln!("{}", unreadable_profile_msg(command_label, profile, &e));
}
}
}
}
let primary = first_existing.or_else(|| profile_candidates.into_iter().next());
(primary, false)
}
#[cfg(unix)]
fn run_simulate_enter() -> i32 {
println!("tirith: running bash enter-mode delivery self-test...");
let outcome = crate::cli::bash_capability::run_and_cache();
if let Some(v) = &outcome.bash_version {
println!(" bash version: {v}");
}
if let Some(p) = &outcome.bash_path {
println!(" bash binary: {}", p.display());
}
println!(" enter delivery: {}", outcome.capability.describe());
println!(" detail: {}", outcome.reason);
match &outcome.cache_path {
Some(path) => println!(" cached to: {}", path.display()),
None => println!(" cache: NOT written (hook keeps its safe default)"),
}
println!();
if outcome.capability.enables_enter() && outcome.cache_path.is_some() {
println!("tirith: enter mode (blocking) is enabled for bash in new shells.");
} else if outcome.capability.enables_enter() {
println!("tirith: enter-mode delivery works here, but the capability cache could not");
println!(" be written — new shells will fall back to preexec until it can be.");
} else {
println!("tirith: bash will use preexec mode (warn-only). For blocking, set");
println!(" TIRITH_BASH_PREEXEC_ENFORCE=1, or run tirith on a shell where");
println!(" enter-mode delivery works.");
println!();
println!(" Preexec enforcement needs to own the whole typed line, so it");
println!(" refuses at startup and says so when any of these hold:");
println!(" - HISTCONTROL/HISTIGNORE filter history (ignorespace,");
println!(" ignoredups, ignoreboth), or history is off");
println!(" - extdebug is already enabled outside tirith");
println!(" - PROMPT_COMMAND is readonly, associative, or otherwise");
println!(" cannot be bracketed");
println!(" It also drops to off mid-session if another tool takes over the");
println!(" DEBUG trap. Enter mode has none of these constraints.");
}
0
}
#[cfg(not(unix))]
fn run_simulate_enter() -> i32 {
println!("tirith: --simulate-enter is only meaningful on Unix (bash enter mode)");
0
}
fn reset_safe_mode() -> i32 {
let state_dir = match tirith_core::policy::state_dir() {
Some(d) => d,
None => {
eprintln!("tirith: could not determine state directory");
return 1;
}
};
let flag = state_dir.join("bash-safe-mode");
if flag.exists() {
match std::fs::remove_file(&flag) {
Ok(()) => {
println!("tirith: bash safe-mode flag removed");
println!(" Next shell will attempt enter mode again.");
0
}
Err(e) => {
eprintln!("tirith: failed to remove {}: {e}", flag.display());
1
}
}
} else {
println!("tirith: no bash safe-mode flag found (enter mode is already enabled)");
0
}
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use crate::cli::test_harness::{with_fake_env, CwdGuard, EnvGuard, ENV_LOCK};
fn first_kiro(tools: &[DetectedTool]) -> Option<&DetectedTool> {
tools.iter().find(|t| t.name == "kiro")
}
fn count_named(tools: &[DetectedTool], name: &str) -> usize {
tools.iter().filter(|t| t.name == name).count()
}
#[test]
fn bash_blocking_guidance_requires_a_fresh_working_enter_mode() {
assert!(!bash_blocking_remediation_needed(
None,
Some("enter"),
Some("works"),
Some(true),
));
for (requested, effective, capability, fresh) in [
(None, Some("preexec"), Some("works"), Some(true)),
(Some("preexec"), None, Some("works"), Some(true)),
(None, None, Some("works"), Some(false)),
(None, None, Some("broken"), Some(true)),
(None, None, None, None),
] {
assert!(
bash_blocking_remediation_needed(requested, effective, capability, fresh,),
"missing, stale, broken, or preexec state must retain blocking guidance"
);
}
}
#[test]
fn unreadable_profile_msg_uses_caller_label_not_hardcoded_doctor() {
let profile = PathBuf::from("/tmp/.zshrc");
let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let doctor_msg = unreadable_profile_msg("tirith: doctor:", &profile, &err);
assert_eq!(
doctor_msg,
"tirith: doctor: cannot read profile /tmp/.zshrc: denied"
);
let onboard_msg = unreadable_profile_msg("tirith: onboard:", &profile, &err);
assert!(
onboard_msg.starts_with("tirith: onboard:"),
"onboard label must drive the prefix, got: {onboard_msg}"
);
assert!(
!onboard_msg.contains("doctor:"),
"helper must not emit a hard-coded 'doctor:' prefix, got: {onboard_msg}"
);
assert!(onboard_msg.contains("cannot read profile /tmp/.zshrc: denied"));
}
fn rendered_guard(integration: &str) -> String {
format!(
"const TIRITH_BIN = \"/opt/tirith/bin/tirith\";\nconst TIRITH_INTEGRATION = \"{integration}\";\npi.on(\"tool_call\", () => {{}});\n"
)
}
fn write_executable(path: &std::path::Path, body: &str) {
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
fn write_effective_wrapper(path: &std::path::Path, protocol: &str) {
let adapter = path.parent().unwrap().join("tirith-check.py");
write_executable(&adapter, crate::assets::TIRITH_CHECK_PY);
let adapter = crate::cli::setup::shell_quote(adapter.to_str().unwrap(), "bash");
write_executable(
path,
&format!(
"#!/bin/sh\nTIRITH_BIN=/opt/tirith/bin/tirith \\\nTIRITH_HOOK_PROTOCOL={protocol} \\\nexec /usr/bin/python3 {adapter}\n"
),
);
}
#[test]
fn detect_ai_tools_reports_installed_blocking_hooks() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
for (relative, integration) in [
(".pi/agent/extensions/tirith-guard.ts", "pi-cli"),
(".prime/agent/extensions/tirith-guard.ts", "prime-agent"),
(".omp/agent/hooks/pre/tirith-guard.ts", "omp"),
] {
let path = home.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, rendered_guard(integration)).unwrap();
}
let cline_wrapper = cline_hook_artifact(home);
write_effective_wrapper(&cline_wrapper, "cline");
let tools = detect_ai_tools_with(home, Some(cwd));
for name in ["pi-cli", "prime-agent", "omp", "cline"] {
let found = tools
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("{name} hook not detected"));
assert_eq!(found.configured_scope, Some("user"), "{name}");
assert_eq!(found.hook_state, HookInstallState::Effective, "{name}");
assert_eq!(count_named(&tools, name), 1, "{name} must appear once");
}
});
}
#[test]
fn detect_ai_tools_retains_broken_hook_artifacts_for_repair() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
let guard = home.join(".omp/agent/hooks/pre/tirith-guard.ts");
std::fs::create_dir_all(guard.parent().unwrap()).unwrap();
std::fs::write(
&guard,
"const TIRITH_BIN = \"__TIRITH_BIN__\";\npi.on(\"tool_call\", () => {});\n",
)
.unwrap();
let cline_wrapper = cline_hook_artifact(home);
std::fs::create_dir_all(cline_wrapper.parent().unwrap()).unwrap();
std::fs::write(
&cline_wrapper,
"#!/bin/sh\nexec python3 tirith-check.py cline\n",
)
.unwrap();
std::fs::create_dir_all(home.join(".openhands")).unwrap();
std::fs::write(home.join(".openhands/hooks.json"), "{\"stop\": []}").unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
for name in ["omp", "cline", "openhands"] {
let found = tools
.iter()
.find(|tool| tool.name == name)
.unwrap_or_else(|| panic!("{name} broken hook must remain repairable"));
assert_eq!(found.hook_state, HookInstallState::Broken, "{name}");
}
});
}
#[test]
fn detect_ai_tools_uses_setup_resolvers_for_custom_and_profile_paths() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
let pi_root = home.join("custom-pi-agent");
let prime_root = home.join("custom-prime-agent");
let _pi = EnvGuard::set("PI_CODING_AGENT_DIR", &pi_root);
let _prime = EnvGuard::set("PRIME_AGENT_CODING_AGENT_DIR", &prime_root);
let _omp_profile = EnvGuard::set("OMP_PROFILE", std::path::Path::new("work"));
let _omp_config = EnvGuard::set("PI_CONFIG_DIR", std::path::Path::new("custom-omp"));
std::fs::create_dir_all(&pi_root).unwrap();
std::fs::create_dir_all(&prime_root).unwrap();
std::fs::create_dir_all(home.join("custom-omp/profiles/work/agent")).unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
for name in ["pi-cli", "prime-agent", "omp"] {
let found = tools
.iter()
.find(|tool| tool.name == name)
.unwrap_or_else(|| panic!("{name} custom installation not detected"));
assert_eq!(found.configured_scope, Some("user"), "{name}");
assert_eq!(found.hook_state, HookInstallState::Absent, "{name}");
}
let omp_path = crate::cli::setup::omp_user_guard_path(home).unwrap();
assert_eq!(
omp_path,
home.join("custom-omp/profiles/work/agent/hooks/pre/tirith-guard.ts")
);
});
}
#[test]
fn complete_wrapper_chain_requires_the_adjacent_current_adapter() {
with_fake_env(true, |home, cwd| {
let wrapper = cline_hook_artifact(home);
write_effective_wrapper(&wrapper, "cline");
let adapter = wrapper.parent().unwrap().join("tirith-check.py");
std::fs::remove_file(&adapter).unwrap();
let tools = detect_ai_tools_with(home, cwd);
let cline = tools.iter().find(|tool| tool.name == "cline").unwrap();
assert_eq!(cline.hook_state, HookInstallState::Broken);
write_executable(&adapter, "# stale adapter\n");
let tools = detect_ai_tools_with(home, cwd);
let cline = tools.iter().find(|tool| tool.name == "cline").unwrap();
assert_eq!(cline.hook_state, HookInstallState::Broken);
});
}
#[test]
fn hook_reads_refuse_symlinks_fifos_and_oversized_files() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
let dir = tempfile::tempdir().unwrap();
let regular = dir.path().join("regular");
std::fs::write(®ular, "ok").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(®ular, &link).unwrap();
assert!(read_hook_text(&link).is_none());
let oversized = dir.path().join("oversized");
let file = std::fs::File::create(&oversized).unwrap();
file.set_len(MAX_DOCTOR_HOOK_BYTES + 1).unwrap();
assert!(read_hook_text(&oversized).is_none());
let fifo = dir.path().join("fifo");
let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap();
if unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) } == 0 {
assert!(read_hook_text(&fifo).is_none());
}
}
#[test]
fn detect_ai_tools_reports_openhands_hooks_at_both_scopes_project_first() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
let _work_dir = EnvGuard::remove("OPENHANDS_WORK_DIR");
let install = |root: &std::path::Path| {
let wrapper = root.join(".openhands/hooks/tirith-pre-tool-use");
write_effective_wrapper(&wrapper, "openhands");
let quoted = crate::cli::setup::shell_quote(wrapper.to_str().unwrap(), "bash");
std::fs::write(
root.join(".openhands/hooks.json"),
serde_json::json!({
"pre_tool_use": [{
"matcher": "terminal",
"hooks": [{"type": "command", "command": quoted, "timeout": 15}]
}]
})
.to_string(),
)
.unwrap();
};
install(home);
let tools = detect_ai_tools_with(home, Some(cwd));
let found = tools
.iter()
.find(|t| t.name == "openhands")
.expect("user hook");
assert_eq!(found.configured_scope, Some("user"));
assert_eq!(found.hook_state, HookInstallState::Effective);
install(cwd);
let tools = detect_ai_tools_with(home, Some(cwd));
let found = tools
.iter()
.find(|t| t.name == "openhands")
.expect("project hook");
assert_eq!(found.configured_scope, Some("project"));
assert_eq!(found.hook_state, HookInstallState::Effective);
assert_eq!(count_named(&tools, "openhands"), 1);
});
}
#[test]
fn detect_ai_tools_prefers_the_project_grok_hook_and_never_doubles_it() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(cwd.join(".git")).unwrap();
for base in [home.to_path_buf(), cwd.to_path_buf()] {
let hooks = base.join(".grok/hooks");
std::fs::create_dir_all(&hooks).unwrap();
write_executable(
&hooks.join("tirith-check.py"),
crate::assets::TIRITH_CHECK_PY,
);
let adapter = crate::cli::setup::shell_quote(
hooks.join("tirith-check.py").to_str().unwrap(),
"bash",
);
let hook_config = serde_json::json!({
"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{
"type": "command", "command": format!("/usr/bin/python3 {adapter}"), "timeout": 15,
"env": {"TIRITH_BIN": "/opt/tirith/bin/tirith", "TIRITH_HOOK_PROTOCOL": "grok-build"}
}]}]}
})
.to_string();
std::fs::write(hooks.join("tirith.json"), hook_config).unwrap();
}
let _cwd = CwdGuard::set(cwd);
let tools = detect_ai_tools_with(home, Some(cwd));
assert_eq!(
count_named(&tools, "grok-build"),
1,
"a host configured at both scopes must still report one row"
);
assert_eq!(
tools
.iter()
.find(|t| t.name == "grok-build")
.unwrap()
.configured_scope,
Some("project")
);
assert_eq!(
tools
.iter()
.find(|t| t.name == "grok-build")
.unwrap()
.hook_state,
HookInstallState::Effective
);
});
}
#[test]
fn detect_ai_tools_passes_kiro_user_scope() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(home.join(".kiro/agents")).unwrap();
std::fs::write(home.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
let k = first_kiro(&tools).expect("kiro detected");
assert_eq!(k.configured_scope, Some("user"));
assert_eq!(count_named(&tools, "kiro"), 1, "exactly one kiro entry");
});
}
#[test]
fn detect_ai_tools_passes_kiro_project_scope() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(cwd.join(".kiro/agents")).unwrap();
std::fs::write(cwd.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
let k = first_kiro(&tools).expect("kiro detected");
assert_eq!(k.configured_scope, Some("project"));
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn detect_ai_tools_passes_kiro_project_scope_from_subdir() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(cwd.join(".kiro/agents")).unwrap();
std::fs::write(cwd.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
let subdir = cwd.join("sub").join("dir");
std::fs::create_dir_all(&subdir).unwrap();
let tools = detect_ai_tools_with(home, Some(&subdir));
let k = first_kiro(&tools).expect("kiro detected from subdir");
assert_eq!(k.configured_scope, Some("project"));
});
}
#[test]
fn detect_ai_tools_kiro_user_bootstrap_only() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(home.join(".kiro")).unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
let k = first_kiro(&tools).expect("kiro bootstrap");
assert_eq!(k.configured_scope, None);
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn detect_ai_tools_kiro_project_bootstrap_from_subdir() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(cwd.join(".kiro")).unwrap();
let subdir = cwd.join("sub").join("dir");
std::fs::create_dir_all(&subdir).unwrap();
let tools = detect_ai_tools_with(home, Some(&subdir));
let k = first_kiro(&tools).expect("kiro project-bootstrap");
assert_eq!(k.configured_scope, None);
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn detect_ai_tools_kiro_project_bootstrap_beats_user_bootstrap() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(home.join(".kiro")).unwrap();
std::fs::create_dir_all(cwd.join(".kiro")).unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
let k = first_kiro(&tools).expect("kiro detected");
assert_eq!(
k.configured_scope, None,
"bootstrap (no managed file) is the right verdict"
);
assert_eq!(
count_named(&tools, "kiro"),
1,
"exactly one entry, not duplicate project+user bootstrap"
);
});
}
#[test]
fn detect_ai_tools_kiro_prefers_project_over_user() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(home.join(".kiro/agents")).unwrap();
std::fs::write(home.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
std::fs::create_dir_all(cwd.join(".kiro/agents")).unwrap();
std::fs::write(cwd.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
let k = first_kiro(&tools).expect("kiro detected");
assert_eq!(k.configured_scope, Some("project"));
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn detect_ai_tools_does_not_classify_home_kiro_as_project() {
with_fake_env(true, |home, _cwd| {
std::fs::create_dir_all(home.join(".kiro/agents")).unwrap();
std::fs::write(home.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
let project = home.join("projects").join("myrepo");
std::fs::create_dir_all(&project).unwrap();
let tools = detect_ai_tools_with(home, Some(&project));
let k = first_kiro(&tools).expect("kiro detected");
assert_eq!(
k.configured_scope,
Some("user"),
"user-scope agent must NOT be misclassified as project just because cwd is under $HOME"
);
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn detect_ai_tools_home_kiro_only_is_user_bootstrap_not_project() {
with_fake_env(true, |home, _cwd| {
std::fs::create_dir_all(home.join(".kiro")).unwrap();
let project = home.join("projects").join("myrepo");
std::fs::create_dir_all(&project).unwrap();
let tools = detect_ai_tools_with(home, Some(&project));
let k = first_kiro(&tools).expect("kiro bootstrap");
assert_eq!(
k.configured_scope, None,
"bootstrap entry, not configured project"
);
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn detect_ai_tools_kiro_prefers_user_over_bootstrap() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
std::fs::create_dir_all(home.join(".kiro/agents")).unwrap();
std::fs::write(home.join(".kiro/agents/tirith-security.json"), "{}").unwrap();
let tools = detect_ai_tools_with(home, Some(cwd));
let k = first_kiro(&tools).expect("kiro detected");
assert_eq!(k.configured_scope, Some("user"));
assert_eq!(count_named(&tools, "kiro"), 1);
});
}
#[test]
fn collect_policy_paths_finds_repo_root_policy() {
with_fake_env(true, |_home, cwd| {
let cwd = cwd.expect("cwd set");
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
std::fs::create_dir_all(cwd.join(".git")).unwrap();
std::fs::create_dir_all(cwd.join(".tirith")).unwrap();
std::fs::write(cwd.join(".tirith/policy.yaml"), "fail_mode: open\n").unwrap();
let expected = cwd.join(".tirith/policy.yaml").display().to_string();
assert_eq!(
collect_policy_paths(cwd.to_str()),
vec![expected],
"doctor must list the repo-root policy",
);
});
}
#[test]
fn collect_policy_paths_walks_up_from_subdir() {
with_fake_env(true, |_home, cwd| {
let cwd = cwd.expect("cwd set");
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
std::fs::create_dir_all(cwd.join(".git")).unwrap();
std::fs::create_dir_all(cwd.join(".tirith")).unwrap();
std::fs::write(cwd.join(".tirith/policy.yaml"), "fail_mode: open\n").unwrap();
let subdir = cwd.join("src/inner");
std::fs::create_dir_all(&subdir).unwrap();
let expected = cwd.join(".tirith/policy.yaml").display().to_string();
assert_eq!(
collect_policy_paths(subdir.to_str()),
vec![expected],
"walk-up from a subdir must surface the repo-root policy",
);
});
}
#[test]
fn collect_policy_paths_finds_cwd_policy_without_git() {
with_fake_env(true, |_home, cwd| {
let cwd = cwd.expect("cwd set");
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
std::fs::create_dir_all(cwd.join(".tirith")).unwrap();
std::fs::write(cwd.join(".tirith/policy.yaml"), "fail_mode: open\n").unwrap();
let expected = cwd.join(".tirith/policy.yaml").display().to_string();
assert_eq!(
collect_policy_paths(cwd.to_str()),
vec![expected],
"a cwd-local policy with no .git must still be listed (#112 repro)",
);
});
}
#[test]
fn collect_policy_paths_finds_user_config_policy_and_dedups() {
with_fake_env(true, |home, cwd| {
let cwd = cwd.expect("cwd set");
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
let config = home.join(".config/tirith");
std::fs::create_dir_all(&config).unwrap();
std::fs::write(config.join("policy.yaml"), "fail_mode: open\n").unwrap();
let expected = config.join("policy.yaml").display().to_string();
let paths = collect_policy_paths(cwd.to_str());
assert_eq!(
paths.len(),
1,
"active policy == user config policy must dedup to one entry: {paths:?}",
);
assert_eq!(paths, vec![expected]);
});
}
#[test]
fn collect_policy_paths_empty_when_no_policy() {
with_fake_env(true, |_home, cwd| {
let cwd = cwd.expect("cwd set");
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
assert!(
collect_policy_paths(cwd.to_str()).is_empty(),
"no policy anywhere must yield an empty list",
);
});
}
#[test]
fn safe_mode_overridden_only_when_flag_and_enter() {
assert!(safe_mode_overridden_by_env(true, Some("enter")));
assert!(!safe_mode_overridden_by_env(false, Some("enter"))); assert!(!safe_mode_overridden_by_env(true, Some("preexec"))); assert!(!safe_mode_overridden_by_env(true, None)); assert!(!safe_mode_overridden_by_env(true, Some("Enter"))); }
#[test]
fn tool_in_profile_detects_mention() {
let tmp = tempfile::tempdir().unwrap();
let profile = tmp.path().join(".zshrc");
std::fs::write(
&profile,
"eval \"$(tirith init --shell zsh)\"\neval \"$(starship init zsh)\"\n",
)
.unwrap();
assert!(
tool_in_profile("starship", Some(&profile)),
"starship init line must be detected in the profile"
);
assert!(
!tool_in_profile("atuin", Some(&profile)),
"atuin is absent from the profile and must not be detected"
);
}
#[test]
fn tool_in_profile_handles_missing_profile() {
assert!(!tool_in_profile("starship", None));
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(!tool_in_profile("starship", Some(&missing)));
}
#[cfg(unix)]
#[test]
fn tool_path_detection_is_pure_and_preserves_a_legitimate_hit() {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempfile::tempdir().unwrap();
let executable = directory.path().join("starship");
std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&executable, permissions).unwrap();
let path = std::env::join_paths([directory.path()]).unwrap();
assert!(tool_on_path_from("starship", &path));
assert!(!tool_on_path_from("missing", &path));
}
#[test]
fn detect_shell_tool_conflicts_reports_only_profile_hits_when_offline_of_path() {
let tmp = tempfile::tempdir().unwrap();
let profile = tmp.path().join(".bashrc");
std::fs::write(&profile, "eval \"$(zoxide init bash)\"\n").unwrap();
let found = detect_shell_tool_conflicts(Some(&profile));
let zoxide = found.iter().find(|t| t.name == "zoxide");
assert!(
zoxide.is_some(),
"zoxide mentioned in profile must be reported, got: {found:?}"
);
assert!(
zoxide.unwrap().in_profile,
"zoxide must be flagged as present in the profile"
);
assert!(
!found.iter().any(|t| t.name == "direnv" && !t.on_path),
"direnv with no signal at all must not appear"
);
}
#[test]
fn known_shell_tools_covers_the_documented_set() {
let names: Vec<&str> = KNOWN_SHELL_TOOLS.iter().map(|(n, _)| *n).collect();
for expected in [
"atuin", "starship", "fzf", "zoxide", "direnv", "mise", "asdf",
] {
assert!(
names.contains(&expected),
"{expected} must be in the known shell-tool table"
);
}
}
#[test]
fn print_protection_status_does_not_panic_on_any_input() {
for s in [
Some("blocks"),
Some("warn-only"),
Some("degraded"),
Some("off"),
Some("future-value"),
None,
] {
print_protection_status(s);
}
}
#[test]
fn redact_home_path_masks_the_literal_home_dir() {
let home = std::path::Path::new("/Users/alice");
let text = "hook dir: /Users/alice/.local/share/tirith/shell\npolicy: /Users/alice/.tirith/policy.yaml";
let red = redact_home_path(text, Some(home));
assert!(
!red.contains("/Users/alice"),
"the literal home path must not survive redaction, got:\n{red}"
);
assert!(
red.contains("~/.local/share/tirith/shell"),
"paths under home must be rewritten to ~, got:\n{red}"
);
}
#[test]
fn redact_home_path_handles_trailing_slash_and_degenerate_homes() {
let red = redact_home_path("x /home/bob/y", Some(std::path::Path::new("/home/bob/")));
assert_eq!(red, "x ~/y");
let untouched = "/usr/bin/tirith";
assert_eq!(
redact_home_path(untouched, Some(std::path::Path::new("/"))),
untouched
);
assert_eq!(redact_home_path(untouched, None), untouched);
}
#[test]
fn redact_secrets_masks_secret_named_keys() {
for line in [
"GITHUB_TOKEN=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"MY_API_KEY=whatever-value-here",
"db_password=hunter2",
"Some Secret: still-masked",
] {
let red = redact_secrets(line);
assert!(
red.contains("<redacted>"),
"a secret-named key must be masked: {line} -> {red}"
);
}
}
#[test]
fn redact_secrets_masks_token_shaped_values() {
for line in [
"TIRITH_SESSION_ID=ghp_0123456789abcdef0123456789abcdef0123",
"FOO=AKIAIOSFODNN7EXAMPLE",
"BAR=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefpracticallyajwt",
"BAZ=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
] {
let red = redact_secrets(line);
assert!(
red.contains("<redacted>"),
"a token-shaped value must be masked: {line} -> {red}"
);
}
}
#[test]
fn redact_secrets_leaves_benign_diagnostic_lines_intact() {
for line in [
"detected shell: bash",
"TIRITH_BASH_MODE=preexec",
"effective mode: enter",
"interactive: true",
"TERM=xterm-256color",
"bash safe mode: false",
] {
assert_eq!(
redact_secrets(line),
line,
"benign diagnostic line must not be redacted: {line}"
);
}
}
#[test]
fn looks_like_secret_does_not_flag_ordinary_values() {
for v in [
"preexec",
"bash",
"true",
"1",
"xterm-256color",
"/Users/alice/.local/state/tirith",
"warn-only",
] {
assert!(
!looks_like_secret(v),
"{v:?} must not be classified as a secret"
);
}
}
#[test]
fn build_bundle_text_redacts_secrets_and_home_path() {
with_fake_env(false, |home, _cwd| {
let secret = "ghp_DEADBEEFdeadbeef0123456789abcdef0123";
let _sid = EnvGuard::set("TIRITH_SESSION_ID", std::path::Path::new(secret));
let _leak = EnvGuard::set(
"AWS_SECRET_ACCESS_KEY",
std::path::Path::new("wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY"),
);
let text = build_bundle_text(Some(home));
assert!(
!text.contains(secret),
"a token-shaped value in an allowlisted env var must be redacted, got:\n{text}"
);
assert!(
text.contains("TIRITH_SESSION_ID=<redacted>"),
"the scrubbed var must still be listed (with a redacted value), got:\n{text}"
);
assert!(
!text.contains("AWS_SECRET_ACCESS_KEY"),
"a non-allowlisted secret var must not appear in the bundle at all, got:\n{text}"
);
assert!(
!text.contains("wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY"),
"a non-allowlisted secret value must never leak, got:\n{text}"
);
let home_str = home.to_string_lossy();
assert!(
!text.contains(home_str.as_ref()),
"the literal home path {home_str} must not survive in the bundle, got:\n{text}"
);
});
}
#[test]
fn build_bundle_text_has_the_documented_sections() {
with_fake_env(false, |home, _cwd| {
let text = build_bundle_text(Some(home));
for section in [
"tirith diagnostic bundle",
"== tirith ==",
"== shell & protection ==",
"== hook chain ==",
"== policy ==",
"== threat database ==",
"== environment (curated, redacted) ==",
"== end of bundle ==",
] {
assert!(
text.contains(section),
"bundle missing section {section:?}, got:\n{text}"
);
}
});
}
fn is_predictable_timestamp_bundle_name(name: &str) -> bool {
let Some(mid) = name
.strip_prefix("tirith-bundle-")
.and_then(|s| s.strip_suffix(".txt"))
else {
return false;
};
let bytes = mid.as_bytes();
bytes.len() == 16
&& bytes[..8].iter().all(u8::is_ascii_digit)
&& bytes[8] == b'T'
&& bytes[9..15].iter().all(u8::is_ascii_digit)
&& bytes[15] == b'Z'
}
#[test]
fn is_predictable_timestamp_bundle_name_recognises_the_old_format() {
assert!(is_predictable_timestamp_bundle_name(
"tirith-bundle-20260522T143000Z.txt"
));
assert!(!is_predictable_timestamp_bundle_name(
"tirith-bundle-a9Xk2Q.txt"
));
assert!(!is_predictable_timestamp_bundle_name("tirith-bundle-.txt"));
assert!(!is_predictable_timestamp_bundle_name("unrelated.txt"));
}
#[test]
fn write_bundle_file_uses_a_random_name_and_tight_mode() {
let dir = tempfile::tempdir().expect("bundle dir");
let body = "tirith diagnostic bundle\n== end of bundle ==\n";
let path = write_bundle_file(dir.path(), body).expect("bundle write");
assert!(path.exists(), "bundle file must exist after the write");
assert_eq!(
path.parent(),
Some(dir.path()),
"bundle must land in the requested directory"
);
assert_eq!(
std::fs::read_to_string(&path).expect("read bundle"),
body,
"bundle content must round-trip"
);
let name = path
.file_name()
.and_then(|n| n.to_str())
.expect("bundle file name");
assert!(
!is_predictable_timestamp_bundle_name(name),
"bundle filename {name:?} must not be the predictable \
tirith-bundle-<timestamp>.txt form"
);
assert!(
name.starts_with("tirith-bundle-") && name.ends_with(".txt"),
"bundle name {name:?} should keep the tirith-bundle- prefix and .txt suffix"
);
let path2 = write_bundle_file(dir.path(), body).expect("second bundle write");
assert_ne!(
path, path2,
"consecutive bundle writes must produce distinct random paths"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path)
.expect("bundle metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "bundle file must be mode 0600, got {mode:o}");
}
}
fn compat_report_for(detected_shell: &str, tirith_status: Option<&str>) -> CompatReport {
CompatReport {
version: "0.0.0-test".to_string(),
binary_path: "/tmp/tirith".to_string(),
detected_shell: detected_shell.to_string(),
interactive: false,
bash_requested_mode: None,
bash_effective_mode: None,
bash_effective_protection: None,
tirith_status: tirith_status.map(str::to_string),
bash_enter_capability: None,
bash_enter_capability_fresh: None,
bash_safe_mode: false,
hook_dir: None,
hooks_materialized: false,
hooks_stale: false,
shell_profile: None,
hook_configured: false,
shadow_binaries: Vec::new(),
policy_paths: Vec::new(),
threat_db: None,
shell_tools: Vec::new(),
powershell_compat: None,
visual_audit: None,
}
}
#[test]
fn compat_human_surfaces_tirith_status_for_non_bash_shell() {
for shell in ["zsh", "fish", "powershell", "nushell"] {
let report = compat_report_for(shell, Some("degraded"));
let out = format_compat_human(&report);
assert!(
out.contains("protection status: DEGRADED"),
"TIRITH_STATUS=degraded must be surfaced for a non-bash shell ({shell}); \
got:\n{out}"
);
assert!(
out.contains(&format!(
"no bash-specific mode state — detected shell is {shell}"
)),
"a non-bash shell with no bash env must take the non-bash branch ({shell}); \
got:\n{out}"
);
}
}
#[test]
fn compat_human_surfaces_plain_status_for_non_bash_shell() {
let report = compat_report_for("zsh", Some("warn-only"));
let out = format_compat_human(&report);
assert!(
out.contains("protection status: warn-only"),
"a non-degraded TIRITH_STATUS must still be surfaced for zsh; got:\n{out}"
);
}
#[test]
fn compat_human_omits_status_line_when_unset() {
let report = compat_report_for("zsh", None);
let out = format_compat_human(&report);
assert!(
!out.contains("protection status:"),
"no protection-status line should appear when TIRITH_STATUS is unset; got:\n{out}"
);
assert!(
out.contains("Install checks"),
"report must be complete; got:\n{out}"
);
}
#[test]
fn compat_protection_status_line_is_shell_independent() {
assert_eq!(
compat_protection_status_line(Some("degraded")).as_deref(),
Some(" protection status: DEGRADED (downgraded to warn-only this session)")
);
assert_eq!(
compat_protection_status_line(Some("blocks")).as_deref(),
Some(" protection status: blocks")
);
assert_eq!(compat_protection_status_line(None), None);
}
#[test]
fn bundle_env_allowlist_excludes_known_secret_holders() {
for forbidden in [
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
"GH_TOKEN",
"NPM_TOKEN",
] {
assert!(
!BUNDLE_ENV_ALLOWLIST.contains(&forbidden),
"{forbidden} must NOT be on the bundle env allowlist"
);
}
}
fn compat_report_with_ps(psreadline_available: Option<bool>) -> CompatReport {
let mut r = compat_report_for("powershell", None);
r.powershell_compat = Some(PsCompatInfo {
binary: "pwsh".to_string(),
psreadline_available,
hook_version_match: None,
});
r
}
#[cfg(unix)]
#[test]
fn powershell_compat_rejects_same_uid_path_shadows_without_execution() {
use std::os::unix::fs::PermissionsExt as _;
let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let temporary = tempfile::Builder::new()
.prefix("tirith-doctor-powershell-shadow-")
.tempdir_in(home::home_dir().expect("test account home"))
.unwrap();
let shadow_bin = temporary.path().join("shadow-bin");
std::fs::create_dir(&shadow_bin).unwrap();
let marker = temporary.path().join("powershell-helper-executed");
let quoted_marker = marker.display().to_string().replace('\'', "'\"'\"'");
for helper in ["pwsh", "powershell"] {
let path = shadow_bin.join(helper);
std::fs::write(
&path,
format!("#!/bin/sh\n: > '{quoted_marker}'\nexit 97\n"),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let inherited = std::env::var_os("PATH").unwrap_or_default();
let mut path_entries = vec![shadow_bin];
path_entries.extend(std::env::split_paths(&inherited));
let path = std::env::join_paths(path_entries).unwrap();
let _path = EnvGuard::set("PATH", std::path::Path::new(path.as_os_str()));
assert!(
detect_powershell_binary("powershell").is_none(),
"doctor must refuse same-UID PATH-selected PowerShell interpreters"
);
assert!(
!marker.exists(),
"doctor compatibility probing must not execute a PATH shadow"
);
}
#[test]
fn compat_human_ps_psreadline_some_true_renders_yes() {
let out = format_compat_human(&compat_report_with_ps(Some(true)));
assert!(
out.contains("PSReadLine module: yes") || out.contains("PSReadLine module: yes"),
"PSReadLine yes branch must render 'PSReadLine module: ... yes'; got:\n{out}"
);
}
#[test]
fn compat_human_ps_psreadline_some_false_renders_no_with_hint() {
let out = format_compat_human(&compat_report_with_ps(Some(false)));
assert!(
out.contains("no (key binding will not work)"),
"PSReadLine false branch must render 'no (key binding will not work)'; got:\n{out}"
);
}
#[test]
fn compat_human_ps_psreadline_none_renders_unknown() {
let out = format_compat_human(&compat_report_with_ps(None));
assert!(
out.contains("unknown (probe failed or timed out)"),
"PSReadLine None branch must render 'unknown (probe failed or timed out)'; got:\n{out}"
);
}
#[test]
fn gather_visual_audit_compat_reads_synthesized_result() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let cfg = tempfile::tempdir().expect("config tempdir");
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", cfg.path());
let tirith_cfg = cfg.path().join("tirith");
std::fs::create_dir_all(&tirith_cfg).expect("mkdir tirith config");
std::fs::write(
tirith_cfg.join("visual-audit-result.json"),
r#"{
"audited_at": "2026-05-30T12:00:00+00:00",
"terminal": "xterm-256color",
"pairs_total": 12,
"distinguishable": 9,
"indistinguishable": 2,
"skipped": 1,
"results": []
}"#,
)
.expect("write result");
let info = gather_visual_audit_compat().expect("result file present → Some");
assert_eq!(info.terminal, "xterm-256color");
assert_eq!(info.pairs_total, 12);
assert_eq!(info.distinguishable, 9);
assert_eq!(info.indistinguishable, 2);
assert_eq!(info.skipped, 1);
assert_eq!(info.audited_at, "2026-05-30T12:00:00+00:00");
}
#[test]
fn gather_visual_audit_compat_absent_is_none() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let cfg = tempfile::tempdir().expect("config tempdir");
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", cfg.path());
assert!(
gather_visual_audit_compat().is_none(),
"absent result file must fail-safe to None"
);
let mut report = compat_report_for("zsh", None);
report.visual_audit = None;
let out = format_compat_human(&report);
assert!(
!out.contains("Visual audit"),
"no visual-audit section should appear when the field is None; got:\n{out}"
);
}
#[test]
fn gather_visual_audit_compat_malformed_is_none() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let cfg = tempfile::tempdir().expect("config tempdir");
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", cfg.path());
let tirith_cfg = cfg.path().join("tirith");
std::fs::create_dir_all(&tirith_cfg).expect("mkdir tirith config");
std::fs::write(
tirith_cfg.join("visual-audit-result.json"),
b"this is not json",
)
.expect("write malformed");
assert!(
gather_visual_audit_compat().is_none(),
"malformed result file must fail-safe to None"
);
}
#[test]
fn compat_human_renders_visual_audit_section() {
let mut report = compat_report_for("zsh", None);
report.visual_audit = Some(VisualAuditCompatInfo {
audited_at: "2026-05-30T12:00:00+00:00".to_string(),
terminal: "screen".to_string(),
pairs_total: 20,
distinguishable: 17,
indistinguishable: 3,
skipped: 0,
});
let out = format_compat_human(&report);
assert!(out.contains("Visual audit"), "section header; got:\n{out}");
assert!(out.contains("recorded TERM:"), "TERM line; got:\n{out}");
assert!(
out.contains("local rendering risk"),
"indistinguishable>0 must render the risk hint; got:\n{out}"
);
assert!(
out.contains("not portable"),
"local-only caveat must be present; got:\n{out}"
);
}
#[test]
fn protection_mode_from_status_maps_known_and_unknown() {
use crate::cli::prompt_status::protection_mode_from_status;
assert_eq!(protection_mode_from_status(Some("blocks")), "guarded");
assert_eq!(protection_mode_from_status(Some("warn-only")), "warn-only");
assert_eq!(protection_mode_from_status(Some("degraded")), "degraded");
assert_eq!(protection_mode_from_status(Some("off")), "off");
assert_eq!(protection_mode_from_status(Some("")), "off");
assert_eq!(protection_mode_from_status(None), "off");
assert_eq!(
protection_mode_from_status(Some("futureValue")),
"futureValue"
);
}
#[test]
fn doctor_quick_and_prompt_status_agree_on_protection_mode() {
let mut environment = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
environment.remove_env("TIRITH_STATUS");
let _eff_guard = EnvGuard::remove("TIRITH_BASH_EFFECTIVE_PROTECTION");
for status in ["blocks", "warn-only", "degraded", "off", "", "futureValue"] {
environment.set_env("TIRITH_STATUS", status);
let doctor_mode = gather_quick_info().protection_mode;
let prompt_mode = crate::cli::prompt_status::protection_mode_for_test();
assert_eq!(
doctor_mode, prompt_mode,
"doctor --quick and prompt-status disagree for TIRITH_STATUS={status:?}"
);
}
}
#[test]
fn gather_quick_info_prefers_effective_protection_over_status() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let _eff_guard = EnvGuard::remove("TIRITH_BASH_EFFECTIVE_PROTECTION");
let _status_guard = EnvGuard::remove("TIRITH_STATUS");
let _eff = EnvGuard::set(
"TIRITH_BASH_EFFECTIVE_PROTECTION",
std::path::Path::new("blocks"),
);
let _status = EnvGuard::set("TIRITH_STATUS", std::path::Path::new("off"));
assert_eq!(
gather_quick_info().protection_mode,
"guarded",
"TIRITH_BASH_EFFECTIVE_PROTECTION=blocks must win over TIRITH_STATUS=off"
);
drop(_eff);
let _eff_removed = EnvGuard::remove("TIRITH_BASH_EFFECTIVE_PROTECTION");
let _status2 = EnvGuard::set("TIRITH_STATUS", std::path::Path::new("warn-only"));
assert_eq!(
gather_quick_info().protection_mode,
"warn-only",
"absent effective var must fall back to TIRITH_STATUS=warn-only"
);
}
#[test]
fn quick_json_has_exactly_the_documented_fields() {
let info = QuickDoctorInfo {
schema_version: 1,
protection_mode: "guarded".to_string(),
policy_path_used: Some("/repo/.tirith/policy.yaml".to_string()),
hook_configured: true,
};
let v: serde_json::Value = serde_json::to_value(&info).expect("serialize QuickDoctorInfo");
let obj = v.as_object().expect("quick JSON is an object");
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"hook_configured",
"policy_path_used",
"protection_mode",
"schema_version"
],
"quick JSON must carry exactly the documented field set"
);
assert!(
obj["hook_configured"].is_boolean(),
"hook_configured must be bool"
);
assert!(
obj["protection_mode"].is_string(),
"protection_mode must be a string"
);
assert!(
obj["policy_path_used"].is_string() || obj["policy_path_used"].is_null(),
"policy_path_used must be a string or null"
);
assert_eq!(obj["schema_version"], serde_json::json!(1));
}
#[test]
fn gather_quick_info_null_policy_when_none_discovered() {
with_fake_env(true, |_home, _cwd| {
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
let _status = EnvGuard::remove("TIRITH_STATUS");
let info = gather_quick_info();
assert_eq!(info.schema_version, 1);
assert!(
info.policy_path_used.is_none(),
"no policy on disk must yield policy_path_used = None, got {:?}",
info.policy_path_used
);
let v = serde_json::to_value(&info).unwrap();
assert!(
v["policy_path_used"].is_null(),
"absent policy must serialize as JSON null"
);
let _: bool = info.hook_configured;
});
}
#[test]
fn gather_quick_info_reflects_discovered_policy_and_status() {
with_fake_env(true, |_home, _cwd| {
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
let root = tempfile::tempdir().expect("policy root tempdir");
std::fs::create_dir_all(root.path().join(".tirith")).unwrap();
let policy = root.path().join(".tirith/policy.yaml");
std::fs::write(&policy, "fail_mode: open\n").unwrap();
let _root = EnvGuard::set("TIRITH_POLICY_ROOT", root.path());
let _status = EnvGuard::set("TIRITH_STATUS", std::path::Path::new("blocks"));
let info = gather_quick_info();
assert_eq!(
info.policy_path_used.as_deref(),
Some(policy.display().to_string().as_str()),
"TIRITH_POLICY_ROOT policy must be the discovered active path"
);
assert_eq!(
info.protection_mode, "guarded",
"TIRITH_STATUS=blocks must map to protection_mode=guarded"
);
});
}
#[test]
fn gather_quick_info_ignores_audit_log_threatdb_and_baseline() {
with_fake_env(true, |home, _cwd| {
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg_cfg = EnvGuard::remove("XDG_CONFIG_HOME");
let _status = EnvGuard::set("TIRITH_STATUS", std::path::Path::new("warn-only"));
let data = tempfile::tempdir().expect("data tempdir");
let _xdg_data = EnvGuard::set("XDG_DATA_HOME", data.path());
let _xdg_state = EnvGuard::set("XDG_STATE_HOME", data.path());
let plant = |dir: &std::path::Path| {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("log.jsonl"), b"{ this is not valid jsonl\n").unwrap();
std::fs::write(dir.join("threatdb.bin"), b"not a real threat db").unwrap();
};
plant(data.path().join("tirith").as_path());
for rel in [".local/share/tirith", "Library/Application Support/tirith"] {
plant(home.join(rel).as_path());
}
let _tdb = EnvGuard::set(
"TIRITH_THREATDB_PATH",
data.path().join("tirith/threatdb.bin").as_path(),
);
let info = gather_quick_info();
assert_eq!(info.schema_version, 1);
assert_eq!(
info.protection_mode, "warn-only",
"protection_mode comes from TIRITH_STATUS, not the audit log"
);
assert!(
info.policy_path_used.is_none(),
"no policy planted → None (proves no DB/baseline side-channel set it)"
);
let _: bool = info.hook_configured;
});
}
#[test]
fn run_quick_json_emits_parseable_minimal_object() {
with_fake_env(true, |_home, _cwd| {
let _root = EnvGuard::remove("TIRITH_POLICY_ROOT");
let _xdg = EnvGuard::remove("XDG_CONFIG_HOME");
let _status = EnvGuard::set("TIRITH_STATUS", std::path::Path::new("degraded"));
let info = gather_quick_info();
let s = serde_json::to_string_pretty(&info).expect("serialize");
let parsed: serde_json::Value = serde_json::from_str(&s).expect("quick JSON parses");
assert_eq!(parsed["protection_mode"], "degraded");
assert!(parsed["hook_configured"].is_boolean());
assert!(parsed["policy_path_used"].is_null());
assert_eq!(parsed["schema_version"], serde_json::json!(1));
assert_eq!(run_quick(true), 0, "run_quick(json=true) must exit 0");
});
}
#[test]
fn create_policy_contained_creates_policy() {
let repo = tempfile::tempdir().unwrap();
let path = repo.path().join(".tirith").join("policy.yaml");
create_policy_contained(repo.path(), &path, "fail_mode: open\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "fail_mode: open\n");
}
#[test]
fn create_policy_contained_refuses_to_clobber_existing() {
let repo = tempfile::tempdir().unwrap();
let dir = repo.path().join(".tirith");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("policy.yaml");
std::fs::write(&path, "SENTINEL\n").unwrap();
let err = create_policy_contained(repo.path(), &path, "fail_mode: open\n").unwrap_err();
assert!(err.contains("already exists"), "{err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "SENTINEL\n");
}
#[test]
fn create_policy_contained_refuses_symlinked_tirith_dir() {
let repo = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), repo.path().join(".tirith")).unwrap();
let path = repo.path().join(".tirith").join("policy.yaml");
assert!(create_policy_contained(repo.path(), &path, "fail_mode: open\n").is_err());
assert!(
!outside.path().join("policy.yaml").exists(),
"no file may be created outside the repo"
);
}
#[test]
fn create_policy_contained_refuses_dangling_symlink_at_final_component() {
let repo = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("victim.yaml");
let dir = repo.path().join(".tirith");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("policy.yaml");
std::os::unix::fs::symlink(&victim, &path).unwrap();
assert!(create_policy_contained(repo.path(), &path, "fail_mode: open\n").is_err());
assert!(
!victim.exists(),
"no external file may be created through the dangling symlink"
);
}
}