Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bb1fbbc
profile: derive Serialize on all profile section types
ghazariann Jun 23, 2026
7248424
seccomp: add audit hooks for file access and network connect
ghazariann Jun 23, 2026
4cfc460
cli: add sandlock learn subcommand
ghazariann Jun 23, 2026
62514ce
profile: add to_toml() method, remove toml dep from sandlock-cli
ghazariann Jun 23, 2026
eab8c1b
core: extend file hook to execve, fix sendto args, add sendmsg
ghazariann Jun 23, 2026
1ee9176
learn: COW observation, parent-dir write collapsing, execve binary ca…
ghazariann Jun 23, 2026
8564a37
tests: add learn round-trip tests for fs read, write, and network
ghazariann Jun 23, 2026
cb3aebc
core: fix on_net_connect doc, shorten comment
ghazariann Jun 23, 2026
7321099
cli: fix learn header, remove minimal from subcommand description
ghazariann Jun 23, 2026
16db5d5
core: fix missing audit fields in NotifPolicy test fixtures
ghazariann Jun 23, 2026
e6f6268
core: add on_execve audit hook for execve/execveat
ghazariann Jun 26, 2026
a342bbf
learn: use on_execve hook for binary capture
ghazariann Jun 26, 2026
319deb1
core: read dynamic linker from /proc/pid/maps after execve
ghazariann Jun 26, 2026
e14ce29
learn: remove ELF PT_INTERP parser, linker now captured via maps
ghazariann Jun 26, 2026
9983f75
profile: skip default fields/sections in TOML serialization
ghazariann Jul 3, 2026
050de2d
learn: resource peaks via create/start/wait, populate [program].exec
ghazariann Jul 3, 2026
7ded9fa
tests: assert resource limits populated by sandlock learn
ghazariann Jul 3, 2026
3456857
learn: replace on_file_access/on_execve/on_net_connect hooks with pol…
ghazariann Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/sandlock-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
jiff = "0.2"
libc = "0.2"
tempfile = "3"

[dev-dependencies]
tempfile = "3"
260 changes: 260 additions & 0 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
//! Implementation of `sandlock learn -o <output.toml>`.
//!
//! Runs a workload under observation and emits a sandlock profile TOML
//! usable by `sandlock run -p`.

use std::collections::{BTreeSet, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};

use anyhow::{anyhow, Result};
use sandlock_core::policy_fn::{SyscallEvent, Verdict};
use sandlock_core::profile::{FilesystemSection, ProfileInput};
use sandlock_core::Sandbox;

use crate::LearnArgs;

// openat flags (from fcntl.h)
const O_WRONLY: u64 = 0o1;
const O_RDWR: u64 = 0o2;
const O_CREAT: u64 = 0o100;

fn is_write_open(flags: u64) -> bool {
flags & (O_WRONLY | O_RDWR | O_CREAT) != 0
}

/// Read the dynamic linker path from `/proc/<pid>/maps`. The kernel loads it
/// during execve (bypassing seccomp), so this is the way to discover it
/// after the execve completes and `/proc/<pid>/maps` reflects the new binary.
fn read_linker_from_maps(pid: u32) -> Option<PathBuf> {
use std::io::BufRead;
let file = std::fs::File::open(format!("/proc/{pid}/maps")).ok()?;
for line in std::io::BufReader::new(file).lines() {
let line = line.ok()?;
// Format: "addr-addr perms offset dev inode pathname"
let pathname = line.splitn(6, ' ').nth(5).map(str::trim).unwrap_or("");
if !pathname.is_empty() && !pathname.starts_with('[') {
let p = std::path::Path::new(pathname);
if p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("ld-"))
.unwrap_or(false)
{
return Some(p.to_path_buf());
}
}
}
None
}

/// Accumulated observations from the policy_fn callback during learn.
#[derive(Clone)]
struct LearnObserver {
reads: Arc<Mutex<BTreeSet<PathBuf>>>,
writes: Arc<Mutex<BTreeSet<PathBuf>>>,
connects: Arc<Mutex<BTreeSet<String>>>,
/// PIDs that just completed an execve — on the NEXT event from that PID,
/// /proc/<pid>/maps will reflect the new binary's dynamic linker.
pending_maps: Arc<Mutex<HashSet<u32>>>,
}

