diff --git a/crates/sandlock-cli/src/events.rs b/crates/sandlock-cli/src/events.rs new file mode 100644 index 00000000..803350ea --- /dev/null +++ b/crates/sandlock-cli/src/events.rs @@ -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>); + +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 = 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()); + } +} diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 781abbe1..02da4cf2 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -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 { @@ -133,6 +134,10 @@ struct RunArgs { #[arg(long = "status-fd", value_name = "FD")] status_fd: Option, + /// Write versioned supervisor observations to a new, private JSONL file + #[arg(long, value_name = "PATH")] + events_jsonl: Option, + /// Sandbox name (also exposed as the virtual hostname; auto-generated if omitted) #[arg(long)] name: Option, @@ -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. @@ -690,6 +699,29 @@ async fn run_command(args: RunArgs) -> Result { 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()] @@ -720,20 +752,60 @@ async fn run_command(args: RunArgs) -> Result { 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() { @@ -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"); } diff --git a/crates/sandlock-cli/tests/runtime_events.rs b/crates/sandlock-cli/tests/runtime_events.rs new file mode 100644 index 00000000..92c9ae77 --- /dev/null +++ b/crates/sandlock-cli/tests/runtime_events.rs @@ -0,0 +1,234 @@ +use serde_json::Value; +use std::path::Path; +use std::process::{Command, Output}; + +fn run(extra: &[&str], command: &[&str]) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sandlock")); + cmd.args(["run", "--timeout", "10"]); + for path in ["/usr", "/bin", "/lib", "/lib64", "/etc"] { + if Path::new(path).exists() { + cmd.args(["-r", path]); + } + } + cmd.args(extra).arg("--").args(command).output().unwrap() +} + +fn rows(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +#[test] +fn default_devices_support_shell_without_granting_all_dev() { + let out = run( + &[], + &[ + "python3", + "-c", + r#" +import os +with open('/dev/null','wb') as f: f.write(b'hello') +for p in ('/dev/zero','/dev/random','/dev/urandom'): + with open(p,'rb') as f: assert len(f.read(1)) == 1 +for p in ('/dev/full','/dev/zero','/dev/random','/dev/urandom'): + try: fd=os.open(p,os.O_WRONLY) + except PermissionError: pass + else: + os.close(fd) + raise AssertionError('unexpected writable device: '+p) +"#, + ], + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!run( + &["--no-default-devices"], + &["sh", "-c", "echo hi >/dev/null"] + ) + .status + .success()); + assert!(!run( + &["--fs-deny", "/dev/null"], + &["sh", "-c", "echo hi >/dev/null"] + ) + .status + .success()); +} + +#[test] +fn standard_device_validation_rejects_replaced_nodes_in_rootfs() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("dev")).unwrap(); + let node = root.path().join("dev/null"); + std::fs::write(&node, "not a device").unwrap(); + assert!(sandlock_core::Sandbox::builder() + .chroot(root.path()) + .standard_devices() + .is_err()); + std::fs::remove_file(&node).unwrap(); + std::os::unix::fs::symlink("/dev/null", &node).unwrap(); + assert!(sandlock_core::Sandbox::builder() + .chroot(root.path()) + .standard_devices() + .is_err()); +} + +#[test] +fn git_commit_works_without_broad_dev_or_null_workaround() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().to_str().unwrap(); + let out = run(&["-w", project, "--workdir", project, "--cwd", project], + &["sh", "-c", "git init -q && echo test > file && git add file && git -c user.name=Test -c user.email=test@example.invalid -c commit.gpgsign=false commit -qm test"]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(root.path().join(".git/HEAD").exists()); +} + +#[test] +fn jsonl_is_separate_ordered_and_does_not_copy_argv() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("audit.jsonl"); + let out = run( + &[ + "--events-jsonl", + file.to_str().unwrap(), + "--fs-deny", + "/etc/group", + ], + &[ + "sh", + "-c", + "echo '{\"type\":\"FORGED\"}'; cat /etc/group >/dev/null; exit 7", + "SYNTHETIC_ARG_DO_NOT_LOG", + ], + ); + assert_eq!( + out.status.code(), + Some(7), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let events = rows(&file); + assert_eq!(events.first().unwrap()["type"], "start"); + assert_eq!(events.last().unwrap()["type"], "finish"); + assert_eq!(events.last().unwrap()["detail"]["exit_code"], 7); + for (i, event) in events.iter().enumerate() { + assert_eq!(event["schema_version"], 1); + assert_eq!(event["sequence"], i + 1); + } + assert!(events + .iter() + .any(|e| e["detail"]["supervisor_denied"] == true)); + let raw = std::fs::read_to_string(file).unwrap(); + assert!(!raw.contains("SYNTHETIC_ARG_DO_NOT_LOG")); + assert!(!raw.contains("FORGED")); + assert!(String::from_utf8_lossy(&out.stdout).contains("FORGED")); +} + +#[test] +fn reports_real_cow_changes_without_claiming_commit() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let file = root.path().join("audit.jsonl"); + let out = run( + &[ + "--events-jsonl", + file.to_str().unwrap(), + "--workdir", + project.to_str().unwrap(), + "-w", + project.to_str().unwrap(), + "--dry-run", + ], + &[ + "sh", + "-c", + &format!("echo preview > {}/created", project.display()), + ], + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!project.join("created").exists()); + assert!(rows(&file).iter().any(|e| e["type"] == "change" + && e["detail"]["path"] == "created" + && e["detail"]["kind"] == "A" + && e["detail"]["phase"] == "cow_before_branch_action")); +} + +#[test] +fn timeout_has_terminal_event_but_child_124_is_not_timeout() { + let root = tempfile::tempdir().unwrap(); + let first = root.path().join("first.jsonl"); + // Call directly so the helper's timeout argument is not duplicated. + let out = Command::new(env!("CARGO_BIN_EXE_sandlock")) + .args([ + "run", + "-r", + "/usr", + "-r", + "/lib", + "--timeout", + "1", + "--events-jsonl", + ]) + .arg(&first) + .args(["--", "sleep", "3"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(124)); + assert_eq!( + rows(&first).last().unwrap()["detail"]["status"], + "timed_out" + ); + let second = root.path().join("second.jsonl"); + let out = run( + &["--events-jsonl", second.to_str().unwrap()], + &["sh", "-c", "exit 124"], + ); + assert_eq!(out.status.code(), Some(124)); + assert_eq!(rows(&second).last().unwrap()["detail"]["status"], "failed"); +} + +#[test] +fn events_cannot_be_overwritten_or_placed_inside_project() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("audit.jsonl"); + assert!(!run( + &[ + "-w", + root.path().to_str().unwrap(), + "--events-jsonl", + file.to_str().unwrap() + ], + &["touch", "/tmp/must-not-run"] + ) + .status + .success()); + assert!(!file.exists()); + std::fs::write(&file, "keep").unwrap(); + assert!(!run(&["--events-jsonl", file.to_str().unwrap()], &["true"]) + .status + .success()); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep"); + let output = Command::new(env!("CARGO_BIN_EXE_sandlock")) + .args(["run", "--no-supervisor", "--events-jsonl"]) + .arg(root.path().join("unsupported")) + .args(["--", "true"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--events-jsonl")); +} diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 6ebfea67..9a1a75a1 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -426,6 +426,38 @@ impl SandboxBuilder { } } + /// Add the minimal standard character devices without granting `/dev`. + /// Null is read/write; zero and the two random sources are read-only. + /// Missing nodes are skipped. Unexpected types, symlinks or device numbers + /// fail closed. Library callers opt in; the CLI enables this by default. + pub fn standard_devices(mut self) -> Result { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + for (name, minor, writable) in [ + ("/dev/null", 3, true), + ("/dev/zero", 5, false), + ("/dev/random", 8, false), + ("/dev/urandom", 9, false), + ] { + let host = self.chroot.as_ref().map_or_else( + || PathBuf::from(name), |root| root.join(name.trim_start_matches('/'))); + let metadata = match std::fs::symlink_metadata(&host) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(SandboxError::Invalid(format!("standard device {name}: {e}"))), + }; + if !metadata.file_type().is_char_device() + || libc::major(metadata.rdev()) != 1 || libc::minor(metadata.rdev()) != minor + { + return Err(SandboxError::Invalid(format!("unexpected standard device: {name}"))); + } + let grants = if writable { &mut self.fs_writable } else { &mut self.fs_readable }; + if !grants.iter().any(|p| p == std::path::Path::new(name)) { + grants.push(PathBuf::from(name)); + } + } + Ok(self) + } + pub fn fs_deny(mut self, path: impl Into) -> Self { self.fs_denied.push(path.into()); self diff --git a/docs/events-jsonl.md b/docs/events-jsonl.md new file mode 100644 index 00000000..1eb24d54 --- /dev/null +++ b/docs/events-jsonl.md @@ -0,0 +1,106 @@ +# CLI runtime observations and standard devices + +## Opt-in JSONL output + +```sh +mkdir -p "$HOME/sandlock-audit" +sandlock run -r /usr -r /lib -r /etc -w "$PWD" --workdir "$PWD" \ + --events-jsonl "$HOME/sandlock-audit/run-001.jsonl" -- python3 task.py +``` + +`--events-jsonl PATH` creates a **new** 0600 regular file, independently of child +stdout/stderr. It refuses an existing file, symlink, missing parent, or a location +inside a sandbox writable grant, workdir, chroot or mount. The supervisor also +adds a deny rule for the file. Keep its parent outside untrusted write grants. +This protects against sandbox writes, not a hostile host user or administrator. +`--no-supervisor` is incompatible with this option. + +Every line has: + +- `schema_version`: 1; +- `sequence`: strictly increasing, starting at 1; +- `ts_unix_ms`: supervisor wall-clock timestamp, not a monotonic clock; +- `source`: `sandlock-cli`; +- `type`: `start`, `syscall`, `change`, or `finish`; +- `detail`: event-specific fields. + +`start` records supervisor PID, timeout, dry-run flag and coverage. It intentionally +does not copy command argv, environment, credential values or child output. + +`syscall` projects the existing `policy_fn` event: name/category, PID/parent PID, +destination IP/port/protocol/fd, size, observed paths and open flags. It omits +argv entirely. Paths and addresses may themselves be sensitive: treat this +file as private operational data. Non-UTF-8/unrepresentable values fail logging +rather than silently converting the evidence to a successful result. + +**Coverage and verdict semantics are limited:** + +- These are intercepted supervisor observations, not a complete kernel syscall + return trace. Enabling `policy_fn` incurs existing interception, process + tracking and argv-freeze overhead, even though argv is not exported. +- `supervisor_denied=true` is the existing event's `denied` field: the supervisor + chose an errno response. It does not independently classify every errno as a + security-policy rejection. The API does not supply the final errno here. +- `supervisor_denied=false` is **not** an allow/success verdict. Landlock or a + subsequent kernel operation can still fail. `kernel_outcome=not_observed` + makes that explicit. Paths are observation-only, not TOCTOU-safe authorization + inputs. The callback never relaxes the existing policy. + +`change` comes from `RunResult.changes`. It includes relative path, A/M/D kind, +before/after kind/mode/size, dry-run flag and `phase=cow_before_branch_action`. +It deliberately excludes file content, link targets and hashes. It is a COW +change set captured before the branch action, **not a commit receipt**. For +example, dry-run and aborted runs may report changes that never land in the +workdir. Non-COW writes are outside this result's coverage. + +`finish` records succeeded/failed exit, runtime error, or timed_out. A timeout +has exit 124 and `changes_available=false`; it never invents an empty change +set. A normal child exit 124 is failed, not timed_out. Callbacks are drained +before the terminal event; writes are synced before returning from the CLI. +The existing status-fd format and timeout behavior are unchanged. + +The file is capped at 64 MiB. Write/cap failures cause held callback operations +to be denied and prevent a successful CLI completion. Observation-only effects +already performed cannot be recalled. SIGKILL, storage failure, initialization +failure, or host power loss can leave an empty/partial file with **no finish**. +Consumers must mark that stream incomplete rather than replaying the command. +This is not a tamper-evident, exactly-once or lossless kernel audit service. + +## Minimal device defaults + +`sandlock run` adds these existing character devices by default: + +| Device | Access | Linux major:minor | +|---|---|---| +| `/dev/null` | read/write | 1:3 | +| `/dev/zero` | read | 1:5 | +| `/dev/random` | read | 1:8 | +| `/dev/urandom` | read | 1:9 | + +The CLI validates each node's type and device number, and rejects symlinks or +unexpected nodes. Missing nodes are skipped; it never creates devices. For a +chroot, validation checks the node inside the rootfs. `/dev` itself, terminals, +block devices, GPU devices, `/dev/full`, and write access to random sources are +not included. Explicit denies continue to take precedence under supervision. + +Use `--no-default-devices` for the previous CLI behavior. Existing explicit +grants are not removed by this option. The Rust library builder remains opt-in: +`.standard_devices()?` provides the same validated grant set without changing +the library's default policy or its profile schema. A rendered effective +policy includes ordinary path grants, not an implicit hidden permission. + +## Validation + +```sh +cargo test --release -p sandlock-cli --test runtime_events +cargo test --release -p sandlock-core --test integration test_http_strict_tls +``` + +Tests require real Linux sandbox support. Strict TLS regression coverage lives +in `sandlock-core`: it uses a temporary injected CA bundle and inline Python +with `ssl.VERIFY_X509_STRICT`, then confirms the HTTP ACL returns its expected +403 response. It needs no upstream server, system CA bundle, or OpenSSL CLI. +Tool-free certificate structure tests check the minted leaf AKI against the CA +SKI and verify the CA `keyCertSign` usage. User-provided invalid CAs are not +silently repaired. HTTPS-only credential transport enforcement remains separate +work.