Initial commit

This commit is contained in:
2026-03-20 18:40:08 +01:00
commit bf53d92d49
10 changed files with 1502 additions and 0 deletions

21
src/agents.rs Normal file
View File

@@ -0,0 +1,21 @@
use std::env;
use std::path::PathBuf;
pub fn agent_rw_paths() -> Vec<PathBuf> {
let home = match env::var("HOME") {
Ok(h) => PathBuf::from(h),
Err(_) => return vec![],
};
let candidates = [
env::var("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".claude")),
env::var("CODEX_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".codex")),
home.join(".pi"),
];
candidates.into_iter().filter(|p| p.is_dir()).collect()
}

267
src/blacklist.rs Normal file
View File

@@ -0,0 +1,267 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::SandboxError;
pub struct PathContext {
pub home: String,
pub run_user: String,
}
pub struct BlacklistOverlays {
pub tmpfs_dirs: Vec<PathBuf>,
pub null_bind_files: Vec<PathBuf>,
}
pub fn resolve_overlays(ctx: &PathContext) -> Result<BlacklistOverlays, SandboxError> {
let mut tmpfs_dirs = Vec::new();
let mut null_bind_files = Vec::new();
for raw in SENSITIVE_PATHS {
let expanded = expand_path(raw, ctx);
for path in expand_glob(&expanded)? {
match classify_path(&path) {
PathKind::Dir => tmpfs_dirs.push(path),
PathKind::File => {
if !is_under_tmpfs_dir(&path, &tmpfs_dirs) {
null_bind_files.push(path);
}
}
PathKind::Missing => {}
}
}
}
Ok(BlacklistOverlays {
tmpfs_dirs,
null_bind_files,
})
}
pub fn resolve_path_context() -> Result<PathContext, SandboxError> {
let home = std::env::var("HOME").map_err(|_| SandboxError::HomeNotSet)?;
let run_user = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| resolve_run_user_from_proc().unwrap_or_else(|| "/run/user/0".into()));
Ok(PathContext { home, run_user })
}
enum PathKind {
Dir,
File,
Missing,
}
fn classify_path(path: &Path) -> PathKind {
match fs::symlink_metadata(path) {
Ok(m) if m.is_dir() => PathKind::Dir,
Ok(_) => PathKind::File,
Err(_) => PathKind::Missing,
}
}
fn expand_path(raw: &str, ctx: &PathContext) -> String {
let s = raw
.replace("${HOME}", &ctx.home)
.replace("${RUNUSER}", &ctx.run_user);
let s = if let Some(rest) = s.strip_prefix('~') {
format!("{}{rest}", ctx.home)
} else {
s
};
assert!(
!s.contains("${"),
"unexpanded variable in SENSITIVE_PATHS entry: {raw}"
);
s
}
fn expand_glob(pattern: &str) -> Result<Vec<PathBuf>, SandboxError> {
let entries = glob::glob(pattern)?;
Ok(entries.filter_map(|r| r.ok()).collect())
}
fn is_under_tmpfs_dir(path: &Path, tmpfs_dirs: &[PathBuf]) -> bool {
tmpfs_dirs.iter().any(|dir| path.starts_with(dir))
}
fn resolve_run_user_from_proc() -> Option<String> {
let status = fs::read_to_string("/proc/self/status").ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
let uid = rest.split_whitespace().next()?;
return Some(format!("/run/user/{uid}"));
}
}
None
}
// ---------------------------------------------------------------------------
// Curated sensitive paths from firejail disable-common.inc + disable-programs.inc.
// Goal: protect secrets, credentials, and session tokens from agentic access.
// ---------------------------------------------------------------------------
const SENSITIVE_PATHS: &[&str] = &[
// -- history files (can leak passwords/tokens typed on command line) --
"${HOME}/.*_history",
"${HOME}/.*_history_*",
"${HOME}/.histfile",
"${HOME}/.history",
"${HOME}/.python-history",
"${HOME}/.pythonhist",
"${HOME}/.viminfo",
"${HOME}/.lesshst",
// -- clipboard managers (may contain copied passwords) --
"${HOME}/.cache/greenclip*",
"${HOME}/.kde/share/apps/klipper",
"${HOME}/.kde4/share/apps/klipper",
"${HOME}/.local/share/klipper",
"/tmp/clipmenu*",
// -- SSH and remote access --
"${HOME}/.ssh",
"${HOME}/.rhosts",
"${HOME}/.shosts",
"/etc/hosts.equiv",
"/etc/ssh",
"/etc/ssh/*",
// -- GPG --
"${HOME}/.gnupg",
// -- git credentials --
"${HOME}/.git-credentials",
"${HOME}/.git-credential-cache",
"${HOME}/.config/hub",
"${HOME}/.config/gh",
// -- general credentials and secrets --
"${HOME}/.netrc",
"${HOME}/.cargo/credentials",
"${HOME}/.cargo/credentials.toml",
"${HOME}/.fetchmailrc",
"${HOME}/.msmtprc",
"${HOME}/.smbcredentials",
"${HOME}/.davfs2/secrets",
"${HOME}/.config/msmtp",
"${HOME}/.config/keybase",
"${HOME}/.minisign",
"${HOME}/.caff",
"${HOME}/.password-store",
// -- cloud provider credentials --
"${HOME}/.aws",
"${HOME}/.boto",
"${HOME}/.config/gcloud",
"${HOME}/.kube",
"${HOME}/.passwd-s3fs",
"${HOME}/.s3cmd",
"/etc/boto.cfg",
// -- keyrings and wallets --
"${HOME}/.gnome2/keyrings",
"${HOME}/.local/share/keyrings",
"${HOME}/.local/share/kwalletd",
"${HOME}/.kde/share/apps/kwallet",
"${HOME}/.kde4/share/apps/kwallet",
// -- certificates and PKI --
"${HOME}/.pki",
"${HOME}/.cert",
"${HOME}/.local/share/pki",
"${HOME}/.local/share/plasma-vault",
"${HOME}/.vaults",
// -- KeePass databases --
"${HOME}/*.kdb",
"${HOME}/*.kdbx",
// -- encryption --
"${HOME}/.ecryptfs",
"${HOME}/.fscrypt",
"${HOME}/.Private",
"${HOME}/Private",
"/.fscrypt",
"/home/.ecryptfs",
"/home/.fscrypt",
"/crypto_keyfile.bin",
// -- system auth files --
"/etc/shadow",
"/etc/shadow+",
"/etc/shadow-",
"/etc/gshadow",
"/etc/gshadow+",
"/etc/gshadow-",
"/etc/passwd+",
"/etc/passwd-",
"/etc/group+",
"/etc/group-",
"/etc/sudo*.conf",
"/etc/sudoers*",
"/etc/doas.conf",
"/etc/davfs2/secrets",
"/etc/msmtprc",
// -- session directory and sockets (lateral movement vectors) --
"/tmp/ssh-*",
"/tmp/tmux-*",
"${RUNUSER}",
"/var/run/docker.sock",
// -- mail (sensitive content) --
"${HOME}/.Mail",
"${HOME}/.mail",
"${HOME}/Mail",
"${HOME}/mail",
"${HOME}/postponed",
"${HOME}/sent",
"${HOME}/.mutt",
"${HOME}/.muttrc",
// -- password managers --
"${HOME}/.keepass",
"${HOME}/.keepassx",
"${HOME}/.keepassxc",
"${HOME}/.config/KeePass",
"${HOME}/.config/KeePassXCrc",
"${HOME}/.config/keepassxc",
"${HOME}/.cache/keepassxc",
"${HOME}/.local/share/KeePass",
"${HOME}/.config/1Password",
"${HOME}/.config/Bitwarden",
"${HOME}/.config/Enpass",
"${HOME}/.cache/Enpass",
"${HOME}/.local/share/Enpass",
"${HOME}/.lastpass",
"${HOME}/.config/Authenticator",
"${HOME}/.cache/Authenticator",
// -- browser profiles (saved passwords, cookies, session tokens) --
"${HOME}/.mozilla",
"${HOME}/.cache/mozilla",
"${HOME}/.config/mozilla",
"${HOME}/.config/google-chrome",
"${HOME}/.cache/google-chrome",
"${HOME}/.config/chromium",
"${HOME}/.cache/chromium",
"${HOME}/.config/BraveSoftware",
"${HOME}/.cache/BraveSoftware",
"${HOME}/.config/microsoft-edge",
"${HOME}/.cache/microsoft-edge",
"${HOME}/.config/vivaldi",
"${HOME}/.cache/vivaldi",
"${HOME}/.config/opera",
"${HOME}/.cache/opera",
"${HOME}/.librewolf",
"${HOME}/.cache/librewolf",
"${HOME}/.config/qutebrowser",
"${HOME}/.cache/qutebrowser",
"${HOME}/.local/opt/tor-browser",
"${HOME}/.tor-browser*",
"${HOME}/.cache/torbrowser",
"${HOME}/.config/torbrowser",
// -- cryptocurrency wallets --
"${HOME}/.*coin",
"${HOME}/.bitcoin",
"${HOME}/.electrum*",
"${HOME}/.ethereum",
"${HOME}/Monero/wallets",
"${HOME}/wallet.dat",
// -- nyx (tor controller) --
"${HOME}/.nyx",
// -- D-Bus sockets (can execute commands via systemd) --
"/run/dbus",
"/var/run/dbus",
// -- X11 / Wayland sockets (keystroke injection, screen capture) --
"/tmp/.X11-unix",
"/tmp/.ICE-unix",
"/tmp/.XIM-unix",
"${RUNUSER}/wayland-*",
"${RUNUSER}/X11-display",
];

