use std::process::Command as StdCommand;
use tokio::process::Command;
pub const SAFE_BASE_VARS: &[&str] = &[
"HOME",
"USER",
"LOGNAME",
"SHELL",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"LC_COLLATE",
"LC_NUMERIC",
"LC_TIME",
"TERM",
"PATH",
"TMPDIR",
"TMP",
"TEMP",
"PWD",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
];
pub fn tool_extras_for(argv0: &str) -> &'static [&'static str] {
match argv0 {
"cargo" | "rustc" | "rustup" | "rustfmt" | "clippy-driver" => &[
"CARGO_HOME",
"RUSTUP_HOME",
"RUST_LOG",
"RUST_BACKTRACE",
"RUSTC_WRAPPER",
"CARGO_TARGET_DIR",
],
"git" => &[
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_PAGER",
],
"npm" | "node" | "yarn" | "pnpm" | "npx" => {
&["NODE_PATH", "NPM_CONFIG_USERCONFIG", "NODE_ENV"]
}
"python" | "python3" | "pip" | "pip3" | "uv" | "pipx" | "poetry" => &[
"PYTHONPATH",
"VIRTUAL_ENV",
"PYENV_ROOT",
"PYENV_VERSION",
"PIPX_HOME",
"PIPX_BIN_DIR",
],
"kubectl" | "helm" | "k9s" => &["KUBECONFIG"],
"docker" | "podman" => &["DOCKER_HOST", "DOCKER_CONFIG"],
"gcloud" | "bq" | "gsutil" => &["CLOUDSDK_CONFIG", "CLOUDSDK_ACTIVE_CONFIG_NAME"],
"aws" => &[
"AWS_CONFIG_FILE",
"AWS_PROFILE",
"AWS_REGION",
"AWS_DEFAULT_REGION",
"AWS_SHARED_CREDENTIALS_FILE",
],
"make" | "gmake" => &["MAKEFLAGS", "MAKELEVEL"],
_ => &[],
}
}
fn parse_argv0(raw_command: &str) -> &str {
raw_command
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("")
}
pub fn scrub(cmd: &mut Command, raw_command: &str) {
cmd.env_clear();
apply_allowlist(raw_command, |name, value| {
cmd.env(name, value);
});
}
pub fn scrub_std(cmd: &mut StdCommand, raw_command: &str) {
cmd.env_clear();
apply_allowlist(raw_command, |name, value| {
cmd.env(name, value);
});
}
fn apply_allowlist(raw_command: &str, mut set: impl FnMut(&str, String)) {
for name in SAFE_BASE_VARS {
if let Ok(value) = std::env::var(name) {
set(name, value);
}
}
let argv0 = parse_argv0(raw_command);
for name in tool_extras_for(argv0) {
if let Ok(value) = std::env::var(name) {
set(name, value);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_argv0_simple_command() {
assert_eq!(parse_argv0("cargo build"), "cargo");
}
#[test]
fn parse_argv0_strips_path_prefix() {
assert_eq!(parse_argv0("/usr/bin/git status"), "git");
}
#[test]
fn parse_argv0_with_pipeline() {
assert_eq!(parse_argv0("cargo test 2>&1 | tee out.log"), "cargo");
}
#[test]
fn parse_argv0_empty() {
assert_eq!(parse_argv0(""), "");
}
#[test]
fn parse_argv0_whitespace_only() {
assert_eq!(parse_argv0(" "), "");
}
#[test]
fn parse_argv0_bash_dash_c() {
assert_eq!(parse_argv0("bash -c 'cargo build'"), "bash");
}
#[test]
fn tool_extras_for_cargo_includes_cargo_home() {
assert!(tool_extras_for("cargo").contains(&"CARGO_HOME"));
}
#[test]
fn tool_extras_for_git_excludes_credential_vectors() {
let extras = tool_extras_for("git");
assert!(extras.contains(&"GIT_AUTHOR_NAME"));
assert!(!extras.contains(&"GIT_ASKPASS"));
assert!(!extras.contains(&"GIT_SSH_COMMAND"));
assert!(!extras.contains(&"GIT_HTTP_USER_AGENT"));
}
#[test]
fn tool_extras_for_npm_excludes_token() {
let extras = tool_extras_for("npm");
assert!(!extras.contains(&"NPM_TOKEN"));
assert!(!extras.contains(&"NODE_AUTH_TOKEN"));
}
#[test]
fn tool_extras_for_aws_excludes_secret_access_key() {
let extras = tool_extras_for("aws");
assert!(extras.contains(&"AWS_PROFILE"));
assert!(!extras.contains(&"AWS_SECRET_ACCESS_KEY"));
assert!(!extras.contains(&"AWS_ACCESS_KEY_ID"));
assert!(!extras.contains(&"AWS_SESSION_TOKEN"));
}
#[test]
fn tool_extras_for_unknown_tool_returns_empty() {
assert!(tool_extras_for("ls").is_empty());
assert!(tool_extras_for("rm").is_empty());
assert!(tool_extras_for("totally-bespoke-tool").is_empty());
}
#[test]
fn safe_base_vars_excludes_all_credential_patterns() {
for var in SAFE_BASE_VARS {
let upper = var.to_uppercase();
assert!(!upper.contains("KEY"), "SAFE_BASE_VARS contains {var}");
assert!(!upper.contains("SECRET"), "SAFE_BASE_VARS contains {var}");
assert!(!upper.contains("TOKEN"), "SAFE_BASE_VARS contains {var}");
assert!(!upper.contains("PASSWORD"), "SAFE_BASE_VARS contains {var}");
assert!(!upper.contains("CRED"), "SAFE_BASE_VARS contains {var}");
}
}
#[test]
fn tool_extras_excludes_all_credential_patterns() {
for argv0 in [
"cargo", "git", "npm", "node", "python", "pip", "uv", "kubectl", "helm", "docker",
"gcloud", "aws", "make",
] {
for var in tool_extras_for(argv0) {
let upper = var.to_uppercase();
assert!(
!upper.contains("TOKEN"),
"{argv0} extras contain TOKEN-shaped var: {var}"
);
assert!(
!upper.contains("SECRET"),
"{argv0} extras contain SECRET-shaped var: {var}"
);
assert!(
!upper.contains("PASSWORD"),
"{argv0} extras contain PASSWORD-shaped var: {var}"
);
if upper.contains("KEY") {
panic!("{argv0} extras contain KEY-shaped var: {var}");
}
}
}
}
fn run_env_with_poison(poison_var: &str, poison_val: &str, raw_command: &str) -> String {
unsafe {
std::env::set_var(poison_var, poison_val);
}
let mut cmd = StdCommand::new("env");
scrub_std(&mut cmd, raw_command);
let output = cmd.output().expect("env spawn");
unsafe {
std::env::remove_var(poison_var);
}
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[test]
fn scrub_strips_openai_api_key() {
let env_dump = run_env_with_poison("KODA_TEST_OPENAI_KEY_1228", "sk-must-not-leak", "ls");
assert!(
!env_dump.contains("sk-must-not-leak"),
"scrub leaked the poison value into child env:\n{env_dump}"
);
assert!(
!env_dump.contains("KODA_TEST_OPENAI_KEY_1228"),
"scrub leaked the var name into child env:\n{env_dump}"
);
}
#[test]
fn scrub_strips_aws_secret_access_key() {
let env_dump = run_env_with_poison(
"KODA_TEST_AWS_SECRET_1228",
"wJalrXUtnFEMI-must-not-leak",
"aws s3 ls",
);
assert!(
!env_dump.contains("wJalrXUtnFEMI-must-not-leak"),
"scrub leaked AWS-shaped secret:\n{env_dump}"
);
}
#[test]
fn scrub_strips_github_token() {
let env_dump = run_env_with_poison(
"KODA_TEST_GITHUB_TOKEN_1228",
"ghp_must-not-leak",
"git status",
);
assert!(
!env_dump.contains("ghp_must-not-leak"),
"scrub leaked GITHUB_TOKEN-shaped value:\n{env_dump}"
);
}
#[test]
fn scrub_keeps_path() {
let mut cmd = StdCommand::new("env");
scrub_std(&mut cmd, "ls");
let output = cmd.output().expect("env spawn");
let env_dump = String::from_utf8_lossy(&output.stdout);
assert!(
env_dump.contains("PATH="),
"scrub dropped PATH — sandbox would be unable to find any tool:\n{env_dump}"
);
}
#[test]
fn scrub_per_tool_extras_for_cargo_only() {
unsafe {
std::env::set_var("CARGO_HOME", "/tmp/koda-test-cargo-home-1228");
}
let mut cargo_cmd = StdCommand::new("env");
scrub_std(&mut cargo_cmd, "cargo build");
let cargo_env =
String::from_utf8_lossy(&cargo_cmd.output().expect("env").stdout).into_owned();
let mut ls_cmd = StdCommand::new("env");
scrub_std(&mut ls_cmd, "ls -la");
let ls_env = String::from_utf8_lossy(&ls_cmd.output().expect("env").stdout).into_owned();
unsafe {
std::env::remove_var("CARGO_HOME");
}
assert!(
cargo_env.contains("/tmp/koda-test-cargo-home-1228"),
"CARGO_HOME should pass through for `cargo …`:\n{cargo_env}"
);
assert!(
!ls_env.contains("/tmp/koda-test-cargo-home-1228"),
"CARGO_HOME should NOT pass through for `ls`:\n{ls_env}"
);
}
}