#[derive(Debug, Clone)]
pub struct LandlockConfig {
pub read_paths: Vec<std::path::PathBuf>,
pub write_paths: Vec<std::path::PathBuf>,
}
impl LandlockConfig {
pub fn for_skill_execution(workspace: &std::path::Path, skills_dir: &std::path::Path) -> Self {
Self {
read_paths: vec![skills_dir.to_path_buf(), workspace.to_path_buf()],
write_paths: vec![workspace.to_path_buf()],
}
}
pub fn for_script_runner(
skills_dir: &std::path::Path,
workspace_dir: Option<&std::path::Path>,
extra_read_paths: &[std::path::PathBuf],
) -> Self {
let mut read_paths = vec![skills_dir.to_path_buf()];
read_paths.extend(extra_read_paths.iter().cloned());
let mut write_paths = vec![std::path::PathBuf::from("/tmp")];
if let Some(ws) = workspace_dir {
read_paths.push(ws.to_path_buf());
write_paths.push(ws.to_path_buf());
}
Self {
read_paths,
write_paths,
}
}
}
pub fn create_landlock_fd(_config: &LandlockConfig) -> Result<Option<i32>, String> {
#[cfg(target_os = "linux")]
{
use landlock::{
ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
};
let abi = ABI::V1;
let read_access = AccessFs::from_read(abi);
let write_access = AccessFs::from_all(abi);
let mut ruleset = match Ruleset::default()
.handle_access(write_access)
.map_err(|e| format!("Landlock ruleset creation failed: {e}"))?
.create()
{
Ok(rs) => rs,
Err(e) => {
tracing::warn!(error = %e, "Landlock not supported on this kernel");
return Ok(None);
}
};
for path in &_config.read_paths {
match PathFd::new(path) {
Ok(fd) => {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_access))
.map_err(|e| {
format!("Landlock read rule failed for {}: {e}", path.display())
})?;
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Landlock: skipping unreadable path"
);
}
}
}
for path in &_config.write_paths {
match PathFd::new(path) {
Ok(fd) => {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, write_access))
.map_err(|e| {
format!("Landlock write rule failed for {}: {e}", path.display())
})?;
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Landlock: skipping unwritable path"
);
}
}
}
use std::os::unix::io::{IntoRawFd, OwnedFd};
let owned_fd: Option<OwnedFd> = ruleset.into();
let raw_fd = match owned_fd {
Some(fd) => fd.into_raw_fd(),
None => {
tracing::warn!("Landlock ruleset produced no fd (kernel too old?)");
return Ok(None);
}
};
tracing::debug!(
fd = raw_fd,
read_paths = ?_config.read_paths,
write_paths = ?_config.write_paths,
"Landlock ruleset fd created for child confinement"
);
Ok(Some(raw_fd))
}
#[cfg(not(target_os = "linux"))]
{
Ok(None)
}
}
pub fn apply_landlock(_config: &LandlockConfig) -> Result<bool, String> {
#[cfg(target_os = "linux")]
{
use landlock::{
ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus,
};
let abi = ABI::V1;
let read_access = AccessFs::from_read(abi);
let write_access = AccessFs::from_all(abi);
let mut ruleset = match Ruleset::default()
.handle_access(write_access)
.map_err(|e| format!("Landlock ruleset creation failed: {e}"))?
.create()
{
Ok(rs) => rs,
Err(e) => {
tracing::warn!(error = %e, "Landlock not supported on this kernel");
return Ok(false);
}
};
for path in &_config.read_paths {
match PathFd::new(path) {
Ok(fd) => {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, read_access))
.map_err(|e| {
format!("Landlock read rule failed for {}: {e}", path.display())
})?;
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Landlock: skipping unreadable path"
);
}
}
}
for path in &_config.write_paths {
match PathFd::new(path) {
Ok(fd) => {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, write_access))
.map_err(|e| {
format!("Landlock write rule failed for {}: {e}", path.display())
})?;
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Landlock: skipping unwritable path"
);
}
}
}
let status = ruleset
.restrict_self()
.map_err(|e| format!("Landlock restrict_self failed: {e}"))?;
match status.ruleset {
RulesetStatus::FullyEnforced => {
tracing::info!(
read_paths = ?_config.read_paths,
write_paths = ?_config.write_paths,
"Landlock confinement fully enforced"
);
Ok(true)
}
RulesetStatus::PartiallyEnforced => {
tracing::warn!(
read_paths = ?_config.read_paths,
write_paths = ?_config.write_paths,
"Landlock confinement partially enforced (some access rights not handled)"
);
Ok(true)
}
RulesetStatus::NotEnforced => {
tracing::warn!("Landlock ruleset not enforced (kernel may be too old)");
Ok(false)
}
}
}
#[cfg(not(target_os = "linux"))]
{
tracing::debug!("Landlock confinement not available on this platform");
Ok(false)
}
}
#[cfg(target_os = "windows")]
#[derive(Debug, Clone)]
pub struct JobObjectConfig {
pub max_active_processes: u32,
pub process_memory_limit: usize,
pub job_memory_limit: usize,
pub kill_on_close: bool,
}
#[cfg(target_os = "windows")]
impl Default for JobObjectConfig {
fn default() -> Self {
Self {
max_active_processes: 4,
process_memory_limit: 256 * 1024 * 1024, job_memory_limit: 512 * 1024 * 1024, kill_on_close: true,
}
}
}
#[cfg(target_os = "windows")]
impl JobObjectConfig {
pub fn for_skill_execution() -> Self {
Self::default()
}
}
#[cfg(target_os = "windows")]
pub fn create_job_object(
config: &JobObjectConfig,
) -> Result<Option<windows_sys::Win32::Foundation::HANDLE>, String> {
use std::mem::zeroed;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::System::JobObjects::{
CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, JOB_OBJECT_LIMIT_JOB_MEMORY,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
};
let job: HANDLE = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() {
return Err(format!(
"CreateJobObjectW failed: {}",
std::io::Error::last_os_error()
));
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
let mut limit_flags: u32 = 0;
if config.max_active_processes > 0 {
info.BasicLimitInformation.ActiveProcessLimit = config.max_active_processes;
limit_flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
}
if config.process_memory_limit > 0 {
info.ProcessMemoryLimit = config.process_memory_limit;
limit_flags |= JOB_OBJECT_LIMIT_PROCESS_MEMORY;
}
if config.job_memory_limit > 0 {
info.JobMemoryLimit = config.job_memory_limit;
limit_flags |= JOB_OBJECT_LIMIT_JOB_MEMORY;
}
if config.kill_on_close {
limit_flags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
}
info.BasicLimitInformation.LimitFlags = limit_flags;
let success = unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const _,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if success == 0 {
let err = std::io::Error::last_os_error();
unsafe { CloseHandle(job) };
return Err(format!("SetInformationJobObject failed: {err}"));
}
tracing::info!(
max_processes = config.max_active_processes,
process_memory_mb = config.process_memory_limit / (1024 * 1024),
job_memory_mb = config.job_memory_limit / (1024 * 1024),
"Windows Job Object created for skill confinement"
);
Ok(Some(job))
}
#[cfg(target_os = "windows")]
pub fn assign_process_to_job(
job: windows_sys::Win32::Foundation::HANDLE,
process: windows_sys::Win32::Foundation::HANDLE,
) -> Result<(), String> {
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
let success = unsafe { AssignProcessToJobObject(job, process) };
if success == 0 {
return Err(format!(
"AssignProcessToJobObject failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn close_job_handle(job: windows_sys::Win32::Foundation::HANDLE) {
unsafe {
windows_sys::Win32::Foundation::CloseHandle(job);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfinementLevel {
KernelEnforced,
ResourceLimitsOnly,
None,
}
pub fn platform_confinement_status() -> (ConfinementLevel, &'static str) {
#[cfg(target_os = "linux")]
{
(
ConfinementLevel::KernelEnforced,
"Linux: Landlock LSM available (kernel 5.13+). \
Filesystem write-denial confinement enforced at kernel level.",
)
}
#[cfg(target_os = "macos")]
{
(
ConfinementLevel::KernelEnforced,
"macOS: sandbox-exec profiles enforce filesystem write-denial confinement.",
)
}
#[cfg(target_os = "windows")]
{
(
ConfinementLevel::ResourceLimitsOnly,
"Windows: Job Objects enforce process/memory limits. \
Filesystem confinement uses application-layer path validation \
(script_allowed_paths). For kernel-level FS sandboxing, run inside \
Windows Sandbox or use AppContainer.",
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
(
ConfinementLevel::None,
"Unsupported platform: no filesystem confinement available. \
Scripts run with the full privileges of the process user.",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_for_skill_execution() {
let config = LandlockConfig::for_skill_execution(
std::path::Path::new("/workspace"),
std::path::Path::new("/skills"),
);
assert_eq!(config.read_paths.len(), 2);
assert_eq!(config.write_paths.len(), 1);
}
#[test]
fn apply_landlock_returns_ok() {
let config = LandlockConfig::for_skill_execution(
std::path::Path::new("/tmp/ws"),
std::path::Path::new("/tmp/skills"),
);
let result = apply_landlock(&config);
assert!(result.is_ok());
}
#[test]
fn config_for_script_runner_basic() {
let config = LandlockConfig::for_script_runner(
std::path::Path::new("/skills"),
Some(std::path::Path::new("/workspace")),
&[std::path::PathBuf::from("/extra/read")],
);
assert_eq!(config.read_paths.len(), 3);
assert!(
config
.read_paths
.contains(&std::path::PathBuf::from("/skills"))
);
assert!(
config
.read_paths
.contains(&std::path::PathBuf::from("/extra/read"))
);
assert!(
config
.read_paths
.contains(&std::path::PathBuf::from("/workspace"))
);
assert_eq!(config.write_paths.len(), 2);
assert!(
config
.write_paths
.contains(&std::path::PathBuf::from("/tmp"))
);
assert!(
config
.write_paths
.contains(&std::path::PathBuf::from("/workspace"))
);
}
#[test]
fn config_for_script_runner_no_workspace() {
let config = LandlockConfig::for_script_runner(std::path::Path::new("/skills"), None, &[]);
assert_eq!(config.read_paths.len(), 1); assert_eq!(config.write_paths.len(), 1); }
#[test]
fn create_landlock_fd_returns_ok() {
let config = LandlockConfig::for_script_runner(std::path::Path::new("/tmp"), None, &[]);
let result = create_landlock_fd(&config);
assert!(result.is_ok());
#[cfg(not(target_os = "linux"))]
assert!(result.unwrap().is_none());
#[cfg(target_os = "linux")]
if let Ok(Some(fd)) = result {
unsafe {
libc::close(fd);
}
}
}
#[test]
fn platform_confinement_status_returns_valid() {
let (level, message) = platform_confinement_status();
assert!(!message.is_empty());
#[cfg(target_os = "linux")]
assert_eq!(level, ConfinementLevel::KernelEnforced);
#[cfg(target_os = "macos")]
assert_eq!(level, ConfinementLevel::KernelEnforced);
#[cfg(target_os = "windows")]
assert_eq!(level, ConfinementLevel::ResourceLimitsOnly);
}
#[cfg(target_os = "windows")]
#[test]
fn job_object_config_defaults() {
let config = JobObjectConfig::for_skill_execution();
assert_eq!(config.max_active_processes, 4);
assert!(config.process_memory_limit > 0);
assert!(config.job_memory_limit > 0);
assert!(config.kill_on_close);
}
#[cfg(target_os = "windows")]
#[test]
fn job_object_creation_succeeds() {
let config = JobObjectConfig::for_skill_execution();
let result = create_job_object(&config);
assert!(result.is_ok());
if let Ok(Some(handle)) = result {
close_job_handle(handle);
}
}
#[test]
fn confinement_level_debug_and_clone() {
let level = ConfinementLevel::KernelEnforced;
let cloned = level.clone();
assert_eq!(level, cloned);
let debug = format!("{:?}", level);
assert!(debug.contains("KernelEnforced"));
}
#[test]
fn confinement_level_variants_are_distinct() {
assert_ne!(ConfinementLevel::KernelEnforced, ConfinementLevel::None);
assert_ne!(
ConfinementLevel::KernelEnforced,
ConfinementLevel::ResourceLimitsOnly
);
assert_ne!(ConfinementLevel::ResourceLimitsOnly, ConfinementLevel::None);
}
#[test]
fn landlock_config_debug() {
let config = LandlockConfig {
read_paths: vec![std::path::PathBuf::from("/a")],
write_paths: vec![std::path::PathBuf::from("/b")],
};
let debug = format!("{:?}", config);
assert!(debug.contains("/a"));
assert!(debug.contains("/b"));
}
#[test]
fn config_for_skill_execution_includes_workspace_in_both() {
let config = LandlockConfig::for_skill_execution(
std::path::Path::new("/workspace"),
std::path::Path::new("/skills"),
);
assert!(
config
.read_paths
.contains(&std::path::PathBuf::from("/workspace"))
);
assert!(
config
.write_paths
.contains(&std::path::PathBuf::from("/workspace"))
);
assert!(
config
.read_paths
.contains(&std::path::PathBuf::from("/skills"))
);
assert!(
!config
.write_paths
.contains(&std::path::PathBuf::from("/skills"))
);
}
#[test]
fn config_for_script_runner_includes_tmp_in_write() {
let config = LandlockConfig::for_script_runner(std::path::Path::new("/skills"), None, &[]);
assert!(
config
.write_paths
.contains(&std::path::PathBuf::from("/tmp"))
);
}
#[test]
fn config_for_script_runner_multiple_extra_paths() {
let extras = vec![
std::path::PathBuf::from("/extra1"),
std::path::PathBuf::from("/extra2"),
std::path::PathBuf::from("/extra3"),
];
let config = LandlockConfig::for_script_runner(
std::path::Path::new("/skills"),
Some(std::path::Path::new("/ws")),
&extras,
);
assert_eq!(config.read_paths.len(), 5);
for extra in &extras {
assert!(config.read_paths.contains(extra));
}
}
#[test]
fn apply_landlock_non_linux_returns_false() {
#[cfg(not(target_os = "linux"))]
{
let config = LandlockConfig {
read_paths: vec![],
write_paths: vec![],
};
let result = apply_landlock(&config).unwrap();
assert!(!result);
}
}
#[test]
fn create_landlock_fd_non_linux_returns_none() {
#[cfg(not(target_os = "linux"))]
{
let config = LandlockConfig {
read_paths: vec![],
write_paths: vec![],
};
let result = create_landlock_fd(&config).unwrap();
assert!(result.is_none());
}
}
#[test]
fn platform_confinement_message_is_nonempty() {
let (_, message) = platform_confinement_status();
assert!(message.len() > 20, "expected descriptive message");
}
}