Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
228 changes: 228 additions & 0 deletions crates/sandlock-cli/src/events.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//! Versioned supervisor observations, never a claim of complete syscall audit.

use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use sandlock_core::policy_fn::{SyscallCategory, SyscallEvent};
use sandlock_core::{Change, Entry};
use serde_json::{json, Value};

const MAX_BYTES: u64 = 64 * 1024 * 1024;

struct State {
file: File,
sequence: u64,
bytes: u64,
failed: bool,
}

#[derive(Clone)]
pub(crate) struct Events(Arc<Mutex<State>>);

impl Events {
/// Create a new regular file only, outside every writable or mounted tree.
pub(crate) fn create(path: &Path, grants: &[PathBuf]) -> io::Result<(Self, PathBuf)> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let path = parent.canonicalize()?.join(path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"events path requires a file name",
)
})?);
for grant in grants {
if path.starts_with(grant.canonicalize()?) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"events file must be outside sandbox write/mount/workdir grants",
));
}
}
let file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&path)?;
Ok((
Self(Arc::new(Mutex::new(State {
file,
sequence: 0,
bytes: 0,
failed: false,
}))),
path,
))
}

pub(crate) fn emit(&self, kind: &str, detail: Value) -> io::Result<()> {
let mut state = self
.0
.lock()
.map_err(|_| io::Error::other("events lock poisoned"))?;
if state.failed {
return Err(io::Error::other("events stream already failed"));
}
state.sequence += 1;
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let mut line = serde_json::to_vec(&json!({
"schema_version": 1, "sequence": state.sequence, "ts_unix_ms": ts,
"source": "sandlock-cli", "type": kind, "detail": detail,
}))?;
line.push(b'\n');
if state.bytes + line.len() as u64 > MAX_BYTES {
state.failed = true;
return Err(io::Error::other("events stream exceeded 64 MiB"));
}
if let Err(e) = state.file.write_all(&line) {
state.failed = true;
return Err(e);
}
state.bytes += line.len() as u64;
Ok(())
}

pub(crate) fn syscall(&self, event: SyscallEvent) -> io::Result<()> {
if event
.path
.iter()
.chain(event.path2.iter())
.any(|p| p.to_str().is_none())
{
if let Ok(mut state) = self.0.lock() {
state.failed = true;
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"non-UTF-8 event path",
));
}
let category = match event.category {
SyscallCategory::File => "file",
SyscallCategory::Network => "network",
SyscallCategory::Process => "process",
SyscallCategory::Memory => "memory",
};
self.emit(
"syscall",
json!({
"syscall": event.syscall, "category": category, "pid": event.pid,
"parent_pid": event.parent_pid, "host": event.host, "port": event.port,
"protocol": event.protocol, "fd": event.fd, "size": event.size,
"path": event.path, "path2": event.path2, "flags": event.flags,
"supervisor_denied": event.denied,
"kernel_outcome": "not_observed", "path_is_observation_only": true,
"argv_omitted": true,
}),
)
}

pub(crate) fn changes(&self, changes: &[Change], dry_run: bool) -> io::Result<()> {
for change in changes {
let path = change.path.to_str().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 change path")
})?;
self.emit("change", json!({
"path": path, "kind": change.kind().to_string(),
"before": change.before.as_ref().map(entry), "after": change.after.as_ref().map(entry),
"dry_run": dry_run, "phase": "cow_before_branch_action",
}))?;
}
Ok(())
}

pub(crate) fn sync(&self) -> io::Result<()> {
let state = self
.0
.lock()
.map_err(|_| io::Error::other("events lock poisoned"))?;
if state.failed {
return Err(io::Error::other(
"events incomplete: write failed or size limit exceeded",
));
}
state.file.sync_all()
}
}

fn entry(entry: &Entry) -> Value {
// No bytes, symlink target or digest: avoid copying data into audit by default.
json!({"kind": format!("{:?}", entry.kind).to_lowercase(), "mode": entry.mode, "size": entry.size})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn protected_file_is_exclusive_and_rejects_writable_parent() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("events.jsonl");
assert!(Events::create(&path, &[root.path().to_owned()]).is_err());
let (events, _) = Events::create(&path, &[]).unwrap();
events.emit("start", json!({})).unwrap();
events
.emit("finish", json!({"status":"succeeded"}))
.unwrap();
events.sync().unwrap();
assert!(Events::create(&path, &[]).is_err());
let text = std::fs::read_to_string(&path).unwrap();
let rows: Vec<Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(rows[0]["sequence"], 1);
assert_eq!(rows[1]["sequence"], 2);
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}

#[test]
fn rejects_symlink_and_marks_write_limit_failure() {
let root = tempfile::tempdir().unwrap();
let link = root.path().join("link");
std::os::unix::fs::symlink(root.path().join("target"), &link).unwrap();
assert!(Events::create(&link, &[]).is_err());
let (events, _) = Events::create(&root.path().join("log"), &[]).unwrap();
events.0.lock().unwrap().bytes = MAX_BYTES;
assert!(events.emit("syscall", json!({})).is_err());
assert!(events.sync().is_err());
}

