use std::sync::OnceLock;
use regex::Regex;
use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct Sandbox {
enabled: bool,
}
impl Sandbox {
pub fn new(enabled: bool) -> Self {
let effective_enabled = if enabled {
if Self::bwrap_available() {
true
} 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."
);
false
}
} else {
false
};
Sandbox {
enabled: effective_enabled,
}
}
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 cwd = std::env::current_dir().unwrap_or_else(|_| ".".into());
let mut cmd = if !self.enabled {
let mut c = Command::new("bash");
c.arg("-c").arg(command);
c
} else {
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
}
}
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
}
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"(?P<pre>[A-Za-z][A-Za-z0-9+.-]*://[^/\s:@]*:)(?P<pw>[^/\s@]+)(?P<at>@)")
.unwrap()
})
}
fn vendor_prefix_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?x)
(?P<b>^|[^A-Za-z0-9])
(?P<tok>
AKIA[0-9A-Z]{16} # AWS Access Key ID
| ghp_[A-Za-z0-9]{36} # GitHub PAT (classic)
| github_pat_[A-Za-z0-9_]{20,} # GitHub PAT (fine-grained)
| gho_[A-Za-z0-9]{30,} # GitHub OAuth
| ghu_[A-Za-z0-9]{30,} # GitHub user-to-server
| ghs_[A-Za-z0-9]{30,} # GitHub server-to-server
| xox[baprs]-[A-Za-z0-9-]{10,} # Slack tokens
| sk-[A-Za-z0-9_-]{20,} # OpenAI/Anthropic/OpenRouter
| sk_live_[A-Za-z0-9]{20,} # Stripe live
| sk_test_[A-Za-z0-9]{20,} # Stripe test
| AIza[A-Za-z0-9_-]{30,} # Google API
| hf_[A-Za-z0-9]{30,} # HuggingFace
| xai-[A-Za-z0-9]{30,} # xAI (Grok)
| eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_=-]{4,} # JWT (3-part)
)
",
)
.unwrap()
})
}
pub fn redact_secrets(text: &str) -> std::borrow::Cow<'_, str> {
redact_secrets_with(text, env_secret_values())
}
fn env_secret_values() -> &'static [String] {
static VALUES: OnceLock<Vec<String>> = OnceLock::new();
VALUES.get_or_init(|| {
let mut v: Vec<String> = std::env::vars()
.filter(|(k, val)| is_sensitive_env_name(k) && val.len() >= 8)
.map(|(_, val)| val)
.collect();
v.sort_by(|a, b| b.len().cmp(&a.len()));
v.dedup();
v
})
}
fn redact_secrets_with<'a>(text: &'a str, literal_secrets: &[String]) -> std::borrow::Cow<'a, str> {
use std::borrow::Cow;
let mut out: Option<String> = None;
for s in literal_secrets {
if s.is_empty() {
continue;
}
let cur = out.as_deref().unwrap_or(text);
if cur.contains(s.as_str()) {
out = Some(cur.replace(s.as_str(), REDACTED));
}
}
let replaced = {
let cur = out.as_deref().unwrap_or(text);
if has_vendor_prefix_gate(cur) {
match vendor_prefix_re().replace_all(cur, "${b}[REDACTED]") {
Cow::Owned(s) => Some(s),
Cow::Borrowed(_) => None,
}
} else {
None
}
};
if let Some(s) = replaced {
out = Some(s);
}
let replaced = {
let cur = out.as_deref().unwrap_or(text);
if cur.contains("://") {
match url_userinfo_re().replace_all(cur, "${pre}[REDACTED]${at}") {
Cow::Owned(s) => Some(s),
Cow::Borrowed(_) => None,
}
} else {
None
}
};
if let Some(s) = replaced {
out = Some(s);
}
match out {
Some(s) => Cow::Owned(s),
None => Cow::Borrowed(text),
}
}
fn scrub_env(cmd: &mut Command) {
for (k, v) in std::env::vars_os() {
let Some(name) = k.to_str() else { continue };
if is_sensitive_env_name(name) {
cmd.env_remove(&k);
continue;
}
if let Some(val) = v.to_str()
&& is_sensitive_env_value(val)
{
cmd.env_remove(&k);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_sensitive_env_name_matches_provider_keys() {
assert!(is_sensitive_env_name("OPENAI_API_KEY"));
assert!(is_sensitive_env_name("ANTHROPIC_API_KEY"));
assert!(is_sensitive_env_name("OPENROUTER_API_KEY"));
assert!(is_sensitive_env_name("DEEPSEEK_API_KEY"));
assert!(is_sensitive_env_name("GLM_API_KEY"));
assert!(is_sensitive_env_name("ZHIPU_API_KEY"));
assert!(is_sensitive_env_name("EXA_API_KEY"));
assert!(is_sensitive_env_name("PARALLEL_API_KEY"));
assert!(is_sensitive_env_name("GEMINI_API_KEY"));
}
#[test]
fn is_sensitive_env_name_matches_pattern_tokens() {
assert!(is_sensitive_env_name("SOMETHING_SECRET"));
assert!(is_sensitive_env_name("DB_PASSWORD"));
assert!(is_sensitive_env_name("MY_TOKEN"));
assert!(is_sensitive_env_name("APP_CREDS"));
assert!(is_sensitive_env_name("OAUTH_TOKEN"));
assert!(is_sensitive_env_name("AUTH_HEADER"));
assert!(is_sensitive_env_name("my_secret"));
}
#[test]
fn is_sensitive_env_name_matches_explicit_cloud_vars() {
assert!(is_sensitive_env_name("AWS_ACCESS_KEY_ID"));
assert!(is_sensitive_env_name("AWS_SESSION_TOKEN"));
}
#[test]
fn is_sensitive_env_name_lets_through_safe_vars() {
assert!(!is_sensitive_env_name("PATH"));
assert!(!is_sensitive_env_name("HOME"));
assert!(!is_sensitive_env_name("USER"));
assert!(!is_sensitive_env_name("LOGNAME"));
assert!(!is_sensitive_env_name("LANG"));
assert!(!is_sensitive_env_name("LC_ALL"));
assert!(!is_sensitive_env_name("TERM"));
assert!(!is_sensitive_env_name("PWD"));
assert!(!is_sensitive_env_name("EDITOR"));
assert!(!is_sensitive_env_name("VISUAL"));
assert!(!is_sensitive_env_name("CARGO_HOME"));
assert!(!is_sensitive_env_name("RUSTC_WRAPPER"));
assert!(!is_sensitive_env_name("GOPATH"));
assert!(!is_sensitive_env_name("VIRTUAL_ENV"));
assert!(!is_sensitive_env_name("NODE_ENV"));
assert!(!is_sensitive_env_name("GITHUB_TOKEN"));
assert!(!is_sensitive_env_name("GH_TOKEN"));
assert!(!is_sensitive_env_name("SSH_AUTH_SOCK"));
}
#[test]
fn is_sensitive_env_value_catches_db_userinfo() {
assert!(is_sensitive_env_value("postgres://user:pass@host:5432/db"));
assert!(is_sensitive_env_value("mysql://root:hunter2@db/app"));
assert!(is_sensitive_env_value(
"mongodb+srv://admin:secret@cluster.example.com/test"
));
assert!(is_sensitive_env_value(
"redis://:supersecret@redis.internal:6379"
));
assert!(is_sensitive_env_value(
"https://deploy:tok123@webhook.example.com/x"
));
}
#[test]
fn is_sensitive_env_value_catches_vendor_prefixes() {
assert!(is_sensitive_env_value("AKIAIOSFODNN7EXAMPLE"));
assert!(is_sensitive_env_value(
"ghp_abcdefghijklmnopqrstuvwxyz0123456789"
));
assert!(is_sensitive_env_value(
"github_pat_abcdefghij1234567890_morechars"
));
assert!(is_sensitive_env_value(
"xoxb-1234567890-abcdefghij-AbCdEfGh"
));
assert!(is_sensitive_env_value("sk-proj-abcdef1234567890ABCDEF"));
assert!(is_sensitive_env_value(
"AIzaSyA-abcdefghijklmnopqrstuvwxyz_-_-_-"
));
assert!(is_sensitive_env_value(
"hf_abcdefghijklmnopqrstuvwxyz0123456789"
));
assert!(is_sensitive_env_value(
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
));
}
#[test]
fn is_sensitive_env_value_lets_through_plain_values() {
assert!(!is_sensitive_env_value("/usr/local/bin:/usr/bin:/bin"));
assert!(!is_sensitive_env_value("/Users/dev/project"));
assert!(!is_sensitive_env_value("xterm-256color"));
assert!(!is_sensitive_env_value("en_US.UTF-8"));
assert!(!is_sensitive_env_value("development"));
assert!(!is_sensitive_env_value(""));
assert!(!is_sensitive_env_value("https://api.example.com/v1"));
assert!(!is_sensitive_env_value("note: see docs at scheme://x"));
assert!(!is_sensitive_env_value(
"abcdef1234567890abcdef1234567890abcdef1234567890"
));
}
#[test]
fn is_sensitive_env_value_short_prefix_lookalikes_not_flagged() {
assert!(!is_sensitive_env_value("AKIA")); assert!(!is_sensitive_env_value("ghp_short")); assert!(!is_sensitive_env_value("sk-")); assert!(!is_sensitive_env_value("eyJhbGciOiJIUzI1NiJ9"));
}
#[test]
fn is_sensitive_env_name_accidental_pattern_excluded() {
assert!(!is_sensitive_env_name("PATH")); assert!(is_sensitive_env_name("KEY_BINDINGS"));
}
#[test]
fn redact_secrets_scrubs_vendor_prefixes() {
let v = redact_secrets("token=sk-abcdefghijklmnopqrstuvwxyz0123 done");
assert!(!v.contains("sk-abcdefghijklmnopqrstuvwxyz0123"), "got {v}");
assert!(v.contains("[REDACTED]"), "got {v}");
let gh = redact_secrets("ghp_0123456789abcdefghijklmnopqrstuvwxyz");
assert!(!gh.contains("ghp_0123456789"), "got {gh}");
let jwt = redact_secrets("auth eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM0.SflKxwRJSMeKKF2");
assert!(
!jwt.contains("eyJhbGciOiJIUzI1"),
"JWT must be redacted, got {jwt}"
);
}
#[test]
fn redact_secrets_scrubs_token_after_dash_or_underscore() {
for s in [
"--header=x-key-sk-abcdefghijklmnopqrstuvwxyz0123",
"FOO_sk-abcdefghijklmnopqrstuvwxyz0123",
"ghp_0123456789abcdefghijklmnopqrstuvwxyz-trailing",
] {
let out = redact_secrets(s);
assert!(
out.contains("[REDACTED]"),
"token after -/_ must be redacted; got {out}"
);
assert!(
!out.contains("sk-abcdefghijklmnopqrstuvwxyz0123")
&& !out.contains("ghp_0123456789abcdefghijklmnopqrstuvwxyz"),
"secret leaked: {out}"
);
}
}
#[test]
fn redact_secrets_scrubs_url_userinfo_password() {
let v = redact_secrets("DATABASE_URL=postgres://user:s3cr3tpassword@db.host/app");
assert!(
!v.contains("s3cr3tpassword"),
"password must be redacted, got {v}"
);
assert!(v.contains("db.host/app"), "got {v}");
}
#[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}");
}
}