Add Landlock sandbox for code execution

- sandbox.rs: Landlock filesystem restrictions applied via pre_exec
- Library paths (/lib, /usr/lib) need execute permission for dynamic linker
- 30s timeout with SIGKILL on expiry
- Network access allowed (Landlock only restricts filesystem)
- Minimal env: PATH, HOME=/tmp, LANG, TERM
This commit is contained in:
Levi Neely 2026-10-01 11:38:43 +02:00
parent 445177bbfa
commit 17f18b044c
5 changed files with 279 additions and 30 deletions

33
Cargo.lock generated
View File

@ -210,6 +210,26 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "errno"
version = "0.3.14"
@ -377,6 +397,17 @@ dependencies = [
"libc",
]
[[package]]
name = "landlock"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
dependencies = [
"enumflags2",
"libc",
"thiserror",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -552,6 +583,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
"landlock",
"libc",
"nine",
"notify",
"org-ast",

View File

@ -31,6 +31,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# File watching
notify = "6"
# Sandboxing
landlock = "0.4"
libc = "0.2"
# Utilities
parking_lot = "0.12"
uuid = { version = "1", features = ["v4"] }

View File

@ -6,6 +6,7 @@ pub mod virtfs;
pub mod state;
pub mod namespace;
pub mod p9;
pub mod sandbox;
pub use state::OrkState;
pub use p9::Server;

View File

@ -0,0 +1,235 @@
//! Sandboxed code execution for org-babel blocks.
//!
//! Uses Landlock (Linux 5.13+) to restrict filesystem access in child processes.
//! Network access is allowed by default.
use std::io::{self, Read};
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::Duration;
use landlock::{Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, ABI};
/// Result of executing a code block.
pub struct ExecResult {
pub status: i32,
pub stdout: String,
pub stderr: String,
}
/// Configuration for sandboxed execution.
pub struct SandboxConfig {
/// Paths with read-only access.
pub ro_paths: Vec<String>,
/// Paths with read-execute access.
pub rox_paths: Vec<String>,
/// Paths with read-write access.
pub rw_paths: Vec<String>,
/// Execution timeout in seconds.
pub timeout_secs: u64,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
ro_paths: vec![
"/etc".into(),
"/proc".into(),
"/sys".into(),
],
rox_paths: vec![
// Binaries
"/bin".into(),
"/usr/bin".into(),
"/usr/local/bin".into(),
// Libraries need execute for dynamic linker mmap
"/lib".into(),
"/lib64".into(),
"/usr/lib".into(),
"/usr/lib64".into(),
"/usr/share".into(),
],
rw_paths: vec![
"/tmp".into(),
"/var/tmp".into(),
"/dev/null".into(),
"/dev/zero".into(),
"/dev/urandom".into(),
],
timeout_secs: 30,
}
}
}
/// Execute code in a sandboxed environment.
pub fn execute_sandboxed(
cmd: &str,
args: &[&str],
_code: &str,
config: &SandboxConfig,
) -> io::Result<ExecResult> {
// Build the command
let mut command = Command::new(cmd);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("HOME", "/tmp")
.env("LANG", "C.UTF-8")
.env("TERM", "dumb");
// Apply Landlock in the child process before exec
let ro_paths = config.ro_paths.clone();
let rox_paths = config.rox_paths.clone();
let rw_paths = config.rw_paths.clone();
unsafe {
command.pre_exec(move || {
// Apply Landlock filesystem restrictions
if let Err(e) = apply_landlock(&ro_paths, &rox_paths, &rw_paths) {
eprintln!("Landlock error: {}", e);
// Don't fail the spawn, just log
}
Ok(())
});
}
// Spawn the process
let mut child = command.spawn().map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("spawn failed: {}", e))
})?;
// Set up timeout
let timeout = Duration::from_secs(config.timeout_secs);
let pid = child.id();
// Spawn timeout thread
let timeout_handle = std::thread::spawn(move || {
std::thread::sleep(timeout);
// Kill the process if still running
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
});
// Wait for the child
let status = child.wait()?;
// Read stdout and stderr
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut out) = child.stdout.take() {
out.read_to_end(&mut stdout)?;
}
if let Some(mut err) = child.stderr.take() {
err.read_to_end(&mut stderr)?;
}
// Timeout thread will exit when process terminates (or kill it if timeout)
drop(timeout_handle);
Ok(ExecResult {
status: status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr: String::from_utf8_lossy(&stderr).to_string(),
})
}
/// Apply Landlock restrictions to the current process.
/// Only restricts filesystem access; network access is allowed.
fn apply_landlock(
ro_paths: &[String],
rox_paths: &[String],
rw_paths: &[String],
) -> io::Result<()> {
// Try to detect ABI version by creating a minimal ruleset
let abi = match Ruleset::default()
.handle_access(AccessFs::Execute)
.map_err(|_| ())
.and_then(|r| r.create().map_err(|_| ()))
{
Ok(_) => ABI::V1, // At least V1 is available
Err(_) => {
// Landlock not available — continue without sandbox
return Ok(());
}
};
// Define access rights
let read_access = AccessFs::ReadFile | AccessFs::ReadDir;
let exec_access = AccessFs::Execute;
let write_access = AccessFs::WriteFile
| AccessFs::RemoveFile
| AccessFs::RemoveDir
| AccessFs::MakeReg
| AccessFs::MakeDir
| AccessFs::MakeSym;
// Collect all rules
let mut rules: Vec<PathBeneath<PathFd>> = Vec::new();
// Read-only paths
for path in ro_paths {
if let Ok(fd) = PathFd::new(path) {
rules.push(PathBeneath::new(fd, read_access));
}
}
// Read-execute paths
for path in rox_paths {
if let Ok(fd) = PathFd::new(path) {
rules.push(PathBeneath::new(fd, read_access | exec_access));
}
}
// Read-write paths
for path in rw_paths {
if let Ok(fd) = PathFd::new(path) {
rules.push(PathBeneath::new(fd, read_access | write_access));
}
}
// Create ruleset and add all rules at once
let ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ruleset handle: {}", e)))?
.create()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ruleset create: {}", e)))?;
// Add rules one by one, chaining the result
let ruleset = rules.into_iter().fold(Ok(ruleset), |r, rule| {
r.and_then(|rs| rs.add_rule(rule).map_err(|e|
io::Error::new(io::ErrorKind::Other, format!("add rule: {}", e))))
})?;
ruleset
.restrict_self()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("restrict: {}", e)))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_execute_bash() {
let config = SandboxConfig::default();
let result = execute_sandboxed("bash", &["-c", "echo hello"], "", &config).unwrap();
assert_eq!(result.status, 0);
assert_eq!(result.stdout.trim(), "hello");
}
#[test]
fn test_execute_python() {
let config = SandboxConfig::default();
let result = execute_sandboxed("python3", &["-c", "print(2+2)"], "", &config).unwrap();
assert_eq!(result.status, 0);
assert_eq!(result.stdout.trim(), "4");
}
}

