Add --keep-fd to pass chosen inherited descriptors into the sandbox

This commit is contained in:
2026-11-04 18:18:24 +01:00
parent f9ac595d63
commit c7d88d87d9
10 changed files with 141 additions and 25 deletions
+4 -1
View File
@@ -47,7 +47,7 @@ Settings can be stored in a TOML config file at `$XDG_CONFIG_HOME/agent-sandbox/
ln -sf "$(pwd)/config-example.toml" "${XDG_CONFIG_HOME:-$HOME/.config}/agent-sandbox/config.toml" ln -sf "$(pwd)/config-example.toml" "${XDG_CONFIG_HOME:-$HOME/.config}/agent-sandbox/config.toml"
``` ```
Top-level keys set defaults; `[profile.<name>]` sections define named presets selectable with `--profile <name>`. CLI flags always take highest precedence, followed by the active profiles, then top-level defaults. When a profile is active, list-valued fields (`ro`, `rw`, `mask`, `env`, `unsetenv`, `bwrap-args`) append to the globals; scalar fields (modes, flags, `entrypoint`, `command`, `chdir`) replace them. Top-level keys set defaults; `[profile.<name>]` sections define named presets selectable with `--profile <name>`. CLI flags always take highest precedence, followed by the active profiles, then top-level defaults. When a profile is active, list-valued fields (`ro`, `rw`, `mask`, `env`, `unsetenv`, `bwrap-args`, `keep-fd`) append to the globals; scalar fields (modes, flags, `entrypoint`, `command`, `chdir`) replace them.
Several profiles combine: `--profile claude,gpu` (or `--profile claude --profile gpu`, or `profile = ["claude", "gpu"]` in the config) merges them left to right under the same rules, so a later profile's scalars win. A CLI `--profile` replaces the config's list rather than extending it. A profile's own `profile = ...` names its parents, which merge before it; a profile reached twice merges once. Several profiles combine: `--profile claude,gpu` (or `--profile claude --profile gpu`, or `profile = ["claude", "gpu"]` in the config) merges them left to right under the same rules, so a later profile's scalars win. A CLI `--profile` replaces the config's list rather than extending it. A profile's own `profile = ...` names its parents, which merge before it; a profile reached twice merges once.
@@ -84,11 +84,14 @@ A user `--rw`/`--ro` targeting `/run/agent-sandbox` overrides the directory, lik
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. 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.
Inherited file descriptors above stderr never reach `bwrap` or the sandbox, because an open directory descriptor would bypass every mount restriction. Pass one through with `--keep-fd FD` (or `keep-fd = [FD]`), for example to feed a `bwrap` option that reads a descriptor.
```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 --setenv DATABASE_URL=postgres://localhost/dev -- claude
agent-sandbox --unsetenv HTTP_PROXY -- claude agent-sandbox --unsetenv HTTP_PROXY -- claude
agent-sandbox --keep-fd 3 --bwrap-arg '--file 3 /tmp/input' -- claude 3<input.txt
``` ```
## Ubuntu 23.10+: AppArmor unprivileged userns restrictions ## Ubuntu 23.10+: AppArmor unprivileged userns restrictions
+1
View File
@@ -50,6 +50,7 @@ env = [
# entrypoint = ["claude", "--dangerously-skip-permissions"] # binary + baked-in args # entrypoint = ["claude", "--dangerously-skip-permissions"] # binary + baked-in args
# command = ["--model", "opus"] # default trailing args # command = ["--model", "opus"] # default trailing args
# bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch # bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch
# keep-fd = [3] # pass inherited fds through
profile = ["claude", "audio"] profile = ["claude", "audio"]
+6
View File
@@ -1,4 +1,5 @@
use std::ffi::OsString; use std::ffi::OsString;
use std::os::fd::RawFd;
use std::path::PathBuf; use std::path::PathBuf;
use clap::Parser; use clap::Parser;
@@ -117,6 +118,11 @@ pub struct Args {
#[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>,
/// Pass an inherited file descriptor into the sandbox (repeatable).
/// All other inherited descriptors above stderr are closed.
#[arg(long = "keep-fd", value_name = "FD", action = clap::ArgAction::Append)]
pub keep_fd: Vec<RawFd>,
/// Run this binary with the trailing args as its arguments /// Run this binary with the trailing args as its arguments
#[arg(long, value_name = "CMD")] #[arg(long, value_name = "CMD")]
pub entrypoint: Option<String>, pub entrypoint: Option<String>,
+5
View File
@@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::ffi::OsString; use std::ffi::OsString;
use std::os::fd::RawFd;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::Deserialize; use serde::Deserialize;
@@ -93,6 +94,7 @@ pub fn build(args: Args, file_config: Option<FileConfig>) -> Result<SandboxConfi
&profile.options.bwrap_args, &profile.options.bwrap_args,
&globals.bwrap_args, &globals.bwrap_args,
))?, ))?,
keep_fds: merge_vecs(args.keep_fd, &profile.options.keep_fd, &globals.keep_fd),
command, command,
command_args, command_args,
persistent_tmp_key, persistent_tmp_key,
@@ -526,6 +528,8 @@ pub struct Options {
pub unsetenv: Vec<String>, pub unsetenv: Vec<String>,
#[serde(default)] #[serde(default)]
pub bwrap_args: Vec<String>, pub bwrap_args: Vec<String>,
#[serde(default)]
pub keep_fd: Vec<RawFd>,
} }
impl Options { impl Options {
@@ -551,6 +555,7 @@ impl Options {
env: append(self.env, extra.env), env: append(self.env, extra.env),
unsetenv: append(self.unsetenv, extra.unsetenv), unsetenv: append(self.unsetenv, extra.unsetenv),
bwrap_args: append(self.bwrap_args, extra.bwrap_args), bwrap_args: append(self.bwrap_args, extra.bwrap_args),
keep_fd: append(self.keep_fd, extra.keep_fd),
} }
} }
+9
View File
@@ -1,3 +1,4 @@
use std::os::fd::RawFd;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug)] #[derive(Debug)]
@@ -36,6 +37,7 @@ pub enum SandboxError {
PersistentTmpDir(PathBuf, String), PersistentTmpDir(PathBuf, String),
ProcChildrenMissing, ProcChildrenMissing,
InheritedFdsCloexec(std::io::Error), InheritedFdsCloexec(std::io::Error),
KeepFd(RawFd, std::io::Error),
} }
impl std::fmt::Display for SandboxError { impl std::fmt::Display for SandboxError {
@@ -117,6 +119,12 @@ impl std::fmt::Display for SandboxError {
f, f,
"the kernel does not provide /proc/<pid>/task/<tid>/children (CONFIG_PROC_CHILDREN); agent-sandbox needs it to find the processes it must clean up" "the kernel does not provide /proc/<pid>/task/<tid>/children (CONFIG_PROC_CHILDREN); agent-sandbox needs it to find the processes it must clean up"
), ),
Self::KeepFd(fd, e) => {
write!(
f,
"cannot pass file descriptor {fd} into the sandbox (--keep-fd): {e}"
)
}
Self::InheritedFdsCloexec(e) => write!( Self::InheritedFdsCloexec(e) => write!(
f, f,
"cannot keep inherited file descriptors out of the sandbox (close_range with CLOSE_RANGE_CLOEXEC needs Linux 5.11+): {e}" "cannot keep inherited file descriptors out of the sandbox (close_range with CLOSE_RANGE_CLOEXEC needs Linux 5.11+): {e}"
@@ -134,6 +142,7 @@ impl std::error::Error for SandboxError {
Self::ConfigRead { source, .. } => Some(source), Self::ConfigRead { source, .. } => Some(source),
Self::ConfigParse { source, .. } => Some(source), Self::ConfigParse { source, .. } => Some(source),
Self::InheritedFdsCloexec(e) => Some(e), Self::InheritedFdsCloexec(e) => Some(e),
Self::KeepFd(_, e) => Some(e),
_ => None, _ => None,
} }
} }
+23 -2
View File
@@ -1,10 +1,20 @@
use std::io; use std::io;
use std::os::fd::RawFd;
use crate::SandboxError; use crate::SandboxError;
const FIRST_FD_AFTER_STDIO: libc::c_uint = 3; const FIRST_FD_AFTER_STDIO: libc::c_uint = 3;
pub fn keep_out_of_sandbox() -> Result<(), SandboxError> { pub fn keep_out_of_sandbox(kept_fds: &[RawFd]) -> Result<(), SandboxError> {
mark_all_after_stdio_cloexec().map_err(SandboxError::InheritedFdsCloexec)?;
for &fd in kept_fds {
clear_cloexec(fd).map_err(|e| SandboxError::KeepFd(fd, e))?;
}
Ok(())
}
fn mark_all_after_stdio_cloexec() -> io::Result<()> {
let result = unsafe { let result = unsafe {
libc::syscall( libc::syscall(
libc::SYS_close_range, libc::SYS_close_range,
@@ -14,7 +24,18 @@ pub fn keep_out_of_sandbox() -> Result<(), SandboxError> {
) )
}; };
if result < 0 { if result < 0 {
return Err(SandboxError::InheritedFdsCloexec(io::Error::last_os_error())); return Err(io::Error::last_os_error());
}
Ok(())
}
pub fn clear_cloexec(fd: RawFd) -> io::Result<()> {
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags < 0 {
return Err(io::Error::last_os_error());
}
if unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 {
return Err(io::Error::last_os_error());
} }
Ok(()) Ok(())
} }
+3 -1
View File
@@ -19,6 +19,7 @@ pub use signals::exit_with_status;
use std::ffi::OsString; use std::ffi::OsString;
use std::fs; use std::fs;
use std::os::fd::RawFd;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitStatus; use std::process::ExitStatus;
use std::time::Duration; use std::time::Duration;
@@ -67,6 +68,7 @@ pub struct SandboxConfig {
pub env: Vec<EnvEntry>, pub env: Vec<EnvEntry>,
pub unsetenv: Vec<String>, pub unsetenv: Vec<String>,
pub bwrap_args: Vec<String>, pub bwrap_args: Vec<String>,
pub keep_fds: Vec<RawFd>,
pub command: PathBuf, pub command: PathBuf,
pub command_args: Vec<OsString>, pub command_args: Vec<OsString>,
pub chdir: PathBuf, pub chdir: PathBuf,
@@ -100,7 +102,7 @@ fn resolve_run_user_from_proc() -> Option<String> {
} }
pub fn run(config: SandboxConfig) -> Result<ExitStatus, SandboxError> { pub fn run(config: SandboxConfig) -> Result<ExitStatus, SandboxError> {
inherited_fds::keep_out_of_sandbox()?; inherited_fds::keep_out_of_sandbox(&config.keep_fds)?;
preflight::check(&config)?; preflight::check(&config)?;
let forwarded_signals = ForwardedSignals::detect(); let forwarded_signals = ForwardedSignals::detect();
+3 -13
View File
@@ -7,6 +7,7 @@ use std::process::Command;
use crate::SandboxConfig; use crate::SandboxConfig;
use crate::errors::SandboxError; use crate::errors::SandboxError;
use crate::inherited_fds;
pub struct PersistentTmpDirs { pub struct PersistentTmpDirs {
tmp: BindSource, tmp: BindSource,
@@ -80,7 +81,8 @@ impl PersistentTmpDir {
.map_err(|e| self.error(format!("open: {e}")))?; .map_err(|e| self.error(format!("open: {e}")))?;
self.verify_owner(&file)?; self.verify_owner(&file)?;
let fd: OwnedFd = file.into(); let fd: OwnedFd = file.into();
clear_cloexec(&fd).map_err(|e| self.error(format!("clear FD_CLOEXEC: {e}")))?; inherited_fds::clear_cloexec(fd.as_raw_fd())
.map_err(|e| self.error(format!("clear FD_CLOEXEC: {e}")))?;
Ok(fd) Ok(fd)
} }
@@ -108,18 +110,6 @@ impl PersistentTmpDir {
} }
} }
fn clear_cloexec(fd: &OwnedFd) -> io::Result<()> {
let raw = fd.as_raw_fd();
let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) };
if flags < 0 {
return Err(io::Error::last_os_error());
}
if unsafe { libc::fcntl(raw, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+47 -8
View File
@@ -500,11 +500,9 @@ fn persistent_tmp_leaks_no_descriptor_into_host_tmp() {
#[test] #[test]
fn inherited_host_descriptor_does_not_reach_sandbox() { fn inherited_host_descriptor_does_not_reach_sandbox() {
const PASSED_FD: i32 = 9;
let host_dir = TempDir::new().unwrap(); let host_dir = TempDir::new().unwrap();
fs::write(host_dir.path().join("canary"), "host-secret").unwrap(); fs::write(host_dir.path().join("canary"), "host-secret").unwrap();
let host_dir_handle = fs::File::open(host_dir.path()).unwrap(); let host_dir_handle = fs::File::open(host_dir.path()).unwrap();
let host_dir_fd = host_dir_handle.as_raw_fd();
let mut sandbox = Sandbox::new(&[]); let mut sandbox = Sandbox::new(&[]);
sandbox.args([ sandbox.args([
@@ -513,12 +511,7 @@ fn inherited_host_descriptor_does_not_reach_sandbox() {
"-c", "-c",
&format!("cat /proc/self/fd/{PASSED_FD}/canary 2>/dev/null; true"), &format!("cat /proc/self/fd/{PASSED_FD}/canary 2>/dev/null; true"),
]); ]);
unsafe { pass_as_fd(&mut sandbox, &host_dir_handle, PASSED_FD);
sandbox.pre_exec(move || match libc::dup2(host_dir_fd, PASSED_FD) {
-1 => Err(std::io::Error::last_os_error()),
_ => Ok(()),
});
}
let output = sandbox let output = sandbox
.output() .output()
.expect("agent-sandbox binary failed to execute"); .expect("agent-sandbox binary failed to execute");
@@ -534,6 +527,52 @@ fn inherited_host_descriptor_does_not_reach_sandbox() {
); );
} }
#[test]
fn kept_descriptor_reaches_sandbox() {
let scratch = TempDir::new().unwrap();
let kept_file = scratch.path().join("kept");
fs::write(&kept_file, "kept-content").unwrap();
let kept_handle = fs::File::open(&kept_file).unwrap();
let mut sandbox = Sandbox::new(&["--keep-fd", &PASSED_FD.to_string()]);
sandbox.args(["--", "bash", "-c", &format!("cat <&{PASSED_FD}")]);
pass_as_fd(&mut sandbox, &kept_handle, PASSED_FD);
let output = sandbox
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout), "kept-content");
}
#[test]
fn keep_fd_rejects_descriptor_that_is_not_open() {
let output = Sandbox::new(&["--keep-fd", "999", "--dry-run"])
.args(["--", "true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("999"), "stderr: {stderr}");
}
const PASSED_FD: i32 = 9;
fn pass_as_fd(sandbox: &mut Sandbox, file: &fs::File, target_fd: i32) {
let source_fd = file.as_raw_fd();
unsafe {
sandbox.pre_exec(move || match libc::dup2(source_fd, target_fd) {
-1 => Err(std::io::Error::last_os_error()),
_ => Ok(()),
});
}
}
#[test] #[test]
fn persistent_tmp_is_noop_in_blacklist_mode() { fn persistent_tmp_is_noop_in_blacklist_mode() {
let label = format!("e2e-bl-noop-{}-{}", std::process::id(), rand_suffix()); let label = format!("e2e-bl-noop-{}-{}", std::process::id(), rand_suffix());
+40
View File
@@ -1255,6 +1255,43 @@ fn build_mask_accumulates() {
assert_eq!(config.mask.len(), 3); assert_eq!(config.mask.len(), 3);
} }
#[test]
fn build_keep_fd_accumulates_globals_then_profile_then_cli() {
let file_config = FileConfig {
options: Options {
keep_fd: vec![3],
..Options::default()
},
profiles: HashMap::from([(
"extra".into(),
Options {
keep_fd: vec![4],
..Options::default()
},
)]),
..FileConfig::default()
};
let args = Args {
profile: vec!["extra".into()],
keep_fd: vec![5],
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert_eq!(config.keep_fds, vec![3, 4, 5]);
}
#[test]
fn keep_fd_parsed_from_config() {
let config = FileConfig::parse("keep-fd = [3, 4]").unwrap();
assert_eq!(config.options.keep_fd, vec![3, 4]);
}
#[test]
fn keep_fd_parsed_from_repeated_cli_flag() {
let args = Args::try_parse_from(["agent-sandbox", "--keep-fd", "3", "--keep-fd", "4"]).unwrap();
assert_eq!(args.keep_fd, vec![3, 4]);
}
#[test] #[test]
fn unknown_option_rejected() { fn unknown_option_rejected() {
let toml = r#" let toml = r#"
@@ -1460,16 +1497,19 @@ fn merge_options_lists_append_base_then_extra() {
let base = Options { let base = Options {
rw: vec!["/a".into()], rw: vec!["/a".into()],
env: vec!["A=1".into()], env: vec!["A=1".into()],
keep_fd: vec![3],
..Options::default() ..Options::default()
}; };
let extra = Options { let extra = Options {
rw: vec!["/b".into()], rw: vec!["/b".into()],
env: vec!["B=2".into()], env: vec!["B=2".into()],
keep_fd: vec![4],
..Options::default() ..Options::default()
}; };
let merged = base.merge_with(extra); let merged = base.merge_with(extra);
assert_eq!(merged.rw, vec!["/a", "/b"]); assert_eq!(merged.rw, vec!["/a", "/b"]);
assert_eq!(merged.env, vec!["A=1", "B=2"]); assert_eq!(merged.env, vec!["A=1", "B=2"]);
assert_eq!(merged.keep_fd, vec![3, 4]);
} }
#[test] #[test]