diff --git a/README.md b/README.md index 1390450..96564dc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ The threat model is prompt injection and accidental damage, not a determined att **Not protected in blacklist mode:** arbitrary readable files outside the sensitive paths list, and D-Bus method calls (access control is daemon-side). +## Environment filtering + +Both modes clamp the environment the child sees so prompt-injected agents can't `printenv` their way to secrets. + +- **Whitelist** clears the parent env and re-adds a small allowlist: identity/shell vars (`HOME`, `PATH`, …), terminal/locale, proxy, non-GUI XDG base dirs, and agent vendor prefixes (`ANTHROPIC_*`, `CLAUDE_*`, `OPENAI_*`, `CODEX_*`, `GEMINI_*`, `OTEL_*`). +- **Blacklist** keeps the parent env but unsets credentials and dangling pointers: cloud creds (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`, …), backup tool passphrases, sockets stripped by path overlays (`SSH_AUTH_SOCK`, `DISPLAY`, `GNUPGHOME`, …), and anything matching `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_PASSPHRASE`, `*_API_KEY`, `*_PRIVATE_KEY`, `*_CLIENT_SECRET`. Vendor-prefix vars (`ANTHROPIC_API_KEY` etc.) are carved out so they survive. + +Disable the built-in policy entirely with `--no-env-filter` (or `env-filter = false` in the config file) to pass the parent env through unchanged. User `--setenv`/`--unsetenv` escape hatches still apply. + ## Seccomp Both modes apply a seccomp-BPF syscall allowlist derived from Podman's default profile. Dangerous syscalls (`mount`, `unshare`, `ptrace`, `bpf`, `perf_event_open`, `io_uring_*`, `keyctl`, `kexec_*`, …) return `ENOSYS`. Disable with `--no-seccomp` or `seccomp = false` in the config file. @@ -41,9 +50,11 @@ command = ["claude", "--dangerously-skip-permissions"] ## Escape hatches -When the agent needs access to something the sandbox blocks, use `--rw` or `--ro`: +When the agent needs access to something the sandbox blocks, use `--rw` or `--ro` for paths and `--setenv`/`--unsetenv` for env vars. User overrides always win over the built-in policies. ```bash agent-sandbox --rw /var/run/docker.sock -- claude --dangerously-skip-permissions agent-sandbox --ro ~/.aws -- claude --dangerously-skip-permissions +agent-sandbox --setenv DATABASE_URL=postgres://localhost/dev -- claude +agent-sandbox --unsetenv HTTP_PROXY -- claude ``` diff --git a/config-example.toml b/config-example.toml index 35ab858..5de799c 100644 --- a/config-example.toml +++ b/config-example.toml @@ -12,6 +12,8 @@ rw = [ "~/.cargo", "~/.rustup", ] +setenv = { DATABASE_URL = "postgres://localhost/dev" } +unsetenv = ["HTTP_PROXY", "HTTPS_PROXY"] entrypoint = ["claude", "--dangerously-skip-permissions"] [profile.blacklist] diff --git a/src/cli.rs b/src/cli.rs index e530b95..d90d98a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,6 +42,14 @@ pub struct Args { #[arg(long, overrides_with = "seccomp")] pub no_seccomp: bool, + /// Enable built-in env filtering (on by default; overrides config-file `env-filter = false`) + #[arg(long, overrides_with = "no_env_filter")] + pub env_filter: bool, + + /// Disable built-in env filtering; pass the parent env through unchanged + #[arg(long, overrides_with = "env_filter")] + pub no_env_filter: bool, + /// Bind an extra path read-write (repeatable) #[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)] pub extra_rw: Vec, @@ -78,6 +86,19 @@ pub struct Args { #[arg(long = "mask", value_name = "PATH", action = clap::ArgAction::Append)] pub mask: Vec, + /// Force-set an environment variable inside the sandbox (repeatable) + #[arg( + long = "setenv", + value_name = "KEY=VALUE", + value_parser = parse_key_value, + action = clap::ArgAction::Append, + )] + pub setenv: Vec<(String, String)>, + + /// Force-unset an environment variable inside the sandbox (repeatable) + #[arg(long = "unsetenv", value_name = "KEY", action = clap::ArgAction::Append)] + pub unsetenv: Vec, + /// Pass an arbitrary argument directly to bwrap (repeatable) #[arg(long = "bwrap-arg", value_name = "ARG", action = clap::ArgAction::Append)] pub bwrap_args: Vec, @@ -90,3 +111,13 @@ pub struct Args { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub command_and_args: Vec, } + +fn parse_key_value(raw: &str) -> Result<(String, String), String> { + let (key, value) = raw + .split_once('=') + .ok_or_else(|| format!("expected KEY=VALUE, got {raw:?}"))?; + if key.is_empty() { + return Err(format!("empty key in {raw:?}")); + } + Ok((key.to_string(), value.to_string())) +} diff --git a/src/config.rs b/src/config.rs index b52e314..fa7a3b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -44,6 +44,12 @@ pub fn build(args: Args, file_config: Option) -> Result) -> Result(cli: Vec, profile: &[T], globals: &[T]) -> Vec { globals.iter().chain(profile).cloned().chain(cli).collect() } +fn merge_setenv( + cli: Vec<(String, String)>, + profile: &BTreeMap, + globals: &BTreeMap, +) -> Vec<(String, String)> { + let mut merged: BTreeMap = globals.clone(); + for (k, v) in profile { + merged.insert(k.clone(), v.clone()); + } + for (k, v) in cli { + merged.insert(k, v); + } + merged.into_iter().collect() +} + fn resolve_command( cli_entrypoint: Option, mut passthrough_args: Vec, @@ -260,6 +283,7 @@ pub struct Options { pub hardened: Option, pub unshare_net: Option, pub seccomp: Option, + pub env_filter: Option, pub entrypoint: Option, pub command: Option, pub dry_run: Option, @@ -271,6 +295,10 @@ pub struct Options { #[serde(default)] pub mask: Vec, #[serde(default)] + pub setenv: BTreeMap, + #[serde(default)] + pub unsetenv: Vec, + #[serde(default)] pub bwrap_args: Vec, } @@ -908,6 +936,70 @@ mod tests { assert_eq!(config.chdir, std::fs::canonicalize("/tmp").unwrap()); } + #[test] + fn build_setenv_merges_globals_profile_cli() { + let file_config = FileConfig { + options: Options { + setenv: BTreeMap::from([ + ("A".into(), "global".into()), + ("B".into(), "global".into()), + ]), + ..Options::default() + }, + profile: HashMap::from([( + "p".into(), + Options { + setenv: BTreeMap::from([ + ("B".into(), "profile".into()), + ("C".into(), "profile".into()), + ]), + ..Options::default() + }, + )]), + ..FileConfig::default() + }; + let args = Args { + profile: Some("p".into()), + setenv: vec![("C".into(), "cli".into()), ("D".into(), "cli".into())], + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + assert_eq!( + config.setenv, + vec![ + ("A".into(), "global".into()), + ("B".into(), "profile".into()), + ("C".into(), "cli".into()), + ("D".into(), "cli".into()), + ] + ); + } + + #[test] + fn build_unsetenv_accumulates() { + let file_config = FileConfig { + options: Options { + unsetenv: vec!["G".into()], + ..Options::default() + }, + profile: HashMap::from([( + "p".into(), + Options { + unsetenv: vec!["P".into()], + ..Options::default() + }, + )]), + ..FileConfig::default() + }; + let args = Args { + profile: Some("p".into()), + unsetenv: vec!["C".into()], + ..args_with_command() + }; + let config = build(args, Some(file_config)).unwrap(); + assert_eq!(config.unsetenv, vec!["G", "P", "C"]); + } + #[test] fn build_mask_accumulates() { let file_config = FileConfig { diff --git a/src/env.rs b/src/env.rs new file mode 100644 index 0000000..c128a2f --- /dev/null +++ b/src/env.rs @@ -0,0 +1,136 @@ +pub fn whitelist_env_args(parent_env: &[(String, String)]) -> Vec { + let mut args = vec!["--clearenv".to_string()]; + for (key, value) in parent_env { + if whitelist_keeps(key) { + args.push("--setenv".to_string()); + args.push(key.clone()); + args.push(value.clone()); + } + } + args +} + +fn whitelist_keeps(key: &str) -> bool { + WHITELIST_KEEP_EXACT.contains(&key) + || WHITELIST_KEEP_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) +} + +const WHITELIST_KEEP_EXACT: &[&str] = &[ + // identity / shell + "HOME", + "USER", + "LOGNAME", + "PATH", + "SHELL", + // terminal + "TERM", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + "CLICOLOR", + // locale + "LANG", + "TZ", + // editor + "EDITOR", + "VISUAL", + "PAGER", + // tmp + "TMPDIR", + // proxy + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + // non-GUI XDG base dirs + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_DIRS", + "XDG_DATA_DIRS", +]; + +const WHITELIST_KEEP_PREFIXES: &[&str] = &[ + "LC_", + "ANTHROPIC_", + "CLAUDE_", + "CLAUDECODE", + "OPENAI_", + "CODEX_", + "GEMINI_", + "OTEL_", +]; + +pub fn blacklist_env_args(parent_env: &[(String, String)]) -> Vec { + let mut args = Vec::new(); + for (key, _) in parent_env { + if blacklist_drops(key) { + args.push("--unsetenv".to_string()); + args.push(key.clone()); + } + } + args +} + +fn blacklist_drops(key: &str) -> bool { + if BLACKLIST_KEEP_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + { + return false; + } + if BLACKLIST_DROP_EXACT.contains(&key) { + return true; + } + BLACKLIST_DROP_SUFFIXES + .iter() + .any(|suffix| key.ends_with(suffix)) +} + +const BLACKLIST_KEEP_PREFIXES: &[&str] = &["ANTHROPIC_", "CLAUDE_", "OPENAI_", "CODEX_", "GEMINI_"]; + +const BLACKLIST_DROP_EXACT: &[&str] = &[ + // dangling sockets after path overlays + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "ICEAUTHORITY", + "SESSION_MANAGER", + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GPG_AGENT_INFO", + "GPG_TTY", + "GNUPGHOME", + // cloud creds (don't fit suffix patterns) + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", + "AZURE_CLIENT_ID", + "AZURE_TENANT_ID", + // backups + "RESTIC_PASSWORD_COMMAND", + "RESTIC_PASSWORD_FILE", + "RESTIC_KEY_HINT", + "BORG_PASSPHRASE", + "BORG_PASSCOMMAND", +]; + +const BLACKLIST_DROP_SUFFIXES: &[&str] = &[ + "_TOKEN", + "_SECRET", + "_PASSWORD", + "_PASSPHRASE", + "_API_KEY", + "_PRIVATE_KEY", + "_CLIENT_SECRET", +]; diff --git a/src/lib.rs b/src/lib.rs index 303d09e..b39e4f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod agents; mod blacklist; pub mod cli; pub mod config; +mod env; mod errors; mod preflight; mod sandbox; @@ -9,7 +10,6 @@ mod seccomp; pub use errors::SandboxError; -use std::env; use std::ffi::OsString; use std::fs; use std::os::unix::process::CommandExt; @@ -25,9 +25,12 @@ pub struct SandboxConfig { pub hardened: bool, pub unshare_net: bool, pub seccomp: bool, + pub env_filter: bool, pub extra_rw: Vec, pub extra_ro: Vec, pub mask: Vec, + pub setenv: Vec<(String, String)>, + pub unsetenv: Vec, pub bwrap_args: Vec, pub command: PathBuf, pub command_args: Vec, @@ -36,14 +39,14 @@ pub struct SandboxConfig { } pub fn require_home() -> Result { - env::var("HOME") + std::env::var("HOME") .ok() .filter(|h| !h.is_empty()) .ok_or(SandboxError::HomeNotSet) } pub fn require_run_user() -> Result { - env::var("XDG_RUNTIME_DIR") + std::env::var("XDG_RUNTIME_DIR") .ok() .or_else(resolve_run_user_from_proc) .ok_or(SandboxError::RunUserNotFound) diff --git a/src/sandbox.rs b/src/sandbox.rs index f957978..5f30e22 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -3,6 +3,7 @@ use std::process::Command; use crate::agents; use crate::blacklist; +use crate::env; use crate::seccomp; use crate::{SandboxConfig, SandboxError, SandboxMode}; @@ -35,6 +36,9 @@ pub fn build_command(config: &SandboxConfig) -> Result { add_ro_bind(&mut cmd, path)?; } + add_env_policy(&mut cmd, config); + add_user_env_overrides(&mut cmd, config); + cmd.args(["--remount-ro", "/"]); cmd.arg("--new-session"); cmd.arg("--die-with-parent"); @@ -55,6 +59,27 @@ pub fn build_command(config: &SandboxConfig) -> Result { Ok(cmd) } +fn add_env_policy(cmd: &mut Command, config: &SandboxConfig) { + if !config.env_filter { + return; + } + let parent_env: Vec<(String, String)> = std::env::vars().collect(); + let args = match config.mode { + SandboxMode::Blacklist => env::blacklist_env_args(&parent_env), + SandboxMode::Whitelist => env::whitelist_env_args(&parent_env), + }; + cmd.args(args); +} + +fn add_user_env_overrides(cmd: &mut Command, config: &SandboxConfig) { + for (key, value) in &config.setenv { + cmd.arg("--setenv").arg(key).arg(value); + } + for key in &config.unsetenv { + cmd.arg("--unsetenv").arg(key); + } +} + fn apply_masks(cmd: &mut Command, masks: &[PathBuf]) { for path in masks { if path.is_file() { diff --git a/tests/integration.rs b/tests/integration.rs index 3c6ff21..8dac1a6 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -949,6 +949,339 @@ fn seccomp_normal_workload_succeeds() { ); } +fn printenv_inside(args: &[&str], vars: &[(&str, &str)], query: &[&str]) -> String { + let script = query + .iter() + .map(|v| format!("printenv {v} || echo MISSING:{v}")) + .collect::>() + .join("; "); + let mut cmd = sandbox(args); + for (k, v) in vars { + cmd.env(k, v); + } + let output = cmd + .args(["--", "bash", "-c", &script]) + .output() + .expect("agent-sandbox binary failed to execute"); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[test] +fn whitelist_keeps_identity_and_terminal_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[("TERM", "xterm-test"), ("LANG", "C.UTF-8")], + &["HOME", "PATH", "TERM", "LANG"], + ); + assert!(!stdout.contains("MISSING:HOME"), "HOME stripped: {stdout}"); + assert!(!stdout.contains("MISSING:PATH"), "PATH stripped: {stdout}"); + assert!(stdout.contains("xterm-test"), "TERM stripped: {stdout}"); + assert!(stdout.contains("C.UTF-8"), "LANG stripped: {stdout}"); +} + +#[test] +fn whitelist_strips_arbitrary_host_var() { + let stdout = printenv_inside( + &["--whitelist"], + &[("SOME_RANDOM_NOISE_VAR", "leak")], + &["SOME_RANDOM_NOISE_VAR"], + ); + assert!( + stdout.contains("MISSING:SOME_RANDOM_NOISE_VAR"), + "expected arbitrary host var to be stripped, got: {stdout}" + ); + assert!(!stdout.contains("leak")); +} + +#[test] +fn whitelist_keeps_vendor_prefixes() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("CLAUDE_FOO", "claude-val"), + ("ANTHROPIC_MODEL", "anthropic-val"), + ("OPENAI_API_KEY", "openai-val"), + ("CODEX_FOO", "codex-val"), + ("GEMINI_API_KEY", "gemini-val"), + ("OTEL_SERVICE_NAME", "otel-val"), + ], + &[ + "CLAUDE_FOO", + "ANTHROPIC_MODEL", + "OPENAI_API_KEY", + "CODEX_FOO", + "GEMINI_API_KEY", + "OTEL_SERVICE_NAME", + ], + ); + for expected in [ + "claude-val", + "anthropic-val", + "openai-val", + "codex-val", + "gemini-val", + "otel-val", + ] { + assert!( + stdout.contains(expected), + "expected {expected} in output, got: {stdout}" + ); + } + assert!(!stdout.contains("MISSING:"), "unexpected strip: {stdout}"); +} + +#[test] +fn whitelist_keeps_lc_prefix() { + let stdout = printenv_inside( + &["--whitelist"], + &[("LC_TIME", "en_US.UTF-8")], + &["LC_TIME"], + ); + assert!(stdout.contains("en_US.UTF-8"), "LC_TIME missing: {stdout}"); +} + +#[test] +fn whitelist_keeps_non_gui_xdg_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("XDG_CONFIG_HOME", "/cfg"), + ("XDG_DATA_HOME", "/data"), + ("XDG_CACHE_HOME", "/cache"), + ("XDG_STATE_HOME", "/state"), + ("XDG_CONFIG_DIRS", "/etc/xdg"), + ("XDG_DATA_DIRS", "/usr/share"), + ], + &[ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_DIRS", + "XDG_DATA_DIRS", + ], + ); + assert!( + !stdout.contains("MISSING:"), + "XDG non-GUI stripped: {stdout}" + ); +} + +#[test] +fn whitelist_strips_gui_xdg_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("XDG_RUNTIME_DIR", "/run/user/1000"), + ("XDG_SESSION_ID", "1"), + ("XDG_CURRENT_DESKTOP", "KDE"), + ("XDG_SEAT", "seat0"), + ], + &[ + "XDG_RUNTIME_DIR", + "XDG_SESSION_ID", + "XDG_CURRENT_DESKTOP", + "XDG_SEAT", + ], + ); + for var in [ + "XDG_RUNTIME_DIR", + "XDG_SESSION_ID", + "XDG_CURRENT_DESKTOP", + "XDG_SEAT", + ] { + assert!( + stdout.contains(&format!("MISSING:{var}")), + "expected {var} stripped, got: {stdout}" + ); + } +} + +#[test] +fn whitelist_strips_dbus_vars() { + let stdout = printenv_inside( + &["--whitelist"], + &[ + ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/foo"), + ("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/bar"), + ], + &["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"], + ); + assert!( + stdout.contains("MISSING:DBUS_SESSION_BUS_ADDRESS"), + "expected DBUS_SESSION stripped: {stdout}" + ); + assert!( + stdout.contains("MISSING:DBUS_SYSTEM_BUS_ADDRESS"), + "expected DBUS_SYSTEM stripped: {stdout}" + ); +} + +#[test] +fn whitelist_setenv_injects_user_var() { + let stdout = printenv_inside( + &["--whitelist", "--setenv", "USER_INJECTED=forced"], + &[], + &["USER_INJECTED"], + ); + assert!(stdout.contains("forced"), "setenv not applied: {stdout}"); +} + +#[test] +fn whitelist_unsetenv_overrides_kept_var() { + let stdout = printenv_inside( + &["--whitelist", "--unsetenv", "TERM"], + &[("TERM", "xterm-test")], + &["TERM"], + ); + assert!( + stdout.contains("MISSING:TERM"), + "expected --unsetenv to strip kept var: {stdout}" + ); +} + +#[test] +fn blacklist_drops_token_and_secret_vars() { + let stdout = printenv_inside( + &[], + &[ + ("GH_TOKEN", "gh-secret"), + ("AWS_SECRET_ACCESS_KEY", "aws-secret"), + ("MY_PASSWORD", "pw"), + ("FOO_API_KEY", "fookey"), + ], + &[ + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "MY_PASSWORD", + "FOO_API_KEY", + ], + ); + for var in [ + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "MY_PASSWORD", + "FOO_API_KEY", + ] { + assert!( + stdout.contains(&format!("MISSING:{var}")), + "expected {var} stripped in blacklist mode, got: {stdout}" + ); + } + for leaked in ["gh-secret", "aws-secret", "pw", "fookey"] { + assert!(!stdout.contains(leaked), "{leaked} leaked: {stdout}"); + } +} + +#[test] +fn blacklist_carves_out_vendor_api_keys() { + let stdout = printenv_inside( + &[], + &[ + ("ANTHROPIC_API_KEY", "anthropic-key"), + ("OPENAI_API_KEY", "openai-key"), + ("GEMINI_API_KEY", "gemini-key"), + ], + &["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"], + ); + for expected in ["anthropic-key", "openai-key", "gemini-key"] { + assert!( + stdout.contains(expected), + "expected {expected} to survive carve-out, got: {stdout}" + ); + } + assert!(!stdout.contains("MISSING:"), "carve-out failed: {stdout}"); +} + +#[test] +fn blacklist_suffix_match_does_not_catch_substring() { + let stdout = printenv_inside( + &[], + &[ + ("TOKENIZER_PATH", "/opt/tok"), + ("MY_TOKEN_HOLDER", "holder"), + ], + &["TOKENIZER_PATH", "MY_TOKEN_HOLDER"], + ); + assert!( + stdout.contains("/opt/tok"), + "TOKENIZER_PATH stripped: {stdout}" + ); + assert!( + stdout.contains("holder"), + "MY_TOKEN_HOLDER stripped: {stdout}" + ); +} + +#[test] +fn blacklist_keeps_unrelated_host_var() { + let stdout = printenv_inside(&[], &[("MY_NICE_VAR", "hello")], &["MY_NICE_VAR"]); + assert!(stdout.contains("hello"), "MY_NICE_VAR stripped: {stdout}"); +} + +#[test] +fn blacklist_keeps_dbus_vars() { + let stdout = printenv_inside( + &[], + &[ + ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/fake"), + ("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/tmp/fake-system"), + ], + &["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"], + ); + assert!(stdout.contains("unix:path=/tmp/fake")); + assert!(stdout.contains("unix:path=/tmp/fake-system")); +} + +#[test] +fn no_env_filter_whitelist_keeps_arbitrary_host_var() { + let stdout = printenv_inside( + &["--whitelist", "--no-env-filter"], + &[("SOME_RANDOM_NOISE_VAR", "kept")], + &["SOME_RANDOM_NOISE_VAR"], + ); + assert!( + stdout.contains("kept"), + "expected --no-env-filter to pass host var through, got: {stdout}" + ); +} + +#[test] +fn no_env_filter_blacklist_keeps_secrets() { + let stdout = printenv_inside(&["--no-env-filter"], &[("GH_TOKEN", "kept")], &["GH_TOKEN"]); + assert!( + stdout.contains("kept"), + "expected --no-env-filter to pass secrets through, got: {stdout}" + ); +} + +#[test] +fn no_env_filter_still_honors_user_setenv() { + let stdout = printenv_inside( + &["--no-env-filter", "--setenv", "FORCED=yes"], + &[], + &["FORCED"], + ); + assert!( + stdout.contains("yes"), + "expected user --setenv to still work with --no-env-filter, got: {stdout}" + ); +} + +#[test] +fn blacklist_setenv_overrides_builtin_deny() { + let stdout = printenv_inside( + &["--setenv", "GH_TOKEN=overridden"], + &[("GH_TOKEN", "original")], + &["GH_TOKEN"], + ); + assert!( + stdout.contains("overridden"), + "expected --setenv to override deny, got: {stdout}" + ); + assert!(!stdout.contains("original")); +} + #[test] fn seccomp_bash_pthread_fallback_works() { // Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's