impl LearnObserver {
fn new() -> Self {
Self {
reads: Arc::new(Mutex::new(BTreeSet::new())),
writes: Arc::new(Mutex::new(BTreeSet::new())),
connects: Arc::new(Mutex::new(BTreeSet::new())),
pending_maps: Arc::new(Mutex::new(HashSet::new())),
}
}

/// The policy_fn callback: classifies each intercepted syscall into
/// reads, writes, or connects for profile generation.
fn on_event(&self, event: SyscallEvent) -> Verdict {
// After an execve, the NEXT event from that PID fires once the
// new binary is running — /proc/<pid>/maps now shows the dynamic
// linker loaded by the kernel (which bypassed seccomp).
if self.pending_maps.lock().unwrap().remove(&event.pid) {
if let Some(linker) = read_linker_from_maps(event.pid) {
self.reads.lock().unwrap().insert(linker);
}
}

match event.syscall.as_str() {
"openat" | "open" => {
if let Some(path) = event.path {
if let Some(fl) = event.flags {
if is_write_open(fl) {
self.writes.lock().unwrap().insert(path);
} else {
self.reads.lock().unwrap().insert(path);
}
}
}
}
"execve" | "execveat" => {
if let Some(path) = event.path {
self.reads.lock().unwrap().insert(path);
}
// Mark PID: read maps on next event after execve completes.
self.pending_maps.lock().unwrap().insert(event.pid);
}
"connect" | "sendto" => {
if let (Some(ip), Some(port)) = (event.host, event.port) {
self.connects.lock().unwrap().insert(format!("tcp://{ip}:{port}"));
}
}
_ => {}
}
Verdict::Allow
}
}


pub async fn run(args: LearnArgs) -> Result<()> {
if args.cmd.is_empty() {
anyhow::bail!("no command given — use: sandlock learn [flags] -- <cmd> [args...]");
}

let cmd_str = args.cmd.join(" ");
let cmd_refs: Vec<&str> = args.cmd.iter().map(String::as_str).collect();

// Fully permissive Landlock so nothing is blocked during observation.
// workdir (COW overlay) lets writes go anywhere without touching the real filesystem.
let cow_dir = tempfile::Builder::new()
.prefix("sandlock-learn-")
.tempdir_in("/var/tmp")
.map_err(|e| anyhow!("failed to create COW tempdir: {e}"))?;

let observer = LearnObserver::new();
let observer_cb = observer.clone();
let policy = Sandbox::builder()
.fs_read("/")
.workdir(cow_dir.path())
.policy_fn(move |event, _ctx| observer_cb.on_event(event))
.build()
.map_err(|e| anyhow!("failed to build sandbox policy: {e}"))?;

eprintln!("sandlock learn: observing {cmd_str} ...");

// Use the three-step lifecycle (create/start/wait) so we can get the child
// PID from sandbox.pid() and sample /proc/<pid> for resource peaks.
let mut sandbox = policy.with_name("sandlock-learn");
sandbox.create_interactive(&cmd_refs).await
.map_err(|e| anyhow!("sandbox error: {e}"))?;
let child_pid = sandbox.pid().expect("child pid after create") as u32;
sandbox.start()
.map_err(|e| anyhow!("sandbox error: {e}"))?;

// Resource peak sampler: polls /proc/<pid> every 100ms until the process exits.
let max_threads = Arc::new(AtomicU64::new(0));
let max_fds = Arc::new(AtomicU64::new(0));
let peak_rss_kb_atomic = Arc::new(AtomicU64::new(0));
let (max_threads_s, max_fds_s, peak_rss_s) = (
Arc::clone(&max_threads), Arc::clone(&max_fds), Arc::clone(&peak_rss_kb_atomic),
);
let sampler = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
match std::fs::read_to_string(format!("/proc/{child_pid}/status")) {
Err(_) => break, // process gone
Ok(s) => {
for line in s.lines() {
if let Some(v) = line.strip_prefix("Threads:") {
if let Ok(n) = v.trim().parse::<u64>() {
max_threads_s.fetch_max(n, Ordering::Relaxed);
}
}
if let Some(v) = line.strip_prefix("VmHWM:") {
if let Ok(n) = v.trim().trim_end_matches("kB").trim().parse::<u64>() {
peak_rss_s.fetch_max(n, Ordering::Relaxed);
}
}
}
}
}
if let Ok(entries) = std::fs::read_dir(format!("/proc/{child_pid}/fd")) {
max_fds_s.fetch_max(entries.count() as u64, Ordering::Relaxed);
}
}
});

