use std::path::{Path, PathBuf};
use tirith_core::exec_provenance::{self, Provenance};
use tirith_core::path_audit;
use tirith_core::policy::{self as policy_mod, Policy};
use tirith_core::verdict::{Finding, Severity};
use super::write_json_stdout;
pub fn guard(action: &str, json: bool) -> i32 {
let enable = match action {
"on" | "enable" | "true" => true,
"off" | "disable" | "false" => false,
"status" => return guard_status(json),
other => {
eprintln!("tirith exec guard: unknown action '{other}' (expected on|off|status)");
return 2;
}
};
let target_path = match resolve_policy_path_for_guard() {
Ok(p) => p,
Err(code) => return code,
};
if let Err(e) = update_policy_guard_key(&target_path, enable) {
eprintln!(
"tirith exec guard: failed to update {}: {e}",
target_path.display()
);
return 1;
}
if json {
let out = serde_json::json!({
"schema_version": 1,
"exec_guard_enabled": enable,
"policy_path": target_path.display().to_string(),
});
if !write_json_stdout(&out, "tirith exec guard: failed to write JSON output") {
return 1;
}
} else {
eprintln!(
"tirith exec guard: {} (written to {})",
if enable { "ON" } else { "OFF" },
target_path.display(),
);
}
0
}
fn guard_status(json: bool) -> i32 {
let policy = Policy::discover_partial(None);
if json {
let out = serde_json::json!({
"schema_version": 1,
"exec_guard_enabled": policy.exec_guard_enabled,
"policy_path": policy.path,
});
if !write_json_stdout(&out, "tirith exec guard: failed to write JSON output") {
return 1;
}
} else {
eprintln!(
"tirith exec guard: {}",
if policy.exec_guard_enabled {
"ON"
} else {
"OFF"
}
);
if !policy.exec_guard_enabled {
eprintln!(
" (when ON, a command whose leader resolves under /tmp, inside the repo, or \
from a user-writable PATH dir ahead of the system path will WARN on the exec \
hot path. Run `tirith exec check <bin>` for the full cold provenance.)"
);
}
}
0
}
fn resolve_policy_path_for_guard() -> Result<PathBuf, i32> {
if let Some(existing) = policy_mod::discover_local_policy_path(None) {
return Ok(existing);
}
let user = policy_mod::config_dir().ok_or_else(|| {
eprintln!("tirith exec guard: could not resolve user config dir");
1
})?;
Ok(user.join("policy.yaml"))
}
const MAX_POLICY_SIZE: u64 = 1024 * 1024;
pub(super) fn update_policy_guard_key(path: &std::path::Path, enable: bool) -> std::io::Result<()> {
let containment_root = path.parent().and_then(|p| p.parent()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"policy path must be <root>/<dir>/policy.yaml",
)
})?;
let policy = Policy::discover_local_only(containment_root.to_str());
let contained = super::prepare_config_destination_permitted(
containment_root,
path,
true,
&policy,
true,
true,
)?;
let existing = match contained.read_capped(MAX_POLICY_SIZE) {
Ok(bytes) => String::from_utf8(bytes).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"policy file is not UTF-8; refusing to rewrite it",
)
})?,
Err(tirith_core::util::OpenRegularError::NotFound) => String::new(),
Err(e) => return Err(open_regular_io_error(e)),
};
let new_line = format!("exec_guard_enabled: {enable}");
let mut out = String::new();
let mut replaced = false;
for line in existing.lines() {
if line.starts_with("exec_guard_enabled:") {
out.push_str(&new_line);
out.push('\n');
replaced = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !replaced {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&new_line);
out.push('\n');
}
verify_guard_key_effective(&out, enable)?;
super::write_prepared_config_file_permitted(
containment_root,
path,
contained,
out.as_bytes(),
true,
&policy,
true,
)
}
fn verify_guard_key_effective(candidate: &str, expected: bool) -> std::io::Result<()> {
let parsed: serde_yaml::Value = serde_yaml::from_str(candidate).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("resulting policy would not parse as YAML: {error}"),
)
})?;
let effective = parsed
.get("exec_guard_enabled")
.and_then(serde_yaml::Value::as_bool);
if effective == Some(expected) {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resulting policy does not have the requested top-level exec_guard_enabled value",
))
}
}
fn open_regular_io_error(e: tirith_core::util::OpenRegularError) -> std::io::Error {
match e {
tirith_core::util::OpenRegularError::Io(io) => io,
tirith_core::util::OpenRegularError::NotRegularFile => std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"policy path is not a regular file (symlink or special file)",
),
tirith_core::util::OpenRegularError::TooLarge => std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"policy file exceeds the size cap",
),
tirith_core::util::OpenRegularError::NotFound => {
std::io::Error::new(std::io::ErrorKind::NotFound, "policy file not found")
}
}
}
pub fn check(bin: &str, json: bool) -> i32 {
let path_value = std::env::var("PATH").unwrap_or_default();
let hits = path_audit::which_all(bin, &path_value);
let Some(first) = hits.first().cloned() else {
if json {
let body = serde_json::json!({
"schema_version": 1,
"command": bin,
"resolved": false,
"message": "not found on PATH",
});
let _ = write_json_stdout(&body, "tirith exec check: failed to write JSON output");
} else {
eprintln!("tirith exec check: `{bin}` was not found on $PATH.");
}
return 2;
};
let prov = exec_provenance::provenance_of(&first);
let mut findings = prov.findings();
if let Some(shadow) = exec_provenance::shadow_finding(bin, &first) {
findings.push(shadow);
}
if json {
let body = serde_json::json!({
"schema_version": 1,
"command": bin,
"resolved": true,
"resolved_path": first.display().to_string(),
"all_path_hits": hits.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"provenance": prov,
"findings": findings,
});
if !write_json_stdout(&body, "tirith exec check: failed to write JSON output") {
return 1;
}
} else {
print_human_check(bin, &first, &hits, &prov, &findings);
}
exit_for(&findings)
}
pub fn provenance(path: &str, json: bool) -> i32 {
let p = expand_path(path);
let prov = exec_provenance::provenance_of(&p);
if !prov.exists {
if json {
let body = serde_json::json!({
"schema_version": 1,
"path": p.display().to_string(),
"exists": false,
});
let _ = write_json_stdout(&body, "tirith exec provenance: failed to write JSON output");
} else {
eprintln!(
"tirith exec provenance: `{}` is not a regular file.",
p.display()
);
}
return 2;
}
let findings = prov.findings();
if json {
let body = serde_json::json!({
"schema_version": 1,
"path": p.display().to_string(),
"provenance": prov,
"findings": findings,
});
if !write_json_stdout(&body, "tirith exec provenance: failed to write JSON output") {
return 1;
}
} else {
print_human_provenance(&prov, &findings);
}
exit_for(&findings)
}
fn expand_path(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home::home_dir() {
return home.join(rest);
}
}
PathBuf::from(path)
}
fn exit_for(findings: &[Finding]) -> i32 {
let high = findings
.iter()
.any(|f| matches!(f.severity, Severity::High | Severity::Critical));
if high {
1
} else {
0
}
}
fn print_human_check(
bin: &str,
resolved: &Path,
hits: &[PathBuf],
prov: &Provenance,
findings: &[Finding],
) {
eprintln!("tirith exec check `{bin}`:");
eprintln!(
" resolves to: {}",
super::sanitize_for_human_output(&resolved.display().to_string(), false)
);
if hits.len() > 1 {
eprintln!(" also on PATH ({} total):", hits.len());
for h in hits.iter().skip(1) {
eprintln!(
" {}",
super::sanitize_for_human_output(&h.display().to_string(), false)
);
}
}
print_provenance_body(prov);
print_findings(findings);
}
fn print_human_provenance(prov: &Provenance, findings: &[Finding]) {
eprintln!(
"tirith exec provenance `{}`:",
super::sanitize_for_human_output(&prov.path, false)
);
print_provenance_body(prov);
print_findings(findings);
}
fn print_provenance_body(prov: &Provenance) {
eprintln!(
" package manager: {}",
prov.package_owner
.as_ref()
.map(|o| format!("{} ({})", o.manager, o.root))
.unwrap_or_else(|| "none (not under a known install root)".to_string())
);
eprintln!(" signature: {}", prov.signature.as_str());
eprintln!(
" file type: {}",
prov.file_type.as_deref().unwrap_or("unknown")
);
if let Some(mode) = &prov.mode {
eprintln!(
" mode: {mode}{}",
if prov.world_writable {
" (WORLD-WRITABLE)"
} else {
""
}
);
}
if let Some(secs) = prov.modified_secs_ago {
eprintln!(
" modified: {secs}s ago{}",
if prov.recently_modified {
" (RECENT — within 5 min)"
} else {
""
}
);
}
}
fn print_findings(findings: &[Finding]) {
if findings.is_empty() {
eprintln!(" no provenance concerns.");
return;
}
eprintln!("\n {} finding(s):", findings.len());
for f in findings {
eprintln!(
" [{}] {} — {}",
f.severity,
f.rule_id,
super::sanitize_for_human_output(&f.title, false)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_path_handles_tilde_and_plain() {
assert_eq!(expand_path("/usr/bin/git"), PathBuf::from("/usr/bin/git"));
if let Some(home) = home::home_dir() {
assert_eq!(expand_path("~/bin/x"), home.join("bin/x"));
}
}
#[test]
fn exit_for_high_is_1_else_0() {
let high = vec![Finding {
rule_id: tirith_core::verdict::RuleId::ExecWorldWritable,
severity: Severity::High,
title: "t".into(),
description: "d".into(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
assert_eq!(exit_for(&high), 1);
assert_eq!(exit_for(&[]), 0);
}
#[test]
fn check_nonexistent_command_exits_2() {
assert_eq!(check("tirith-no-such-bin-xyz-9999", true), 2);
}
#[test]
fn guard_unknown_action_returns_2() {
assert_eq!(guard("bogus", false), 2);
}
#[test]
fn update_policy_guard_key_appends_and_replaces() {
let _global = crate::cli::test_harness::ENV_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let dir = tempfile::tempdir().unwrap();
let tirith_dir = dir.path().join(".tirith");
std::fs::create_dir(&tirith_dir).unwrap();
let path = tirith_dir.join("policy.yaml");
std::fs::write(&path, "paranoia: 2\nfail_mode: open\n").unwrap();
update_policy_guard_key(&path, true).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("exec_guard_enabled: true"), "{content}");
assert!(content.contains("paranoia: 2"), "other lines preserved");
update_policy_guard_key(&path, false).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("exec_guard_enabled: false"), "{content}");
assert_eq!(
content.matches("exec_guard_enabled:").count(),
1,
"must not duplicate the key"
);
update_policy_guard_key(&path, true).unwrap();
let yaml = std::fs::read_to_string(&path).unwrap();
let parsed = Policy::try_parse_yaml(&yaml).expect("policy YAML must parse");
assert!(
parsed.exec_guard_enabled,
"exec_guard_enabled must round-trip to the engine-readable Policy"
);
}
#[test]
fn update_policy_guard_key_ignores_indented_lookalike() {
let dir = tempfile::tempdir().unwrap();
let tirith_dir = dir.path().join(".tirith");
std::fs::create_dir(&tirith_dir).unwrap();
let path = tirith_dir.join("policy.yaml");
std::fs::write(
&path,
"custom_rule:\n exec_guard_enabled: false\n other: 1\nparanoia: 2\n",
)
.unwrap();
update_policy_guard_key(&path, true).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains(" exec_guard_enabled: false"),
"nested key must be preserved with its indentation: {content}"
);
assert_eq!(
content
.lines()
.filter(|line| line.starts_with("exec_guard_enabled:"))
.count(),
1,
"exactly one top-level key must be present: {content}"
);
let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap();
assert_eq!(
parsed
.get("exec_guard_enabled")
.and_then(serde_yaml::Value::as_bool),
Some(true)
);
}
#[test]
fn update_policy_guard_key_rejects_unparseable_candidate() {
let dir = tempfile::tempdir().unwrap();
let tirith_dir = dir.path().join(".tirith");
std::fs::create_dir(&tirith_dir).unwrap();
let path = tirith_dir.join("policy.yaml");
let original = "a:\n - 1\n - 2\n bad: [\n";
std::fs::write(&path, original).unwrap();
let result = update_policy_guard_key(&path, true);
assert!(
result.is_err(),
"invalid YAML baseline must fail: {result:?}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
original,
"validation failure must leave the original policy untouched"
);
}
#[cfg(unix)]
#[test]
fn update_policy_guard_key_does_not_follow_symlink() {
let dir = tempfile::tempdir().unwrap();
let sentinel = dir.path().join("sentinel.yaml");
let original = "paranoia: 2\n# do not clobber\n";
std::fs::write(&sentinel, original).unwrap();
let tirith_dir = dir.path().join(".tirith");
std::fs::create_dir(&tirith_dir).unwrap();
let policy = tirith_dir.join("policy.yaml");
std::os::unix::fs::symlink(&sentinel, &policy).unwrap();
let res = update_policy_guard_key(&policy, true);
assert!(
res.is_err(),
"writing through a symlinked policy path must error, got {res:?}"
);
let after = std::fs::read_to_string(&sentinel).unwrap();
assert_eq!(after, original, "symlink target must be unchanged");
assert!(
!after.contains("exec_guard_enabled"),
"the guard key must not have leaked into the symlink target: {after}"
);
}
#[cfg(unix)]
#[test]
fn update_policy_guard_key_rejects_symlinked_intermediate_dir() {
let base = tempfile::tempdir().unwrap();
let outside = base.path().join("outside");
std::fs::create_dir(&outside).unwrap();
let sentinel = outside.join("policy.yaml");
let original = "paranoia: 2\n# do not clobber via dir symlink\n";
std::fs::write(&sentinel, original).unwrap();
let root = base.path().join("root");
std::fs::create_dir(&root).unwrap();
let tirith_link = root.join(".tirith");
std::os::unix::fs::symlink(&outside, &tirith_link).unwrap();
let policy = tirith_link.join("policy.yaml");
let res = update_policy_guard_key(&policy, true);
assert!(
res.is_err(),
"a symlinked intermediate `.tirith` dir must be rejected, got {res:?}"
);
let after = std::fs::read_to_string(&sentinel).unwrap();
assert_eq!(
after, original,
"the dir-symlink target must be byte-unchanged"
);
assert!(
!after.contains("exec_guard_enabled"),
"the guard key must not have leaked through the dir symlink: {after}"
);
}
}