View File

@ -7,9 +7,10 @@ use std::collections::HashMap;
use std::fs;
use std::io::{self, Result};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use crate::sandbox::{self, SandboxConfig};
/// Format a timestamp for display.
fn format_timestamp(ts: &Timestamp) -> String {
match &ts.value {
@ -392,13 +393,6 @@ impl OrkState {
}
}
/// Result of executing a source block.
struct ExecResult {
status: i32,
stdout: String,
stderr: String,
}
/// Indent each line of text with a prefix.
fn indent_lines(text: &str, prefix: &str) -> String {
if text.is_empty() {
@ -563,7 +557,7 @@ impl OrkState {
}
/// Execute a source block and return the result.
fn execute_block(&self, block: &SourceBlock) -> Result<ExecResult> {
fn execute_block(&self, block: &SourceBlock) -> Result<sandbox::ExecResult> {
let lang = block.language.as_deref().unwrap_or("sh");
let (cmd, args): (&str, Vec<&str>) = match lang {
@ -578,27 +572,9 @@ impl OrkState {
)),
};
// Execute with timeout
let output = Command::new(cmd)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("LANG", "C.UTF-8")
.output()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("failed to execute {}: {}", cmd, e),
))?;
Ok(ExecResult {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
// Execute in sandbox with Landlock + timeout
let config = SandboxConfig::default();
sandbox::execute_sandboxed(cmd, &args, &block.contents, &config)
}
}