use crate::{Caveats, ToolResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxKind {
Landlock,
#[default]
None,
}
pub trait Sandbox: Send + Sync {
fn kind(&self) -> SandboxKind;
fn apply(&self, effective: &Caveats) -> ToolResult<()>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSandbox;
impl Sandbox for NoopSandbox {
fn kind(&self) -> SandboxKind {
SandboxKind::None
}
fn apply(&self, _effective: &Caveats) -> ToolResult<()> {
Ok(())
}
}
pub fn best_available_sandbox() -> Box<dyn Sandbox> {
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
{
if landlock_impl::landlock_is_supported() {
return Box::new(landlock_impl::LandlockSandbox::new());
}
}
Box::new(NoopSandbox)
}
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
pub use landlock_impl::{landlock_is_supported, LandlockSandbox};
#[cfg(all(target_os = "linux", feature = "linux-landlock"))]
mod landlock_impl {
use super::{Sandbox, SandboxKind};
use crate::{Caveats, Scope, ToolError, ToolResult};
use landlock::{
path_beneath_rules, Access, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetStatus, ABI,
};
const ABI_FLOOR: ABI = ABI::V3;
pub fn landlock_is_supported() -> bool {
Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(AccessFs::from_all(ABI::V1))
.and_then(|r| r.create())
.is_ok()
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LandlockSandbox;
impl LandlockSandbox {
pub fn new() -> Self {
Self
}
}
impl Sandbox for LandlockSandbox {
fn kind(&self) -> SandboxKind {
SandboxKind::Landlock
}
fn apply(&self, effective: &Caveats) -> ToolResult<()> {
let write = AccessFs::from_write(ABI_FLOOR);
let roots: Vec<String> = match &effective.fs_write {
Scope::All => vec!["/".to_string()],
Scope::Only(set) => set
.iter()
.filter(|p| std::path::Path::new(p).exists())
.cloned()
.collect(),
};
let status = Ruleset::default()
.set_compatibility(CompatLevel::BestEffort)
.handle_access(write)
.map_err(landlock_denied)?
.create()
.map_err(landlock_denied)?
.add_rules(path_beneath_rules(&roots, write))
.map_err(landlock_denied)?
.restrict_self()
.map_err(landlock_denied)?;
if status.ruleset == RulesetStatus::NotEnforced {
return Err(ToolError::denied(
"landlock ruleset was not enforced by this kernel",
));
}
Ok(())
}
}
fn landlock_denied(e: impl std::fmt::Display) -> ToolError {
ToolError::denied(format!("landlock: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_reports_none_and_never_fails() {
let s = NoopSandbox;
assert_eq!(s.kind(), SandboxKind::None);
assert!(s.apply(&Caveats::top()).is_ok());
}
#[test]
fn sandbox_kind_serde_is_snake_case() {
assert_eq!(
serde_json::to_string(&SandboxKind::None).unwrap(),
"\"none\""
);
assert_eq!(
serde_json::to_string(&SandboxKind::Landlock).unwrap(),
"\"landlock\""
);
}
#[test]
fn best_available_sandbox_is_a_sandbox() {
let sb = best_available_sandbox();
assert!(sb.apply(&Caveats::top()).is_ok());
}
}
#[cfg(all(target_os = "linux", feature = "linux-landlock", test))]
mod landlock_kernel_tests {
use super::*;
use crate::Scope;
use std::fs;
use std::path::PathBuf;
fn unique_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let mut d = std::env::temp_dir();
d.push(format!(
"agent-bridle-ll-{}-{}-{}",
tag,
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn fs_write_is_kernel_enforced_outside_scope_denied_inside_allowed() {
if !landlock_is_supported() {
eprintln!("skipping fs_write landlock test: kernel lacks Landlock");
return;
}
let allowed = unique_dir("allowed");
let forbidden = unique_dir("forbidden");
let allowed_t = allowed.clone();
let forbidden_t = forbidden.clone();
let (inside_ok, outside) = std::thread::spawn(move || {
let cav = Caveats {
fs_write: Scope::only([allowed_t.to_string_lossy().into_owned()]),
..Caveats::top()
};
LandlockSandbox::new().apply(&cav).expect("apply landlock");
let inside = fs::write(allowed_t.join("ok.txt"), b"hi");
let outside = fs::write(forbidden_t.join("escape.txt"), b"nope");
(inside.is_ok(), outside)
})
.join()
.unwrap();
assert!(inside_ok, "writing within fs_write scope must succeed");
let err = outside.expect_err("writing outside fs_write scope must be denied by Landlock");
assert_eq!(
err.kind(),
std::io::ErrorKind::PermissionDenied,
"the denial must come from the kernel (EACCES)"
);
let _ = fs::remove_dir_all(&allowed);
let _ = fs::remove_dir_all(&forbidden);
}
#[test]
fn empty_fs_write_scope_denies_all_writes() {
if !landlock_is_supported() {
eprintln!("skipping empty-scope landlock test: kernel lacks Landlock");
return;
}
let dir = unique_dir("none");
let dir_t = dir.clone();
let outside = std::thread::spawn(move || {
let cav = Caveats {
fs_write: Scope::none(),
..Caveats::top()
};
LandlockSandbox::new().apply(&cav).expect("apply landlock");
fs::write(dir_t.join("x.txt"), b"nope")
})
.join()
.unwrap();
assert_eq!(
outside
.expect_err("empty fs_write must deny all writes")
.kind(),
std::io::ErrorKind::PermissionDenied
);
let _ = fs::remove_dir_all(&dir);
}
}