63
src/errors.rs Normal file
View File

@@ -0,0 +1,63 @@
use std::path::PathBuf;
#[derive(Debug)]
pub enum SandboxError {
HomeNotSet,
BwrapNotFound,
CommandNotFound(PathBuf),
CommandNotExecutable(PathBuf),
RwPathMissing(PathBuf),
RoPathMissing(PathBuf),
ChdirMissing(PathBuf),
CurrentDirUnavailable(std::io::Error),
GlobPattern(glob::PatternError),
Io(std::io::Error),
}
impl std::fmt::Display for SandboxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::HomeNotSet => write!(
f,
"$HOME is not set; cannot determine which paths to protect"
),
Self::BwrapNotFound => write!(
f,
"bwrap not found; install bubblewrap (e.g. `apt install bubblewrap` or `pacman -S bubblewrap`)"
),
Self::CommandNotFound(p) => write!(f, "command not found: {}", p.display()),
Self::CommandNotExecutable(p) => {
write!(f, "command is not executable: {}", p.display())
}
Self::RwPathMissing(p) => write!(f, "--rw path does not exist: {}", p.display()),
Self::RoPathMissing(p) => write!(f, "--ro path does not exist: {}", p.display()),
Self::ChdirMissing(p) => write!(f, "--chdir path does not exist: {}", p.display()),
Self::CurrentDirUnavailable(e) => write!(f, "cannot determine current directory: {e}"),
Self::GlobPattern(e) => write!(f, "invalid glob pattern: {e}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
}
}
}
impl std::error::Error for SandboxError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CurrentDirUnavailable(e) => Some(e),
Self::GlobPattern(e) => Some(e),
Self::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for SandboxError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<glob::PatternError> for SandboxError {
fn from(e: glob::PatternError) -> Self {
Self::GlobPattern(e)
}
}