let result = sandbox.wait().await
.map_err(|e| anyhow!("sandbox error: {e}"))?;

sampler.abort();

eprintln!("sandlock learn: done (exit={:?})", result.code());

let peak_rss_kb = peak_rss_kb_atomic.load(Ordering::Relaxed);
let threads = max_threads.load(Ordering::Relaxed);
let fds = max_fds.load(Ordering::Relaxed);

// Build the profile.
let mut profile_out = ProfileInput::default();

// Record the observed command so `sandlock run -p profile.toml` works
// without repeating the command on the CLI.
profile_out.program.exec = Some(PathBuf::from(&args.cmd[0]));
profile_out.program.args = args.cmd[1..].to_vec();

let cow_path = cow_dir.path().to_path_buf();
profile_out.filesystem = FilesystemSection {
// Filter reads by existence to drop failed PATH-probe openats.
// Executed binaries are merged into read.
read: observer.reads.lock().unwrap().iter()
.filter(|p| p.exists() && !p.starts_with(&cow_path))
.cloned()
.collect(),
// For writes: if the file exists, record the specific path (existing file modified).
// If it doesn't exist on the real FS (COW intercepted a create), record the parent
// directory instead, Landlock requires the path to exist, and the program needs
// write access to the directory to create new files inside it.
write: observer.writes.lock().unwrap().iter()
.filter(|p| !p.starts_with(&cow_path))
.filter_map(|p| {
if p.exists() {
Some(p.clone())
} else {
p.parent().filter(|d| d.exists()).map(|d| d.to_path_buf())
}
})
.collect(),
..Default::default()
};
profile_out.network.allow = observer.connects.lock().unwrap().iter().cloned().collect();

// Fill limits with observed peaks + headroom so the profile is usable with sandlock run.
if peak_rss_kb > 0 {
let mib = (peak_rss_kb + 1023) / 1024; // ceil to MiB
let headroom = (mib * 5 / 4).max(16); // +25%, min 16M
profile_out.limits.memory = Some(format!("{headroom}M"));
}
if threads > 0 {
profile_out.limits.processes = Some((threads * 2).max(4) as u32);
}
if fds > 0 {
profile_out.limits.open_files = Some((fds * 2).max(32) as u32);
}

let header = format!(
"# generated by sandlock learn\n\
# command: {}\n\n",
cmd_str.replace('\n', " ")
);
let body = profile_out.to_toml()
.map_err(|e| anyhow!("failed to serialize profile: {e}"))?;
let toml_out = format!("{header}{body}");

match args.output {
Some(ref path) => {
std::fs::write(path, &toml_out)
.map_err(|e| anyhow!("failed to write {}: {e}", path.display()))?;
eprintln!("sandlock learn: profile written to {}", path.display());
}
None => print!("{toml_out}"),
}

Ok(())
}
19 changes: 19 additions & 0 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::path::PathBuf;
use std::time::SystemTime;

mod network_registry;
mod learn;

#[derive(Parser)]
#[command(name = "sandlock", about = "Lightweight process sandbox", version)]
Expand All @@ -33,6 +34,8 @@ enum Command {
#[command(subcommand)]
action: ProfileAction,
},
/// Observe a workload and emit a sandlock profile
Learn(LearnArgs),
}

/// Arguments for the `run` subcommand.
Expand Down Expand Up @@ -200,6 +203,18 @@ enum ProfileAction {
Delete { name: String },
}

/// Arguments for the `learn` subcommand.
#[derive(clap::Args)]
struct LearnArgs {
/// Write observed profile to this file (default: print to stdout)
#[arg(short = 'o', long, value_name = "PATH")]
output: Option<PathBuf>,

/// Command to observe (everything after --)
#[arg(last = true, required = true)]
cmd: Vec<String>,
}

#[derive(serde::Serialize)]
struct SandboxStatus {
exit_code: i32,
Expand Down Expand Up @@ -303,6 +318,10 @@ async fn main() -> Result<()> {
println!(" Platform: {}", std::env::consts::ARCH);
}

Command::Learn(args) => {
learn::run(args).await?;
}

Command::Profile { action } => {
match action {
ProfileAction::List => {
Expand Down
Loading
Loading