use std::path::{Path, PathBuf};
const ENV_ENABLED: &str = "AGENT_BLOCK_SANDBOX";
const ENV_FS_RW: &str = "AGENT_BLOCK_SANDBOX_FS_RW";
const ENV_TCP: &str = "AGENT_BLOCK_SANDBOX_TCP";
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const ALWAYS_WRITABLE: &[&str] = &["/tmp", "/dev/null", "/dev/urandom", "/dev/tty"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxConfig {
pub enabled: bool,
pub fs_rw: Vec<PathBuf>,
pub tcp: bool,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: false,
fs_rw: Vec::new(),
tcp: true,
}
}
}
impl SandboxConfig {
pub fn from_env(cli_enabled: bool) -> Self {
Self::from_parts(
cli_enabled,
std::env::var(ENV_ENABLED).ok().as_deref(),
std::env::var(ENV_FS_RW).ok().as_deref(),
std::env::var(ENV_TCP).ok().as_deref(),
)
}
fn from_parts(
cli_enabled: bool,
enabled_raw: Option<&str>,
fs_rw_raw: Option<&str>,
tcp_raw: Option<&str>,
) -> Self {
Self {
enabled: cli_enabled || enabled_raw.is_some_and(is_truthy),
fs_rw: fs_rw_raw.map(split_paths).unwrap_or_default(),
tcp: tcp_raw.is_none_or(is_truthy),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SandboxError {
#[error("sandbox mode is Linux-only (Landlock + seccomp); this build targets '{os}'")]
Unsupported {
os: &'static str,
},
#[error(
"sandbox requested but Landlock is not enforced by this kernel \
(needs Linux 5.13+ with CONFIG_SECURITY_LANDLOCK and landlock in the active LSM list)"
)]
NotEnforced,
#[error("sandbox: project root '{path}' cannot be resolved: {error}")]
ProjectRoot {
path: String,
error: String,
},
#[error("failed to install Landlock ruleset: {0}")]
Landlock(String),
#[error("failed to install seccomp filter: {0}")]
Seccomp(String),
}
pub fn apply(config: &SandboxConfig, project_root: &Path) -> Result<(), SandboxError> {
if !config.enabled {
return Ok(());
}
apply_platform(config, project_root)
}
#[cfg(target_os = "linux")]
fn apply_platform(config: &SandboxConfig, project_root: &Path) -> Result<(), SandboxError> {
linux::apply(config, project_root)
}
#[cfg(not(target_os = "linux"))]
fn apply_platform(_config: &SandboxConfig, _project_root: &Path) -> Result<(), SandboxError> {
Err(SandboxError::Unsupported {
os: std::env::consts::OS,
})
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn write_allowlist(config: &SandboxConfig, project_root: &Path) -> Vec<PathBuf> {
let mut candidates: Vec<(PathBuf, bool)> = Vec::new();
candidates.push((project_root.to_path_buf(), true));
if let Some(home) = state_home() {
candidates.push((home, true));
}
candidates.extend(ALWAYS_WRITABLE.iter().map(|p| (PathBuf::from(p), false)));
candidates.extend(config.fs_rw.iter().map(|p| (p.clone(), true)));
let mut out: Vec<PathBuf> = Vec::with_capacity(candidates.len());
for (path, explicit) in candidates {
let resolved = match path.canonicalize() {
Ok(p) => p,
Err(err) => {
if explicit {
tracing::warn!(
path = %path.display(),
error = %err,
"sandbox: write path not granted (unresolvable) — writes there will fail"
);
} else {
tracing::debug!(
path = %path.display(),
error = %err,
"sandbox: built-in write path absent, skipped"
);
}
continue;
}
};
if !out.contains(&resolved) {
out.push(resolved);
}
}
out
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn state_home() -> Option<PathBuf> {
if let Some(v) = std::env::var_os("AGENT_BLOCK_HOME") {
return Some(PathBuf::from(v));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".agent-block"))
}
fn is_truthy(raw: &str) -> bool {
!matches!(
raw.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
fn split_paths(raw: &str) -> Vec<PathBuf> {
raw.split(':')
.filter(|segment| !segment.trim().is_empty())
.map(PathBuf::from)
.collect()
}
#[cfg(target_os = "linux")]
mod linux {
use super::{write_allowlist, SandboxConfig, SandboxError};
use landlock::{
path_beneath_rules, Access, AccessFs, AccessNet, CompatLevel, Compatible, Ruleset,
RulesetAttr, RulesetCreatedAttr, RulesetStatus, ABI,
};
use std::path::Path;
const FS_ABI: ABI = ABI::V4;
const NET_ABI: ABI = ABI::V4;
pub(super) fn apply(config: &SandboxConfig, project_root: &Path) -> Result<(), SandboxError> {
let project_root =
project_root
.canonicalize()
.map_err(|err| SandboxError::ProjectRoot {
path: project_root.display().to_string(),
error: err.to_string(),
})?;
let writable = write_allowlist(config, &project_root);
let granted = writable.len();
let mut ruleset = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(AccessFs::from_all(FS_ABI))
.map_err(landlock_err)?;
if !config.tcp {
ruleset = ruleset
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(AccessNet::from_all(NET_ABI))
.map_err(landlock_err)?;
}
let created = ruleset.create().map_err(landlock_err)?;
let created = created
.add_rules(path_beneath_rules(["/"], AccessFs::from_read(FS_ABI)))
.map_err(landlock_err)?;
let created = created
.add_rules(path_beneath_rules(&writable, AccessFs::from_all(FS_ABI)))
.map_err(landlock_err)?;
let status = created.restrict_self().map_err(landlock_err)?;
match status.ruleset {
RulesetStatus::FullyEnforced => {
tracing::info!(
writable = granted,
tcp = config.tcp,
"sandbox: filesystem boundary fully enforced"
);
}
RulesetStatus::PartiallyEnforced => {
tracing::warn!(
writable = granted,
tcp = config.tcp,
"sandbox: filesystem boundary only partially enforced — this kernel \
dropped some access rights (older Landlock ABI). Writes outside the \
allowlist are still denied; newer rights (e.g. file truncation) may \
not be. An explicit TCP denial is never dropped: it aborts startup \
on kernels that cannot enforce it"
);
}
_ => return Err(SandboxError::NotEnforced),
}
super::seccomp::deny_io_uring()?;
Ok(())
}
fn landlock_err<E: std::fmt::Debug>(err: E) -> SandboxError {
SandboxError::Landlock(format!("{err:?}"))
}
}
#[cfg(target_os = "linux")]
mod seccomp {
use super::SandboxError;
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
pub(super) fn deny_io_uring() -> Result<(), SandboxError> {
use seccompiler::{
apply_filter_all_threads, BpfProgram, SeccompAction, SeccompFilter, SeccompRule,
TargetArch,
};
use std::collections::BTreeMap;
#[cfg(target_arch = "x86_64")]
const ARCH: TargetArch = TargetArch::x86_64;
#[cfg(target_arch = "aarch64")]
const ARCH: TargetArch = TargetArch::aarch64;
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
#[allow(clippy::unnecessary_cast)]
for syscall in [
libc::SYS_io_uring_setup,
libc::SYS_io_uring_enter,
libc::SYS_io_uring_register,
] {
rules.insert(syscall as i64, Vec::new());
}
let filter = SeccompFilter::new(
rules,
SeccompAction::Allow,
SeccompAction::Errno(libc::EPERM as u32),
ARCH,
)
.map_err(seccomp_err)?;
let program: BpfProgram = filter.try_into().map_err(seccomp_err)?;
apply_filter_all_threads(&program).map_err(seccomp_err)?;
tracing::info!("sandbox: io_uring syscalls denied (EPERM)");
Ok(())
}
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub(super) fn deny_io_uring() -> Result<(), SandboxError> {
tracing::warn!(
arch = std::env::consts::ARCH,
"sandbox: io_uring deny skipped — no seccomp filter is compiled for this \
architecture; the Landlock filesystem boundary is unaffected"
);
Ok(())
}
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
fn seccomp_err<E: std::fmt::Debug>(err: E) -> SandboxError {
SandboxError::Seccomp(format!("{err:?}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_off_and_network_open() {
let cfg = SandboxConfig::from_parts(false, None, None, None);
assert!(!cfg.enabled);
assert!(cfg.fs_rw.is_empty());
assert!(cfg.tcp, "TCP must default to unrestricted");
assert_eq!(cfg, SandboxConfig::default());
}
#[test]
fn cli_flag_enables_without_env() {
let cfg = SandboxConfig::from_parts(true, None, None, None);
assert!(cfg.enabled);
}
#[test]
fn env_enables_without_cli_flag() {
assert!(SandboxConfig::from_parts(false, Some("1"), None, None).enabled);
assert!(SandboxConfig::from_parts(false, Some("true"), None, None).enabled);
assert!(!SandboxConfig::from_parts(false, Some("0"), None, None).enabled);
assert!(!SandboxConfig::from_parts(false, Some(""), None, None).enabled);
assert!(SandboxConfig::from_parts(true, Some("0"), None, None).enabled);
}
#[test]
fn fs_rw_splits_on_colon() {
let cfg = SandboxConfig::from_parts(true, None, Some("/opt/cache:/srv/data"), None);
assert_eq!(
cfg.fs_rw,
vec![PathBuf::from("/opt/cache"), PathBuf::from("/srv/data")]
);
}
#[test]
fn fs_rw_drops_empty_segments() {
let cfg = SandboxConfig::from_parts(true, None, Some(":/a::/b: :"), None);
assert_eq!(cfg.fs_rw, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
}
#[test]
fn fs_rw_empty_string_yields_no_paths() {
let cfg = SandboxConfig::from_parts(true, None, Some(""), None);
assert!(cfg.fs_rw.is_empty());
}
#[test]
fn fs_rw_single_path_has_no_separator() {
let cfg = SandboxConfig::from_parts(true, None, Some("/opt/cache"), None);
assert_eq!(cfg.fs_rw, vec![PathBuf::from("/opt/cache")]);
}
#[test]
fn tcp_falsy_values_deny() {
for raw in ["0", "false", "FALSE", " False ", "no", "off", ""] {
let cfg = SandboxConfig::from_parts(true, None, None, Some(raw));
assert!(!cfg.tcp, "expected {raw:?} to deny TCP");
}
}
#[test]
fn tcp_truthy_values_allow() {
for raw in ["1", "true", "TRUE", "yes", "on", "anything"] {
let cfg = SandboxConfig::from_parts(true, None, None, Some(raw));
assert!(cfg.tcp, "expected {raw:?} to allow TCP");
}
}
#[test]
fn write_allowlist_keeps_existing_and_drops_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("does-not-exist");
let cfg = SandboxConfig {
enabled: true,
fs_rw: vec![missing.clone()],
tcp: true,
};
let allowed = write_allowlist(&cfg, dir.path());
let project = dir
.path()
.canonicalize()
.expect("canonicalize project root");
assert!(
allowed.contains(&project),
"project root must stay writable"
);
assert!(
!allowed.iter().any(|p| p.ends_with("does-not-exist")),
"missing paths are skipped, not fatal"
);
}
#[test]
fn write_allowlist_deduplicates() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = SandboxConfig {
enabled: true,
fs_rw: vec![dir.path().to_path_buf(), dir.path().join(".")],
tcp: true,
};
let allowed = write_allowlist(&cfg, dir.path());
let project = dir
.path()
.canonicalize()
.expect("canonicalize project root");
assert_eq!(
allowed.iter().filter(|p| **p == project).count(),
1,
"duplicate entries must collapse to a single rule"
);
}
}