41
src/lib.rs Normal file
View File

@@ -0,0 +1,41 @@
mod agents;
mod blacklist;
mod errors;
mod preflight;
mod sandbox;
pub use errors::SandboxError;
use std::ffi::OsString;
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
pub enum SandboxMode {
Blacklist,
Whitelist,
}
pub struct SandboxConfig {
pub mode: SandboxMode,
pub hardened: bool,
pub no_net: bool,
pub extra_rw: Vec<PathBuf>,
pub extra_ro: Vec<PathBuf>,
pub command: PathBuf,
pub command_args: Vec<OsString>,
pub chdir: PathBuf,
pub dry_run: bool,
}
pub fn run(config: SandboxConfig) -> Result<(), SandboxError> {
preflight::check(&config)?;
let mut cmd = sandbox::build_command(&config)?;
if config.dry_run {
println!("{:?}", cmd);
return Ok(());
}
Err(SandboxError::Io(cmd.exec()))
}

131
src/main.rs Normal file
View File

@@ -0,0 +1,131 @@
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::process;
use clap::Parser;
use agent_sandbox::{SandboxConfig, SandboxMode};
#[derive(Parser, Debug)]
#[command(
name = "agent-sandbox",
version,
about = "Sandbox agentic coding assistants with bubblewrap"
)]
struct Args {
/// Blacklist mode: bind / read-only, overlay sensitive paths (default)
#[arg(long, conflicts_with = "whitelist")]
blacklist: bool,
/// Whitelist mode: only explicitly listed minimal paths visible
#[arg(long)]
whitelist: bool,
/// Harden: unshare IPC, PID, UTS; private /tmp, /dev, /run
#[arg(long)]
hardened: bool,
/// Unshare the network namespace
#[arg(long)]
no_net: bool,
/// Bind an extra path read-write (repeatable)
#[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)]
extra_rw: Vec<PathBuf>,
/// Bind an extra path read-only (repeatable)
#[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)]
extra_ro: Vec<PathBuf>,
/// Print the bwrap command without executing
#[arg(long)]
dry_run: bool,
/// Working directory inside the sandbox (default: current directory)
#[arg(long, value_name = "PATH")]
chdir: Option<PathBuf>,
/// Command and arguments to run inside the sandbox
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
command_and_args: Vec<OsString>,
}
fn main() {
let args = Args::parse();
let (command, command_args) = resolve_command(args.command_and_args);
let command = assert_binary_exists(&command);
let chdir = assert_chdir(args.chdir);
let mode = if args.whitelist {
SandboxMode::Whitelist
} else {
SandboxMode::Blacklist
};
let config = SandboxConfig {
mode,
hardened: args.hardened,
no_net: args.no_net,
extra_rw: args.extra_rw,
extra_ro: args.extra_ro,
command,
command_args,
chdir,
dry_run: args.dry_run,
};
if let Err(e) = agent_sandbox::run(config) {
eprintln!("error: {e}");
process::exit(1);
}
}
fn resolve_command(mut positional: Vec<OsString>) -> (OsString, Vec<OsString>) {
if !positional.is_empty() {
let cmd = positional.remove(0);
return (cmd, positional);
}
if let Ok(cmd) = std::env::var("SANDBOX_CMD") {
return (OsString::from(cmd), vec![]);
}
(
OsString::from("claude"),
vec![OsString::from("--dangerously-skip-permissions")],
)
}
fn assert_binary_exists(name: &OsStr) -> PathBuf {
resolve_binary(name).unwrap_or_else(|| {
eprintln!("error: command not found: {}", name.to_string_lossy());
process::exit(1);
})
}
fn assert_chdir(explicit: Option<PathBuf>) -> PathBuf {
if let Some(p) = explicit {
return p;
}
match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
eprintln!(
"error: {}",
agent_sandbox::SandboxError::CurrentDirUnavailable(e)
);
process::exit(1);
}
}
}
fn resolve_binary(name: &OsStr) -> Option<PathBuf> {
let path = PathBuf::from(name);
if path.is_absolute() || path.components().count() > 1 {
return path.is_file().then_some(path);
}
std::env::var_os("PATH").and_then(|path_var| {
std::env::split_paths(&path_var)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
})
}

