use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
use crate::cli::{confirm, write_json_stdout};
pub const NOT_A_SANDBOX_BANNER: &str = "\
file isolation only; not a sandbox. The command runs with full user privileges \
and can read your keychain, ssh keys, AWS creds, and the network. Use this for \
filesystem-impact preview ONLY.";
#[allow(dead_code)]
pub const ISOLATION_KIND: &str = "file_only_not_a_sandbox";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum IsolationKind {
#[serde(rename = "file_only_not_a_sandbox")]
FileOnlyNotASandbox,
#[serde(rename = "capsule_contained")]
CapsuleContained,
}
const STRIP_ENV_ALLOWLIST: [&str; 5] = ["HOME", "PATH", "USER", "LANG", "TERM"];
const MAX_FILES: usize = 100_000;
pub fn run(
command: &[OsString],
copy_repo: bool,
strip_env: bool,
capsule: bool,
json: bool,
) -> i32 {
let Some(program) = command.first() else {
eprintln!(
"tirith temp-run: no command given \
(usage: tirith temp-run -- ./script.sh)"
);
return 2;
};
if program.is_empty() || program.to_str().is_some_and(|s| s.trim().is_empty()) {
eprintln!(
"tirith temp-run: no command given \
(usage: tirith temp-run -- ./script.sh)"
);
return 2;
}
let command_display = command_display(command);
let temp = match tempfile::Builder::new()
.prefix("tirith-temp-run-")
.tempdir()
{
Ok(t) => t,
Err(e) => {
eprintln!("tirith temp-run: failed to create temp directory: {e}");
return 2;
}
};
let temp_path = temp.path().to_path_buf();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let copied = if copy_repo {
match copy_repo_into(&cwd, &temp_path) {
Ok(n) => Some(n),
Err(e) => {
eprintln!("tirith temp-run: failed to copy repo: {e}");
return 2;
}
}
} else {
None
};
if !json {
print_preamble(
&command_display,
&temp_path,
copy_repo,
strip_env,
capsule,
copied,
);
}
let before = inventory(&temp_path);
let run_outcome = run_in_dir(command, &temp_path, strip_env, capsule);
let (exit_code, capsule_report) = match run_outcome {
Ok((code, report)) => (code, report),
Err(e) => {
eprintln!("tirith temp-run: failed to run command: {e}");
return 2;
}
};
if let Some(ref report) = capsule_report {
if !json {
print_capsule_report(report);
}
}
let after = inventory(&temp_path);
let (new_files, modified_files) = diff_inventories(&before, &after, &temp_path);
let delete = confirm(
&format!("tirith temp-run: delete temp dir {}?", temp_path.display()),
false,
);
let kept_path = if delete {
drop(temp); None
} else {
let persisted = temp.keep();
Some(persisted)
};
if json {
let wrote = emit_json(
&command_display,
command,
exit_code,
copy_repo,
strip_env,
capsule,
capsule_report.as_ref(),
copied,
&new_files,
&modified_files,
kept_path.as_deref(),
);
if !wrote && exit_code == 0 {
return 2;
}
} else {
print_result(exit_code, &new_files, &modified_files, kept_path.as_deref());
}
exit_code
}
fn print_preamble(
command_str: &str,
temp_path: &Path,
copy_repo: bool,
strip_env: bool,
capsule: bool,
copied: Option<usize>,
) {
let s = tirith_core::style::Stream::Stdout;
println!(
"{} {}",
tirith_core::style::bold("temp-run:", s),
command_str
);
if capsule {
println!(
" {}",
tirith_core::style::red(
"best-effort OS containment (--capsule); a host without a working backend runs \
uncontained — see the capsule line below",
s
)
);
} else {
println!(" {}", tirith_core::style::red(NOT_A_SANDBOX_BANNER, s));
}
println!(" temp dir: {}", temp_path.display());
if copy_repo {
match copied {
Some(n) => println!(" seeded: copied {n} file(s) from the repo (.git excluded)"),
None => println!(" seeded: repo copy"),
}
} else {
println!(" seeded: empty (pass --copy-repo to copy the repo, .git excluded)");
}
if strip_env {
println!(
" env: stripped to allowlist [{}] (convenience, NOT secret scrubbing)",
STRIP_ENV_ALLOWLIST.join(", ")
);
} else {
println!(" env: inherited in full (pass --strip-env to trim to an allowlist)");
}
println!();
}
fn print_result(
exit_code: i32,
new_files: &[String],
modified_files: &[String],
kept_path: Option<&Path>,
) {
println!(" exit code: {exit_code}");
print_list_section("new files", new_files);
print_list_section("modified files", modified_files);
match kept_path {
Some(p) => println!("\n kept temp dir: {}", p.display()),
None => println!("\n temp dir deleted"),
}
}
fn print_list_section(label: &str, items: &[String]) {
if items.is_empty() {
println!("\n {label}: none");
} else {
println!("\n {label} ({}):", items.len());
for i in items {
println!(" {i}");
}
}
}
#[allow(clippy::too_many_arguments)]
fn emit_json(
command_display: &str,
command: &[OsString],
exit_code: i32,
copy_repo: bool,
strip_env: bool,
capsule: bool,
capsule_report: Option<&CapsuleReport>,
copied: Option<usize>,
new_files: &[String],
modified_files: &[String],
kept_path: Option<&Path>,
) -> bool {
let contained = capsule && capsule_report.map(|r| r.contained).unwrap_or(false);
let isolation_kind = if contained {
IsolationKind::CapsuleContained
} else {
IsolationKind::FileOnlyNotASandbox
};
let argv: Vec<String> = command
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let json_val = serde_json::json!({
"isolation_kind": isolation_kind,
"not_a_sandbox": !contained,
"disclaimer": NOT_A_SANDBOX_BANNER,
"command": command_display,
"argv": argv,
"exit_code": exit_code,
"copy_repo": copy_repo,
"files_copied": copied,
"strip_env": strip_env,
"env_allowlist": if strip_env { STRIP_ENV_ALLOWLIST.to_vec() } else { Vec::new() },
"capsule_requested": capsule,
"capsule_backend": capsule_report.map(|r| r.backend_id),
"capsule_contained": capsule_report.map(|r| r.contained),
"new_files": new_files,
"modified_files": modified_files,
"temp_dir_kept": kept_path.is_some(),
"temp_dir": kept_path.map(|p| p.display().to_string()),
});
write_json_stdout(&json_val, "tirith temp-run: failed to write JSON output")
}
fn command_display(command: &[OsString]) -> String {
let display_parts: Vec<String> = command
.iter()
.map(|arg| super::sanitize_for_human_output(&arg.to_string_lossy(), false))
.collect();
super::shell_join(&display_parts)
}
#[derive(Debug, Clone)]
pub struct CapsuleReport {
pub backend_id: &'static str,
pub contained: bool,
}
fn run_in_dir(
command: &[OsString],
dir: &Path,
strip_env: bool,
capsule: bool,
) -> std::io::Result<(i32, Option<CapsuleReport>)> {
if capsule {
return run_in_dir_capsuled(command, dir, strip_env);
}
let (program, args) = command
.split_first()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty argv"))?;
let mut cmd = Command::new(program);
cmd.args(args);
cmd.current_dir(dir);
if strip_env {
cmd.env_clear();
for key in STRIP_ENV_ALLOWLIST {
if let Some(val) = std::env::var_os(key) {
cmd.env(key, val);
}
}
}
let status = cmd.status()?;
Ok((status.code().unwrap_or(128), None))
}
fn run_in_dir_capsuled(
command: &[OsString],
dir: &Path,
strip_env: bool,
) -> std::io::Result<(i32, Option<CapsuleReport>)> {
use tirith_core::capsule::CapsuleSpec;
let mut spec = CapsuleSpec::locked_down();
spec.filesystem.write_roots.push(dir.to_path_buf());
for root in [
"/bin",
"/usr",
"/lib",
"/lib64",
"/etc",
"/System",
"/private/var/select",
] {
let p = std::path::PathBuf::from(root);
if p.exists() {
spec.filesystem.read_roots.push(p);
}
}
let allow = if strip_env {
STRIP_ENV_ALLOWLIST.to_vec()
} else {
vec!["PATH", "USER", "LANG", "TERM", "SHELL"]
};
spec.environment.allow = allow.into_iter().map(|s| s.to_string()).collect();
let (program, args) = command
.split_first()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty argv"))?;
match crate::cli::capsule::run_to_completion_os(
&spec,
program.as_os_str(),
args,
Some(dir),
&[],
crate::cli::capsule::DegradedPolicy::AllowDegraded,
) {
Ok(outcome) => Ok((
outcome.exit_code,
Some(CapsuleReport {
backend_id: outcome.backend_id,
contained: !outcome.degraded,
}),
)),
Err(refused) => {
Err(std::io::Error::other(refused.reason))
}
}
}
fn print_capsule_report(report: &CapsuleReport) {
if report.contained {
println!(
" capsule: contained via '{}' (fs confined to the temp dir, no network)",
report.backend_id
);
} else {
println!(
" capsule: DEGRADED — ran UNCONTAINED (backend '{}' could not enforce containment \
on this host)",
report.backend_id
);
}
}
fn copy_repo_into(src: &Path, dst: &Path) -> std::io::Result<usize> {
use walkdir::WalkDir;
let mut copied = 0usize;
for entry in WalkDir::new(src)
.follow_links(false)
.into_iter()
.filter_entry(|e| !(e.file_type().is_dir() && e.file_name().to_str() == Some(".git")))
{
if copied >= MAX_FILES {
break;
}
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if path
.components()
.any(|c| c.as_os_str().to_str() == Some(".git"))
{
continue;
}
let rel = match path.strip_prefix(src) {
Ok(r) => r,
Err(_) => continue,
};
if rel.as_os_str().is_empty() {
continue; }
let target = dst.join(rel);
let ft = entry.file_type();
if ft.is_dir() {
std::fs::create_dir_all(&target)?;
} else if ft.is_file() {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(path, &target)?;
copied += 1;
}
}
Ok(copied)
}
fn inventory(root: &Path) -> BTreeMap<String, SystemTime> {
use walkdir::WalkDir;
let mut out = BTreeMap::new();
for entry in WalkDir::new(root).follow_links(false) {
if out.len() >= MAX_FILES {
break;
}
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_dir() {
if let Ok(mtime) = meta.modified() {
out.insert(entry.path().to_string_lossy().into_owned(), mtime);
}
}
}
out
}
fn diff_inventories(
before: &BTreeMap<String, SystemTime>,
after: &BTreeMap<String, SystemTime>,
root: &Path,
) -> (Vec<String>, Vec<String>) {
let rel = |p: &str| -> String {
Path::new(p)
.strip_prefix(root)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| p.to_string())
};
let mut new_files: Vec<String> = after
.keys()
.filter(|p| !before.contains_key(*p))
.map(|p| rel(p))
.collect();
new_files.sort();
let mut modified_files: Vec<String> = after
.iter()
.filter_map(|(p, mtime_after)| {
before
.get(p)
.filter(|mtime_before| *mtime_before != mtime_after)
.map(|_| rel(p))
})
.collect();
modified_files.sort();
(new_files, modified_files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn banner_states_not_a_sandbox_and_full_privileges() {
assert!(NOT_A_SANDBOX_BANNER.contains("not a sandbox"));
assert!(NOT_A_SANDBOX_BANNER.contains("full user privileges"));
assert!(NOT_A_SANDBOX_BANNER.contains("keychain"));
assert_eq!(ISOLATION_KIND, "file_only_not_a_sandbox");
}
#[test]
fn isolation_kind_const_matches_enum() {
let serialized =
serde_json::to_value(IsolationKind::FileOnlyNotASandbox).expect("serialize enum");
assert_eq!(serialized, serde_json::Value::String(ISOLATION_KIND.into()));
}
#[test]
fn legacy_command_display_is_quoted_and_terminal_sanitized() {
let display = command_display(&[
OsString::from("probe"),
OsString::from("two words"),
OsString::from("\u{1b}[31mred\nnext"),
]);
assert!(display.contains("'two words'"));
assert!(!display.contains('\u{1b}'));
assert!(!display.contains('\n'));
assert!(display.contains("rednext"));
}
#[test]
fn copy_repo_excludes_git_directory() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
fs::create_dir_all(src.path().join(".git/objects")).unwrap();
fs::write(src.path().join(".git/config"), b"[core]").unwrap();
fs::write(src.path().join(".git/objects/abc"), b"obj").unwrap();
fs::create_dir_all(src.path().join("src")).unwrap();
fs::write(src.path().join("src/main.rs"), b"fn main() {}").unwrap();
fs::write(src.path().join("README.md"), b"# hi").unwrap();
let copied = copy_repo_into(src.path(), dst.path()).unwrap();
assert_eq!(copied, 2, "should copy main.rs and README.md only");
assert!(dst.path().join("src/main.rs").is_file());
assert!(dst.path().join("README.md").is_file());
assert!(
!dst.path().join(".git").exists(),
".git must be excluded from the copy"
);
}
#[test]
fn diff_reports_new_and_modified_files() {
let root = tempfile::tempdir().unwrap();
let before = inventory(root.path());
assert!(before.is_empty());
fs::write(root.path().join("created.txt"), b"new").unwrap();
let after = inventory(root.path());
let (new_files, modified_files) = diff_inventories(&before, &after, root.path());
assert_eq!(new_files, vec!["created.txt".to_string()]);
assert!(modified_files.is_empty());
}
}