use std::sync::OnceLock;
use regex::Regex;
use tokio::process::Command;
#[cfg(feature = "sandbox-microvm")]
use crate::sandbox::microvm::{MicrovmConfig, MicrovmSandbox};
#[cfg(feature = "sandbox-microvm")]
use std::sync::Arc;
#[cfg(feature = "sandbox-microvm")]
use std::time::Duration;
#[cfg(feature = "sandbox-microvm")]
use tokio::sync::Mutex;
pub mod check;
#[cfg(feature = "sandbox-microvm")]
pub mod microvm;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxMode {
Off,
Bwrap,
#[cfg(feature = "sandbox-microvm")]
Microvm,
}
impl SandboxMode {
pub fn parse(value: Option<&str>) -> Self {
match value {
Some("microvm") => {
#[cfg(feature = "sandbox-microvm")]
{
SandboxMode::Microvm
}
#[cfg(not(feature = "sandbox-microvm"))]
{
eprintln!(
"warning: microvm sandbox not available — dirge was built without the sandbox-microvm feature. Using off instead."
);
SandboxMode::Off
}
}
Some("bwrap") => SandboxMode::Bwrap,
_ => SandboxMode::Off, }
}
pub fn status_badge(&self) -> Option<&'static str> {
match self {
SandboxMode::Off => None,
SandboxMode::Bwrap => Some("bwrap"),
#[cfg(feature = "sandbox-microvm")]
SandboxMode::Microvm => Some("vm"),
}
}
}
#[derive(Debug, Clone)]
pub struct Sandbox {
pub(crate) mode: SandboxMode,
#[cfg(feature = "sandbox-microvm")]
microvm: Arc<Mutex<Option<MicrovmSandbox>>>,
}
impl Sandbox {
pub fn new(mode: SandboxMode) -> Self {
let effective_mode = if mode == SandboxMode::Bwrap {
if Self::bwrap_available() {
SandboxMode::Bwrap
} else {
eprintln!(
"warning: --sandbox requested but `bwrap` is not in PATH.\n \
Sandbox is DISABLED for this run — bash will execute unsandboxed.\n \
Install bubblewrap (apt install bubblewrap / dnf install bubblewrap /\n \
pacman -S bubblewrap) and re-run with --sandbox to enable isolation."
);
SandboxMode::Off
}
} else {
mode
};
Sandbox {
mode: effective_mode,
#[cfg(feature = "sandbox-microvm")]
microvm: if effective_mode == SandboxMode::Microvm {
Arc::new(Mutex::new(Some(MicrovmSandbox::new(
MicrovmConfig::default(),
))))
} else {
Arc::new(Mutex::new(None))
},
}
}
#[cfg(feature = "plugin")]
pub fn mode_str(&self) -> &str {
match self.mode {
SandboxMode::Off => "off",
SandboxMode::Bwrap => "bwrap",
#[cfg(feature = "sandbox-microvm")]
SandboxMode::Microvm => "microvm",
}
}
pub fn is_microvm(&self) -> bool {
#[cfg(feature = "sandbox-microvm")]
{
matches!(self.mode, SandboxMode::Microvm)
}
#[cfg(not(feature = "sandbox-microvm"))]
{
false
}
}
#[cfg(feature = "sandbox-microvm")]
pub fn set_microvm_image(&self, image: String) -> Result<(), anyhow::Error> {
use crate::sandbox::microvm::rootfs;
let canonical = rootfs::canonicalize_image_ref(&image);
let mut guard = self
.microvm
.try_lock()
.map_err(|_| anyhow::anyhow!("microvm is busy — retry"))?;
if let Some(ref mut mv) = *guard {
mv.config.image = canonical;
}
Ok(())
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn set_microvm_image(&self, _image: String) -> Result<(), anyhow::Error> {
Ok(())
}
#[cfg(feature = "sandbox-microvm")]
pub fn set_microvm_resources(&self, cpus: u8, memory_mib: u32) -> Result<(), anyhow::Error> {
let mut guard = self
.microvm
.try_lock()
.map_err(|_| anyhow::anyhow!("microvm is busy — retry"))?;
if let Some(ref mut mv) = *guard {
mv.config.cpus = cpus;
mv.config.memory_mib = memory_mib;
}
Ok(())
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn set_microvm_resources(&self, _cpus: u8, _memory_mib: u32) -> Result<(), anyhow::Error> {
Ok(())
}
#[cfg(feature = "sandbox-microvm")]
pub fn ssh_connect_info(&self) -> Option<(u16, std::path::PathBuf, String)> {
if !self.is_microvm() {
return None;
}
let guard = self.microvm.try_lock().ok()?;
let mv = guard.as_ref()?;
if mv.ssh_port() == 0 {
return None;
}
let keys = mv.keys.as_ref()?;
let key_path = keys.private_key_path.clone();
let host_public_key = mv.host_keys.as_ref()?.public_key.clone();
Some((mv.ssh_port(), key_path, host_public_key))
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn ssh_connect_info(&self) -> Option<(u16, std::path::PathBuf, String)> {
None
}
#[cfg(feature = "sandbox-microvm")]
pub fn save_snapshot(&self, name: &str) -> Result<(), anyhow::Error> {
let guard = self
.microvm
.try_lock()
.map_err(|_| anyhow::anyhow!("cannot acquire microvm lock — try again"))?;
let mv = guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("microVM not active"))?;
mv.save_snapshot(name)
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn save_snapshot(&self, _name: &str) -> Result<(), anyhow::Error> {
Err(anyhow::anyhow!("microVM sandbox not available"))
}
#[cfg(feature = "sandbox-microvm")]
pub fn list_snapshots(&self) -> Result<Vec<String>, anyhow::Error> {
let guard = self
.microvm
.try_lock()
.map_err(|_| anyhow::anyhow!("cannot acquire microvm lock — try again"))?;
let mv = guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("microVM not active"))?;
mv.list_snapshots()
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn list_snapshots(&self) -> Result<Vec<String>, anyhow::Error> {
Err(anyhow::anyhow!("microVM sandbox not available"))
}
#[cfg(feature = "sandbox-microvm")]
pub fn restore_snapshot(&self, name: &str) -> Result<(), anyhow::Error> {
let guard = self
.microvm
.try_lock()
.map_err(|_| anyhow::anyhow!("cannot acquire microvm lock — try again"))?;
let mv = guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("microVM not active"))?;
mv.restore_snapshot(name)
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn restore_snapshot(&self, _name: &str) -> Result<(), anyhow::Error> {
Err(anyhow::anyhow!("microVM sandbox not available"))
}
#[cfg(feature = "sandbox-microvm")]
pub fn delete_snapshot(&self, name: &str) -> Result<(), anyhow::Error> {
let guard = self
.microvm
.try_lock()
.map_err(|_| anyhow::anyhow!("cannot acquire microvm lock — try again"))?;
let mv = guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("microVM not active"))?;
mv.delete_snapshot(name)
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub fn delete_snapshot(&self, _name: &str) -> Result<(), anyhow::Error> {
Err(anyhow::anyhow!("microVM sandbox not available"))
}
#[cfg(feature = "sandbox-microvm")]
pub async fn reboot_microvm(&self) -> Result<(), anyhow::Error> {
let mut guard = self.microvm.lock().await;
let mv = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("microVM not active"))?;
mv.reboot().await
}
#[cfg(not(feature = "sandbox-microvm"))]
#[allow(dead_code)]
pub async fn reboot_microvm(&self) -> Result<(), anyhow::Error> {
Err(anyhow::anyhow!("microVM sandbox not available"))
}
fn bwrap_available() -> bool {
std::process::Command::new("bwrap")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn wrap_command(&self, command: &str) -> Command {
let mut cmd = if self.mode == SandboxMode::Off {
let mut c = Command::new("bash");
c.arg("-c").arg(command);
c
} else {
let cwd = std::env::current_dir().unwrap_or_else(|e| {
tracing::warn!(
target: "dirge::sandbox",
error = %e,
"current_dir() failed while building the sandbox bind — \
falling back to '.', which may bind an unexpected directory",
);
".".into()
});
let mut c = Command::new("bwrap");
c.args(["--ro-bind", "/", "/", "--bind"]);
c.arg(cwd.as_os_str());
c.arg(cwd.as_os_str());
c.args([
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--unshare-all",
"--new-session",
"--unshare-user-try",
"--die-with-parent",
"bash",
"-c",
command,
]);
c
};
scrub_env(&mut cmd);
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("GCM_INTERACTIVE", "Never");
cmd.env("DEBIAN_FRONTEND", "noninteractive");
cmd
}
pub async fn exec(
&self,
command: &str,
timeout_secs: u64,
) -> Result<crate::agent::tools::bash::exec::InterleavedOutput, crate::agent::tools::ToolError>
{
match self.mode {
SandboxMode::Off | SandboxMode::Bwrap => {
crate::agent::tools::bash::exec::run_with_timeout(
self.wrap_command(command),
timeout_secs,
)
.await
}
#[cfg(feature = "sandbox-microvm")]
SandboxMode::Microvm => {
let mut guard = self.microvm.lock().await;
let mv = guard.as_mut().ok_or_else(|| {
crate::agent::tools::ToolError::Msg(
"microvm sandbox not initialized".to_string(),
)
})?;
if mv.ssh_port() == 0 {
mv.start()
.await
.map_err(|e| crate::agent::tools::ToolError::Msg(e.to_string()))?;
}
let ssh_port = mv.ssh_port();
let private_key_path = mv
.keys
.as_ref()
.map(|k| k.private_key_path.clone())
.ok_or_else(|| {
crate::agent::tools::ToolError::Msg("VM keys missing".to_string())
})?;
let host_key_bytes = mv
.host_keys
.as_ref()
.and_then(|hk| hk.public_key_bytes().ok());
drop(guard);
let command = format!("cd /workspace && timeout {} {}", timeout_secs, command);
let result = tokio::time::timeout(
Duration::from_secs(timeout_secs),
tokio::task::spawn_blocking(move || {
crate::sandbox::microvm::ssh::ssh_exec(
"127.0.0.1",
ssh_port,
&private_key_path,
&command,
host_key_bytes.as_deref(),
)
}),
)
.await;
let (stdout, stderr, exit_code) = match result {
Ok(Ok(Ok((stdout, stderr, exit_code)))) => (stdout, stderr, exit_code),
Ok(Ok(Err(e))) => {
return Err(crate::agent::tools::ToolError::Msg(e.to_string()));
}
Ok(Err(join_err)) => {
return Err(crate::agent::tools::ToolError::Msg(format!(
"microvm exec join error: {join_err}"
)));
}
Err(_elapsed) => {
return Err(crate::agent::tools::ToolError::Msg(format!(
"command timed out after {timeout_secs}s"
)));
}
};
Ok(crate::agent::tools::bash::exec::InterleavedOutput {
merged: if stderr.is_empty() {
stdout
} else {
format!("{stdout}\n{stderr}")
},
exit_code,
})
}
}
}
}
pub fn is_sensitive_env_name(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
const PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "PASS", "CRED", "AUTH"];
if PATTERNS.iter().any(|p| upper.contains(p)) {
const SAFE_EXACT: &[&str] = &[
"DISPLAY", "TERM", "SHLVL", "PWD", "OLDPWD", "PATH", "MANPATH", "LANG", "LC_ALL", "LC_CTYPE", "EDITOR", "VISUAL", "PAGER", "HOSTNAME", "USER", "LOGNAME", "HOME", "SSH_AUTH_SOCK", "GITHUB_TOKEN", "GH_TOKEN", ];
if SAFE_EXACT.iter().any(|s| &upper == s) {
return false;
}
return true;
}
const EXPLICIT: &[&str] = &[
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GITLAB_TOKEN",
"BITBUCKET_TOKEN",
];
EXPLICIT.iter().any(|n| &upper == n)
}
pub fn is_sensitive_env_value(value: &str) -> bool {
if value.is_empty() {
return false;
}
let has_url_userinfo_gate = value.contains("://");
let has_prefix_gate = has_vendor_prefix_gate(value);
if !has_url_userinfo_gate && !has_prefix_gate {
return false;
}
if has_url_userinfo_gate && url_userinfo_re().is_match(value) {
return true;
}
if has_prefix_gate && vendor_prefix_re().is_match(value) {
return true;
}
false
}
#[allow(dead_code)]
const REDACTED: &str = "[REDACTED]";
fn has_vendor_prefix_gate(s: &str) -> bool {
s.contains("AKIA")
|| s.contains("ghp_")
|| s.contains("xox")
|| s.contains("sk-")
|| s.contains("sk_live_")
|| s.contains("sk_test_")
|| s.contains("AIza")
|| s.contains("github_pat_")
|| s.contains("hf_")
|| s.contains("xai-")
|| s.contains("eyJ")
}
fn url_userinfo_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)([a-z][a-z0-9+.-]*://[^:]+:)([^@]+)(@)")
.expect("hardcoded URL-userinfo regex")
})
}
fn vendor_prefix_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(concat!(
r"(?ix)\b(?:",
r"sk-(?:live_|test_|proj-)?[a-zA-Z0-9+/=]{20,}(?:-[a-zA-Z0-9+/=]+)*",
r"|",
r"sk-ant-api[0-9]{2}-[A-Za-z0-9+/=]{90,}[_-][A-Za-z0-9]{5}",
r"|",
r"AKIA[A-Z0-9]{16}",
r"|",
r"ghp_[A-Za-z0-9]{36,}",
r"|",
r"github_pat_[A-Za-z0-9]{22,}_[A-Za-z0-9]{59,}",
r"|",
r"hf_[A-Za-z0-9]{34,}",
r"|",
r"xox[bpras]-[0-9]{2,}-[0-9]{2,}-[0-9]{2,}-[a-zA-Z0-9]{32,}",
r"|",
r"xai-[A-Za-z0-9+/=]{20,}(?:\.[A-Za-z0-9+/=]+)*",
r"|",
r"AIza[0-9A-Za-z_-]{35}",
r")",
))
.expect("hardcoded vendor prefix credential regex")
})
}
pub fn scrub_env(cmd: &mut Command) {
let mut sensitive_values = Vec::new();
let mut keys_to_remove: Vec<String> = Vec::new();
for (key, value) in cmd.as_std().get_envs() {
let Some(name) = key.to_str() else { continue };
if is_sensitive_env_name(name) {
if let Some(val) = value {
if let Some(s) = val.to_str() {
sensitive_values.push(s.to_string());
}
}
keys_to_remove.push(name.to_string());
}
}
for name in &keys_to_remove {
cmd.env_remove(name.as_str());
}
let original_env: Vec<(String, String)> = std::env::vars().collect();
let known_values: Vec<String> = if sensitive_values.is_empty() {
Vec::new()
} else {
let env_map: std::collections::HashMap<String, String> = original_env.into_iter().collect();
std::env::vars()
.filter_map(|(k, _)| {
let upper = k.to_ascii_uppercase();
if is_sensitive_env_name(&upper) {
env_map.get(&k).cloned()
} else {
None
}
})
.chain(sensitive_values)
.collect()
};
let cmd_keys: Vec<String> = cmd
.as_std()
.get_envs()
.filter_map(|(k, _)| k.to_str().map(|s| s.to_string()))
.collect();
for key in &cmd_keys {
if let Some(cmd_val) = cmd.as_std().get_envs().find_map(|(k, v)| {
if k.to_str() == Some(key.as_str()) {
v
} else {
None
}
}) {
let cmd_val_str = cmd_val.to_str().unwrap_or("");
if is_sensitive_env_value(cmd_val_str)
|| known_values
.iter()
.any(|kv| cmd_val_str.contains(kv.as_str()))
{
cmd.env(key, "[REDACTED]");
}
}
}
}
pub fn redact_secrets(text: &str) -> std::borrow::Cow<'_, str> {
redact_secrets_with(text, &[])
}
pub fn redact_secrets_with<'a>(
text: &'a str,
known_values: &'a [String],
) -> std::borrow::Cow<'a, str> {
let mut result = None;
let url_re = url_userinfo_re();
if url_re.is_match(text) {
let r = result.get_or_insert_with(|| text.to_string());
*r = url_re.replace_all(r, "${1}[REDACTED]${3}").into_owned();
}
let prefix_re = vendor_prefix_re();
if prefix_re.is_match(text) {
let r = result.get_or_insert_with(|| text.to_string());
*r = prefix_re.replace_all(r, "[REDACTED]").into_owned();
}
if !known_values.is_empty() {
for val in known_values {
if !val.is_empty() && text.contains(val.as_str()) {
let r = result.get_or_insert_with(|| text.to_string());
*r = r.replace(val.as_str(), "[REDACTED]");
}
}
}
match result {
Some(owned) => std::borrow::Cow::Owned(owned),
None => std::borrow::Cow::Borrowed(text),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_sensitive_env_name() {
assert!(is_sensitive_env_name("OPENAI_API_KEY"));
assert!(is_sensitive_env_name("ANTHROPIC_API_KEY"));
assert!(!is_sensitive_env_name("GITHUB_TOKEN")); assert!(!is_sensitive_env_name("GH_TOKEN")); assert!(is_sensitive_env_name("MY_SECRET"));
assert!(is_sensitive_env_name("DB_PASSWORD"));
assert!(!is_sensitive_env_name("PATH"));
assert!(!is_sensitive_env_name("HOME"));
assert!(!is_sensitive_env_name("USER"));
assert!(!is_sensitive_env_name("LANG"));
}
#[test]
fn test_is_sensitive_env_value() {
assert!(is_sensitive_env_value(
"postgres://user:secret@localhost/db"
));
assert!(is_sensitive_env_value(
"sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_AAAAA"
));
assert!(!is_sensitive_env_value("just a normal string"));
assert!(!is_sensitive_env_value(""));
}
#[test]
fn test_redact_secrets_url_credentials() {
let input = "DATABASE_URL=postgres://user:hunter2@localhost/db";
let cleaned = redact_secrets(input);
assert!(!cleaned.contains("hunter2"), "got {cleaned}");
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn test_redact_secrets_vendor_prefix() {
let input = "export OPENAI_API_KEY=sk-proj-abcdef1234567890abcdef1234567890abcdef12";
let cleaned = redact_secrets(input);
assert!(!cleaned.contains("sk-proj-"), "got {cleaned}");
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn test_redact_secrets_anthropic_key() {
let input = "ANTHROPIC_API_KEY=sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaa";
let cleaned = redact_secrets(input);
assert!(!cleaned.contains("sk-ant-api"), "got {cleaned}");
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn scrub_env_denylist_strips_openai_key() {
let mut cmd = Command::new("bash");
cmd.arg("-c").arg("echo done");
cmd.env("PATH", "/bin");
cmd.env("HOME", "/home/user");
cmd.env("OPENAI_API_KEY", "sk-abc123");
scrub_env(&mut cmd);
let out = format!("{:?}", cmd.as_std());
assert!(!out.contains("OPENAI_API_KEY="), "got {out}");
assert!(!out.contains("sk-abc123"), "got {out}");
assert!(out.contains("PATH"), "got {out}");
assert!(out.contains("HOME"), "got {out}");
}
#[test]
fn scrub_env_url_credential_value_is_redacted() {
let mut cmd = Command::new("bash");
cmd.arg("-c").arg("echo done");
cmd.env("DATABASE_URL", "postgres://user:hunter2@localhost/db");
cmd.env("USER", "testuser");
scrub_env(&mut cmd);
let out = format!("{:?}", cmd.as_std());
assert!(!out.contains("hunter2"), "got {out}");
assert!(out.contains("[REDACTED]"), "got {out}");
}
#[test]
fn scrub_env_value_redaction_with_known_secrets() {
let mut cmd = Command::new("bash");
cmd.arg("-c").arg("echo done");
cmd.env("BUILD_TOKEN", "dev-token-1234");
let secrets = vec!["dev-token-1234".to_string()];
let cleaned = redact_secrets_with("export BUILD_TOKEN=dev-token-1234", &secrets);
assert!(!cleaned.contains("dev-token-1234"), "got {cleaned}");
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn sandbox_new_with_bwrap_missing_disables() {
let sb = Sandbox::new(SandboxMode::Bwrap);
assert!(sb.mode == SandboxMode::Bwrap || sb.mode == SandboxMode::Off);
}
#[test]
fn sandbox_new_off_stays_off() {
let sb = Sandbox::new(SandboxMode::Off);
assert_eq!(sb.mode, SandboxMode::Off);
}
#[test]
fn redact_secrets_multiple_patterns() {
let input = "some text with a key sk-proj-abcdef1234567890abcdef12345678 and a url postgres://u:p@h";
let cleaned = redact_secrets(input);
assert!(!cleaned.contains("sk-proj-"), "got {cleaned}");
assert!(!cleaned.contains(":p@"), "got {cleaned}");
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn github_pat_prefix_is_caught() {
let input = "export GITHUB_PAT=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jjkkkkkkllllll";
let cleaned = redact_secrets(input);
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn xai_prefix_is_caught() {
let input = "export XAI_KEY=xai-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let cleaned = redact_secrets(input);
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn slack_webhook_prefix_is_caught() {
let prefix = format!("{}{}{}{}{}", 'x', 'o', 'x', 'b', '-');
let input = format!(
"export SLACK_HOOK={}12-34-56-abcdefabcdefabcdefabcdef12345678",
prefix
);
let cleaned = redact_secrets(&input);
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn aws_access_key_is_name_sensitive() {
assert!(is_sensitive_env_name("AWS_ACCESS_KEY_ID"));
assert!(is_sensitive_env_name("AWS_SECRET_ACCESS_KEY"));
}
#[test]
fn github_token_is_explicitly_caught() {
let input = "GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz1234567890abcdef";
let cleaned = redact_secrets(input);
assert!(cleaned.contains("[REDACTED]"), "got {cleaned}");
}
#[test]
fn plain_output_survives() {
let input = "compiled 42 files in 1.3s, all tests passed";
let out = redact_secrets(input);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
assert_eq!(out, input);
}
#[test]
fn gh_token_name_is_in_safe_exact() {
assert!(!is_sensitive_env_name("GH_TOKEN"));
let input = "GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz1234567890abcd";
assert!(redact_secrets(input).contains("[REDACTED]"));
}
#[test]
fn safe_exact_disallowlist_is_case_insensitive() {
assert!(!is_sensitive_env_name("Gh_Token"));
assert!(!is_sensitive_env_name("GitHub_Token"));
}
#[test]
fn redact_secrets_leaves_plain_text_untouched() {
let plain = "compiled 42 files in 1.3s, all tests passed";
assert!(matches!(
redact_secrets(plain),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(redact_secrets(plain), plain);
}
#[test]
fn redact_secrets_scrubs_known_env_values() {
let secrets = vec!["super-secret-build-value-1234".to_string()];
let out = redact_secrets_with("export X=super-secret-build-value-1234", &secrets);
assert!(!out.contains("super-secret-build-value-1234"), "got {out}");
assert!(out.contains("[REDACTED]"), "got {out}");
}
#[test]
fn vendor_prefix_gate_detects_sk_prefix() {
let s = format!("{}{}{}", "s", "k-", "testkey1234567890abcdefg");
assert!(has_vendor_prefix_gate(&s));
}
#[test]
fn vendor_prefix_gate_detects_ghp_prefix() {
let s = format!("{}{}{}", "g", "hp_", "testkey12345678901234567890123456789");
assert!(has_vendor_prefix_gate(&s));
}
#[test]
fn vendor_prefix_gate_detects_github_pat_prefix() {
let s = format!(
"github_{}at_11ABCDEFGHIJKLMNOPQRSTUV_abcdefghijklmnopqrstuvwxyz1234567890",
"p"
);
assert!(has_vendor_prefix_gate(&s));
}
#[test]
fn vendor_prefix_gate_detects_hf_prefix() {
let s = format!("{}{}{}", "h", "f_", "testkey12345678901234567890123456789");
assert!(has_vendor_prefix_gate(&s));
}
#[test]
fn vendor_prefix_gate_detects_xai_prefix() {
let s = format!("{}{}{}", "xa", "i-", "testkey1234567890abcdefg");
assert!(has_vendor_prefix_gate(&s));
}
#[test]
fn vendor_prefix_gate_detects_akia() {
assert!(has_vendor_prefix_gate("AKIA1234567890ABCD"));
}
#[allow(non_snake_case)]
#[test]
fn vendor_prefix_gate_detects_eyJ() {
assert!(has_vendor_prefix_gate("eyJhbGciOiJIUzI1NiJ9"));
}
#[test]
fn vendor_prefix_gate_plain_text_rejected() {
assert!(!has_vendor_prefix_gate("hello world"));
assert!(!has_vendor_prefix_gate("PATH=/usr/bin"));
assert!(!has_vendor_prefix_gate(""));
}
#[test]
fn sandbox_mode_parse_microvm() {
#[cfg(feature = "sandbox-microvm")]
assert_eq!(SandboxMode::parse(Some("microvm")), SandboxMode::Microvm);
#[cfg(not(feature = "sandbox-microvm"))]
assert_eq!(SandboxMode::parse(Some("microvm")), SandboxMode::Off);
}
#[test]
fn sandbox_mode_parse_bare_off() {
assert_eq!(SandboxMode::parse(None), SandboxMode::Off);
assert_eq!(SandboxMode::parse(Some("bwrap")), SandboxMode::Bwrap);
assert_eq!(SandboxMode::parse(Some("")), SandboxMode::Off);
assert_eq!(SandboxMode::parse(Some("garbage")), SandboxMode::Off);
assert_eq!(SandboxMode::parse(Some("off")), SandboxMode::Off);
assert_eq!(SandboxMode::parse(Some("none")), SandboxMode::Off);
}
#[test]
fn sandbox_mode_status_badge() {
assert_eq!(SandboxMode::Off.status_badge(), None);
assert_eq!(SandboxMode::Bwrap.status_badge(), Some("bwrap"));
#[cfg(feature = "sandbox-microvm")]
assert_eq!(SandboxMode::Microvm.status_badge(), Some("vm"));
}
#[cfg(feature = "sandbox-microvm")]
#[test]
fn sandbox_new_microvm_mode() {
let sb = Sandbox::new(SandboxMode::Microvm);
assert!(sb.is_microvm());
assert_eq!(sb.mode, SandboxMode::Microvm);
}
#[cfg(feature = "sandbox-microvm")]
#[test]
fn sandbox_ssh_connect_info_none_before_microvm_start() {
let sb = Sandbox::new(SandboxMode::Microvm);
assert!(sb.ssh_connect_info().is_none());
}
#[cfg(feature = "sandbox-microvm")]
#[test]
fn set_microvm_image_in_bwrap_mode_is_noop() {
let sb = Sandbox::new(SandboxMode::Bwrap);
sb.set_microvm_image("alpine".to_string()).unwrap();
assert!(sb.ssh_connect_info().is_none());
}
#[cfg(feature = "sandbox-microvm")]
#[test]
fn set_microvm_resources_in_bwrap_mode_is_noop() {
let sb = Sandbox::new(SandboxMode::Bwrap);
sb.set_microvm_resources(4, 2048).unwrap();
assert!(sb.ssh_connect_info().is_none());
}
#[test]
fn scrub_env_mixed_name_and_value_redaction() {
let mut cmd = Command::new("bash");
cmd.arg("-c").arg("echo done");
cmd.env("OPENAI_API_KEY", "sk-secret-123");
let pw = "abc123xyz";
let db_url = format!("postgres://user:{}@localhost/db", pw);
cmd.env("DATABASE_URL", db_url);
cmd.env("HOME", "/home/user");
scrub_env(&mut cmd);
let out = format!("{:?}", cmd.as_std());
assert!(
!out.contains("OPENAI_API_KEY=sk-secret-123"),
"name-based strip failed — value still present: {out}"
);
assert!(!out.contains("sk-secret-123"), "secret value leaked: {out}");
assert!(!out.contains(pw), "URL credential leaked: {out}");
assert!(out.contains("[REDACTED]"), "value not redacted: {out}");
assert!(out.contains("HOME"), "benign env removed: {out}");
}
#[test]
fn redact_secrets_with_multiple_known_values() {
let known = vec!["secret-one".to_string(), "secret-two".to_string()];
let input = "TOKEN_A=secret-one TOKEN_B=secret-two TOKEN_C=safe";
let cleaned = redact_secrets_with(input, &known);
assert!(
!cleaned.contains("secret-one"),
"secret-one leaked: {cleaned}"
);
assert!(
!cleaned.contains("secret-two"),
"secret-two leaked: {cleaned}"
);
assert!(cleaned.contains("safe"), "benign value stripped: {cleaned}");
assert_eq!(
cleaned.matches("[REDACTED]").count(),
2,
"expected 2 redactions, got: {cleaned}"
);
}
#[tokio::test]
async fn exec_off_mode_echo() {
let sb = Sandbox::new(SandboxMode::Off);
let result = sb.exec("echo hello", 5).await;
assert!(result.is_ok(), "exec should succeed, got: {result:?}");
let output = result.unwrap();
assert!(
output.merged.contains("hello"),
"expected 'hello', got: {}",
output.merged
);
assert_eq!(
output.exit_code, 0,
"expected exit 0, got {}",
output.exit_code
);
}
#[tokio::test]
async fn exec_off_mode_nonzero_exit() {
let sb = Sandbox::new(SandboxMode::Off);
let result = sb.exec("exit 42", 5).await;
assert!(
result.is_ok(),
"exec of 'exit 42' should succeed (exit code captured), got: {result:?}"
);
let output = result.unwrap();
assert_eq!(
output.exit_code, 42,
"expected exit 42, got {}",
output.exit_code
);
}
#[tokio::test]
async fn exec_off_mode_stderr_captured() {
let sb = Sandbox::new(SandboxMode::Off);
let result = sb.exec("echo stderr-msg >&2", 5).await;
assert!(result.is_ok(), "exec should succeed, got: {result:?}");
let output = result.unwrap();
assert!(
output.merged.contains("stderr-msg"),
"expected stderr in merged output, got: {}",
output.merged
);
}
#[test]
fn wrap_command_off_produces_bash() {
let sb = Sandbox::new(SandboxMode::Off);
let cmd = sb.wrap_command("echo hello");
let out = format!("{:?}", cmd.as_std());
assert!(out.contains("bash"), "expected bash, got: {out}");
assert!(
out.contains("echo hello"),
"expected 'echo hello' in command args, got: {out}"
);
}
#[test]
fn wrap_command_sets_noninteractive_env() {
let sb = Sandbox::new(SandboxMode::Off);
let cmd = sb.wrap_command("true");
let found: std::collections::HashMap<String, String> = cmd
.as_std()
.get_envs()
.filter_map(|(k, v)| {
v.map(|v| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
})
.collect();
assert_eq!(
found.get("GIT_TERMINAL_PROMPT").map(String::as_str),
Some("0")
);
assert_eq!(
found.get("GCM_INTERACTIVE").map(String::as_str),
Some("Never")
);
assert_eq!(
found.get("DEBIAN_FRONTEND").map(String::as_str),
Some("noninteractive")
);
}
#[test]
fn wrap_command_bwrap_produces_bwrap_with_isolation_args() {
let sb = Sandbox::new(SandboxMode::Bwrap);
let cmd = sb.wrap_command("echo hello");
let out = format!("{:?}", cmd.as_std());
if sb.mode == SandboxMode::Bwrap {
assert!(out.contains("bwrap"), "expected bwrap, got: {out}");
assert!(
out.contains("--unshare-all"),
"expected --unshare-all, got: {out}"
);
assert!(out.contains("--dev"), "expected --dev, got: {out}");
assert!(
out.contains("--die-with-parent"),
"expected --die-with-parent, got: {out}"
);
assert!(out.contains("echo hello"), "expected command, got: {out}");
}
}
}