38
src/preflight.rs Normal file
View File

@@ -0,0 +1,38 @@
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
use crate::SandboxConfig;
use crate::errors::SandboxError;
pub fn check(config: &SandboxConfig) -> Result<(), SandboxError> {
check_bwrap()?;
check_command(config)?;
check_chdir(config)?;
Ok(())
}
fn check_chdir(config: &SandboxConfig) -> Result<(), SandboxError> {
if !config.chdir.is_dir() {
return Err(SandboxError::ChdirMissing(config.chdir.clone()));
}
Ok(())
}
fn check_bwrap() -> Result<(), SandboxError> {
Command::new("bwrap")
.arg("--version")
.output()
.map_err(|_| SandboxError::BwrapNotFound)?;
Ok(())
}
fn check_command(config: &SandboxConfig) -> Result<(), SandboxError> {
if !config.command.is_file() {
return Err(SandboxError::CommandNotFound(config.command.clone()));
}
let metadata = std::fs::metadata(&config.command)?;
if metadata.permissions().mode() & 0o111 == 0 {
return Err(SandboxError::CommandNotExecutable(config.command.clone()));
}
Ok(())
}

132
src/sandbox.rs Normal file
View File

@@ -0,0 +1,132 @@
use std::path::Path;
use std::process::Command;
use crate::agents;
use crate::blacklist;
use crate::{SandboxConfig, SandboxError, SandboxMode};
pub fn build_command(config: &SandboxConfig) -> Result<Command, SandboxError> {
let mut cmd = Command::new("bwrap");
let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist);
if hardened {
cmd.args(["--unshare-ipc", "--unshare-pid", "--unshare-uts"]);
cmd.args(["--hostname", "sandbox"]);
}
if config.no_net {
cmd.arg("--unshare-net");
}
match config.mode {
SandboxMode::Blacklist => add_blacklist_mode(&mut cmd)?,
SandboxMode::Whitelist => add_whitelist_mode(&mut cmd)?,
}
if hardened {
cmd.args(["--tmpfs", "/tmp"]);
cmd.args(["--dev", "/dev"]);
cmd.args(["--tmpfs", "/run"]);
cmd.args(["--proc", "/proc"]);
}
for path in agents::agent_rw_paths() {
cmd.arg("--bind").arg(&path).arg(&path);
}
add_rw_bind(&mut cmd, &config.chdir)?;
for path in &config.extra_rw {
add_rw_bind(&mut cmd, path)?;
}
for path in &config.extra_ro {
add_ro_bind(&mut cmd, path)?;
}
cmd.arg("--die-with-parent");
cmd.arg("--chdir").arg(&config.chdir);
cmd.arg("--")
.arg(&config.command)
.args(&config.command_args);
Ok(cmd)
}
fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> {
let ctx = blacklist::resolve_path_context()?;
cmd.args(["--ro-bind", "/", "/"]);
let overlays = blacklist::resolve_overlays(&ctx)?;
for dir in &overlays.tmpfs_dirs {
cmd.arg("--tmpfs").arg(dir);
}
for file in &overlays.null_bind_files {
cmd.arg("--ro-bind").arg("/dev/null").arg(file);
}
Ok(())
}
fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> {
let home = std::env::var("HOME").map_err(|_| SandboxError::HomeNotSet)?;
cmd.args(["--ro-bind", "/usr", "/usr"]);
for path in ["/lib", "/lib64", "/lib32", "/bin", "/sbin"] {
cmd.args(["--ro-bind-try", path, path]);
}
for path in [
"/etc/ld.so.cache",
"/etc/ld.so.conf",
"/etc/ld.so.conf.d",
"/etc/alternatives",
] {
cmd.args(["--ro-bind-try", path, path]);
}
cmd.args(["--ro-bind", "/etc/ssl", "/etc/ssl"]);
cmd.args([
"--ro-bind-try",
"/etc/ca-certificates",
"/etc/ca-certificates",
]);
cmd.args(["--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf"]);
cmd.args(["--ro-bind", "/etc/nsswitch.conf", "/etc/nsswitch.conf"]);
cmd.args(["--ro-bind", "/etc/passwd", "/etc/passwd"]);
cmd.args(["--ro-bind", "/etc/group", "/etc/group"]);
for path in [
"/etc/hosts",
"/etc/gai.conf",
"/etc/services",
"/etc/protocols",
] {
cmd.args(["--ro-bind-try", path, path]);
}
for path in ["/etc/hostname", "/etc/localtime", "/etc/machine-id"] {
cmd.args(["--ro-bind-try", path, path]);
}
let local_bin = format!("{home}/.local/bin");
cmd.arg("--ro-bind-try").arg(&local_bin).arg(&local_bin);
let cache_dir = format!("{home}/.cache");
cmd.arg("--tmpfs").arg(&cache_dir);
Ok(())
}
fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
if !path.exists() {
return Err(SandboxError::RwPathMissing(path.to_path_buf()));
}
cmd.arg("--bind").arg(path).arg(path);
Ok(())
}
fn add_ro_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> {
if !path.exists() {
return Err(SandboxError::RoPathMissing(path.to_path_buf()));
}
cmd.arg("--ro-bind").arg(path).arg(path);
Ok(())
}