Filter environment variables in both sandbox modes

Whitelist mode now clears the parent env and re-adds a small allowlist
(identity, terminal, locale, proxy, non-GUI XDG, vendor prefixes).
Blacklist mode strips cloud credentials, backup passphrases, dangling
socket pointers, and anything matching *_TOKEN, *_SECRET, *_PASSWORD,
*_PASSPHRASE, *_API_KEY, *_PRIVATE_KEY, *_CLIENT_SECRET; vendor prefix
carve-outs keep ANTHROPIC_API_KEY and friends.

Users can override via --setenv KEY=VALUE and --unsetenv KEY (and the
corresponding TOML keys), or opt out of the built-in policy entirely
with --no-env-filter.
This commit is contained in:
2026-04-08 09:22:11 +02:00
parent 12644ae31e
commit 25f0037aab
8 changed files with 638 additions and 5 deletions
+31
View File
@@ -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<PathBuf>,
@@ -78,6 +86,19 @@ pub struct Args {
#[arg(long = "mask", value_name = "PATH", action = clap::ArgAction::Append)]
pub mask: Vec<PathBuf>,
/// 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<String>,
/// Pass an arbitrary argument directly to bwrap (repeatable)
#[arg(long = "bwrap-arg", value_name = "ARG", action = clap::ArgAction::Append)]
pub bwrap_args: Vec<String>,
@@ -90,3 +111,13 @@ pub struct Args {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command_and_args: Vec<OsString>,
}
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()))
}
+93 -1
View File
@@ -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<FileConfig>) -> Result<SandboxConfi
globals.seccomp,
true,
),
env_filter: merge_flag_with_default(
merge_flag_pair(args.env_filter, args.no_env_filter),
profile.env_filter,
globals.env_filter,
true,
),
dry_run: merge_flag(
merge_flag_pair(args.dry_run, args.no_dry_run),
profile.dry_run,
@@ -53,6 +59,8 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
extra_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?,
extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?,
mask: merge_vecs(args.mask, &profile.mask, &globals.mask),
setenv: merge_setenv(args.setenv, &profile.setenv, &globals.setenv),
unsetenv: merge_vecs(args.unsetenv, &profile.unsetenv, &globals.unsetenv),
bwrap_args: split_bwrap_args(merge_vecs(
args.bwrap_args,
&profile.bwrap_args,
@@ -158,6 +166,21 @@ fn merge_vecs<T: Clone>(cli: Vec<T>, profile: &[T], globals: &[T]) -> Vec<T> {
globals.iter().chain(profile).cloned().chain(cli).collect()
}
fn merge_setenv(
cli: Vec<(String, String)>,
profile: &BTreeMap<String, String>,
globals: &BTreeMap<String, String>,
) -> Vec<(String, String)> {
let mut merged: BTreeMap<String, String> = 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<String>,
mut passthrough_args: Vec<OsString>,
@@ -260,6 +283,7 @@ pub struct Options {
pub hardened: Option<bool>,
pub unshare_net: Option<bool>,
pub seccomp: Option<bool>,
pub env_filter: Option<bool>,
pub entrypoint: Option<CommandValue>,
pub command: Option<CommandValue>,
pub dry_run: Option<bool>,
@@ -271,6 +295,10 @@ pub struct Options {
#[serde(default)]
pub mask: Vec<PathBuf>,
#[serde(default)]
pub setenv: BTreeMap<String, String>,
#[serde(default)]
pub unsetenv: Vec<String>,
#[serde(default)]
pub bwrap_args: Vec<String>,
}
@@ -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 {
+136
View File
@@ -0,0 +1,136 @@
pub fn whitelist_env_args(parent_env: &[(String, String)]) -> Vec<String> {
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<String> {
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",
];
+6 -3
View File
@@ -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<PathBuf>,
pub extra_ro: Vec<PathBuf>,
pub mask: Vec<PathBuf>,
pub setenv: Vec<(String, String)>,
pub unsetenv: Vec<String>,
pub bwrap_args: Vec<String>,
pub command: PathBuf,
pub command_args: Vec<OsString>,
@@ -36,14 +39,14 @@ pub struct SandboxConfig {
}
pub fn require_home() -> Result<String, SandboxError> {
env::var("HOME")
std::env::var("HOME")
.ok()
.filter(|h| !h.is_empty())
.ok_or(SandboxError::HomeNotSet)
}
pub fn require_run_user() -> Result<String, SandboxError> {
env::var("XDG_RUNTIME_DIR")
std::env::var("XDG_RUNTIME_DIR")
.ok()
.or_else(resolve_run_user_from_proc)
.ok_or(SandboxError::RunUserNotFound)
+25
View File
@@ -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<Command, SandboxError> {
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<Command, SandboxError> {
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() {