#[test]
fn non_utf8_observation_fails_without_panicking_or_claiming_complete() {
use std::os::unix::ffi::OsStringExt;
let root = tempfile::tempdir().unwrap();
let (events, _) = Events::create(&root.path().join("log"), &[]).unwrap();
let event = SyscallEvent {
syscall: "openat".into(),
category: SyscallCategory::File,
pid: 1,
parent_pid: None,
host: None,
port: None,
size: None,
argv: None,
denied: false,
path: Some(std::ffi::OsString::from_vec(vec![255]).into()),
path2: None,
flags: None,
protocol: None,
fd: None,
};
assert!(events.syscall(event).is_err());
assert!(events.sync().is_err());
}
}
77 changes: 75 additions & 2 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 learn;
mod events;
#[derive(Parser)]
#[command(name = "sandlock", about = "Lightweight process sandbox", version)]
struct Cli {
Expand Down Expand Up @@ -133,6 +134,10 @@ struct RunArgs {
#[arg(long = "status-fd", value_name = "FD")]
status_fd: Option<i32>,

/// Write versioned supervisor observations to a new, private JSONL file
#[arg(long, value_name = "PATH")]
events_jsonl: Option<PathBuf>,

/// Sandbox name (also exposed as the virtual hostname; auto-generated if omitted)
#[arg(long)]
name: Option<String>,
Expand All @@ -154,6 +159,10 @@ struct RunArgs {
#[arg(long)]
no_supervisor: bool,

/// Do not add the minimal standard character-device grants
#[arg(long)]
no_default_devices: bool,

/// Allow the named protection to degrade silently if the host kernel ABI lacks support.
/// Repeatable. Accepted values: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev,
/// signal-scope, abstract-unix-socket-scope.
Expand Down Expand Up @@ -690,6 +699,29 @@ async fn run_command(args: RunArgs) -> Result<i32> {
builder = builder.disable(parse_protection(s).map_err(|e| anyhow!(e))?);
}

if !args.no_default_devices {
builder = builder.standard_devices()?;
}
let events = if let Some(ref path) = args.events_jsonl {
let mut grants = builder.fs_writable.clone();
grants.extend(builder.workdir.iter().cloned());
grants.extend(builder.chroot.iter().cloned());
grants.extend(builder.fs_mount.iter().map(|(_, host)| host.clone()));
let (sink, path) = events::Events::create(path, &grants)?;
let callback = sink.clone();
builder = builder.fs_deny(path).policy_fn(move |event, _ctx| {
// Never relax the static policy. On logging failure held operations
// fail closed; observation-only operations cannot be recalled.
if callback.syscall(event).is_ok() {
sandlock_core::policy_fn::Verdict::Allow
} else {
sandlock_core::policy_fn::Verdict::Deny
}
});
Some(sink)
} else {
None
};
let policy = builder.build()?;
let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = args.exec_shell {
vec!["/bin/sh", "-c", shell_cmd.as_str()]
Expand Down Expand Up @@ -720,20 +752,60 @@ async fn run_command(args: RunArgs) -> Result<i32> {
policy.on_error = BranchAction::Abort;
}

if let Some(ref sink) = events {
sink.emit("start", serde_json::json!({
"supervisor_pid": std::process::id(), "argv_omitted": true,
"dry_run": args.dry_run, "timeout_seconds": args.timeout,
"coverage": "policy_fn observations, not all kernel outcomes",
}))?;
}
let result = if let Some(secs) = args.timeout {
match tokio::time::timeout(
std::time::Duration::from_secs(secs),
policy.run_interactive(&cmd_strs),
).await {
Ok(r) => r?,
Ok(r) => r,
Err(_) => {
eprintln!("sandlock: timeout after {}s", secs);
drop(policy);
if let Some(ref sink) = events {
sink.emit("finish", serde_json::json!({
"status": "timed_out", "exit_code": 124, "changes_available": false,
}))?;
sink.sync()?;
}
return Ok(124);
}
}
} else {
policy.run_interactive(&cmd_strs).await?
policy.run_interactive(&cmd_strs).await
};
let result = match result {
Ok(result) => result,
Err(error) => {
drop(policy);
if let Some(ref sink) = events {
sink.emit("finish", serde_json::json!({
"status": "runtime_error", "changes_available": false,
}))?;
sink.sync()?;
}
return Err(error.into());
}
};
// Drain observation callbacks and finalize the existing branch lifecycle
// before emitting a terminal event. Changes still describe the pre-action
// COW snapshot, not an independently verified commit receipt.
drop(policy);
if let Some(ref sink) = events {
sink.changes(&result.changes, args.dry_run)?;
sink.emit("finish", serde_json::json!({
"status": if result.success() { "succeeded" } else { "failed" },
"exit_code": result.code(), "exit_status": format!("{:?}", result.exit_status),
"changes_available": true, "change_count": result.changes.len(),
}))?;
sink.sync()?;
}

if args.dry_run {
if result.changes.is_empty() {
Expand Down Expand Up @@ -818,6 +890,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> {
if args.gpu.is_some() { bad.push("--gpu"); }
if args.dry_run { bad.push("--dry-run"); }
if args.status_fd.is_some() { bad.push("--status-fd"); }
if args.events_jsonl.is_some() { bad.push("--events-jsonl"); }
if !pb.fs_denied.is_empty() { bad.push("--fs-deny"); }
if !args.fs_mount.is_empty() { bad.push("--fs-mount"); }

Expand Down
Loading
Loading