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:
13
README.md
13
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).
|
**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
|
## 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.
|
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
|
## 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
|
```bash
|
||||||
agent-sandbox --rw /var/run/docker.sock -- claude --dangerously-skip-permissions
|
agent-sandbox --rw /var/run/docker.sock -- claude --dangerously-skip-permissions
|
||||||
agent-sandbox --ro ~/.aws -- 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ rw = [
|
|||||||
"~/.cargo",
|
"~/.cargo",
|
||||||
"~/.rustup",
|
"~/.rustup",
|
||||||
]
|
]
|
||||||
|
setenv = { DATABASE_URL = "postgres://localhost/dev" }
|
||||||
|
unsetenv = ["HTTP_PROXY", "HTTPS_PROXY"]
|
||||||
entrypoint = ["claude", "--dangerously-skip-permissions"]
|
entrypoint = ["claude", "--dangerously-skip-permissions"]
|
||||||
|
|
||||||
[profile.blacklist]
|
[profile.blacklist]
|
||||||
|
|||||||
31
src/cli.rs
31
src/cli.rs
@@ -42,6 +42,14 @@ pub struct Args {
|
|||||||
#[arg(long, overrides_with = "seccomp")]
|
#[arg(long, overrides_with = "seccomp")]
|
||||||
pub no_seccomp: bool,
|
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)
|
/// Bind an extra path read-write (repeatable)
|
||||||
#[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)]
|
#[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)]
|
||||||
pub extra_rw: Vec<PathBuf>,
|
pub extra_rw: Vec<PathBuf>,
|
||||||
@@ -78,6 +86,19 @@ pub struct Args {
|
|||||||
#[arg(long = "mask", value_name = "PATH", action = clap::ArgAction::Append)]
|
#[arg(long = "mask", value_name = "PATH", action = clap::ArgAction::Append)]
|
||||||
pub mask: Vec<PathBuf>,
|
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)
|
/// Pass an arbitrary argument directly to bwrap (repeatable)
|
||||||
#[arg(long = "bwrap-arg", value_name = "ARG", action = clap::ArgAction::Append)]
|
#[arg(long = "bwrap-arg", value_name = "ARG", action = clap::ArgAction::Append)]
|
||||||
pub bwrap_args: Vec<String>,
|
pub bwrap_args: Vec<String>,
|
||||||
@@ -90,3 +111,13 @@ pub struct Args {
|
|||||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||||
pub command_and_args: Vec<OsString>,
|
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()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -44,6 +44,12 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
|
|||||||
globals.seccomp,
|
globals.seccomp,
|
||||||
true,
|
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(
|
dry_run: merge_flag(
|
||||||
merge_flag_pair(args.dry_run, args.no_dry_run),
|
merge_flag_pair(args.dry_run, args.no_dry_run),
|
||||||
profile.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_rw: merge_paths(args.extra_rw, &profile.rw, &globals.rw)?,
|
||||||
extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?,
|
extra_ro: merge_paths(args.extra_ro, &profile.ro, &globals.ro)?,
|
||||||
mask: merge_vecs(args.mask, &profile.mask, &globals.mask),
|
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(
|
bwrap_args: split_bwrap_args(merge_vecs(
|
||||||
args.bwrap_args,
|
args.bwrap_args,
|
||||||
&profile.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()
|
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(
|
fn resolve_command(
|
||||||
cli_entrypoint: Option<String>,
|
cli_entrypoint: Option<String>,
|
||||||
mut passthrough_args: Vec<OsString>,
|
mut passthrough_args: Vec<OsString>,
|
||||||
@@ -260,6 +283,7 @@ pub struct Options {
|
|||||||
pub hardened: Option<bool>,
|
pub hardened: Option<bool>,
|
||||||
pub unshare_net: Option<bool>,
|
pub unshare_net: Option<bool>,
|
||||||
pub seccomp: Option<bool>,
|
pub seccomp: Option<bool>,
|
||||||
|
pub env_filter: Option<bool>,
|
||||||
pub entrypoint: Option<CommandValue>,
|
pub entrypoint: Option<CommandValue>,
|
||||||
pub command: Option<CommandValue>,
|
pub command: Option<CommandValue>,
|
||||||
pub dry_run: Option<bool>,
|
pub dry_run: Option<bool>,
|
||||||
@@ -271,6 +295,10 @@ pub struct Options {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mask: Vec<PathBuf>,
|
pub mask: Vec<PathBuf>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub setenv: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unsetenv: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub bwrap_args: Vec<String>,
|
pub bwrap_args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,6 +936,70 @@ mod tests {
|
|||||||
assert_eq!(config.chdir, std::fs::canonicalize("/tmp").unwrap());
|
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]
|
#[test]
|
||||||
fn build_mask_accumulates() {
|
fn build_mask_accumulates() {
|
||||||
let file_config = FileConfig {
|
let file_config = FileConfig {
|
||||||
|
|||||||
136
src/env.rs
Normal file
136
src/env.rs
Normal 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",
|
||||||
|
];
|
||||||
@@ -2,6 +2,7 @@ mod agents;
|
|||||||
mod blacklist;
|
mod blacklist;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
mod env;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod preflight;
|
mod preflight;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
@@ -9,7 +10,6 @@ mod seccomp;
|
|||||||
|
|
||||||
pub use errors::SandboxError;
|
pub use errors::SandboxError;
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
@@ -25,9 +25,12 @@ pub struct SandboxConfig {
|
|||||||
pub hardened: bool,
|
pub hardened: bool,
|
||||||
pub unshare_net: bool,
|
pub unshare_net: bool,
|
||||||
pub seccomp: bool,
|
pub seccomp: bool,
|
||||||
|
pub env_filter: bool,
|
||||||
pub extra_rw: Vec<PathBuf>,
|
pub extra_rw: Vec<PathBuf>,
|
||||||
pub extra_ro: Vec<PathBuf>,
|
pub extra_ro: Vec<PathBuf>,
|
||||||
pub mask: Vec<PathBuf>,
|
pub mask: Vec<PathBuf>,
|
||||||
|
pub setenv: Vec<(String, String)>,
|
||||||
|
pub unsetenv: Vec<String>,
|
||||||
pub bwrap_args: Vec<String>,
|
pub bwrap_args: Vec<String>,
|
||||||
pub command: PathBuf,
|
pub command: PathBuf,
|
||||||
pub command_args: Vec<OsString>,
|
pub command_args: Vec<OsString>,
|
||||||
@@ -36,14 +39,14 @@ pub struct SandboxConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn require_home() -> Result<String, SandboxError> {
|
pub fn require_home() -> Result<String, SandboxError> {
|
||||||
env::var("HOME")
|
std::env::var("HOME")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|h| !h.is_empty())
|
.filter(|h| !h.is_empty())
|
||||||
.ok_or(SandboxError::HomeNotSet)
|
.ok_or(SandboxError::HomeNotSet)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn require_run_user() -> Result<String, SandboxError> {
|
pub fn require_run_user() -> Result<String, SandboxError> {
|
||||||
env::var("XDG_RUNTIME_DIR")
|
std::env::var("XDG_RUNTIME_DIR")
|
||||||
.ok()
|
.ok()
|
||||||
.or_else(resolve_run_user_from_proc)
|
.or_else(resolve_run_user_from_proc)
|
||||||
.ok_or(SandboxError::RunUserNotFound)
|
.ok_or(SandboxError::RunUserNotFound)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::process::Command;
|
|||||||
|
|
||||||
use crate::agents;
|
use crate::agents;
|
||||||
use crate::blacklist;
|
use crate::blacklist;
|
||||||
|
use crate::env;
|
||||||
use crate::seccomp;
|
use crate::seccomp;
|
||||||
use crate::{SandboxConfig, SandboxError, SandboxMode};
|
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_ro_bind(&mut cmd, path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_env_policy(&mut cmd, config);
|
||||||
|
add_user_env_overrides(&mut cmd, config);
|
||||||
|
|
||||||
cmd.args(["--remount-ro", "/"]);
|
cmd.args(["--remount-ro", "/"]);
|
||||||
cmd.arg("--new-session");
|
cmd.arg("--new-session");
|
||||||
cmd.arg("--die-with-parent");
|
cmd.arg("--die-with-parent");
|
||||||
@@ -55,6 +59,27 @@ pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
|
|||||||
Ok(cmd)
|
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]) {
|
fn apply_masks(cmd: &mut Command, masks: &[PathBuf]) {
|
||||||
for path in masks {
|
for path in masks {
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
|
|||||||
@@ -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::<Vec<_>>()
|
||||||
|
.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]
|
#[test]
|
||||||
fn seccomp_bash_pthread_fallback_works() {
|
fn seccomp_bash_pthread_fallback_works() {
|
||||||
// Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's
|
// Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's
|
||||||
|
|||||||
Reference in New Issue
Block a user