#[cfg(target_os = "linux")]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
use crate::tools::SandboxPolicy;
#[derive(Clone)]
pub struct SandboxApprovalHandler(pub Arc<dyn PermissionsApprovalHandler>);
impl std::fmt::Debug for SandboxApprovalHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SandboxApprovalHandler(..)")
}
}
impl std::ops::Deref for SandboxApprovalHandler {
type Target = dyn PermissionsApprovalHandler;
fn deref(&self) -> &Self::Target {
&*self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxEscalation {
#[default]
Deny,
Ask,
Allow,
}
impl SandboxEscalation {
pub fn rank(self) -> u8 {
match self {
SandboxEscalation::Deny => 0,
SandboxEscalation::Ask => 1,
SandboxEscalation::Allow => 2,
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.replace('_', "-").to_ascii_lowercase().as_str() {
"deny" => Some(SandboxEscalation::Deny),
"ask" => Some(SandboxEscalation::Ask),
"allow" => Some(SandboxEscalation::Allow),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxEnvPolicy {
#[default]
Inherit,
Filtered,
None,
}
impl SandboxEnvPolicy {
pub fn rank(self) -> u8 {
match self {
SandboxEnvPolicy::None => 0,
SandboxEnvPolicy::Filtered => 1,
SandboxEnvPolicy::Inherit => 2,
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.replace('_', "-").to_ascii_lowercase().as_str() {
"inherit" => Some(SandboxEnvPolicy::Inherit),
"filtered" => Some(SandboxEnvPolicy::Filtered),
"none" => Some(SandboxEnvPolicy::None),
_ => None,
}
}
}
const FILTERED_ENV_DENYLIST_PREFIXES: &[&str] = &[
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GITHUB_TOKEN",
"GH_TOKEN",
"GITLAB_TOKEN",
"NPM_TOKEN",
"DOCKER_PASSWORD",
"GOOGLE_APPLICATION_CREDENTIALS",
"AZURE_CLIENT_SECRET",
"SSH_AUTH_SOCK",
"SUPERCODE_",
];
fn is_filtered_env_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
if FILTERED_ENV_DENYLIST_PREFIXES
.iter()
.any(|p| upper == *p || upper.starts_with(p))
{
return true;
}
[
"TOKEN",
"SECRET",
"PASSWORD",
"_KEY",
"CREDENTIAL",
"APIKEY",
]
.iter()
.any(|needle| upper.contains(needle))
}
const MINIMAL_ENV_KEEP: &[&str] = &["PATH", "HOME", "TERM", "LANG", "LC_ALL", "TMPDIR"];
pub fn apply_env_policy<I, K, V>(policy: SandboxEnvPolicy, base: I) -> Vec<(String, String)>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let base: Vec<(String, String)> = base
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
match policy {
SandboxEnvPolicy::Inherit => base,
SandboxEnvPolicy::Filtered => base
.into_iter()
.filter(|(k, _)| !is_filtered_env_key(k))
.collect(),
SandboxEnvPolicy::None => base
.into_iter()
.filter(|(k, _)| MINIMAL_ENV_KEEP.contains(&k.as_str()))
.collect(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FsDecision {
NotRequested,
Confine,
RunUnconfinedWithWarning {
reason: String,
},
Refuse {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NetDecision {
NotRequested,
Confine,
GapWarn {
reason: String,
},
}
pub fn os_sandbox_active(tier: SandboxPolicy, os_enabled: Option<bool>) -> bool {
match tier {
SandboxPolicy::DangerFullAccess => false,
_ => os_enabled.unwrap_or(true),
}
}
#[allow(clippy::too_many_arguments)]
pub fn decide_fs(
tier: SandboxPolicy,
os_enabled: Option<bool>,
fs_available: bool,
escalation: SandboxEscalation,
approval: Option<&dyn PermissionsApprovalHandler>,
subject: &str,
) -> FsDecision {
if !os_sandbox_active(tier, os_enabled) {
return FsDecision::NotRequested;
}
if fs_available {
return FsDecision::Confine;
}
let reason = format!(
"sandbox: filesystem confinement ({tier:?}) was requested but is unavailable on this \
platform/kernel (no Landlock support) for `{subject}`"
);
resolve_escalation(escalation, approval, "bash", subject, reason)
}
pub fn decide_net(
network_enabled: bool,
has_domain_rules: bool,
net_available: bool,
) -> NetDecision {
if !network_enabled {
return NetDecision::NotRequested;
}
if has_domain_rules {
return NetDecision::GapWarn {
reason: "sandbox: capabilities.permissions.sandbox.network.allow_domains/\
deny_domains was set, but domain-level network filtering has no OS \
primitive on this platform — that needs an out-of-scope TLS-MITM proxy \
(COMPOSABLE-HARNESS-DESIGN.md gap honesty note). Network was NOT \
confined for this call."
.to_string(),
};
}
if net_available {
return NetDecision::Confine;
}
NetDecision::GapWarn {
reason: "sandbox: capabilities.permissions.sandbox.network.enabled was set, but a \
coarse network cut-off is unavailable on this platform/kernel (no \
unprivileged network-namespace support). Network was NOT confined for this \
call."
.to_string(),
}
}
fn resolve_escalation(
escalation: SandboxEscalation,
approval: Option<&dyn PermissionsApprovalHandler>,
tool: &str,
subject: &str,
reason: String,
) -> FsDecision {
match escalation {
SandboxEscalation::Deny => FsDecision::Refuse { reason },
SandboxEscalation::Allow => FsDecision::RunUnconfinedWithWarning { reason },
SandboxEscalation::Ask => match approval {
Some(handler) => {
let raw_args = serde_json::Value::Null;
let req = ApprovalRequest {
tool,
subject: Some(subject),
raw_args: &raw_args,
};
match handler.ask(&req) {
ApprovalOutcome::Deny => FsDecision::Refuse { reason },
ApprovalOutcome::Allow | ApprovalOutcome::AllowForSession => {
FsDecision::RunUnconfinedWithWarning { reason }
}
}
}
None => FsDecision::Refuse { reason },
},
}
}
pub fn warn_once(reason: &str) {
static WARNED: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
let set = WARNED.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
if let Ok(mut set) = set.lock() {
if set.insert(reason.to_string()) {
eprintln!("\x1b[33mwarning: {reason}\x1b[0m");
}
}
}
#[cfg(target_os = "linux")]
pub fn landlock_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
use landlock::{AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, ABI};
Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(AccessFs::from_write(ABI::V1))
.and_then(|r| r.create())
.is_ok()
})
}
#[cfg(not(target_os = "linux"))]
pub fn landlock_available() -> bool {
false
}
#[cfg(target_os = "linux")]
pub fn netns_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(probe_netns_fork)
}
#[cfg(not(target_os = "linux"))]
pub fn netns_available() -> bool {
false
}
#[cfg(target_os = "linux")]
fn probe_netns_fork() -> bool {
unsafe {
let pid = libc::fork();
if pid == 0 {
let rc = libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET);
libc::_exit(i32::from(rc != 0));
} else if pid > 0 {
let mut status: libc::c_int = 0;
if libc::waitpid(pid, &mut status, 0) != pid {
return false;
}
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0
} else {
false
}
}
}
#[cfg(target_os = "linux")]
pub fn apply_linux_confinement(
cmd: &mut tokio::process::Command,
confine_fs: bool,
fs_allow_writes: bool,
cwd: PathBuf,
extra_write_dirs: Vec<PathBuf>,
confine_net: bool,
) {
if !confine_fs && !confine_net {
return;
}
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
unsafe {
cmd.pre_exec(move || {
if confine_net {
netns_isolate_self(uid, gid)
.map_err(|e| std::io::Error::other(format!("sandbox netns: {e}")))?;
}
if confine_fs {
landlock_restrict_self(&cwd, &extra_write_dirs, fs_allow_writes)
.map_err(|e| std::io::Error::other(format!("sandbox landlock: {e}")))?;
}
Ok(())
});
}
}
#[cfg(target_os = "linux")]
fn netns_isolate_self(uid: libc::uid_t, gid: libc::gid_t) -> Result<(), String> {
unsafe {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
return Err(format!(
"unshare(CLONE_NEWUSER|CLONE_NEWNET): errno {}",
*libc::__errno_location()
));
}
}
write_proc_self_raw("setgroups", b"deny")?;
write_proc_self_raw("uid_map", format!("0 {uid} 1\n").as_bytes())?;
write_proc_self_raw("gid_map", format!("0 {gid} 1\n").as_bytes())?;
Ok(())
}
#[cfg(target_os = "linux")]
fn write_proc_self_raw(name: &str, contents: &[u8]) -> Result<(), String> {
let path = format!("/proc/self/{name}\0");
unsafe {
let fd = libc::open(path.as_ptr() as *const libc::c_char, libc::O_WRONLY);
if fd < 0 {
return Err(format!(
"open(/proc/self/{name}): errno {}",
*libc::__errno_location()
));
}
let n = libc::write(fd, contents.as_ptr() as *const libc::c_void, contents.len());
let write_errno = *libc::__errno_location();
libc::close(fd);
if n != contents.len() as isize {
return Err(format!("write(/proc/self/{name}): errno {write_errno}"));
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn landlock_restrict_self(
cwd: &Path,
extra_write_dirs: &[PathBuf],
fs_allow_writes: bool,
) -> Result<(), String> {
use landlock::{
path_beneath_rules, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetStatus, ABI,
};
let write_access = AccessFs::from_write(ABI::V1);
let created = Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(write_access)
.map_err(|e| e.to_string())?
.create()
.map_err(|e| e.to_string())?
.set_compatibility(CompatLevel::HardRequirement);
let created = if fs_allow_writes {
let mut dirs = Vec::with_capacity(1 + extra_write_dirs.len());
dirs.push(cwd.to_path_buf());
dirs.extend(extra_write_dirs.iter().cloned());
created
.add_rules(path_beneath_rules(&dirs, write_access))
.map_err(|e| e.to_string())?
} else {
created
};
let status = created.restrict_self().map_err(|e| e.to_string())?;
if status.ruleset != RulesetStatus::FullyEnforced {
return Err(format!(
"ruleset not fully enforced ({:?}) — refusing to claim confinement it doesn't have",
status.ruleset
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::ApprovalOutcome;
struct FakeApproval(ApprovalOutcome);
impl PermissionsApprovalHandler for FakeApproval {
fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
self.0
}
}
#[test]
fn danger_full_access_is_always_inactive() {
assert!(!os_sandbox_active(
SandboxPolicy::DangerFullAccess,
Some(true)
));
assert!(!os_sandbox_active(
SandboxPolicy::DangerFullAccess,
Some(false)
));
assert!(!os_sandbox_active(SandboxPolicy::DangerFullAccess, None));
}
#[test]
fn confining_tier_defaults_active_when_enabled_unset() {
assert!(os_sandbox_active(SandboxPolicy::WorkspaceWrite, None));
assert!(os_sandbox_active(SandboxPolicy::ReadOnly, None));
}
#[test]
fn explicit_enabled_false_overrides_confining_tier() {
assert!(!os_sandbox_active(
SandboxPolicy::WorkspaceWrite,
Some(false)
));
}
#[test]
fn danger_full_access_never_requests_fs_confinement() {
let d = decide_fs(
SandboxPolicy::DangerFullAccess,
None,
false, SandboxEscalation::Deny,
None,
"echo hi",
);
assert_eq!(d, FsDecision::NotRequested);
}
#[test]
fn explicit_enabled_false_never_requests_fs_confinement() {
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
Some(false),
false,
SandboxEscalation::Deny,
None,
"echo hi",
);
assert_eq!(d, FsDecision::NotRequested);
}
#[test]
fn available_confining_tier_confines() {
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
None,
true,
SandboxEscalation::Deny,
None,
"echo hi",
);
assert_eq!(d, FsDecision::Confine);
}
#[test]
fn unavailable_plus_deny_refuses() {
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
None,
false,
SandboxEscalation::Deny,
None,
"echo hi",
);
assert!(matches!(d, FsDecision::Refuse { .. }), "got {d:?}");
}
#[test]
fn unavailable_plus_ask_no_handler_fails_closed() {
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
None,
false,
SandboxEscalation::Ask,
None,
"echo hi",
);
assert!(matches!(d, FsDecision::Refuse { .. }), "got {d:?}");
}
#[test]
fn unavailable_plus_ask_denying_handler_refuses() {
let handler = FakeApproval(ApprovalOutcome::Deny);
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
None,
false,
SandboxEscalation::Ask,
Some(&handler),
"echo hi",
);
assert!(matches!(d, FsDecision::Refuse { .. }), "got {d:?}");
}
#[test]
fn unavailable_plus_ask_approving_handler_runs_unconfined_with_warning() {
let handler = FakeApproval(ApprovalOutcome::Allow);
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
None,
false,
SandboxEscalation::Ask,
Some(&handler),
"echo hi",
);
assert!(
matches!(d, FsDecision::RunUnconfinedWithWarning { .. }),
"got {d:?}"
);
}
#[test]
fn unavailable_plus_allow_runs_unconfined_with_warning_no_handler_needed() {
let d = decide_fs(
SandboxPolicy::WorkspaceWrite,
None,
false,
SandboxEscalation::Allow,
None,
"echo hi",
);
assert!(
matches!(d, FsDecision::RunUnconfinedWithWarning { .. }),
"got {d:?}"
);
}
#[test]
fn ask_never_bypasses_when_denied_even_with_allow_for_session_semantics_elsewhere() {
let handler = FakeApproval(ApprovalOutcome::Deny);
let d = decide_fs(
SandboxPolicy::ReadOnly,
None,
false,
SandboxEscalation::Ask,
Some(&handler),
"cat /etc/shadow",
);
assert_eq!(
d,
FsDecision::Refuse {
reason: "sandbox: filesystem confinement (ReadOnly) was requested but is \
unavailable on this platform/kernel (no Landlock support) for `cat \
/etc/shadow`"
.to_string()
}
);
}
#[test]
fn network_not_requested_is_a_pure_noop() {
assert_eq!(decide_net(false, false, true), NetDecision::NotRequested);
assert_eq!(decide_net(false, true, true), NetDecision::NotRequested);
}
#[test]
fn network_requested_and_available_confines() {
assert_eq!(decide_net(true, false, true), NetDecision::Confine);
}
#[test]
fn network_requested_but_unavailable_gap_warns_never_refuses() {
let d = decide_net(true, false, false);
assert!(matches!(d, NetDecision::GapWarn { .. }), "got {d:?}");
}
#[test]
fn network_domain_rules_always_gap_warn_even_when_netns_available() {
let d = decide_net(true, true, true);
assert!(matches!(d, NetDecision::GapWarn { .. }), "got {d:?}");
}
#[test]
fn escalation_parse_and_rank_order() {
assert_eq!(
SandboxEscalation::parse("deny"),
Some(SandboxEscalation::Deny)
);
assert_eq!(
SandboxEscalation::parse("ASK"),
Some(SandboxEscalation::Ask)
);
assert_eq!(
SandboxEscalation::parse("allow"),
Some(SandboxEscalation::Allow)
);
assert_eq!(SandboxEscalation::parse("bogus"), None);
assert!(SandboxEscalation::Deny.rank() < SandboxEscalation::Ask.rank());
assert!(SandboxEscalation::Ask.rank() < SandboxEscalation::Allow.rank());
}
#[test]
fn env_policy_parse_and_rank_order() {
assert_eq!(
SandboxEnvPolicy::parse("inherit"),
Some(SandboxEnvPolicy::Inherit)
);
assert_eq!(
SandboxEnvPolicy::parse("filtered"),
Some(SandboxEnvPolicy::Filtered)
);
assert_eq!(
SandboxEnvPolicy::parse("none"),
Some(SandboxEnvPolicy::None)
);
assert_eq!(SandboxEnvPolicy::parse("bogus"), None);
assert!(SandboxEnvPolicy::None.rank() < SandboxEnvPolicy::Filtered.rank());
assert!(SandboxEnvPolicy::Filtered.rank() < SandboxEnvPolicy::Inherit.rank());
}
fn sample_env() -> Vec<(String, String)> {
vec![
("PATH".to_string(), "/usr/bin".to_string()),
("HOME".to_string(), "/home/u".to_string()),
("OPENROUTER_API_KEY".to_string(), "sk-secret".to_string()),
("MY_APP_TOKEN".to_string(), "t-secret".to_string()),
("HARMLESS_VAR".to_string(), "ok".to_string()),
]
}
#[test]
fn env_inherit_is_byte_identical_passthrough() {
let out = apply_env_policy(SandboxEnvPolicy::Inherit, sample_env());
assert_eq!(out, sample_env());
}
#[test]
fn env_filtered_strips_secrets_keeps_the_rest() {
let out = apply_env_policy(SandboxEnvPolicy::Filtered, sample_env());
let keys: Vec<&str> = out.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"PATH"));
assert!(keys.contains(&"HOME"));
assert!(keys.contains(&"HARMLESS_VAR"));
assert!(!keys.contains(&"OPENROUTER_API_KEY"));
assert!(!keys.contains(&"MY_APP_TOKEN"));
}
#[test]
fn env_none_keeps_only_the_minimal_set() {
let out = apply_env_policy(SandboxEnvPolicy::None, sample_env());
let keys: Vec<&str> = out.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["PATH", "HOME"]);
}
#[test]
fn warn_once_dedupes_exact_text() {
warn_once("sandbox test warning A");
warn_once("sandbox test warning A");
warn_once("sandbox test warning B");
}
}