diff --git a/Cargo.lock b/Cargo.lock index 4d62edb..bc21bec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1569,6 +1569,7 @@ dependencies = [ "sandlock-core", "serde", "serde_json", + "tempfile", "tokio", ] @@ -1577,8 +1578,10 @@ name = "sandlock-core" version = "0.7.0" dependencies = [ "bincode", + "clap", "goblin", "hudsucker", + "jiff", "libc", "nix", "pathdiff", diff --git a/README.md b/README.md index 6be30b6..cbcadfd 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ sandlock run \ # (e.g. /etc/ssl/certs/), then pass both files here. sandlock run \ --http-allow "POST api.openai.com/v1/*" \ - --https-ca ca.pem --https-key ca-key.pem \ + --http-ca ca.pem --http-key ca-key.pem \ -r /usr -r /lib -r /etc -- python3 agent.py # Server listening on a port (Landlock --net-bind, separate from --net-allow) @@ -185,9 +185,9 @@ sandlock run --no-supervisor -r /proc -r /usr -r /lib -r /lib64 -r /bin -r /etc ### Python API ```python -from sandlock import Sandbox, Policy, confine +from sandlock import Sandbox, confine -policy = Policy( +sandbox = Sandbox( fs_writable=["/tmp/sandbox"], fs_readable=["/usr", "/lib", "/etc"], max_memory="256M", @@ -196,37 +196,37 @@ policy = Policy( ) # Run a command (with optional timeout in seconds) -result = Sandbox(policy).run(["python3", "-c", "print('hello')"], timeout=30) +result = sandbox.run(["python3", "-c", "print('hello')"], timeout=30) assert result.success assert b"hello" in result.stdout # HTTP ACL: only allow specific API calls -agent_policy = Policy( +agent = Sandbox( fs_readable=["/usr", "/lib", "/etc"], http_allow=["POST api.openai.com/v1/chat/completions"], http_deny=["* */admin/*"], ) -result = Sandbox(agent_policy).run(["python3", "agent.py"]) +result = agent.run(["python3", "agent.py"]) # Chroot with per-sandbox mount (Docker-style -v, no root needed) -chroot_policy = Policy( +chrooted = Sandbox( chroot="/opt/rootfs", fs_mount={"/work": "/tmp/sandbox-1/work"}, # maps /work inside chroot fs_readable=["/usr", "/bin", "/lib", "/etc"], cwd="/work", ) -result = Sandbox(chroot_policy).run(["python3", "task.py"]) +result = chrooted.run(["python3", "task.py"]) # Port virtualization: query port mappings while sandbox is running -sb = Sandbox(Policy(port_remap=True, fs_readable=["/usr", "/lib", "/etc"]), name="api.local") +sb = Sandbox(port_remap=True, fs_readable=["/usr", "/lib", "/etc"], name="api.local") # sb.ports() returns {virtual_port: real_port} while running # Confine the current process (Landlock filesystem only, irreversible) -confine(Policy(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"])) +confine(Sandbox(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"])) # Dry-run: see what files would change, then discard -policy = Policy(fs_writable=["."], workdir=".", fs_readable=["/usr", "/lib", "/bin", "/etc"]) -result = Sandbox(policy).dry_run(["make", "build"]) +sandbox = Sandbox(fs_writable=["."], workdir=".", fs_readable=["/usr", "/lib", "/bin", "/etc"]) +result = sandbox.dry_run(["make", "build"]) for c in result.changes: print(f"{c.kind} {c.path}") # A=added, M=modified, D=deleted ``` @@ -234,18 +234,18 @@ for c in result.changes: ### Pipeline Chain sandboxed stages with the `|` operator — each stage has its own -independent policy. Data flows through kernel pipes. +independent sandbox config. Data flows through kernel pipes. ```python -from sandlock import Sandbox, Policy +from sandlock import Sandbox -trusted = Policy(fs_readable=["/usr", "/lib", "/bin", "/etc", "/opt/data"]) -restricted = Policy(fs_readable=["/usr", "/lib", "/bin", "/etc"]) +trusted = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc", "/opt/data"]) +restricted = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc"]) # Reader can access data, processor cannot result = ( - Sandbox(trusted).cmd(["cat", "/opt/data/secret.csv"]) - | Sandbox(restricted).cmd(["tr", "a-z", "A-Z"]) + trusted.cmd(["cat", "/opt/data/secret.csv"]) + | restricted.cmd(["tr", "a-z", "A-Z"]) ).run() assert b"SECRET" in result.stdout ``` @@ -254,12 +254,12 @@ assert b"SECRET" in result.stdout executor runs it with data access but no network: ```python -planner = Policy(fs_readable=["/usr", "/lib", "/bin", "/etc"]) -executor = Policy(fs_readable=["/usr", "/lib", "/bin", "/etc", "/data"]) +planner = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc"]) +executor = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc", "/data"]) result = ( - Sandbox(planner).cmd(["python3", "-c", "print('cat /data/input.txt')"]) - | Sandbox(executor).cmd(["sh"]) + planner.cmd(["python3", "-c", "print('cat /data/input.txt')"]) + | executor.cmd(["sh"]) ).run() ``` @@ -271,7 +271,7 @@ Events carry syscall name, category, PID, network destination (for returns a verdict to allow, deny, or audit. ```python -from sandlock import Sandbox, Policy +from sandlock import Sandbox import errno def on_event(event, ctx): @@ -294,11 +294,12 @@ def on_event(event, ctx): return 0 # allow -policy = Policy( +sandbox = Sandbox( fs_readable=["/usr", "/lib", "/etc"], net_allow=["api.example.com:443"], + policy_fn=on_event, ) -result = Sandbox(policy, policy_fn=on_event).run(["python3", "agent.py"]) +result = sandbox.run(["python3", "agent.py"]) ``` **Verdicts:** `0`/`False` = allow, `True`/`-1` = deny (EPERM), @@ -337,41 +338,48 @@ positive int = deny with errno, `"audit"`/`-2` = allow + flag. ### Rust API ```rust -use sandlock_core::{ConfinePolicy, Policy, Sandbox, Pipeline, Stage, confine}; +use sandlock_core::{confine, Confinement, Sandbox, Stage}; +use sandlock_core::sandbox::ByteSize; +use sandlock_core::policy_fn::Verdict; // Basic run -let policy = Policy::builder() +let mut sandbox = Sandbox::builder() .fs_read("/usr").fs_read("/lib") .fs_write("/tmp") .max_memory(ByteSize::mib(256)) + .name("hello-box") .build()?; -let result = Sandbox::run(&policy, Some("hello-box"), &["echo", "hello"]).await?; +let result = sandbox.run(&["echo", "hello"]).await?; assert!(result.success()); // HTTP ACL: restrict API access at the HTTP level -let policy = Policy::builder() +let mut agent = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read("/etc") .http_allow("POST api.openai.com/v1/chat/completions") .http_deny("* */admin/*") + .name("agent-box") .build()?; -let result = Sandbox::run(&policy, Some("agent-box"), &["python3", "agent.py"]).await?; +let result = agent.run(&["python3", "agent.py"]).await?; // Confine the current process (Landlock filesystem only, irreversible) -let policy = ConfinePolicy::builder() +let confinement = Confinement::builder() .fs_read("/usr").fs_read("/lib") .fs_write("/tmp") .build(); -confine(&policy)?; +confine(&confinement)?; // Pipeline +let producer = Sandbox::builder() + .fs_read("/usr").fs_read("/lib").fs_read("/bin") + .build()?; +let consumer = producer.clone(); let result = ( - Stage::new(&policy_a, &["echo", "hello"]) - | Stage::new(&policy_b, &["tr", "a-z", "A-Z"]) + Stage::new(&producer, &["echo", "hello"]) + | Stage::new(&consumer, &["tr", "a-z", "A-Z"]) ).run(None).await?; // Dynamic policy -use sandlock_core::policy_fn::Verdict; -let policy = Policy::builder() +let mut dynamic = Sandbox::builder() .fs_read("/usr").fs_read("/lib") .policy_fn(|event, ctx| { if event.argv_contains("curl") { @@ -384,31 +392,41 @@ let policy = Policy::builder() Verdict::Allow }) .build()?; +let result = dynamic.run(&["python3", "agent.py"]).await?; ``` ## Profiles -Save reusable policies as TOML files in `~/.config/sandlock/profiles/`: -Profiles contain policy only; pass a sandbox instance name with `--name`. +Save reusable sandbox profiles as TOML files in +`~/.config/sandlock/profiles/`. Profiles use a sectioned schema; top-level +flat keys such as `fs_readable = [...]` are rejected. Pass a sandbox instance +name with `--name` when you need a stable virtual hostname. ```toml # ~/.config/sandlock/profiles/build.toml -fs_writable = ["/tmp/work"] -fs_readable = ["/usr", "/lib", "/lib64", "/bin", "/etc"] +[program] +exec = "make" +args = ["-j4"] clean_env = true -max_memory = "512M" -max_processes = 50 -block_syscalls = [] +env = { CC = "gcc", LANG = "C.UTF-8" } + +[filesystem] +read = ["/usr", "/lib", "/lib64", "/bin", "/etc"] +write = ["/tmp/work"] + +[limits] +memory = "512M" +processes = 50 -[env] -CC = "gcc" -LANG = "C.UTF-8" +[syscalls] +extra_deny = [] ``` ```bash sandlock profile list sandlock profile show build -sandlock run -p build -- make -j4 +sandlock run -p build # uses [program].exec + args +sandlock run -p build -- make test # trailing command overrides [program] ``` ## How It Works @@ -478,7 +496,7 @@ pipes and feeds combined output to a reducer's stdin — fully pipe-based data flow with no temp files. ```python -from sandlock import Sandbox, Policy +from sandlock import Sandbox def init(): global model, data @@ -489,12 +507,17 @@ def work(clone_id): shard = data[clone_id::4] print(sum(shard)) # stdout → per-clone pipe -# Map: fork 4 clones with separate policies -mapper = Sandbox(data_policy, init_fn=init, work_fn=work) +# Map: fork 4 clones with a separate sandbox config +mapper = Sandbox( + fs_readable=["/usr", "/lib", "/bin", "/etc", "/data"], + init_fn=init, + work_fn=work, +) clones = mapper.fork(4) # Reduce: pipe clone outputs to reducer stdin -result = Sandbox(reduce_policy).reduce( +reducer = Sandbox(fs_readable=["/usr", "/lib", "/bin", "/etc"]) +result = reducer.reduce( ["python3", "-c", "import sys; print(sum(int(l) for l in sys.stdin))"], clones, ) @@ -502,20 +525,26 @@ print(result.stdout) # b"total\n" ``` ```rust -let mut mapper = Sandbox::new_with_fns(&map_policy, Some("mapper"), - || { load_data(); }, - |id| { println!("{}", compute(id)); }, -)?; +let mut mapper = Sandbox::builder() + .fs_read("/usr").fs_read("/lib").fs_read("/bin").fs_read("/etc") + .fs_read("/data") + .name("mapper") + .init_fn(|| { load_data(); }) + .work_fn(|id| { println!("{}", compute(id)); }) + .build()?; let mut clones = mapper.fork(4).await?; -let reducer = Sandbox::new(&reduce_policy, Some("reducer"))?; +let reducer = Sandbox::builder() + .fs_read("/usr").fs_read("/lib").fs_read("/bin").fs_read("/etc") + .name("reducer") + .build()?; let result = reducer.reduce( &["python3", "-c", "import sys; print(sum(int(l) for l in sys.stdin))"], &mut clones, ).await?; ``` -Map and reduce run in separate sandboxes with independent policies — +Map and reduce run in separate sandboxes with independent configs — the mapper has data access, the reducer doesn't. Each clone inherits Landlock + seccomp confinement. `CLONE_ID=0..N-1` is set automatically. @@ -588,12 +617,12 @@ allow-all. **HTTP / HTTPS interception.** `--http-allow` / `--http-deny` route matching ports through a transparent proxy. Each rule with a concrete host auto-extends `--net-allow` with `host:80` (and `host:443` when -`--https-ca` is set) so the proxy's intercept ports are reachable; +`--http-ca` is set) so the proxy's intercept ports are reachable; wildcard hosts auto-add `:80` / `:443` (any IP). All auto-added -entries are TCP. HTTPS MITM is opt-in: pass `--https-ca ` and -`--https-key ` for a CA *you generate* and trust inside the +entries are TCP. HTTPS MITM is opt-in: pass `--http-ca ` and +`--http-key ` for a CA *you generate* and trust inside the sandbox (typically install the cert into the workload's -`/etc/ssl/certs/`). Without `--https-ca`, port 443 is not intercepted +`/etc/ssl/certs/`). Without `--http-ca`, port 443 is not intercepted — `--net-allow host:443` permits raw TLS to the host with no content inspection. @@ -651,17 +680,17 @@ cargo test --release cd python && pip install -e . && pytest tests/ ``` -## Policy Reference +## Sandbox Reference ```python -Policy( +Sandbox( # Filesystem (Landlock) fs_writable=["/tmp"], # Read/write access fs_readable=["/usr", "/lib"], # Read-only access fs_denied=["/proc/kcore"], # Explicitly denied # Syscall filtering (seccomp) - block_syscalls=[], # Extra syscalls to block in addition to Sandlock defaults + extra_deny_syscalls=[], # Extra syscalls to block in addition to Sandlock defaults # Network — see "Network Model" above. Each entry is one of: # bare host:port[,port,...] — TCP (default scheme) @@ -683,8 +712,8 @@ Policy( http_allow=["POST api.openai.com/v1/*"], # Allow rules (METHOD host/path) http_deny=["* */admin/*"], # Block rules (checked first) http_ports=[80], # Ports to intercept (default: [80]) - https_ca="ca.pem", # CA cert for HTTPS MITM (adds port 443) - https_key="ca-key.pem", # CA key for HTTPS MITM + http_ca="ca.pem", # CA cert for HTTPS MITM (adds port 443) + http_key="ca-key.pem", # CA key for HTTPS MITM # Resources max_memory="512M", # Memory limit diff --git a/crates/sandlock-cli/Cargo.toml b/crates/sandlock-cli/Cargo.toml index d5f5a72..4c1b336 100644 --- a/crates/sandlock-cli/Cargo.toml +++ b/crates/sandlock-cli/Cargo.toml @@ -13,7 +13,7 @@ name = "sandlock" path = "src/main.rs" [dependencies] -sandlock-core = { version = "0.7.0", path = "../sandlock-core" } +sandlock-core = { version = "0.7.0", path = "../sandlock-core", features = ["cli"] } clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } anyhow = "1" @@ -21,3 +21,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" jiff = "0.2" libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 953caae..cfaab05 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -1,8 +1,9 @@ use clap::{Parser, Subcommand}; -use sandlock_core::{Policy, Sandbox}; -use sandlock_core::policy::ByteSize; +use sandlock_core::Sandbox; +use sandlock_core::sandbox::{BranchAction, ByteSize, FsIsolation, SandboxBuilder}; use sandlock_core::profile; use anyhow::{Result, anyhow}; +use std::path::PathBuf; use std::time::SystemTime; mod network_registry; @@ -17,121 +18,7 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Run a command in a sandbox - Run { - #[arg(short = 'r', long = "fs-read", value_name = "PATH")] - fs_read: Vec, - #[arg(short = 'w', long = "fs-write", value_name = "PATH")] - fs_write: Vec, - #[arg(short = 'm', long = "max-memory")] - max_memory: Option, - #[arg(short = 'P', long = "max-processes")] - max_processes: Option, - #[arg(short = 't', long)] - timeout: Option, - /// Outbound endpoint allow rule (TCP, plus UDP when - /// `--allow-udp` is set). Repeatable. Each value is - /// `host:port[,port,...]` (IP-restricted), `:port` or `*:port` - /// (any IP). Examples: `api.openai.com:443`, - /// `github.com:22,443`, `:8080`, `1.1.1.1:53`. - /// See README "Network Model". - #[arg(long = "net-allow", value_name = "SPEC")] - net_allow: Vec, - #[arg(long = "net-bind")] - net_bind: Vec, - #[arg(long)] - time_start: Option, - #[arg(long)] - random_seed: Option, - #[arg(long)] - clean_env: bool, - #[arg(long)] - num_cpus: Option, - #[arg(short = 'p', long)] - profile: Option, - #[arg(long = "status-fd", value_name = "FD")] - status_fd: Option, - #[arg(short = 'c', long = "cpu")] - max_cpu: Option, - #[arg(long)] - max_open_files: Option, - #[arg(long)] - chroot: Option, - /// Map to the given UID inside a user namespace (e.g. --uid 0 for fake root) - #[arg(long)] - uid: Option, - #[arg(long)] - workdir: Option, - #[arg(long)] - cwd: Option, - #[arg(long = "fs-isolation", value_name = "MODE")] - fs_isolation: Option, - #[arg(long = "fs-storage", value_name = "PATH")] - fs_storage: Option, - #[arg(long = "max-disk")] - max_disk: Option, - /// Allow SysV IPC syscalls (shared memory, message queues, - /// semaphores). Denied by default: sandlock does not use IPC - /// namespaces, so without this denial two sandboxes on the - /// same host share a SysV keyspace and can rendezvous via a - /// well-known key. Enable only when running software that - /// requires SysV IPC (e.g. Oracle, Sybase, MPI intra-node). - #[arg(long = "allow-sysv-ipc")] - allow_sysv_ipc: bool, - #[arg(long = "http-allow", value_name = "RULE")] - http_allow: Vec, - #[arg(long = "http-deny", value_name = "RULE")] - http_deny: Vec, - /// TCP ports to intercept for HTTP ACL (default: 80, plus 443 with --https-ca) - #[arg(long = "http-port", value_name = "PORT")] - http_ports: Vec, - /// PEM CA certificate for HTTPS MITM (enables port 443 interception) - #[arg(long = "https-ca", value_name = "PATH")] - https_ca: Option, - /// PEM CA private key for HTTPS MITM (required with --https-ca) - #[arg(long = "https-key", value_name = "PATH")] - https_key: Option, - #[arg(long)] - port_remap: bool, - #[arg(long)] - no_randomize_memory: bool, - #[arg(long)] - no_huge_pages: bool, - #[arg(long)] - deterministic_dirs: bool, - /// Sandbox name (also exposed as the virtual hostname; auto-generated if omitted) - #[arg(long)] - name: Option, - #[arg(long)] - no_coredump: bool, - #[arg(long = "env", value_name = "KEY=VALUE")] - env_vars: Vec, - #[arg(short = 'e', long = "exec-shell", value_name = "CMD")] - exec_shell: Option, - #[arg(short = 'i', long)] - interactive: bool, - #[arg(long = "fs-deny", value_name = "PATH")] - fs_deny: Vec, - /// Mount a host path inside the sandbox (e.g. --fs-mount /work:/host/path) - #[arg(long = "fs-mount", value_name = "VIRTUAL:HOST")] - fs_mount: Vec, - /// CPU cores to pin the sandbox to (e.g. --cpu-cores 0,2,3) - #[arg(long = "cpu-cores", value_delimiter = ',')] - cpu_cores: Vec, - /// GPU device indices visible to the sandbox (e.g. --gpu 0,2) - #[arg(long = "gpu", value_delimiter = ',')] - gpu_devices: Vec, - /// Use a local Docker image as chroot rootfs - #[arg(long)] - image: Option, - /// Dry-run: run the command, show filesystem changes, then discard - #[arg(long)] - dry_run: bool, - /// No-supervisor mode: apply Landlock rules + deny-only seccomp filter, then exec directly - #[arg(long)] - no_supervisor: bool, - #[arg(last = true)] - cmd: Vec, - }, + Run(RunArgs), /// Check kernel feature support Check, /// List all running sandboxes @@ -148,6 +35,85 @@ enum Command { }, } +/// Arguments for the `run` subcommand. +/// +/// Sandbox-level flags come from `SandboxBuilder` via `#[clap(flatten)]`. +/// CLI-only flags (profile, timeout, image, etc.) and non-clap-friendly +/// sandbox fields (max_memory, fs_mount, env, gpu, cpu-cores) remain here. +#[derive(clap::Args)] +struct RunArgs { + // ── Sandbox flags (flattened from SandboxBuilder) ─────────────────────── + #[clap(flatten)] + sandbox_builder: SandboxBuilder, + + // ── Sandbox-builder fields that need special parsing (not in SandboxBuilder's clap derive) ── + #[arg(short = 'm', long = "max-memory")] + max_memory: Option, + + #[arg(long = "max-disk")] + max_disk: Option, + + #[arg(long = "fs-isolation", value_name = "MODE")] + fs_isolation: Option, + + #[arg(long)] + time_start: Option, + + /// Mount a host path inside the sandbox (e.g. --fs-mount /work:/host/path) + #[arg(long = "fs-mount", value_name = "VIRTUAL:HOST")] + fs_mount: Vec, + + #[arg(long = "env", value_name = "KEY=VALUE")] + env_vars: Vec, + + /// CPU cores to pin the sandbox to (e.g. --cpu-cores 0,2,3) + #[arg(long = "cpu-cores", value_delimiter = ',')] + cpu_cores: Vec, + + /// GPU device indices visible to the sandbox (e.g. --gpu 0,2) + #[arg(long = "gpu", value_delimiter = ',')] + gpu_devices: Vec, + + // ── CLI-only flags ─────────────────────────────────────────────────────── + #[arg(short = 't', long)] + timeout: Option, + + #[arg(short = 'p', long, conflicts_with = "profile_file")] + profile: Option, + + /// Load a profile directly from a file path (TOML format) + #[arg(long = "profile-file", value_name = "PATH", conflicts_with = "profile")] + profile_file: Option, + + #[arg(long = "status-fd", value_name = "FD")] + status_fd: Option, + + /// Sandbox name (also exposed as the virtual hostname; auto-generated if omitted) + #[arg(long)] + name: Option, + + #[arg(short = 'e', long = "exec-shell", value_name = "CMD")] + exec_shell: Option, + + #[arg(short = 'i', long)] + interactive: bool, + + /// Use a local Docker image as chroot rootfs + #[arg(long)] + image: Option, + + /// Dry-run: run the command, show filesystem changes, then discard + #[arg(long)] + dry_run: bool, + + /// No-supervisor mode: apply Landlock rules + deny-only seccomp filter, then exec directly + #[arg(long)] + no_supervisor: bool, + + #[arg(last = true)] + cmd: Vec, +} + #[derive(Subcommand)] enum ProfileAction { /// List available profiles @@ -170,319 +136,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Command::Run { fs_read, fs_write, max_memory, max_processes, timeout, - net_allow, net_bind, time_start, random_seed, - clean_env, num_cpus, profile: profile_name, status_fd, - max_cpu, max_open_files, chroot, uid, workdir, cwd, - fs_isolation, fs_storage, max_disk, allow_sysv_ipc, - http_allow, http_deny, http_ports, https_ca, https_key, - port_remap, no_randomize_memory, no_huge_pages, deterministic_dirs, name, no_coredump, - env_vars, exec_shell, interactive: _, fs_deny, fs_mount, cpu_cores, gpu_devices, image, dry_run, no_supervisor, cmd } => - { - if no_supervisor { - validate_no_supervisor( - &max_memory, &max_processes, &max_cpu, &max_open_files, - &timeout, &net_allow, &net_bind, - &http_allow, &http_deny, &http_ports, - &num_cpus, &random_seed, &time_start, no_randomize_memory, - no_huge_pages, deterministic_dirs, &name, &chroot, - &image, &uid, &workdir, &cwd, &fs_isolation, &fs_storage, - &max_disk, port_remap, &cpu_cores, &gpu_devices, dry_run, - &status_fd, &fs_deny, &fs_mount, - )?; - - // Build a minimal policy with only fs rules - let mut builder = if let Some(ref name) = profile_name { - let base = sandlock_core::profile::load_profile(name)?; - if !base.fs_denied.is_empty() { - return Err(anyhow!( - "--no-supervisor is incompatible with: --fs-deny (from profile {})", - name - )); - } - let mut b = Policy::builder(); - for p in &base.fs_readable { b = b.fs_read(p); } - for p in &base.fs_writable { b = b.fs_write(p); } - b = b.allow_sysv_ipc(base.allow_sysv_ipc); - b - } else { - Policy::builder() - }; - - for p in &fs_read { builder = builder.fs_read(p); } - for p in &fs_write { builder = builder.fs_write(p); } - for p in &fs_deny { builder = builder.fs_deny(p); } - if allow_sysv_ipc { builder = builder.allow_sysv_ipc(true); } - if clean_env { builder = builder.clean_env(true); } - for spec in &env_vars { - if let Some((k, v)) = spec.split_once('=') { - builder = builder.env_var(k, v); - } else { - return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); - } - } - - let policy = builder.build()?; - - if exec_shell.is_none() && cmd.is_empty() { - return Err(anyhow!("no command specified")); - } - - let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = exec_shell { - vec!["/bin/sh", "-c", shell_cmd.as_str()] - } else { - cmd.iter().map(|s| s.as_str()).collect() - }; - - return no_supervisor_exec(&policy, &cmd_strs); - } - - // Start from profile or default - let mut builder = if let Some(ref name) = profile_name { - let base = profile::load_profile(name)?; - // Rebuild builder from loaded profile as base - let mut b = Policy::builder(); - for p in &base.fs_readable { b = b.fs_read(p); } - for p in &base.fs_writable { b = b.fs_write(p); } - for p in &base.fs_denied { b = b.fs_deny(p); } - for rule in &base.net_allow { - let host_part = rule.host.as_deref().unwrap_or("*"); - let spec = match rule.protocol { - sandlock_core::policy::Protocol::Tcp => { - let ports = format_ports(&rule.ports, rule.all_ports); - format!("tcp://{}:{}", host_part, ports) - } - sandlock_core::policy::Protocol::Udp => { - let ports = format_ports(&rule.ports, rule.all_ports); - format!("udp://{}:{}", host_part, ports) - } - sandlock_core::policy::Protocol::Icmp => { - format!("icmp://{}", host_part) - } - }; - b = b.net_allow(spec); - } - for p in &base.net_bind { b = b.net_bind_port(*p); } - for rule in &base.http_allow { - let s = format!("{} {}{}", rule.method, rule.host, rule.path); - b = b.http_allow(&s); - } - for rule in &base.http_deny { - let s = format!("{} {}{}", rule.method, rule.host, rule.path); - b = b.http_deny(&s); - } - for port in &base.http_ports { - b = b.http_port(*port); - } - if let Some(mem) = base.max_memory { b = b.max_memory(mem); } - b = b.max_processes(base.max_processes); - if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); } - if let Some(seed) = base.random_seed { b = b.random_seed(seed); } - if let Some(n) = base.num_cpus { b = b.num_cpus(n); } - b = b.block_syscalls(base.block_syscalls.clone()); - b = b.allow_sysv_ipc(base.allow_sysv_ipc); - b = b.clean_env(base.clean_env); - if let Some(ref w) = base.workdir { b = b.workdir(w); } - if let Some(ref c) = base.cwd { b = b.cwd(c); } - b - } else { - Policy::builder() - }; - - // CLI overrides - for p in &fs_read { builder = builder.fs_read(p); } - for p in &fs_write { builder = builder.fs_write(p); } - if let Some(ref m) = max_memory { builder = builder.max_memory(ByteSize::parse(m)?); } - if let Some(n) = max_processes { builder = builder.max_processes(n); } - for spec in &net_allow { builder = builder.net_allow(spec); } - for p in &net_bind { builder = builder.net_bind_port(*p); } - if let Some(seed) = random_seed { builder = builder.random_seed(seed); } - if clean_env { builder = builder.clean_env(true); } - if let Some(n) = num_cpus { builder = builder.num_cpus(n); } - if let Some(ref ts) = time_start { - let t = parse_time_start(ts)?; - builder = builder.time_start(t); - } - if let Some(cpu) = max_cpu { builder = builder.max_cpu(cpu); } - if let Some(n) = max_open_files { builder = builder.max_open_files(n); } - for p in &fs_deny { builder = builder.fs_deny(p); } - for spec in &fs_mount { - let (virt, host) = spec.split_once(':') - .ok_or_else(|| anyhow!("--fs-mount requires VIRTUAL:HOST, got: {}", spec))?; - builder = builder.fs_mount(virt, host); - } - if let Some(ref path) = chroot { builder = builder.chroot(path); } - if let Some(id) = uid { builder = builder.uid(id); } - if let Some(ref path) = workdir { builder = builder.workdir(path); } - if let Some(ref path) = cwd { builder = builder.cwd(path); } - if let Some(ref mode) = fs_isolation { - use sandlock_core::policy::FsIsolation; - let iso = match mode.as_str() { - "none" => FsIsolation::None, - "overlayfs" => FsIsolation::OverlayFs, - "branchfs" => FsIsolation::BranchFs, - other => return Err(anyhow!("unknown --fs-isolation mode: {}", other)), - }; - builder = builder.fs_isolation(iso); - } - if let Some(ref path) = fs_storage { builder = builder.fs_storage(path); } - if let Some(ref s) = max_disk { builder = builder.max_disk(ByteSize::parse(s)?); } - // UDP, the kernel ping socket (SOCK_DGRAM + IPPROTO_ICMP), - // and raw ICMP are all gated by `--net-allow` rule presence - // (`udp://...`, `icmp://...`, `icmp-raw://*` respectively). - if allow_sysv_ipc { builder = builder.allow_sysv_ipc(true); } - for rule in &http_allow { builder = builder.http_allow(rule); } - for rule in &http_deny { builder = builder.http_deny(rule); } - for port in &http_ports { builder = builder.http_port(*port); } - if let Some(ref ca) = https_ca { builder = builder.https_ca(ca); } - if let Some(ref key) = https_key { builder = builder.https_key(key); } - if port_remap { builder = builder.port_remap(true); } - if !cpu_cores.is_empty() { builder = builder.cpu_cores(cpu_cores); } - if !gpu_devices.is_empty() { builder = builder.gpu_devices(gpu_devices); } - if no_randomize_memory { builder = builder.no_randomize_memory(true); } - if no_huge_pages { builder = builder.no_huge_pages(true); } - if deterministic_dirs { builder = builder.deterministic_dirs(true); } - let sandbox_name = name.clone().unwrap_or_else(|| network_registry::next_name()); - if no_coredump { builder = builder.no_coredump(true); } - for spec in &env_vars { - if let Some((k, v)) = spec.split_once('=') { - builder = builder.env_var(k, v); - } else { - return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); - } - } - - // Handle --image: extract rootfs, set chroot, get default cmd - let image_cmd: Option>; - if let Some(ref img) = image { - let rootfs = sandlock_core::image::extract(img, None)?; - builder = builder.chroot(rootfs); - // Add standard paths inside the chroot - builder = builder.fs_read("/usr").fs_read("/lib").fs_read("/lib64") - .fs_read("/bin").fs_read("/sbin").fs_read("/etc") - .fs_read("/proc").fs_read("/dev"); - if cmd.is_empty() { - image_cmd = Some(sandlock_core::image::inspect_cmd(img)?); - } else { - image_cmd = None; - } - } else { - image_cmd = None; - } - - if exec_shell.is_none() && cmd.is_empty() && image_cmd.is_none() { - return Err(anyhow!("no command specified")); - } - - let policy = builder.build()?; - let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = exec_shell { - vec!["/bin/sh", "-c", shell_cmd.as_str()] - } else if let Some(ref ic) = image_cmd { - ic.iter().map(|s| s.as_str()).collect() - } else { - cmd.iter().map(|s| s.as_str()).collect() - }; - - let result = if dry_run { - if policy.workdir.is_none() { - return Err(anyhow!("--dry-run requires --workdir")); - } - let dr = if let Some(secs) = timeout { - tokio::time::timeout( - std::time::Duration::from_secs(secs), - Sandbox::dry_run_interactive(&policy, Some(sandbox_name.as_str()), &cmd_strs) - ).await.unwrap_or_else(|_| { - eprintln!("sandlock: timeout after {}s", secs); - std::process::exit(124); - })? - } else { - Sandbox::dry_run_interactive(&policy, Some(sandbox_name.as_str()), &cmd_strs).await? - }; - - if dr.changes.is_empty() { - eprintln!("sandlock: dry-run: no filesystem changes"); - } else { - eprintln!("sandlock: dry-run: filesystem changes:"); - for change in &dr.changes { - eprintln!("{}", change); - } - } - dr.run_result - } else if policy.port_remap { - // Use spawn+wait so we can register/unregister network state. - let mut sb = Sandbox::new(&policy, Some(sandbox_name.as_str()))?; - - // Set up callback to update registry on each port bind. - let reg_name = sandbox_name.clone(); - sb.set_on_bind(move |ports| { - let _ = network_registry::update_ports(®_name, ports.clone()); - }); - - sb.spawn(&cmd_strs).await?; - - let pid = sb.pid().unwrap_or(0); - let registered_hosts: Vec = policy - .net_allow - .iter() - .filter_map(|r| r.host.clone()) - .collect(); - if let Err(e) = network_registry::register( - &sandbox_name, pid, std::collections::HashMap::new(), - registered_hosts, - None, // virtual_etc_hosts populated by core at runtime - ) { - eprintln!("sandlock: network registry: {}", e); - } - - let result = if let Some(secs) = timeout { - match tokio::time::timeout( - std::time::Duration::from_secs(secs), - sb.wait() - ).await { - Ok(r) => r?, - Err(_) => { - let _ = network_registry::unregister(&sandbox_name); - eprintln!("sandlock: timeout after {}s", secs); - std::process::exit(124); - } - } - } else { - sb.wait().await? - }; - let _ = network_registry::unregister(&sandbox_name); - result - } else if let Some(secs) = timeout { - tokio::time::timeout( - std::time::Duration::from_secs(secs), - Sandbox::run_interactive(&policy, Some(sandbox_name.as_str()), &cmd_strs) - ).await.unwrap_or_else(|_| { - eprintln!("sandlock: timeout after {}s", secs); - std::process::exit(124); - })? - } else { - Sandbox::run_interactive(&policy, Some(sandbox_name.as_str()), &cmd_strs).await? - }; - - if let Some(fd) = status_fd { - use std::io::Write as _; - use std::os::unix::io::FromRawFd; - use sandlock_core::ExitStatus as SandlockExitStatus; - let (code, signal) = match &result.exit_status { - SandlockExitStatus::Code(c) => (*c, None), - SandlockExitStatus::Signal(s) => (-1, Some(*s)), - SandlockExitStatus::Killed => (-1, None), - SandlockExitStatus::Timeout => (-1, None), - }; - let status = SandboxStatus { exit_code: code, signal }; - if let Ok(json) = serde_json::to_string(&status) { - let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; - let _ = writeln!(file, "{}", json); - std::mem::forget(file); // Don't close the fd - } - } - - std::process::exit(result.code().unwrap_or(1)); - } + Command::Run(args) => run_command(args).await?, Command::List => { match network_registry::list() { @@ -589,76 +243,445 @@ async fn main() -> Result<()> { Ok(()) } +/// Implementation of `sandlock run`. +async fn run_command(args: RunArgs) -> Result<()> { + let pb = &args.sandbox_builder; + + if args.no_supervisor { + validate_no_supervisor(&args)?; + + // Load profile once (if any) and split into policy base + program spec. + let (ns_profile_base, ns_profile_spec) = if let Some(ref name) = args.profile { + let (base, spec) = sandlock_core::profile::load_profile(name)?; + (Some(base), Some(spec)) + } else if let Some(ref path) = args.profile_file { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow!("failed to read profile file {}: {}", path.display(), e))?; + let (base, spec) = sandlock_core::profile::parse_profile(&content)?; + (Some(base), Some(spec)) + } else { + (None, None) + }; + + // Build a minimal policy with only fs rules + let mut builder = if let Some(ref base) = ns_profile_base { + validate_no_supervisor_profile(base, &profile_source(&args))?; + let mut b = Sandbox::builder(); + for p in &base.fs_readable { b = b.fs_read(p); } + for p in &base.fs_writable { b = b.fs_write(p); } + if !base.extra_allow_syscalls.is_empty() { + b = b.extra_allow_syscalls(base.extra_allow_syscalls.clone()); + } + if !base.extra_deny_syscalls.is_empty() { + b = b.extra_deny_syscalls(base.extra_deny_syscalls.clone()); + } + b = b.clean_env(base.clean_env); + for (k, v) in &base.env { b = b.env_var(k, v); } + b + } else { + Sandbox::builder() + }; + + // Derive the effective command: profile's [program] section supplies the + // default; a trailing positional command on the CLI overrides it. + let effective_cmd: Vec = if !args.cmd.is_empty() || args.exec_shell.is_some() { + args.cmd.clone() + } else if let Some(spec) = ns_profile_spec { + if let Some(exec) = spec.exec { + let exec_str = exec.into_os_string().into_string() + .map_err(|_| anyhow!("non-UTF-8 exec path in profile"))?; + let mut v = vec![exec_str]; + v.extend(spec.args); + v + } else { + Vec::new() + } + } else { + Vec::new() + }; + + // Apply CLI fs/syscall/env flags on top of the profile base. + for p in &pb.fs_readable { builder = builder.fs_read(p); } + for p in &pb.fs_writable { builder = builder.fs_write(p); } + for p in &pb.fs_denied { builder = builder.fs_deny(p); } + if !pb.extra_allow_syscalls.is_empty() { builder = builder.extra_allow_syscalls(pb.extra_allow_syscalls.clone()); } + if !pb.extra_deny_syscalls.is_empty() { builder = builder.extra_deny_syscalls(pb.extra_deny_syscalls.clone()); } + if pb.clean_env { builder = builder.clean_env(true); } + for spec in &args.env_vars { + if let Some((k, v)) = spec.split_once('=') { + builder = builder.env_var(k, v); + } else { + return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); + } + } + + let policy = builder.build()?; + + if args.exec_shell.is_none() && effective_cmd.is_empty() { + return Err(anyhow!("no command specified (no trailing command and no [program].exec in profile)")); + } + + let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = args.exec_shell { + vec!["/bin/sh", "-c", shell_cmd.as_str()] + } else { + effective_cmd.iter().map(|s| s.as_str()).collect() + }; + + return no_supervisor_exec(&policy, &cmd_strs); + } + + // Hoist the profile load so we don't read+parse twice. + let (base_from_profile, profile_program_spec) = if let Some(ref name) = args.profile { + let (base, spec) = profile::load_profile(name)?; + (Some(base), Some(spec)) + } else if let Some(ref path) = args.profile_file { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow!("failed to read profile file {}: {}", path.display(), e))?; + let (base, spec) = profile::parse_profile(&content)?; + (Some(base), Some(spec)) + } else { + (None, None) + }; + + // Start from profile or default + let mut builder = if let Some(base) = base_from_profile { + // Rebuild builder from loaded profile as base + let mut b = Sandbox::builder(); + for p in &base.fs_readable { b = b.fs_read(p); } + for p in &base.fs_writable { b = b.fs_write(p); } + for p in &base.fs_denied { b = b.fs_deny(p); } + for rule in &base.net_allow { + let host_part = rule.host.as_deref().unwrap_or("*"); + let spec = match rule.protocol { + sandlock_core::sandbox::Protocol::Tcp => { + let ports = format_ports(&rule.ports, rule.all_ports); + format!("tcp://{}:{}", host_part, ports) + } + sandlock_core::sandbox::Protocol::Udp => { + let ports = format_ports(&rule.ports, rule.all_ports); + format!("udp://{}:{}", host_part, ports) + } + sandlock_core::sandbox::Protocol::Icmp => { + format!("icmp://{}", host_part) + } + }; + b = b.net_allow(spec); + } + for p in &base.net_bind { b = b.net_bind_port(*p); } + for rule in &base.http_allow { + let s = format!("{} {}{}", rule.method, rule.host, rule.path); + b = b.http_allow(&s); + } + for rule in &base.http_deny { + let s = format!("{} {}{}", rule.method, rule.host, rule.path); + b = b.http_deny(&s); + } + for port in &base.http_ports { + b = b.http_port(*port); + } + if let Some(mem) = base.max_memory { b = b.max_memory(mem); } + b = b.max_processes(base.max_processes); + if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); } + if let Some(seed) = base.random_seed { b = b.random_seed(seed); } + if let Some(n) = base.num_cpus { b = b.num_cpus(n); } + if let Some(n) = base.max_open_files { b = b.max_open_files(n); } + if let Some(disk) = base.max_disk { b = b.max_disk(disk); } + if !base.extra_deny_syscalls.is_empty() { b = b.extra_deny_syscalls(base.extra_deny_syscalls.clone()); } + if !base.extra_allow_syscalls.is_empty() { b = b.extra_allow_syscalls(base.extra_allow_syscalls.clone()); } + b = b.clean_env(base.clean_env); + for (k, v) in &base.env { b = b.env_var(k, v); } + if let Some(ref w) = base.workdir { b = b.workdir(w); } + if let Some(ref c) = base.cwd { b = b.cwd(c); } + // HTTP MITM material + if let Some(ref ca) = base.http_ca { b = b.http_ca(ca); } + if let Some(ref key) = base.http_key { b = b.http_key(key); } + // Filesystem extras + if let Some(ref path) = base.chroot { b = b.chroot(path); } + if let Some(ref path) = base.fs_storage { b = b.fs_storage(path); } + if base.fs_isolation != sandlock_core::sandbox::FsIsolation::None { + b = b.fs_isolation(base.fs_isolation.clone()); + } + for (virt, host) in &base.fs_mount { b = b.fs_mount(virt, host); } + b = b.on_exit(base.on_exit.clone()); + b = b.on_error(base.on_error.clone()); + b = b.deterministic_dirs(base.deterministic_dirs); + // Determinism / process knobs + b = b.no_randomize_memory(base.no_randomize_memory); + b = b.no_huge_pages(base.no_huge_pages); + b = b.no_coredump(base.no_coredump); + if let Some(t) = base.time_start { b = b.time_start(t); } + // Network virtualization + b = b.port_remap(base.port_remap); + // Process identity + if let Some(uid) = base.uid { b = b.uid(uid); } + // Hardware constraints + if let Some(ref devs) = base.gpu_devices { b = b.gpu_devices(devs.clone()); } + if let Some(ref cores) = base.cpu_cores { b = b.cpu_cores(cores.clone()); } + b + } else { + Sandbox::builder() + }; + + // CLI overrides — fields from flattened SandboxBuilder + for p in &pb.fs_readable { builder = builder.fs_read(p); } + for p in &pb.fs_writable { builder = builder.fs_write(p); } + if let Some(n) = pb.max_processes { builder = builder.max_processes(n); } + for spec in &pb.net_allow { builder = builder.net_allow(spec); } + for p in &pb.net_bind { builder = builder.net_bind_port(*p); } + if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); } + if pb.clean_env { builder = builder.clean_env(true); } + if let Some(n) = pb.num_cpus { builder = builder.num_cpus(n); } + if let Some(cpu) = pb.max_cpu { builder = builder.max_cpu(cpu); } + if let Some(n) = pb.max_open_files { builder = builder.max_open_files(n); } + for p in &pb.fs_denied { builder = builder.fs_deny(p); } + if let Some(ref path) = pb.chroot { builder = builder.chroot(path); } + if let Some(id) = pb.uid { builder = builder.uid(id); } + if let Some(ref path) = pb.workdir { builder = builder.workdir(path); } + if let Some(ref path) = pb.cwd { builder = builder.cwd(path); } + if let Some(ref path) = pb.fs_storage { builder = builder.fs_storage(path); } + if !pb.extra_allow_syscalls.is_empty() { builder = builder.extra_allow_syscalls(pb.extra_allow_syscalls.clone()); } + if !pb.extra_deny_syscalls.is_empty() { builder = builder.extra_deny_syscalls(pb.extra_deny_syscalls.clone()); } + for rule in &pb.http_allow { builder = builder.http_allow(rule); } + for rule in &pb.http_deny { builder = builder.http_deny(rule); } + for port in &pb.http_ports { builder = builder.http_port(*port); } + if let Some(ref ca) = pb.http_ca { builder = builder.http_ca(ca); } + if let Some(ref key) = pb.http_key { builder = builder.http_key(key); } + if pb.port_remap { builder = builder.port_remap(true); } + if pb.no_randomize_memory { builder = builder.no_randomize_memory(true); } + if pb.no_huge_pages { builder = builder.no_huge_pages(true); } + if pb.deterministic_dirs { builder = builder.deterministic_dirs(true); } + if pb.no_coredump { builder = builder.no_coredump(true); } + + // CLI overrides — non-clap-friendly fields (still parsed here) + if let Some(ref m) = args.max_memory { builder = builder.max_memory(ByteSize::parse(m)?); } + if let Some(ref ts) = args.time_start { + let t = parse_time_start(ts)?; + builder = builder.time_start(t); + } + if let Some(ref mode) = args.fs_isolation { + use sandlock_core::sandbox::FsIsolation; + let iso = match mode.as_str() { + "none" => FsIsolation::None, + "overlayfs" => FsIsolation::OverlayFs, + "branchfs" => FsIsolation::BranchFs, + other => return Err(anyhow!("unknown --fs-isolation mode: {}", other)), + }; + builder = builder.fs_isolation(iso); + } + if let Some(ref s) = args.max_disk { builder = builder.max_disk(ByteSize::parse(s)?); } + for spec in &args.fs_mount { + let (virt, host) = spec.split_once(':') + .ok_or_else(|| anyhow!("--fs-mount requires VIRTUAL:HOST, got: {}", spec))?; + builder = builder.fs_mount(virt, host); + } + if !args.cpu_cores.is_empty() { builder = builder.cpu_cores(args.cpu_cores.clone()); } + if !args.gpu_devices.is_empty() { builder = builder.gpu_devices(args.gpu_devices.clone()); } + for spec in &args.env_vars { + if let Some((k, v)) = spec.split_once('=') { + builder = builder.env_var(k, v); + } else { + return Err(anyhow!("--env requires KEY=VALUE, got: {}", spec)); + } + } + + let sandbox_name = args.name.clone().unwrap_or_else(|| network_registry::next_name()); + + // Handle --image: extract rootfs, set chroot, get default cmd + let image_cmd: Option>; + if let Some(ref img) = args.image { + let rootfs = sandlock_core::image::extract(img, None)?; + builder = builder.chroot(rootfs); + // Add standard paths inside the chroot + builder = builder.fs_read("/usr").fs_read("/lib").fs_read("/lib64") + .fs_read("/bin").fs_read("/sbin").fs_read("/etc") + .fs_read("/proc").fs_read("/dev"); + if args.cmd.is_empty() { + image_cmd = Some(sandlock_core::image::inspect_cmd(img)?); + } else { + image_cmd = None; + } + } else { + image_cmd = None; + } + + // Derive the effective command: profile's [program] section supplies a + // default; a trailing positional command on the CLI overrides it. + let profile_cmd: Option> = if args.cmd.is_empty() && args.exec_shell.is_none() && image_cmd.is_none() { + if let Some(spec) = profile_program_spec { + if let Some(exec) = spec.exec { + let exec_str = exec.into_os_string().into_string() + .map_err(|_| anyhow!("non-UTF-8 exec path in profile"))?; + let mut v = vec![exec_str]; + v.extend(spec.args); + Some(v) + } else { + None + } + } else { + None + } + } else { + None + }; + + if args.exec_shell.is_none() && args.cmd.is_empty() && image_cmd.is_none() && profile_cmd.is_none() { + return Err(anyhow!("no command specified (no trailing command and no [program].exec in profile)")); + } + + 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()] + } else if let Some(ref ic) = image_cmd { + ic.iter().map(|s| s.as_str()).collect() + } else if !args.cmd.is_empty() { + args.cmd.iter().map(|s| s.as_str()).collect() + } else if let Some(ref pc) = profile_cmd { + pc.iter().map(|s| s.as_str()).collect() + } else { + // Unreachable: the check above would have returned an error. + unreachable!("no command source available") + }; + + // Bake the instance name into the sandbox so all lifecycle methods use it. + let mut policy = policy.with_name(sandbox_name.clone()); + + let result = if args.dry_run { + if policy.workdir.is_none() { + return Err(anyhow!("--dry-run requires --workdir")); + } + let dr = if let Some(secs) = args.timeout { + tokio::time::timeout( + std::time::Duration::from_secs(secs), + policy.dry_run_interactive(&cmd_strs) + ).await.unwrap_or_else(|_| { + eprintln!("sandlock: timeout after {}s", secs); + std::process::exit(124); + })? + } else { + policy.dry_run_interactive(&cmd_strs).await? + }; + + if dr.changes.is_empty() { + eprintln!("sandlock: dry-run: no filesystem changes"); + } else { + eprintln!("sandlock: dry-run: filesystem changes:"); + for change in &dr.changes { + eprintln!("{}", change); + } + } + dr.run_result + } else if policy.port_remap { + // Use spawn+wait so we can register/unregister network state. + + // Set up callback to update registry on each port bind. + let reg_name = sandbox_name.clone(); + policy.set_on_bind(move |ports| { + let _ = network_registry::update_ports(®_name, ports.clone()); + }); + + policy.spawn(&cmd_strs).await?; + + let pid = policy.pid().unwrap_or(0); + let registered_hosts: Vec = policy + .net_allow + .iter() + .filter_map(|r| r.host.clone()) + .collect(); + if let Err(e) = network_registry::register( + &sandbox_name, pid, std::collections::HashMap::new(), + registered_hosts, + None, // virtual_etc_hosts populated by core at runtime + ) { + eprintln!("sandlock: network registry: {}", e); + } + + let result = if let Some(secs) = args.timeout { + match tokio::time::timeout( + std::time::Duration::from_secs(secs), + policy.wait() + ).await { + Ok(r) => r?, + Err(_) => { + let _ = network_registry::unregister(&sandbox_name); + eprintln!("sandlock: timeout after {}s", secs); + std::process::exit(124); + } + } + } else { + policy.wait().await? + }; + let _ = network_registry::unregister(&sandbox_name); + result + } else if let Some(secs) = args.timeout { + tokio::time::timeout( + std::time::Duration::from_secs(secs), + policy.run_interactive(&cmd_strs) + ).await.unwrap_or_else(|_| { + eprintln!("sandlock: timeout after {}s", secs); + std::process::exit(124); + })? + } else { + policy.run_interactive(&cmd_strs).await? + }; + + if let Some(fd) = args.status_fd { + use std::io::Write as _; + use std::os::unix::io::FromRawFd; + use sandlock_core::ExitStatus as SandlockExitStatus; + let (code, signal) = match &result.exit_status { + SandlockExitStatus::Code(c) => (*c, None), + SandlockExitStatus::Signal(s) => (-1, Some(*s)), + SandlockExitStatus::Killed => (-1, None), + SandlockExitStatus::Timeout => (-1, None), + }; + let status = SandboxStatus { exit_code: code, signal }; + if let Ok(json) = serde_json::to_string(&status) { + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + let _ = writeln!(file, "{}", json); + std::mem::forget(file); // Don't close the fd + } + } + + std::process::exit(result.code().unwrap_or(1)); +} + /// Validate that no flags incompatible with --no-supervisor are set. -#[allow(clippy::too_many_arguments)] -fn validate_no_supervisor( - max_memory: &Option, - max_processes: &Option, - max_cpu: &Option, - max_open_files: &Option, - timeout: &Option, - net_allow: &[String], - net_bind: &[u16], - http_allow: &[String], - http_deny: &[String], - http_ports: &[u16], - num_cpus: &Option, - random_seed: &Option, - time_start: &Option, - no_randomize_memory: bool, - no_huge_pages: bool, - deterministic_dirs: bool, - name: &Option, - chroot: &Option, - image: &Option, - uid: &Option, - workdir: &Option, - cwd: &Option, - fs_isolation: &Option, - fs_storage: &Option, - max_disk: &Option, - port_remap: bool, - cpu_cores: &[u32], - gpu_devices: &[u32], - dry_run: bool, - status_fd: &Option, - fs_deny: &[String], - fs_mount: &[String], -) -> Result<()> { +fn validate_no_supervisor(args: &RunArgs) -> Result<()> { + let pb = &args.sandbox_builder; let mut bad = Vec::new(); - if max_memory.is_some() { bad.push("--max-memory"); } - if max_processes.is_some() { bad.push("--max-processes"); } - if max_cpu.is_some() { bad.push("--max-cpu"); } - if max_open_files.is_some() { bad.push("--max-open-files"); } - if timeout.is_some() { bad.push("--timeout"); } - if !net_allow.is_empty() { bad.push("--net-allow"); } - if !net_bind.is_empty() { bad.push("--net-bind"); } - if !http_allow.is_empty() { bad.push("--http-allow"); } - if !http_deny.is_empty() { bad.push("--http-deny"); } - if !http_ports.is_empty() { bad.push("--http-port"); } - if num_cpus.is_some() { bad.push("--num-cpus"); } - if random_seed.is_some() { bad.push("--random-seed"); } - if time_start.is_some() { bad.push("--time-start"); } - if no_randomize_memory { bad.push("--no-randomize-memory"); } - if no_huge_pages { bad.push("--no-huge-pages"); } - if deterministic_dirs { bad.push("--deterministic-dirs"); } - if name.is_some() { bad.push("--name"); } - if chroot.is_some() { bad.push("--chroot"); } - if image.is_some() { bad.push("--image"); } - if uid.is_some() { bad.push("--uid"); } - if workdir.is_some() { bad.push("--workdir"); } - if cwd.is_some() { bad.push("--cwd"); } - if fs_isolation.is_some() { bad.push("--fs-isolation"); } - if fs_storage.is_some() { bad.push("--fs-storage"); } - if max_disk.is_some() { bad.push("--max-disk"); } - if port_remap { bad.push("--port-remap"); } - if !cpu_cores.is_empty() { bad.push("--cpu-cores"); } - if !gpu_devices.is_empty() { bad.push("--gpu"); } - if dry_run { bad.push("--dry-run"); } - if status_fd.is_some() { bad.push("--status-fd"); } - if !fs_deny.is_empty() { bad.push("--fs-deny"); } - if !fs_mount.is_empty() { bad.push("--fs-mount"); } + if args.max_memory.is_some() { bad.push("--max-memory"); } + if pb.max_processes.is_some() { bad.push("--max-processes"); } + if pb.max_cpu.is_some() { bad.push("--max-cpu"); } + if pb.max_open_files.is_some() { bad.push("--max-open-files"); } + if args.timeout.is_some() { bad.push("--timeout"); } + if !pb.net_allow.is_empty() { bad.push("--net-allow"); } + if !pb.net_bind.is_empty() { bad.push("--net-bind"); } + if !pb.http_allow.is_empty() { bad.push("--http-allow"); } + if !pb.http_deny.is_empty() { bad.push("--http-deny"); } + if !pb.http_ports.is_empty() { bad.push("--http-port"); } + if pb.num_cpus.is_some() { bad.push("--num-cpus"); } + if pb.random_seed.is_some() { bad.push("--random-seed"); } + if args.time_start.is_some() { bad.push("--time-start"); } + if pb.no_randomize_memory { bad.push("--no-randomize-memory"); } + if pb.no_huge_pages { bad.push("--no-huge-pages"); } + if pb.deterministic_dirs { bad.push("--deterministic-dirs"); } + if args.name.is_some() { bad.push("--name"); } + if pb.chroot.is_some() { bad.push("--chroot"); } + if args.image.is_some() { bad.push("--image"); } + if pb.uid.is_some() { bad.push("--uid"); } + if pb.workdir.is_some() { bad.push("--workdir"); } + if pb.cwd.is_some() { bad.push("--cwd"); } + if args.fs_isolation.is_some() { bad.push("--fs-isolation"); } + if pb.fs_storage.is_some() { bad.push("--fs-storage"); } + if args.max_disk.is_some() { bad.push("--max-disk"); } + if pb.port_remap { bad.push("--port-remap"); } + if !args.cpu_cores.is_empty() { bad.push("--cpu-cores"); } + if !args.gpu_devices.is_empty() { bad.push("--gpu"); } + if args.dry_run { bad.push("--dry-run"); } + if args.status_fd.is_some() { bad.push("--status-fd"); } + if !pb.fs_denied.is_empty() { bad.push("--fs-deny"); } + if !args.fs_mount.is_empty() { bad.push("--fs-mount"); } if !bad.is_empty() { return Err(anyhow!( @@ -670,9 +693,73 @@ fn validate_no_supervisor( Ok(()) } +fn profile_source(args: &RunArgs) -> String { + args.profile.as_deref() + .map(|n| format!("profile {n}")) + .unwrap_or_else(|| { + let path = args.profile_file + .as_ref() + .expect("profile_source called without a loaded profile"); + format!("profile file {}", path.display()) + }) +} + +/// Validate profile fields against the smaller no-supervisor execution model. +/// +/// No-supervisor mode only applies Landlock filesystem allow rules, the +/// deny-only seccomp blocklist, and environment changes before exec. Reject +/// profile fields that require the supervisor or other setup paths so profile +/// users do not get a silently weakened sandbox. +fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> { + let mut bad = Vec::new(); + + if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); } + if !profile.net_allow.is_empty() { bad.push("[network].allow"); } + if !profile.net_bind.is_empty() { bad.push("[network].bind"); } + if profile.port_remap { bad.push("[network].port_remap"); } + if !profile.http_allow.is_empty() { bad.push("[http].allow"); } + if !profile.http_deny.is_empty() { bad.push("[http].deny"); } + if !profile.http_ports.is_empty() { bad.push("[http].ports"); } + if profile.http_ca.is_some() { bad.push("[config].http_ca"); } + if profile.http_key.is_some() { bad.push("[config].http_key"); } + if profile.max_memory.is_some() { bad.push("[limits].memory"); } + if profile.max_processes != 64 { bad.push("[limits].processes"); } + if profile.max_open_files.is_some() { bad.push("[limits].open_files"); } + if profile.max_cpu.is_some() { bad.push("[limits].cpu"); } + if profile.max_disk.is_some() { bad.push("[limits].disk"); } + if profile.gpu_devices.is_some() { bad.push("[limits].gpu_devices"); } + if profile.cpu_cores.is_some() { bad.push("[limits].cpu_cores"); } + if profile.num_cpus.is_some() { bad.push("[limits].num_cpus"); } + if profile.random_seed.is_some() { bad.push("[determinism].random_seed"); } + if profile.time_start.is_some() { bad.push("[determinism].time_start"); } + if profile.deterministic_dirs { bad.push("[determinism].deterministic_dirs"); } + if profile.no_randomize_memory { bad.push("[determinism].no_randomize_memory"); } + if profile.no_huge_pages { bad.push("[program].no_huge_pages"); } + if profile.no_coredump { bad.push("[program].no_coredump"); } + if profile.fs_isolation != FsIsolation::None { bad.push("[filesystem].isolation"); } + if profile.workdir.is_some() { bad.push("[config].workdir"); } + if profile.fs_storage.is_some() { bad.push("[config].fs_storage"); } + if profile.cwd.is_some() { bad.push("[program].cwd"); } + if profile.uid.is_some() { bad.push("[program].uid"); } + if profile.chroot.is_some() { bad.push("[filesystem].chroot"); } + if !profile.fs_mount.is_empty() { bad.push("[filesystem].mount"); } + if profile.on_exit != BranchAction::Commit { bad.push("[filesystem].on_exit"); } + if profile.on_error != BranchAction::Abort { bad.push("[filesystem].on_error"); } + + if !bad.is_empty() { + return Err(anyhow!( + "--no-supervisor is incompatible with {} field(s): {}", + source, + bad.join(", ") + )); + } + + Ok(()) +} + /// Execute a command with no-supervisor confinement. /// Applies Landlock + deny-only seccomp filter, handles env, then execs. -fn no_supervisor_exec(policy: &Policy, cmd: &[&str]) -> Result<()> { +fn no_supervisor_exec(policy: &Sandbox, cmd: &[&str]) -> Result<()> { use std::ffi::CString; // 1. Apply Landlock confinement (sets NO_NEW_PRIVS + Landlock rules) diff --git a/crates/sandlock-cli/tests/profile_integration.rs b/crates/sandlock-cli/tests/profile_integration.rs new file mode 100644 index 0000000..1c268a0 --- /dev/null +++ b/crates/sandlock-cli/tests/profile_integration.rs @@ -0,0 +1,147 @@ +use std::process::Command; + +fn sandlock_bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_sandlock")) +} + +#[test] +fn profile_program_section_supplies_command() { + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("p.toml"); + std::fs::write(&profile_path, r#" + [program] + exec = "/bin/true" + + [filesystem] + read = ["/usr", "/lib", "/lib64", "/bin", "/etc"] + "#).unwrap(); + + let out = sandlock_bin() + .args(["run", "--profile-file", profile_path.to_str().unwrap()]) + .output() + .expect("spawn sandlock"); + + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn trailing_command_overrides_profile_program_section() { + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("p.toml"); + // Profile says exec = "/bin/false" — should be overridden by CLI command. + std::fs::write(&profile_path, r#" + [program] + exec = "/bin/false" + + [filesystem] + read = ["/usr", "/lib", "/lib64", "/bin", "/etc"] + "#).unwrap(); + + let out = sandlock_bin() + .args(["run", "--profile-file", profile_path.to_str().unwrap(), "--", "/bin/true"]) + .output() + .expect("spawn sandlock"); + + assert!(out.status.success(), "trailing command /bin/true should win over profile's /bin/false; stderr: {}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn profile_with_args_are_passed_to_command() { + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("p.toml"); + std::fs::write(&profile_path, r#" + [program] + exec = "/bin/sh" + args = ["-c", "exit 0"] + + [filesystem] + read = ["/usr", "/lib", "/lib64", "/bin", "/etc"] + "#).unwrap(); + + let out = sandlock_bin() + .args(["run", "--profile-file", profile_path.to_str().unwrap()]) + .output() + .expect("spawn sandlock"); + + assert!(out.status.success(), "profile-supplied args should work; stderr: {}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn missing_exec_and_no_trailing_command_is_error() { + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("p.toml"); + // Profile has no [program] section at all. + std::fs::write(&profile_path, r#" + [filesystem] + read = ["/usr"] + "#).unwrap(); + + let out = sandlock_bin() + .args(["run", "--profile-file", profile_path.to_str().unwrap()]) + .output() + .expect("spawn sandlock"); + + assert!(!out.status.success(), "should fail when no command source is available"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no command") || stderr.contains("exec"), + "error message should mention missing command; stderr: {}", stderr + ); +} + +#[test] +fn profile_by_name_loads_program_section() { + let tmp = tempfile::tempdir().unwrap(); + // Sandlock's profile_dir() honors XDG_CONFIG_HOME if set. + let profiles_dir = tmp.path().join("sandlock").join("profiles"); + std::fs::create_dir_all(&profiles_dir).unwrap(); + let profile_path = profiles_dir.join("by-name-test.toml"); + std::fs::write(&profile_path, r#" + [program] + exec = "/bin/true" + + [filesystem] + read = ["/usr", "/lib", "/lib64", "/bin", "/etc"] + "#).unwrap(); + + let out = std::process::Command::new(env!("CARGO_BIN_EXE_sandlock")) + .env("XDG_CONFIG_HOME", tmp.path()) + .args(["run", "--profile", "by-name-test"]) + .output() + .expect("spawn sandlock"); + + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn no_supervisor_rejects_supervisor_only_profile_fields() { + let tmp = tempfile::tempdir().unwrap(); + let profile_path = tmp.path().join("p.toml"); + std::fs::write(&profile_path, r#" + [program] + exec = "/bin/true" + + [filesystem] + read = ["/usr", "/lib", "/lib64", "/bin", "/etc"] + + [network] + allow = ["example.com:443"] + "#).unwrap(); + + let out = sandlock_bin() + .args(["run", "--no-supervisor", "--profile-file", profile_path.to_str().unwrap()]) + .output() + .expect("spawn sandlock"); + + assert!(!out.status.success(), "--no-supervisor should reject network rules from profiles"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--no-supervisor") && stderr.contains("[network].allow"), + "stderr should explain incompatible profile field; stderr: {}", + stderr, + ); +} diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index c02de58..cee818a 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -22,8 +22,14 @@ bincode = "1" serde_json = "1" walkdir = "2" toml = "0.8" +jiff = "0.2" pathdiff = "0.2" hudsucker = "0.22" +clap = { version = "4", features = ["derive"], optional = true } + +[features] +default = [] +cli = ["dep:clap"] [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/sandlock-core/examples/openat_audit.rs b/crates/sandlock-core/examples/openat_audit.rs index cd2f2f4..b28a1ca 100644 --- a/crates/sandlock-core/examples/openat_audit.rs +++ b/crates/sandlock-core/examples/openat_audit.rs @@ -25,7 +25,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use sandlock_core::seccomp::notif::NotifAction; -use sandlock_core::{HandlerCtx, Policy, Sandbox}; +use sandlock_core::{HandlerCtx, Sandbox}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -37,7 +37,7 @@ async fn main() -> Result<(), Box> { let cmd_ref: Vec<&str> = cmd.iter().map(String::as_str).collect(); // Minimal policy: read /usr, /lib, /etc, /proc; write /tmp. - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read("/lib64") @@ -61,13 +61,12 @@ async fn main() -> Result<(), Box> { } }; - let result = Sandbox::run_with_extra_handlers( - &policy, - Some("openat-audit"), - &cmd_ref, - [(libc::SYS_openat, audit)], - ) - .await?; + let result = policy.clone().with_name("openat-audit") + .run_with_extra_handlers( + &cmd_ref, + [(libc::SYS_openat, audit)], + ) + .await?; println!( "exit={:?} opens={} stdout={:?}", diff --git a/crates/sandlock-core/src/checkpoint.rs b/crates/sandlock-core/src/checkpoint.rs index 5e573e3..bba588a 100644 --- a/crates/sandlock-core/src/checkpoint.rs +++ b/crates/sandlock-core/src/checkpoint.rs @@ -1,6 +1,6 @@ use serde::{Serialize, Deserialize}; -use crate::policy::Policy; -use crate::error::{SandlockError, SandboxError}; +use crate::sandbox::Sandbox; +use crate::error::{SandlockError, SandboxRuntimeError}; use std::io; use std::path::{Path, PathBuf}; @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize)] pub struct Checkpoint { pub name: String, - pub policy: Policy, + pub policy: Sandbox, pub process_state: ProcessState, pub fd_table: Vec, pub cow_snapshot: Option, @@ -294,32 +294,32 @@ fn parse_fdinfo(pid: i32, fd: i32) -> io::Result<(i32, u64)> { /// Capture a checkpoint from a running, stopped sandbox. /// The sandbox must already be frozen (SIGSTOP'd and fork-held). -pub(crate) fn capture(pid: i32, policy: &Policy) -> Result { +pub(crate) fn capture(pid: i32, policy: &Sandbox) -> Result { // Seize via ptrace (PTRACE_SEIZE + PTRACE_INTERRUPT — doesn't auto-SIGSTOP) ptrace_seize(pid).map_err(|e| { - SandlockError::Sandbox(SandboxError::Child(format!("ptrace seize: {}", e))) + SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace seize: {}", e))) })?; // Capture registers let regs = ptrace_getregs(pid).map_err(|e| { - SandlockError::Sandbox(SandboxError::Child(format!("ptrace getregs: {}", e))) + SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace getregs: {}", e))) })?; // Capture memory maps let maps = - parse_proc_maps(pid).map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + parse_proc_maps(pid).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; // Capture memory data let memory_data = - capture_memory(pid, &maps).map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + capture_memory(pid, &maps).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; // Capture fd table let fd_table = - capture_fd_table(pid).map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + capture_fd_table(pid).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; // Detach ptrace_detach(pid).map_err(|e| { - SandlockError::Sandbox(SandboxError::Child(format!("ptrace detach: {}", e))) + SandlockError::Runtime(SandboxRuntimeError::Child(format!("ptrace detach: {}", e))) })?; // Capture cwd and exe from /proc @@ -354,7 +354,7 @@ pub(crate) fn capture(pid: i32, policy: &Policy) -> Result/ // ├── meta.json # name, cow_snapshot -// ├── policy.dat # bincode-serialized Policy +// ├── policy.dat # bincode-serialized Sandbox // ├── app_state.bin # optional raw app state // └── process/ // ├── info.json # pid, cwd, exe @@ -366,17 +366,17 @@ pub(crate) fn capture(pid: i32, policy: &Policy) -> Result.bin # raw memory contents per segment fn io_err(e: impl std::fmt::Display) -> SandlockError { - SandlockError::Sandbox(SandboxError::Child(e.to_string())) + SandlockError::Runtime(SandboxRuntimeError::Child(e.to_string())) } fn write_json(path: &Path, val: &T) -> Result<(), SandlockError> { let json = serde_json::to_string_pretty(val).map_err(io_err)?; - std::fs::write(path, json).map_err(|e| SandlockError::Sandbox(SandboxError::Io(e))) + std::fs::write(path, json).map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e))) } fn read_json Deserialize<'de>>(path: &Path) -> Result { let data = std::fs::read_to_string(path) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; serde_json::from_str(&data).map_err(io_err) } @@ -422,10 +422,10 @@ impl Checkpoint { let tmp = dir.with_extension("tmp"); if tmp.exists() { std::fs::remove_dir_all(&tmp) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; } std::fs::create_dir_all(&tmp) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; let res = self.save_inner(&tmp); if res.is_err() { @@ -436,10 +436,10 @@ impl Checkpoint { // Atomic rename into place if dir.exists() { std::fs::remove_dir_all(dir) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; } std::fs::rename(&tmp, dir) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; Ok(()) } @@ -454,18 +454,18 @@ impl Checkpoint { // policy.dat (bincode — complex struct, not human-readable anyway) let policy_bytes = bincode::serialize(&self.policy).map_err(io_err)?; std::fs::write(dir.join("policy.dat"), &policy_bytes) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; // app_state.bin if let Some(ref state) = self.app_state { std::fs::write(dir.join("app_state.bin"), state) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; } // process/ let proc_dir = dir.join("process"); std::fs::create_dir(&proc_dir) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; // process/info.json write_json(&proc_dir.join("info.json"), &InfoJson { @@ -511,20 +511,20 @@ impl Checkpoint { // process/threads/0.bin — main thread register state let threads_dir = proc_dir.join("threads"); std::fs::create_dir(&threads_dir) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; let reg_bytes: Vec = self.process_state.regs.iter() .flat_map(|r| r.to_le_bytes()) .collect(); std::fs::write(threads_dir.join("0.bin"), ®_bytes) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; // process/memory/.bin — 1:1 with memory_map.json entries let mem_dir = proc_dir.join("memory"); std::fs::create_dir(&mem_dir) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; for (i, seg) in self.process_state.memory_data.iter().enumerate() { std::fs::write(mem_dir.join(format!("{}.bin", i)), &seg.data) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; } Ok(()) @@ -533,7 +533,7 @@ impl Checkpoint { /// Load a checkpoint from a directory. pub fn load(dir: &Path) -> Result { if !dir.is_dir() { - return Err(SandlockError::Sandbox(SandboxError::Child( + return Err(SandlockError::Runtime(SandboxRuntimeError::Child( format!("Checkpoint not found: {}", dir.display()), ))); } @@ -543,14 +543,14 @@ impl Checkpoint { // policy.dat let policy_bytes = std::fs::read(dir.join("policy.dat")) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; - let policy: Policy = bincode::deserialize(&policy_bytes).map_err(io_err)?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; + let policy: Sandbox = bincode::deserialize(&policy_bytes).map_err(io_err)?; // app_state.bin let app_state_path = dir.join("app_state.bin"); let app_state = if app_state_path.exists() { Some(std::fs::read(&app_state_path) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?) + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?) } else { None }; @@ -582,7 +582,7 @@ impl Checkpoint { // process/threads/0.bin let reg_bytes = std::fs::read(proc_dir.join("threads").join("0.bin")) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; let regs: Vec = reg_bytes.chunks_exact(8) .map(|chunk| u64::from_le_bytes(chunk.try_into().unwrap())) .collect(); @@ -593,7 +593,7 @@ impl Checkpoint { for (i, map) in maps_json.iter().enumerate() { let seg_path = mem_dir.join(format!("{}.bin", i)); let data = std::fs::read(&seg_path) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; memory_data.push(MemorySegment { start: map.start, data, diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 3cf396c..8777b68 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -6,7 +6,7 @@ use std::io; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use crate::arch; -use crate::policy::{FsIsolation, Policy}; +use crate::sandbox::{FsIsolation, Sandbox}; use crate::seccomp::bpf::{self, stmt, jump}; use crate::sys::structs::{ AF_INET, AF_INET6, @@ -223,7 +223,7 @@ pub fn syscall_name_to_nr(name: &str) -> Option { "readlink" => arch::SYS_READLINK?, "futimesat" => arch::SYS_FUTIMESAT?, "fork" => arch::SYS_FORK?, - // SysV IPC (gated by --allow-sysv-ipc; denied by default) + // SysV IPC (gated by extra_allow_syscalls=["sysv_ipc"]; denied by default) "shmget" => libc::SYS_shmget, "shmat" => libc::SYS_shmat, "shmdt" => libc::SYS_shmdt, @@ -242,11 +242,11 @@ pub fn syscall_name_to_nr(name: &str) -> Option { } // ============================================================ -// Policy → syscall lists +// Sandbox → syscall lists // ============================================================ /// Determine which syscalls need `SECCOMP_RET_USER_NOTIF`. -pub fn notif_syscalls(policy: &Policy, sandbox_name: Option<&str>) -> Vec { +pub fn notif_syscalls(policy: &Sandbox, sandbox_name: Option<&str>) -> Vec { let mut nrs = vec![ libc::SYS_clone as u32, libc::SYS_clone3 as u32, @@ -272,9 +272,9 @@ pub fn notif_syscalls(policy: &Policy, sandbox_name: Option<&str>) -> Vec { // shmget is in notif only when SysV IPC is allowed. The BPF // layout puts notif JEQs before deny JEQs, so a syscall on // both lists would notify (RET_USER_NOTIF) and silently - // bypass the kernel-level deny. When --allow-sysv-ipc is - // unset, shmget belongs only on the blocklist. - if policy.allow_sysv_ipc { + // bypass the kernel-level deny. When extra_allow_syscalls does not contain "sysv_ipc", + // shmget belongs only on the blocklist. + if policy.allows_sysv_ipc() { nrs.push(libc::SYS_shmget as u32); } } @@ -445,16 +445,16 @@ pub fn notif_syscalls(policy: &Policy, sandbox_name: Option<&str>) -> Vec { } /// Resolve `NO_SUPERVISOR_BLOCKLIST_SYSCALLS` names to numbers, plus -/// SysV IPC syscalls when `policy.allow_sysv_ipc` is false. -pub fn no_supervisor_blocklist_syscall_numbers(policy: &Policy) -> Vec { +/// SysV IPC syscalls when `policy.allows_sysv_ipc()` is false. +pub fn no_supervisor_blocklist_syscall_numbers(policy: &Sandbox) -> Vec { use crate::sys::structs::NO_SUPERVISOR_BLOCKLIST_SYSCALLS; let mut nrs: Vec = NO_SUPERVISOR_BLOCKLIST_SYSCALLS .iter() .copied() - .chain(policy.block_syscalls.iter().map(String::as_str)) + .chain(policy.extra_deny_syscalls.iter().map(String::as_str)) .filter_map(|n| syscall_name_to_nr(n)) .collect(); - if !policy.allow_sysv_ipc { + if !policy.allows_sysv_ipc() { for name in SYSV_IPC_BLOCKLIST_SYSCALLS { if let Some(nr) = syscall_name_to_nr(name) { if !nrs.contains(&nr) { @@ -471,15 +471,15 @@ pub fn no_supervisor_blocklist_syscall_numbers(policy: &Policy) -> Vec { /// Resolve the default syscall blocklist plus policy extras to numbers. /// /// SysV IPC syscalls are appended to the resolved blocklist when -/// `policy.allow_sysv_ipc` is false. -pub fn blocklist_syscall_numbers(policy: &Policy) -> Vec { +/// `policy.allows_sysv_ipc()` is false. +pub fn blocklist_syscall_numbers(policy: &Sandbox) -> Vec { let mut nrs: Vec = DEFAULT_BLOCKLIST_SYSCALLS .iter() .copied() - .chain(policy.block_syscalls.iter().map(String::as_str)) + .chain(policy.extra_deny_syscalls.iter().map(String::as_str)) .filter_map(|n| syscall_name_to_nr(n)) .collect(); - if !policy.allow_sysv_ipc { + if !policy.allows_sysv_ipc() { for name in SYSV_IPC_BLOCKLIST_SYSCALLS { if let Some(nr) = syscall_name_to_nr(name) { if !nrs.contains(&nr) { @@ -501,7 +501,7 @@ pub fn blocklist_syscall_numbers(policy: &Policy) -> Vec { /// - ioctl: block TIOCSTI, TIOCLINUX, SIOCGIF*, SIOCETHTOOL /// - prctl: block PR_SET_DUMPABLE, PR_SET_SECUREBITS, PR_SET_PTRACER /// - socket: block SOCK_RAW/SOCK_DGRAM on AF_INET/AF_INET6 (with type mask) -pub fn arg_filters(policy: &Policy) -> Vec { +pub fn arg_filters(policy: &Sandbox) -> Vec { let ret_errno = SECCOMP_RET_ERRNO | EPERM as u32; let nr_clone = libc::SYS_clone as u32; let nr_ioctl = libc::SYS_ioctl as u32; @@ -578,7 +578,7 @@ pub fn arg_filters(policy: &Policy) -> Vec { // net_allow. The kernel ping socket uses SOCK_DGRAM with // IPPROTO_ICMP, so the same type bit gates both — destination // filtering at sendto (Phase 2) is what separates them per-rule. - use crate::policy::Protocol; + use crate::sandbox::Protocol; let any_udp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Udp); let any_icmp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Icmp); let mut blocked_types: Vec = Vec::new(); @@ -721,7 +721,7 @@ fn write_id_maps_overflow() { /// child never outlives the fork point because `confine_child` either execs /// or exits. pub(crate) struct ChildSpawnArgs<'a> { - pub policy: &'a Policy, + pub sandbox: &'a Sandbox, pub cmd: &'a [CString], pub pipes: &'a PipePair, pub cow_config: Option<&'a ChildMountConfig>, @@ -742,7 +742,7 @@ pub(crate) struct ChildSpawnArgs<'a> { /// `_exit(127)` on any error. pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { let ChildSpawnArgs { - policy, + sandbox, cmd, pipes, cow_config, @@ -790,7 +790,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } // 4. Optional: disable ASLR - if policy.no_randomize_memory { + if sandbox.no_randomize_memory { const ADDR_NO_RANDOMIZE: libc::c_ulong = 0x0040000; // Read current personality first (0xffffffff = query), then OR in the flag. let current = unsafe { libc::personality(0xffffffff) }; @@ -803,7 +803,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } // 4b. Optional: CPU core binding - if let Some(ref cores) = policy.cpu_cores { + if let Some(ref cores) = sandbox.cpu_cores { if !cores.is_empty() { let mut set = unsafe { std::mem::zeroed::() }; unsafe { libc::CPU_ZERO(&mut set) }; @@ -824,14 +824,14 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } // 5. Optional: disable THP - if policy.no_huge_pages { + if sandbox.no_huge_pages { if unsafe { libc::prctl(libc::PR_SET_THP_DISABLE, 1, 0, 0, 0) } != 0 { fail!("prctl(PR_SET_THP_DISABLE)"); } } // 5c. Optional: disable core dumps - if policy.no_coredump { + if sandbox.no_coredump { // Set RLIMIT_CORE to 0 — the kernel will not write a core file. // We intentionally do NOT call prctl(PR_SET_DUMPABLE, 0) because // that would break pidfd_getfd which the supervisor needs. @@ -849,7 +849,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { // 5b. User namespace for --uid mapping (when not using OverlayFS COW, // which sets up its own user namespace) - if let Some(target_uid) = policy.uid { + if let Some(target_uid) = sandbox.uid { if cow_config.is_none() { if unsafe { libc::unshare(libc::CLONE_NEWUSER) } != 0 { fail!("unshare(CLONE_NEWUSER)"); @@ -910,16 +910,16 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { // 6. Optional: change working directory // cwd controls where the child starts; workdir is only for COW - let effective_cwd = if let Some(ref cwd) = policy.cwd { - if let Some(ref chroot_root) = policy.chroot { + let effective_cwd = if let Some(ref cwd) = sandbox.cwd { + if let Some(ref chroot_root) = sandbox.chroot { Some(chroot_root.join(cwd.strip_prefix("/").unwrap_or(cwd))) } else { Some(cwd.clone()) } - } else if let Some(ref chroot_root) = policy.chroot { + } else if let Some(ref chroot_root) = sandbox.chroot { // Default to chroot root Some(chroot_root.to_path_buf()) - } else if let Some(ref workdir) = policy.workdir { + } else if let Some(ref workdir) = sandbox.workdir { // Default to workdir when set (COW working directory) Some(workdir.clone()) } else { @@ -942,13 +942,13 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } // 8. Apply Landlock confinement (IRREVERSIBLE) - if let Err(e) = crate::landlock::confine(policy) { + if let Err(e) = crate::landlock::confine(sandbox) { fail!(format!("landlock: {}", e)); } // 9. Assemble and install seccomp filter (IRREVERSIBLE) - let deny = blocklist_syscall_numbers(policy); - let args = arg_filters(policy); + let deny = blocklist_syscall_numbers(sandbox); + let args = arg_filters(sandbox); let mut keep_fd: i32 = -1; if nested { @@ -973,7 +973,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { // them and the handler silently never fires. We merge `extra_syscalls` // into the notif list and dedup so each syscall produces exactly one // JEQ in the assembled program. - let mut notif = notif_syscalls(policy, sandbox_name); + let mut notif = notif_syscalls(sandbox, sandbox_name); if !extra_syscalls.is_empty() { notif.extend_from_slice(extra_syscalls); } @@ -1007,7 +1007,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { } // Mark this process as confined for in-process nesting detection - crate::sandbox::CONFINED.store(true, std::sync::atomic::Ordering::Relaxed); + crate::process::CONFINED.store(true, std::sync::atomic::Ordering::Relaxed); // 10. Wait for parent to signal ready match read_u32_fd(pipes.ready_r.as_raw_fd()) { @@ -1023,18 +1023,18 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { close_fds_above(2, &fds_to_keep); // 13. Apply environment - if policy.clean_env { + if sandbox.clean_env { // Clear all env vars first for (key, _) in std::env::vars_os() { std::env::remove_var(&key); } } - for (key, value) in &policy.env { + for (key, value) in &sandbox.env { std::env::set_var(key, value); } // 13b. GPU device visibility - if let Some(ref devices) = policy.gpu_devices { + if let Some(ref devices) = sandbox.gpu_devices { if !devices.is_empty() { let vis = devices.iter().map(|d| d.to_string()).collect::>().join(","); std::env::set_var("CUDA_VISIBLE_DEVICES", &vis); @@ -1051,7 +1051,7 @@ pub(crate) fn confine_child(args: ChildSpawnArgs<'_>) -> ! { .chain(std::iter::once(std::ptr::null())) .collect(); - if policy.chroot.is_some() { + if sandbox.chroot.is_some() { // With chroot the seccomp handler rewrites the filename to a host path // (or /proc/self/fd/N). Pass a separate PATH_MAX buffer as the `file` // argument so the rewrite does not corrupt argv[0] — which must stay as @@ -1125,7 +1125,7 @@ mod tests { #[test] fn test_notif_syscalls_always_has_clone() { - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let nrs = notif_syscalls(&policy, None); assert!(nrs.contains(&(libc::SYS_clone as u32))); assert!(nrs.contains(&(libc::SYS_clone3 as u32))); @@ -1144,7 +1144,7 @@ mod tests { #[test] fn test_notif_syscalls_fork_gated_on_policy_fn() { let Some(fork) = arch::SYS_FORK else { return }; - let policy = Policy::builder() + let policy = Sandbox::builder() .policy_fn(|_event, _ctx| crate::policy_fn::Verdict::Allow) .build() .unwrap(); @@ -1158,9 +1158,9 @@ mod tests { // otherwise it is on the kernel blocklist and notifying would // bypass the deny (notif JEQs precede deny JEQs in the BPF // layout). - let policy = Policy::builder() - .max_memory(crate::policy::ByteSize::mib(256)) - .allow_sysv_ipc(true) + let policy = Sandbox::builder() + .max_memory(crate::sandbox::ByteSize::mib(256)) + .extra_allow_syscalls(vec!["sysv_ipc".into()]) .build() .unwrap(); let nrs = notif_syscalls(&policy, None); @@ -1173,12 +1173,12 @@ mod tests { #[test] fn test_notif_syscalls_memory_excludes_shmget_when_sysv_ipc_denied() { - // With max_memory but allow_sysv_ipc=false (the default), + // With max_memory but allows_sysv_ipc()=false (the default), // shmget must NOT be in notif: if it were, the BPF filter // would route it to RET_USER_NOTIF before reaching the deny // JEQ, silently bypassing the kernel-level deny. - let policy = Policy::builder() - .max_memory(crate::policy::ByteSize::mib(256)) + let policy = Sandbox::builder() + .max_memory(crate::sandbox::ByteSize::mib(256)) .build() .unwrap(); let nrs = notif_syscalls(&policy, None); @@ -1190,7 +1190,7 @@ mod tests { #[test] fn test_notif_syscalls_net() { - let policy = Policy::builder() + let policy = Sandbox::builder() .net_allow("example.com:443") .build() .unwrap(); @@ -1203,7 +1203,7 @@ mod tests { #[test] fn test_notif_syscalls_sandbox_name_enables_hostname_virtualization() { - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let nrs = notif_syscalls(&policy, Some("api.local")); assert!(nrs.contains(&(libc::SYS_uname as u32))); assert!(nrs.contains(&(libc::SYS_openat as u32))); @@ -1216,7 +1216,7 @@ mod tests { const SYS_FACCESSAT2: u32 = 439; // Chroot mode - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot("/tmp") .build() .unwrap(); @@ -1226,7 +1226,7 @@ mod tests { "chroot notif filter must include SYS_faccessat2 (439)"); // COW mode - let policy = Policy::builder() + let policy = Sandbox::builder() .workdir("/tmp") .build() .unwrap(); @@ -1238,7 +1238,7 @@ mod tests { #[test] fn test_blocklist_syscall_numbers_default() { - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let nrs = blocklist_syscall_numbers(&policy); // Should contain mount, ptrace, etc. assert!(nrs.contains(&(libc::SYS_mount as u32))); @@ -1255,13 +1255,13 @@ mod tests { #[test] fn test_blocklist_syscall_numbers_custom() { - let policy = Policy::builder() - .block_syscalls(vec!["mount".into(), "ptrace".into()]) + let policy = Sandbox::builder() + .extra_deny_syscalls(vec!["mount".into(), "ptrace".into()]) .build() .unwrap(); let nrs = blocklist_syscall_numbers(&policy); // User-supplied blocklist still gets SysV IPC appended - // (allow_sysv_ipc defaults to false). + // (allows_sysv_ipc() defaults to false). assert!(nrs.contains(&(libc::SYS_mount as u32))); assert!(nrs.contains(&(libc::SYS_ptrace as u32))); assert!(nrs.contains(&(libc::SYS_shmget as u32))); @@ -1269,9 +1269,9 @@ mod tests { #[test] fn test_blocklist_syscall_numbers_custom_with_sysv_ipc_allowed() { - let policy = Policy::builder() - .block_syscalls(vec!["mount".into(), "ptrace".into()]) - .allow_sysv_ipc(true) + let policy = Sandbox::builder() + .extra_deny_syscalls(vec!["mount".into(), "ptrace".into()]) + .extra_allow_syscalls(vec!["sysv_ipc".into()]) .build() .unwrap(); let nrs = blocklist_syscall_numbers(&policy); @@ -1284,8 +1284,8 @@ mod tests { #[test] fn test_blocklist_syscall_numbers_default_with_sysv_ipc_allowed() { - let policy = Policy::builder() - .allow_sysv_ipc(true) + let policy = Sandbox::builder() + .extra_allow_syscalls(vec!["sysv_ipc".into()]) .build() .unwrap(); let nrs = blocklist_syscall_numbers(&policy); @@ -1298,7 +1298,7 @@ mod tests { #[test] fn test_no_supervisor_blocklist_includes_sysv_ipc_by_default() { - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let nrs = no_supervisor_blocklist_syscall_numbers(&policy); assert!(nrs.contains(&(libc::SYS_shmget as u32))); assert!(nrs.contains(&(libc::SYS_msgget as u32))); @@ -1307,8 +1307,8 @@ mod tests { #[test] fn test_no_supervisor_blocklist_excludes_sysv_ipc_when_allowed() { - let policy = Policy::builder() - .allow_sysv_ipc(true) + let policy = Sandbox::builder() + .extra_allow_syscalls(vec!["sysv_ipc".into()]) .build() .unwrap(); let nrs = no_supervisor_blocklist_syscall_numbers(&policy); @@ -1322,7 +1322,7 @@ mod tests { use crate::sys::structs::{ BPF_JEQ, BPF_JSET, BPF_JMP, BPF_K, }; - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let filters = arg_filters(&policy); // Should contain JEQ for clone syscall nr assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) @@ -1354,7 +1354,7 @@ mod tests { fn test_arg_filters_raw_sockets() { use crate::sys::structs::{BPF_ALU, BPF_AND, BPF_JEQ, BPF_JMP, BPF_K}; // Raw sockets are blocked by default — no `icmp-raw://*` rule. - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let filters = arg_filters(&policy); // Should have AF_INET check assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) @@ -1374,7 +1374,7 @@ mod tests { fn test_arg_filters_udp_denied_by_default() { use crate::sys::structs::{BPF_JEQ, BPF_JMP, BPF_K}; // UDP is denied by default — no `udp://...` rule in net_allow. - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); let filters = arg_filters(&policy); // Should have JEQ SOCK_DGRAM assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) diff --git a/crates/sandlock-core/src/error.rs b/crates/sandlock-core/src/error.rs index 3570da5..2158f76 100644 --- a/crates/sandlock-core/src/error.rs +++ b/crates/sandlock-core/src/error.rs @@ -3,12 +3,12 @@ use thiserror::Error; /// Root error type for all sandlock operations. #[derive(Debug, Error)] pub enum SandlockError { - #[error("policy error: {0}")] - Policy(#[from] PolicyError), - #[error("sandbox error: {0}")] Sandbox(#[from] SandboxError), + #[error("process error: {0}")] + Runtime(#[from] SandboxRuntimeError), + #[error("memory protection error: {0}")] MemoryProtect(String), @@ -16,9 +16,10 @@ pub enum SandlockError { Handler(#[from] crate::seccomp::dispatch::HandlerError), } +/// Errors from sandbox configuration validation and building. #[derive(Debug, Error)] -pub enum PolicyError { - #[error("invalid policy: {0}")] +pub enum SandboxError { + #[error("invalid sandbox: {0}")] Invalid(String), #[error("fs_isolation requires workdir to be set")] @@ -31,8 +32,9 @@ pub enum PolicyError { UnsupportedForConfine(String), } +/// Errors from the sandbox process runtime (fork, confinement, child, etc.). #[derive(Debug, Error)] -pub enum SandboxError { +pub enum SandboxRuntimeError { #[error("fork failed: {0}")] Fork(#[source] std::io::Error), diff --git a/crates/sandlock-core/src/http_acl.rs b/crates/sandlock-core/src/http_acl.rs index a24f034..e861316 100644 --- a/crates/sandlock-core/src/http_acl.rs +++ b/crates/sandlock-core/src/http_acl.rs @@ -10,7 +10,7 @@ use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; use tokio::net::TcpListener; use tokio::sync::oneshot; -use crate::policy::{http_acl_check, HttpRule}; +use crate::sandbox::{http_acl_check, HttpRule}; /// Shared map from proxy client address to the original destination IP /// that the sandboxed process tried to connect to. Written by the seccomp diff --git a/crates/sandlock-core/src/image.rs b/crates/sandlock-core/src/image.rs index 0c5b70d..f83b433 100644 --- a/crates/sandlock-core/src/image.rs +++ b/crates/sandlock-core/src/image.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use crate::error::{SandboxError, SandlockError}; +use crate::error::{SandboxRuntimeError, SandlockError}; /// Default cache directory for extracted images. fn default_cache_dir() -> PathBuf { @@ -56,11 +56,11 @@ pub fn extract(image: &str, cache_dir: Option<&Path>) -> Result) -> Result Result<(), SandlockError> { std::fs::create_dir_all(rootfs) - .map_err(|e| SandboxError::Io(e))?; + .map_err(|e| SandboxRuntimeError::Io(e))?; // docker export → tar stream → extract let mut child = Command::new("docker") @@ -92,7 +92,7 @@ fn extract_container(container_id: &str, rootfs: &Path) -> Result<(), SandlockEr .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() - .map_err(|e| SandboxError::Child(format!("docker export: {}", e)))?; + .map_err(|e| SandboxRuntimeError::Child(format!("docker export: {}", e)))?; let stdout = child.stdout.take().unwrap(); @@ -104,20 +104,20 @@ fn extract_container(container_id: &str, rootfs: &Path) -> Result<(), SandlockEr .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .status() - .map_err(|e| SandboxError::Child(format!("tar extract: {}", e)))?; + .map_err(|e| SandboxRuntimeError::Child(format!("tar extract: {}", e)))?; let docker_status = child.wait() - .map_err(|e| SandboxError::Child(format!("docker export wait: {}", e)))?; + .map_err(|e| SandboxRuntimeError::Child(format!("docker export wait: {}", e)))?; if !docker_status.success() { // Clean up partial extraction let _ = std::fs::remove_dir_all(rootfs); - return Err(SandboxError::Child("docker export failed".into()).into()); + return Err(SandboxRuntimeError::Child("docker export failed".into()).into()); } if !tar_status.success() { let _ = std::fs::remove_dir_all(rootfs); - return Err(SandboxError::Child("tar extraction failed".into()).into()); + return Err(SandboxRuntimeError::Child("tar extraction failed".into()).into()); } Ok(()) @@ -134,7 +134,7 @@ pub fn inspect_cmd(image: &str) -> Result, SandlockError> { image, ]) .output() - .map_err(|_| SandboxError::Child("docker inspect failed".into()))?; + .map_err(|_| SandboxRuntimeError::Child("docker inspect failed".into()))?; if !output.status.success() { return Ok(vec!["/bin/sh".into()]); diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index f75da7d..d36f337 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -2,7 +2,7 @@ use std::os::fd::OwnedFd; use std::path::Path; use crate::error::{ConfinementError, SandlockError}; -use crate::policy::Policy; +use crate::sandbox::Sandbox; use crate::sys::structs::{ LandlockNetPortAttr, LandlockPathBeneathAttr, LandlockRulesetAttr, LANDLOCK_ACCESS_FS_EXECUTE, LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_MAKE_BLOCK, @@ -170,28 +170,28 @@ fn add_net_rule(ruleset_fd: &OwnedFd, port: u16, access: u64) -> Result<(), Conf /// Minimum Landlock ABI version required by sandlock. pub const MIN_ABI: u32 = 6; -/// Apply Landlock confinement based on the given `Policy`. +/// Apply Landlock confinement based on the given `Sandbox`. /// /// Requires Landlock ABI v6 or later. Returns an error if the kernel does /// not meet this requirement. -pub fn confine(policy: &Policy) -> Result<(), SandlockError> { +pub fn confine(policy: &Sandbox) -> Result<(), SandlockError> { confine_inner(policy, true) } /// Apply Landlock filesystem confinement without TCP bind/connect rules. -pub fn confine_filesystem(policy: &Policy) -> Result<(), SandlockError> { +pub fn confine_filesystem(policy: &Sandbox) -> Result<(), SandlockError> { confine_inner(policy, false) } -fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> { +fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError> { // Step 1 -- detect and validate ABI version. let abi = abi_version().map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; if abi < MIN_ABI { - return Err(SandlockError::Sandbox( - crate::error::SandboxError::Confinement( + return Err(SandlockError::Runtime( + crate::error::SandboxRuntimeError::Confinement( ConfinementError::InsufficientAbi { required: MIN_ABI, actual: abi, @@ -218,7 +218,7 @@ fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> // on-behalf path), so they're filtered out here — feeding them to // Landlock would either be a no-op (for unhandled protocols) or // wrongly install TCP rules from a UDP wildcard. - use crate::policy::Protocol; + use crate::sandbox::Protocol; let net_wildcard = policy .net_allow .iter() @@ -243,7 +243,7 @@ fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> let ruleset_fd = syscall::landlock_create_ruleset(&attr, std::mem::size_of::(), 0) .map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement( + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement( ConfinementError::Landlock(format!("create ruleset: {}", e)), )) })?; @@ -267,7 +267,7 @@ fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> path.as_path() }; add_path_rule(&ruleset_fd, rule_path, fs_write_mask).map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; } @@ -281,7 +281,7 @@ fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> path.as_path() }; add_path_rule(&ruleset_fd, rule_path, READ_ACCESS).map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; } @@ -310,7 +310,7 @@ fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> if handle_net { for &port in &policy.net_bind { add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; } } @@ -340,14 +340,14 @@ fn confine_inner(policy: &Policy, handle_net: bool) -> Result<(), SandlockError> } for port in connect_ports { add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_CONNECT_TCP).map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; } } // Step 6 — enforce (irreversible). syscall::landlock_restrict_self(&ruleset_fd, 0).map_err(|e| { - SandlockError::Sandbox(crate::error::SandboxError::Confinement( + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement( ConfinementError::Landlock(format!("restrict_self: {}", e)), )) })?; diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 3554063..c9d272d 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -1,8 +1,8 @@ pub mod error; -pub mod policy; +pub mod sandbox; // formerly `policy`; contains Sandbox + SandboxBuilder + Confinement pub mod profile; pub mod result; -pub mod sandbox; +pub mod process; // runtime helpers (is_nested, CONFINED); SandboxProcess removed pub(crate) mod arch; pub(crate) mod sys; pub mod landlock; @@ -29,11 +29,13 @@ pub(crate) mod http_acl; pub use error::SandlockError; pub use checkpoint::Checkpoint; -pub use policy::{ConfinePolicy, ConfinePolicyBuilder, Policy, PolicyBuilder}; +pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; pub use result::{RunResult, ExitStatus}; -pub use sandbox::Sandbox; pub use pipeline::{Stage, Pipeline, Gather}; pub use dry_run::{Change, ChangeKind, DryRunResult}; +// Sectioned-profile parsing types: ProfileInput is the top-level deserialization +// target; ProgramSpec carries [program].exec/args (not a Sandbox field). +pub use crate::profile::{ProfileInput, ProgramSpec}; // Public extension API — see docs/extension-handlers.md. pub use seccomp::dispatch::{Handler, HandlerCtx, HandlerError}; @@ -49,16 +51,16 @@ pub const MIN_LANDLOCK_ABI: u32 = landlock::MIN_ABI; /// Confine the calling process with Landlock restrictions. /// -/// This applies `PR_SET_NO_NEW_PRIVS` and Landlock rules from the policy. -/// IPC and signal isolation are always enabled. The confinement is -/// **irreversible**. +/// This applies `PR_SET_NO_NEW_PRIVS` and Landlock rules from the sandbox's +/// filesystem fields. IPC and signal isolation are always enabled. The +/// confinement is **irreversible**. /// /// This does NOT fork or exec — it confines the current process in-place. -pub fn confine(policy: &ConfinePolicy) -> Result<(), SandlockError> { +pub fn confine(confinement: &Confinement) -> Result<(), SandlockError> { // Set NO_NEW_PRIVS (required for Landlock) if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { - return Err(SandlockError::Sandbox( - error::SandboxError::Confinement( + return Err(SandlockError::Runtime( + error::SandboxRuntimeError::Confinement( error::ConfinementError::Landlock(format!( "prctl(PR_SET_NO_NEW_PRIVS): {}", std::io::Error::last_os_error() @@ -67,11 +69,11 @@ pub fn confine(policy: &ConfinePolicy) -> Result<(), SandlockError> { )); } - let mut builder = Policy::builder(); - for path in &policy.fs_readable { + let mut builder = Sandbox::builder(); + for path in &confinement.fs_readable { builder = builder.fs_read(path.clone()); } - for path in &policy.fs_writable { + for path in &confinement.fs_writable { builder = builder.fs_write(path.clone()); } let stripped = builder.build()?; diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 9101dec..0233b71 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -78,8 +78,8 @@ fn parse_port_from_sockaddr(bytes: &[u8]) -> Option { /// Returns `None` for protocols sandlock does not gate via `net_allow` /// (raw, SCTP, etc.) — the handler treats those as "no rule applies" /// which collapses to the default-deny path. -fn query_socket_protocol(fd: RawFd) -> Option { - use crate::policy::Protocol; +fn query_socket_protocol(fd: RawFd) -> Option { + use crate::sandbox::Protocol; let mut proto: libc::c_int = 0; let mut len: libc::socklen_t = std::mem::size_of::() as libc::socklen_t; let rc = unsafe { @@ -562,7 +562,7 @@ async fn send_msghdr_on_behalf( ctx: &Arc, notif_fd: RawFd, dup_fd: &std::os::unix::io::OwnedFd, - protocol: crate::policy::Protocol, + protocol: crate::sandbox::Protocol, msghdr_ptr: u64, flags: i32, ) -> Result { @@ -853,9 +853,9 @@ pub struct ResolvedNetAllowSet { /// (PortAllow::Any). A `*` host on ICMP becomes `any_ip_all_ports`, /// which the handler reads as "no destination check." pub async fn resolve_net_allow( - rules: &[crate::policy::NetAllow], + rules: &[crate::sandbox::NetAllow], ) -> io::Result { - use crate::policy::Protocol; + use crate::sandbox::Protocol; // Single shared etc_hosts for all protocols. Every concrete host // (regardless of protocol) ends up resolvable in the sandbox. @@ -945,7 +945,7 @@ pub async fn resolve_net_allow( #[cfg(test)] mod tests { use super::*; - use crate::policy::NetAllow; + use crate::sandbox::NetAllow; #[tokio::test] async fn test_resolve_net_allow_empty() { @@ -960,7 +960,7 @@ mod tests { #[tokio::test] async fn test_resolve_net_allow_concrete_host() { let rules = vec![NetAllow { - protocol: crate::policy::Protocol::Tcp, + protocol: crate::sandbox::Protocol::Tcp, host: Some("localhost".to_string()), ports: vec![80, 443], all_ports: false, @@ -980,7 +980,7 @@ mod tests { #[tokio::test] async fn test_resolve_net_allow_any_ip() { - let rules = vec![NetAllow { protocol: crate::policy::Protocol::Tcp, host: None, ports: vec![8080], all_ports: false }]; + let rules = vec![NetAllow { protocol: crate::sandbox::Protocol::Tcp, host: None, ports: vec![8080], all_ports: false }]; let resolved = resolve_net_allow(&rules).await.unwrap(); assert!(resolved.tcp.per_ip.is_empty()); assert!(resolved.tcp.any_ip_ports.contains(&8080)); @@ -991,7 +991,7 @@ mod tests { #[tokio::test] async fn test_resolve_net_allow_any_ip_all_ports() { // `:*` — fully unrestricted egress, TCP-only. - let rules = vec![NetAllow { protocol: crate::policy::Protocol::Tcp, host: None, ports: vec![], all_ports: true }]; + let rules = vec![NetAllow { protocol: crate::sandbox::Protocol::Tcp, host: None, ports: vec![], all_ports: true }]; let resolved = resolve_net_allow(&rules).await.unwrap(); assert!(resolved.tcp.any_ip_all_ports); assert!(resolved.tcp.per_ip.is_empty()); @@ -1006,7 +1006,7 @@ mod tests { async fn test_resolve_net_allow_concrete_host_all_ports() { // `localhost:*` — every port to localhost only, TCP. let rules = vec![NetAllow { - protocol: crate::policy::Protocol::Tcp, + protocol: crate::sandbox::Protocol::Tcp, host: Some("localhost".to_string()), ports: vec![], all_ports: true, @@ -1028,9 +1028,9 @@ mod tests { // into per_ip (the runtime layer chooses Unrestricted, ignoring // the concrete entries). let rules = vec![ - NetAllow { protocol: crate::policy::Protocol::Tcp, host: None, ports: vec![], all_ports: true }, + NetAllow { protocol: crate::sandbox::Protocol::Tcp, host: None, ports: vec![], all_ports: true }, NetAllow { - protocol: crate::policy::Protocol::Tcp, + protocol: crate::sandbox::Protocol::Tcp, host: Some("localhost".to_string()), ports: vec![22], all_ports: false, @@ -1051,13 +1051,13 @@ mod tests { // This is the property Phase 2 relies on for protocol routing. let rules = vec![ NetAllow { - protocol: crate::policy::Protocol::Tcp, + protocol: crate::sandbox::Protocol::Tcp, host: Some("localhost".to_string()), ports: vec![443], all_ports: false, }, NetAllow { - protocol: crate::policy::Protocol::Udp, + protocol: crate::sandbox::Protocol::Udp, host: None, ports: vec![53], all_ports: false, @@ -1079,7 +1079,7 @@ mod tests { // ICMP rules carry no ports; concrete hosts go into per_ip with // PortAllow::Any-style empty port set, plus per_ip_all_ports. let rules = vec![NetAllow { - protocol: crate::policy::Protocol::Icmp, + protocol: crate::sandbox::Protocol::Icmp, host: Some("localhost".to_string()), ports: vec![], all_ports: false, @@ -1098,7 +1098,7 @@ mod tests { async fn test_resolve_icmp_wildcard() { // `icmp://*` — any ICMP destination. let rules = vec![NetAllow { - protocol: crate::policy::Protocol::Icmp, + protocol: crate::sandbox::Protocol::Icmp, host: None, ports: vec![], all_ports: false, diff --git a/crates/sandlock-core/src/pipeline.rs b/crates/sandlock-core/src/pipeline.rs index feb4d6e..6ea5f4a 100644 --- a/crates/sandlock-core/src/pipeline.rs +++ b/crates/sandlock-core/src/pipeline.rs @@ -15,10 +15,9 @@ use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd}; use std::time::Duration; -use crate::error::{SandboxError, SandlockError}; -use crate::policy::Policy; -use crate::result::{ExitStatus, RunResult}; +use crate::error::{SandboxRuntimeError, SandlockError}; use crate::sandbox::Sandbox; +use crate::result::{ExitStatus, RunResult}; // ============================================================ // Stage @@ -28,15 +27,15 @@ use crate::sandbox::Sandbox; /// /// Not executed until `.run()` is called or the stage is part of a pipeline. pub struct Stage { - pub policy: Policy, + pub sandbox: Sandbox, pub args: Vec, } impl Stage { /// Create a new stage with the given policy and command. - pub fn new(policy: &Policy, args: &[&str]) -> Self { + pub fn new(sandbox: &Sandbox, args: &[&str]) -> Self { Self { - policy: policy.clone(), + sandbox: sandbox.clone(), args: args.iter().map(|s| s.to_string()).collect(), } } @@ -44,10 +43,9 @@ impl Stage { /// Run this single stage and return the result. pub async fn run(self, timeout: Option) -> Result { let cmd_refs: Vec<&str> = self.args.iter().map(|s| s.as_str()).collect(); - let name = "stage"; + let mut sb = self.sandbox.with_name("stage"); if let Some(dur) = timeout { - match tokio::time::timeout(dur, Sandbox::run_interactive(&self.policy, Some(name), &cmd_refs)).await - { + match tokio::time::timeout(dur, sb.run_interactive(&cmd_refs)).await { Ok(result) => result, Err(_) => Ok(RunResult { exit_status: ExitStatus::Timeout, @@ -56,7 +54,7 @@ impl Stage { }), } } else { - Sandbox::run_interactive(&self.policy, Some(name), &cmd_refs).await + sb.run_interactive(&cmd_refs).await } } } @@ -86,7 +84,7 @@ impl Pipeline { /// Create a pipeline from a list of stages (must have >= 2). pub fn new(stages: Vec) -> Result { if stages.len() < 2 { - return Err(SandlockError::Sandbox(SandboxError::Child( + return Err(SandlockError::Runtime(SandboxRuntimeError::Child( "Pipeline requires at least 2 stages".into(), ))); } @@ -145,19 +143,19 @@ async fn run_pipeline(stages: Vec) -> Result { // Create inter-stage pipes: pipe[i] connects stage[i] stdout → stage[i+1] stdin let mut inter_pipes: Vec<(OwnedFd, OwnedFd)> = Vec::with_capacity(n - 1); for _ in 0..n - 1 { - inter_pipes.push(make_pipe().map_err(SandboxError::Io)?); + inter_pipes.push(make_pipe().map_err(SandboxRuntimeError::Io)?); } // Create capture pipes for last stage's stdout and stderr - let (cap_stdout_r, cap_stdout_w) = make_pipe().map_err(SandboxError::Io)?; - let (cap_stderr_r, cap_stderr_w) = make_pipe().map_err(SandboxError::Io)?; + let (cap_stdout_r, cap_stdout_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; + let (cap_stderr_r, cap_stderr_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; // Spawn each stage let mut sandboxes: Vec = Vec::with_capacity(n); for (i, stage) in stages.into_iter().enumerate() { let name = format!("pipeline-stage-{}", i); - let mut sb = Sandbox::new(&stage.policy, Some(name.as_str()))?; + let mut sb = stage.sandbox.clone().with_name(name); // Determine stdin for this stage let stdin_fd: Option = if i == 0 { @@ -278,10 +276,10 @@ impl Gather { pub async fn run(self, timeout: Option) -> Result { let consumer = self.consumer.ok_or_else(|| { - SandlockError::Sandbox(SandboxError::Child("Gather requires a consumer".into())) + SandlockError::Runtime(SandboxRuntimeError::Child("Gather requires a consumer".into())) })?; if self.sources.is_empty() { - return Err(SandlockError::Sandbox(SandboxError::Child( + return Err(SandlockError::Runtime(SandboxRuntimeError::Child( "Gather requires at least one source".into(), ))); } @@ -312,7 +310,7 @@ async fn run_gather( // Last source → consumer stdin (fd 0), others → fd 3, 4, 5, ... let mut source_pipes: Vec<(OwnedFd, OwnedFd)> = Vec::with_capacity(n); for _ in 0..n { - source_pipes.push(make_pipe().map_err(SandboxError::Io)?); + source_pipes.push(make_pipe().map_err(SandboxRuntimeError::Io)?); } // Assign consumer fds: last source → fd 0, others → fd 3, 4, ... @@ -331,14 +329,14 @@ async fn run_gather( .join(","); // Capture pipes for consumer stdout/stderr - let (cap_stdout_r, cap_stdout_w) = make_pipe().map_err(SandboxError::Io)?; - let (cap_stderr_r, cap_stderr_w) = make_pipe().map_err(SandboxError::Io)?; + let (cap_stdout_r, cap_stdout_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; + let (cap_stderr_r, cap_stderr_w) = make_pipe().map_err(SandboxRuntimeError::Io)?; // Spawn producers: each writes stdout to its pipe let mut sandboxes: Vec = Vec::with_capacity(n + 1); for (i, ns) in sources.into_iter().enumerate() { let name = format!("gather-source-{}", ns.name); - let mut sb = Sandbox::new(&ns.stage.policy, Some(name.as_str()))?; + let mut sb = ns.stage.sandbox.clone().with_name(name); let stdout_fd = source_pipes[i].1.as_raw_fd(); let cmd_refs: Vec<&str> = ns.stage.args.iter().map(|s| s.as_str()).collect(); sb.spawn_with_io(&cmd_refs, None, Some(stdout_fd), None).await?; @@ -346,11 +344,11 @@ async fn run_gather( } // Spawn consumer with extra fds from source pipes - let mut consumer_policy = consumer.policy.clone(); + let mut consumer_sandbox = consumer.sandbox.clone(); // Inject _SANDLOCK_GATHER env var - consumer_policy.env.insert("_SANDLOCK_GATHER".to_string(), gather_env); + consumer_sandbox.env.insert("_SANDLOCK_GATHER".to_string(), gather_env); - let mut consumer_sb = Sandbox::new(&consumer_policy, Some("gather-consumer"))?; + let mut consumer_sb = consumer_sandbox.clone().with_name("gather-consumer"); let stdin_fd = source_pipes[n - 1].0.as_raw_fd(); // Build extra fd mappings for non-stdin sources diff --git a/crates/sandlock-core/src/policy.rs b/crates/sandlock-core/src/policy.rs deleted file mode 100644 index a2fc45c..0000000 --- a/crates/sandlock-core/src/policy.rs +++ /dev/null @@ -1,1519 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::time::SystemTime; - -use serde::{Deserialize, Serialize}; - -use crate::error::PolicyError; - -/// A byte size value. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ByteSize(pub u64); - -impl ByteSize { - pub fn bytes(n: u64) -> Self { - ByteSize(n) - } - - pub fn kib(n: u64) -> Self { - ByteSize(n * 1024) - } - - pub fn mib(n: u64) -> Self { - ByteSize(n * 1024 * 1024) - } - - pub fn gib(n: u64) -> Self { - ByteSize(n * 1024 * 1024 * 1024) - } - - pub fn parse(s: &str) -> Result { - let s = s.trim(); - if s.is_empty() { - return Err(PolicyError::Invalid("empty byte size string".into())); - } - - // Check for suffix - let last = s.chars().last().unwrap(); - if last.is_ascii_alphabetic() { - let (num_str, suffix) = s.split_at(s.len() - 1); - let n: u64 = num_str - .trim() - .parse() - .map_err(|_| PolicyError::Invalid(format!("invalid byte size: {}", s)))?; - match suffix.to_ascii_uppercase().as_str() { - "K" => Ok(ByteSize::kib(n)), - "M" => Ok(ByteSize::mib(n)), - "G" => Ok(ByteSize::gib(n)), - other => Err(PolicyError::Invalid(format!("unknown byte size suffix: {}", other))), - } - } else { - let n: u64 = s - .parse() - .map_err(|_| PolicyError::Invalid(format!("invalid byte size: {}", s)))?; - Ok(ByteSize(n)) - } - } -} - -/// Policy for confining the current process in place. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ConfinePolicy { - pub fs_writable: Vec, - pub fs_readable: Vec, -} - -impl ConfinePolicy { - pub fn builder() -> ConfinePolicyBuilder { - ConfinePolicyBuilder::default() - } -} - -#[derive(Default)] -pub struct ConfinePolicyBuilder { - fs_writable: Vec, - fs_readable: Vec, -} - -impl ConfinePolicyBuilder { - pub fn fs_write(mut self, path: impl Into) -> Self { - self.fs_writable.push(path.into()); - self - } - - pub fn fs_read(mut self, path: impl Into) -> Self { - self.fs_readable.push(path.into()); - self - } - - pub fn build(self) -> ConfinePolicy { - ConfinePolicy { - fs_writable: self.fs_writable, - fs_readable: self.fs_readable, - } - } -} - -impl TryFrom<&Policy> for ConfinePolicy { - type Error = PolicyError; - - fn try_from(policy: &Policy) -> Result { - let mut unsupported = Vec::new(); - if !policy.fs_denied.is_empty() { unsupported.push("fs_denied"); } - if !policy.block_syscalls.is_empty() { unsupported.push("block_syscalls"); } - if !policy.net_allow.is_empty() { unsupported.push("net_allow"); } - if !policy.net_bind.is_empty() { unsupported.push("net_bind"); } - if policy.allow_sysv_ipc { unsupported.push("allow_sysv_ipc"); } - if !policy.http_allow.is_empty() { unsupported.push("http_allow"); } - if !policy.http_deny.is_empty() { unsupported.push("http_deny"); } - if !policy.http_ports.is_empty() { unsupported.push("http_ports"); } - if policy.https_ca.is_some() { unsupported.push("https_ca"); } - if policy.https_key.is_some() { unsupported.push("https_key"); } - if policy.max_memory.is_some() { unsupported.push("max_memory"); } - if policy.max_processes != 64 { unsupported.push("max_processes"); } - if policy.max_open_files.is_some() { unsupported.push("max_open_files"); } - if policy.max_cpu.is_some() { unsupported.push("max_cpu"); } - if policy.random_seed.is_some() { unsupported.push("random_seed"); } - if policy.time_start.is_some() { unsupported.push("time_start"); } - if policy.no_randomize_memory { unsupported.push("no_randomize_memory"); } - if policy.no_huge_pages { unsupported.push("no_huge_pages"); } - if policy.no_coredump { unsupported.push("no_coredump"); } - if policy.deterministic_dirs { unsupported.push("deterministic_dirs"); } - if policy.fs_isolation != FsIsolation::None { unsupported.push("fs_isolation"); } - if policy.workdir.is_some() { unsupported.push("workdir"); } - if policy.cwd.is_some() { unsupported.push("cwd"); } - if policy.fs_storage.is_some() { unsupported.push("fs_storage"); } - if policy.max_disk.is_some() { unsupported.push("max_disk"); } - if policy.on_exit != BranchAction::Commit { unsupported.push("on_exit"); } - if policy.on_error != BranchAction::Abort { unsupported.push("on_error"); } - if !policy.fs_mount.is_empty() { unsupported.push("fs_mount"); } - if policy.chroot.is_some() { unsupported.push("chroot"); } - if policy.clean_env { unsupported.push("clean_env"); } - if !policy.env.is_empty() { unsupported.push("env"); } - if policy.gpu_devices.is_some() { unsupported.push("gpu_devices"); } - if policy.cpu_cores.is_some() { unsupported.push("cpu_cores"); } - if policy.num_cpus.is_some() { unsupported.push("num_cpus"); } - if policy.port_remap { unsupported.push("port_remap"); } - if policy.uid.is_some() { unsupported.push("uid"); } - if policy.policy_fn.is_some() { unsupported.push("policy_fn"); } - - if !unsupported.is_empty() { - return Err(PolicyError::UnsupportedForConfine(unsupported.join(", "))); - } - - Ok(Self { - fs_writable: policy.fs_writable.clone(), - fs_readable: policy.fs_readable.clone(), - }) - } -} - -/// Filesystem isolation mode. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum FsIsolation { - #[default] - None, - OverlayFs, - BranchFs, -} - -/// Action to take on branch exit. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum BranchAction { - #[default] - Commit, - Abort, - Keep, -} - -/// L4 protocol that a `NetAllow` rule applies to. -/// -/// `Tcp` is the default if a rule has no scheme (the bare `host:port` -/// form). `Udp` and `Icmp` require an explicit scheme. -/// -/// `Icmp` is the kernel's unprivileged ping socket -/// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` — -/// destinations are filterable per host. Sandlock does not expose raw -/// ICMP (`SOCK_RAW + IPPROTO_ICMP`): destination filtering at `sendto` -/// would lie because raw sockets let the agent craft the IP header, -/// and packet-crafting capabilities aren't part of the XOA threat -/// model. Workloads that genuinely need raw ICMP should run outside -/// sandlock or rely on the host's `ping_group_range` for the dgram -/// path instead. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Protocol { - Tcp, - Udp, - Icmp, -} - -impl Protocol { - fn parse(s: &str) -> Option { - match s { - "tcp" => Some(Protocol::Tcp), - "udp" => Some(Protocol::Udp), - "icmp" => Some(Protocol::Icmp), - _ => None, - } - } -} - -/// A network endpoint allow rule. -/// -/// Each rule permits one protocol's traffic to one host (or any IP, for -/// the `:port` form) on a specific set of ports. Multiple rules are -/// OR'd: traffic is permitted if any rule matches the protocol, the -/// destination IP, and the destination port. -/// -/// ICMP rules carry no port (ICMP has none); their `ports` is empty -/// and `all_ports` is false. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct NetAllow { - /// L4 protocol this rule applies to. - #[serde(default = "default_protocol_tcp")] - pub protocol: Protocol, - /// Hostname; `None` means "any IP" (the `:port` form, or `icmp://*`). - pub host: Option, - /// Permitted ports. Must be non-empty unless `all_ports` is true, - /// in which case it must be empty. Always empty for `Protocol::Icmp`. - pub ports: Vec, - /// "Any port" wildcard from the `*` token in port position. When - /// true, `ports` is empty; the rule permits every TCP/UDP port to - /// the host (or to any IP, when `host` is `None`). - #[serde(default)] - pub all_ports: bool, -} - -fn default_protocol_tcp() -> Protocol { Protocol::Tcp } - -impl NetAllow { - /// Parse a rule spec. Forms: - /// - /// - `host:port[,port,...]`, `:port`, `*:port`, `host:*`, `:*`, `*:*` - /// — TCP (the default scheme). - /// - `tcp://...` — explicit TCP, same suffix grammar as the bare form. - /// - `udp://...` — UDP, same suffix grammar as the bare form. - /// - `icmp://host` or `icmp://*` — ICMP echo (kernel ping socket). - /// No port field; `icmp://host:80` is rejected. - /// - /// `*` in port position means "any port" (the all-ports wildcard). - /// Mixing `*` with concrete ports (e.g. `host:80,*`) is rejected. - pub fn parse(s: &str) -> Result { - // Split off the optional scheme prefix `://`. If absent, - // default to TCP and the rest of the parser is unchanged. - let (protocol, rest) = match s.split_once("://") { - Some((scheme, body)) => { - let proto = Protocol::parse(scheme).ok_or_else(|| { - PolicyError::Invalid(format!( - "--net-allow: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", - scheme, s - )) - })?; - (proto, body) - } - None => (Protocol::Tcp, s), - }; - - if protocol == Protocol::Icmp { - return Self::parse_icmp(rest, s); - } - - let (host_part, port_part) = rest.rsplit_once(':').ok_or_else(|| { - PolicyError::Invalid(format!( - "--net-allow: expected `host:port` or `:port`, got `{}`", - s - )) - })?; - let host = match host_part { - "" | "*" => None, - h => Some(h.to_string()), - }; - - // Detect the wildcard token. We split on ',' first so a - // single `*` is a clean match — `*,80` is rejected explicitly - // below rather than letting `*` parse as port 0. - let mut ports = Vec::new(); - let mut saw_wildcard = false; - for p in port_part.split(',') { - let p = p.trim(); - if p == "*" { - saw_wildcard = true; - continue; - } - let n: u16 = p.parse().map_err(|_| { - PolicyError::Invalid(format!("--net-allow: invalid port `{}` in `{}`", p, s)) - })?; - if n == 0 { - return Err(PolicyError::Invalid(format!( - "--net-allow: port 0 is not valid in `{}`", - s - ))); - } - ports.push(n); - } - if saw_wildcard && !ports.is_empty() { - return Err(PolicyError::Invalid(format!( - "--net-allow: cannot mix `*` with concrete ports in `{}`", - s - ))); - } - if !saw_wildcard && ports.is_empty() { - return Err(PolicyError::Invalid(format!( - "--net-allow: at least one port required in `{}`", - s - ))); - } - Ok(NetAllow { protocol, host, ports, all_ports: saw_wildcard }) - } - - /// Parse the body of an `icmp://` rule. Accepts a host or `*` — - /// ICMP has no ports, so any `:` separator is rejected. - fn parse_icmp(body: &str, full: &str) -> Result { - if body.contains(':') { - return Err(PolicyError::Invalid(format!( - "--net-allow: icmp rules take no port, got `{}`", - full - ))); - } - if body.is_empty() { - return Err(PolicyError::Invalid(format!( - "--net-allow: icmp rule needs a host or `*`, got `{}`", - full - ))); - } - let host = match body { - "*" => None, - h => Some(h.to_string()), - }; - Ok(NetAllow { - protocol: Protocol::Icmp, - host, - ports: Vec::new(), - all_ports: false, - }) - } -} - -/// An HTTP access control rule. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct HttpRule { - pub method: String, - pub host: String, - pub path: String, -} - -impl HttpRule { - /// Parse a rule from "METHOD host/path" format. - /// - /// Examples: - /// - `"GET api.example.com/v1/*"` → method="GET", host="api.example.com", path="/v1/*" - /// - `"* */admin/*"` → method="*", host="*", path="/admin/*" - /// - `"GET example.com"` → method="GET", host="example.com", path="/*" - pub fn parse(s: &str) -> Result { - let s = s.trim(); - let (method, rest) = s - .split_once(char::is_whitespace) - .ok_or_else(|| PolicyError::Invalid(format!("invalid http rule: {}", s)))?; - let rest = rest.trim(); - if rest.is_empty() { - return Err(PolicyError::Invalid(format!("invalid http rule: {}", s))); - } - - let (host, path) = if let Some(pos) = rest.find('/') { - let (h, p) = rest.split_at(pos); - // Normalize the rule path, but preserve trailing * for glob matching. - let has_wildcard = p.ends_with('*'); - let mut normalized = normalize_path(p); - if has_wildcard && !normalized.ends_with('*') { - normalized.push('*'); - } - (h.to_string(), normalized) - } else { - (rest.to_string(), "/*".to_string()) - }; - - Ok(HttpRule { - method: method.to_uppercase(), - host, - path, - }) - } - - /// Check whether this rule matches the given request parameters. - /// The request path is normalized before matching to prevent bypasses - /// via `//`, `/../`, `/.`, or percent-encoding. - pub fn matches(&self, method: &str, host: &str, path: &str) -> bool { - // Method match - if self.method != "*" && !self.method.eq_ignore_ascii_case(method) { - return false; - } - // Host match - if self.host != "*" && !self.host.eq_ignore_ascii_case(host) { - return false; - } - // Path match — normalize to prevent encoding/traversal bypasses - let normalized = normalize_path(path); - prefix_or_exact_match(&self.path, &normalized) - } -} - -/// Normalize an HTTP path to prevent ACL bypasses via encoding tricks. -/// -/// - Decodes percent-encoded characters (e.g. `%2F` → `/`, `%61` → `a`) -/// - Collapses duplicate slashes (`//` → `/`) -/// - Resolves `.` and `..` segments -/// - Ensures the path starts with `/` -pub fn normalize_path(path: &str) -> String { - // 1. Percent-decode - let mut decoded = String::with_capacity(path.len()); - let mut chars = path.bytes(); - while let Some(b) = chars.next() { - if b == b'%' { - let hi = chars.next(); - let lo = chars.next(); - if let (Some(h), Some(l)) = (hi, lo) { - let hex = [h, l]; - if let Ok(s) = std::str::from_utf8(&hex) { - if let Ok(val) = u8::from_str_radix(s, 16) { - decoded.push(val as char); - continue; - } - } - // Malformed percent encoding — keep as-is - decoded.push(b as char); - decoded.push(h as char); - decoded.push(l as char); - } else { - decoded.push(b as char); - } - } else { - decoded.push(b as char); - } - } - - // 2. Split into segments, resolve . and .., skip empty segments (collapses //) - let mut segments: Vec<&str> = Vec::new(); - for seg in decoded.split('/') { - match seg { - "" | "." => {} - ".." => { - segments.pop(); - } - s => segments.push(s), - } - } - - // 3. Reconstruct with leading / - let mut result = String::with_capacity(decoded.len()); - result.push('/'); - result.push_str(&segments.join("/")); - result -} - -/// Simple prefix or exact matching for paths. Supports trailing `*` as a prefix match. -/// -/// Only supports: -/// - `"/*"` or `"*"` matches everything -/// - `"/v1/*"` matches "/v1/foo", "/v1/foo/bar" (prefix match) -/// - `"/v1/models"` matches exactly "/v1/models" (exact match) -/// -/// Does NOT support mid-pattern wildcards (e.g., "/v1/*/models"). -pub fn prefix_or_exact_match(pattern: &str, value: &str) -> bool { - if pattern == "/*" || pattern == "*" { - return true; - } - if let Some(prefix) = pattern.strip_suffix('*') { - value.starts_with(prefix) - } else { - pattern == value - } -} - -/// Evaluate HTTP ACL rules against a request. -/// -/// - Block rules are checked first; if any match, return false. -/// - Allow rules are checked next; if any match, return true. -/// - If allow rules exist but none matched, return false (deny-by-default). -/// - If no rules at all, return true (unrestricted). -pub fn http_acl_check( - allow: &[HttpRule], - deny: &[HttpRule], - method: &str, - host: &str, - path: &str, -) -> bool { - // Block rules checked first - for rule in deny { - if rule.matches(method, host, path) { - return false; - } - } - // Allow rules checked next - if allow.is_empty() && deny.is_empty() { - return true; // unrestricted - } - if allow.is_empty() { - // Only block rules exist; anything not denied is allowed - return true; - } - for rule in allow { - if rule.matches(method, host, path) { - return true; - } - } - false // allow rules exist but none matched -} - -/// Sandbox policy configuration. -#[derive(Clone, Serialize, Deserialize)] -pub struct Policy { - // Filesystem access - pub fs_writable: Vec, - pub fs_readable: Vec, - pub fs_denied: Vec, - - // Extra syscall filtering on top of Sandlock's default blocklist. - pub block_syscalls: Vec, - - // Network - /// Outbound endpoint allowlist as a list of `(protocol, host?, ports)` - /// rules. Each rule names a protocol (TCP/UDP/ICMP) and either a - /// concrete host or "any IP." TCP and UDP rules carry ports; ICMP - /// rules have none. - /// - /// **Protocol gating falls out of rule presence.** Sandlock denies - /// UDP and ICMP socket creation by default; opting in is "list at - /// least one rule for that protocol" (e.g. `udp://*:*` for any UDP, - /// `icmp://*` for any ICMP echo). TCP is always permitted. - /// - /// Empty `net_allow` and empty `http_allow`/`http_deny` together - /// mean "deny all outbound" (Landlock direct path denies, no - /// on-behalf path is enabled). Otherwise, the on-behalf path - /// enforces these rules: a destination is permitted iff any rule - /// matches the protocol, destination IP (or has `host: None` = any - /// IP), and destination port (N/A for ICMP). - /// - /// HTTP rules with concrete hosts auto-add a matching - /// `(Tcp, host, [80])` (and `(Tcp, host, [443])` when `--https-ca` - /// is set) entry at build time so the proxy's intercept ports - /// remain reachable. HTTP rules with wildcard hosts auto-add - /// `(Tcp, None, [80])` instead. - pub net_allow: Vec, - pub net_bind: Vec, - /// Permit SysV IPC syscalls: shared memory (`shmget`/`shmat`/ - /// `shmdt`/`shmctl`), message queues (`msgget`/`msgsnd`/`msgrcv`/ - /// `msgctl`), and semaphores (`semget`/`semop`/`semctl`/ - /// `semtimedop`). Denied by default because sandlock does not use - /// IPC namespaces; without this denial, two sandboxes on the same - /// host share a SysV IPC keyspace and can rendezvous via a - /// well-known key. - pub allow_sysv_ipc: bool, - - // HTTP ACL - pub http_allow: Vec, - pub http_deny: Vec, - /// TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 when - /// https_ca is set). Override with `http_ports` to intercept custom ports. - pub http_ports: Vec, - /// PEM CA cert for HTTPS MITM. When set, port 443 is also intercepted. - pub https_ca: Option, - /// PEM CA key for HTTPS MITM. Required when https_ca is set. - pub https_key: Option, - - // Namespace isolation — always enabled, not user-configurable. - - // Resource limits - pub max_memory: Option, - pub max_processes: u32, - pub max_open_files: Option, - pub max_cpu: Option, - - // Reproducibility - pub random_seed: Option, - pub time_start: Option, - pub no_randomize_memory: bool, - pub no_huge_pages: bool, - pub no_coredump: bool, - pub deterministic_dirs: bool, - - // Filesystem branch - pub fs_isolation: FsIsolation, - pub workdir: Option, - pub cwd: Option, - pub fs_storage: Option, - pub max_disk: Option, - pub on_exit: BranchAction, - pub on_error: BranchAction, - - // Mount mappings: (virtual_path_inside_chroot, host_path_on_disk) - pub fs_mount: Vec<(PathBuf, PathBuf)>, - - // Environment - pub chroot: Option, - pub clean_env: bool, - pub env: HashMap, - // Devices - pub gpu_devices: Option>, - - // CPU - pub cpu_cores: Option>, - pub num_cpus: Option, - pub port_remap: bool, - - // User namespace - pub uid: Option, - - // Dynamic policy callback - #[serde(skip)] - pub policy_fn: Option, -} - -impl std::fmt::Debug for Policy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Policy") - .field("fs_readable", &self.fs_readable) - .field("fs_writable", &self.fs_writable) - .field("max_memory", &self.max_memory) - .field("max_processes", &self.max_processes) - .field("policy_fn", &self.policy_fn.as_ref().map(|_| "")) - .finish_non_exhaustive() - } -} - -impl Policy { - pub fn builder() -> PolicyBuilder { - PolicyBuilder::default() - } -} - -fn validate_syscall_names(names: &[String]) -> Result<(), PolicyError> { - let unknown: Vec<&str> = names - .iter() - .map(String::as_str) - .filter(|name| crate::context::syscall_name_to_nr(name).is_none()) - .collect(); - if unknown.is_empty() { - Ok(()) - } else { - Err(PolicyError::Invalid(format!( - "unknown syscall name(s): {}", - unknown.join(", ") - ))) - } -} - -/// Fluent builder for `Policy`. -#[derive(Default)] -pub struct PolicyBuilder { - fs_writable: Vec, - fs_readable: Vec, - fs_denied: Vec, - - block_syscalls: Vec, - - /// Raw `--net-allow` specs; parsed in `build()` to surface errors. - net_allow: Vec, - net_bind: Vec, - allow_sysv_ipc: bool, - - http_allow: Vec, - http_deny: Vec, - http_ports: Vec, - https_ca: Option, - https_key: Option, - - max_memory: Option, - max_processes: Option, - max_open_files: Option, - max_cpu: Option, - - random_seed: Option, - time_start: Option, - no_randomize_memory: bool, - no_huge_pages: bool, - no_coredump: bool, - deterministic_dirs: bool, - - fs_isolation: Option, - workdir: Option, - cwd: Option, - fs_storage: Option, - max_disk: Option, - on_exit: Option, - on_error: Option, - - fs_mount: Vec<(PathBuf, PathBuf)>, - chroot: Option, - clean_env: bool, - env: HashMap, - - gpu_devices: Option>, - - cpu_cores: Option>, - num_cpus: Option, - port_remap: bool, - - uid: Option, - policy_fn: Option, -} - -impl PolicyBuilder { - pub fn fs_write(mut self, path: impl Into) -> Self { - self.fs_writable.push(path.into()); - self - } - - pub fn fs_read(mut self, path: impl Into) -> Self { - self.fs_readable.push(path.into()); - self - } - - pub fn fs_read_if_exists(self, path: impl Into) -> Self { - let path = path.into(); - if path.exists() { - self.fs_read(path) - } else { - self - } - } - - pub fn fs_deny(mut self, path: impl Into) -> Self { - self.fs_denied.push(path.into()); - self - } - - pub fn block_syscalls(mut self, calls: Vec) -> Self { - self.block_syscalls.extend(calls); - self - } - - /// Add a network endpoint rule. Spec is `host:port[,port,...]`, - /// `:port`, or `*:port`. Validated at `build()` time so callers - /// receive parse errors via the standard `PolicyBuilder` flow. - /// - /// Examples: - /// - `.net_allow("api.openai.com:443")` — HTTPS to OpenAI only - /// - `.net_allow("github.com:22,443")` — SSH and HTTPS to GitHub - /// - `.net_allow(":8080")` — any IP on port 8080 - pub fn net_allow(mut self, spec: impl Into) -> Self { - self.net_allow.push(spec.into()); - self - } - - pub fn net_bind_port(mut self, port: u16) -> Self { - self.net_bind.push(port); - self - } - - /// Permit SysV IPC syscalls (shm/msg/sem). Denied by default - /// because sandlock does not use IPC namespaces — without this - /// denial, sandboxes on the same host share a SysV keyspace. - pub fn allow_sysv_ipc(mut self, v: bool) -> Self { - self.allow_sysv_ipc = v; - self - } - - pub fn http_allow(mut self, rule: &str) -> Self { - self.http_allow.push(rule.to_string()); - self - } - - pub fn http_deny(mut self, rule: &str) -> Self { - self.http_deny.push(rule.to_string()); - self - } - - pub fn http_port(mut self, port: u16) -> Self { - self.http_ports.push(port); - self - } - - pub fn https_ca(mut self, path: impl Into) -> Self { - self.https_ca = Some(path.into()); - self - } - - pub fn https_key(mut self, path: impl Into) -> Self { - self.https_key = Some(path.into()); - self - } - - pub fn max_memory(mut self, size: ByteSize) -> Self { - self.max_memory = Some(size); - self - } - - pub fn max_processes(mut self, n: u32) -> Self { - self.max_processes = Some(n); - self - } - - pub fn max_open_files(mut self, n: u32) -> Self { - self.max_open_files = Some(n); - self - } - - pub fn max_cpu(mut self, pct: u8) -> Self { - self.max_cpu = Some(pct); - self - } - - pub fn random_seed(mut self, seed: u64) -> Self { - self.random_seed = Some(seed); - self - } - - pub fn time_start(mut self, t: SystemTime) -> Self { - self.time_start = Some(t); - self - } - - pub fn no_randomize_memory(mut self, v: bool) -> Self { - self.no_randomize_memory = v; - self - } - - pub fn no_huge_pages(mut self, v: bool) -> Self { - self.no_huge_pages = v; - self - } - - pub fn no_coredump(mut self, v: bool) -> Self { - self.no_coredump = v; - self - } - - pub fn deterministic_dirs(mut self, v: bool) -> Self { - self.deterministic_dirs = v; - self - } - - pub fn fs_isolation(mut self, iso: FsIsolation) -> Self { - self.fs_isolation = Some(iso); - self - } - - pub fn workdir(mut self, path: impl Into) -> Self { - self.workdir = Some(path.into()); - self - } - - pub fn cwd(mut self, path: impl Into) -> Self { - self.cwd = Some(path.into()); - self - } - - pub fn fs_storage(mut self, path: impl Into) -> Self { - self.fs_storage = Some(path.into()); - self - } - - pub fn max_disk(mut self, size: ByteSize) -> Self { - self.max_disk = Some(size); - self - } - - pub fn on_exit(mut self, action: BranchAction) -> Self { - self.on_exit = Some(action); - self - } - - pub fn on_error(mut self, action: BranchAction) -> Self { - self.on_error = Some(action); - self - } - - pub fn chroot(mut self, path: impl Into) -> Self { - self.chroot = Some(path.into()); - self - } - - pub fn fs_mount(mut self, virtual_path: impl Into, host_path: impl Into) -> Self { - self.fs_mount.push((virtual_path.into(), host_path.into())); - self - } - - pub fn clean_env(mut self, v: bool) -> Self { - self.clean_env = v; - self - } - - pub fn env_var(mut self, key: impl Into, value: impl Into) -> Self { - self.env.insert(key.into(), value.into()); - self - } - - - pub fn gpu_devices(mut self, devices: Vec) -> Self { - self.gpu_devices = Some(devices); - self - } - - pub fn cpu_cores(mut self, cores: Vec) -> Self { - self.cpu_cores = Some(cores); - self - } - - pub fn num_cpus(mut self, n: u32) -> Self { - self.num_cpus = Some(n); - self - } - - pub fn port_remap(mut self, v: bool) -> Self { - self.port_remap = v; - self - } - - pub fn policy_fn( - mut self, - f: impl Fn(crate::policy_fn::SyscallEvent, &mut crate::policy_fn::PolicyContext) -> crate::policy_fn::Verdict + Send + Sync + 'static, - ) -> Self { - self.policy_fn = Some(std::sync::Arc::new(f)); - self - } - - pub fn uid(mut self, id: u32) -> Self { - self.uid = Some(id); - self - } - - pub fn build(self) -> Result { - validate_syscall_names(&self.block_syscalls)?; - - // Validate: max_cpu must be 1-100 - if let Some(cpu) = self.max_cpu { - if cpu == 0 || cpu > 100 { - return Err(PolicyError::InvalidCpuPercent(cpu)); - } - } - - // Validate: https_ca and https_key must both be set or both unset - if self.https_ca.is_some() != self.https_key.is_some() { - return Err(PolicyError::Invalid( - "--https-ca and --https-key must both be provided together".into(), - )); - } - - // Parse HTTP rules (deferred from builder methods to propagate errors) - let http_allow: Vec = self - .http_allow - .iter() - .map(|s| HttpRule::parse(s)) - .collect::>()?; - let http_deny: Vec = self - .http_deny - .iter() - .map(|s| HttpRule::parse(s)) - .collect::>()?; - - // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured. - let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) { - let mut ports = vec![80]; - if self.https_ca.is_some() { - ports.push(443); - } - ports - } else { - self.http_ports - }; - - // Parse user-supplied --net-allow specs. - let mut net_allow: Vec = self - .net_allow - .iter() - .map(|s| NetAllow::parse(s)) - .collect::>()?; - - // Auto-merge HTTP rules into the network allowlist so the proxy's - // intercept ports remain reachable. A rule with a concrete host - // tightens the IP allowlist (only that host on http_ports); - // wildcard hosts add a `:port` (any IP) rule. This mirrors the - // intent of the old `http_port → net_connect` merge but at the - // endpoint level so HTTP and net_allow stay aligned. - if !http_ports.is_empty() { - let mut wildcard_seen = false; - let mut concrete_hosts: Vec = Vec::new(); - for rule in http_allow.iter().chain(http_deny.iter()) { - if rule.host == "*" { - wildcard_seen = true; - } else if !concrete_hosts.iter().any(|h| h.eq_ignore_ascii_case(&rule.host)) { - concrete_hosts.push(rule.host.clone()); - } - } - if wildcard_seen || (http_allow.is_empty() && http_deny.is_empty()) { - // Fallback: explicit --http-port without rules, or wildcard rules. - net_allow.push(NetAllow { - protocol: Protocol::Tcp, - host: None, - ports: http_ports.clone(), - all_ports: false, - }); - } - for h in concrete_hosts { - net_allow.push(NetAllow { - protocol: Protocol::Tcp, - host: Some(h), - ports: http_ports.clone(), - all_ports: false, - }); - } - } - - // Validate: fs_isolation != None requires workdir - let fs_isolation = self.fs_isolation.unwrap_or_default(); - if fs_isolation != FsIsolation::None && self.workdir.is_none() { - return Err(PolicyError::FsIsolationRequiresWorkdir); - } - - Ok(Policy { - fs_writable: self.fs_writable, - fs_readable: self.fs_readable, - fs_denied: self.fs_denied, - block_syscalls: self.block_syscalls, - net_allow, - net_bind: self.net_bind, - allow_sysv_ipc: self.allow_sysv_ipc, - http_allow, - http_deny, - http_ports, - https_ca: self.https_ca, - https_key: self.https_key, - max_memory: self.max_memory, - max_processes: self.max_processes.unwrap_or(64), - max_open_files: self.max_open_files, - max_cpu: self.max_cpu, - random_seed: self.random_seed, - time_start: self.time_start, - no_randomize_memory: self.no_randomize_memory, - no_huge_pages: self.no_huge_pages, - no_coredump: self.no_coredump, - deterministic_dirs: self.deterministic_dirs, - fs_isolation, - workdir: self.workdir, - cwd: self.cwd, - fs_storage: self.fs_storage, - max_disk: self.max_disk, - on_exit: self.on_exit.unwrap_or_default(), - on_error: self.on_error.unwrap_or_default(), - fs_mount: self.fs_mount, - chroot: self.chroot, - clean_env: self.clean_env, - env: self.env, - gpu_devices: self.gpu_devices, - cpu_cores: self.cpu_cores, - num_cpus: self.num_cpus, - port_remap: self.port_remap, - uid: self.uid, - policy_fn: self.policy_fn, - }) - } -} - -#[cfg(test)] -mod http_rule_tests { - use super::*; - - // --- HttpRule::parse tests --- - - #[test] - fn parse_basic_get() { - let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap(); - assert_eq!(rule.method, "GET"); - assert_eq!(rule.host, "api.example.com"); - assert_eq!(rule.path, "/v1/*"); - } - - #[test] - fn parse_wildcard_method_and_host() { - let rule = HttpRule::parse("* */admin/*").unwrap(); - assert_eq!(rule.method, "*"); - assert_eq!(rule.host, "*"); - assert_eq!(rule.path, "/admin/*"); - } - - #[test] - fn parse_post_with_exact_path() { - let rule = HttpRule::parse("POST example.com/upload").unwrap(); - assert_eq!(rule.method, "POST"); - assert_eq!(rule.host, "example.com"); - assert_eq!(rule.path, "/upload"); - } - - #[test] - fn parse_no_path_defaults_to_wildcard() { - let rule = HttpRule::parse("GET example.com").unwrap(); - assert_eq!(rule.method, "GET"); - assert_eq!(rule.host, "example.com"); - assert_eq!(rule.path, "/*"); - } - - #[test] - fn parse_method_uppercased() { - let rule = HttpRule::parse("get example.com/foo").unwrap(); - assert_eq!(rule.method, "GET"); - } - - #[test] - fn parse_error_no_space() { - assert!(HttpRule::parse("GETexample.com").is_err()); - } - - #[test] - fn parse_error_empty_host() { - assert!(HttpRule::parse("GET ").is_err()); - } - - // --- prefix_or_exact_match tests --- - - #[test] - fn prefix_or_exact_match_wildcard_all() { - assert!(prefix_or_exact_match("/*", "/anything")); - assert!(prefix_or_exact_match("*", "/anything")); - assert!(prefix_or_exact_match("/*", "/")); - } - - #[test] - fn prefix_or_exact_match_prefix() { - assert!(prefix_or_exact_match("/v1/*", "/v1/foo")); - assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar")); - assert!(prefix_or_exact_match("/v1/*", "/v1/")); - assert!(!prefix_or_exact_match("/v1/*", "/v2/foo")); - } - - #[test] - fn prefix_or_exact_match_exact() { - assert!(prefix_or_exact_match("/v1/models", "/v1/models")); - assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra")); - assert!(!prefix_or_exact_match("/v1/models", "/v1/model")); - } - - // --- HttpRule::matches tests --- - - #[test] - fn matches_exact() { - let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap(); - assert!(rule.matches("GET", "api.example.com", "/v1/models")); - assert!(!rule.matches("POST", "api.example.com", "/v1/models")); - assert!(!rule.matches("GET", "other.com", "/v1/models")); - assert!(!rule.matches("GET", "api.example.com", "/v1/other")); - } - - #[test] - fn matches_wildcard_method() { - let rule = HttpRule::parse("* api.example.com/v1/*").unwrap(); - assert!(rule.matches("GET", "api.example.com", "/v1/foo")); - assert!(rule.matches("POST", "api.example.com", "/v1/bar")); - } - - #[test] - fn matches_wildcard_host() { - let rule = HttpRule::parse("GET */v1/*").unwrap(); - assert!(rule.matches("GET", "any.host.com", "/v1/foo")); - } - - #[test] - fn matches_case_insensitive_method() { - let rule = HttpRule::parse("GET example.com/foo").unwrap(); - assert!(rule.matches("get", "example.com", "/foo")); - assert!(rule.matches("Get", "example.com", "/foo")); - } - - #[test] - fn matches_case_insensitive_host() { - let rule = HttpRule::parse("GET Example.COM/foo").unwrap(); - assert!(rule.matches("GET", "example.com", "/foo")); - } - - // --- http_acl_check tests --- - - #[test] - fn acl_no_rules_allows_all() { - assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo")); - } - - #[test] - fn acl_allow_only_permits_matching() { - let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; - assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo")); - assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo")); - assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo")); - } - - #[test] - fn acl_deny_only_blocks_matching() { - let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings")); - assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page")); - } - - #[test] - fn acl_deny_takes_precedence_over_allow() { - let allow = vec![HttpRule::parse("* example.com/*").unwrap()]; - let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()]; - assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public")); - assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings")); - } - - #[test] - fn acl_allow_deny_by_default_when_no_match() { - let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; - // Different host, not matched by allow -> denied - assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo")); - } - - // --- PolicyBuilder integration --- - - #[test] - fn builder_http_rules() { - let policy = Policy::builder() - .http_allow("GET api.example.com/v1/*") - .http_deny("* */admin/*") - .build() - .unwrap(); - assert_eq!(policy.http_allow.len(), 1); - assert_eq!(policy.http_deny.len(), 1); - assert_eq!(policy.http_allow[0].method, "GET"); - assert_eq!(policy.http_deny[0].host, "*"); - } - - #[test] - fn builder_invalid_http_allow_returns_error() { - let result = Policy::builder() - .http_allow("GETexample.com") - .build(); - assert!(result.is_err()); - } - - #[test] - fn builder_invalid_http_deny_returns_error() { - let result = Policy::builder() - .http_deny("BADRULE") - .build(); - assert!(result.is_err()); - } - - #[test] - fn builder_https_ca_without_key_returns_error() { - let result = Policy::builder() - .https_ca("/tmp/ca.pem") - .build(); - assert!(result.is_err()); - } - - #[test] - fn builder_https_key_without_ca_returns_error() { - let result = Policy::builder() - .https_key("/tmp/key.pem") - .build(); - assert!(result.is_err()); - } - - #[test] - fn builder_https_ca_and_key_together_ok() { - let policy = Policy::builder() - .https_ca("/tmp/ca.pem") - .https_key("/tmp/key.pem") - .build() - .unwrap(); - assert!(policy.https_ca.is_some()); - assert!(policy.https_key.is_some()); - } - - // --- normalize_path tests --- - - #[test] - fn normalize_path_basic() { - assert_eq!(normalize_path("/foo/bar"), "/foo/bar"); - assert_eq!(normalize_path("/"), "/"); - } - - #[test] - fn normalize_path_double_slashes() { - assert_eq!(normalize_path("/foo//bar"), "/foo/bar"); - assert_eq!(normalize_path("//foo///bar//"), "/foo/bar"); - } - - #[test] - fn normalize_path_dot_segments() { - assert_eq!(normalize_path("/foo/./bar"), "/foo/bar"); - assert_eq!(normalize_path("/foo/../bar"), "/bar"); - assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz"); - } - - #[test] - fn normalize_path_dotdot_at_root() { - assert_eq!(normalize_path("/../foo"), "/foo"); - assert_eq!(normalize_path("/../../foo"), "/foo"); - } - - #[test] - fn normalize_path_percent_encoding() { - // %2F = /, %61 = a - assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar"); - assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings"); - } - - #[test] - fn normalize_path_mixed_bypass_attempts() { - // Double-encoded traversal - assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings"); - assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings"); - assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings"); - assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin"); - } - - // --- ACL bypass prevention tests --- - - #[test] - fn acl_deny_prevents_double_slash_bypass() { - let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; - // These should all be caught by the deny rule - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings")); - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings")); - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings")); - } - - #[test] - fn acl_deny_prevents_dot_segment_bypass() { - let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings")); - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings")); - } - - #[test] - fn acl_deny_prevents_percent_encoding_bypass() { - let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; - // %61dmin = admin - assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings")); - } - - #[test] - fn acl_allow_normalized_path_still_works() { - let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()]; - assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models")); - assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models")); - assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models")); - // These resolve to different paths and should be denied - assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra")); - assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models")); - } - - #[test] - fn parse_normalizes_rule_path() { - let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap(); - assert_eq!(rule.path, "/v1/models/*"); - - let rule = HttpRule::parse("GET example.com/v1//models").unwrap(); - assert_eq!(rule.path, "/v1/models"); - } - - // --- NetAllow::parse tests --- - - #[test] - fn netallow_parse_concrete_host_port() { - let r = NetAllow::parse("example.com:443").unwrap(); - assert_eq!(r.host.as_deref(), Some("example.com")); - assert_eq!(r.ports, vec![443]); - assert!(!r.all_ports); - } - - #[test] - fn netallow_parse_any_host_port() { - let r = NetAllow::parse(":8080").unwrap(); - assert_eq!(r.host, None); - assert_eq!(r.ports, vec![8080]); - assert!(!r.all_ports); - - let r = NetAllow::parse("*:8080").unwrap(); - assert_eq!(r.host, None); - assert_eq!(r.ports, vec![8080]); - assert!(!r.all_ports); - } - - #[test] - fn netallow_parse_multiple_ports() { - let r = NetAllow::parse("github.com:22,80,443").unwrap(); - assert_eq!(r.host.as_deref(), Some("github.com")); - assert_eq!(r.ports, vec![22, 80, 443]); - assert!(!r.all_ports); - } - - #[test] - fn netallow_parse_wildcard_any_host_any_port_colon() { - let r = NetAllow::parse(":*").unwrap(); - assert_eq!(r.host, None); - assert!(r.ports.is_empty()); - assert!(r.all_ports); - } - - #[test] - fn netallow_parse_wildcard_any_host_any_port_star() { - let r = NetAllow::parse("*:*").unwrap(); - assert_eq!(r.host, None); - assert!(r.ports.is_empty()); - assert!(r.all_ports); - } - - #[test] - fn netallow_parse_wildcard_concrete_host_any_port() { - let r = NetAllow::parse("example.com:*").unwrap(); - assert_eq!(r.host.as_deref(), Some("example.com")); - assert!(r.ports.is_empty()); - assert!(r.all_ports); - } - - #[test] - fn netallow_parse_rejects_mixed_wildcard_and_concrete() { - // `host:80,*` and `host:*,80` are both ambiguous: the user - // either meant "any port" (wildcard wins) or "ports 80 plus - // some weird placeholder". Refuse and force a clean spec. - let err = NetAllow::parse("example.com:80,*").unwrap_err(); - assert!(format!("{}", err).contains("cannot mix")); - let err = NetAllow::parse("example.com:*,80").unwrap_err(); - assert!(format!("{}", err).contains("cannot mix")); - } - - #[test] - fn netallow_parse_rejects_port_zero() { - let err = NetAllow::parse("example.com:0").unwrap_err(); - assert!(format!("{}", err).contains("port 0")); - } - - #[test] - fn netallow_parse_rejects_empty_port() { - let err = NetAllow::parse("example.com:").unwrap_err(); - assert!(format!("{}", err).contains("invalid port")); - } - - #[test] - fn netallow_parse_rejects_no_colon() { - let err = NetAllow::parse("example.com").unwrap_err(); - assert!(format!("{}", err).contains("expected")); - } - - #[test] - fn netallow_parse_repeated_wildcard_is_idempotent() { - // `*,*` collapses to a single wildcard — neither token contributes - // a concrete port, so the rule remains "any port". - let r = NetAllow::parse(":*,*").unwrap(); - assert!(r.all_ports); - assert!(r.ports.is_empty()); - } - - // --- Protocol scheme prefix tests --- - - #[test] - fn netallow_bare_form_defaults_to_tcp() { - let r = NetAllow::parse("example.com:443").unwrap(); - assert_eq!(r.protocol, Protocol::Tcp); - } - - #[test] - fn netallow_explicit_tcp_scheme() { - let r = NetAllow::parse("tcp://example.com:443").unwrap(); - assert_eq!(r.protocol, Protocol::Tcp); - assert_eq!(r.host.as_deref(), Some("example.com")); - assert_eq!(r.ports, vec![443]); - } - - #[test] - fn netallow_udp_scheme_with_host_port() { - let r = NetAllow::parse("udp://1.1.1.1:53").unwrap(); - assert_eq!(r.protocol, Protocol::Udp); - assert_eq!(r.host.as_deref(), Some("1.1.1.1")); - assert_eq!(r.ports, vec![53]); - } - - #[test] - fn netallow_udp_wildcard_any_anywhere() { - // The "any UDP" gate, equivalent to the old `allow_udp = true`. - let r = NetAllow::parse("udp://*:*").unwrap(); - assert_eq!(r.protocol, Protocol::Udp); - assert_eq!(r.host, None); - assert!(r.all_ports); - } - - #[test] - fn netallow_icmp_scheme_with_host() { - let r = NetAllow::parse("icmp://github.com").unwrap(); - assert_eq!(r.protocol, Protocol::Icmp); - assert_eq!(r.host.as_deref(), Some("github.com")); - assert!(r.ports.is_empty()); - assert!(!r.all_ports); - } - - #[test] - fn netallow_icmp_wildcard() { - // The "any ICMP echo" gate, equivalent to the old - // `allow_icmp = true` for the SOCK_DGRAM path. - let r = NetAllow::parse("icmp://*").unwrap(); - assert_eq!(r.protocol, Protocol::Icmp); - assert_eq!(r.host, None); - } - - #[test] - fn netallow_icmp_rejects_port() { - // ICMP has no port — `:port` is meaningless and refused - // explicitly so users can't write a rule that doesn't do what - // they think. - let err = NetAllow::parse("icmp://github.com:80").unwrap_err(); - assert!(format!("{}", err).contains("icmp rules take no port")); - } - - #[test] - fn netallow_icmp_rejects_empty_body() { - let err = NetAllow::parse("icmp://").unwrap_err(); - assert!(format!("{}", err).contains("needs a host or `*`")); - } - - #[test] - fn netallow_unknown_scheme_rejected() { - // Including `icmp-raw` — sandlock does not expose raw ICMP, so - // the scheme is unknown rather than a special-case error. - for spec in ["sctp://host:1234", "icmp-raw://*"] { - let err = NetAllow::parse(spec).unwrap_err(); - assert!(format!("{}", err).contains("unknown scheme"), "spec: {}", spec); - } - } -} diff --git a/crates/sandlock-core/src/process.rs b/crates/sandlock-core/src/process.rs new file mode 100644 index 0000000..b8b2989 --- /dev/null +++ b/crates/sandlock-core/src/process.rs @@ -0,0 +1,31 @@ +// Nesting-detection helpers used by sandbox.rs. + +use std::sync::atomic::{AtomicBool, Ordering}; + +// ============================================================ +// Nesting detection +// ============================================================ + +/// Set after seccomp confinement in the child process. +/// Any subsequent Sandbox in this process is nested. +pub(crate) static CONFINED: AtomicBool = AtomicBool::new(false); + +/// Detect if this process is already inside a sandbox. +/// +/// Checks both the in-process flag and /proc/self/status (Seccomp: 2) +/// to catch cross-process nesting (e.g. `sandlock run -- python agent.py` +/// where agent.py creates inner sandboxes). +pub fn is_nested() -> bool { + if CONFINED.load(Ordering::Relaxed) { + return true; + } + // Check /proc/self/status for active seccomp filter + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if line.starts_with("Seccomp:") { + return line.trim().ends_with('2'); + } + } + } + false +} diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index a9db120..c3b1831 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -1,128 +1,294 @@ -use crate::policy::{ByteSize, Policy}; +use crate::sandbox::{ByteSize, Sandbox}; use crate::error::SandlockError; +use serde::Deserialize; use std::path::PathBuf; +use std::collections::HashMap; +use std::time::SystemTime; -/// Default profile directory. -pub fn profile_dir() -> PathBuf { - dirs_or_fallback().join("profiles") +/// Program identity supplied by a profile alongside the policy. +/// Not a `Sandbox` field — passed separately to the sandbox runner. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ProgramSpec { + pub exec: Option, + pub args: Vec, } -fn dirs_or_fallback() -> PathBuf { - std::env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); - PathBuf::from(home).join(".config") - }) - .join("sandlock") +/// Top-level profile input. Each section maps to one schema section. +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct ProfileInput { + pub config: ConfigSection, + pub determinism: DeterminismSection, + pub program: ProgramSection, + pub filesystem: FilesystemSection, + pub network: NetworkSection, + pub http: HttpSection, + pub syscalls: SyscallsSection, + pub limits: LimitsSection, } -/// Load a profile by name. -pub fn load_profile(name: &str) -> Result { - let path = profile_dir().join(format!("{}.toml", name)); - let content = std::fs::read_to_string(&path) - .map_err(|e| SandlockError::Policy(crate::error::PolicyError::Invalid( - format!("profile '{}': {}", name, e) - )))?; - parse_profile(&content) +// Field names follow the schema vocabulary and match `Sandbox`'s field names 1:1. +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct ConfigSection { + pub http_ca: Option, + pub http_key: Option, + pub fs_storage: Option, + pub workdir: Option, } -/// Parse a TOML profile string into a Policy. -pub fn parse_profile(content: &str) -> Result { - let table: toml::Table = content.parse() - .map_err(|e| SandlockError::Policy(crate::error::PolicyError::Invalid( - format!("TOML parse error: {}", e) - )))?; +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct DeterminismSection { + pub random_seed: Option, + /// RFC3339 timestamp string. Maps to `Sandbox::time_start`. + pub time_start: Option, + pub deterministic_dirs: bool, + pub no_randomize_memory: bool, +} - // Accept both [sandbox] section and flat format (Python-compatible) - let sandbox = table.get("sandbox") - .and_then(|v| v.as_table()) - .unwrap_or(&table); +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct ProgramSection { + pub exec: Option, + pub args: Vec, + pub env: HashMap, + pub cwd: Option, + pub uid: Option, + pub clean_env: bool, + pub no_coredump: bool, + pub no_huge_pages: bool, +} - if sandbox.contains_key("name") { - return Err(SandlockError::Policy(crate::error::PolicyError::Invalid( - "profile field 'name' is not policy; pass the sandbox name at run time".into(), - ))); - } - if sandbox.contains_key("syscall_policy") { - return Err(SandlockError::Policy(crate::error::PolicyError::Invalid( - "profile field 'syscall_policy' was removed; Sandlock always applies its \ - default syscall blocklist, and 'block_syscalls' only adds entries".into(), - ))); - } +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct FilesystemSection { + pub read: Vec, + pub write: Vec, + pub deny: Vec, + /// One of `"none"`, `"overlayfs"`, `"branchfs"`. Maps to `Sandbox::fs_isolation`. + pub isolation: Option, + pub chroot: Option, + /// Each entry has the form `"VIRTUAL:HOST"`, matching `--fs-mount` syntax. + pub mount: Vec, + /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_exit`. + pub on_exit: Option, + /// One of `"commit"`, `"abort"`, `"keep"`. Maps to `Sandbox::on_error`. + pub on_error: Option, +} - let mut builder = Policy::builder(); +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct NetworkSection { + pub bind: Vec, + pub allow: Vec, + pub port_remap: bool, +} - // Parse string arrays - if let Some(paths) = sandbox.get("fs_readable").and_then(|v| v.as_array()) { - for p in paths { if let Some(s) = p.as_str() { builder = builder.fs_read(s); } } - } - if let Some(paths) = sandbox.get("fs_writable").and_then(|v| v.as_array()) { - for p in paths { if let Some(s) = p.as_str() { builder = builder.fs_write(s); } } - } - if let Some(paths) = sandbox.get("fs_denied").and_then(|v| v.as_array()) { - for p in paths { if let Some(s) = p.as_str() { builder = builder.fs_deny(s); } } - } - if let Some(specs) = sandbox.get("net_allow").and_then(|v| v.as_array()) { - for s in specs { if let Some(spec) = s.as_str() { builder = builder.net_allow(spec); } } - } - if let Some(rules) = sandbox.get("http_allow").and_then(|v| v.as_array()) { - for r in rules { if let Some(s) = r.as_str() { builder = builder.http_allow(s); } } - } - if let Some(rules) = sandbox.get("http_deny").and_then(|v| v.as_array()) { - for r in rules { if let Some(s) = r.as_str() { builder = builder.http_deny(s); } } - } - if let Some(ports) = sandbox.get("http_ports").and_then(|v| v.as_array()) { - for p in ports { if let Some(v) = p.as_integer() { builder = builder.http_port(v as u16); } } - } +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct HttpSection { + pub ports: Vec, + pub allow: Vec, + pub deny: Vec, +} - // Parse integers - if let Some(v) = sandbox.get("max_processes").and_then(|v| v.as_integer()) { - builder = builder.max_processes(v as u32); - } - if let Some(v) = sandbox.get("max_cpu").and_then(|v| v.as_integer()) { - builder = builder.max_cpu(v as u8); - } - if let Some(v) = sandbox.get("num_cpus").and_then(|v| v.as_integer()) { - builder = builder.num_cpus(v as u32); - } - if let Some(v) = sandbox.get("random_seed").and_then(|v| v.as_integer()) { - builder = builder.random_seed(v as u64); - } +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct SyscallsSection { + pub extra_allow: Vec, + pub extra_deny: Vec, +} + +// Field names drop the `max_` prefix that `Sandbox` uses (`memory`, not +// `max_memory`) — the section name `[limits]` makes the prefix redundant. +// `parse_input` maps each of these to the corresponding `Sandbox::max_*` field. +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, default)] +pub struct LimitsSection { + /// `ByteSize` string, e.g. `"512M"` (suffixes K/M/G only; IEC `MiB`/`GiB` + /// not yet supported). Maps to `Sandbox::max_memory`. + pub memory: Option, + pub processes: Option, + pub open_files: Option, + /// CPU cap as a percentage (0–100). Maps to `Sandbox::max_cpu`. + pub cpu: Option, + /// `ByteSize` string, e.g. `"256M"` (suffixes K/M/G only; IEC `MiB`/`GiB` + /// not yet supported). Maps to `Sandbox::max_disk`. + pub disk: Option, + pub gpu_devices: Option>, + pub cpu_cores: Option>, + pub num_cpus: Option, +} + +/// Convert a parsed `ProfileInput` into a `(Sandbox, ProgramSpec)` pair. +/// +/// Forwards each schema section's fields to the corresponding `SandboxBuilder` +/// method calls. The three private helpers (`parse_fs_isolation`, +/// `parse_branch_action`, `parse_mount_spec`) handle string-to-typed-value +/// conversions for fields that lack `FromStr` impls on their target types. +pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), SandlockError> { + let mut b = Sandbox::builder(); - // Parse string values - if let Some(v) = sandbox.get("max_memory").and_then(|v| v.as_str()) { - builder = builder.max_memory(ByteSize::parse(v)?); + // [config] + if let Some(p) = input.config.http_ca { b = b.http_ca(p); } + if let Some(p) = input.config.http_key { b = b.http_key(p); } + if let Some(p) = input.config.fs_storage { b = b.fs_storage(p); } + if let Some(p) = input.config.workdir { b = b.workdir(p); } + + // [determinism] + if let Some(s) = input.determinism.random_seed { b = b.random_seed(s); } + if let Some(s) = input.determinism.time_start.as_deref() { + b = b.time_start(parse_time_start(s)?); } + if input.determinism.deterministic_dirs { b = b.deterministic_dirs(true); } + if input.determinism.no_randomize_memory { b = b.no_randomize_memory(true); } + + // [program] — process knobs go to Sandbox; exec/args go to ProgramSpec. + for (k, v) in input.program.env.iter() { b = b.env_var(k, v); } + if let Some(c) = input.program.cwd { b = b.cwd(c); } + if let Some(u) = input.program.uid { b = b.uid(u); } + if input.program.clean_env { b = b.clean_env(true); } + if input.program.no_coredump { b = b.no_coredump(true); } + if input.program.no_huge_pages { b = b.no_huge_pages(true); } - // Parse booleans - if let Some(v) = sandbox.get("allow_sysv_ipc").and_then(|v| v.as_bool()) { - builder = builder.allow_sysv_ipc(v); + // [filesystem] + for p in input.filesystem.read.iter() { b = b.fs_read(p); } + for p in input.filesystem.write.iter() { b = b.fs_write(p); } + for p in input.filesystem.deny.iter() { b = b.fs_deny(p); } + if let Some(s) = input.filesystem.isolation.as_deref() { + b = b.fs_isolation(parse_fs_isolation(s)?); } - if let Some(v) = sandbox.get("clean_env").and_then(|v| v.as_bool()) { - builder = builder.clean_env(v); + if let Some(c) = input.filesystem.chroot { b = b.chroot(c); } + for spec in input.filesystem.mount.iter() { + let (virt, host) = parse_mount_spec(spec)?; + b = b.fs_mount(virt, host); } - if let Some(v) = sandbox.get("deterministic_dirs").and_then(|v| v.as_bool()) { - builder = builder.deterministic_dirs(v); + if let Some(s) = input.filesystem.on_exit.as_deref() { b = b.on_exit(parse_branch_action(s)?); } + if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); } + + // [network] + for p in input.network.bind.iter() { b = b.net_bind_port(*p); } + for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); } + if input.network.port_remap { b = b.port_remap(true); } + + // [http] + for p in input.http.ports.iter() { b = b.http_port(*p); } + for r in input.http.allow.iter() { b = b.http_allow(r); } + for r in input.http.deny.iter() { b = b.http_deny(r); } + + // [syscalls] + if !input.syscalls.extra_allow.is_empty() { + b = b.extra_allow_syscalls(input.syscalls.extra_allow); } - if let Some(v) = sandbox.get("workdir").and_then(|v| v.as_str()) { - builder = builder.workdir(v); + if !input.syscalls.extra_deny.is_empty() { + b = b.extra_deny_syscalls(input.syscalls.extra_deny); } - if let Some(v) = sandbox.get("cwd").and_then(|v| v.as_str()) { - builder = builder.cwd(v); + + // [limits] + if let Some(s) = input.limits.memory.as_deref() { + b = b.max_memory(ByteSize::parse(s).map_err(SandlockError::Sandbox)?); } - // Parse port arrays - if let Some(ports) = sandbox.get("net_bind").and_then(|v| v.as_array()) { - for p in ports { if let Some(n) = p.as_integer() { builder = builder.net_bind_port(n as u16); } } + if let Some(n) = input.limits.processes { b = b.max_processes(n); } + if let Some(n) = input.limits.open_files { b = b.max_open_files(n); } + if let Some(p) = input.limits.cpu { b = b.max_cpu(p); } + if let Some(s) = input.limits.disk.as_deref() { + b = b.max_disk(ByteSize::parse(s).map_err(SandlockError::Sandbox)?); } + if let Some(g) = input.limits.gpu_devices { b = b.gpu_devices(g); } + if let Some(c) = input.limits.cpu_cores { b = b.cpu_cores(c); } + if let Some(n) = input.limits.num_cpus { b = b.num_cpus(n); } + + let policy = b.build()?; + let spec = ProgramSpec { exec: input.program.exec, args: input.program.args }; + Ok((policy, spec)) +} - // Parse extra syscall blocklist entries. - if let Some(syscalls) = sandbox.get("block_syscalls").and_then(|v| v.as_array()) { - let names: Vec = syscalls.iter().filter_map(|v| v.as_str().map(String::from)).collect(); - builder = builder.block_syscalls(names); +/// Parses the `[filesystem].isolation` schema string into a `FsIsolation`. +fn parse_fs_isolation(s: &str) -> Result { + use crate::error::SandboxError; + use crate::sandbox::FsIsolation; + Ok(match s { + "none" => FsIsolation::None, + "overlayfs" => FsIsolation::OverlayFs, + "branchfs" => FsIsolation::BranchFs, + other => return Err(SandlockError::Sandbox(SandboxError::Invalid( + format!("invalid fs isolation {other:?}; expected \"none\" | \"overlayfs\" | \"branchfs\""), + ))), + }) +} + +/// Parses an `[filesystem].on_exit` / `on_error` string into a `BranchAction`. +fn parse_branch_action(s: &str) -> Result { + use crate::error::SandboxError; + use crate::sandbox::BranchAction; + Ok(match s { + "commit" => BranchAction::Commit, + "abort" => BranchAction::Abort, + "keep" => BranchAction::Keep, + other => return Err(SandlockError::Sandbox(SandboxError::Invalid( + format!("invalid branch action {other:?}; expected \"commit\" | \"abort\" | \"keep\""), + ))), + }) +} + +/// Parses a `"VIRTUAL:HOST"` mount spec string into a `(virtual, host)` pair. +fn parse_mount_spec(s: &str) -> Result<(PathBuf, PathBuf), SandlockError> { + use crate::error::SandboxError; + let (virt, host) = s.split_once(':').ok_or_else(|| SandlockError::Sandbox(SandboxError::Invalid( + format!("invalid mount spec {s:?}; expected \"VIRTUAL:HOST\""), + )))?; + if virt.is_empty() || host.is_empty() { + return Err(SandlockError::Sandbox(SandboxError::Invalid( + format!("invalid mount spec {s:?}; both VIRTUAL and HOST must be non-empty"), + ))); } + Ok((PathBuf::from(virt), PathBuf::from(host))) +} + +/// Parses an RFC3339 timestamp string into `SystemTime`. +fn parse_time_start(s: &str) -> Result { + use crate::error::SandboxError; + let ts: jiff::Timestamp = s.parse().map_err(|e| { + SandlockError::Sandbox(SandboxError::Invalid( + format!("invalid [determinism].time_start {s:?}: {e}"), + )) + })?; + Ok(ts.into()) +} - builder.build().map_err(|e| SandlockError::Policy(e)) +/// Default profile directory. +pub fn profile_dir() -> PathBuf { + dirs_or_fallback().join("profiles") +} + +fn dirs_or_fallback() -> PathBuf { + std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(home).join(".config") + }) + .join("sandlock") +} + +/// Parse a TOML profile string into a Sandbox + ProgramSpec. +pub fn parse_profile(content: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> { + let input: ProfileInput = toml::from_str(content) + .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid( + format!("TOML parse error: {e}"), + )))?; + parse_input(input) +} + +/// Load a profile by name. +pub fn load_profile(name: &str) -> Result<(Sandbox, ProgramSpec), SandlockError> { + let path = profile_dir().join(format!("{}.toml", name)); + let content = std::fs::read_to_string(&path) + .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid( + format!("profile '{}': {}", name, e), + )))?; + parse_profile(&content) } /// List available profile names. @@ -131,7 +297,7 @@ pub fn list_profiles() -> Result, SandlockError> { if !dir.exists() { return Ok(Vec::new()); } let mut names = Vec::new(); for entry in std::fs::read_dir(&dir) - .map_err(|e| SandlockError::Policy(crate::error::PolicyError::Invalid(format!("read dir: {}", e))))? { + .map_err(|e| SandlockError::Sandbox(crate::error::SandboxError::Invalid(format!("read dir: {}", e))))? { if let Ok(entry) = entry { if let Some(name) = entry.path().file_stem() { if entry.path().extension().map_or(false, |e| e == "toml") { @@ -149,72 +315,236 @@ mod tests { use super::*; #[test] - fn parse_basic_profile() { + fn list_profiles_empty_dir() { + // With no profile dir, list_profiles() should return an empty vec. + std::env::set_var("XDG_CONFIG_HOME", "/tmp/sandlock-test-nonexistent"); + let profiles = list_profiles().unwrap(); + assert!(profiles.is_empty()); + std::env::remove_var("XDG_CONFIG_HOME"); + } + + #[test] + fn profile_input_deserializes_minimal() { + let toml = r#" + [program] + exec = "/bin/true" + "#; + let parsed: ProfileInput = toml::from_str(toml).unwrap(); + assert_eq!(parsed.program.exec, Some("/bin/true".into())); + assert!(parsed.program.args.is_empty()); + assert_eq!(parsed.config, ConfigSection::default()); + assert_eq!(parsed.filesystem, FilesystemSection::default()); + } + + #[test] + fn config_section_maps_to_policy_http_fields() { + let toml = r#" + [config] + http_ca = "/tmp/ca.pem" + http_key = "/tmp/ca.key" + [program] + exec = "/bin/true" + "#; + let input: ProfileInput = toml::from_str(toml).unwrap(); + let (policy, _spec) = parse_input(input).unwrap(); + assert_eq!(policy.http_ca.as_deref(), Some(std::path::Path::new("/tmp/ca.pem"))); + assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key"))); + } + + #[test] + fn syscalls_extra_allow_sysv_ipc_sets_vec() { + let toml = r#" + [program] + exec = "/bin/true" + [syscalls] + extra_allow = ["sysv_ipc"] + extra_deny = ["ptrace"] + "#; + let input: ProfileInput = toml::from_str(toml).unwrap(); + let (policy, _spec) = parse_input(input).unwrap(); + assert!(policy.allows_sysv_ipc()); + assert_eq!(policy.extra_deny_syscalls, vec!["ptrace".to_string()]); + } + + #[test] + fn parse_mount_spec_rejects_missing_colon() { + let toml = r#" + [program] + exec = "/bin/true" + [filesystem] + mount = ["nocolon"] + "#; + let input: ProfileInput = toml::from_str(toml).unwrap(); + let err = parse_input(input).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("VIRTUAL:HOST"), "got: {msg}"); + } + + #[test] + fn parse_mount_spec_rejects_empty_half() { let toml = r#" -[sandbox] -fs_readable = ["/usr", "/lib", "/bin"] -fs_writable = ["/tmp"] -max_memory = "2G" -max_processes = 64 -"#; - let policy = parse_profile(toml).unwrap(); - assert_eq!(policy.fs_readable.len(), 3); - assert_eq!(policy.fs_writable.len(), 1); - assert_eq!(policy.max_memory, Some(ByteSize::gib(2))); - assert_eq!(policy.max_processes, 64); + [program] + exec = "/bin/true" + [filesystem] + mount = [":/host"] + "#; + let input: ProfileInput = toml::from_str(toml).unwrap(); + let err = parse_input(input).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("non-empty"), "got: {msg}"); } #[test] - fn parse_flat_format() { - // Flat format (no [sandbox] section) should work + fn parse_profile_full_example() { let toml = r#" -fs_readable = ["/usr", "/lib"] -clean_env = true -"#; - let policy = parse_profile(toml).unwrap(); + [config] + http_ca = "/etc/sandlock/ca.pem" + http_key = "/etc/sandlock/ca.key" + fs_storage = "/var/sandlock/redis-worker" + workdir = "/var/sandlock/redis-worker/work" + + [determinism] + random_seed = 42 + deterministic_dirs = true + no_randomize_memory = true + + [program] + exec = "/usr/bin/redis-cli" + args = ["-h", "cache.internal", "-p", "6379"] + cwd = "/var/lib/redis" + uid = 1000 + clean_env = true + no_coredump = true + + [filesystem] + read = ["/usr", "/etc/redis"] + write = ["/var/lib/redis/state"] + deny = ["/proc/sys"] + isolation = "overlayfs" + chroot = "/var/lib/redis-rootfs" + mount = ["/data:/srv/redis-data"] + on_exit = "commit" + on_error = "abort" + + [network] + bind = [8080] + allow = ["tcp://cache.internal:6379"] + port_remap = true + + [http] + ports = [80, 443] + allow = ["GET api.internal/v1/*"] + deny = ["* */admin/*"] + + [syscalls] + extra_allow = ["sysv_ipc"] + extra_deny = ["ptrace", "mount"] + + [limits] + memory = "512M" + processes = 32 + cpu = 80 + "#; + + let (policy, spec) = parse_profile(toml).unwrap(); + assert_eq!(spec.exec.as_deref(), Some(std::path::Path::new("/usr/bin/redis-cli"))); + assert_eq!(spec.args.len(), 4); + assert!(policy.allows_sysv_ipc()); + assert_eq!(policy.extra_deny_syscalls.len(), 2); assert_eq!(policy.fs_readable.len(), 2); - assert!(policy.clean_env); + // 1 user rule (tcp://cache.internal:6379) + at least 1 http-port-derived + // rule that the builder auto-merges (api.internal on http.ports). The + // merge is the contract being verified here. + assert!(policy.net_allow.len() >= 2); + assert_eq!(policy.http_allow.len(), 1); + assert_eq!(policy.fs_mount.len(), 1); + } + + #[test] + fn parse_profile_unknown_section_field_is_error() { + let toml = r#" + [program] + exec = "/bin/true" + bogus = 1 + "#; + let err = parse_profile(toml).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("unknown field"), "got: {msg}"); } #[test] - fn parse_sandbox_section_format() { - // [sandbox] section format should also work + fn parse_profile_old_flat_format_is_error() { + // Old format used top-level "fs_readable = [...]"; we no longer accept it. let toml = r#" -[sandbox] -fs_readable = ["/usr"] -max_processes = 10 -"#; - let policy = parse_profile(toml).unwrap(); - assert_eq!(policy.fs_readable.len(), 1); - assert_eq!(policy.max_processes, 10); + fs_readable = ["/usr"] + "#; + let err = parse_profile(toml).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("unknown field"), "got: {msg}"); } #[test] - fn parse_invalid_toml() { - let err = parse_profile("not valid toml {{{").unwrap_err(); - assert!(err.to_string().contains("TOML parse error")); + fn parse_profile_time_start_sets_policy_field() { + let toml = r#" + [program] + exec = "/bin/true" + [determinism] + time_start = "2026-01-01T00:00:00Z" + "#; + let (policy, _spec) = parse_profile(toml).unwrap(); + assert!(policy.time_start.is_some()); } #[test] - fn reject_name_in_profile() { - let err = parse_profile(r#"name = "api.local""#).unwrap_err(); - assert!(err.to_string().contains("name")); - assert!(err.to_string().contains("not policy")); + fn parse_profile_invalid_time_start_is_error() { + let toml = r#" + [program] + exec = "/bin/true" + [determinism] + time_start = "not-a-time" + "#; + let err = parse_profile(toml).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("time_start"), "got: {msg}"); } #[test] - fn reject_removed_syscall_policy_in_profile() { - let err = parse_profile(r#"syscall_policy = "none""#).unwrap_err(); - assert!(err.to_string().contains("syscall_policy")); - assert!(err.to_string().contains("removed")); + fn isolation_overlayfs_without_workdir_is_error() { + let toml = r#" + [program] + exec = "/bin/true" + [filesystem] + isolation = "overlayfs" + "#; + let err = parse_profile(toml).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.to_lowercase().contains("workdir"), + "expected error to mention workdir; got: {msg}" + ); } #[test] - fn list_profiles_empty_dir() { - // With no profile dir, should return empty vec - std::env::set_var("XDG_CONFIG_HOME", "/tmp/sandlock-test-nonexistent"); - let profiles = list_profiles().unwrap(); - assert!(profiles.is_empty()); - std::env::remove_var("XDG_CONFIG_HOME"); + fn isolation_none_without_workdir_is_ok() { + let toml = r#" + [program] + exec = "/bin/true" + [filesystem] + isolation = "none" + "#; + let (_p, _s) = parse_profile(toml).unwrap(); + } + + #[test] + fn isolation_overlayfs_with_workdir_is_ok() { + let toml = r#" + [program] + exec = "/bin/true" + [config] + workdir = "/tmp/wd" + [filesystem] + isolation = "overlayfs" + "#; + let (_p, _s) = parse_profile(toml).unwrap(); } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 2ee247f..f8e7ee2 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1,386 +1,1183 @@ -// Sandbox orchestrator — public API that coordinates fork, confinement, -// and async supervision of sandboxed child processes. - -use std::ffi::CString; -use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd}; +use std::collections::HashMap; +use std::os::fd::AsRawFd; +use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; +use std::time::SystemTime; -use tokio::sync::Mutex; +use serde::{Deserialize, Serialize}; use tokio::task::JoinHandle; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use crate::context; +use crate::error::SandboxError; -use crate::context::{self, PipePair, read_u32_fd, write_u32_fd}; -use crate::cow::{CowBranch, overlayfs::OverlayBranch, branchfs::BranchFsBranch}; -use crate::error::{SandboxError, SandlockError}; -use crate::network; -use crate::policy::{BranchAction, FsIsolation, Policy}; -use crate::result::{ExitStatus, RunResult}; -use crate::seccomp::ctx::SupervisorCtx; -use crate::seccomp::notif::{self, NotifPolicy}; -use crate::seccomp::state::{ChrootState, CowState, NetworkState, PolicyFnState, ProcfsState, ResourceState, TimeRandomState}; -use crate::sys::syscall; +/// A byte size value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ByteSize(pub u64); -// ============================================================ -// Nesting detection -// ============================================================ +impl ByteSize { + pub fn bytes(n: u64) -> Self { + ByteSize(n) + } + + pub fn kib(n: u64) -> Self { + ByteSize(n * 1024) + } + + pub fn mib(n: u64) -> Self { + ByteSize(n * 1024 * 1024) + } + + pub fn gib(n: u64) -> Self { + ByteSize(n * 1024 * 1024 * 1024) + } + + pub fn parse(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(SandboxError::Invalid("empty byte size string".into())); + } + + // Check for suffix + let last = s.chars().last().unwrap(); + if last.is_ascii_alphabetic() { + let (num_str, suffix) = s.split_at(s.len() - 1); + let n: u64 = num_str + .trim() + .parse() + .map_err(|_| SandboxError::Invalid(format!("invalid byte size: {}", s)))?; + match suffix.to_ascii_uppercase().as_str() { + "K" => Ok(ByteSize::kib(n)), + "M" => Ok(ByteSize::mib(n)), + "G" => Ok(ByteSize::gib(n)), + other => Err(SandboxError::Invalid(format!("unknown byte size suffix: {}", other))), + } + } else { + let n: u64 = s + .parse() + .map_err(|_| SandboxError::Invalid(format!("invalid byte size: {}", s)))?; + Ok(ByteSize(n)) + } + } +} + +/// Confinement for confining the current process in place. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Confinement { + pub fs_writable: Vec, + pub fs_readable: Vec, +} + +impl Confinement { + pub fn builder() -> ConfinementBuilder { + ConfinementBuilder::default() + } +} + +#[derive(Default)] +pub struct ConfinementBuilder { + fs_writable: Vec, + fs_readable: Vec, +} + +impl ConfinementBuilder { + pub fn fs_write(mut self, path: impl Into) -> Self { + self.fs_writable.push(path.into()); + self + } + + pub fn fs_read(mut self, path: impl Into) -> Self { + self.fs_readable.push(path.into()); + self + } + + pub fn build(self) -> Confinement { + Confinement { + fs_writable: self.fs_writable, + fs_readable: self.fs_readable, + } + } +} + +impl TryFrom<&Sandbox> for Confinement { + type Error = SandboxError; + + fn try_from(sandbox: &Sandbox) -> Result { + let mut unsupported = Vec::new(); + if !sandbox.fs_denied.is_empty() { unsupported.push("fs_denied"); } + if !sandbox.extra_deny_syscalls.is_empty() { unsupported.push("extra_deny_syscalls"); } + if !sandbox.net_allow.is_empty() { unsupported.push("net_allow"); } + if !sandbox.net_bind.is_empty() { unsupported.push("net_bind"); } + if sandbox.allows_sysv_ipc() { unsupported.push("extra_allow_syscalls=[\"sysv_ipc\"]"); } + if !sandbox.http_allow.is_empty() { unsupported.push("http_allow"); } + if !sandbox.http_deny.is_empty() { unsupported.push("http_deny"); } + if !sandbox.http_ports.is_empty() { unsupported.push("http_ports"); } + if sandbox.http_ca.is_some() { unsupported.push("http_ca"); } + if sandbox.http_key.is_some() { unsupported.push("http_key"); } + if sandbox.max_memory.is_some() { unsupported.push("max_memory"); } + if sandbox.max_processes != 64 { unsupported.push("max_processes"); } + if sandbox.max_open_files.is_some() { unsupported.push("max_open_files"); } + if sandbox.max_cpu.is_some() { unsupported.push("max_cpu"); } + if sandbox.random_seed.is_some() { unsupported.push("random_seed"); } + if sandbox.time_start.is_some() { unsupported.push("time_start"); } + if sandbox.no_randomize_memory { unsupported.push("no_randomize_memory"); } + if sandbox.no_huge_pages { unsupported.push("no_huge_pages"); } + if sandbox.no_coredump { unsupported.push("no_coredump"); } + if sandbox.deterministic_dirs { unsupported.push("deterministic_dirs"); } + if sandbox.fs_isolation != FsIsolation::None { unsupported.push("fs_isolation"); } + if sandbox.workdir.is_some() { unsupported.push("workdir"); } + if sandbox.cwd.is_some() { unsupported.push("cwd"); } + if sandbox.fs_storage.is_some() { unsupported.push("fs_storage"); } + if sandbox.max_disk.is_some() { unsupported.push("max_disk"); } + if sandbox.on_exit != BranchAction::Commit { unsupported.push("on_exit"); } + if sandbox.on_error != BranchAction::Abort { unsupported.push("on_error"); } + if !sandbox.fs_mount.is_empty() { unsupported.push("fs_mount"); } + if sandbox.chroot.is_some() { unsupported.push("chroot"); } + if sandbox.clean_env { unsupported.push("clean_env"); } + if !sandbox.env.is_empty() { unsupported.push("env"); } + if sandbox.gpu_devices.is_some() { unsupported.push("gpu_devices"); } + if sandbox.cpu_cores.is_some() { unsupported.push("cpu_cores"); } + if sandbox.num_cpus.is_some() { unsupported.push("num_cpus"); } + if sandbox.port_remap { unsupported.push("port_remap"); } + if sandbox.uid.is_some() { unsupported.push("uid"); } + if sandbox.policy_fn.is_some() { unsupported.push("policy_fn"); } + + if !unsupported.is_empty() { + return Err(SandboxError::UnsupportedForConfine(unsupported.join(", "))); + } + + Ok(Self { + fs_writable: sandbox.fs_writable.clone(), + fs_readable: sandbox.fs_readable.clone(), + }) + } +} + +/// Filesystem isolation mode. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum FsIsolation { + #[default] + None, + OverlayFs, + BranchFs, +} -/// Set after seccomp confinement in the child process. -/// Any subsequent Sandbox in this process is nested. -pub(crate) static CONFINED: AtomicBool = AtomicBool::new(false); +/// Action to take on branch exit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum BranchAction { + #[default] + Commit, + Abort, + Keep, +} -/// Detect if this process is already inside a sandbox. +/// L4 protocol that a `NetAllow` rule applies to. /// -/// Checks both the in-process flag and /proc/self/status (Seccomp: 2) -/// to catch cross-process nesting (e.g. `sandlock run -- python agent.py` -/// where agent.py creates inner sandboxes). -pub fn is_nested() -> bool { - if CONFINED.load(Ordering::Relaxed) { - return true; +/// `Tcp` is the default if a rule has no scheme (the bare `host:port` +/// form). `Udp` and `Icmp` require an explicit scheme. +/// +/// `Icmp` is the kernel's unprivileged ping socket +/// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` — +/// destinations are filterable per host. Sandlock does not expose raw +/// ICMP (`SOCK_RAW + IPPROTO_ICMP`): destination filtering at `sendto` +/// would lie because raw sockets let the agent craft the IP header, +/// and packet-crafting capabilities aren't part of the XOA threat +/// model. Workloads that genuinely need raw ICMP should run outside +/// sandlock or rely on the host's `ping_group_range` for the dgram +/// path instead. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + Tcp, + Udp, + Icmp, +} + +impl Protocol { + fn parse(s: &str) -> Option { + match s { + "tcp" => Some(Protocol::Tcp), + "udp" => Some(Protocol::Udp), + "icmp" => Some(Protocol::Icmp), + _ => None, + } + } +} + +/// A network endpoint allow rule. +/// +/// Each rule permits one protocol's traffic to one host (or any IP, for +/// the `:port` form) on a specific set of ports. Multiple rules are +/// OR'd: traffic is permitted if any rule matches the protocol, the +/// destination IP, and the destination port. +/// +/// ICMP rules carry no port (ICMP has none); their `ports` is empty +/// and `all_ports` is false. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct NetAllow { + /// L4 protocol this rule applies to. + #[serde(default = "default_protocol_tcp")] + pub protocol: Protocol, + /// Hostname; `None` means "any IP" (the `:port` form, or `icmp://*`). + pub host: Option, + /// Permitted ports. Must be non-empty unless `all_ports` is true, + /// in which case it must be empty. Always empty for `Protocol::Icmp`. + pub ports: Vec, + /// "Any port" wildcard from the `*` token in port position. When + /// true, `ports` is empty; the rule permits every TCP/UDP port to + /// the host (or to any IP, when `host` is `None`). + #[serde(default)] + pub all_ports: bool, +} + +fn default_protocol_tcp() -> Protocol { Protocol::Tcp } + +impl NetAllow { + /// Parse a rule spec. Forms: + /// + /// - `host:port[,port,...]`, `:port`, `*:port`, `host:*`, `:*`, `*:*` + /// — TCP (the default scheme). + /// - `tcp://...` — explicit TCP, same suffix grammar as the bare form. + /// - `udp://...` — UDP, same suffix grammar as the bare form. + /// - `icmp://host` or `icmp://*` — ICMP echo (kernel ping socket). + /// No port field; `icmp://host:80` is rejected. + /// + /// `*` in port position means "any port" (the all-ports wildcard). + /// Mixing `*` with concrete ports (e.g. `host:80,*`) is rejected. + pub fn parse(s: &str) -> Result { + // Split off the optional scheme prefix `://`. If absent, + // default to TCP and the rest of the parser is unchanged. + let (protocol, rest) = match s.split_once("://") { + Some((scheme, body)) => { + let proto = Protocol::parse(scheme).ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-allow: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", + scheme, s + )) + })?; + (proto, body) + } + None => (Protocol::Tcp, s), + }; + + if protocol == Protocol::Icmp { + return Self::parse_icmp(rest, s); + } + + let (host_part, port_part) = rest.rsplit_once(':').ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-allow: expected `host:port` or `:port`, got `{}`", + s + )) + })?; + let host = match host_part { + "" | "*" => None, + h => Some(h.to_string()), + }; + + // Detect the wildcard token. We split on ',' first so a + // single `*` is a clean match — `*,80` is rejected explicitly + // below rather than letting `*` parse as port 0. + let mut ports = Vec::new(); + let mut saw_wildcard = false; + for p in port_part.split(',') { + let p = p.trim(); + if p == "*" { + saw_wildcard = true; + continue; + } + let n: u16 = p.parse().map_err(|_| { + SandboxError::Invalid(format!("--net-allow: invalid port `{}` in `{}`", p, s)) + })?; + if n == 0 { + return Err(SandboxError::Invalid(format!( + "--net-allow: port 0 is not valid in `{}`", + s + ))); + } + ports.push(n); + } + if saw_wildcard && !ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-allow: cannot mix `*` with concrete ports in `{}`", + s + ))); + } + if !saw_wildcard && ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-allow: at least one port required in `{}`", + s + ))); + } + Ok(NetAllow { protocol, host, ports, all_ports: saw_wildcard }) + } + + /// Parse the body of an `icmp://` rule. Accepts a host or `*` — + /// ICMP has no ports, so any `:` separator is rejected. + fn parse_icmp(body: &str, full: &str) -> Result { + if body.contains(':') { + return Err(SandboxError::Invalid(format!( + "--net-allow: icmp rules take no port, got `{}`", + full + ))); + } + if body.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-allow: icmp rule needs a host or `*`, got `{}`", + full + ))); + } + let host = match body { + "*" => None, + h => Some(h.to_string()), + }; + Ok(NetAllow { + protocol: Protocol::Icmp, + host, + ports: Vec::new(), + all_ports: false, + }) } - // Check /proc/self/status for active seccomp filter - if let Ok(status) = std::fs::read_to_string("/proc/self/status") { - for line in status.lines() { - if line.starts_with("Seccomp:") { - return line.trim().ends_with('2'); +} + +/// An HTTP access control rule. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HttpRule { + pub method: String, + pub host: String, + pub path: String, +} + +impl HttpRule { + /// Parse a rule from "METHOD host/path" format. + /// + /// Examples: + /// - `"GET api.example.com/v1/*"` → method="GET", host="api.example.com", path="/v1/*" + /// - `"* */admin/*"` → method="*", host="*", path="/admin/*" + /// - `"GET example.com"` → method="GET", host="example.com", path="/*" + pub fn parse(s: &str) -> Result { + let s = s.trim(); + let (method, rest) = s + .split_once(char::is_whitespace) + .ok_or_else(|| SandboxError::Invalid(format!("invalid http rule: {}", s)))?; + let rest = rest.trim(); + if rest.is_empty() { + return Err(SandboxError::Invalid(format!("invalid http rule: {}", s))); + } + + let (host, path) = if let Some(pos) = rest.find('/') { + let (h, p) = rest.split_at(pos); + // Normalize the rule path, but preserve trailing * for glob matching. + let has_wildcard = p.ends_with('*'); + let mut normalized = normalize_path(p); + if has_wildcard && !normalized.ends_with('*') { + normalized.push('*'); } + (h.to_string(), normalized) + } else { + (rest.to_string(), "/*".to_string()) + }; + + Ok(HttpRule { + method: method.to_uppercase(), + host, + path, + }) + } + + /// Check whether this rule matches the given request parameters. + /// The request path is normalized before matching to prevent bypasses + /// via `//`, `/../`, `/.`, or percent-encoding. + pub fn matches(&self, method: &str, host: &str, path: &str) -> bool { + // Method match + if self.method != "*" && !self.method.eq_ignore_ascii_case(method) { + return false; } + // Host match + if self.host != "*" && !self.host.eq_ignore_ascii_case(host) { + return false; + } + // Path match — normalize to prevent encoding/traversal bypasses + let normalized = normalize_path(path); + prefix_or_exact_match(&self.path, &normalized) } - false } -// ============================================================ -// SandboxState -// ============================================================ +/// Normalize an HTTP path to prevent ACL bypasses via encoding tricks. +/// +/// - Decodes percent-encoded characters (e.g. `%2F` → `/`, `%61` → `a`) +/// - Collapses duplicate slashes (`//` → `/`) +/// - Resolves `.` and `..` segments +/// - Ensures the path starts with `/` +pub fn normalize_path(path: &str) -> String { + // 1. Percent-decode + let mut decoded = String::with_capacity(path.len()); + let mut chars = path.bytes(); + while let Some(b) = chars.next() { + if b == b'%' { + let hi = chars.next(); + let lo = chars.next(); + if let (Some(h), Some(l)) = (hi, lo) { + let hex = [h, l]; + if let Ok(s) = std::str::from_utf8(&hex) { + if let Ok(val) = u8::from_str_radix(s, 16) { + decoded.push(val as char); + continue; + } + } + // Malformed percent encoding — keep as-is + decoded.push(b as char); + decoded.push(h as char); + decoded.push(l as char); + } else { + decoded.push(b as char); + } + } else { + decoded.push(b as char); + } + } -enum SandboxState { - Created, - Running, - Paused, - Stopped(ExitStatus), + // 2. Split into segments, resolve . and .., skip empty segments (collapses //) + let mut segments: Vec<&str> = Vec::new(); + for seg in decoded.split('/') { + match seg { + "" | "." => {} + ".." => { + segments.pop(); + } + s => segments.push(s), + } + } + + // 3. Reconstruct with leading / + let mut result = String::with_capacity(decoded.len()); + result.push('/'); + result.push_str(&segments.join("/")); + result +} + +/// Simple prefix or exact matching for paths. Supports trailing `*` as a prefix match. +/// +/// Only supports: +/// - `"/*"` or `"*"` matches everything +/// - `"/v1/*"` matches "/v1/foo", "/v1/foo/bar" (prefix match) +/// - `"/v1/models"` matches exactly "/v1/models" (exact match) +/// +/// Does NOT support mid-pattern wildcards (e.g., "/v1/*/models"). +pub fn prefix_or_exact_match(pattern: &str, value: &str) -> bool { + if pattern == "/*" || pattern == "*" { + return true; + } + if let Some(prefix) = pattern.strip_suffix('*') { + value.starts_with(prefix) + } else { + pattern == value + } +} + +/// Evaluate HTTP ACL rules against a request. +/// +/// - Block rules are checked first; if any match, return false. +/// - Allow rules are checked next; if any match, return true. +/// - If allow rules exist but none matched, return false (deny-by-default). +/// - If no rules at all, return true (unrestricted). +pub fn http_acl_check( + allow: &[HttpRule], + deny: &[HttpRule], + method: &str, + host: &str, + path: &str, +) -> bool { + // Block rules checked first + for rule in deny { + if rule.matches(method, host, path) { + return false; + } + } + // Allow rules checked next + if allow.is_empty() && deny.is_empty() { + return true; // unrestricted + } + if allow.is_empty() { + // Only block rules exist; anything not denied is allowed + return true; + } + for rule in allow { + if rule.matches(method, host, path) { + return true; + } + } + false // allow rules exist but none matched } // ============================================================ -// Sandbox +// Runtime — private heap-allocated state, present only while running // ============================================================ -/// The main user-facing sandbox API. -/// -/// Orchestrates fork, confinement (Landlock + seccomp), and async -/// notification-based supervision of the sandboxed child process. -pub struct Sandbox { +/// Private runtime state. Only allocated after `start()` / `run()` is +/// called; `None` for config-only `Sandbox` instances. +struct Runtime { name: String, - policy: Policy, - state: SandboxState, + state: RuntimeState, child_pid: Option, - pidfd: Option, + pidfd: Option, notif_handle: Option>, throttle_handle: Option>, loadavg_handle: Option>, - /// Capture pipe read ends — kept alive so the child doesn't get SIGPIPE. - _stdout_read: Option, - _stderr_read: Option, - /// COW filesystem branch (OverlayFS or BranchFS). - cow_branch: Option>, - /// Seccomp COW branch extracted from supervisor state after child exits. + _stdout_read: Option, + _stderr_read: Option, + cow_branch: Option>, seccomp_cow: Option, - /// Shared resource state for freeze/thaw and loadavg support. - supervisor_resource: Option>>, - /// Shared COW state for post-wait extraction. - supervisor_cow: Option>>, - /// Shared network state for port mapping queries. - supervisor_network: Option>>, - /// Control pipe for fork commands (parent end). - ctrl_fd: Option, - /// Stdout pipe read end (for fork clones — used by reduce). - stdout_pipe: Option, - /// Init function (runs once in child before fork). - init_fn: Option>, - /// Work function (runs in each fork clone). - work_fn: Option>, - /// Optional fd overrides for stdin/stdout/stderr (used by Pipeline). + supervisor_resource: Option>>, + supervisor_cow: Option>>, + supervisor_network: Option>>, + ctrl_fd: Option, + stdout_pipe: Option, io_overrides: Option<(Option, Option, Option)>, - /// Extra fd mappings for the child: (target_fd, source_fd). - /// Each pair dup2's source_fd to target_fd in the child before exec. extra_fds: Vec<(i32, i32)>, - /// HTTP ACL proxy handle — kept alive so the proxy runs while the child is alive. http_acl_handle: Option, - /// Optional callback invoked when a port bind is recorded. #[allow(clippy::type_complexity)] - on_bind: Option) + Send + Sync>>, - /// User-supplied extra syscall handlers as `(syscall_nr, Arc)` - /// pairs. Taken on spawn and appended to the dispatch table after - /// all builtin handlers. + on_bind: Option) + Send + Sync>>, extra_handlers: Vec<(i64, Arc)>, } -impl Sandbox { - /// Create a new sandbox in the `Created` state. - pub fn new(policy: &Policy, name: Option<&str>) -> Result { - let name = resolve_name(name)?; - Ok(Self::create(policy, name)) - } +/// Lifecycle state for the runtime. +enum RuntimeState { + Created, + Running, + Paused, + Stopped(crate::result::ExitStatus), +} - /// Create a sandbox with init and work functions for COW forking. +/// Sandbox configuration. +#[derive(Serialize, Deserialize)] +pub struct Sandbox { + // Filesystem access + pub fs_writable: Vec, + pub fs_readable: Vec, + pub fs_denied: Vec, + + // Extra syscall filtering on top of Sandlock's default blocklist. + pub extra_deny_syscalls: Vec, + pub extra_allow_syscalls: Vec, + + // Network + /// Outbound endpoint allowlist as a list of `(protocol, host?, ports)` + /// rules. Each rule names a protocol (TCP/UDP/ICMP) and either a + /// concrete host or "any IP." TCP and UDP rules carry ports; ICMP + /// rules have none. /// - /// `init_fn` runs once in the child to load expensive state. - /// `work_fn` runs in each COW clone created by `fork(N)`. + /// **Protocol gating falls out of rule presence.** Sandlock denies + /// UDP and ICMP socket creation by default; opting in is "list at + /// least one rule for that protocol" (e.g. `udp://*:*` for any UDP, + /// `icmp://*` for any ICMP echo). TCP is always permitted. /// - /// ```ignore - /// let mut sb = Sandbox::new_with_fns(&policy, Some("rollout"), - /// || { load_model(); }, - /// |clone_id| { rollout(clone_id); }, - /// )?; - /// let clones = sb.fork(1000).await?; - /// ``` - pub fn new_with_fns( - policy: &Policy, - name: Option<&str>, - init_fn: impl FnOnce() + Send + 'static, - work_fn: impl Fn(u32) + Send + Sync + 'static, - ) -> Result { - let name = resolve_name(name)?; - let mut sb = Self::create(policy, name); - sb.init_fn = Some(Box::new(init_fn)); - sb.work_fn = Some(Arc::new(work_fn)); - Ok(sb) - } - - fn create(policy: &Policy, name: String) -> Self { + /// Empty `net_allow` and empty `http_allow`/`http_deny` together + /// mean "deny all outbound" (Landlock direct path denies, no + /// on-behalf path is enabled). Otherwise, the on-behalf path + /// enforces these rules: a destination is permitted iff any rule + /// matches the protocol, destination IP (or has `host: None` = any + /// IP), and destination port (N/A for ICMP). + /// + /// HTTP rules with concrete hosts auto-add a matching + /// `(Tcp, host, [80])` (and `(Tcp, host, [443])` when `--http-ca` + /// is set) entry at build time so the proxy's intercept ports + /// remain reachable. HTTP rules with wildcard hosts auto-add + /// `(Tcp, None, [80])` instead. + pub net_allow: Vec, + pub net_bind: Vec, + // HTTP ACL + pub http_allow: Vec, + pub http_deny: Vec, + /// TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 when + /// http_ca is set). Override with `http_ports` to intercept custom ports. + pub http_ports: Vec, + /// PEM CA cert for HTTPS MITM. When set, port 443 is also intercepted. + pub http_ca: Option, + /// PEM CA key for HTTPS MITM. Required when http_ca is set. + pub http_key: Option, + + // Namespace isolation — always enabled, not user-configurable. + + // Resource limits + pub max_memory: Option, + pub max_processes: u32, + pub max_open_files: Option, + pub max_cpu: Option, + + // Reproducibility + pub random_seed: Option, + pub time_start: Option, + pub no_randomize_memory: bool, + pub no_huge_pages: bool, + pub no_coredump: bool, + pub deterministic_dirs: bool, + + // Filesystem branch + pub fs_isolation: FsIsolation, + pub workdir: Option, + pub cwd: Option, + pub fs_storage: Option, + pub max_disk: Option, + pub on_exit: BranchAction, + pub on_error: BranchAction, + + // Mount mappings: (virtual_path_inside_chroot, host_path_on_disk) + pub fs_mount: Vec<(PathBuf, PathBuf)>, + + // Environment + pub chroot: Option, + pub clean_env: bool, + pub env: HashMap, + // Devices + pub gpu_devices: Option>, + + // CPU + pub cpu_cores: Option>, + pub num_cpus: Option, + pub port_remap: bool, + + // User namespace + pub uid: Option, + + // Dynamic policy callback + #[serde(skip)] + pub policy_fn: Option, + + // Sandbox instance name (exposed as virtual hostname; auto-generated if None). + // Not serialized — instance names are set at runtime, not in the policy file. + #[serde(skip)] + pub name: Option, + + // COW fork init function — runs once in the child before COW cloning. + // Not serialized; not cloned (FnOnce can't be cloned — drops to None on clone). + #[serde(skip)] + init_fn: Option>, + + // COW fork work function — runs in each COW clone. + // Not serialized; cloned via Arc (cheap). + #[serde(skip)] + work_fn: Option>, + + // Heap-allocated runtime state; `None` when not started. + #[serde(skip)] + runtime: Option>, +} + +impl std::fmt::Debug for Sandbox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sandbox") + .field("fs_readable", &self.fs_readable) + .field("fs_writable", &self.fs_writable) + .field("max_memory", &self.max_memory) + .field("max_processes", &self.max_processes) + .field("policy_fn", &self.policy_fn.as_ref().map(|_| "")) + .field("name", &self.name) + .field("runtime", &self.runtime.as_ref().map(|_| "")) + .finish_non_exhaustive() + } +} + +impl Clone for Sandbox { + /// Clone a `Sandbox` — config and runtime-kwargs fields are cloned; the + /// runtime state is not (the clone starts with `runtime: None`). + /// + /// Field clone semantics: + /// - `policy_fn` — Arc bump (cheap). + /// - `work_fn` — Arc bump (cheap); multiple Sandboxes share the closure. + /// - `init_fn` — **dropped to `None`** (FnOnce can't be cloned). If the + /// clone also needs an init function, call `.init_fn(...)` on it + /// separately or set it via `SandboxBuilder::init_fn`. + /// - `runtime` — always `None`; the clone is a fresh, un-started Sandbox. + fn clone(&self) -> Self { Self { - name, - policy: policy.clone(), - state: SandboxState::Created, - child_pid: None, - pidfd: None, - notif_handle: None, - throttle_handle: None, - loadavg_handle: None, - _stdout_read: None, - _stderr_read: None, - cow_branch: None, - seccomp_cow: None, - supervisor_resource: None, - supervisor_cow: None, - supervisor_network: None, - ctrl_fd: None, - stdout_pipe: None, + fs_writable: self.fs_writable.clone(), + fs_readable: self.fs_readable.clone(), + fs_denied: self.fs_denied.clone(), + extra_deny_syscalls: self.extra_deny_syscalls.clone(), + extra_allow_syscalls: self.extra_allow_syscalls.clone(), + net_allow: self.net_allow.clone(), + net_bind: self.net_bind.clone(), + http_allow: self.http_allow.clone(), + http_deny: self.http_deny.clone(), + http_ports: self.http_ports.clone(), + http_ca: self.http_ca.clone(), + http_key: self.http_key.clone(), + max_memory: self.max_memory, + max_processes: self.max_processes, + max_open_files: self.max_open_files, + max_cpu: self.max_cpu, + random_seed: self.random_seed, + time_start: self.time_start, + no_randomize_memory: self.no_randomize_memory, + no_huge_pages: self.no_huge_pages, + no_coredump: self.no_coredump, + deterministic_dirs: self.deterministic_dirs, + fs_isolation: self.fs_isolation.clone(), + workdir: self.workdir.clone(), + cwd: self.cwd.clone(), + fs_storage: self.fs_storage.clone(), + max_disk: self.max_disk, + on_exit: self.on_exit.clone(), + on_error: self.on_error.clone(), + fs_mount: self.fs_mount.clone(), + chroot: self.chroot.clone(), + clean_env: self.clean_env, + env: self.env.clone(), + gpu_devices: self.gpu_devices.clone(), + cpu_cores: self.cpu_cores.clone(), + num_cpus: self.num_cpus, + port_remap: self.port_remap, + uid: self.uid, + policy_fn: self.policy_fn.clone(), + name: self.name.clone(), + // init_fn (FnOnce) cannot be cloned — the clone gets None. + // If the clone also needs an init function, set it explicitly. init_fn: None, - work_fn: None, - io_overrides: None, - extra_fds: Vec::new(), - http_acl_handle: None, - on_bind: None, - extra_handlers: Vec::new(), + // work_fn is Arc-wrapped — clone bumps the reference count. + work_fn: self.work_fn.clone(), + // Runtime is NOT cloned — the clone starts with no runtime. + runtime: None, } } +} - /// One-shot: spawn a sandboxed process, wait for it to exit, and return - /// the result. Stdout and stderr are captured. - pub async fn run( - policy: &Policy, - name: Option<&str>, - cmd: &[&str], - ) -> Result { - let mut sb = Self::new(policy, name)?; - sb.do_spawn(cmd, true).await?; - sb.wait().await +impl Sandbox { + pub fn builder() -> SandboxBuilder { + SandboxBuilder::default() } - /// Run a sandboxed process with inherited stdio (interactive mode). - pub async fn run_interactive( - policy: &Policy, - name: Option<&str>, - cmd: &[&str], - ) -> Result { - let mut sb = Self::new(policy, name)?; - sb.do_spawn(cmd, false).await?; - sb.wait().await + /// Returns true iff the policy grants the `sysv_ipc` syscall group. + pub fn allows_sysv_ipc(&self) -> bool { + self.extra_allow_syscalls.iter().any(|s| s == "sysv_ipc") } - /// One-shot run with user-supplied syscall handlers. + /// Validate cross-section invariants — checks that span multiple fields. /// - /// `extra_handlers` is any `IntoIterator` over `(syscall, handler)` pairs - /// where: + /// Currently: + /// - `fs_isolation != "none"` requires `workdir` to be set. /// - /// * `syscall: S` is anything implementing `TryInto` — `i64`/`u32` - /// raw numbers (validated through - /// [`crate::seccomp::syscall::Syscall::checked`]), or a pre-validated - /// [`crate::seccomp::syscall::Syscall`]. - /// * `handler: H` is anything implementing - /// [`crate::seccomp::dispatch::Handler`] — a struct with explicit - /// `impl Handler` for stateful handlers, or a closure of shape - /// `Fn(&HandlerCtx) -> impl Future` via the - /// blanket impl. - /// - /// Handlers are registered in the dispatch table **after** all builtin - /// handlers for the same syscall, so they observe the post-builtin view - /// (e.g. `chroot`-normalized paths on `openat`) and cannot bypass builtin - /// confinement. - /// - /// Validation happens up-front (before fork): each `syscall` is checked - /// through `Syscall::checked`, and the blocklist contract is enforced via - /// [`crate::seccomp::dispatch::validate_handler_syscalls_against_policy`]. - /// - /// # Example + /// Idempotent: calling repeatedly is safe. + pub fn validate(&self) -> Result<(), SandboxError> { + if self.fs_isolation != FsIsolation::None && self.workdir.is_none() { + return Err(SandboxError::FsIsolationRequiresWorkdir); + } + Ok(()) + } + + // ================================================================ + // Runtime accessor helpers (private) + // ================================================================ + + fn rt(&self) -> &Runtime { + self.runtime.as_ref().expect("sandbox not started") + } + + fn rt_mut(&mut self) -> &mut Runtime { + self.runtime.as_mut().expect("sandbox not started") + } + + // ================================================================ + // Runtime lifecycle API (public) + // ================================================================ + + /// Set the sandbox instance name (also exposed as the virtual hostname). + /// Auto-generated if not set. + pub fn set_name(&mut self, name: impl Into) { + self.name = Some(name.into()); + } + + /// Set the sandbox instance name and return `self`. Convenience for + /// pipeline fan-out where a base config is cloned and each clone gets a + /// fresh name: /// /// ```ignore - /// use sandlock_core::{Policy, Sandbox}; - /// use sandlock_core::seccomp::notif::NotifAction; - /// - /// # tokio_test::block_on(async { - /// let policy = Policy::builder().fs_read("/usr").build().unwrap(); + /// let template = Sandbox::builder()...build()?; + /// let mut s1 = template.clone().with_name("worker-1"); + /// let mut s2 = template.clone().with_name("worker-2"); + /// ``` + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Set the COW-fork init function and return `self`. /// - /// let audit = |cx: &sandlock_core::HandlerCtx<'_>| { - /// let pid = cx.notif.data.pid; - /// async move { - /// eprintln!("openat from pid {}", pid); - /// NotifAction::Continue - /// } - /// }; + /// The init function runs once in the child process before any COW clones + /// are created. Use it to load expensive shared state. + pub fn with_init_fn(mut self, f: impl FnOnce() + Send + 'static) -> Self { + self.init_fn = Some(Box::new(f)); + self + } + + /// Set the COW-fork work function and return `self`. /// - /// let result = Sandbox::run_with_extra_handlers( - /// &policy, - /// Some("audit"), - /// &["/usr/bin/true"], - /// [(libc::SYS_openat, audit)], - /// ).await.unwrap(); - /// # }); - /// ``` - pub async fn run_with_extra_handlers( - policy: &Policy, - name: Option<&str>, - cmd: &[&str], - extra_handlers: I, - ) -> Result - where - I: IntoIterator, - S: TryInto, - H: crate::seccomp::dispatch::Handler, - { - let pending = collect_extra_handlers(extra_handlers, policy)?; - let mut sb = Self::new(policy, name)?; - sb.extra_handlers = pending; - sb.do_spawn(cmd, true).await?; - sb.wait().await + /// The work function runs in each COW clone (`fork(N)` produces N clones). + pub fn with_work_fn(mut self, f: impl Fn(u32) + Send + Sync + 'static) -> Self { + self.work_fn = Some(Arc::new(f)); + self } - /// Interactive-stdio counterpart of [`Self::run_with_extra_handlers`]. - pub async fn run_interactive_with_extra_handlers( - policy: &Policy, - name: Option<&str>, - cmd: &[&str], - extra_handlers: I, - ) -> Result - where - I: IntoIterator, - S: TryInto, - H: crate::seccomp::dispatch::Handler, - { - let pending = collect_extra_handlers(extra_handlers, policy)?; - let mut sb = Self::new(policy, name)?; - sb.extra_handlers = pending; - sb.do_spawn(cmd, false).await?; - sb.wait().await + /// Return the sandbox name if set, or `None` if not yet started. + pub fn instance_name(&self) -> Option<&str> { + self.runtime.as_ref().map(|r| r.name.as_str()) + .or_else(|| self.name.as_deref()) } - /// Dry-run: spawn, wait, collect filesystem changes, then abort. - /// Returns the run result plus a list of changes that would have been - /// committed. The workdir is left unchanged. - pub async fn dry_run( - policy: &Policy, - name: Option<&str>, - cmd: &[&str], - ) -> Result { - let mut policy = policy.clone(); - policy.on_exit = BranchAction::Keep; - policy.on_error = BranchAction::Keep; - - let mut sb = Self::new(&policy, name)?; - sb.do_spawn(cmd, true).await?; - let run_result = sb.wait().await?; - let changes = sb.collect_changes().await; - sb.do_abort().await; - Ok(crate::dry_run::DryRunResult { run_result, changes }) + /// Return the child PID if spawned. + pub fn pid(&self) -> Option { + self.runtime.as_ref().and_then(|r| r.child_pid) } - /// Dry-run with inherited stdio (interactive mode). - pub async fn dry_run_interactive( - policy: &Policy, - name: Option<&str>, - cmd: &[&str], - ) -> Result { - let mut policy = policy.clone(); - policy.on_exit = BranchAction::Keep; - policy.on_error = BranchAction::Keep; - - let mut sb = Self::new(&policy, name)?; - sb.do_spawn(cmd, false).await?; - let run_result = sb.wait().await?; - let changes = sb.collect_changes().await; - sb.do_abort().await; - Ok(crate::dry_run::DryRunResult { run_result, changes }) + /// Return whether the child is currently running or paused. + pub fn is_running(&self) -> bool { + self.runtime.as_ref().map(|r| { + matches!(r.state, RuntimeState::Running | RuntimeState::Paused) + }).unwrap_or(false) } - /// Collect changes from whichever COW branch exists. - async fn collect_changes(&self) -> Vec { - if let Some(ref branch) = self.cow_branch { - return branch.changes().unwrap_or_default(); - } - if let Some(ref cow) = self.seccomp_cow { - return cow.changes().unwrap_or_default(); + /// Send SIGSTOP to the child's process group. + pub fn pause(&mut self) -> Result<(), crate::error::SandlockError> { + use crate::error::SandboxRuntimeError; + let pid = self.runtime.as_ref() + .and_then(|rt| rt.child_pid) + .ok_or(SandboxRuntimeError::NotRunning)?; + let ret = unsafe { libc::killpg(pid, libc::SIGSTOP) }; + if ret < 0 { + return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); } - Vec::new() + self.rt_mut().state = RuntimeState::Paused; + Ok(()) } - /// Abort both COW branch types (used by dry_run to discard changes). - async fn do_abort(&mut self) { - if let Some(branch) = self.cow_branch.take() { - let _ = branch.abort(); + /// Send SIGCONT to the child's process group. + pub fn resume(&mut self) -> Result<(), crate::error::SandlockError> { + use crate::error::SandboxRuntimeError; + let pid = self.runtime.as_ref() + .and_then(|rt| rt.child_pid) + .ok_or(SandboxRuntimeError::NotRunning)?; + let ret = unsafe { libc::killpg(pid, libc::SIGCONT) }; + if ret < 0 { + return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); } - if let Some(ref mut cow) = self.seccomp_cow { - let _ = cow.abort(); + self.rt_mut().state = RuntimeState::Running; + Ok(()) + } + + /// Send SIGKILL to the child's process group. + pub fn kill(&mut self) -> Result<(), crate::error::SandlockError> { + use crate::error::SandboxRuntimeError; + let pid = self.runtime.as_ref() + .and_then(|rt| rt.child_pid) + .ok_or(SandboxRuntimeError::NotRunning)?; + let ret = unsafe { libc::killpg(pid, libc::SIGKILL) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::ESRCH) { + return Err(SandboxRuntimeError::Io(err).into()); + } } + Ok(()) } - /// Create N COW clones of this sandbox. - /// - /// Requires `new_with_fns()`. Forks a confined child, runs `init_fn`, - /// then forks N times using raw `fork()`. Each - /// clone gets `CLONE_ID=0..N-1` and runs `work_fn(clone_id)`. - /// - /// Memory pages from `init_fn` are shared copy-on-write across all - /// clones — 1000 clones of a 50MB process use ~50MB total. - /// - /// Returns PIDs of all clones. Use `waitpid` to collect them. - /// Create N COW clones, each runs `work_fn(clone_id)`. - /// - /// Returns a Vec of Sandbox handles — one per clone. Each clone is - /// a live process that can be waited on, killed, or paused. - /// - /// ```ignore - /// let clones = sb.fork(4).await?; - /// for mut c in clones { c.wait().await?; } - /// ``` - pub async fn fork(&mut self, n: u32) -> Result, SandlockError> { - let init_fn = self.init_fn.take() - .ok_or_else(|| SandboxError::Child("fork() requires new_with_fns()".into()))?; - let work_fn = self.work_fn.take() - .ok_or_else(|| SandboxError::Child("fork() requires new_with_fns()".into()))?; + /// Set a callback invoked whenever a port bind is recorded. + pub fn set_on_bind(&mut self, cb: impl Fn(&HashMap) + Send + Sync + 'static) { + // Ensure runtime exists so we have somewhere to store the callback. + // In practice, set_on_bind is always called before spawn. + let _ = self.ensure_runtime(); + self.rt_mut().on_bind = Some(Box::new(cb)); + } - let policy = self.policy.clone(); + /// Return the current virtual-to-real port mappings. + pub async fn port_mappings(&self) -> HashMap { + if let Some(ref rt) = self.runtime { + if let Some(ref net) = rt.supervisor_network { + let ns = net.lock().await; + return ns.port_map.virtual_to_real.clone(); + } + } + HashMap::new() + } + + /// Wait for the child process to exit. + pub async fn wait(&mut self) -> Result { + use crate::error::SandboxRuntimeError; + use crate::result::{ExitStatus, RunResult}; + + let pid = self.rt().child_pid.ok_or(SandboxRuntimeError::NotRunning)?; + + if let RuntimeState::Stopped(ref es) = self.rt().state { + return Ok(RunResult { + exit_status: es.clone(), + stdout: None, + stderr: None, + }); + } + + let exit_status = tokio::task::spawn_blocking(move || -> ExitStatus { + let mut status: i32 = 0; + loop { + let ret = unsafe { libc::waitpid(pid, &mut status, 0) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + return ExitStatus::Killed; + } + break; + } + sandbox_wait_status_to_exit(status) + }) + .await + .unwrap_or(ExitStatus::Killed); + + self.rt_mut().state = RuntimeState::Stopped(exit_status.clone()); + + let rt = self.rt_mut(); + if let Some(h) = rt.notif_handle.take() { h.abort(); } + if let Some(h) = rt.throttle_handle.take() { h.abort(); } + if let Some(h) = rt.loadavg_handle.take() { h.abort(); } + + if let Some(ref cow_state) = self.rt().supervisor_cow.clone() { + let mut cow = cow_state.lock().await; + self.rt_mut().seccomp_cow = cow.branch.take(); + } + + let stdout = self.rt_mut()._stdout_read.take().map(sandbox_read_fd_to_end); + let stderr = self.rt_mut()._stderr_read.take().map(sandbox_read_fd_to_end); + + Ok(RunResult { exit_status, stdout, stderr }) + } + + /// Spawn a sandboxed process without waiting for it to exit. + /// Use `wait()` to collect the exit status when done. + #[doc(hidden)] + pub async fn spawn(&mut self, cmd: &[&str]) -> Result<(), crate::error::SandlockError> { + self.do_spawn(cmd, false).await + } + + /// Like `spawn` but captures stdout and stderr (available via `wait()`). + #[doc(hidden)] + pub async fn spawn_captured(&mut self, cmd: &[&str]) -> Result<(), crate::error::SandlockError> { + self.do_spawn(cmd, true).await + } + + /// Spawn with explicit stdin/stdout/stderr fd redirection. + #[doc(hidden)] + pub async fn spawn_with_io( + &mut self, + cmd: &[&str], + stdin_fd: Option, + stdout_fd: Option, + stderr_fd: Option, + ) -> Result<(), crate::error::SandlockError> { + self.ensure_runtime()?; + self.rt_mut().io_overrides = Some((stdin_fd, stdout_fd, stderr_fd)); + self.do_spawn(cmd, false).await + } + + /// Like `spawn_with_io` but also maps extra fds into the child. + #[doc(hidden)] + pub async fn spawn_with_gather_io( + &mut self, + cmd: &[&str], + stdin_fd: Option, + stdout_fd: Option, + stderr_fd: Option, + extra_fds: Vec<(i32, i32)>, + ) -> Result<(), crate::error::SandlockError> { + self.ensure_runtime()?; + self.rt_mut().io_overrides = Some((stdin_fd, stdout_fd, stderr_fd)); + self.rt_mut().extra_fds = extra_fds; + self.do_spawn(cmd, false).await + } + + /// Commit COW writes to the original directory. + #[doc(hidden)] + pub async fn commit(&mut self) -> Result<(), crate::error::SandlockError> { + use crate::error::{SandboxRuntimeError, SandlockError}; + if let Some(ref mut rt) = self.runtime { + if let Some(branch) = rt.cow_branch.take() { + branch.commit().map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Branch(e)))?; + } + } + Ok(()) + } + + /// Discard COW writes. + #[doc(hidden)] + pub async fn abort_branch(&mut self) -> Result<(), crate::error::SandlockError> { + use crate::error::{SandboxRuntimeError, SandlockError}; + if let Some(ref mut rt) = self.runtime { + if let Some(branch) = rt.cow_branch.take() { + branch.abort().map_err(|e| SandlockError::Runtime(SandboxRuntimeError::Branch(e)))?; + } + } + Ok(()) + } + + /// Freeze the sandbox: hold fork notifications + SIGSTOP the process group. + pub(crate) async fn freeze(&self) -> Result<(), crate::error::SandlockError> { + use crate::error::{SandboxRuntimeError, SandlockError}; + let rt = self.runtime.as_ref().ok_or(SandlockError::Runtime(SandboxRuntimeError::NotRunning))?; + let pid = rt.child_pid.ok_or(SandlockError::Runtime(SandboxRuntimeError::NotRunning))?; + if let Some(ref resource) = rt.supervisor_resource { + let mut rs = resource.lock().await; + rs.hold_forks = true; + } + unsafe { libc::killpg(pid, libc::SIGSTOP); } + Ok(()) + } + + /// Thaw the sandbox: release held fork notifications + SIGCONT. + pub(crate) async fn thaw(&self) -> Result<(), crate::error::SandlockError> { + use crate::error::{SandboxRuntimeError, SandlockError}; + let rt = self.runtime.as_ref().ok_or(SandlockError::Runtime(SandboxRuntimeError::NotRunning))?; + let pid = rt.child_pid.ok_or(SandlockError::Runtime(SandboxRuntimeError::NotRunning))?; + if let Some(ref resource) = rt.supervisor_resource { + let mut rs = resource.lock().await; + rs.hold_forks = false; + rs.held_notif_ids.clear(); + } + unsafe { libc::killpg(pid, libc::SIGCONT); } + Ok(()) + } + + /// Capture a checkpoint of the running sandbox. + pub async fn checkpoint(&self) -> Result { + use crate::error::{SandboxRuntimeError, SandlockError}; + let pid = self.runtime.as_ref() + .and_then(|rt| rt.child_pid) + .ok_or(SandlockError::Runtime(SandboxRuntimeError::NotRunning))?; + self.freeze().await?; + let cp = crate::checkpoint::capture(pid, self); + self.thaw().await?; + cp + } + + // ================================================================ + // One-shot / lifecycle instance API + // ================================================================ + + /// One-shot: spawn, wait, and return the result. Stdout and stderr are + /// captured. This is the primary way to run a sandboxed command: + /// + /// ```ignore + /// let mut sandbox = Sandbox::builder() + /// .fs_read("/usr") + /// .name("my-sandbox") + /// .build()?; + /// let result = sandbox.run(&["echo", "hello"]).await?; + /// ``` + pub async fn run( + &mut self, + cmd: &[&str], + ) -> Result { + self.do_spawn(cmd, true).await?; + self.wait().await + } + + /// Run with inherited stdio (interactive mode). + pub async fn run_interactive( + &mut self, + cmd: &[&str], + ) -> Result { + self.do_spawn(cmd, false).await?; + self.wait().await + } + + /// One-shot run with user-supplied syscall handlers. + pub async fn run_with_extra_handlers( + &mut self, + cmd: &[&str], + extra_handlers: I, + ) -> Result + where + I: IntoIterator, + S: TryInto, + H: crate::seccomp::dispatch::Handler, + { + let pending = sandbox_collect_extra_handlers(extra_handlers, self)?; + self.ensure_runtime()?; + self.rt_mut().extra_handlers = pending; + self.do_spawn(cmd, true).await?; + self.wait().await + } + + /// Interactive-stdio counterpart of `run_with_extra_handlers`. + pub async fn run_interactive_with_extra_handlers( + &mut self, + cmd: &[&str], + extra_handlers: I, + ) -> Result + where + I: IntoIterator, + S: TryInto, + H: crate::seccomp::dispatch::Handler, + { + let pending = sandbox_collect_extra_handlers(extra_handlers, self)?; + self.ensure_runtime()?; + self.rt_mut().extra_handlers = pending; + self.do_spawn(cmd, false).await?; + self.wait().await + } + + /// Dry-run: spawn, wait, collect filesystem changes, then abort. + pub async fn dry_run( + &mut self, + cmd: &[&str], + ) -> Result { + self.on_exit = BranchAction::Keep; + self.on_error = BranchAction::Keep; + self.do_spawn(cmd, true).await?; + let run_result = self.wait().await?; + let changes = self.collect_changes().await; + self.do_abort().await; + Ok(crate::dry_run::DryRunResult { run_result, changes }) + } + + /// Dry-run with inherited stdio. + pub async fn dry_run_interactive( + &mut self, + cmd: &[&str], + ) -> Result { + self.on_exit = BranchAction::Keep; + self.on_error = BranchAction::Keep; + self.do_spawn(cmd, false).await?; + let run_result = self.wait().await?; + let changes = self.collect_changes().await; + self.do_abort().await; + Ok(crate::dry_run::DryRunResult { run_result, changes }) + } + + /// Create N COW clones of this sandbox. + /// + /// `fork()` requires `init_fn` and `work_fn` to be set on the sandbox (via + /// `SandboxBuilder::init_fn` / `work_fn`, or `Sandbox::with_init_fn` / + /// `with_work_fn`). Returns an error if either is missing. + pub async fn fork(&mut self, n: u32) -> Result, crate::error::SandlockError> { + use crate::error::SandboxRuntimeError; + use std::os::fd::{FromRawFd, OwnedFd}; + + // Pull init_fn / work_fn directly from self (they live on Sandbox, not + // Runtime, so ensure_runtime hasn't consumed them yet). + let init_fn = self.init_fn.take() + .ok_or_else(|| SandboxRuntimeError::Child("fork() requires init_fn and work_fn — use SandboxBuilder::init_fn() / work_fn() or Sandbox::with_init_fn() / with_work_fn()".into()))?; + let work_fn = self.work_fn.take() + .ok_or_else(|| SandboxRuntimeError::Child("fork() requires init_fn and work_fn — use SandboxBuilder::init_fn() / work_fn() or Sandbox::with_init_fn() / with_work_fn()".into()))?; + // Initialize the runtime block so we can record child PID / state below. + self.ensure_runtime()?; + + let sandbox_cfg = self.clone(); // config only, no runtime - // Create control pipe let mut ctrl_fds = [0i32; 2]; if unsafe { libc::pipe2(ctrl_fds.as_mut_ptr(), 0) } < 0 { - return Err(SandboxError::Io(std::io::Error::last_os_error()).into()); + return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); } let ctrl_parent = unsafe { OwnedFd::from_raw_fd(ctrl_fds[0]) }; let ctrl_child_fd = ctrl_fds[1]; - // Create per-clone stdout pipes (parent keeps read ends) let mut pipe_read_ends: Vec = Vec::with_capacity(n as usize); let mut pipe_write_fds: Vec = Vec::with_capacity(n as usize); for _ in 0..n { @@ -393,77 +1190,65 @@ impl Sandbox { } } - // Fork the template child let pid = unsafe { libc::fork() }; if pid < 0 { unsafe { libc::close(ctrl_child_fd) }; - return Err(SandboxError::Fork(std::io::Error::last_os_error()).into()); + return Err(SandboxRuntimeError::Fork(std::io::Error::last_os_error()).into()); } if pid == 0 { - // ===== CHILD (template) ===== drop(ctrl_parent); - unsafe { libc::setpgid(0, 0) }; unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) }; unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; - let _ = crate::landlock::confine(&policy); + let _ = crate::landlock::confine(&sandbox_cfg); - let deny = crate::context::blocklist_syscall_numbers(&policy); - let args = crate::context::arg_filters(&policy); + let deny = crate::context::blocklist_syscall_numbers(&sandbox_cfg); + let args = crate::context::arg_filters(&sandbox_cfg); let filter = match crate::seccomp::bpf::assemble_filter(&[], &deny, &args) { Ok(f) => f, Err(_) => unsafe { libc::_exit(1) }, }; let _ = crate::seccomp::bpf::install_deny_filter(&filter); - CONFINED.store(true, std::sync::atomic::Ordering::Relaxed); + crate::process::CONFINED.store(true, std::sync::atomic::Ordering::Relaxed); - // Run init (loads expensive state, shared via COW) init_fn(); - // Close read ends in template (parent owns them) drop(pipe_read_ends); - - // Fork N clones, send PIDs, wait for all crate::fork::fork_ready_loop_fn(ctrl_child_fd, n, &*work_fn, &pipe_write_fds); unsafe { libc::_exit(0) }; } - // ===== PARENT ===== unsafe { libc::close(ctrl_child_fd) }; - // Close write ends in parent (template/clones own them) for wfd in &pipe_write_fds { if *wfd >= 0 { unsafe { libc::close(*wfd) }; } } - self.child_pid = Some(pid); - self.state = SandboxState::Running; + self.rt_mut().child_pid = Some(pid); + self.rt_mut().state = RuntimeState::Running; - // Read N clone PIDs let ctrl_fd = ctrl_parent.as_raw_fd(); let mut pid_buf = vec![0u8; n as usize * 4]; - read_exact(ctrl_fd, &mut pid_buf); + sandbox_read_exact(ctrl_fd, &mut pid_buf); let clone_pids: Vec = pid_buf.chunks(4) .map(|c| u32::from_be_bytes(c.try_into().unwrap_or([0; 4])) as i32) .collect(); let live_count = clone_pids.iter().filter(|&&p| p > 0).count(); - // Read exit codes (template waits for all clones first) let mut code_buf = vec![0u8; live_count * 4]; - read_exact(ctrl_fd, &mut code_buf); - self.ctrl_fd = Some(ctrl_parent); + sandbox_read_exact(ctrl_fd, &mut code_buf); + self.rt_mut().ctrl_fd = Some(ctrl_parent); - // Wait for template to exit let mut status = 0i32; unsafe { libc::waitpid(pid, &mut status, 0) }; - // Create clone handles with stdout pipe read ends let mut code_idx = 0; let mut clones = Vec::with_capacity(live_count); let mut pipe_iter = pipe_read_ends.into_iter(); + let rt_name = self.rt().name.clone(); for &clone_pid in &clone_pids { let pipe = pipe_iter.next(); if clone_pid <= 0 { continue; } @@ -473,51 +1258,65 @@ impl Sandbox { ); code_idx += 1; - let mut sb = Sandbox::create(&policy, format!("{}-fork-{}", self.name, clone_pid)); - sb.child_pid = Some(clone_pid); - sb.state = SandboxState::Stopped(if code == 0 { - ExitStatus::Code(0) - } else if code > 0 { - ExitStatus::Code(code) - } else { - ExitStatus::Killed - }); - sb.stdout_pipe = pipe; - clones.push(sb); + let mut clone_sb = sandbox_cfg.clone(); + let clone_name = format!("{}-fork-{}", rt_name, clone_pid); + clone_sb.runtime = Some(Box::new(Runtime { + name: clone_name, + state: RuntimeState::Stopped(if code == 0 { + crate::result::ExitStatus::Code(0) + } else if code > 0 { + crate::result::ExitStatus::Code(code) + } else { + crate::result::ExitStatus::Killed + }), + child_pid: Some(clone_pid), + pidfd: None, + notif_handle: None, + throttle_handle: None, + loadavg_handle: None, + _stdout_read: None, + _stderr_read: None, + cow_branch: None, + seccomp_cow: None, + supervisor_resource: None, + supervisor_cow: None, + supervisor_network: None, + ctrl_fd: None, + stdout_pipe: pipe, + io_overrides: None, + extra_fds: Vec::new(), + http_acl_handle: None, + on_bind: None, + extra_handlers: Vec::new(), + })); + clones.push(clone_sb); } Ok(clones) } /// Reduce: wait for all clones, then run a reducer command. - /// - /// Waits for every clone to finish, then runs `cmd` in this sandbox. - /// The reducer can read clone results from shared files, tmpdir, etc. - /// - /// ```ignore - /// let clones = mapper.fork(4).await?; - /// let result = reducer.reduce(&["python3", "sum.py"], &mut clones).await?; - /// ``` pub async fn reduce( &self, cmd: &[&str], clones: &mut [Sandbox], - ) -> Result { - // Read each clone's stdout pipe and concatenate + ) -> Result { + use crate::error::SandboxRuntimeError; + let mut combined = Vec::new(); for clone in clones.iter_mut() { - if let Some(pipe) = clone.stdout_pipe.take() { - combined.extend_from_slice(&read_fd_to_end(pipe)); + if let Some(ref mut rt) = clone.runtime { + if let Some(pipe) = rt.stdout_pipe.take() { + combined.extend_from_slice(&sandbox_read_fd_to_end(pipe)); + } } } - // Create a pipe to feed combined data to reducer's stdin let mut stdin_fds = [0i32; 2]; if unsafe { libc::pipe2(stdin_fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { - return Err(SandboxError::Io(std::io::Error::last_os_error()).into()); + return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); } - // Write combined data in a blocking thread (avoid deadlock with large data) let write_fd = stdin_fds[1]; let write_handle = tokio::task::spawn_blocking(move || { unsafe { @@ -526,10 +1325,13 @@ impl Sandbox { } }); - // Spawn reducer with stdin from pipe, capture stdout - let reducer_name = format!("{}-reduce", self.name); - let mut reducer = Sandbox::new(&self.policy, Some(reducer_name.as_str()))?; - reducer.io_overrides = Some((Some(stdin_fds[0]), None, None)); + let base_name = self.instance_name() + .unwrap_or("sandbox") + .to_owned(); + let reducer_name = base_name + "-reduce"; + let mut reducer = self.clone().with_name(reducer_name); + reducer.ensure_runtime()?; + reducer.rt_mut().io_overrides = Some((Some(stdin_fds[0]), None, None)); reducer.do_spawn(cmd, true).await?; unsafe { libc::close(stdin_fds[0]) }; @@ -537,353 +1339,157 @@ impl Sandbox { reducer.wait().await } - /// Wait for the child process to exit. - pub async fn wait(&mut self) -> Result { - let pid = self.child_pid.ok_or(SandboxError::NotRunning)?; - - if let SandboxState::Stopped(ref es) = self.state { - return Ok(RunResult { - exit_status: es.clone(), - stdout: None, - stderr: None, - }); + /// Lazily initialize the runtime block. + /// + /// Called by lifecycle methods (`spawn`, `run`, `fork`, etc.) on first + /// use. Validates and resolves the sandbox name. Idempotent: returns + /// immediately if runtime is already set. + fn ensure_runtime(&mut self) -> Result<(), crate::error::SandlockError> { + if self.runtime.is_some() { + return Ok(()); } + let name = sandbox_resolve_name(self.name.as_deref())?; + self.runtime = Some(Box::new(Runtime { + name, + state: RuntimeState::Created, + child_pid: None, + pidfd: None, + notif_handle: None, + throttle_handle: None, + loadavg_handle: None, + _stdout_read: None, + _stderr_read: None, + cow_branch: None, + seccomp_cow: None, + supervisor_resource: None, + supervisor_cow: None, + supervisor_network: None, + ctrl_fd: None, + stdout_pipe: None, + io_overrides: None, + extra_fds: Vec::new(), + http_acl_handle: None, + on_bind: None, + extra_handlers: Vec::new(), + })); + Ok(()) + } - // Blocking waitpid in a blocking thread so we don't block the tokio runtime. - let exit_status = tokio::task::spawn_blocking(move || -> ExitStatus { - let mut status: i32 = 0; - loop { - let ret = unsafe { libc::waitpid(pid, &mut status, 0) }; - if ret < 0 { - let err = std::io::Error::last_os_error(); - if err.raw_os_error() == Some(libc::EINTR) { - continue; - } - // Child already reaped or invalid pid - return ExitStatus::Killed; - } - break; - } - wait_status_to_exit(status) - }) - .await - .unwrap_or(ExitStatus::Killed); - - self.state = SandboxState::Stopped(exit_status.clone()); + // ================================================================ + // Internal: collect_changes / do_abort + // ================================================================ - // Abort supervisor tasks now that the child is gone. - if let Some(h) = self.notif_handle.take() { - h.abort(); + async fn collect_changes(&self) -> Vec { + if let Some(ref rt) = self.runtime { + if let Some(ref branch) = rt.cow_branch { + return branch.changes().unwrap_or_default(); + } + if let Some(ref cow) = rt.seccomp_cow { + return cow.changes().unwrap_or_default(); + } } - if let Some(h) = self.throttle_handle.take() { - h.abort(); + Vec::new() + } + + async fn do_abort(&mut self) { + if let Some(ref mut rt) = self.runtime { + if let Some(branch) = rt.cow_branch.take() { + let _ = branch.abort(); + } + if let Some(ref mut cow) = rt.seccomp_cow { + let _ = cow.abort(); + } } - if let Some(h) = self.loadavg_handle.take() { - h.abort(); + } + + // ================================================================ + // Internal: do_spawn (the main fork/confinement entry point) + // ================================================================ + + async fn do_spawn(&mut self, cmd: &[&str], capture: bool) -> Result<(), crate::error::SandlockError> { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use crate::error::SandboxRuntimeError; + use crate::context::{PipePair, read_u32_fd, write_u32_fd}; + use crate::cow::{CowBranch, overlayfs::OverlayBranch, branchfs::BranchFsBranch}; + use crate::network; + use crate::seccomp::ctx::SupervisorCtx; + use crate::seccomp::notif::{self, NotifPolicy}; + use crate::seccomp::state::{ChrootState, CowState, NetworkState, PolicyFnState, ProcfsState, ResourceState, TimeRandomState}; + use crate::sys::syscall; + use std::time::Duration; + + self.ensure_runtime()?; + + if !matches!(self.rt().state, RuntimeState::Created) { + return Err(SandboxRuntimeError::Child("sandbox already spawned".into()).into()); } - // Extract seccomp COW branch while we're still in async context - // (can properly .lock().await the tokio Mutex). This avoids the - // try_lock() race in sync drop() that could skip cleanup entirely. - if let Some(ref cow_state) = self.supervisor_cow { - let mut cow = cow_state.lock().await; - self.seccomp_cow = cow.branch.take(); + if cmd.is_empty() { + return Err(SandboxRuntimeError::Child("empty command".into()).into()); } - // Drain captured stdout/stderr if available - let stdout = self._stdout_read.take().map(|fd| read_fd_to_end(fd)); - let stderr = self._stderr_read.take().map(|fd| read_fd_to_end(fd)); + let c_cmd: Vec = cmd + .iter() + .map(|s| CString::new(*s).map_err(|_| SandboxRuntimeError::Child("invalid command string".into()))) + .collect::, _>>()?; - Ok(RunResult { - exit_status, - stdout, - stderr, - }) - } + let nested = crate::process::is_nested(); - /// Send SIGSTOP to the child's process group. - pub fn pause(&mut self) -> Result<(), SandlockError> { - let pid = self.child_pid.ok_or(SandboxError::NotRunning)?; - let ret = unsafe { libc::killpg(pid, libc::SIGSTOP) }; - if ret < 0 { - return Err(SandboxError::Io(std::io::Error::last_os_error()).into()); - } - self.state = SandboxState::Paused; - Ok(()) - } + let pipes = PipePair::new().map_err(SandboxRuntimeError::Io)?; - /// Send SIGCONT to the child's process group. - pub fn resume(&mut self) -> Result<(), SandlockError> { - let pid = self.child_pid.ok_or(SandboxError::NotRunning)?; - let ret = unsafe { libc::killpg(pid, libc::SIGCONT) }; - if ret < 0 { - return Err(SandboxError::Io(std::io::Error::last_os_error()).into()); - } - self.state = SandboxState::Running; - Ok(()) - } + let resolved_net_allow = network::resolve_net_allow(&self.net_allow) + .await + .map_err(SandboxRuntimeError::Io)?; + let virtual_etc_hosts = resolved_net_allow.etc_hosts.clone(); - /// Send SIGKILL to the child's process group. - pub fn kill(&mut self) -> Result<(), SandlockError> { - let pid = self.child_pid.ok_or(SandboxError::NotRunning)?; - let ret = unsafe { libc::killpg(pid, libc::SIGKILL) }; - if ret < 0 { - let err = std::io::Error::last_os_error(); - // ESRCH means the process is already gone — not an error. - if err.raw_os_error() != Some(libc::ESRCH) { - return Err(SandboxError::Io(err).into()); - } - } - Ok(()) - } - - /// Return the child PID, if spawned. - pub fn pid(&self) -> Option { - self.child_pid - } - - /// Set a callback invoked whenever a port bind is recorded. - pub fn set_on_bind(&mut self, cb: impl Fn(&std::collections::HashMap) + Send + Sync + 'static) { - self.on_bind = Some(Box::new(cb)); - } - - /// Return the current virtual-to-real port mappings. - /// - /// Returns a snapshot of all ports where the real (host) port differs from - /// the virtual port the sandbox requested. Empty if port_remap is disabled - /// or no ports have been remapped. - pub async fn port_mappings(&self) -> std::collections::HashMap { - if let Some(ref net) = self.supervisor_network { - let ns = net.lock().await; - ns.port_map.virtual_to_real.clone() - } else { - std::collections::HashMap::new() - } - } - - /// Return whether the child is currently running. - #[doc(hidden)] - pub fn is_running(&self) -> bool { - matches!(self.state, SandboxState::Running | SandboxState::Paused) - } - - /// Return a reference to the policy. - pub fn policy(&self) -> &Policy { - &self.policy - } - - /// Return the sandbox instance name. - /// - /// The same value is exposed to the child as its virtual hostname. - pub fn name(&self) -> &str { - &self.name - } - - /// Commit COW writes to the original directory. - #[doc(hidden)] - pub async fn commit(&mut self) -> Result<(), SandlockError> { - if let Some(branch) = self.cow_branch.take() { - branch.commit().map_err(|e| SandlockError::Sandbox(SandboxError::Branch(e)))?; - } - Ok(()) - } - - /// Discard COW writes. - #[doc(hidden)] - pub async fn abort_branch(&mut self) -> Result<(), SandlockError> { - if let Some(branch) = self.cow_branch.take() { - branch.abort().map_err(|e| SandlockError::Sandbox(SandboxError::Branch(e)))?; - } - Ok(()) - } - - /// Freeze the sandbox: hold all fork notifications + SIGSTOP the process group. - pub(crate) async fn freeze(&self) -> Result<(), SandlockError> { - let pid = self.child_pid.ok_or(SandlockError::Sandbox(SandboxError::NotRunning))?; - - // Set hold_forks in resource state - if let Some(ref resource) = self.supervisor_resource { - let mut rs = resource.lock().await; - rs.hold_forks = true; - } - - // SIGSTOP the process group - unsafe { libc::killpg(pid, libc::SIGSTOP); } - Ok(()) - } - - /// Thaw the sandbox: release held fork notifications + SIGCONT. - pub(crate) async fn thaw(&self) -> Result<(), SandlockError> { - let pid = self.child_pid.ok_or(SandlockError::Sandbox(SandboxError::NotRunning))?; - - // Release held forks - if let Some(ref resource) = self.supervisor_resource { - let mut rs = resource.lock().await; - rs.hold_forks = false; - rs.held_notif_ids.clear(); - } - - // SIGCONT the process group - unsafe { libc::killpg(pid, libc::SIGCONT); } - Ok(()) - } - - /// Spawn a sandboxed process without waiting for it to exit. - /// Use `wait()` to collect the exit status when done. - #[doc(hidden)] - pub async fn spawn(&mut self, cmd: &[&str]) -> Result<(), SandlockError> { - self.do_spawn(cmd, false).await - } - - /// Like `spawn` but captures stdout and stderr (available via `wait()`). - /// Not part of the public API — used by the FFI crate. - #[doc(hidden)] - pub async fn spawn_captured(&mut self, cmd: &[&str]) -> Result<(), SandlockError> { - self.do_spawn(cmd, true).await - } - - /// Spawn with explicit stdin/stdout/stderr fd redirection. - /// - /// Each `Option` overrides the corresponding fd in the child: - /// - `stdin_fd`: dup2'd to fd 0 - /// - `stdout_fd`: dup2'd to fd 1 - /// - `stderr_fd`: dup2'd to fd 2 - /// - /// The caller is responsible for closing the fds after this call. - #[doc(hidden)] - pub async fn spawn_with_io( - &mut self, - cmd: &[&str], - stdin_fd: Option, - stdout_fd: Option, - stderr_fd: Option, - ) -> Result<(), SandlockError> { - self.io_overrides = Some((stdin_fd, stdout_fd, stderr_fd)); - self.do_spawn(cmd, false).await - } - - /// Like `spawn_with_io` but also maps extra fds into the child. - /// `extra_fds` is a list of (target_fd, source_fd) pairs. - #[doc(hidden)] - pub async fn spawn_with_gather_io( - &mut self, - cmd: &[&str], - stdin_fd: Option, - stdout_fd: Option, - stderr_fd: Option, - extra_fds: Vec<(i32, i32)>, - ) -> Result<(), SandlockError> { - self.io_overrides = Some((stdin_fd, stdout_fd, stderr_fd)); - self.extra_fds = extra_fds; - self.do_spawn(cmd, false).await - } - - /// Capture a checkpoint of the running sandbox. - pub async fn checkpoint(&self) -> Result { - let pid = self.child_pid.ok_or(SandlockError::Sandbox(SandboxError::NotRunning))?; - - // Freeze - self.freeze().await?; - - // Capture state - let cp = crate::checkpoint::capture(pid, &self.policy); - - // Thaw regardless of capture result - self.thaw().await?; - - cp - } - - // ============================================================ - // Internal: do_spawn - // ============================================================ - - /// Fork a child, apply confinement, and start the supervisor. - async fn do_spawn(&mut self, cmd: &[&str], capture: bool) -> Result<(), SandlockError> { - // 1. Validate state - if !matches!(self.state, SandboxState::Created) { - return Err(SandboxError::Child("sandbox already spawned".into()).into()); - } - - if cmd.is_empty() { - return Err(SandboxError::Child("empty command".into()).into()); - } - - // 2. Convert cmd to Vec - let c_cmd: Vec = cmd - .iter() - .map(|s| CString::new(*s).map_err(|_| SandboxError::Child("invalid command string".into()))) - .collect::, _>>()?; - - // 3. Detect nesting (before fork, in parent) - let nested = is_nested(); - - // 4. Create synchronization pipes - let pipes = PipePair::new().map_err(SandboxError::Io)?; - - // 4. Resolve --net-allow rules into the runtime endpoint allowlist. - // The resolved form contains: - // - per_ip: HashMap> (concrete-host rules) - // - any_ip_ports: HashSet (`:port` rules) - // - all_ports: HashSet (union — for Landlock) - // - etc_hosts: Option (synthetic when any - // concrete host present) - let resolved_net_allow = network::resolve_net_allow(&self.policy.net_allow) - .await - .map_err(SandboxError::Io)?; - let virtual_etc_hosts = resolved_net_allow.etc_hosts.clone(); - - // 5. Spawn HTTP ACL proxy if rules are configured - if !self.policy.http_allow.is_empty() || !self.policy.http_deny.is_empty() { + if !self.http_allow.is_empty() || !self.http_deny.is_empty() { let handle = crate::http_acl::spawn_http_acl_proxy( - self.policy.http_allow.clone(), - self.policy.http_deny.clone(), - self.policy.https_ca.as_deref(), - self.policy.https_key.as_deref(), - ).await.map_err(SandboxError::Io)?; - self.http_acl_handle = Some(handle); + self.http_allow.clone(), + self.http_deny.clone(), + self.http_ca.as_deref(), + self.http_key.as_deref(), + ).await.map_err(SandboxRuntimeError::Io)?; + self.rt_mut().http_acl_handle = Some(handle); } - // 6. Create COW branch if requested - let cow_branch: Option> = match self.policy.fs_isolation { + let cow_branch: Option> = match self.fs_isolation { FsIsolation::OverlayFs => { - let workdir = self.policy.workdir.as_ref() - .ok_or_else(|| SandlockError::Sandbox(SandboxError::Child("OverlayFs requires workdir".into())))?; - let storage = self.policy.fs_storage.as_ref() + let workdir = self.workdir.as_ref() + .ok_or_else(|| crate::error::SandlockError::Runtime(SandboxRuntimeError::Child("OverlayFs requires workdir".into())))?; + let storage = self.fs_storage.as_ref() .cloned() .unwrap_or_else(|| std::env::temp_dir().join("sandlock-overlay")); std::fs::create_dir_all(&storage) - .map_err(|e| SandlockError::Sandbox(SandboxError::Io(e)))?; + .map_err(|e| crate::error::SandlockError::Runtime(SandboxRuntimeError::Io(e)))?; let branch = OverlayBranch::create(workdir, &storage) - .map_err(|e| SandlockError::Sandbox(SandboxError::Branch(e)))?; + .map_err(|e| crate::error::SandlockError::Runtime(SandboxRuntimeError::Branch(e)))?; Some(Box::new(branch)) } FsIsolation::BranchFs => { - let workdir = self.policy.workdir.as_ref() - .ok_or_else(|| SandlockError::Sandbox(SandboxError::Child("BranchFs requires workdir".into())))?; + let workdir = self.workdir.as_ref() + .ok_or_else(|| crate::error::SandlockError::Runtime(SandboxRuntimeError::Child("BranchFs requires workdir".into())))?; let branch = BranchFsBranch::create(workdir) - .map_err(|e| SandlockError::Sandbox(SandboxError::Branch(e)))?; + .map_err(|e| crate::error::SandlockError::Runtime(SandboxRuntimeError::Branch(e)))?; Some(Box::new(branch)) } FsIsolation::None => None, }; - // Ask the backend for mount config (only OverlayFS needs one). let cow_config = cow_branch.as_ref().and_then(|b| b.child_mount_config()); - // 6. Create stdout/stderr capture pipes (if capture mode) let (stdout_r, stderr_r) = if capture { let mut stdout_fds = [0i32; 2]; let mut stderr_fds = [0i32; 2]; if unsafe { libc::pipe2(stdout_fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { - return Err(SandboxError::Io(std::io::Error::last_os_error()).into()); + return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); } if unsafe { libc::pipe2(stderr_fds.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { unsafe { libc::close(stdout_fds[0]); libc::close(stdout_fds[1]); } - return Err(SandboxError::Io(std::io::Error::last_os_error()).into()); + return Err(SandboxRuntimeError::Io(std::io::Error::last_os_error()).into()); } ( Some(( @@ -899,208 +1505,145 @@ impl Sandbox { (None, None) }; - // 6. Fork let pid = unsafe { libc::fork() }; if pid < 0 { - return Err(SandboxError::Fork(std::io::Error::last_os_error()).into()); + return Err(SandboxRuntimeError::Fork(std::io::Error::last_os_error()).into()); } if pid == 0 { // ===== CHILD PROCESS ===== - // Drop parent's pipe ends by leaking them (they are OwnedFd and would - // close the fd on drop, but we only want to close OUR ends). - // The child does not use notif_r or ready_w. - // We must forget them so that Drop doesn't close the raw fds that - // confine_child may still use. - // - // We use std::mem::forget on the read end of notif and write end of ready - // because confine_child uses notif_w and ready_r (via the PipePair reference). - // The parent's ends (notif_r, ready_w) need to be closed in the child. - // However, since PipePair owns all four fds and confine_child takes - // a reference to it, we pass the whole PipePair and let confine_child - // handle it. confine_child never returns. - - // Apply io_overrides (from spawn_with_io / pipeline) - if let Some((stdin_fd, stdout_fd, stderr_fd)) = self.io_overrides { - if let Some(fd) = stdin_fd { - unsafe { libc::dup2(fd, 0) }; - } - if let Some(fd) = stdout_fd { - unsafe { libc::dup2(fd, 1) }; - } - if let Some(fd) = stderr_fd { - unsafe { libc::dup2(fd, 2) }; - } + let io_overrides = self.rt().io_overrides; + if let Some((stdin_fd, stdout_fd, stderr_fd)) = io_overrides { + if let Some(fd) = stdin_fd { unsafe { libc::dup2(fd, 0) }; } + if let Some(fd) = stdout_fd { unsafe { libc::dup2(fd, 1) }; } + if let Some(fd) = stderr_fd { unsafe { libc::dup2(fd, 2) }; } } - // Apply extra fd mappings (from gather) - for &(target_fd, source_fd) in &self.extra_fds { + let extra_fds_copy = self.rt().extra_fds.clone(); + for &(target_fd, source_fd) in &extra_fds_copy { unsafe { libc::dup2(source_fd, target_fd) }; } - // Redirect stdout/stderr if capturing if let Some((_, ref stdout_w)) = stdout_r { unsafe { libc::dup2(stdout_w.as_raw_fd(), 1) }; } if let Some((_, ref stderr_w)) = stderr_r { unsafe { libc::dup2(stderr_w.as_raw_fd(), 2) }; } - // Drop capture pipe read ends in child (they belong to parent). - // The write ends will be closed by O_CLOEXEC on exec. drop(stdout_r); drop(stderr_r); - // Collect target fds from gather that must survive close_fds_above - let gather_keep_fds: Vec = self.extra_fds.iter().map(|&(target, _)| target).collect(); - - // Collect extra-handler syscall numbers for the BPF filter the child - // is about to install. This must be a plain `Vec` because the - // child does not need (and cannot use after exec) the heap-allocated - // closures stored in `self.extra_handlers` — only the registered - // syscall numbers must be added to the BPF notif list so the kernel - // raises notifications for them. The supervisor in the parent owns - // the closures themselves. - let extra_syscalls: Vec = self - .extra_handlers + let gather_keep_fds: Vec = extra_fds_copy.iter().map(|&(target, _)| target).collect(); + + let extra_syscalls: Vec = self.rt().extra_handlers .iter() .map(|h| h.0 as u32) .collect(); - // This never returns. + let sandbox_name = self.rt().name.clone(); context::confine_child(context::ChildSpawnArgs { - policy: &self.policy, + sandbox: self, cmd: &c_cmd, pipes: &pipes, cow_config: cow_config.as_ref(), nested, keep_fds: &gather_keep_fds, - sandbox_name: Some(self.name.as_str()), + sandbox_name: Some(sandbox_name.as_str()), extra_syscalls: &extra_syscalls, }); } // ===== PARENT PROCESS ===== + self.rt_mut().cow_branch = cow_branch; - // Store COW branch in parent - self.cow_branch = cow_branch; - - // 7. Close child's pipe ends drop(pipes.notif_w); drop(pipes.ready_r); - // Drop capture pipe write ends in parent (they belong to child). - // Store the read ends so the child doesn't get SIGPIPE. - self._stdout_read = stdout_r.map(|(r, _w)| r); - self._stderr_read = stderr_r.map(|(r, _w)| r); + self.rt_mut()._stdout_read = stdout_r.map(|(r, _w)| r); + self.rt_mut()._stderr_read = stderr_r.map(|(r, _w)| r); - // 8. Set child_pid, state=Running - self.child_pid = Some(pid); - self.state = SandboxState::Running; + self.rt_mut().child_pid = Some(pid); + self.rt_mut().state = RuntimeState::Running; - // 9. Open pidfd via syscall::pidfd_open let pidfd = match syscall::pidfd_open(pid as u32, 0) { Ok(fd) => Some(fd), - Err(_) => None, // pidfd not available on older kernels — proceed without + Err(_) => None, }; - // 10. Read notif fd number from pipe (what child wrote) - // 0 = nested mode (no supervisor needed) let notif_fd_num = read_u32_fd(pipes.notif_r.as_raw_fd()) - .map_err(|e| SandboxError::Child(format!("read notif fd from child: {}", e)))?; + .map_err(|e| SandboxRuntimeError::Child(format!("read notif fd from child: {}", e)))?; - let is_nested = notif_fd_num == 0; + let is_nested_mode = notif_fd_num == 0; - // 11. Copy notif fd from child (skip if nested) - let notif_fd = if is_nested { + let notif_fd = if is_nested_mode { None } else if let Some(ref pfd) = pidfd { Some(syscall::pidfd_getfd(pfd, notif_fd_num as i32, 0) - .map_err(|e| SandboxError::Child(format!("pidfd_getfd: {}", e)))?) + .map_err(|e| SandboxRuntimeError::Child(format!("pidfd_getfd: {}", e)))?) } else { let path = format!("/proc/{}/fd/{}", pid, notif_fd_num); let cpath = CString::new(path).unwrap(); let raw = unsafe { libc::open(cpath.as_ptr(), libc::O_RDWR) }; if raw < 0 { - return Err( - SandboxError::Child("failed to open notif fd from /proc".into()).into(), - ); + return Err(SandboxRuntimeError::Child("failed to open notif fd from /proc".into()).into()); } Some(unsafe { OwnedFd::from_raw_fd(raw) }) }; - // 11b–14. Supervisor setup (skip in nested mode) if let Some(notif_fd) = notif_fd { - // vDSO patching for determinism - if self.policy.time_start.is_some() || self.policy.random_seed.is_some() { - let time_offset = self.policy.time_start.map(|t| crate::time::calculate_time_offset(t)); - if let Err(e) = crate::vdso::patch(pid, time_offset, self.policy.random_seed.is_some()) { + if self.time_start.is_some() || self.random_seed.is_some() { + let time_offset = self.time_start.map(|t| crate::time::calculate_time_offset(t)); + if let Err(e) = crate::vdso::patch(pid, time_offset, self.random_seed.is_some()) { eprintln!("sandlock: pre-exec vDSO patching failed (will retry after exec): {}", e); } } - // Build NotifPolicy - let time_offset_val = self.policy.time_start + let time_offset_val = self.time_start .map(|t| crate::time::calculate_time_offset(t)) .unwrap_or(0); + let rt_name = self.rt().name.clone(); let notif_policy = NotifPolicy { - max_memory_bytes: self.policy.max_memory.map(|m| m.0).unwrap_or(0), - max_processes: self.policy.max_processes, - has_memory_limit: self.policy.max_memory.is_some(), - has_net_allowlist: !self.policy.net_allow.is_empty() - || self.policy.policy_fn.is_some() - || !self.policy.http_allow.is_empty() - || !self.policy.http_deny.is_empty(), - has_random_seed: self.policy.random_seed.is_some(), - has_time_start: self.policy.time_start.is_some(), - // True if any consumer can inspect argv on execve: - // the policy_fn callback or an extra handler bound to - // execve/execveat (which can use read_child_mem). Both - // require the freeze + fork-event tracking to keep - // argv reads TOCTOU-safe. - argv_safety_required: self.policy.policy_fn.is_some() - || self.extra_handlers.iter().any(|h| { - h.0 == libc::SYS_execve - || h.0 == libc::SYS_execveat + max_memory_bytes: self.max_memory.map(|m| m.0).unwrap_or(0), + max_processes: self.max_processes, + has_memory_limit: self.max_memory.is_some(), + has_net_allowlist: !self.net_allow.is_empty() + || self.policy_fn.is_some() + || !self.http_allow.is_empty() + || !self.http_deny.is_empty(), + has_random_seed: self.random_seed.is_some(), + has_time_start: self.time_start.is_some(), + argv_safety_required: self.policy_fn.is_some() + || self.rt().extra_handlers.iter().any(|h| { + h.0 == libc::SYS_execve || h.0 == libc::SYS_execveat }), time_offset: time_offset_val, - num_cpus: self.policy.num_cpus, - port_remap: self.policy.port_remap, - cow_enabled: self.policy.workdir.is_some() && self.policy.fs_isolation == FsIsolation::None, - chroot_root: self.policy.chroot.as_ref().and_then(|p| std::fs::canonicalize(p).ok()), - chroot_readable: self.policy.fs_readable.clone(), - chroot_writable: self.policy.fs_writable.clone(), - chroot_denied: self.policy.fs_denied.clone(), - chroot_mounts: self.policy.fs_mount.iter().map(|(vp, hp)| { + num_cpus: self.num_cpus, + port_remap: self.port_remap, + cow_enabled: self.workdir.is_some() && self.fs_isolation == FsIsolation::None, + chroot_root: self.chroot.as_ref().and_then(|p| std::fs::canonicalize(p).ok()), + chroot_readable: self.fs_readable.clone(), + chroot_writable: self.fs_writable.clone(), + chroot_denied: self.fs_denied.clone(), + chroot_mounts: self.fs_mount.iter().map(|(vp, hp)| { (vp.clone(), std::fs::canonicalize(hp).unwrap_or_else(|_| hp.clone())) }).collect(), - deterministic_dirs: self.policy.deterministic_dirs, - virtual_hostname: Some(self.name.clone()), - has_http_acl: !self.policy.http_allow.is_empty() || !self.policy.http_deny.is_empty(), + deterministic_dirs: self.deterministic_dirs, + virtual_hostname: Some(rt_name), + has_http_acl: !self.http_allow.is_empty() || !self.http_deny.is_empty(), virtual_etc_hosts, }; - // Create domain states use rand::SeedableRng; use rand_chacha::ChaCha8Rng; - let random_state = self.policy.random_seed.map(|seed| ChaCha8Rng::seed_from_u64(seed)); - let time_offset = self.policy.time_start.map(|t| crate::time::calculate_time_offset(t)); + let random_state = self.random_seed.map(|seed| ChaCha8Rng::seed_from_u64(seed)); + let time_offset = self.time_start.map(|t| crate::time::calculate_time_offset(t)); - // TimeRandomState let time_random_state = TimeRandomState::new(time_offset, random_state); - // NetworkState — three protocol-keyed policies. Each is - // built from the protocol's slice of net_allow rules; the - // on-behalf handler picks the right one at check time - // based on the dup'd fd's SO_PROTOCOL. A protocol with no - // rules gets `Unrestricted` *only* when there are no rules - // for any protocol — otherwise it's an empty AllowList, - // i.e. deny-all for that protocol. (Empty across the board - // means "no on-behalf path active," matching pre-Phase-1 - // behavior where Landlock is the sole enforcer.) let mut net_state = NetworkState::new(); - let no_rules = self.policy.net_allow.is_empty(); + let no_rules = self.net_allow.is_empty(); let policy_from = |resolved: &network::ResolvedNetAllow| { if no_rules || resolved.any_ip_all_ports { crate::seccomp::notif::NetworkPolicy::Unrestricted @@ -1127,53 +1670,41 @@ impl Sandbox { net_state.tcp_policy = policy_from(&resolved_net_allow.tcp); net_state.udp_policy = policy_from(&resolved_net_allow.udp); net_state.icmp_policy = policy_from(&resolved_net_allow.icmp); - net_state.http_acl_addr = self.http_acl_handle.as_ref().map(|h| h.addr); - net_state.http_acl_ports = self.policy.http_ports.iter().copied().collect(); - net_state.http_acl_orig_dest = self.http_acl_handle.as_ref().map(|h| h.orig_dest.clone()); - if let Some(cb) = self.on_bind.take() { + net_state.http_acl_addr = self.rt().http_acl_handle.as_ref().map(|h| h.addr); + net_state.http_acl_ports = self.http_ports.iter().copied().collect(); + net_state.http_acl_orig_dest = self.rt().http_acl_handle.as_ref().map(|h| h.orig_dest.clone()); + if let Some(cb) = self.rt_mut().on_bind.take() { net_state.port_map.on_bind = Some(cb); } - // ProcfsState (per-notification process state lives in ProcessIndex). let procfs_state = ProcfsState::new(); - // ResourceState let mut res_state = ResourceState::new( notif_policy.max_memory_bytes, notif_policy.max_processes, ); res_state.proc_count = 1; - // CowState let mut cow_state = CowState::new(); - if self.policy.workdir.is_some() && self.policy.fs_isolation == FsIsolation::None { - let workdir = self.policy.workdir.as_ref().unwrap(); - let storage = self.policy.fs_storage.as_deref(); - let max_disk = self.policy.max_disk.map(|b| b.0).unwrap_or(0); + if self.workdir.is_some() && self.fs_isolation == FsIsolation::None { + let workdir = self.workdir.as_ref().unwrap(); + let storage = self.fs_storage.as_deref(); + let max_disk = self.max_disk.map(|b| b.0).unwrap_or(0); match crate::cow::seccomp::SeccompCowBranch::create(workdir, storage, max_disk) { Ok(branch) => { cow_state.branch = Some(branch); } Err(e) => { eprintln!("sandlock: seccomp COW branch creation failed: {}", e); } } } - // PolicyFnState let mut policy_fn_state = PolicyFnState::new(); if let Ok(mut denied) = policy_fn_state.denied_paths.write() { - for path in &self.policy.fs_denied { + for path in &self.fs_denied { denied.insert(path.to_string_lossy().into_owned()); } } - if let Some(ref callback) = self.policy.policy_fn { - // The dynamic-policy "live" view is IP-only — derive it - // from per_ip keys (each represents an IP that some - // endpoint rule mentions). The any_ip case has no IPs to - // expose to the callback. - // The dynamic-policy live view exposes the IPs the - // sandbox can talk to; that's the union of TCP+UDP+ICMP - // destination IPs (plus any from policy_fn overrides - // applied later). We collect from all three policies. + if let Some(ref callback) = self.policy_fn { let mut allowed_ips: std::collections::HashSet = std::collections::HashSet::new(); for p in [&net_state.tcp_policy, &net_state.udp_policy, &net_state.icmp_policy] { @@ -1197,30 +1728,24 @@ impl Sandbox { policy_fn_state.event_tx = Some(tx); } - // ChrootState let chroot_state = ChrootState::new(); - use std::os::unix::io::AsRawFd; let notif_raw_fd = notif_fd.as_raw_fd(); let child_pidfd_raw = pidfd.as_ref().map(|pfd| pfd.as_raw_fd()); - let res_state = Arc::new(Mutex::new(res_state)); - self.supervisor_resource = Some(Arc::clone(&res_state)); + let res_state = Arc::new(tokio::sync::Mutex::new(res_state)); + self.rt_mut().supervisor_resource = Some(Arc::clone(&res_state)); - let cow_state = Arc::new(Mutex::new(cow_state)); - self.supervisor_cow = Some(Arc::clone(&cow_state)); + let cow_state = Arc::new(tokio::sync::Mutex::new(cow_state)); + self.rt_mut().supervisor_cow = Some(Arc::clone(&cow_state)); - let net_state = Arc::new(Mutex::new(net_state)); - self.supervisor_network = Some(Arc::clone(&net_state)); + let net_state = Arc::new(tokio::sync::Mutex::new(net_state)); + self.rt_mut().supervisor_network = Some(Arc::clone(&net_state)); - let procfs_state = Arc::new(Mutex::new(procfs_state)); - let time_random_state = Arc::new(Mutex::new(time_random_state)); - let policy_fn_state = Arc::new(Mutex::new(policy_fn_state)); - let chroot_state = Arc::new(Mutex::new(chroot_state)); - // Root child gets per-process state (with watcher) on its - // first notification. When policy_fn is active, fork-like - // syscalls are traced at creation time so descendants are - // registered before they can run user code. + let procfs_state = Arc::new(tokio::sync::Mutex::new(procfs_state)); + let time_random_state = Arc::new(tokio::sync::Mutex::new(time_random_state)); + let policy_fn_state = Arc::new(tokio::sync::Mutex::new(policy_fn_state)); + let chroot_state = Arc::new(tokio::sync::Mutex::new(chroot_state)); let processes = Arc::new(crate::seccomp::state::ProcessIndex::new()); let ctx = Arc::new(SupervisorCtx { @@ -1238,19 +1763,15 @@ impl Sandbox { notif_fd: notif_raw_fd, }); - // Spawn notif supervisor. `extra_handlers` is consumed here - // (moved into the supervisor task) because each `Arc` - // is shared with the dispatch table and must outlive it. - let extra_handlers = std::mem::take(&mut self.extra_handlers); - self.notif_handle = Some(tokio::spawn( + let extra_handlers = std::mem::take(&mut self.rt_mut().extra_handlers); + self.rt_mut().notif_handle = Some(tokio::spawn( notif::supervisor(notif_fd, ctx, extra_handlers), )); - // Spawn load average sampling task (every 5s, like the kernel) let la_resource = Arc::clone(&res_state); - self.loadavg_handle = Some(tokio::spawn(async move { + self.rt_mut().loadavg_handle = Some(tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(5)); - interval.tick().await; // skip immediate first tick + interval.tick().await; loop { interval.tick().await; let mut rs = la_resource.lock().await; @@ -1260,173 +1781,120 @@ impl Sandbox { })); } - // 15. Optionally spawn CPU throttle task - if let Some(cpu_pct) = self.policy.max_cpu { + if let Some(cpu_pct) = self.max_cpu { if cpu_pct < 100 { let child_pid = pid; - self.throttle_handle = Some(tokio::spawn(throttle_cpu(child_pid, cpu_pct))); + self.rt_mut().throttle_handle = Some(tokio::spawn(sandbox_throttle_cpu(child_pid, cpu_pct))); } } - // 16. Signal child "ready" via pipe write_u32_fd(pipes.ready_w.as_raw_fd(), 1) - .map_err(|e| SandboxError::Child(format!("write ready signal: {}", e)))?; + .map_err(|e| SandboxRuntimeError::Child(format!("write ready signal: {}", e)))?; - // 17. Store pidfd - self.pidfd = pidfd; + self.rt_mut().pidfd = pidfd; Ok(()) } } -// ============================================================ -// Helpers -// ============================================================ - -/// Convert a user-supplied iterator of `(syscall, handler)` pairs into -/// the internal `Vec<(i64, Arc)>` shape used by the -/// supervisor, validating each syscall up-front against the blocklist. -fn collect_extra_handlers( - extra_handlers: I, - policy: &Policy, -) -> Result)>, SandlockError> -where - I: IntoIterator, - S: TryInto, - H: crate::seccomp::dispatch::Handler, -{ - use crate::seccomp::dispatch::{Handler, HandlerError}; - - let pending: Vec<(i64, Arc)> = extra_handlers - .into_iter() - .map(|(syscall, handler)| { - let nr = syscall.try_into().map_err(HandlerError::from)?.raw(); - let h: Arc = Arc::new(handler); - Ok::<_, HandlerError>((nr, h)) - }) - .collect::>()?; - - let nrs: Vec = pending.iter().map(|(nr, _)| *nr).collect(); - crate::seccomp::dispatch::validate_handler_syscalls_against_policy(&nrs, policy) - .map_err(|syscall_nr| HandlerError::OnDenySyscall { syscall_nr })?; - - Ok(pending) -} - -// ============================================================ -// Drop — kill and reap child if still running -// ============================================================ +// ================================================================ +// Drop for Sandbox — kills and reaps child if still running +// ================================================================ impl Drop for Sandbox { fn drop(&mut self) { - if let Some(pid) = self.child_pid { - if matches!(self.state, SandboxState::Running | SandboxState::Paused) { - // Kill the entire process group - unsafe { libc::killpg(pid, libc::SIGKILL) }; - // Reap the zombie - let mut status: i32 = 0; - unsafe { libc::waitpid(pid, &mut status, 0) }; + if let Some(ref mut rt) = self.runtime { + if let Some(pid) = rt.child_pid { + if matches!(rt.state, RuntimeState::Running | RuntimeState::Paused) { + unsafe { libc::killpg(pid, libc::SIGKILL) }; + let mut status: i32 = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + } } - } - - if let Some(h) = self.notif_handle.take() { - h.abort(); - } - if let Some(h) = self.throttle_handle.take() { - h.abort(); - } - if let Some(h) = self.loadavg_handle.take() { - h.abort(); - } - // COW cleanup based on exit status. - // Determine action once, then apply to whichever branch exists. - let is_error = matches!( - self.state, - SandboxState::Stopped(ref s) if !matches!(s, ExitStatus::Code(0)) - ); - let action = if is_error { - &self.policy.on_error - } else { - &self.policy.on_exit - }; + if let Some(h) = rt.notif_handle.take() { h.abort(); } + if let Some(h) = rt.throttle_handle.take() { h.abort(); } + if let Some(h) = rt.loadavg_handle.take() { h.abort(); } - // OverlayFS / BranchFS COW branch - if let Some(ref branch) = self.cow_branch { - match action { - BranchAction::Commit => { let _ = branch.commit(); } - BranchAction::Abort => { let _ = branch.abort(); } - BranchAction::Keep => {} + let is_error = matches!( + rt.state, + RuntimeState::Stopped(ref s) if !matches!(s, crate::result::ExitStatus::Code(0)) + ); + let action = if is_error { &self.on_error } else { &self.on_exit }; + let action = action.clone(); + + if let Some(ref branch) = rt.cow_branch { + match action { + BranchAction::Commit => { let _ = branch.commit(); } + BranchAction::Abort => { let _ = branch.abort(); } + BranchAction::Keep => {} + } } - } - // Seccomp COW branch (extracted from supervisor state in wait()) - if let Some(ref mut cow) = self.seccomp_cow { - match action { - BranchAction::Commit => { let _ = cow.commit(); } - BranchAction::Abort => { let _ = cow.abort(); } - BranchAction::Keep => {} + if let Some(ref mut cow) = rt.seccomp_cow { + match action { + BranchAction::Commit => { let _ = cow.commit(); } + BranchAction::Abort => { let _ = cow.abort(); } + BranchAction::Keep => {} + } } } } } -// ============================================================ +// ================================================================ // CPU throttle -// ============================================================ +// ================================================================ -/// Periodically SIGSTOP/SIGCONT the child process group to throttle CPU usage. -async fn throttle_cpu(pid: i32, cpu_pct: u8) { +async fn sandbox_throttle_cpu(pid: i32, cpu_pct: u8) { + use std::time::Duration; let period = Duration::from_millis(100); let run_time = period * cpu_pct as u32 / 100; let stop_time = period - run_time; - loop { tokio::time::sleep(run_time).await; - if unsafe { libc::killpg(pid, libc::SIGSTOP) } < 0 { - break; - } + if unsafe { libc::killpg(pid, libc::SIGSTOP) } < 0 { break; } tokio::time::sleep(stop_time).await; - if unsafe { libc::killpg(pid, libc::SIGCONT) } < 0 { - break; - } + if unsafe { libc::killpg(pid, libc::SIGCONT) } < 0 { break; } } } -// ============================================================ -// Helpers -// ============================================================ +// ================================================================ +// Process name resolution +// ================================================================ -static NEXT_SANDBOX_NAME: AtomicU64 = AtomicU64::new(1); +static NEXT_SANDBOX_NAME: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); -fn resolve_name(name: Option<&str>) -> Result { +fn sandbox_resolve_name(name: Option<&str>) -> Result { match name { - Some(name) => validate_name(name.to_string()), + Some(n) => sandbox_validate_name(n.to_string()), None => Ok(format!( "sandbox-{}-{}", std::process::id(), - NEXT_SANDBOX_NAME.fetch_add(1, Ordering::Relaxed), + NEXT_SANDBOX_NAME.fetch_add(1, std::sync::atomic::Ordering::Relaxed), )), } } -fn validate_name(name: String) -> Result { +fn sandbox_validate_name(name: String) -> Result { + use crate::error::SandboxRuntimeError; if name.is_empty() { - return Err(SandboxError::Child("sandbox name must not be empty".into()).into()); + return Err(SandboxRuntimeError::Child("sandbox name must not be empty".into()).into()); } if name.len() > 64 { - return Err(SandboxError::Child("sandbox name must be at most 64 bytes".into()).into()); + return Err(SandboxRuntimeError::Child("sandbox name must be at most 64 bytes".into()).into()); } if name.as_bytes().contains(&0) { - return Err(SandboxError::Child("sandbox name must not contain NUL bytes".into()).into()); + return Err(SandboxRuntimeError::Child("sandbox name must not contain NUL bytes".into()).into()); } Ok(name) } -/// Convert a raw waitpid status to our ExitStatus enum. -/// Read all bytes from a file descriptor until EOF. -/// Read exactly `buf.len()` bytes from a raw fd. -fn read_exact(fd: i32, buf: &mut [u8]) { +// ================================================================ +// I/O helpers (private) +// ================================================================ + +fn sandbox_read_exact(fd: i32, buf: &mut [u8]) { let mut off = 0; while off < buf.len() { let r = unsafe { libc::read(fd, buf[off..].as_mut_ptr() as *mut _, buf.len() - off) }; @@ -1435,15 +1903,18 @@ fn read_exact(fd: i32, buf: &mut [u8]) { } } -fn read_fd_to_end(fd: OwnedFd) -> Vec { +fn sandbox_read_fd_to_end(fd: std::os::fd::OwnedFd) -> Vec { use std::io::Read; + use std::os::fd::IntoRawFd; + use std::os::unix::io::FromRawFd; let mut file = unsafe { std::fs::File::from_raw_fd(fd.into_raw_fd()) }; let mut buf = Vec::new(); let _ = file.read_to_end(&mut buf); buf } -fn wait_status_to_exit(status: i32) -> ExitStatus { +fn sandbox_wait_status_to_exit(status: i32) -> crate::result::ExitStatus { + use crate::result::ExitStatus; if libc::WIFEXITED(status) { ExitStatus::Code(libc::WEXITSTATUS(status)) } else if libc::WIFSIGNALED(status) { @@ -1457,3 +1928,1141 @@ fn wait_status_to_exit(status: i32) -> ExitStatus { ExitStatus::Killed } } + +fn sandbox_collect_extra_handlers( + extra_handlers: I, + sandbox: &Sandbox, +) -> Result)>, crate::error::SandlockError> +where + I: IntoIterator, + S: TryInto, + H: crate::seccomp::dispatch::Handler, +{ + use crate::seccomp::dispatch::{Handler, HandlerError}; + + let pending: Vec<(i64, Arc)> = extra_handlers + .into_iter() + .map(|(syscall, handler)| { + let nr = syscall.try_into().map_err(HandlerError::from)?.raw(); + let h: Arc = Arc::new(handler); + Ok::<_, HandlerError>((nr, h)) + }) + .collect::>()?; + + let nrs: Vec = pending.iter().map(|(nr, _)| *nr).collect(); + crate::seccomp::dispatch::validate_handler_syscalls_against_policy(&nrs, sandbox) + .map_err(|syscall_nr| HandlerError::OnDenySyscall { syscall_nr })?; + + Ok(pending) +} + +fn validate_syscall_names(names: &[String]) -> Result<(), SandboxError> { + let unknown: Vec<&str> = names + .iter() + .map(String::as_str) + .filter(|name| crate::context::syscall_name_to_nr(name).is_none()) + .collect(); + if unknown.is_empty() { + Ok(()) + } else { + Err(SandboxError::Invalid(format!( + "unknown syscall name(s): {}", + unknown.join(", ") + ))) + } +} + +/// Fluent builder for `Sandbox`. +/// +/// When the `cli` feature is enabled this struct also derives `clap::Args` so +/// that the CLI can expose all per-field flags via `#[clap(flatten)]` without +/// duplicating the flag declarations. +#[derive(Default)] +#[cfg_attr(feature = "cli", derive(clap::Args))] +pub struct SandboxBuilder { + #[cfg_attr(feature = "cli", arg(short = 'r', long = "fs-read", value_name = "PATH"))] + pub fs_readable: Vec, + + #[cfg_attr(feature = "cli", arg(short = 'w', long = "fs-write", value_name = "PATH"))] + pub fs_writable: Vec, + + #[cfg_attr(feature = "cli", arg(long = "fs-deny", value_name = "PATH"))] + pub fs_denied: Vec, + + /// Extra syscall names to deny (in addition to Sandlock's default blocklist) + #[cfg_attr(feature = "cli", arg(long = "extra-deny-syscall", value_name = "NAME"))] + pub extra_deny_syscalls: Vec, + + /// Extra syscall group names to allow (e.g. sysv_ipc) + #[cfg_attr(feature = "cli", arg(long = "extra-allow-syscall", value_name = "NAME"))] + pub extra_allow_syscalls: Vec, + + /// Outbound endpoint allow rule. Repeatable. Each value is + /// `host:port[,port,...]` (IP-restricted), `:port` or `*:port` + /// (any IP), or `udp://...` / `icmp://...` for UDP/ICMP. + /// Examples: `api.openai.com:443`, `github.com:22,443`, `:8080`. + #[cfg_attr(feature = "cli", arg(long = "net-allow", value_name = "SPEC"))] + pub net_allow: Vec, + + #[cfg_attr(feature = "cli", arg(long = "net-bind"))] + pub net_bind: Vec, + + #[cfg_attr(feature = "cli", arg(long = "http-allow", value_name = "RULE"))] + pub http_allow: Vec, + + #[cfg_attr(feature = "cli", arg(long = "http-deny", value_name = "RULE"))] + pub http_deny: Vec, + + /// TCP ports to intercept for HTTP ACL (default: 80, plus 443 with --http-ca) + #[cfg_attr(feature = "cli", arg(long = "http-port", value_name = "PORT"))] + pub http_ports: Vec, + + /// PEM CA certificate for HTTPS MITM (enables port 443 interception) + #[cfg_attr(feature = "cli", arg(long = "http-ca", value_name = "PATH"))] + pub http_ca: Option, + + /// PEM CA private key for HTTPS MITM (required with --http-ca) + #[cfg_attr(feature = "cli", arg(long = "http-key", value_name = "PATH"))] + pub http_key: Option, + + // max_memory uses a string in the CLI (e.g. "512M"); not directly clap-friendly as ByteSize. + #[cfg_attr(feature = "cli", clap(skip))] + pub max_memory: Option, + + #[cfg_attr(feature = "cli", arg(short = 'P', long = "max-processes"))] + pub max_processes: Option, + + #[cfg_attr(feature = "cli", arg(long = "max-open-files"))] + pub max_open_files: Option, + + #[cfg_attr(feature = "cli", arg(short = 'c', long = "cpu"))] + pub max_cpu: Option, + + #[cfg_attr(feature = "cli", arg(long = "random-seed"))] + pub random_seed: Option, + + // time_start requires ISO 8601 string parsing; not directly clap-friendly as SystemTime. + #[cfg_attr(feature = "cli", clap(skip))] + pub time_start: Option, + + #[cfg_attr(feature = "cli", arg(long = "no-randomize-memory"))] + pub no_randomize_memory: bool, + + #[cfg_attr(feature = "cli", arg(long = "no-huge-pages"))] + pub no_huge_pages: bool, + + #[cfg_attr(feature = "cli", arg(long = "no-coredump"))] + pub no_coredump: bool, + + #[cfg_attr(feature = "cli", arg(long = "deterministic-dirs"))] + pub deterministic_dirs: bool, + + // fs_isolation requires string-to-enum parsing; not directly clap-friendly as FsIsolation. + #[cfg_attr(feature = "cli", clap(skip))] + pub fs_isolation: Option, + + #[cfg_attr(feature = "cli", arg(long = "workdir"))] + pub workdir: Option, + + #[cfg_attr(feature = "cli", arg(long = "cwd"))] + pub cwd: Option, + + #[cfg_attr(feature = "cli", arg(long = "fs-storage", value_name = "PATH"))] + pub fs_storage: Option, + + // max_disk uses a string in the CLI (e.g. "10G"); not directly clap-friendly as ByteSize. + #[cfg_attr(feature = "cli", clap(skip))] + pub max_disk: Option, + + // on_exit/on_error are not exposed as CLI flags. + #[cfg_attr(feature = "cli", clap(skip))] + pub on_exit: Option, + + #[cfg_attr(feature = "cli", clap(skip))] + pub on_error: Option, + + // fs_mount requires VIRTUAL:HOST string splitting; not directly clap-friendly as Vec<(PathBuf,PathBuf)>. + #[cfg_attr(feature = "cli", clap(skip))] + pub fs_mount: Vec<(PathBuf, PathBuf)>, + + #[cfg_attr(feature = "cli", arg(long = "chroot"))] + pub chroot: Option, + + #[cfg_attr(feature = "cli", arg(long = "clean-env"))] + pub clean_env: bool, + + // env requires KEY=VALUE string splitting; not directly clap-friendly as HashMap. + #[cfg_attr(feature = "cli", clap(skip))] + pub env: HashMap, + + // gpu_devices in CLI uses Vec with value_delimiter; SandboxBuilder stores Option>. + #[cfg_attr(feature = "cli", clap(skip))] + pub gpu_devices: Option>, + + // cpu_cores in CLI uses Vec with value_delimiter; SandboxBuilder stores Option>. + #[cfg_attr(feature = "cli", clap(skip))] + pub cpu_cores: Option>, + + #[cfg_attr(feature = "cli", arg(long = "num-cpus"))] + pub num_cpus: Option, + + #[cfg_attr(feature = "cli", arg(long = "port-remap"))] + pub port_remap: bool, + + #[cfg_attr(feature = "cli", arg(long = "uid"))] + pub uid: Option, + + // Internal callback — never a CLI flag. + #[cfg_attr(feature = "cli", clap(skip))] + pub policy_fn: Option, + + // Sandbox instance name — stored for transfer into the Sandbox at build time. + #[cfg_attr(feature = "cli", clap(skip))] + pub name: Option, + + // COW fork init function — runs once in the child before COW cloning. + #[cfg_attr(feature = "cli", clap(skip))] + pub(crate) init_fn: Option>, + + // COW fork work function — runs in each COW clone. + #[cfg_attr(feature = "cli", clap(skip))] + pub(crate) work_fn: Option>, +} + +impl std::fmt::Debug for SandboxBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxBuilder") + .field("fs_readable", &self.fs_readable) + .field("fs_writable", &self.fs_writable) + .field("max_memory", &self.max_memory) + .field("max_processes", &self.max_processes) + .field("policy_fn", &self.policy_fn.as_ref().map(|_| "")) + .finish_non_exhaustive() + } +} + +impl Clone for SandboxBuilder { + /// Clone a `SandboxBuilder`. All config and callback fields are cloned. + /// `init_fn` (FnOnce) is dropped to `None` on the clone; `work_fn` clones + /// via Arc. If the clone also needs an init function, set it again with + /// `.init_fn(...)`. + fn clone(&self) -> Self { + Self { + fs_readable: self.fs_readable.clone(), + fs_writable: self.fs_writable.clone(), + fs_denied: self.fs_denied.clone(), + extra_deny_syscalls: self.extra_deny_syscalls.clone(), + extra_allow_syscalls: self.extra_allow_syscalls.clone(), + net_allow: self.net_allow.clone(), + net_bind: self.net_bind.clone(), + http_allow: self.http_allow.clone(), + http_deny: self.http_deny.clone(), + http_ports: self.http_ports.clone(), + http_ca: self.http_ca.clone(), + http_key: self.http_key.clone(), + max_memory: self.max_memory, + max_processes: self.max_processes, + max_open_files: self.max_open_files, + max_cpu: self.max_cpu, + random_seed: self.random_seed, + time_start: self.time_start, + no_randomize_memory: self.no_randomize_memory, + no_huge_pages: self.no_huge_pages, + no_coredump: self.no_coredump, + deterministic_dirs: self.deterministic_dirs, + fs_isolation: self.fs_isolation.clone(), + workdir: self.workdir.clone(), + cwd: self.cwd.clone(), + fs_storage: self.fs_storage.clone(), + max_disk: self.max_disk, + on_exit: self.on_exit.clone(), + on_error: self.on_error.clone(), + fs_mount: self.fs_mount.clone(), + chroot: self.chroot.clone(), + clean_env: self.clean_env, + env: self.env.clone(), + gpu_devices: self.gpu_devices.clone(), + cpu_cores: self.cpu_cores.clone(), + num_cpus: self.num_cpus, + port_remap: self.port_remap, + uid: self.uid, + policy_fn: self.policy_fn.clone(), + name: self.name.clone(), + // init_fn (FnOnce) cannot be cloned — drop to None. + init_fn: None, + // work_fn is Arc-wrapped — clone bumps the reference count. + work_fn: self.work_fn.clone(), + } + } +} + +impl SandboxBuilder { + pub fn fs_write(mut self, path: impl Into) -> Self { + self.fs_writable.push(path.into()); + self + } + + pub fn fs_read(mut self, path: impl Into) -> Self { + self.fs_readable.push(path.into()); + self + } + + pub fn fs_read_if_exists(self, path: impl Into) -> Self { + let path = path.into(); + if path.exists() { + self.fs_read(path) + } else { + self + } + } + + pub fn fs_deny(mut self, path: impl Into) -> Self { + self.fs_denied.push(path.into()); + self + } + + pub fn extra_deny_syscalls(mut self, calls: Vec) -> Self { + self.extra_deny_syscalls.extend(calls); + self + } + + pub fn extra_allow_syscalls(mut self, names: Vec) -> Self { + self.extra_allow_syscalls.extend(names); + self + } + + /// Add a network endpoint rule. Spec is `host:port[,port,...]`, + /// `:port`, or `*:port`. Validated at `build()` time so callers + /// receive parse errors via the standard `SandboxBuilder` flow. + /// + /// Examples: + /// - `.net_allow("api.openai.com:443")` — HTTPS to OpenAI only + /// - `.net_allow("github.com:22,443")` — SSH and HTTPS to GitHub + /// - `.net_allow(":8080")` — any IP on port 8080 + pub fn net_allow(mut self, spec: impl Into) -> Self { + self.net_allow.push(spec.into()); + self + } + + pub fn net_bind_port(mut self, port: u16) -> Self { + self.net_bind.push(port); + self + } + + pub fn http_allow(mut self, rule: &str) -> Self { + self.http_allow.push(rule.to_string()); + self + } + + pub fn http_deny(mut self, rule: &str) -> Self { + self.http_deny.push(rule.to_string()); + self + } + + pub fn http_port(mut self, port: u16) -> Self { + self.http_ports.push(port); + self + } + + pub fn http_ca(mut self, path: impl Into) -> Self { + self.http_ca = Some(path.into()); + self + } + + pub fn http_key(mut self, path: impl Into) -> Self { + self.http_key = Some(path.into()); + self + } + + pub fn max_memory(mut self, size: ByteSize) -> Self { + self.max_memory = Some(size); + self + } + + pub fn max_processes(mut self, n: u32) -> Self { + self.max_processes = Some(n); + self + } + + pub fn max_open_files(mut self, n: u32) -> Self { + self.max_open_files = Some(n); + self + } + + pub fn max_cpu(mut self, pct: u8) -> Self { + self.max_cpu = Some(pct); + self + } + + pub fn random_seed(mut self, seed: u64) -> Self { + self.random_seed = Some(seed); + self + } + + pub fn time_start(mut self, t: SystemTime) -> Self { + self.time_start = Some(t); + self + } + + pub fn no_randomize_memory(mut self, v: bool) -> Self { + self.no_randomize_memory = v; + self + } + + pub fn no_huge_pages(mut self, v: bool) -> Self { + self.no_huge_pages = v; + self + } + + pub fn no_coredump(mut self, v: bool) -> Self { + self.no_coredump = v; + self + } + + pub fn deterministic_dirs(mut self, v: bool) -> Self { + self.deterministic_dirs = v; + self + } + + pub fn fs_isolation(mut self, iso: FsIsolation) -> Self { + self.fs_isolation = Some(iso); + self + } + + pub fn workdir(mut self, path: impl Into) -> Self { + self.workdir = Some(path.into()); + self + } + + pub fn cwd(mut self, path: impl Into) -> Self { + self.cwd = Some(path.into()); + self + } + + pub fn fs_storage(mut self, path: impl Into) -> Self { + self.fs_storage = Some(path.into()); + self + } + + pub fn max_disk(mut self, size: ByteSize) -> Self { + self.max_disk = Some(size); + self + } + + pub fn on_exit(mut self, action: BranchAction) -> Self { + self.on_exit = Some(action); + self + } + + pub fn on_error(mut self, action: BranchAction) -> Self { + self.on_error = Some(action); + self + } + + pub fn chroot(mut self, path: impl Into) -> Self { + self.chroot = Some(path.into()); + self + } + + pub fn fs_mount(mut self, virtual_path: impl Into, host_path: impl Into) -> Self { + self.fs_mount.push((virtual_path.into(), host_path.into())); + self + } + + pub fn clean_env(mut self, v: bool) -> Self { + self.clean_env = v; + self + } + + pub fn env_var(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), value.into()); + self + } + + + pub fn gpu_devices(mut self, devices: Vec) -> Self { + self.gpu_devices = Some(devices); + self + } + + pub fn cpu_cores(mut self, cores: Vec) -> Self { + self.cpu_cores = Some(cores); + self + } + + pub fn num_cpus(mut self, n: u32) -> Self { + self.num_cpus = Some(n); + self + } + + pub fn port_remap(mut self, v: bool) -> Self { + self.port_remap = v; + self + } + + pub fn policy_fn( + mut self, + f: impl Fn(crate::policy_fn::SyscallEvent, &mut crate::policy_fn::PolicyContext) -> crate::policy_fn::Verdict + Send + Sync + 'static, + ) -> Self { + self.policy_fn = Some(std::sync::Arc::new(f)); + self + } + + pub fn uid(mut self, id: u32) -> Self { + self.uid = Some(id); + self + } + + /// Set the sandbox instance name (exposed as the virtual hostname). + /// Auto-generated if not set. + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Set the COW-fork init function. + /// + /// The init function runs once in the child process before any COW clones + /// are created. Required for `Sandbox::fork()`. + pub fn init_fn(mut self, f: impl FnOnce() + Send + 'static) -> Self { + self.init_fn = Some(Box::new(f)); + self + } + + /// Set the COW-fork work function. + /// + /// The work function runs in each COW clone (`fork(N)` produces N clones). + /// Required for `Sandbox::fork()`. + pub fn work_fn(mut self, f: impl Fn(u32) + Send + Sync + 'static) -> Self { + self.work_fn = Some(Arc::new(f)); + self + } + + /// Build a `Sandbox`, parsing all string fields and running per-field + /// validation, but **without** the cross-section checks that + /// `Sandbox::validate` performs. Use this in tests that deliberately + /// construct sandboxes violating cross-section invariants. + pub fn build_unchecked(self) -> Result { + validate_syscall_names(&self.extra_deny_syscalls)?; + + // Validate: max_cpu must be 1-100 + if let Some(cpu) = self.max_cpu { + if cpu == 0 || cpu > 100 { + return Err(SandboxError::InvalidCpuPercent(cpu)); + } + } + + // Validate: http_ca and http_key must both be set or both unset + if self.http_ca.is_some() != self.http_key.is_some() { + return Err(SandboxError::Invalid( + "--http-ca and --http-key must both be provided together".into(), + )); + } + + // Parse HTTP rules (deferred from builder methods to propagate errors) + let http_allow: Vec = self + .http_allow + .into_iter() + .map(|s| HttpRule::parse(&s)) + .collect::>()?; + let http_deny: Vec = self + .http_deny + .into_iter() + .map(|s| HttpRule::parse(&s)) + .collect::>()?; + + // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured. + let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) { + let mut ports = vec![80]; + if self.http_ca.is_some() { + ports.push(443); + } + ports + } else { + self.http_ports + }; + + // Parse user-supplied --net-allow specs. + let mut net_allow: Vec = self + .net_allow + .into_iter() + .map(|s| NetAllow::parse(&s)) + .collect::>()?; + + // Auto-merge HTTP rules into the network allowlist so the proxy's + // intercept ports remain reachable. A rule with a concrete host + // tightens the IP allowlist (only that host on http_ports); + // wildcard hosts add a `:port` (any IP) rule. This mirrors the + // intent of the old `http_port → net_connect` merge but at the + // endpoint level so HTTP and net_allow stay aligned. + if !http_ports.is_empty() { + let mut wildcard_seen = false; + let mut concrete_hosts: Vec = Vec::new(); + for rule in http_allow.iter().chain(http_deny.iter()) { + if rule.host == "*" { + wildcard_seen = true; + } else if !concrete_hosts.iter().any(|h| h.eq_ignore_ascii_case(&rule.host)) { + concrete_hosts.push(rule.host.clone()); + } + } + if wildcard_seen || (http_allow.is_empty() && http_deny.is_empty()) { + // Fallback: explicit --http-port without rules, or wildcard rules. + net_allow.push(NetAllow { + protocol: Protocol::Tcp, + host: None, + ports: http_ports.clone(), + all_ports: false, + }); + } + for h in concrete_hosts { + net_allow.push(NetAllow { + protocol: Protocol::Tcp, + host: Some(h), + ports: http_ports.clone(), + all_ports: false, + }); + } + } + + let fs_isolation = self.fs_isolation.unwrap_or_default(); + Ok(Sandbox { + fs_writable: self.fs_writable, + fs_readable: self.fs_readable, + fs_denied: self.fs_denied, + extra_deny_syscalls: self.extra_deny_syscalls, + extra_allow_syscalls: self.extra_allow_syscalls, + net_allow, + net_bind: self.net_bind, + http_allow, + http_deny, + http_ports, + http_ca: self.http_ca, + http_key: self.http_key, + max_memory: self.max_memory, + max_processes: self.max_processes.unwrap_or(64), + max_open_files: self.max_open_files, + max_cpu: self.max_cpu, + random_seed: self.random_seed, + time_start: self.time_start, + no_randomize_memory: self.no_randomize_memory, + no_huge_pages: self.no_huge_pages, + no_coredump: self.no_coredump, + deterministic_dirs: self.deterministic_dirs, + fs_isolation, + workdir: self.workdir, + cwd: self.cwd, + fs_storage: self.fs_storage, + max_disk: self.max_disk, + on_exit: self.on_exit.unwrap_or_default(), + on_error: self.on_error.unwrap_or_default(), + fs_mount: self.fs_mount, + chroot: self.chroot, + clean_env: self.clean_env, + env: self.env, + gpu_devices: self.gpu_devices, + cpu_cores: self.cpu_cores, + num_cpus: self.num_cpus, + port_remap: self.port_remap, + uid: self.uid, + policy_fn: self.policy_fn, + name: self.name, + init_fn: self.init_fn, + work_fn: self.work_fn, + runtime: None, + }) + } + + /// Build a `Sandbox`, parsing all string fields, running per-field validation, + /// and verifying cross-section invariants via `Sandbox::validate`. + pub fn build(self) -> Result { + let p = self.build_unchecked()?; + p.validate()?; + Ok(p) + } +} + +#[cfg(test)] +mod http_rule_tests { + use super::*; + + // --- HttpRule::parse tests --- + + #[test] + fn parse_basic_get() { + let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap(); + assert_eq!(rule.method, "GET"); + assert_eq!(rule.host, "api.example.com"); + assert_eq!(rule.path, "/v1/*"); + } + + #[test] + fn parse_wildcard_method_and_host() { + let rule = HttpRule::parse("* */admin/*").unwrap(); + assert_eq!(rule.method, "*"); + assert_eq!(rule.host, "*"); + assert_eq!(rule.path, "/admin/*"); + } + + #[test] + fn parse_post_with_exact_path() { + let rule = HttpRule::parse("POST example.com/upload").unwrap(); + assert_eq!(rule.method, "POST"); + assert_eq!(rule.host, "example.com"); + assert_eq!(rule.path, "/upload"); + } + + #[test] + fn parse_no_path_defaults_to_wildcard() { + let rule = HttpRule::parse("GET example.com").unwrap(); + assert_eq!(rule.method, "GET"); + assert_eq!(rule.host, "example.com"); + assert_eq!(rule.path, "/*"); + } + + #[test] + fn parse_method_uppercased() { + let rule = HttpRule::parse("get example.com/foo").unwrap(); + assert_eq!(rule.method, "GET"); + } + + #[test] + fn parse_error_no_space() { + assert!(HttpRule::parse("GETexample.com").is_err()); + } + + #[test] + fn parse_error_empty_host() { + assert!(HttpRule::parse("GET ").is_err()); + } + + // --- prefix_or_exact_match tests --- + + #[test] + fn prefix_or_exact_match_wildcard_all() { + assert!(prefix_or_exact_match("/*", "/anything")); + assert!(prefix_or_exact_match("*", "/anything")); + assert!(prefix_or_exact_match("/*", "/")); + } + + #[test] + fn prefix_or_exact_match_prefix() { + assert!(prefix_or_exact_match("/v1/*", "/v1/foo")); + assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar")); + assert!(prefix_or_exact_match("/v1/*", "/v1/")); + assert!(!prefix_or_exact_match("/v1/*", "/v2/foo")); + } + + #[test] + fn prefix_or_exact_match_exact() { + assert!(prefix_or_exact_match("/v1/models", "/v1/models")); + assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra")); + assert!(!prefix_or_exact_match("/v1/models", "/v1/model")); + } + + // --- HttpRule::matches tests --- + + #[test] + fn matches_exact() { + let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap(); + assert!(rule.matches("GET", "api.example.com", "/v1/models")); + assert!(!rule.matches("POST", "api.example.com", "/v1/models")); + assert!(!rule.matches("GET", "other.com", "/v1/models")); + assert!(!rule.matches("GET", "api.example.com", "/v1/other")); + } + + #[test] + fn matches_wildcard_method() { + let rule = HttpRule::parse("* api.example.com/v1/*").unwrap(); + assert!(rule.matches("GET", "api.example.com", "/v1/foo")); + assert!(rule.matches("POST", "api.example.com", "/v1/bar")); + } + + #[test] + fn matches_wildcard_host() { + let rule = HttpRule::parse("GET */v1/*").unwrap(); + assert!(rule.matches("GET", "any.host.com", "/v1/foo")); + } + + #[test] + fn matches_case_insensitive_method() { + let rule = HttpRule::parse("GET example.com/foo").unwrap(); + assert!(rule.matches("get", "example.com", "/foo")); + assert!(rule.matches("Get", "example.com", "/foo")); + } + + #[test] + fn matches_case_insensitive_host() { + let rule = HttpRule::parse("GET Example.COM/foo").unwrap(); + assert!(rule.matches("GET", "example.com", "/foo")); + } + + // --- http_acl_check tests --- + + #[test] + fn acl_no_rules_allows_all() { + assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo")); + } + + #[test] + fn acl_allow_only_permits_matching() { + let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; + assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo")); + assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo")); + assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo")); + } + + #[test] + fn acl_deny_only_blocks_matching() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings")); + assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page")); + } + + #[test] + fn acl_deny_takes_precedence_over_allow() { + let allow = vec![HttpRule::parse("* example.com/*").unwrap()]; + let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()]; + assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public")); + assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings")); + } + + #[test] + fn acl_allow_deny_by_default_when_no_match() { + let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()]; + // Different host, not matched by allow -> denied + assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo")); + } + + // --- SandboxBuilder integration --- + + #[test] + fn builder_http_rules() { + let policy = Sandbox::builder() + .http_allow("GET api.example.com/v1/*") + .http_deny("* */admin/*") + .build() + .unwrap(); + assert_eq!(policy.http_allow.len(), 1); + assert_eq!(policy.http_deny.len(), 1); + assert_eq!(policy.http_allow[0].method, "GET"); + assert_eq!(policy.http_deny[0].host, "*"); + } + + #[test] + fn builder_invalid_http_allow_returns_error() { + let result = Sandbox::builder() + .http_allow("GETexample.com") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_invalid_http_deny_returns_error() { + let result = Sandbox::builder() + .http_deny("BADRULE") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_http_ca_without_key_returns_error() { + let result = Sandbox::builder() + .http_ca("/tmp/ca.pem") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_http_key_without_ca_returns_error() { + let result = Sandbox::builder() + .http_key("/tmp/key.pem") + .build(); + assert!(result.is_err()); + } + + #[test] + fn builder_http_ca_and_key_together_ok() { + let policy = Sandbox::builder() + .http_ca("/tmp/ca.pem") + .http_key("/tmp/key.pem") + .build() + .unwrap(); + assert!(policy.http_ca.is_some()); + assert!(policy.http_key.is_some()); + } + + #[test] + fn allows_sysv_ipc_reads_extra_allow_syscalls() { + let p = Sandbox::builder() + .extra_allow_syscalls(vec!["sysv_ipc".into()]) + .build() + .unwrap(); + assert!(p.allows_sysv_ipc()); + + let p2 = Sandbox::builder().build().unwrap(); + assert!(!p2.allows_sysv_ipc()); + + let p3 = Sandbox::builder() + .extra_allow_syscalls(vec!["other_group".into()]) + .build() + .unwrap(); + assert!(!p3.allows_sysv_ipc()); + } + + // --- normalize_path tests --- + + #[test] + fn normalize_path_basic() { + assert_eq!(normalize_path("/foo/bar"), "/foo/bar"); + assert_eq!(normalize_path("/"), "/"); + } + + #[test] + fn normalize_path_double_slashes() { + assert_eq!(normalize_path("/foo//bar"), "/foo/bar"); + assert_eq!(normalize_path("//foo///bar//"), "/foo/bar"); + } + + #[test] + fn normalize_path_dot_segments() { + assert_eq!(normalize_path("/foo/./bar"), "/foo/bar"); + assert_eq!(normalize_path("/foo/../bar"), "/bar"); + assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz"); + } + + #[test] + fn normalize_path_dotdot_at_root() { + assert_eq!(normalize_path("/../foo"), "/foo"); + assert_eq!(normalize_path("/../../foo"), "/foo"); + } + + #[test] + fn normalize_path_percent_encoding() { + // %2F = /, %61 = a + assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar"); + assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings"); + } + + #[test] + fn normalize_path_mixed_bypass_attempts() { + // Double-encoded traversal + assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings"); + assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings"); + assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings"); + assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin"); + } + + // --- ACL bypass prevention tests --- + + #[test] + fn acl_deny_prevents_double_slash_bypass() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + // These should all be caught by the deny rule + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings")); + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings")); + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings")); + } + + #[test] + fn acl_deny_prevents_dot_segment_bypass() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings")); + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings")); + } + + #[test] + fn acl_deny_prevents_percent_encoding_bypass() { + let deny = vec![HttpRule::parse("* */admin/*").unwrap()]; + // %61dmin = admin + assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings")); + } + + #[test] + fn acl_allow_normalized_path_still_works() { + let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()]; + assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models")); + assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models")); + assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models")); + // These resolve to different paths and should be denied + assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra")); + assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models")); + } + + #[test] + fn parse_normalizes_rule_path() { + let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap(); + assert_eq!(rule.path, "/v1/models/*"); + + let rule = HttpRule::parse("GET example.com/v1//models").unwrap(); + assert_eq!(rule.path, "/v1/models"); + } + + // --- NetAllow::parse tests --- + + #[test] + fn netallow_parse_concrete_host_port() { + let r = NetAllow::parse("example.com:443").unwrap(); + assert_eq!(r.host.as_deref(), Some("example.com")); + assert_eq!(r.ports, vec![443]); + assert!(!r.all_ports); + } + + #[test] + fn netallow_parse_any_host_port() { + let r = NetAllow::parse(":8080").unwrap(); + assert_eq!(r.host, None); + assert_eq!(r.ports, vec![8080]); + assert!(!r.all_ports); + + let r = NetAllow::parse("*:8080").unwrap(); + assert_eq!(r.host, None); + assert_eq!(r.ports, vec![8080]); + assert!(!r.all_ports); + } + + #[test] + fn netallow_parse_multiple_ports() { + let r = NetAllow::parse("github.com:22,80,443").unwrap(); + assert_eq!(r.host.as_deref(), Some("github.com")); + assert_eq!(r.ports, vec![22, 80, 443]); + assert!(!r.all_ports); + } + + #[test] + fn netallow_parse_wildcard_any_host_any_port_colon() { + let r = NetAllow::parse(":*").unwrap(); + assert_eq!(r.host, None); + assert!(r.ports.is_empty()); + assert!(r.all_ports); + } + + #[test] + fn netallow_parse_wildcard_any_host_any_port_star() { + let r = NetAllow::parse("*:*").unwrap(); + assert_eq!(r.host, None); + assert!(r.ports.is_empty()); + assert!(r.all_ports); + } + + #[test] + fn netallow_parse_wildcard_concrete_host_any_port() { + let r = NetAllow::parse("example.com:*").unwrap(); + assert_eq!(r.host.as_deref(), Some("example.com")); + assert!(r.ports.is_empty()); + assert!(r.all_ports); + } + + #[test] + fn netallow_parse_rejects_mixed_wildcard_and_concrete() { + // `host:80,*` and `host:*,80` are both ambiguous: the user + // either meant "any port" (wildcard wins) or "ports 80 plus + // some weird placeholder". Refuse and force a clean spec. + let err = NetAllow::parse("example.com:80,*").unwrap_err(); + assert!(format!("{}", err).contains("cannot mix")); + let err = NetAllow::parse("example.com:*,80").unwrap_err(); + assert!(format!("{}", err).contains("cannot mix")); + } + + #[test] + fn netallow_parse_rejects_port_zero() { + let err = NetAllow::parse("example.com:0").unwrap_err(); + assert!(format!("{}", err).contains("port 0")); + } + + #[test] + fn netallow_parse_rejects_empty_port() { + let err = NetAllow::parse("example.com:").unwrap_err(); + assert!(format!("{}", err).contains("invalid port")); + } + + #[test] + fn netallow_parse_rejects_no_colon() { + let err = NetAllow::parse("example.com").unwrap_err(); + assert!(format!("{}", err).contains("expected")); + } + + #[test] + fn netallow_parse_repeated_wildcard_is_idempotent() { + // `*,*` collapses to a single wildcard — neither token contributes + // a concrete port, so the rule remains "any port". + let r = NetAllow::parse(":*,*").unwrap(); + assert!(r.all_ports); + assert!(r.ports.is_empty()); + } + + // --- Protocol scheme prefix tests --- + + #[test] + fn netallow_bare_form_defaults_to_tcp() { + let r = NetAllow::parse("example.com:443").unwrap(); + assert_eq!(r.protocol, Protocol::Tcp); + } + + #[test] + fn netallow_explicit_tcp_scheme() { + let r = NetAllow::parse("tcp://example.com:443").unwrap(); + assert_eq!(r.protocol, Protocol::Tcp); + assert_eq!(r.host.as_deref(), Some("example.com")); + assert_eq!(r.ports, vec![443]); + } + + #[test] + fn netallow_udp_scheme_with_host_port() { + let r = NetAllow::parse("udp://1.1.1.1:53").unwrap(); + assert_eq!(r.protocol, Protocol::Udp); + assert_eq!(r.host.as_deref(), Some("1.1.1.1")); + assert_eq!(r.ports, vec![53]); + } + + #[test] + fn netallow_udp_wildcard_any_anywhere() { + // The "any UDP" gate, equivalent to the old `allow_udp = true`. + let r = NetAllow::parse("udp://*:*").unwrap(); + assert_eq!(r.protocol, Protocol::Udp); + assert_eq!(r.host, None); + assert!(r.all_ports); + } + + #[test] + fn netallow_icmp_scheme_with_host() { + let r = NetAllow::parse("icmp://github.com").unwrap(); + assert_eq!(r.protocol, Protocol::Icmp); + assert_eq!(r.host.as_deref(), Some("github.com")); + assert!(r.ports.is_empty()); + assert!(!r.all_ports); + } + + #[test] + fn netallow_icmp_wildcard() { + // The "any ICMP echo" gate, equivalent to the old + // `allow_icmp = true` for the SOCK_DGRAM path. + let r = NetAllow::parse("icmp://*").unwrap(); + assert_eq!(r.protocol, Protocol::Icmp); + assert_eq!(r.host, None); + } + + #[test] + fn netallow_icmp_rejects_port() { + // ICMP has no port — `:port` is meaningless and refused + // explicitly so users can't write a rule that doesn't do what + // they think. + let err = NetAllow::parse("icmp://github.com:80").unwrap_err(); + assert!(format!("{}", err).contains("icmp rules take no port")); + } + + #[test] + fn netallow_icmp_rejects_empty_body() { + let err = NetAllow::parse("icmp://").unwrap_err(); + assert!(format!("{}", err).contains("needs a host or `*`")); + } + + #[test] + fn netallow_unknown_scheme_rejected() { + // Including `icmp-raw` — sandlock does not expose raw ICMP, so + // the scheme is unknown rather than a special-case error. + for spec in ["sctp://host:1234", "icmp-raw://*"] { + let err = NetAllow::parse(spec).unwrap_err(); + assert!(format!("{}", err).contains("unknown scheme"), "spec: {}", spec); + } + } +} diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index dd6a42e..824efab 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -160,7 +160,7 @@ pub enum HandlerError { /// surface it to the end user. pub(crate) fn validate_handler_syscalls_against_policy( syscall_nrs: &[i64], - policy: &crate::policy::Policy, + policy: &crate::sandbox::Sandbox, ) -> Result<(), i64> { let blocklist: std::collections::HashSet = crate::context::blocklist_syscall_numbers(policy).into_iter().collect(); @@ -1172,7 +1172,7 @@ mod extra_handler_tests { /// block. /// /// Uses `mremap` because it is in `syscall_name_to_nr` but not in - /// `DEFAULT_BLOCKLIST_SYSCALLS` — putting it into `block_syscalls` is the only + /// `DEFAULT_BLOCKLIST_SYSCALLS` — putting it into `extra_deny_syscalls` is the only /// way it ends up on the extra blocklist, so the test isolates the user-supplied /// path of `blocklist_syscall_numbers` from the default branch covered by /// `extra_handler_on_default_blocklist_syscall_is_rejected`. @@ -1182,8 +1182,8 @@ mod extra_handler_tests { /// hosts where seccomp integration tests are skipped. #[test] fn validate_extras_rejects_user_specified_blocklist() { - let policy = crate::policy::Policy::builder() - .block_syscalls(vec!["mremap".into()]) + let policy = crate::sandbox::Sandbox::builder() + .extra_deny_syscalls(vec!["mremap".into()]) .build() .expect("policy builds"); diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index e3f26f9..9eb1a9a 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -345,10 +345,10 @@ impl NetworkState { pub fn effective_network_policy( &self, pid: u32, - protocol: crate::policy::Protocol, + protocol: crate::sandbox::Protocol, live_policy: Option<&std::sync::Arc>>, ) -> crate::seccomp::notif::NetworkPolicy { - use crate::policy::Protocol; + use crate::sandbox::Protocol; use crate::seccomp::notif::{NetworkPolicy, PortAllow}; let ip_only_allow = |ips: &HashSet| { let per_ip = ips.iter().map(|&ip| (ip, PortAllow::Any)).collect(); diff --git a/crates/sandlock-core/src/sys/structs.rs b/crates/sandlock-core/src/sys/structs.rs index fd8c10a..1acdfc6 100644 --- a/crates/sandlock-core/src/sys/structs.rs +++ b/crates/sandlock-core/src/sys/structs.rs @@ -265,7 +265,7 @@ pub const ECONNREFUSED: i32 = 111; // ============================================================ /// SysV IPC syscalls. Appended to the kernel-level blocklist when -/// `policy.allow_sysv_ipc` is false. Sandlock does not use an IPC +/// `policy.allows_sysv_ipc()` is false. Sandlock does not use an IPC /// namespace, so without these denials two sandboxes on the same host /// share a SysV keyspace and can rendezvous via a well-known key. /// diff --git a/crates/sandlock-core/tests/integration/test_checkpoint.rs b/crates/sandlock-core/tests/integration/test_checkpoint.rs index d606d37..52439a5 100644 --- a/crates/sandlock-core/tests/integration/test_checkpoint.rs +++ b/crates/sandlock-core/tests/integration/test_checkpoint.rs @@ -1,15 +1,15 @@ -use sandlock_core::{Policy, Sandbox, Checkpoint}; +use sandlock_core::{Sandbox, Checkpoint}; /// Test that checkpoint save/load roundtrips correctly. #[tokio::test] async fn test_checkpoint_save_load() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .build().unwrap(); // Start a long-running process - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); // We need to spawn something that stays alive long enough to checkpoint // Use "sleep 60" — we'll kill it after checkpoint sb.spawn(&["sleep", "60"]).await.unwrap(); @@ -52,12 +52,12 @@ async fn test_checkpoint_save_load() { /// Test that checkpoint captures memory maps correctly. #[tokio::test] async fn test_checkpoint_memory_maps() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); sb.spawn(&["sleep", "60"]).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -79,12 +79,12 @@ async fn test_checkpoint_memory_maps() { /// Test that app_state round-trips through save/load. #[tokio::test] async fn test_checkpoint_app_state_roundtrip() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); sb.spawn(&["sleep", "60"]).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -112,12 +112,12 @@ async fn test_checkpoint_app_state_roundtrip() { /// Test that checkpoint without app_state doesn't create app_state.bin. #[tokio::test] async fn test_checkpoint_no_app_state_file() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); sb.spawn(&["sleep", "60"]).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -139,12 +139,12 @@ async fn test_checkpoint_no_app_state_file() { /// Test that process info (pid, cwd, exe) is captured correctly. #[tokio::test] async fn test_checkpoint_process_info() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); sb.spawn(&["sleep", "60"]).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -169,8 +169,8 @@ async fn test_checkpoint_load_nonexistent() { /// Test checkpoint on a non-running sandbox fails gracefully. #[tokio::test] async fn test_checkpoint_not_running() { - let policy = Policy::builder().build().unwrap(); - let sb = Sandbox::new(&policy, Some("test")).unwrap(); + let policy = Sandbox::builder().build().unwrap(); + let sb = policy.clone().with_name("test"); let result = sb.checkpoint().await; assert!(result.is_err(), "Checkpoint on non-running sandbox should error"); } diff --git a/crates/sandlock-core/tests/integration/test_chroot.rs b/crates/sandlock-core/tests/integration/test_chroot.rs index db296a9..62f58ba 100644 --- a/crates/sandlock-core/tests/integration/test_chroot.rs +++ b/crates/sandlock-core/tests/integration/test_chroot.rs @@ -1,6 +1,6 @@ -use sandlock_core::policy::BranchAction; +use sandlock_core::sandbox::BranchAction; #[allow(unused_imports)] -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; @@ -14,8 +14,8 @@ fn helper_binary() -> PathBuf { } /// Minimal fs_readable set needed to run rootfs-helper under chroot. -fn minimal_exec_policy(rootfs: &PathBuf) -> sandlock_core::PolicyBuilder { - Policy::builder() +fn minimal_exec_policy(rootfs: &PathBuf) -> sandlock_core::SandboxBuilder { + Sandbox::builder() .chroot(rootfs) .fs_read("/usr") .fs_read("/bin") @@ -90,7 +90,7 @@ fn cleanup_rootfs(rootfs: &PathBuf) { async fn test_chroot_ls_root() { let rootfs = build_test_rootfs("ls-root"); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -101,7 +101,7 @@ async fn test_chroot_ls_root() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "ls", "/"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "ls", "/"]).await; match result { Ok(r) => { assert!( @@ -130,7 +130,7 @@ async fn test_chroot_no_escape() { let sentinel = "sandlock-chroot-sentinel"; fs::write(rootfs.join("etc/sentinel"), sentinel).unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -142,7 +142,7 @@ async fn test_chroot_no_escape() { // Path traversal: /../../etc/sentinel should resolve to /etc/sentinel inside // the chroot (the sentinel file we created), not escape to the host. - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "cat", "/../../etc/sentinel"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "cat", "/../../etc/sentinel"]).await; match result { Ok(r) => { assert!( @@ -169,7 +169,7 @@ async fn test_chroot_no_escape() { async fn test_chroot_getcwd() { let rootfs = build_test_rootfs("getcwd"); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -179,7 +179,7 @@ async fn test_chroot_getcwd() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "pwd"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "pwd"]).await; match result { Ok(r) => { assert!( @@ -201,7 +201,7 @@ async fn test_chroot_getcwd() { async fn test_chroot_write_file() { let rootfs = build_test_rootfs("write-file"); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -212,9 +212,7 @@ async fn test_chroot_write_file() { .build() .unwrap(); - let result = Sandbox::run( - &policy, Some("test"), - &["rootfs-helper", "sh", "-c", "echo hello > /tmp/test.txt && cat /tmp/test.txt"], + let result = policy.clone().with_name("test").run(&["rootfs-helper", "sh", "-c", "echo hello > /tmp/test.txt && cat /tmp/test.txt"], ) .await; match result { @@ -254,7 +252,7 @@ async fn test_chroot_cow_directory_open_stays_in_rootfs() { )); fs::write(&host_marker, "host").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -267,7 +265,7 @@ async fn test_chroot_cow_directory_open_stays_in_rootfs() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "ls", "/tmp"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "ls", "/tmp"]).await; match result { Ok(r) => { assert!( @@ -300,7 +298,7 @@ async fn test_chroot_with_cow() { let rootfs = build_test_rootfs("cow"); let tmp_dir = rootfs.join("tmp"); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -313,9 +311,7 @@ async fn test_chroot_with_cow() { .build() .unwrap(); - let result = Sandbox::run( - &policy, Some("test"), - &["rootfs-helper", "sh", "-c", "echo cow-test > /tmp/cow.txt"], + let result = policy.clone().with_name("test").run(&["rootfs-helper", "sh", "-c", "echo cow-test > /tmp/cow.txt"], ) .await; match result { @@ -344,7 +340,7 @@ async fn test_chroot_with_cow() { async fn test_chroot_proc_self_root() { let rootfs = build_test_rootfs("proc-self-root"); - let policy = Policy::builder() + let policy = Sandbox::builder() .chroot(&rootfs) .fs_read("/usr") .fs_read("/bin") @@ -354,7 +350,7 @@ async fn test_chroot_proc_self_root() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "readlink", "/proc/self/root"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "readlink", "/proc/self/root"]).await; match result { Ok(r) => { assert!( @@ -387,9 +383,7 @@ async fn test_chroot_write_denied_without_fs_write() { .build() .unwrap(); - let result = Sandbox::run( - &policy, Some("test"), - &["rootfs-helper", "sh", "-c", "echo denied > /tmp/should-fail.txt"], + let result = policy.clone().with_name("test").run(&["rootfs-helper", "sh", "-c", "echo denied > /tmp/should-fail.txt"], ) .await; match result { @@ -419,7 +413,7 @@ async fn test_chroot_exec_with_root_readable() { .unwrap(); // Use /bin/rootfs-helper which goes through the bin -> usr/bin symlink - let result = Sandbox::run(&policy, Some("test"), &["/bin/rootfs-helper", "echo", "chroot-exec-ok"]).await; + let result = policy.clone().with_name("test").run(&["/bin/rootfs-helper", "echo", "chroot-exec-ok"]).await; match result { Ok(r) => { assert!( @@ -453,7 +447,7 @@ async fn test_chroot_fs_deny_blocks_virtual_path() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "cat", "/etc/hostname"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "cat", "/etc/hostname"]).await; match result { Ok(r) => { assert!( @@ -482,7 +476,7 @@ async fn test_chroot_read_denied_without_fs_read() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "cat", "/etc/hostname"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "cat", "/etc/hostname"]).await; match result { Ok(r) => { assert!( @@ -521,7 +515,7 @@ async fn test_fs_mount_read_write() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["rootfs-helper", "cat", "/work/input.txt"]).await; + let result = policy.clone().with_name("test").run(&["rootfs-helper", "cat", "/work/input.txt"]).await; match result { Ok(r) => { assert!( diff --git a/crates/sandlock-core/tests/integration/test_cow.rs b/crates/sandlock-core/tests/integration/test_cow.rs index ccd1344..b0a18d5 100644 --- a/crates/sandlock-core/tests/integration/test_cow.rs +++ b/crates/sandlock-core/tests/integration/test_cow.rs @@ -1,5 +1,5 @@ -use sandlock_core::{Policy, Sandbox}; -use sandlock_core::policy::{FsIsolation, BranchAction}; +use sandlock_core::{Sandbox}; +use sandlock_core::sandbox::{FsIsolation, BranchAction}; use std::fs; use std::path::PathBuf; @@ -16,7 +16,7 @@ async fn test_overlayfs_basic_commands() { let storage = temp_dir("basic-storage"); fs::write(workdir.join("hello.txt"), "original").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .fs_write(&workdir) @@ -26,7 +26,7 @@ async fn test_overlayfs_basic_commands() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["cat", "hello.txt"]).await; + let result = policy.clone().with_name("test").run(&["cat", "hello.txt"]).await; // May fail on systems without unprivileged overlayfs support match result { Ok(r) => assert!(r.success(), "cat should succeed"), @@ -44,7 +44,7 @@ async fn test_overlayfs_write_isolation() { let storage = temp_dir("isolation-storage"); fs::write(workdir.join("data.txt"), "original").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .fs_write(&workdir) @@ -57,7 +57,7 @@ async fn test_overlayfs_write_isolation() { .unwrap(); // Write to a file inside the sandbox - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", "echo modified > data.txt"]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", "echo modified > data.txt"]).await; match result { Ok(_r) => { // Original file should still say "original" (COW aborted) @@ -78,7 +78,7 @@ async fn test_overlayfs_commit() { let storage = temp_dir("commit-storage"); fs::write(workdir.join("data.txt"), "original").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .fs_write(&workdir) @@ -89,7 +89,7 @@ async fn test_overlayfs_commit() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", "echo committed > data.txt"]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", "echo committed > data.txt"]).await; match result { Ok(r) => { if r.success() { @@ -107,7 +107,7 @@ async fn test_overlayfs_commit() { /// Test that policy validation catches missing workdir. #[tokio::test] async fn test_cow_requires_workdir() { - let result = Policy::builder() + let result = Sandbox::builder() .fs_isolation(FsIsolation::OverlayFs) .build(); assert!(result.is_err(), "Should fail without workdir"); @@ -123,7 +123,7 @@ async fn test_seccomp_cow_create_file() { let workdir = temp_dir("seccomp-create"); fs::write(workdir.join("existing.txt"), "hello").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .fs_write(&workdir) @@ -134,7 +134,7 @@ async fn test_seccomp_cow_create_file() { let new_file = workdir.join("new.txt"); let cmd = format!("touch {}", new_file.display()); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await; match result { Ok(r) => { assert!(r.success(), "touch should succeed, stderr: {}", r.stderr_str().unwrap_or("")); @@ -153,7 +153,7 @@ async fn test_seccomp_cow_abort() { let workdir = temp_dir("seccomp-abort"); fs::write(workdir.join("existing.txt"), "original").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .fs_write(&workdir) @@ -164,7 +164,7 @@ async fn test_seccomp_cow_abort() { let new_file = workdir.join("aborted.txt"); let cmd = format!("touch {}", new_file.display()); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await; match result { Ok(_) => { // After abort, new file should NOT exist @@ -190,7 +190,7 @@ async fn test_seccomp_cow_relative_path_abort() { let workdir = temp_dir("seccomp-relpath"); fs::write(workdir.join("orig.txt"), "original\n").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -201,7 +201,7 @@ async fn test_seccomp_cow_relative_path_abort() { .unwrap(); // Use relative paths (triggers AT_FDCWD in openat) — the child's cwd is set via .cwd(). - let result = Sandbox::run(&policy, Some("test"), &[ + let result = policy.clone().with_name("test").run(&[ "sh", "-c", "echo MUTATED >> orig.txt; echo leak > leaked.txt" ]).await; match result { @@ -225,7 +225,7 @@ async fn test_seccomp_cow_relative_path_commit() { let workdir = temp_dir("seccomp-relpath-commit"); fs::write(workdir.join("orig.txt"), "original\n").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -235,7 +235,7 @@ async fn test_seccomp_cow_relative_path_commit() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &[ + let result = policy.clone().with_name("test").run(&[ "sh", "-c", "echo APPENDED >> orig.txt; echo new > created.txt" ]).await; match result { @@ -265,7 +265,7 @@ async fn test_seccomp_cow_open_directory() { let workdir = temp_dir("seccomp-opendir"); let out_file = workdir.join("opendir_ok.txt"); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -286,7 +286,7 @@ async fn test_seccomp_cow_open_directory() { ), out_file.display() ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &script]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", &script]).await; match result { Ok(r) => { assert!(r.success(), "script should succeed, stderr: {}", r.stderr_str().unwrap_or("")); @@ -309,7 +309,7 @@ async fn test_seccomp_cow_chdir_to_created_dir() { let workdir = temp_dir("seccomp-chdir"); let out_file = workdir.join("chdir_ok.txt"); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -333,7 +333,7 @@ async fn test_seccomp_cow_chdir_to_created_dir() { ), out_file.display() ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &script]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", &script]).await; match result { Ok(r) => { assert!(r.success(), "script should succeed, stderr: {}", r.stderr_str().unwrap_or("")); @@ -364,7 +364,7 @@ async fn test_seccomp_cow_legacy_open_syscall() { "sandlock-test-legacy-open-{}", std::process::id() )); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir).fs_write("/tmp") @@ -396,7 +396,7 @@ async fn test_seccomp_cow_legacy_open_syscall() { " open('{out}', 'w').write(f'FAILED:errno={{err}}')\n", ), wd = workdir.display(), out = out_file.display()); - let result = Sandbox::run(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}, stderr={}", result.code(), result.stderr_str().unwrap_or("")); let content = fs::read_to_string(&out_file).unwrap_or_default(); assert_eq!(content, "created via raw open", "raw open ABI should work with COW"); @@ -422,7 +422,7 @@ async fn test_seccomp_cow_excl_after_unlink() { )); fs::write(workdir.join("target.txt"), "original").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir).fs_write("/tmp") @@ -455,7 +455,7 @@ async fn test_seccomp_cow_excl_after_unlink() { " open('{out}', 'w').write(f'OPEN_FAILED:{{err}}')\n", ), wd = workdir.display(), out = out_file.display()); - let result = Sandbox::run(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}, stderr={}", result.code(), result.stderr_str().unwrap_or("")); let content = fs::read_to_string(&out_file).unwrap_or_default(); assert_eq!(content, "OK", "O_EXCL after unlink should succeed, got: {}", content); @@ -473,7 +473,7 @@ async fn test_seccomp_cow_read_existing() { let workdir = temp_dir("seccomp-read"); fs::write(workdir.join("data.txt"), "hello world").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc") .fs_write(&workdir) @@ -488,7 +488,7 @@ async fn test_seccomp_cow_read_existing() { workdir.join("data.txt").display(), out_file.display() ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await; + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await; match result { Ok(r) => { assert!(r.success(), "cat should succeed"); diff --git a/crates/sandlock-core/tests/integration/test_determinism.rs b/crates/sandlock-core/tests/integration/test_determinism.rs index aef4960..ae20c26 100644 --- a/crates/sandlock-core/tests/integration/test_determinism.rs +++ b/crates/sandlock-core/tests/integration/test_determinism.rs @@ -1,11 +1,11 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::time::{Duration, SystemTime}; /// Test that random_seed produces deterministic output from /dev/urandom. /// Run the same command twice with the same seed — reads should match. #[tokio::test] async fn test_random_seed_deterministic() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -18,10 +18,10 @@ async fn test_random_seed_deterministic() { .unwrap(); // Read 16 bytes from /dev/urandom via od — exercises the openat interception path. - let r1 = Sandbox::run(&policy, Some("test"), &["sh", "-c", "od -A n -N 16 -t x1 /dev/urandom"]) + let r1 = policy.clone().with_name("test").run(&["sh", "-c", "od -A n -N 16 -t x1 /dev/urandom"]) .await .unwrap(); - let r2 = Sandbox::run(&policy, Some("test"), &["sh", "-c", "od -A n -N 16 -t x1 /dev/urandom"]) + let r2 = policy.clone().with_name("test").run(&["sh", "-c", "od -A n -N 16 -t x1 /dev/urandom"]) .await .unwrap(); @@ -44,7 +44,7 @@ async fn test_random_seed_deterministic() { /// Test that different seeds produce different /dev/urandom output. #[tokio::test] async fn test_random_seed_different_seeds() { - let p1 = Policy::builder() + let p1 = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -54,7 +54,7 @@ async fn test_random_seed_different_seeds() { .random_seed(1) .build() .unwrap(); - let p2 = Policy::builder() + let p2 = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -66,8 +66,8 @@ async fn test_random_seed_different_seeds() { .unwrap(); let cmd = &["sh", "-c", "od -A n -N 16 -t x1 /dev/urandom"]; - let r1 = Sandbox::run(&p1, Some("test"), cmd).await.unwrap(); - let r2 = Sandbox::run(&p2, Some("test"), cmd).await.unwrap(); + let r1 = p1.clone().with_name("test").run(cmd).await.unwrap(); + let r2 = p2.clone().with_name("test").run(cmd).await.unwrap(); assert!(r1.success()); assert!(r2.success()); @@ -90,7 +90,7 @@ async fn test_random_seed_different_seeds() { async fn test_time_start_frozen() { // Freeze to 2000-06-15T00:00:00Z (mid-year avoids timezone boundary issues) let y2k = SystemTime::UNIX_EPOCH + Duration::from_secs(961027200); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -101,7 +101,7 @@ async fn test_time_start_frozen() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["date", "+%Y"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["date", "+%Y"]).await.unwrap(); assert!(result.success(), "date command failed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "2000", "Expected year 2000, got: {:?}", stdout.trim()); @@ -111,7 +111,7 @@ async fn test_time_start_frozen() { #[tokio::test] async fn test_time_start_basic_commands_work() { let past = SystemTime::UNIX_EPOCH + Duration::from_secs(1000000000); // 2001-09-09 - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -121,7 +121,7 @@ async fn test_time_start_basic_commands_work() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["echo", "hello"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["echo", "hello"]).await.unwrap(); assert!(result.success()); } @@ -129,7 +129,7 @@ async fn test_time_start_basic_commands_work() { #[tokio::test] async fn test_combined_determinism() { let past = SystemTime::UNIX_EPOCH + Duration::from_secs(946684800); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -141,7 +141,7 @@ async fn test_combined_determinism() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["echo", "deterministic"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["echo", "deterministic"]).await.unwrap(); assert!(result.success()); } @@ -149,7 +149,7 @@ async fn test_combined_determinism() { /// Run directory iteration twice — output should match and be sorted. #[tokio::test] async fn test_deterministic_dirs() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -164,8 +164,8 @@ async fn test_deterministic_dirs() { // the sandbox's getdents virtualization. Some minimal ls implementations // do not support `-f`, so avoid depending on ls option support here. let scan = "python3 - <<'PY'\nimport os\nprint('\\n'.join(e.name for e in os.scandir('/etc')))\nPY"; - let r1 = Sandbox::run(&policy, Some("test"), &["sh", "-c", scan]).await.unwrap(); - let r2 = Sandbox::run(&policy, Some("test"), &["sh", "-c", scan]).await.unwrap(); + let r1 = policy.clone().with_name("test").run(&["sh", "-c", scan]).await.unwrap(); + let r2 = policy.clone().with_name("test").run(&["sh", "-c", scan]).await.unwrap(); assert!( r1.success(), "First directory scan failed: {}", @@ -201,7 +201,7 @@ async fn test_deterministic_dirs() { /// Test that hostname virtualizes both uname() and /etc/hostname. #[tokio::test] async fn test_hostname_virtualization() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -211,13 +211,13 @@ async fn test_hostname_virtualization() { .unwrap(); // Verify uname() returns the virtual hostname. - let result = Sandbox::run(&policy, Some("mybox"), &["hostname"]).await.unwrap(); + let result = policy.clone().with_name("mybox").run(&["hostname"]).await.unwrap(); assert!(result.success(), "hostname command failed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "mybox", "Expected hostname 'mybox', got: {:?}", stdout.trim()); // Verify /etc/hostname also returns the virtual hostname. - let result = Sandbox::run(&policy, Some("mybox"), &["cat", "/etc/hostname"]).await.unwrap(); + let result = policy.clone().with_name("mybox").run(&["cat", "/etc/hostname"]).await.unwrap(); assert!(result.success(), "cat /etc/hostname failed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "mybox", "Expected /etc/hostname 'mybox', got: {:?}", stdout.trim()); diff --git a/crates/sandlock-core/tests/integration/test_dry_run.rs b/crates/sandlock-core/tests/integration/test_dry_run.rs index 1f812b0..b3d00f2 100644 --- a/crates/sandlock-core/tests/integration/test_dry_run.rs +++ b/crates/sandlock-core/tests/integration/test_dry_run.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use sandlock_core::dry_run::ChangeKind; use std::fs; use std::path::PathBuf; @@ -14,7 +14,7 @@ async fn test_dry_run_reports_added_file() { let workdir = temp_dir("add"); fs::write(workdir.join("existing.txt"), "hello").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -24,7 +24,7 @@ async fn test_dry_run_reports_added_file() { let new_file = workdir.join("new.txt"); let cmd = format!("echo created > {}", new_file.display()); - let result = Sandbox::dry_run(&policy, Some("test"), &["sh", "-c", &cmd]).await; + let result = policy.clone().with_name("test").dry_run(&["sh", "-c", &cmd]).await; match result { Ok(dr) => { assert!(dr.run_result.success()); @@ -45,7 +45,7 @@ async fn test_dry_run_reports_modified_file() { let workdir = temp_dir("modify"); fs::write(workdir.join("data.txt"), "original").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -54,7 +54,7 @@ async fn test_dry_run_reports_modified_file() { .unwrap(); let cmd = format!("echo modified > {}/data.txt", workdir.display()); - let result = Sandbox::dry_run(&policy, Some("test"), &["sh", "-c", &cmd]).await; + let result = policy.clone().with_name("test").dry_run(&["sh", "-c", &cmd]).await; match result { Ok(dr) => { assert!(dr.run_result.success()); @@ -76,7 +76,7 @@ async fn test_dry_run_reports_deleted_file() { let workdir = temp_dir("delete"); fs::write(workdir.join("victim.txt"), "delete me").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/etc") .fs_read("/proc").fs_read("/dev") .fs_write(&workdir) @@ -85,7 +85,7 @@ async fn test_dry_run_reports_deleted_file() { .unwrap(); let cmd = format!("rm {}/victim.txt", workdir.display()); - let result = Sandbox::dry_run(&policy, Some("test"), &["sh", "-c", &cmd]).await; + let result = policy.clone().with_name("test").dry_run(&["sh", "-c", &cmd]).await; match result { Ok(dr) => { assert!(dr.run_result.success()); diff --git a/crates/sandlock-core/tests/integration/test_extra_handlers.rs b/crates/sandlock-core/tests/integration/test_extra_handlers.rs index 8e84e56..9678c40 100644 --- a/crates/sandlock-core/tests/integration/test_extra_handlers.rs +++ b/crates/sandlock-core/tests/integration/test_extra_handlers.rs @@ -29,7 +29,7 @@ use std::sync::{Arc, Mutex}; use sandlock_core::seccomp::notif::NotifAction; use sandlock_core::{ - Handler, HandlerCtx, HandlerError, Policy, Sandbox, SandlockError, SyscallError, + Handler, HandlerCtx, HandlerError, Sandbox, SandlockError, SyscallError, }; /// Read a NUL-terminated path from the sandboxed child's address space. @@ -60,7 +60,7 @@ fn read_path_from_child(pid: u32, addr: u64) -> Option { String::from_utf8(buf[..nul].to_vec()).ok() } -fn base_policy() -> sandlock_core::PolicyBuilder { +fn base_policy() -> sandlock_core::SandboxBuilder { // `fs_read_if_exists` for `/lib64` because aarch64 hosts (Ubuntu CI // arm64 runner) do not have it — the dynamic linker lives under // `/lib/aarch64-linux-gnu/`. A strict `fs_read` here makes Landlock @@ -68,7 +68,7 @@ fn base_policy() -> sandlock_core::PolicyBuilder { // confinement, surfacing as `pipe closed before 4 bytes read` // in the parent. Mirrors the convention used in upstream // `test_dry_run`, `test_fork`, `test_netlink_virt`, `test_landlock`. - Policy::builder() + Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -109,10 +109,7 @@ async fn extra_handler_intercepts_syscall_outside_builtin_set() { } }; - let result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + let result = policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_getcwd, handler)], ) .await @@ -152,10 +149,7 @@ async fn extra_handler_continue_lets_syscall_proceed() { } }; - let result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + let result = policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_getcwd, handler)], ) .await @@ -183,9 +177,9 @@ async fn extra_handler_continue_lets_syscall_proceed() { async fn empty_extras_preserves_default_behaviour() { let policy = base_policy().build().unwrap(); - let baseline = Sandbox::run(&policy, None, &["/bin/pwd"]).await.unwrap(); + let baseline = policy.clone().run(&["/bin/pwd"]).await.unwrap(); let no_handlers: [(i64, fn(&HandlerCtx) -> std::future::Ready); 0] = []; - let with_extras = Sandbox::run_with_extra_handlers(&policy, None, &["/bin/pwd"], no_handlers) + let with_extras = policy.clone().run_with_extra_handlers(&["/bin/pwd"], no_handlers) .await .unwrap(); @@ -221,10 +215,7 @@ async fn extra_handler_runs_after_builtin_returns_continue() { } }; - let result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + let result = policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_openat, handler)], ) .await @@ -280,10 +271,7 @@ async fn builtin_non_continue_blocks_extra() { } }; - let _ = Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + let _ = policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_openat, handler)], ) .await @@ -359,10 +347,7 @@ async fn chain_of_extras_runs_in_insertion_order() { action: NotifAction::Errno(libc::EACCES), }; - let result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + let result = policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_getcwd, h1), (libc::SYS_getcwd, h2)], ) .await @@ -400,10 +385,7 @@ async fn extra_handler_on_default_blocklist_syscall_is_rejected() { let policy = base_policy().build().unwrap(); let handler = |_cx: &HandlerCtx| async { NotifAction::Continue }; - let result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["true"], + let result = policy.clone().run_with_extra_handlers(&["true"], [(libc::SYS_mount, handler)], ) .await; @@ -420,7 +402,7 @@ async fn extra_handler_on_default_blocklist_syscall_is_rejected() { ); } -/// User-supplied `block_syscalls` entries must be honoured by the same guard +/// User-supplied `extra_deny_syscalls` entries must be honoured by the same guard /// that protects DEFAULT_BLOCKLIST: an extra registered on a syscall the caller /// explicitly asked to block would otherwise let a `Continue` from the /// handler reach the deny-JEQ via the notif path and bypass the kernel @@ -430,20 +412,17 @@ async fn extra_handler_on_default_blocklist_syscall_is_rejected() { /// driving the user-list branch of `blocklist_syscall_numbers` (see /// `crates/sandlock-core/src/context.rs`). Uses `SYS_mremap` because it is /// in `syscall_name_to_nr` but **not** in DEFAULT_BLOCKLIST — putting it into -/// `block_syscalls` is the only way it lands on the blocklist, isolating the +/// `extra_deny_syscalls` is the only way it lands on the blocklist, isolating the /// user-supplied branch under test from the default-blocklist branch. #[tokio::test] async fn extra_handler_on_user_specified_blocklist_is_rejected() { let policy = base_policy() - .block_syscalls(vec!["mremap".into()]) + .extra_deny_syscalls(vec!["mremap".into()]) .build() .unwrap(); let handler = |_cx: &HandlerCtx| async { NotifAction::Continue }; - let result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["true"], + let result = policy.clone().run_with_extra_handlers(&["true"], [(libc::SYS_mremap, handler)], ) .await; @@ -490,10 +469,7 @@ async fn handler_via_blanket_impl_dispatches_in_sandbox() { } }; - let _result = Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + let _result = policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_getcwd, handler)], ) .await @@ -556,10 +532,7 @@ async fn struct_handler_state_persists_across_sandbox_calls() { let out = temp_out("struct-handler-counter"); let cmd = format!("/bin/pwd; /bin/pwd; echo done > {}", out.display()); - Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_getcwd, handler)], ) .await @@ -583,7 +556,7 @@ async fn run_with_extra_handlers_rejects_negative_syscall() { let handler = |_cx: &HandlerCtx| async { NotifAction::Continue }; let result = - Sandbox::run_with_extra_handlers(&policy, None, &["true"], [(-5i64, handler)]).await; + policy.clone().run_with_extra_handlers(&["true"], [(-5i64, handler)]).await; match result { Err(SandlockError::Handler(HandlerError::InvalidSyscall(SyscallError::Negative(-5)))) => {} @@ -601,7 +574,7 @@ async fn run_with_extra_handlers_rejects_arch_unknown_syscall() { let handler = |_cx: &HandlerCtx| async { NotifAction::Continue }; let result = - Sandbox::run_with_extra_handlers(&policy, None, &["true"], [(99_999i64, handler)]).await; + policy.clone().run_with_extra_handlers(&["true"], [(99_999i64, handler)]).await; match result { Err(SandlockError::Handler(HandlerError::InvalidSyscall( @@ -661,10 +634,7 @@ async fn run_with_extra_handlers_preserves_insertion_order_in_sandbox_chain() { action: NotifAction::Errno(libc::EACCES), }; - Sandbox::run_with_extra_handlers( - &policy, - None, - &["sh", "-c", &cmd], + policy.clone().run_with_extra_handlers(&["sh", "-c", &cmd], [(libc::SYS_getcwd, h1), (libc::SYS_getcwd, h2)], ) .await @@ -688,7 +658,7 @@ async fn run_with_extra_handlers_rejects_handler_on_default_blocklist_syscall() // SYS_mount is in DEFAULT_BLOCKLIST_SYSCALLS. let result = - Sandbox::run_with_extra_handlers(&policy, None, &["true"], [(libc::SYS_mount, handler)]).await; + policy.clone().run_with_extra_handlers(&["true"], [(libc::SYS_mount, handler)]).await; match result { Err(SandlockError::Handler(HandlerError::OnDenySyscall { syscall_nr })) => { diff --git a/crates/sandlock-core/tests/integration/test_fork.rs b/crates/sandlock-core/tests/integration/test_fork.rs index 077da44..4f2c40b 100644 --- a/crates/sandlock-core/tests/integration/test_fork.rs +++ b/crates/sandlock-core/tests/integration/test_fork.rs @@ -1,8 +1,8 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::sync::atomic::{AtomicU32, Ordering}; -fn base_policy() -> Policy { - Policy::builder() +fn base_policy() -> Sandbox { + Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -17,14 +17,12 @@ async fn test_fork_basic() { let _ = std::fs::create_dir_all(&out_dir); let out = out_dir.clone(); - let policy = base_policy(); - let mut sb = Sandbox::new_with_fns( - &policy, Some("test"), - || {}, - move |clone_id| { + let mut sb = base_policy() + .with_name("test") + .with_init_fn(|| {}) + .with_work_fn(move |clone_id| { let _ = std::fs::write(out.join(format!("{}", clone_id)), clone_id.to_string()); - }, - ).unwrap(); + }); let mut clones = sb.fork(4).await.unwrap(); assert_eq!(clones.len(), 4); @@ -54,15 +52,13 @@ async fn test_fork_cow_sharing() { let _ = std::fs::create_dir_all(&out_dir); let out = out_dir.clone(); - let policy = base_policy(); - let mut sb = Sandbox::new_with_fns( - &policy, Some("test"), - || { SHARED.store(42, Ordering::Relaxed); }, - move |clone_id| { + let mut sb = base_policy() + .with_name("test") + .with_init_fn(|| { SHARED.store(42, Ordering::Relaxed); }) + .with_work_fn(move |clone_id| { let val = SHARED.load(Ordering::Relaxed); let _ = std::fs::write(out.join(format!("{}", clone_id)), val.to_string()); - }, - ).unwrap(); + }); let mut clones = sb.fork(3).await.unwrap(); for c in clones.iter_mut() { let _ = c.wait().await; } @@ -82,15 +78,13 @@ async fn test_fork_clone_id_env() { let _ = std::fs::create_dir_all(&out_dir); let out = out_dir.clone(); - let policy = base_policy(); - let mut sb = Sandbox::new_with_fns( - &policy, Some("test"), - || {}, - move |_| { + let mut sb = base_policy() + .with_name("test") + .with_init_fn(|| {}) + .with_work_fn(move |_| { let id = std::env::var("CLONE_ID").unwrap_or_default(); let _ = std::fs::write(out.join(&id), &id); - }, - ).unwrap(); + }); let mut clones = sb.fork(3).await.unwrap(); for c in clones.iter_mut() { let _ = c.wait().await; } @@ -106,24 +100,20 @@ async fn test_fork_clone_id_env() { /// Test map-reduce: clone stdout flows via pipes to reducer stdin. #[tokio::test] async fn test_fork_reduce() { - let map_policy = base_policy(); - let reduce_policy = base_policy(); - // Map: each clone prints its square to stdout (captured via pipe) - let mut mapper = Sandbox::new_with_fns( - &map_policy, Some("test"), - || {}, - |clone_id| { + let mut mapper = base_policy() + .with_name("test") + .with_init_fn(|| {}) + .with_work_fn(|clone_id| { // Write to stdout — goes to per-clone pipe use std::io::Write; let _ = writeln!(std::io::stdout(), "{}", clone_id * clone_id); - }, - ).unwrap(); + }); let mut clones = mapper.fork(4).await.unwrap(); // Reduce: reads all clone pipes, feeds to reducer stdin - let reducer = Sandbox::new(&reduce_policy, Some("test")).unwrap(); + let reducer = base_policy().with_name("test"); let result = reducer.reduce( &["python3", "-c", "import sys; print(sum(int(l) for l in sys.stdin))"], &mut clones, @@ -141,14 +131,12 @@ async fn test_fork_clone_exit_status() { let _ = std::fs::create_dir_all(&out_dir); let out = out_dir.clone(); - let policy = base_policy(); - let mut sb = Sandbox::new_with_fns( - &policy, Some("test"), - || {}, - move |clone_id| { + let mut sb = base_policy() + .with_name("test") + .with_init_fn(|| {}) + .with_work_fn(move |clone_id| { let _ = std::fs::write(out.join(format!("{}", clone_id)), "done"); - }, - ).unwrap(); + }); let mut clones = sb.fork(3).await.unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_http_acl.rs b/crates/sandlock-core/tests/integration/test_http_acl.rs index 97dacc1..301d693 100644 --- a/crates/sandlock-core/tests/integration/test_http_acl.rs +++ b/crates/sandlock-core/tests/integration/test_http_acl.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::io::{BufRead, BufReader, Read as _, Write as _}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; @@ -12,8 +12,8 @@ fn temp_file(name: &str) -> PathBuf { )) } -fn base_policy() -> sandlock_core::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::SandboxBuilder { + Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -137,7 +137,7 @@ async fn test_http_allow_get() { .unwrap(); let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -162,7 +162,7 @@ async fn test_http_deny_non_matching() { .unwrap(); let script = http_script(&format!("http://127.0.0.1:{}/denied", port), &out); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -188,7 +188,7 @@ async fn test_http_deny_precedence() { // GET /public — should succeed let script = http_script(&format!("http://127.0.0.1:{}/public", port), &out_allowed); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -197,7 +197,7 @@ async fn test_http_deny_precedence() { // GET /secret — should be denied let script = http_script(&format!("http://127.0.0.1:{}/secret", port), &out_denied); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -222,7 +222,7 @@ async fn test_http_no_acl_unrestricted() { .unwrap(); let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -248,7 +248,7 @@ async fn test_http_method_filtering() { // GET should succeed let script = http_script(&format!("http://127.0.0.1:{}/anything", port), &out_get); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -257,7 +257,7 @@ async fn test_http_method_filtering() { // POST should be denied let script = post_script(&format!("http://127.0.0.1:{}/anything", port), &out_post); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -285,7 +285,7 @@ async fn test_http_multiple_allow_rules() { // GET /get — should succeed (matches first rule) let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out_get); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -294,7 +294,7 @@ async fn test_http_multiple_allow_rules() { // GET /anything — should be denied (not in allow list) let script = http_script(&format!("http://127.0.0.1:{}/anything", port), &out_other); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -322,7 +322,7 @@ async fn test_http_wildcard_host() { // GET /get — should succeed let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out_get); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -331,7 +331,7 @@ async fn test_http_wildcard_host() { // GET /admin/settings — should be denied let script = http_script(&format!("http://127.0.0.1:{}/admin/settings", port), &out_denied); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -384,7 +384,7 @@ async fn test_http_non_intercepted_port() { port = port, ); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); srv.join().unwrap(); @@ -412,7 +412,7 @@ async fn test_http_acl_ipv6_allow() { .unwrap(); let script = http_script(&format!("http://[::1]:{}/get", port), &out); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -436,7 +436,7 @@ async fn test_http_acl_ipv6_deny() { .unwrap(); let script = http_script(&format!("http://[::1]:{}/denied", port), &out); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -483,7 +483,7 @@ async fn test_http_ipv6_non_intercepted_port() { port = port, ); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); srv.join().unwrap(); @@ -509,7 +509,7 @@ async fn test_http_acl_ipv6_method_filtering() { // GET should succeed let script = http_script(&format!("http://[::1]:{}/anything", port), &out_get); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); @@ -518,7 +518,7 @@ async fn test_http_acl_ipv6_method_filtering() { // POST should be denied let script = post_script(&format!("http://[::1]:{}/anything", port), &out_post); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success()); diff --git a/crates/sandlock-core/tests/integration/test_landlock.rs b/crates/sandlock-core/tests/integration/test_landlock.rs index beb5b46..c0e5286 100644 --- a/crates/sandlock-core/tests/integration/test_landlock.rs +++ b/crates/sandlock-core/tests/integration/test_landlock.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::path::PathBuf; fn temp_file(name: &str) -> PathBuf { @@ -18,7 +18,7 @@ async fn test_can_read_allowed_path() { let out = temp_file("read-allowed-out"); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -32,7 +32,7 @@ async fn test_can_read_allowed_path() { .unwrap(); let cmd_str = format!("cat {} > {}", input.display(), out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["sh", "-c", &cmd_str]) + let result = policy.clone().with_name("test").run_interactive(&["sh", "-c", &cmd_str]) .await .unwrap(); assert!(result.success(), "cat should succeed for allowed path"); @@ -46,7 +46,7 @@ async fn test_can_read_allowed_path() { #[tokio::test] async fn test_cannot_read_outside_allowed() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -58,7 +58,7 @@ async fn test_cannot_read_outside_allowed() { .unwrap(); // /etc is NOT in fs_read, so cat /etc/group should fail - let result = Sandbox::run(&policy, Some("test"), &["cat", "/etc/group"]) + let result = policy.clone().with_name("test").run(&["cat", "/etc/group"]) .await .unwrap(); assert!(!result.success(), "cat should fail without /etc in fs_read"); @@ -68,7 +68,7 @@ async fn test_cannot_read_outside_allowed() { async fn test_can_write_to_writable_path() { let out = temp_file("write-ok"); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -81,7 +81,7 @@ async fn test_can_write_to_writable_path() { .unwrap(); let cmd_str = format!("echo hello > {}", out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["sh", "-c", &cmd_str]) + let result = policy.clone().with_name("test").run_interactive(&["sh", "-c", &cmd_str]) .await .unwrap(); assert!(result.success(), "writing to /tmp should succeed"); @@ -99,7 +99,7 @@ async fn test_cannot_write_to_readonly_path() { let target = dir.join("nope.txt"); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -114,7 +114,7 @@ async fn test_cannot_write_to_readonly_path() { // dir is read-only, writing should fail let cmd_str = format!("echo nope > {} 2>/dev/null", target.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["sh", "-c", &cmd_str]) + let result = policy.clone().with_name("test").run_interactive(&["sh", "-c", &cmd_str]) .await .unwrap(); assert!(!result.success(), "writing to read-only dir should fail"); @@ -129,7 +129,7 @@ async fn test_denied_path_blocks_read() { let input = dir.join("secret.txt"); std::fs::write(&input, "secret-data").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -143,7 +143,7 @@ async fn test_denied_path_blocks_read() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["cat", input.to_str().unwrap()]) + let result = policy.clone().with_name("test").run(&["cat", input.to_str().unwrap()]) .await .unwrap(); assert!(!result.success(), "cat should fail on denied path"); @@ -153,7 +153,7 @@ async fn test_denied_path_blocks_read() { #[tokio::test] async fn test_denied_path_blocks_exec() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -166,7 +166,7 @@ async fn test_denied_path_blocks_exec() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["/bin/cat", "/etc/hostname"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["/bin/cat", "/etc/hostname"]).await.unwrap(); assert!(!result.success(), "exec should fail on denied binary path"); } @@ -230,7 +230,7 @@ async fn test_isolate_ipc() { out = out.display(), ); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -243,7 +243,7 @@ async fn test_isolate_ipc() { .build() .unwrap(); - let _result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &child_script]) + let _result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &child_script]) .await .unwrap(); @@ -284,7 +284,7 @@ async fn test_isolate_signals_blocks_parent() { out = out.display(), ); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -297,7 +297,7 @@ async fn test_isolate_signals_blocks_parent() { .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "python script should exit 0"); @@ -335,7 +335,7 @@ async fn test_isolate_signals_allows_self() { out = out.display(), ); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -348,7 +348,7 @@ async fn test_isolate_signals_allows_self() { .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); assert!(result.success(), "python script should exit 0"); diff --git a/crates/sandlock-core/tests/integration/test_netlink_virt.rs b/crates/sandlock-core/tests/integration/test_netlink_virt.rs index b39cb1c..0fb41fb 100644 --- a/crates/sandlock-core/tests/integration/test_netlink_virt.rs +++ b/crates/sandlock-core/tests/integration/test_netlink_virt.rs @@ -1,8 +1,8 @@ use std::path::PathBuf; -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; -fn base_policy() -> sandlock_core::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::SandboxBuilder { + Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/etc").fs_read("/proc") .fs_read("/dev").fs_write("/tmp") @@ -24,7 +24,7 @@ async fn if_nameindex_returns_only_lo() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -54,7 +54,7 @@ async fn loopback_bind_succeeds() { // port 0 in Landlock net rules means "allow any port" let policy = base_policy().net_bind_port(0).build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -79,7 +79,7 @@ async fn getaddrinfo_ai_addrconfig_returns_v4_and_v6() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -104,7 +104,7 @@ async fn proc_net_dev_shows_only_lo() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -124,7 +124,7 @@ async fn proc_net_if_inet6_shows_only_lo() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -154,7 +154,7 @@ async fn ioctl_siocgifconf_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -187,7 +187,7 @@ async fn ioctl_siocethtool_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -215,7 +215,7 @@ async fn sys_class_net_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -247,7 +247,7 @@ async fn af_alg_socket_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -289,7 +289,7 @@ async fn niche_socket_families_blocked() { ), af = af, out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); @@ -318,7 +318,7 @@ async fn non_route_netlink_still_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); let contents = std::fs::read_to_string(&out).unwrap_or_default(); diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index b98e654..8cf6a99 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::net::TcpListener; use std::path::PathBuf; @@ -6,8 +6,8 @@ fn temp_file(name: &str) -> PathBuf { std::env::temp_dir().join(format!("sandlock-test-net-{}-{}", name, std::process::id())) } -fn base_policy() -> sandlock_core::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::SandboxBuilder { + Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -49,7 +49,7 @@ async fn test_udp_rule_scopes_destination_by_host() { "s.close()\n", ), ok = out_allowed.display(), deny = out_blocked.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -87,7 +87,7 @@ async fn test_udp_wildcard_allows_any_destination() { "s.close()\n", ), a = out_a.display(), b = out_b.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -125,7 +125,7 @@ async fn test_udp_rule_does_not_authorize_tcp() { "s.close()\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); @@ -209,7 +209,7 @@ async fn test_sendmmsg_partial_failure_on_blocked_destination() { "s.close()\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -278,7 +278,7 @@ async fn test_sendmmsg_single_blocked_returns_econnrefused() { "s.close()\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); @@ -314,7 +314,7 @@ async fn test_net_allow_blocks_disallowed_host() { " open('{out}', 'w').write('BLOCKED')\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "BLOCKED", "connection to 1.1.1.1 should be blocked"); @@ -356,7 +356,7 @@ async fn test_net_allow_permits_listed_endpoint() { "open('{out}', 'w').write('CONNECTED')\n", ), out = out.display(), port = test_port); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "CONNECTED"); @@ -389,7 +389,7 @@ async fn test_net_allow_any_ip_port() { " s.close()\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "REFUSED", "connect to permitted port should reach kernel; got: {}", content); @@ -444,7 +444,7 @@ async fn test_net_allow_endpoint_rejects_other_ports() { " s.close()\n", ), out = out.display(), port = blocked_port); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); stop.store(true, std::sync::atomic::Ordering::SeqCst); let _ = acceptor.join(); @@ -496,7 +496,7 @@ async fn test_grandchild_network_connect() { "sys.exit(child.returncode)\n", ), out = out.display(), port = port); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "hello", "grandchild should connect and read data"); @@ -533,7 +533,7 @@ async fn test_net_allow_wildcard_any_host_any_port() { "open('{out}', 'w').write(data.decode())\n", ), out = out.display(), port = port); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "ok", "wildcard :* should permit arbitrary egress"); @@ -583,7 +583,7 @@ async fn test_net_allow_wildcard_host_only() { "open('{out}', 'w').write(','.join(results))\n", ), out = out.display(), port = port); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert!(content.contains("local:ok"), "localhost should connect; got: {}", content); diff --git a/crates/sandlock-core/tests/integration/test_pipeline.rs b/crates/sandlock-core/tests/integration/test_pipeline.rs index 2db49fd..fe580a9 100644 --- a/crates/sandlock-core/tests/integration/test_pipeline.rs +++ b/crates/sandlock-core/tests/integration/test_pipeline.rs @@ -1,9 +1,9 @@ -use sandlock_core::policy::Policy; +use sandlock_core::sandbox::Sandbox; use sandlock_core::pipeline::{Stage, Pipeline, Gather}; use std::time::Duration; -fn base_policy() -> Policy { - Policy::builder() +fn base_policy() -> Sandbox { + Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -78,7 +78,7 @@ async fn test_disjoint_policies() { std::fs::write(&secret, "sensitive data").unwrap(); // Stage 1: can read the temp dir - let reader_policy = Policy::builder() + let reader_policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_read(&tmp) @@ -172,7 +172,7 @@ async fn test_xoa_data_flow() { let planner_policy = base_policy(); // Executor: can read workspace - let executor_policy = Policy::builder() + let executor_policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_read(&tmp) @@ -244,7 +244,7 @@ async fn test_gather_disjoint_policies() { std::fs::write(&secret, "secret data").unwrap(); // Data source: can read the file - let data_policy = Policy::builder() + let data_policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_read(&tmp) diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 9ed8875..0afa090 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -1,10 +1,10 @@ -use sandlock_core::policy::{ByteSize, FsIsolation, BranchAction, Policy}; +use sandlock_core::sandbox::{ByteSize, FsIsolation, BranchAction, Sandbox}; #[test] fn test_default_policy() { - let policy = Policy::builder().build().unwrap(); + let policy = Sandbox::builder().build().unwrap(); assert_eq!(policy.max_processes, 64); - assert!(policy.block_syscalls.is_empty()); + assert!(policy.extra_deny_syscalls.is_empty()); // UDP, ICMP, and raw ICMP are denied by default — there are no rules // for those protocols in `net_allow`, which is what the BPF filter // gates on now (no separate booleans). @@ -16,7 +16,7 @@ fn test_default_policy() { #[test] fn test_builder_fs_paths() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_write("/tmp") @@ -28,7 +28,7 @@ fn test_builder_fs_paths() { #[test] fn test_builder_network() { - let policy = Policy::builder() + let policy = Sandbox::builder() .net_bind_port(8080) .net_allow("api.example.com:443,80") .build() @@ -42,7 +42,7 @@ fn test_builder_network() { #[test] fn test_net_allow_parse_grammar() { - use sandlock_core::policy::NetAllow; + use sandlock_core::sandbox::NetAllow; assert!(NetAllow::parse("foo.com:443").is_ok()); assert!(NetAllow::parse("foo.com:22,443").is_ok()); assert!(NetAllow::parse(":8080").is_ok()); @@ -55,7 +55,7 @@ fn test_net_allow_parse_grammar() { #[test] fn test_builder_resource_limits() { - let policy = Policy::builder() + let policy = Sandbox::builder() .max_memory(ByteSize::mib(512)) .max_processes(20) .max_cpu(50) @@ -68,21 +68,21 @@ fn test_builder_resource_limits() { #[test] fn test_unknown_syscall_is_rejected() { - let result = Policy::builder() - .block_syscalls(vec!["definitely_not_a_syscall".into()]) + let result = Sandbox::builder() + .extra_deny_syscalls(vec!["definitely_not_a_syscall".into()]) .build(); assert!(result.is_err()); } #[test] fn test_invalid_cpu_percent() { - assert!(Policy::builder().max_cpu(0).build().is_err()); - assert!(Policy::builder().max_cpu(101).build().is_err()); + assert!(Sandbox::builder().max_cpu(0).build().is_err()); + assert!(Sandbox::builder().max_cpu(101).build().is_err()); } #[test] fn test_fs_isolation_requires_workdir() { - assert!(Policy::builder() + assert!(Sandbox::builder() .fs_isolation(FsIsolation::OverlayFs) .build() .is_err()); @@ -112,16 +112,16 @@ fn test_bytesize_parse_invalid() { #[test] fn test_clean_env() { - let p = Policy::builder().build().unwrap(); + let p = Sandbox::builder().build().unwrap(); assert!(!p.clean_env, "clean_env should default to false"); - let p = Policy::builder().clean_env(true).build().unwrap(); + let p = Sandbox::builder().clean_env(true).build().unwrap(); assert!(p.clean_env); } #[test] fn test_env_var() { - let p = Policy::builder() + let p = Sandbox::builder() .env_var("FOO", "bar") .env_var("BAZ", "qux") .build() @@ -132,8 +132,8 @@ fn test_env_var() { #[test] fn test_udp_default_denied() { // Opt in via `.net_allow("udp://*:*")` (or a scoped UDP rule). - let p = Policy::builder().build().unwrap(); - use sandlock_core::policy::Protocol; + let p = Sandbox::builder().build().unwrap(); + use sandlock_core::sandbox::Protocol; assert!(!p.net_allow.iter().any(|r| r.protocol == Protocol::Udp)); } @@ -141,14 +141,14 @@ fn test_udp_default_denied() { fn test_icmp_default_denied() { // Opt in via `.net_allow("icmp://*")` (kernel ping socket). // Raw ICMP is unconditionally denied — sandlock does not expose it. - let p = Policy::builder().build().unwrap(); - use sandlock_core::policy::Protocol; + let p = Sandbox::builder().build().unwrap(); + use sandlock_core::sandbox::Protocol; assert!(!p.net_allow.iter().any(|r| r.protocol == Protocol::Icmp)); } #[test] fn test_branch_action_defaults() { - let p = Policy::builder() + let p = Sandbox::builder() .workdir("/tmp") .build() .unwrap(); @@ -158,13 +158,13 @@ fn test_branch_action_defaults() { #[test] fn test_port_remap_flag() { - let p = Policy::builder().port_remap(true).build().unwrap(); + let p = Sandbox::builder().port_remap(true).build().unwrap(); assert!(p.port_remap); } #[test] fn test_fs_deny() { - let p = Policy::builder() + let p = Sandbox::builder() .fs_deny("/proc/kcore") .fs_deny("/sys/firmware") .build() @@ -174,13 +174,13 @@ fn test_fs_deny() { #[test] fn test_cpu_cores_default_none() { - let p = Policy::builder().build().unwrap(); + let p = Sandbox::builder().build().unwrap(); assert!(p.cpu_cores.is_none()); } #[test] fn test_cpu_cores_builder() { - let p = Policy::builder() + let p = Sandbox::builder() .cpu_cores(vec![0, 2, 3]) .build() .unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_policy_fn.rs b/crates/sandlock-core/tests/integration/test_policy_fn.rs index e5aea95..3c665d8 100644 --- a/crates/sandlock-core/tests/integration/test_policy_fn.rs +++ b/crates/sandlock-core/tests/integration/test_policy_fn.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use sandlock_core::policy_fn::{Verdict, SyscallCategory}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -7,8 +7,8 @@ fn temp_file(name: &str) -> PathBuf { std::env::temp_dir().join(format!("sandlock-test-policyfn-{}-{}", name, std::process::id())) } -fn base_policy() -> sandlock_core::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::SandboxBuilder { + Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -28,8 +28,7 @@ async fn test_policy_fn_receives_events_with_metadata() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["python3", "-c", "print('hello')"], + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", "print('hello')"], ).await.unwrap(); assert!(result.success()); @@ -72,7 +71,7 @@ async fn test_policy_fn_deny_connect() { " open('{out}', 'w').write(f'BLOCKED:{{e.errno}}')\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success()); let content = std::fs::read_to_string(&out).unwrap_or_default(); @@ -111,7 +110,7 @@ async fn test_policy_fn_restrict_network_takes_effect() { " open('{out}', 'w').write(f'BLOCKED:{{e.errno}}')\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success()); let content = std::fs::read_to_string(&out).unwrap_or_default(); @@ -148,7 +147,7 @@ async fn test_policy_fn_deny_path() { " open('{out}', 'w').write(f'BLOCKED:{{e.errno}}')\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success()); let content = std::fs::read_to_string(&out).unwrap_or_default(); @@ -171,8 +170,7 @@ async fn test_policy_fn_passthrough() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["python3", "-c", "print('hello')"], + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", "print('hello')"], ).await.unwrap(); assert!(result.success()); @@ -198,8 +196,7 @@ async fn test_policy_fn_execve_argv() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["python3", "-c", "print('argv test')"], + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", "print('argv test')"], ).await.unwrap(); assert!(result.success()); @@ -224,8 +221,7 @@ async fn test_policy_fn_deny_by_argv() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["echo", "malicious"], + let result = policy.clone().with_name("test").run_interactive(&["echo", "malicious"], ).await.unwrap(); assert!(!result.success(), "execve with 'malicious' in argv should be denied"); } @@ -248,7 +244,7 @@ async fn test_policy_fn_connect_metadata() { .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &[ + let result = policy.clone().with_name("test").run_interactive(&[ "python3", "-c", "import socket; s=socket.socket(); s.settimeout(0.1); \ s.connect_ex(('127.0.0.1', 9999)); s.close()", @@ -282,8 +278,7 @@ async fn test_policy_fn_event_categories() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["python3", "-c", "print('categories')"], + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", "print('categories')"], ).await.unwrap(); assert!(result.success()); @@ -316,8 +311,7 @@ async fn test_policy_fn_parent_pid() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["python3", "-c", "print('ppid')"], + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", "print('ppid')"], ).await.unwrap(); assert!(result.success()); @@ -361,7 +355,7 @@ async fn test_policy_fn_deny_with_eacces() { " s.close()\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success()); let content = std::fs::read_to_string(&out).unwrap_or_default(); @@ -392,8 +386,7 @@ async fn test_policy_fn_audit() { .build() .unwrap(); - let result = Sandbox::run_interactive( - &policy, Some("test"), &["cat", "/etc/hostname"], + let result = policy.clone().with_name("test").run_interactive(&["cat", "/etc/hostname"], ).await.unwrap(); // Audit should allow the syscall — cat should succeed assert!(result.success(), "Audit should allow, not deny"); @@ -441,7 +434,7 @@ sys.exit(1) "#).unwrap(); std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); - let result = Sandbox::run(&policy, Some("test"), &[script.to_str().unwrap()]) + let result = policy.clone().with_name("test").run(&[script.to_str().unwrap()]) .await .unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_port_remap.rs b/crates/sandlock-core/tests/integration/test_port_remap.rs index da07ecd..bbbe00b 100644 --- a/crates/sandlock-core/tests/integration/test_port_remap.rs +++ b/crates/sandlock-core/tests/integration/test_port_remap.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; use std::path::PathBuf; /// Helper to find a free port. @@ -11,8 +11,8 @@ fn temp_file(name: &str) -> PathBuf { std::env::temp_dir().join(format!("sandlock-test-remap-{}-{}", name, std::process::id())) } -fn base_policy() -> sandlock_core::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::SandboxBuilder { + Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -41,7 +41,7 @@ async fn test_port_remap_bind() { out = out.display() ); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "OK"); @@ -90,7 +90,7 @@ async fn test_port_remap_loopback() { out = out.display() ); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, "PASS"); @@ -120,7 +120,7 @@ async fn test_port_remap_getsockname() { out = out.display() ); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert_eq!(content, port.to_string(), "getsockname should return bound port"); @@ -159,7 +159,7 @@ async fn test_port_remap_conflict() { out = out.display() ); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert!(content.starts_with("BOUND:"), "bind should succeed via remap, got: {}", content); diff --git a/crates/sandlock-core/tests/integration/test_privileged.rs b/crates/sandlock-core/tests/integration/test_privileged.rs index 9721c65..551c196 100644 --- a/crates/sandlock-core/tests/integration/test_privileged.rs +++ b/crates/sandlock-core/tests/integration/test_privileged.rs @@ -1,4 +1,4 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; /// Check if user namespaces with uid mapping actually work in this environment. /// Some CI environments (containers, restricted kernels) allow unshare but block @@ -36,7 +36,7 @@ async fn test_uid_zero() { return; } - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -47,7 +47,7 @@ async fn test_uid_zero() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["id", "-u"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["id", "-u"]).await.unwrap(); assert!(result.success(), "id -u failed: {:?}", result.exit_status); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "0", "Expected uid 0, got: {:?}", stdout.trim()); @@ -61,7 +61,7 @@ async fn test_uid_zero_gid_zero() { return; } - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -72,7 +72,7 @@ async fn test_uid_zero_gid_zero() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["id", "-g"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["id", "-g"]).await.unwrap(); assert!(result.success(), "id -g failed: {:?}", result.exit_status); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "0", "Expected gid 0, got: {:?}", stdout.trim()); @@ -81,7 +81,7 @@ async fn test_uid_zero_gid_zero() { /// Test that without --uid, uid is NOT 0 (assuming tests don't run as root). #[tokio::test] async fn test_no_uid_keeps_real_uid() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -91,7 +91,7 @@ async fn test_no_uid_keeps_real_uid() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["id", "-u"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["id", "-u"]).await.unwrap(); assert!(result.success()); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); // If running as root already, skip this check @@ -103,7 +103,7 @@ async fn test_no_uid_keeps_real_uid() { /// Test that --uid 0 doesn't break basic command execution. #[tokio::test] async fn test_uid_zero_echo() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -113,7 +113,7 @@ async fn test_uid_zero_echo() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["echo", "hello"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["echo", "hello"]).await.unwrap(); assert!(result.success()); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "hello"); @@ -127,7 +127,7 @@ async fn test_uid_custom() { return; } - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -138,7 +138,7 @@ async fn test_uid_custom() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["id", "-u"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["id", "-u"]).await.unwrap(); assert!(result.success(), "id -u failed: {:?}", result.exit_status); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "1000", "Expected uid 1000, got: {:?}", stdout.trim()); diff --git a/crates/sandlock-core/tests/integration/test_procfs.rs b/crates/sandlock-core/tests/integration/test_procfs.rs index 1860573..e0e81e8 100644 --- a/crates/sandlock-core/tests/integration/test_procfs.rs +++ b/crates/sandlock-core/tests/integration/test_procfs.rs @@ -1,10 +1,10 @@ -use sandlock_core::policy::ByteSize; -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::sandbox::ByteSize; +use sandlock_core::{Sandbox}; /// Test that num_cpus virtualizes both /proc/cpuinfo and sched_getaffinity. #[tokio::test] async fn test_num_cpus_virtualization() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -16,13 +16,13 @@ async fn test_num_cpus_virtualization() { .unwrap(); // Verify /proc/cpuinfo shows 2 processors. - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", "grep -c ^processor /proc/cpuinfo"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", "grep -c ^processor /proc/cpuinfo"]).await.unwrap(); assert!(result.success(), "grep /proc/cpuinfo should succeed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "2", "/proc/cpuinfo should show 2 processors, got: {:?}", stdout.trim()); // Verify nproc (sched_getaffinity) also reports 2. - let result = Sandbox::run(&policy, Some("test"), &["nproc"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["nproc"]).await.unwrap(); assert!(result.success(), "nproc should succeed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); assert_eq!(stdout.trim(), "2", "nproc should report 2 CPUs, got: {:?}", stdout.trim()); @@ -31,7 +31,7 @@ async fn test_num_cpus_virtualization() { /// Test that max_memory virtualizes /proc/meminfo. #[tokio::test] async fn test_meminfo_virtualization() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -43,7 +43,7 @@ async fn test_meminfo_virtualization() { .unwrap(); // Read meminfo — should show virtualized values - let result = Sandbox::run(&policy, Some("test"), &["cat", "/proc/meminfo"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["cat", "/proc/meminfo"]).await.unwrap(); assert!(result.success(), "cat /proc/meminfo should succeed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); // 256 MiB = 262144 kB @@ -56,7 +56,7 @@ async fn test_meminfo_virtualization() { /// Test that sensitive /proc paths are blocked. #[tokio::test] async fn test_sensitive_proc_blocked() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -68,14 +68,14 @@ async fn test_sensitive_proc_blocked() { .unwrap(); // /proc/kcore should be denied - let result = Sandbox::run(&policy, Some("test"), &["cat", "/proc/kcore"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["cat", "/proc/kcore"]).await.unwrap(); assert!(!result.success(), "/proc/kcore should be denied"); } /// Test basic sandbox still works without /proc virtualization. #[tokio::test] async fn test_no_proc_virt_still_works() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -85,7 +85,7 @@ async fn test_no_proc_virt_still_works() { .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["cat", "/proc/version"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["cat", "/proc/version"]).await.unwrap(); assert!(result.success(), "Should work without proc virtualization"); } @@ -102,7 +102,7 @@ async fn test_proc_net_tcp_filtered() { let port = listener.local_addr().unwrap().port(); drop(listener); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -129,7 +129,7 @@ async fn test_proc_net_tcp_filtered() { "open('{out}', 'w').write(str(len(ports)))\n", ), port = port, out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); let count: usize = content.parse().unwrap_or(999); @@ -141,13 +141,13 @@ async fn test_proc_net_tcp_filtered() { /// Test that /proc/mounts is virtualized and only shows sandbox mounts. #[tokio::test] async fn test_proc_mounts_virtualized() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["cat", "/proc/mounts"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["cat", "/proc/mounts"]).await.unwrap(); assert!(result.success(), "cat /proc/mounts should succeed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); // Should contain the root entry (no chroot → rootfs) @@ -160,13 +160,13 @@ async fn test_proc_mounts_virtualized() { /// Test that /proc/self/mountinfo is virtualized. #[tokio::test] async fn test_proc_self_mountinfo_virtualized() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["cat", "/proc/self/mountinfo"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["cat", "/proc/self/mountinfo"]).await.unwrap(); assert!(result.success(), "cat /proc/self/mountinfo should succeed"); let stdout = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); // Should contain root entry in mountinfo format @@ -182,7 +182,7 @@ async fn test_proc_parent_pid_blocked() { std::process::id() )); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -210,7 +210,7 @@ async fn test_proc_parent_pid_blocked() { "open('{out}', 'w').write(','.join(results))\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "script should exit 0"); let content = std::fs::read_to_string(&out).unwrap_or_default(); let _ = std::fs::remove_file(&out); @@ -228,7 +228,7 @@ async fn test_proc_net_tcp_hides_host_ports() { std::process::id() )); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -248,7 +248,7 @@ async fn test_proc_net_tcp_hides_host_ports() { "open('{out}', 'w').write(str(len(ports)))\n", ), out = out.display()); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); let count: usize = content.parse().unwrap_or(999); diff --git a/crates/sandlock-core/tests/integration/test_resource.rs b/crates/sandlock-core/tests/integration/test_resource.rs index 1c8a1a7..9cadb04 100644 --- a/crates/sandlock-core/tests/integration/test_resource.rs +++ b/crates/sandlock-core/tests/integration/test_resource.rs @@ -1,13 +1,13 @@ use std::time::{Duration, Instant}; -use sandlock_core::policy::ByteSize; -use sandlock_core::{ExitStatus, Policy, Sandbox}; +use sandlock_core::sandbox::ByteSize; +use sandlock_core::{Sandbox, ExitStatus}; use libc; /// Helper: build a base policy that allows Python3 and basic filesystem access. -fn base_policy() -> sandlock_core::policy::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::sandbox::SandboxBuilder { + Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -34,7 +34,7 @@ async fn test_cpu_throttle_slows_execution() { // Run CPU-bound workload without throttle let policy_fast = base_policy().build().unwrap(); let start_fast = Instant::now(); - Sandbox::run_interactive(&policy_fast, Some("test"), &[ + policy_fast.clone().with_name("test").run_interactive(&[ "python3", "-c", "s = 0\nfor i in range(2_000_000): s += i\n", @@ -54,7 +54,7 @@ async fn test_cpu_throttle_slows_execution() { ); let policy_slow = base_policy().max_cpu(25).build().unwrap(); let start_slow = Instant::now(); - Sandbox::run_interactive(&policy_slow, Some("test"), &["python3", "-c", &script]) + policy_slow.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); let slow_elapsed = start_slow.elapsed(); @@ -79,7 +79,7 @@ async fn test_cpu_throttle_100_no_slowdown() { // Run without throttle let policy_base = base_policy().build().unwrap(); let start_base = Instant::now(); - Sandbox::run_interactive(&policy_base, Some("test"), &[ + policy_base.clone().with_name("test").run_interactive(&[ "python3", "-c", "s = 0\nfor i in range(2_000_000): s += i\n", @@ -91,7 +91,7 @@ async fn test_cpu_throttle_100_no_slowdown() { // Run with max_cpu(100) — should not slow down let policy_full = base_policy().max_cpu(100).build().unwrap(); let start_full = Instant::now(); - Sandbox::run_interactive(&policy_full, Some("test"), &[ + policy_full.clone().with_name("test").run_interactive(&[ "python3", "-c", "s = 0\nfor i in range(2_000_000): s += i\n", @@ -116,7 +116,7 @@ async fn test_timeout_kills_process() { let result = tokio::time::timeout( Duration::from_secs(2), - Sandbox::run_interactive(&policy, Some("test"), &["sleep", "300"]), + policy.clone().with_name("test").run_interactive(&["sleep", "300"]), ) .await; @@ -153,7 +153,7 @@ async fn test_process_limit_enforced() { ), out = out.display()); let policy = base_policy().max_processes(3).build().unwrap(); - Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -192,7 +192,7 @@ async fn test_process_limit_allows_sequential_reuse() { ), out = out.display()); let policy = base_policy().max_processes(3).build().unwrap(); - Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -232,7 +232,7 @@ async fn test_threads_do_not_count_toward_process_limit_clone3() { // pre-fix bug that counted threads as processes would block thread // creation immediately. let policy = base_policy().max_processes(2).build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert!( matches!(result.exit_status, ExitStatus::Code(0)), "python should exit 0; got {:?}", @@ -264,7 +264,7 @@ async fn test_memory_limit_enforced() { .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await; + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await; // Process must be killed with SIGKILL when exceeding memory limit let run_result = result.expect("sandbox should return a result"); @@ -284,7 +284,7 @@ async fn test_memory_limit_enforced() { #[tokio::test] async fn test_spawn_and_kill() { let policy = base_policy().build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); sb.spawn(&["sleep", "300"]).await.unwrap(); sb.kill().unwrap(); @@ -312,7 +312,7 @@ async fn test_cpu_cores_affinity() { .cpu_cores(vec![0]) .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]).await.unwrap(); + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]).await.unwrap(); assert_eq!(result.code(), Some(0)); let content = std::fs::read_to_string(&out).expect("temp file should exist"); @@ -324,7 +324,7 @@ async fn test_cpu_cores_affinity() { #[tokio::test] async fn test_pause_resume() { let policy = base_policy().build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = policy.clone().with_name("test"); sb.spawn(&["sleep", "300"]).await.unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_sandbox.rs b/crates/sandlock-core/tests/integration/test_sandbox.rs index c46f154..01b3bae 100644 --- a/crates/sandlock-core/tests/integration/test_sandbox.rs +++ b/crates/sandlock-core/tests/integration/test_sandbox.rs @@ -1,8 +1,8 @@ -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; #[tokio::test] async fn test_echo() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -11,14 +11,14 @@ async fn test_echo() { .fs_read("/proc") .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["echo", "hello"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["echo", "hello"]).await.unwrap(); assert!(result.success()); assert_eq!(result.code(), Some(0)); } #[tokio::test] async fn test_exit_code() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -26,14 +26,14 @@ async fn test_exit_code() { .fs_read("/proc") .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", "exit 42"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", "exit 42"]).await.unwrap(); assert_eq!(result.code(), Some(42)); } #[tokio::test] async fn test_denied_path() { // No /etc in readable paths - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -41,13 +41,13 @@ async fn test_denied_path() { .fs_read("/proc") .build() .unwrap(); - let result = Sandbox::run(&policy, Some("test"), &["cat", "/etc/group"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["cat", "/etc/group"]).await.unwrap(); assert!(!result.success()); } #[tokio::test] async fn test_denied_syscall() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -59,51 +59,60 @@ async fn test_denied_syscall() { .unwrap(); // mount is in DEFAULT_BLOCKLIST_SYSCALLS; redirect stderr to /dev/null // (need /dev readable for this) - let result = Sandbox::run( - &policy, Some("test"), - &["sh", "-c", "mount -t tmpfs none /tmp 2>/dev/null; echo $?"], - ) - .await - .unwrap(); + let result = policy.clone().with_name("test") + .run(&["sh", "-c", "mount -t tmpfs none /tmp 2>/dev/null; echo $?"]) + .await + .unwrap(); // sh exits 0 even though mount failed inside the sandbox assert!(result.success()); } #[tokio::test] async fn test_kill_not_running() { - let policy = Policy::builder().build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); - assert_eq!(sb.name(), "test"); + let mut sb = Sandbox::builder().name("test").build().unwrap(); + assert_eq!(sb.instance_name(), Some("test")); assert!(sb.kill().is_err()); // NotRunning } #[tokio::test] async fn test_invalid_sandbox_name() { - let policy = Policy::builder().build().unwrap(); - assert!(Sandbox::new(&policy, None).is_ok()); - assert!(Sandbox::new(&policy, Some("")).is_err()); - assert!(Sandbox::new(&policy, Some("bad\0name")).is_err()); + // No name → auto-generated; valid. Use a simple command that exits fast. + let result = Sandbox::builder() + .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin").fs_read("/proc") + .build().unwrap() + .run(&["true"]) + .await; + assert!(result.is_ok(), "sandbox with no name should auto-generate a valid name"); + + // Empty name → error at ensure_runtime time (inside spawn). + let result = Sandbox::builder().build().unwrap().with_name("").run(&["true"]).await; + assert!(result.is_err(), "empty sandbox name should fail"); + + // NUL byte in name → error. + let result = Sandbox::builder().build().unwrap().with_name("bad\0name").run(&["true"]).await; + assert!(result.is_err(), "NUL byte in sandbox name should fail"); + + // Name > 64 bytes → error. let long_name = "x".repeat(65); - assert!(Sandbox::new(&policy, Some(long_name.as_str())).is_err()); + let result = Sandbox::builder().build().unwrap().with_name(long_name).run(&["true"]).await; + assert!(result.is_err(), "sandbox name > 64 bytes should fail"); } #[tokio::test] async fn test_pause_not_running() { - let policy = Policy::builder().build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = Sandbox::builder().name("test").build().unwrap(); assert!(sb.pause().is_err()); } #[tokio::test] async fn test_resume_not_running() { - let policy = Policy::builder().build().unwrap(); - let mut sb = Sandbox::new(&policy, Some("test")).unwrap(); + let mut sb = Sandbox::builder().name("test").build().unwrap(); assert!(sb.resume().is_err()); } #[tokio::test] async fn test_default_policy_runs_ls() { - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -114,7 +123,7 @@ async fn test_default_policy_runs_ls() { .unwrap(); // Use "ls /bin" instead of "ls /" because Landlock restricts access // to specific subtrees, not the root directory itself. - let result = Sandbox::run(&policy, Some("test"), &["ls", "/bin"]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["ls", "/bin"]).await.unwrap(); assert!(result.success()); } @@ -127,7 +136,7 @@ async fn test_default_policy_runs_ls() { #[tokio::test] async fn test_nested_sandbox() { // Outer: allows /etc - let outer = Policy::builder() + let outer = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") @@ -135,14 +144,14 @@ async fn test_nested_sandbox() { .unwrap(); // Inner: does NOT allow /etc — run cat /etc/group, should fail - let inner = Policy::builder() + let inner = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/proc") .build() .unwrap(); // Spawn outer, then nest inner inside it - let mut outer_sb = Sandbox::new(&outer, Some("test")).unwrap(); + let mut outer_sb = outer.clone().with_name("test"); outer_sb.spawn(&["sleep", "10"]).await.unwrap(); // The inner sandbox runs in the same parent process context — @@ -159,10 +168,10 @@ async fn test_nested_sandbox() { // Sequential sandboxes: first sandbox applies Landlock + seccomp, // second sandbox from the same parent gets EBUSY on seccomp // but Landlock stacks. Verify both work independently. - let r1 = Sandbox::run(&outer, Some("test"), &["cat", "/etc/group"]).await.unwrap(); + let r1 = outer.clone().with_name("test").run(&["cat", "/etc/group"]).await.unwrap(); assert!(r1.success(), "outer should allow /etc"); - let r2 = Sandbox::run(&inner, Some("test"), &["cat", "/etc/group"]).await.unwrap(); + let r2 = inner.clone().with_name("test").run(&["cat", "/etc/group"]).await.unwrap(); assert!(!r2.success(), "inner should deny /etc"); } @@ -186,7 +195,7 @@ async fn test_nested_sandbox_via_cli() { }; // Outer allows /etc + sandlock binary; inner does not allow /etc - let outer = Policy::builder() + let outer = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_read(sandlock_bin.parent().unwrap()) @@ -198,9 +207,9 @@ async fn test_nested_sandbox_via_cli() { "{} run -r /usr -r /lib{} -r /bin -r /proc -- cat /etc/group", bin, lib64_arg ); - let result = Sandbox::run( - &outer, Some("test"), &["sh", "-c", &inner_cmd], - ).await.unwrap(); + let result = outer.clone().with_name("test") + .run(&["sh", "-c", &inner_cmd]) + .await.unwrap(); assert!(!result.success(), "inner sandbox should block /etc"); // Inner with /etc allowed — should succeed @@ -208,9 +217,9 @@ async fn test_nested_sandbox_via_cli() { "{} run -r /usr -r /lib{} -r /bin -r /etc -r /proc -- echo nested-ok", bin, lib64_arg ); - let result = Sandbox::run( - &outer, Some("test"), &["sh", "-c", &inner_cmd], - ).await.unwrap(); + let result = outer.clone().with_name("test") + .run(&["sh", "-c", &inner_cmd]) + .await.unwrap(); assert!(result.success(), "nested sandbox with shared paths should work"); } @@ -224,7 +233,7 @@ async fn test_denied_path_hardlink_blocked() { let secret = tmp.path().join("secret.txt"); std::fs::write(&secret, "TOP_SECRET").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/proc").fs_read("/etc") .fs_read(tmp.path()) @@ -240,7 +249,7 @@ async fn test_denied_path_hardlink_blocked() { tmp.path().display(), tmp.path().display(), ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await.unwrap(); assert!( result.stdout_str().map_or(true, |s| !s.contains("TOP_SECRET")), "hardlink bypass: sandbox allowed reading denied file via hardlink" @@ -253,7 +262,7 @@ async fn test_denied_path_rename_blocked() { let secret = tmp.path().join("secret.txt"); std::fs::write(&secret, "TOP_SECRET").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/proc").fs_read("/etc") .fs_read(tmp.path()) @@ -269,7 +278,7 @@ async fn test_denied_path_rename_blocked() { tmp.path().display(), tmp.path().display(), ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await.unwrap(); assert!( result.stdout_str().map_or(true, |s| !s.contains("TOP_SECRET")), "rename bypass: sandbox allowed reading denied file via rename" @@ -282,7 +291,7 @@ async fn test_denied_path_symlink_blocked() { let secret = tmp.path().join("secret.txt"); std::fs::write(&secret, "TOP_SECRET").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/proc").fs_read("/etc") .fs_read(tmp.path()) @@ -298,7 +307,7 @@ async fn test_denied_path_symlink_blocked() { tmp.path().display(), tmp.path().display(), ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await.unwrap(); assert!( result.stdout_str().map_or(true, |s| !s.contains("TOP_SECRET")), "symlink bypass: sandbox allowed reading denied file via symlink" @@ -316,7 +325,7 @@ async fn test_denied_path_preexisting_symlink_blocked() { let link = tmp.path().join("preexisting_link"); std::os::unix::fs::symlink(&secret, &link).unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/proc").fs_read("/etc") .fs_read(tmp.path()) @@ -325,7 +334,7 @@ async fn test_denied_path_preexisting_symlink_blocked() { .unwrap(); let cmd = format!("cat {}", link.display()); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await.unwrap(); assert!( result.stdout_str().map_or(true, |s| !s.contains("TOP_SECRET")), "pre-existing symlink bypass: read denied file through symlink created before sandbox" @@ -345,7 +354,7 @@ async fn test_denied_path_chained_symlinks_blocked() { std::os::unix::fs::symlink("secret.txt", &link1).unwrap(); std::os::unix::fs::symlink("link1", &link2).unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/proc").fs_read("/etc") .fs_read(tmp.path()) @@ -354,7 +363,7 @@ async fn test_denied_path_chained_symlinks_blocked() { .unwrap(); let cmd = format!("cat {}", link2.display()); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await.unwrap(); assert!( result.stdout_str().map_or(true, |s| !s.contains("TOP_SECRET")), "chained symlink bypass: read denied file through symlink chain" @@ -368,7 +377,7 @@ async fn test_denied_path_allows_normal_writes() { let secret = tmp.path().join("secret.txt"); std::fs::write(&secret, "TOP_SECRET").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64") .fs_read("/bin").fs_read("/proc").fs_read("/etc") .fs_read(tmp.path()) @@ -382,7 +391,7 @@ async fn test_denied_path_allows_normal_writes() { "echo ok > {0}/a.txt && ln {0}/a.txt {0}/b.txt && cat {0}/b.txt", tmp.path().display(), ); - let result = Sandbox::run(&policy, Some("test"), &["sh", "-c", &cmd]).await.unwrap(); + let result = policy.clone().with_name("test").run(&["sh", "-c", &cmd]).await.unwrap(); assert!(result.success(), "normal write should succeed"); assert_eq!(result.stdout_str(), Some("ok")); } @@ -415,7 +424,7 @@ async fn test_chroot() { // Create a marker file inside the chroot std::fs::write(chroot_dir.join("marker.txt"), "inside-chroot").unwrap(); - let policy = Policy::builder() + let policy = Sandbox::builder() .fs_read("/usr") .fs_read("/bin") .fs_read("/") @@ -424,10 +433,7 @@ async fn test_chroot() { .unwrap(); // cat the marker file — it should be at /marker.txt inside the chroot - let result = Sandbox::run_interactive( - &policy, Some("test"), - &["cat", "/marker.txt"], - ).await; + let result = policy.clone().with_name("test").run_interactive(&["cat", "/marker.txt"]).await; match result { Ok(r) => { diff --git a/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs b/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs index 2dd68b5..2790d64 100644 --- a/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs +++ b/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; -use sandlock_core::{Policy, Sandbox}; +use sandlock_core::{Sandbox}; /// Helper: base policy with standard FS paths for running commands. -fn base_policy() -> sandlock_core::PolicyBuilder { - Policy::builder() +fn base_policy() -> sandlock_core::SandboxBuilder { + Sandbox::builder() .fs_read("/usr") .fs_read("/lib") .fs_read_if_exists("/lib64") @@ -35,7 +35,7 @@ async fn test_mount_blocked() { out.display() ); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["sh", "-c", &cmd_str]) + let result = policy.clone().with_name("test").run_interactive(&["sh", "-c", &cmd_str]) .await .unwrap(); @@ -60,7 +60,7 @@ async fn test_ptrace_blocked() { out.display() ); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["sh", "-c", &cmd_str]) + let result = policy.clone().with_name("test").run_interactive(&["sh", "-c", &cmd_str]) .await .unwrap(); @@ -92,7 +92,7 @@ async fn test_personality_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -127,7 +127,7 @@ async fn test_raw_socket_blocked() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -170,7 +170,7 @@ async fn test_raw_icmp_always_denied() { .net_allow("icmp://*") .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -209,7 +209,7 @@ async fn test_icmp_dgram_allowed_with_icmp_rule() { .net_allow("icmp://*") .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -252,7 +252,7 @@ async fn test_udp_allowed_when_opted_in() { .net_allow("udp://*:*") .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -287,7 +287,7 @@ async fn test_udp_denied_by_default() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -325,7 +325,7 @@ async fn test_sysv_shmget_denied_by_default() { ), out = out.display()); let policy = base_policy().build().unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -343,7 +343,7 @@ async fn test_sysv_shmget_denied_by_default() { } // ------------------------------------------------------------------ -// 7b. allow_sysv_ipc(true) restores SysV shm. +// 7b. extra_allow_syscalls(["sysv_ipc"]) restores SysV shm. // ------------------------------------------------------------------ #[tokio::test] async fn test_sysv_shmget_allowed_when_opted_in() { @@ -361,10 +361,10 @@ async fn test_sysv_shmget_allowed_when_opted_in() { ), out = out.display()); let policy = base_policy() - .allow_sysv_ipc(true) + .extra_allow_syscalls(vec!["sysv_ipc".into()]) .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); @@ -373,7 +373,7 @@ async fn test_sysv_shmget_allowed_when_opted_in() { assert_eq!( contents.trim(), "ALLOWED", - "shmget should be permitted under --allow-sysv-ipc; got: {}", + "shmget should be permitted under extra_allow_syscalls=[\"sysv_ipc\"]; got: {}", contents.trim() ); assert!(result.success()); @@ -401,7 +401,7 @@ async fn test_tcp_always_allowed() { let policy = base_policy() .build() .unwrap(); - let result = Sandbox::run_interactive(&policy, Some("test"), &["python3", "-c", &script]) + let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await .unwrap(); diff --git a/crates/sandlock-core/tests/sandbox_validate.rs b/crates/sandlock-core/tests/sandbox_validate.rs new file mode 100644 index 0000000..311836d --- /dev/null +++ b/crates/sandlock-core/tests/sandbox_validate.rs @@ -0,0 +1,20 @@ +use sandlock_core::sandbox::{FsIsolation, Sandbox}; + +#[test] +fn validate_overlayfs_without_workdir_fails() { + let p = Sandbox::builder() + .fs_isolation(FsIsolation::OverlayFs) + .build_unchecked() + .unwrap(); + let err = p.validate().unwrap_err(); + assert!(format!("{err}").to_lowercase().contains("workdir")); +} + +#[test] +fn validate_none_without_workdir_succeeds() { + let p = Sandbox::builder() + .fs_isolation(FsIsolation::None) + .build_unchecked() + .unwrap(); + assert!(p.validate().is_ok()); +} diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index ce2f10d..d98d9f2 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -17,61 +17,53 @@ extern "C" { /* Opaque handle types */ typedef void sandlock_builder_t; -typedef void sandlock_policy_t; +typedef void sandlock_sandbox_t; typedef void sandlock_result_t; typedef void sandlock_pipeline_t; /* ---------------------------------------------------------------- - * Policy Builder + * Sandbox Builder * ---------------------------------------------------------------- */ -sandlock_builder_t *sandlock_policy_builder_new(void); +sandlock_builder_t *sandlock_sandbox_builder_new(void); /* Filesystem */ -sandlock_builder_t *sandlock_policy_builder_fs_read(sandlock_builder_t *b, const char *path); -sandlock_builder_t *sandlock_policy_builder_fs_write(sandlock_builder_t *b, const char *path); -sandlock_builder_t *sandlock_policy_builder_fs_deny(sandlock_builder_t *b, const char *path); -sandlock_builder_t *sandlock_policy_builder_workdir(sandlock_builder_t *b, const char *path); -sandlock_builder_t *sandlock_policy_builder_chroot(sandlock_builder_t *b, const char *path); +sandlock_builder_t *sandlock_sandbox_builder_fs_read(sandlock_builder_t *b, const char *path); +sandlock_builder_t *sandlock_sandbox_builder_fs_write(sandlock_builder_t *b, const char *path); +sandlock_builder_t *sandlock_sandbox_builder_fs_deny(sandlock_builder_t *b, const char *path); +sandlock_builder_t *sandlock_sandbox_builder_workdir(sandlock_builder_t *b, const char *path); +sandlock_builder_t *sandlock_sandbox_builder_chroot(sandlock_builder_t *b, const char *path); /* Resource limits */ -sandlock_builder_t *sandlock_policy_builder_max_memory(sandlock_builder_t *b, uint64_t bytes); -sandlock_builder_t *sandlock_policy_builder_max_processes(sandlock_builder_t *b, uint32_t n); -sandlock_builder_t *sandlock_policy_builder_max_cpu(sandlock_builder_t *b, uint8_t pct); -sandlock_builder_t *sandlock_policy_builder_num_cpus(sandlock_builder_t *b, uint32_t n); +sandlock_builder_t *sandlock_sandbox_builder_max_memory(sandlock_builder_t *b, uint64_t bytes); +sandlock_builder_t *sandlock_sandbox_builder_max_processes(sandlock_builder_t *b, uint32_t n); +sandlock_builder_t *sandlock_sandbox_builder_max_cpu(sandlock_builder_t *b, uint8_t pct); +sandlock_builder_t *sandlock_sandbox_builder_num_cpus(sandlock_builder_t *b, uint32_t n); /* Network */ /* `spec` is `host:port[,port,...]` (IP-restricted) or `:port` / `*:port` - * (any IP). Validated when the policy is built. */ -sandlock_builder_t *sandlock_policy_builder_net_allow(sandlock_builder_t *b, const char *spec); -sandlock_builder_t *sandlock_policy_builder_net_bind_port(sandlock_builder_t *b, uint16_t port); -sandlock_builder_t *sandlock_policy_builder_port_remap(sandlock_builder_t *b, bool v); -/* UDP socket creation. Denied by default; opt in with v=true. */ -sandlock_builder_t *sandlock_policy_builder_allow_udp(sandlock_builder_t *b, bool v); -/* Permit ICMP raw sockets only (AF_INET/AF_INET6 + SOCK_RAW + IPPROTO_ICMP[V6]). - * All other raw socket types remain denied. */ -sandlock_builder_t *sandlock_policy_builder_allow_icmp(sandlock_builder_t *b, bool v); - -/* Mode */ -sandlock_builder_t *sandlock_policy_builder_privileged(sandlock_builder_t *b, bool v); + * (any IP). Validated when the sandbox is built. */ +sandlock_builder_t *sandlock_sandbox_builder_net_allow(sandlock_builder_t *b, const char *spec); +sandlock_builder_t *sandlock_sandbox_builder_net_bind_port(sandlock_builder_t *b, uint16_t port); +sandlock_builder_t *sandlock_sandbox_builder_port_remap(sandlock_builder_t *b, bool v); +/* Protocol gating (UDP, ICMP) is expressed via net_allow rule schemes + * (`udp://`, `icmp://`) — there are no separate boolean setters. */ /* Isolation & determinism */ -sandlock_builder_t *sandlock_policy_builder_isolate_ipc(sandlock_builder_t *b, bool v); -sandlock_builder_t *sandlock_policy_builder_isolate_signals(sandlock_builder_t *b, bool v); -sandlock_builder_t *sandlock_policy_builder_random_seed(sandlock_builder_t *b, uint64_t seed); -sandlock_builder_t *sandlock_policy_builder_clean_env(sandlock_builder_t *b, bool v); -sandlock_builder_t *sandlock_policy_builder_env_var(sandlock_builder_t *b, const char *key, const char *value); -sandlock_builder_t *sandlock_policy_builder_no_randomize_memory(sandlock_builder_t *b, bool v); -sandlock_builder_t *sandlock_policy_builder_no_huge_pages(sandlock_builder_t *b, bool v); +sandlock_builder_t *sandlock_sandbox_builder_random_seed(sandlock_builder_t *b, uint64_t seed); +sandlock_builder_t *sandlock_sandbox_builder_clean_env(sandlock_builder_t *b, bool v); +sandlock_builder_t *sandlock_sandbox_builder_env_var(sandlock_builder_t *b, const char *key, const char *value); +sandlock_builder_t *sandlock_sandbox_builder_no_randomize_memory(sandlock_builder_t *b, bool v); +sandlock_builder_t *sandlock_sandbox_builder_no_huge_pages(sandlock_builder_t *b, bool v); /* Build & free */ /* On failure, *err is set to -1 and *err_msg (if non-null) is set to a * heap-allocated C string with the error description. Caller frees it * via sandlock_string_free. Pass NULL for err_msg to discard. */ -sandlock_policy_t *sandlock_policy_build(sandlock_builder_t *b, - int *err, - char **err_msg); -void sandlock_policy_free(sandlock_policy_t *p); +sandlock_sandbox_t *sandlock_sandbox_build(sandlock_builder_t *b, + int *err, + char **err_msg); +void sandlock_sandbox_free(sandlock_sandbox_t *p); /* sandlock_string_free is declared further down — used for any * heap-allocated C string the FFI returns to the caller. */ @@ -80,11 +72,15 @@ void sandlock_policy_free(sandlock_policy_t *p); * ---------------------------------------------------------------- */ /** Run with captured stdout/stderr. Returns result handle (NULL on failure). */ -sandlock_result_t *sandlock_run(const sandlock_policy_t *policy, +/* name may be NULL to auto-generate as "sandbox-{pid}". */ +sandlock_result_t *sandlock_run(const sandlock_sandbox_t *policy, + const char *name, const char *const *argv, unsigned int argc); /** Run with inherited stdio. Returns exit code (-1 on failure). */ -int sandlock_run_interactive(const sandlock_policy_t *policy, +/* name may be NULL to auto-generate as "sandbox-{pid}". */ +int sandlock_run_interactive(const sandlock_sandbox_t *policy, + const char *name, const char *const *argv, unsigned int argc); /* ---------------------------------------------------------------- @@ -114,7 +110,7 @@ void sandlock_string_free(char *s); sandlock_pipeline_t *sandlock_pipeline_new(void); void sandlock_pipeline_add_stage(sandlock_pipeline_t *pipe, - const sandlock_policy_t *policy, + const sandlock_sandbox_t *policy, const char *const *argv, unsigned int argc); /** Run pipeline (consumes pipe). timeout_ms=0 means no timeout. */ diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 10e5f0f..edf2917 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -9,17 +9,17 @@ use std::ptr; use std::time::Duration; use sandlock_core::pipeline::Stage; -use sandlock_core::policy::{ByteSize, PolicyBuilder}; -use sandlock_core::{Policy, RunResult, Sandbox}; +use sandlock_core::sandbox::{BranchAction, ByteSize, FsIsolation, SandboxBuilder}; +use sandlock_core::{Sandbox, RunResult}; // ---------------------------------------------------------------- // Opaque wrapper types // ---------------------------------------------------------------- -/// Opaque handle wrapping a [`Policy`]. +/// Opaque handle wrapping a [`Sandbox`]. #[repr(C)] -pub struct sandlock_policy_t { - _private: Policy, +pub struct sandlock_sandbox_t { + _private: Sandbox, } /// Opaque handle wrapping a [`RunResult`]. @@ -31,25 +31,25 @@ pub struct sandlock_result_t { /// Opaque handle wrapping a [`Pipeline`]. #[allow(non_camel_case_types)] pub struct sandlock_pipeline_t { - stages: Vec<(Policy, Vec)>, + stages: Vec<(Sandbox, Vec)>, } // ---------------------------------------------------------------- -// Policy Builder — filesystem +// Sandbox Builder — filesystem // ---------------------------------------------------------------- #[no_mangle] -pub extern "C" fn sandlock_policy_builder_new() -> *mut PolicyBuilder { - Box::into_raw(Box::new(Policy::builder())) +pub extern "C" fn sandlock_sandbox_builder_new() -> *mut SandboxBuilder { + Box::into_raw(Box::new(Sandbox::builder())) } /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_fs_read( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_fs_read( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -59,10 +59,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_fs_read( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_fs_write( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_fs_write( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -72,10 +72,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_fs_write( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_fs_deny( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_fs_deny( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -85,10 +85,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_fs_deny( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_fs_storage( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_fs_storage( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -101,16 +101,16 @@ pub unsafe extern "C" fn sandlock_policy_builder_fs_storage( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_fs_isolation( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_fs_isolation( + b: *mut SandboxBuilder, mode: u8, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); let iso = match mode { - 1 => sandlock_core::policy::FsIsolation::OverlayFs, - 2 => sandlock_core::policy::FsIsolation::BranchFs, - _ => sandlock_core::policy::FsIsolation::None, + 1 => FsIsolation::OverlayFs, + 2 => FsIsolation::BranchFs, + _ => FsIsolation::None, }; Box::into_raw(Box::new(builder.fs_isolation(iso))) } @@ -118,9 +118,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_fs_isolation( /// # Safety /// `b` must be a valid pointer. `devices` must point to `len` u32 values (or be null when len == 0). #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_gpu_devices( - b: *mut PolicyBuilder, devices: *const u32, len: u32, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_gpu_devices( + b: *mut SandboxBuilder, devices: *const u32, len: u32, +) -> *mut SandboxBuilder { if b.is_null() || (len > 0 && devices.is_null()) { return b; } let slice = if len > 0 { std::slice::from_raw_parts(devices, len as usize) @@ -134,10 +134,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_gpu_devices( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_workdir( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_workdir( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -147,10 +147,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_workdir( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_cwd( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_cwd( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -160,10 +160,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_cwd( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_chroot( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_chroot( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -175,11 +175,11 @@ pub unsafe extern "C" fn sandlock_policy_builder_chroot( /// # Safety /// `b`, `virtual_path`, and `host_path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_fs_mount( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_fs_mount( + b: *mut SandboxBuilder, virtual_path: *const c_char, host_path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || virtual_path.is_null() || host_path.is_null() { return b; } let vp = CStr::from_ptr(virtual_path).to_str().unwrap_or(""); let hp = CStr::from_ptr(host_path).to_str().unwrap_or(""); @@ -193,16 +193,16 @@ pub unsafe extern "C" fn sandlock_policy_builder_fs_mount( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_on_exit( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_on_exit( + b: *mut SandboxBuilder, action: u8, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); let action = match action { - 1 => sandlock_core::policy::BranchAction::Abort, - 2 => sandlock_core::policy::BranchAction::Keep, - _ => sandlock_core::policy::BranchAction::Commit, + 1 => BranchAction::Abort, + 2 => BranchAction::Keep, + _ => BranchAction::Commit, }; Box::into_raw(Box::new(builder.on_exit(action))) } @@ -213,30 +213,30 @@ pub unsafe extern "C" fn sandlock_policy_builder_on_exit( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_on_error( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_on_error( + b: *mut SandboxBuilder, action: u8, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); let action = match action { - 1 => sandlock_core::policy::BranchAction::Abort, - 2 => sandlock_core::policy::BranchAction::Keep, - _ => sandlock_core::policy::BranchAction::Commit, + 1 => BranchAction::Abort, + 2 => BranchAction::Keep, + _ => BranchAction::Commit, }; Box::into_raw(Box::new(builder.on_error(action))) } // ---------------------------------------------------------------- -// Policy Builder — resource limits +// Sandbox Builder — resource limits // ---------------------------------------------------------------- /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_max_memory( - b: *mut PolicyBuilder, bytes: u64, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_max_memory( + b: *mut SandboxBuilder, bytes: u64, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.max_memory(ByteSize(bytes)))) @@ -245,9 +245,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_max_memory( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_max_disk( - b: *mut PolicyBuilder, bytes: u64, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_max_disk( + b: *mut SandboxBuilder, bytes: u64, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.max_disk(ByteSize(bytes)))) @@ -256,9 +256,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_max_disk( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_max_processes( - b: *mut PolicyBuilder, n: u32, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_max_processes( + b: *mut SandboxBuilder, n: u32, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.max_processes(n))) @@ -267,9 +267,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_max_processes( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_max_cpu( - b: *mut PolicyBuilder, pct: u8, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_max_cpu( + b: *mut SandboxBuilder, pct: u8, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.max_cpu(pct))) @@ -278,9 +278,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_max_cpu( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_num_cpus( - b: *mut PolicyBuilder, n: u32, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_num_cpus( + b: *mut SandboxBuilder, n: u32, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.num_cpus(n))) @@ -289,9 +289,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_num_cpus( /// # Safety /// `b` must be a valid builder pointer. `cores` must point to `len` u32 values. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_cpu_cores( - b: *mut PolicyBuilder, cores: *const u32, len: u32, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_cpu_cores( + b: *mut SandboxBuilder, cores: *const u32, len: u32, +) -> *mut SandboxBuilder { if b.is_null() || (len > 0 && cores.is_null()) { return b; } let slice = if len > 0 { std::slice::from_raw_parts(cores, len as usize) @@ -303,7 +303,7 @@ pub unsafe extern "C" fn sandlock_policy_builder_cpu_cores( } // ---------------------------------------------------------------- -// Policy Builder — network +// Sandbox Builder — network // ---------------------------------------------------------------- /// Append a `--net-allow` endpoint rule. `spec` is `host:port[,port,...]`, @@ -313,9 +313,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_cpu_cores( /// # Safety /// `b` and `spec` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_net_allow( - b: *mut PolicyBuilder, spec: *const c_char, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow( + b: *mut SandboxBuilder, spec: *const c_char, +) -> *mut SandboxBuilder { if b.is_null() || spec.is_null() { return b; } let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -325,9 +325,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_net_allow( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_net_bind_port( - b: *mut PolicyBuilder, port: u16, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_net_bind_port( + b: *mut SandboxBuilder, port: u16, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.net_bind_port(port))) @@ -336,9 +336,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_net_bind_port( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_port_remap( - b: *mut PolicyBuilder, v: bool, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_port_remap( + b: *mut SandboxBuilder, v: bool, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.port_remap(v))) @@ -353,25 +353,25 @@ pub unsafe extern "C" fn sandlock_policy_builder_port_remap( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_uid( - b: *mut PolicyBuilder, id: u32, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_uid( + b: *mut SandboxBuilder, id: u32, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.uid(id))) } // ---------------------------------------------------------------- -// Policy Builder — HTTP ACL +// Sandbox Builder — HTTP ACL // ---------------------------------------------------------------- /// # Safety /// `b` and `rule` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_http_allow( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_http_allow( + b: *mut SandboxBuilder, rule: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || rule.is_null() { return b; } let rule = CStr::from_ptr(rule).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -381,10 +381,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_http_allow( /// # Safety /// `b` and `rule` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_http_deny( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_http_deny( + b: *mut SandboxBuilder, rule: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || rule.is_null() { return b; } let rule = CStr::from_ptr(rule).to_str().unwrap_or(""); let builder = *Box::from_raw(b); @@ -394,10 +394,10 @@ pub unsafe extern "C" fn sandlock_policy_builder_http_deny( /// # Safety /// `b` must be a valid pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_http_port( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_http_port( + b: *mut SandboxBuilder, port: u16, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.http_port(port))) @@ -406,39 +406,39 @@ pub unsafe extern "C" fn sandlock_policy_builder_http_port( /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_https_ca( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_http_ca( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.https_ca(path))) + Box::into_raw(Box::new(builder.http_ca(path))) } /// # Safety /// `b` and `path` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_https_key( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_http_key( + b: *mut SandboxBuilder, path: *const c_char, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() || path.is_null() { return b; } let path = CStr::from_ptr(path).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.https_key(path))) + Box::into_raw(Box::new(builder.http_key(path))) } // ---------------------------------------------------------------- -// Policy Builder — isolation & determinism +// Sandbox Builder — isolation & determinism // ---------------------------------------------------------------- /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_random_seed( - b: *mut PolicyBuilder, seed: u64, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_random_seed( + b: *mut SandboxBuilder, seed: u64, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.random_seed(seed))) @@ -447,9 +447,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_random_seed( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_clean_env( - b: *mut PolicyBuilder, v: bool, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_clean_env( + b: *mut SandboxBuilder, v: bool, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.clean_env(v))) @@ -458,9 +458,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_clean_env( /// # Safety /// `b`, `key`, and `value` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_env_var( - b: *mut PolicyBuilder, key: *const c_char, value: *const c_char, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_env_var( + b: *mut SandboxBuilder, key: *const c_char, value: *const c_char, +) -> *mut SandboxBuilder { if b.is_null() || key.is_null() || value.is_null() { return b; } let key = CStr::from_ptr(key).to_str().unwrap_or(""); let value = CStr::from_ptr(value).to_str().unwrap_or(""); @@ -471,9 +471,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_env_var( /// # Safety /// `b` must be a valid builder pointer. `epoch_secs` is seconds since UNIX epoch. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_time_start( - b: *mut PolicyBuilder, epoch_secs: u64, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_time_start( + b: *mut SandboxBuilder, epoch_secs: u64, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); let t = std::time::UNIX_EPOCH + Duration::from_secs(epoch_secs); @@ -483,22 +483,35 @@ pub unsafe extern "C" fn sandlock_policy_builder_time_start( /// # Safety /// `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_block_syscalls( - b: *mut PolicyBuilder, names: *const c_char, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_extra_deny_syscalls( + b: *mut SandboxBuilder, names: *const c_char, +) -> *mut SandboxBuilder { if b.is_null() || names.is_null() { return b; } let builder = *Box::from_raw(b); let s = CStr::from_ptr(names).to_str().unwrap_or(""); let calls: Vec = s.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); - Box::into_raw(Box::new(builder.block_syscalls(calls))) + Box::into_raw(Box::new(builder.extra_deny_syscalls(calls))) +} + +/// # Safety +/// `b` must be a valid builder pointer. `names` is a comma-separated NUL-terminated string. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_extra_allow_syscalls( + b: *mut SandboxBuilder, names: *const c_char, +) -> *mut SandboxBuilder { + if b.is_null() || names.is_null() { return b; } + let builder = *Box::from_raw(b); + let s = CStr::from_ptr(names).to_str().unwrap_or(""); + let names: Vec = s.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + Box::into_raw(Box::new(builder.extra_allow_syscalls(names))) } /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_max_open_files( - b: *mut PolicyBuilder, n: c_uint, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_max_open_files( + b: *mut SandboxBuilder, n: c_uint, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.max_open_files(n))) @@ -507,9 +520,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_max_open_files( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_no_randomize_memory( - b: *mut PolicyBuilder, v: bool, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_no_randomize_memory( + b: *mut SandboxBuilder, v: bool, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.no_randomize_memory(v))) @@ -518,9 +531,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_no_randomize_memory( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_no_huge_pages( - b: *mut PolicyBuilder, v: bool, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_no_huge_pages( + b: *mut SandboxBuilder, v: bool, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.no_huge_pages(v))) @@ -529,9 +542,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_no_huge_pages( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_no_coredump( - b: *mut PolicyBuilder, v: bool, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_no_coredump( + b: *mut SandboxBuilder, v: bool, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.no_coredump(v))) @@ -540,16 +553,16 @@ pub unsafe extern "C" fn sandlock_policy_builder_no_coredump( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_deterministic_dirs( - b: *mut PolicyBuilder, v: bool, -) -> *mut PolicyBuilder { +pub unsafe extern "C" fn sandlock_sandbox_builder_deterministic_dirs( + b: *mut SandboxBuilder, v: bool, +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); Box::into_raw(Box::new(builder.deterministic_dirs(v))) } // ---------------------------------------------------------------- -// Policy Builder — build & free +// Sandbox Builder — build & free // ---------------------------------------------------------------- /// Consume the builder and produce a policy. @@ -564,9 +577,9 @@ pub unsafe extern "C" fn sandlock_policy_builder_deterministic_dirs( /// be null. When `err_msg` is non-null, it must point to writable /// storage for one `*mut c_char`. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_build( - b: *mut PolicyBuilder, err: *mut c_int, err_msg: *mut *mut c_char, -) -> *mut sandlock_policy_t { +pub unsafe extern "C" fn sandlock_sandbox_build( + b: *mut SandboxBuilder, err: *mut c_int, err_msg: *mut *mut c_char, +) -> *mut sandlock_sandbox_t { if !err_msg.is_null() { *err_msg = ptr::null_mut(); } if b.is_null() { // Null-builder is a programmer error in the binding layer, @@ -581,7 +594,7 @@ pub unsafe extern "C" fn sandlock_policy_build( match builder.build() { Ok(policy) => { if !err.is_null() { *err = 0; } - Box::into_raw(Box::new(sandlock_policy_t { _private: policy })) + Box::into_raw(Box::new(sandlock_sandbox_t { _private: policy })) } Err(e) => { if !err.is_null() { *err = -1; } @@ -600,9 +613,9 @@ pub unsafe extern "C" fn sandlock_policy_build( } /// # Safety -/// `p` must be null or a valid pointer from `sandlock_policy_build`. +/// `p` must be null or a valid pointer from `sandlock_sandbox_build`. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_free(p: *mut sandlock_policy_t) { +pub unsafe extern "C" fn sandlock_sandbox_free(p: *mut sandlock_sandbox_t) { if !p.is_null() { drop(Box::from_raw(p)); } } @@ -617,15 +630,15 @@ pub unsafe extern "C" fn sandlock_policy_free(p: *mut sandlock_policy_t) { /// `policy` must be a valid policy pointer. #[no_mangle] pub unsafe extern "C" fn sandlock_confine( - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, ) -> c_int { if policy.is_null() { return -1; } let policy = &(*policy)._private; - let policy = match sandlock_core::ConfinePolicy::try_from(policy) { - Ok(policy) => policy, + let confinement = match sandlock_core::sandbox::Confinement::try_from(policy) { + Ok(c) => c, Err(_) => return -1, }; - match sandlock_core::confine(&policy) { + match sandlock_core::confine(&confinement) { Ok(()) => 0, Err(_) => -1, } @@ -643,7 +656,7 @@ pub unsafe extern "C" fn sandlock_confine( /// `argv` must point to `argc` C strings. #[no_mangle] pub unsafe extern "C" fn sandlock_run( - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, name: *const c_char, argv: *const *const c_char, argc: c_uint, @@ -661,7 +674,11 @@ pub unsafe extern "C" fn sandlock_run( Ok(rt) => rt, Err(_) => return ptr::null_mut(), }; - match rt.block_on(Sandbox::run(policy, name.as_deref(), &arg_refs)) { + let mut sb = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), + }; + match rt.block_on(sb.run(&arg_refs)) { Ok(result) => Box::into_raw(Box::new(sandlock_result_t { _private: result })), Err(_) => ptr::null_mut(), } @@ -689,7 +706,7 @@ pub struct sandlock_handle_t { /// `argv` must point to `argc` C strings. #[no_mangle] pub unsafe extern "C" fn sandlock_spawn( - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, name: *const c_char, argv: *const *const c_char, argc: c_uint, @@ -708,9 +725,9 @@ pub unsafe extern "C" fn sandlock_spawn( Err(_) => return ptr::null_mut(), }; - let mut sb = match Sandbox::new(policy, name.as_deref()) { - Ok(sb) => sb, - Err(_) => return ptr::null_mut(), + let mut sb = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), }; if rt.block_on(sb.spawn_captured(&arg_refs)).is_err() { @@ -822,7 +839,7 @@ pub unsafe extern "C" fn sandlock_handle_free(h: *mut sandlock_handle_t) { /// `argv` must point to `argc` C strings. #[no_mangle] pub unsafe extern "C" fn sandlock_run_interactive( - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, name: *const c_char, argv: *const *const c_char, argc: c_uint, @@ -840,7 +857,11 @@ pub unsafe extern "C" fn sandlock_run_interactive( Ok(rt) => rt, Err(_) => return -1, }; - match rt.block_on(Sandbox::run_interactive(policy, name.as_deref(), &arg_refs)) { + let mut sb = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), + }; + match rt.block_on(sb.run_interactive(&arg_refs)) { Ok(result) => result.code().unwrap_or(-1), Err(_) => -1, } @@ -970,7 +991,7 @@ pub struct sandlock_dry_run_result_t { /// `argv` must point to `argc` C strings. #[no_mangle] pub unsafe extern "C" fn sandlock_dry_run( - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, name: *const c_char, argv: *const *const c_char, argc: c_uint, @@ -988,7 +1009,11 @@ pub unsafe extern "C" fn sandlock_dry_run( Ok(rt) => rt, Err(_) => return ptr::null_mut(), }; - match rt.block_on(Sandbox::dry_run(policy, name.as_deref(), &arg_refs)) { + let mut sb = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), + }; + match rt.block_on(sb.dry_run(&arg_refs)) { Ok(result) => Box::into_raw(Box::new(sandlock_dry_run_result_t { _private: result })), Err(_) => ptr::null_mut(), } @@ -1118,7 +1143,7 @@ pub extern "C" fn sandlock_pipeline_new() -> *mut sandlock_pipeline_t { #[no_mangle] pub unsafe extern "C" fn sandlock_pipeline_add_stage( pipe: *mut sandlock_pipeline_t, - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, argv: *const *const c_char, argc: c_uint, ) { @@ -1188,8 +1213,8 @@ pub unsafe extern "C" fn sandlock_pipeline_free(pipe: *mut sandlock_pipeline_t) #[allow(non_camel_case_types)] pub struct sandlock_gather_t { - sources: Vec<(String, sandlock_core::Policy, Vec)>, - consumer: Option<(sandlock_core::Policy, Vec)>, + sources: Vec<(String, sandlock_core::Sandbox, Vec)>, + consumer: Option<(sandlock_core::Sandbox, Vec)>, } /// Create a new empty gather. @@ -1209,7 +1234,7 @@ pub extern "C" fn sandlock_gather_new() -> *mut sandlock_gather_t { pub unsafe extern "C" fn sandlock_gather_add_source( g: *mut sandlock_gather_t, name: *const c_char, - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, argv: *const *const c_char, argc: c_uint, ) { @@ -1227,7 +1252,7 @@ pub unsafe extern "C" fn sandlock_gather_add_source( #[no_mangle] pub unsafe extern "C" fn sandlock_gather_set_consumer( g: *mut sandlock_gather_t, - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, argv: *const *const c_char, argc: c_uint, ) { @@ -1289,7 +1314,7 @@ pub unsafe extern "C" fn sandlock_gather_free(g: *mut sandlock_gather_t) { } // ---------------------------------------------------------------- -// Policy callback (policy_fn) +// Sandbox callback (policy_fn) // ---------------------------------------------------------------- /// C-compatible syscall event passed to the policy callback. @@ -1332,10 +1357,10 @@ pub type sandlock_policy_fn_t = unsafe extern "C" fn( /// `b` must be a valid builder pointer. `cb` must be a valid function pointer /// that remains valid for the lifetime of the sandbox. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_policy_fn( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_sandbox_builder_policy_fn( + b: *mut SandboxBuilder, cb: sandlock_policy_fn_t, -) -> *mut PolicyBuilder { +) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); @@ -1525,7 +1550,7 @@ pub type sandlock_work_fn_t = unsafe extern "C" fn(clone_id: u32); /// be valid function pointers. #[no_mangle] pub unsafe extern "C" fn sandlock_new_with_fns( - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, name: *const c_char, init_fn: sandlock_init_fn_t, work_fn: sandlock_work_fn_t, @@ -1540,10 +1565,12 @@ pub unsafe extern "C" fn sandlock_new_with_fns( let init = move || { unsafe { init_fn() } }; let work = move |id: u32| { unsafe { work_fn(id) } }; - match Sandbox::new_with_fns(policy, name.as_deref(), init, work) { - Ok(sb) => Box::into_raw(Box::new(sb)), - Err(_) => ptr::null_mut(), - } + let sb = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), + }; + let sb = sb.with_init_fn(init).with_work_fn(work); + Box::into_raw(Box::new(sb)) } /// Opaque handle for fork result (holds clone handles with pipes). @@ -1598,7 +1625,7 @@ pub unsafe extern "C" fn sandlock_fork_result_pid(r: *const sandlock_fork_result #[no_mangle] pub unsafe extern "C" fn sandlock_reduce( fork_result: *mut sandlock_fork_result_t, - policy: *const sandlock_policy_t, + policy: *const sandlock_sandbox_t, name: *const c_char, argv: *const *const c_char, argc: c_uint, @@ -1620,12 +1647,12 @@ pub unsafe extern "C" fn sandlock_reduce( Err(_) => return ptr::null_mut(), }; - let reducer = match Sandbox::new(policy, name.as_deref()) { - Ok(r) => r, - Err(_) => return ptr::null_mut(), + let reducer = match name { + Some(ref n) => policy.clone().with_name(n.clone()), + None => policy.clone(), }; - match rt.block_on(reducer.reduce(&arg_refs, &mut fr.clones)) { + match rt.block_on(reducer.reduce(&arg_refs, &mut fr.clones.as_mut_slice())) { Ok(result) => Box::into_raw(Box::new(sandlock_result_t { _private: result })), Err(_) => ptr::null_mut(), } @@ -1657,12 +1684,12 @@ pub unsafe extern "C" fn sandlock_wait(sb: *mut Sandbox) -> c_int { } } -/// Free a sandbox handle. +/// Free a Sandbox handle created by `sandlock_new_with_fns`. /// /// # Safety -/// `sb` must be null or a valid sandbox pointer. +/// `sb` must be null or a valid Sandbox pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_sandbox_free(sb: *mut Sandbox) { +pub unsafe extern "C" fn sandlock_sandbox_fns_free(sb: *mut Sandbox) { if !sb.is_null() { drop(Box::from_raw(sb)); } } diff --git a/python/README.md b/python/README.md index a05157e..f47eb93 100644 --- a/python/README.md +++ b/python/README.md @@ -13,13 +13,13 @@ pip install sandlock ## Quick start ```python -from sandlock import Sandbox, Policy +from sandlock import Sandbox -policy = Policy( +sandbox = Sandbox( fs_readable=["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"], fs_writable=["/tmp"], ) -result = Sandbox(policy).run(["echo", "hello"], timeout=10) +result = sandbox.run(["echo", "hello"], timeout=10) assert result.success print(result.stdout) # b"hello\n" ``` @@ -37,14 +37,37 @@ Returns -1 if Landlock is unavailable. Return the minimum Landlock ABI version required by sandlock (currently 6). -### Policy +### Sandbox ```python -sandlock.Policy(**kwargs) +sandlock.Sandbox(**kwargs) ``` -An immutable (frozen dataclass) sandbox policy. All fields are optional. -Unset fields mean "no restriction" unless noted otherwise. +Sandbox configuration and runtime handle. Holds both the policy (filesystem, +network, resource limits, etc.) and runtime state. Construct once, then call +`run()`, `start()` + lifecycle methods, or use as a context manager. + +All config fields are optional. Unset fields mean "no restriction" unless +noted otherwise. Runtime kwargs (`name`, `policy_fn`, `init_fn`, `work_fn`) +are set at construction time alongside config fields. + +A single `Sandbox` instance holds at most one running process at a time. +For concurrent execution, create multiple instances. + +**Runtime kwargs:** + +- `name` -- sandbox name (also its virtual hostname inside the sandbox). + Auto-generated as `sandbox-{pid}` when omitted. +- `policy_fn` -- optional callback for dynamic per-event policy decisions + (see [Dynamic policy](#dynamic-policy)). +- `init_fn` / `work_fn` -- callbacks for COW fork mode (see [Fork](#fork)). + +`Sandbox` is a context manager: + +```python +with Sandbox(fs_readable=["/usr", "/lib"]) as sb: + result = sb.run(["echo", "hello"]) +``` #### Filesystem (Landlock) @@ -76,9 +99,9 @@ denied by default. Block rules are checked first and take precedence. |-----------|------|---------|-------------| | `http_allow` | `list[str]` | `[]` | Allow rules in `"METHOD host/path"` format | | `http_deny` | `list[str]` | `[]` | Block rules in `"METHOD host/path"` format | -| `http_ports` | `list[int]` | `[80]` | TCP ports to intercept (443 added when `https_ca` is set) | -| `https_ca` | `str \| None` | `None` | CA certificate for HTTPS MITM | -| `https_key` | `str \| None` | `None` | CA private key for HTTPS MITM | +| `http_ports` | `list[int]` | `[80]` | TCP ports to intercept (443 added when `http_ca` is set) | +| `http_ca` | `str \| None` | `None` | CA certificate for HTTPS MITM | +| `http_key` | `str \| None` | `None` | CA private key for HTTPS MITM | Rule format: `"METHOD host/path"` where method and host can be `*` for wildcard, and path supports trailing `*` for prefix matching. Paths are @@ -86,7 +109,7 @@ normalized (percent-decoding, `..` resolution, `//` collapsing) before matching to prevent bypasses. ```python -policy = Policy( +sandbox = Sandbox( fs_readable=["/usr", "/lib", "/etc"], http_allow=[ "GET docs.python.org/*", @@ -94,7 +117,7 @@ policy = Policy( ], http_deny=["* */admin/*"], ) -result = Sandbox(policy).run(["python3", "agent.py"]) +result = sandbox.run(["python3", "agent.py"]) ``` #### Chroot with mount mapping @@ -104,19 +127,19 @@ but without kernel bind mounts or root privileges. Each sandbox gets its own persistent workspace while sharing a read-only rootfs. ```python -policy = Policy( +sandbox = Sandbox( chroot="/opt/rootfs", fs_mount={"/work": "/tmp/sandbox-1/work"}, fs_readable=["/usr", "/bin", "/lib", "/etc"], cwd="/work", ) -result = Sandbox(policy).run(["python3", "task.py"]) +result = sandbox.run(["python3", "task.py"]) ``` Combine with `workdir` + `max_disk` for quota-enforced writes: ```python -policy = Policy( +sandbox = Sandbox( chroot="/opt/rootfs", fs_mount={"/work": "/tmp/sandbox-1/work"}, workdir="/tmp/sandbox-1/work", @@ -142,7 +165,8 @@ policy = Policy( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `block_syscalls` | `list[str]` | `[]` | Extra syscalls to block in addition to Sandlock defaults | +| `extra_deny_syscalls` | `list[str]` | `[]` | Extra syscall names to block in addition to Sandlock defaults | +| `extra_allow_syscalls` | `list[str]` | `[]` | Syscall groups to allow that are blocked by default (e.g. `"sysv_ipc"` to enable SysV shared memory, semaphores, and message queues) | Sandlock always applies its default syscall blocklist. @@ -186,28 +210,6 @@ Sandlock always applies its default syscall blocklist. | `on_exit` | `BranchAction` | `COMMIT` | `COMMIT`, `ABORT`, or `KEEP` | | `on_error` | `BranchAction` | `ABORT` | `COMMIT`, `ABORT`, or `KEEP` | -### Sandbox - -```python -sandlock.Sandbox(policy, policy_fn=None, init_fn=None, work_fn=None, name=None) -``` - -Create a sandbox from a `Policy`. - -- `policy` -- a `Policy` instance. -- `name` -- sandbox name (also exposed as the virtual hostname inside the sandbox). - Auto-generated as `sandbox-{pid}` when omitted. -- `policy_fn` -- optional callback for dynamic per-event decisions (see - [Dynamic policy](#dynamic-policy)). -- `init_fn` / `work_fn` -- callbacks for COW fork mode (see [Fork](#fork)). - -Sandbox is a context manager: - -```python -with Sandbox(policy) as sb: - result = sb.run(["echo", "hello"]) -``` - #### `sandbox.run(cmd, timeout=None) -> Result` Run a command, capturing stdout and stderr. @@ -216,16 +218,27 @@ Run a command, capturing stdout and stderr. - `timeout` -- max execution time in seconds (float). `None` = no timeout. ```python -result = Sandbox(policy).run(["python3", "-c", "print(42)"], timeout=10.0) +result = sandbox.run(["python3", "-c", "print(42)"], timeout=10.0) ``` +#### `sandbox.start(cmd) -> None` + +Spawn `cmd` without waiting. Use `pid`, `pause()`, `resume()`, `kill()`, +and `wait()` to manage the process lifecycle. + +Raises `RuntimeError` if a process is already running. + +#### `sandbox.wait() -> Result` + +Wait for the running process to finish and return its `Result`. + #### `sandbox.dry_run(cmd, timeout=None) -> DryRunResult` Run a command in a temporary COW layer, then discard all writes. Returns the list of filesystem changes that would have been made. ```python -result = Sandbox(policy).dry_run(["sh", "-c", "echo hi > /tmp/out.txt"]) +result = sandbox.dry_run(["sh", "-c", "echo hi > /tmp/out.txt"]) for change in result.changes: print(change.kind, change.path) # "A /tmp/out.txt" ``` @@ -242,14 +255,18 @@ The sandbox name. The child PID while running, `None` otherwise. +#### `sandbox.is_running -> bool` + +`True` if a process is currently running in this sandbox. + #### `sandbox.ports() -> dict[int, int]` Current port mappings `{virtual_port: real_port}` while running. Only contains entries where port remapping occurred. Requires `port_remap=True`. -#### `sandbox.pause()` / `sandbox.resume()` +#### `sandbox.pause()` / `sandbox.resume()` / `sandbox.kill()` -Send SIGSTOP / SIGCONT to the sandbox process group. +Send SIGSTOP / SIGCONT / SIGKILL to the sandbox process group. Raises `RuntimeError` if the sandbox is not running. #### `sandbox.checkpoint(save_fn=None) -> Checkpoint` @@ -291,8 +308,8 @@ Chain sandboxed commands with pipes using the `|` operator: ```python result = ( - Sandbox(policy_a).cmd(["echo", "hello"]) - | Sandbox(policy_b).cmd(["tr", "a-z", "A-Z"]) + sandbox_a.cmd(["echo", "hello"]) + | sandbox_b.cmd(["tr", "a-z", "A-Z"]) ).run() assert result.stdout == b"HELLO\n" ``` @@ -310,7 +327,7 @@ Run the pipeline. Each stage's stdout feeds the next stage's stdin. Use `policy_fn` to make per-syscall decisions at runtime: ```python -from sandlock import Sandbox, Policy, SyscallEvent, PolicyContext +from sandlock import Sandbox, SyscallEvent, PolicyContext def my_policy(event: SyscallEvent, ctx: PolicyContext): if event.category == "network" and event.host == "evil.com": @@ -320,7 +337,7 @@ def my_policy(event: SyscallEvent, ctx: PolicyContext): return True # deny return False # allow -sb = Sandbox(Policy(...), policy_fn=my_policy) +sb = Sandbox(..., policy_fn=my_policy) ``` #### SyscallEvent @@ -370,7 +387,8 @@ Callback return values: COW fork for parallel execution with shared initialization: ```python -sb = Sandbox(policy, +sb = Sandbox( + fs_readable=[...], init_fn=lambda: load_model(), work_fn=lambda clone_id: process(clone_id), ) @@ -382,7 +400,7 @@ clones = sb.fork(4) # returns ForkResult with .pids Pipe combined clone output into a reducer command: ```python -result = Sandbox(policy).reduce(["python3", "sum.py"], clones) +result = sandbox.reduce(["python3", "sum.py"], clones) ``` ### Checkpoint @@ -390,7 +408,7 @@ result = Sandbox(policy).reduce(["python3", "sum.py"], clones) Save and restore sandbox state: ```python -sb = Sandbox(policy) +sb = Sandbox(...) # ... start a long-running process ... cp = sb.checkpoint(save_fn=lambda: my_state_bytes()) cp.save("my-snapshot") @@ -414,13 +432,13 @@ Default store: `~/.sandlock/checkpoints/`. ### Profiles -Load policies from TOML files: -Profiles contain policy only; pass the sandbox instance name to `Sandbox(..., name=...)`. +Load sandbox configuration from TOML files: +Profiles contain sandbox config only; pass the sandbox name at construction: `Sandbox(..., name=...)`. ```python from sandlock import load_profile, list_profiles -policy = load_profile("web-scraper") +sandbox = load_profile("web-scraper") names = list_profiles() ``` @@ -428,8 +446,8 @@ names = list_profiles() ``` SandlockError (base) - +-- PolicyError invalid policy configuration - +-- SandboxError sandbox lifecycle errors + +-- SandboxError invalid sandbox configuration + +-- SandboxRuntimeError sandbox lifecycle errors +-- ForkError fork failed +-- ChildError child exited abnormally +-- BranchError BranchFS operation failed @@ -445,7 +463,7 @@ All exceptions are importable from `sandlock.exceptions` or directly from `sandlock`: ```python -from sandlock import SandlockError, SandboxError, PolicyError +from sandlock import SandlockError, SandboxError, SandboxRuntimeError ``` ### Enums @@ -510,7 +528,7 @@ permissions explicitly: | `env` | `{"KEY": "val"}` | Environment variables to pass | | `max_memory` | `"256M"` | Memory limit | -Any `Policy` field name is accepted as a capability key. +Any `Sandbox` field name is accepted as a capability key. #### `await mcp.add_mcp_session(session)` @@ -526,9 +544,9 @@ are forwarded to their server session. result = await mcp.call_tool("read_file", {"path": "data.txt"}) ``` -#### `mcp.get_policy(tool_name) -> Policy` +#### `mcp.get_policy(tool_name) -> Sandbox` -Return the computed `Policy` for a registered tool. +Return the computed `Sandbox` for a registered tool. #### `mcp.tool_definitions_openai() -> list[dict]` @@ -566,9 +584,9 @@ Claude Desktop configuration: } ``` -#### `policy_for_tool(*, workspace, capabilities=None) -> Policy` +#### `policy_for_tool(*, workspace, capabilities=None) -> Sandbox` -Build a deny-by-default `Policy` from explicit capabilities. Used +Build a deny-by-default `Sandbox` from explicit capabilities. Used internally by `McpSandbox` but available for direct use. #### `capabilities_from_mcp_tool(tool) -> dict` diff --git a/python/examples/basic.py b/python/examples/basic.py index 25f4aa4..657033a 100644 --- a/python/examples/basic.py +++ b/python/examples/basic.py @@ -2,13 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 """Basic Sandlock sandbox examples.""" -from sandlock import Sandbox, Policy +from sandlock import Sandbox + +# Minimum filesystem readable to exec common binaries. +_BASE_READ = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"] def example_run_command(): """Run a command in a sandbox.""" print("=== Run command ===") - result = Sandbox(Policy()).run(["echo", "Hello from sandbox!"]) + result = Sandbox(fs_readable=_BASE_READ).run( + ["echo", "Hello from sandbox!"] + ) print(f" success: {result.success}") print(f" stdout: {result.stdout.decode().strip()}") print() @@ -18,20 +23,22 @@ def example_run_python(): """Run a Python expression in a sandbox.""" print("=== Run Python ===") - result = Sandbox(Policy()).run(["python3", "-c", "print(2 ** 10)"]) + result = Sandbox(fs_readable=_BASE_READ).run( + ["python3", "-c", "print(2 ** 10)"] + ) print(f" success: {result.success}") print(f" stdout: {result.stdout.decode().strip()}") print() def example_with_policy(): - """Run with filesystem restrictions.""" + """Run with filesystem restrictions, including a writable scratch dir.""" print("=== With policy ===") - policy = Policy( - fs_readable=["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"], + sandbox = Sandbox( + fs_readable=_BASE_READ, fs_writable=["/tmp"], ) - result = Sandbox(policy).run(["ls", "/usr"]) + result = sandbox.run(["ls", "/usr"]) print(f" success: {result.success}") print(f" files: {result.stdout.decode().strip()[:100]}...") print() diff --git a/python/examples/fork.py b/python/examples/fork.py index 4480181..1601ada 100644 --- a/python/examples/fork.py +++ b/python/examples/fork.py @@ -13,7 +13,7 @@ import sys import time import ctypes -from sandlock import Sandbox, Policy +from sandlock import Sandbox # Unique token set by init(), never re-set _TOKEN = None @@ -85,14 +85,14 @@ def get_shared_pages(pid): def main(): - policy = Policy( - fs_writable=["/tmp"], - fs_readable=[sys.prefix, "/usr", "/lib", "/etc", "/proc", "/dev"], - ) - print("=== COW Clone Proof ===\n", flush=True) - with Sandbox(policy, init_fn=init, work_fn=work) as sb: + with Sandbox( + fs_writable=["/tmp"], + fs_readable=[sys.prefix, "/usr", "/lib", "/etc", "/proc", "/dev"], + init_fn=init, + work_fn=work, + ) as sb: # Fork 3 clones fork_result = sb.fork(3) diff --git a/python/examples/nested.py b/python/examples/nested.py index 72de78a..69d6e74 100644 --- a/python/examples/nested.py +++ b/python/examples/nested.py @@ -2,30 +2,28 @@ # SPDX-License-Identifier: Apache-2.0 """Nested sandbox example.""" -from sandlock import Sandbox, Policy +from sandlock import Sandbox def example_nested(): """Create nested sandboxes with progressively restrictive policies.""" print("=== Nested sandboxes ===") - outer_policy = Policy( + outer = Sandbox( fs_readable=["/usr", "/lib", "/lib64", "/bin", "/etc", "/proc", "/dev"], fs_writable=["/tmp"], ) - inner_policy = Policy( + inner = Sandbox( fs_readable=["/usr", "/lib", "/lib64", "/bin"], fs_writable=[], ) # Outer sandbox can write to /tmp - sb = Sandbox(outer_policy) - result = sb.run(["python3", "-c", "print('outer ok')"]) + result = outer.run(["python3", "-c", "print('outer ok')"]) print(f" outer: {result.success} — {result.stdout.decode().strip()}") # Inner sandbox: more restrictive (independent sandbox with tighter policy) - inner = Sandbox(inner_policy) result = inner.run(["echo", "inner ok"]) print(f" inner: {result.success} — {result.stdout.decode().strip()}") print() diff --git a/python/examples/prompt_injection_defense.py b/python/examples/prompt_injection_defense.py index 8511346..547d7fd 100644 --- a/python/examples/prompt_injection_defense.py +++ b/python/examples/prompt_injection_defense.py @@ -202,7 +202,7 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): The executor sees the data but has no network access. Even if the LLM were somehow tricked, exfiltration is blocked. """ - from sandlock import Sandbox, Policy + from sandlock import Sandbox print("=" * 60) print("DEMO 2: WITH SANDLOCK XOA — planner/executor pipeline") @@ -230,7 +230,7 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): # # Data flows: planner stdout ──pipe──▶ executor stdin - planner_policy = Policy( + planner = Sandbox( fs_readable=list(dict.fromkeys([ "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", "/dev", python_prefix, @@ -241,7 +241,7 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): # NO workspace in fs_readable — planner cannot see data files ) - executor_policy = Policy( + executor = Sandbox( fs_readable=list(dict.fromkeys([ workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", python_prefix, @@ -299,12 +299,8 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): print() result = ( - Sandbox(planner_policy).cmd( - [sys.executable, "-c", planner_script] - ) - | Sandbox(executor_policy).cmd( - [sys.executable, "-"] # reads script from stdin - ) + planner.cmd([sys.executable, "-c", planner_script]) + | executor.cmd([sys.executable, "-"]) # reads script from stdin ).run(timeout=30) if result.stderr: diff --git a/python/examples/supply_chain_defense.py b/python/examples/supply_chain_defense.py index f660569..84fc4da 100644 --- a/python/examples/supply_chain_defense.py +++ b/python/examples/supply_chain_defense.py @@ -19,7 +19,7 @@ import tempfile import threading -from sandlock import Sandbox, Policy +from sandlock import Sandbox def main(): @@ -73,16 +73,14 @@ def serve(): """) python_paths = [p for p in sys.path if p and os.path.isdir(p)] - policy = Policy( - fs_readable=["/usr", "/lib", "/lib64", "/bin", - "/etc", "/dev", "/tmp", workspace] + python_paths, - fs_writable=[workspace, "/tmp"], - ) + fs_readable = ["/usr", "/lib", "/lib64", "/bin", + "/etc", "/dev", "/tmp", workspace] + python_paths + fs_writable = [workspace, "/tmp"] # --- Run 1: no defense --- print("=== Without policy_fn ===", flush=True) received.clear() - with Sandbox(policy) as sb: + with Sandbox(fs_readable=fs_readable, fs_writable=fs_writable) as sb: result = sb.run(["python3", agent_py]) print(result.stdout.decode(), end="", flush=True) print(f"Secret leaked: {bool(received)}\n", flush=True) @@ -102,7 +100,11 @@ def install_guard(event, ctx): ctx.restrict_pid_network(event.pid, []) break - with Sandbox(policy, policy_fn=install_guard) as sb: + with Sandbox( + fs_readable=fs_readable, + fs_writable=fs_writable, + policy_fn=install_guard, + ) as sb: result = sb.run(["python3", agent_py]) print(result.stdout.decode(), end="", flush=True) print(f"Secret leaked: {bool(received)}", flush=True) diff --git a/python/examples/web_search_injection_defense.py b/python/examples/web_search_injection_defense.py index 6b71c93..a2b6082 100644 --- a/python/examples/web_search_injection_defense.py +++ b/python/examples/web_search_injection_defense.py @@ -29,7 +29,7 @@ import threading from openai import OpenAI -from sandlock import Sandbox, Policy +from sandlock import Sandbox # --------------------------------------------------------------------------- # Attacker's exfil listener @@ -203,27 +203,27 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): "/dev", python_prefix, ] + python_paths)) - # Planner policy (shared by planner1 + planner2): reach OpenAI, + # Planner sandbox (shared by planner1 + planner2): reach OpenAI, # NO filesystem access to the data file. - planner_policy = Policy( + planner = Sandbox( fs_readable=base_readable, net_allow=["api.openai.com:443"], clean_env=True, env={"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]}, ) - # Searcher policy: can read the data file, NO network. Receives the + # Searcher sandbox: can read the data file, NO network. Receives the # query as a command-line arg from the orchestrator. - searcher_policy = Policy( + searcher = Sandbox( fs_readable=base_readable + [workspace], net_allow=[], clean_env=True, env={"DATA_FILE": data_path}, ) - # Executor policy: NO network, NO direct data access. Receives both + # Executor sandbox: NO network, NO direct data access. Receives both # the code and the raw results via gather pipes. - executor_policy = Policy( + executor = Sandbox( fs_readable=base_readable + ["/home"], # for sandlock imports net_allow=[], clean_env=True, @@ -255,7 +255,7 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): print() print("[planner1] asking LLM for a search query (sees task only)...") sys.stdout.flush() - q_res = Sandbox(planner_policy).cmd( + q_res = planner.cmd( [sys.executable, "-c", planner1_script] ).run(timeout=30) query = (q_res.stdout.decode().strip().splitlines() or [""])[-1] @@ -328,15 +328,9 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): sys.stdout.flush() result = ( - Sandbox(searcher_policy).cmd( - [sys.executable, "-c", searcher_script, query] - ).as_("data") - + Sandbox(planner_policy).cmd( - [sys.executable, "-c", planner2_script] - ).as_("code") - | Sandbox(executor_policy).cmd( - [sys.executable, "-c", executor_script] - ) + searcher.cmd([sys.executable, "-c", searcher_script, query]).as_("data") + + planner.cmd([sys.executable, "-c", planner2_script]).as_("code") + | executor.cmd([sys.executable, "-c", executor_script]) ).run(timeout=30) if result.stderr: diff --git a/python/src/sandlock/__init__.py b/python/src/sandlock/__init__.py index e31f1a0..e39ed69 100644 --- a/python/src/sandlock/__init__.py +++ b/python/src/sandlock/__init__.py @@ -7,12 +7,12 @@ from ._version import __version__ from ._sdk import ( - Sandbox, Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, + Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, NamedStage, Gather, GatherPipeline, landlock_abi_version, min_landlock_abi, confine, ) from .inputs import inputs -from .policy import Policy, FsIsolation, BranchAction, parse_ports, Change, DryRunResult +from .sandbox import Sandbox, FsIsolation, BranchAction, parse_ports, Change, DryRunResult from ._profile import load_profile, list_profiles from .exceptions import ( SandlockError, @@ -43,7 +43,6 @@ "Gather", "GatherPipeline", "inputs", - "Policy", "FsIsolation", "BranchAction", "parse_ports", diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index dad724d..53845b1 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -1,8 +1,27 @@ # SPDX-License-Identifier: Apache-2.0 """TOML profile loading for Sandlock. -Profiles are stored as TOML files under ``~/.config/sandlock/profiles/``. -Field names match ``Policy`` exactly — no translation layer. +Profiles use the sectioned policy schema (the same one parsed by the +Rust CLI). Each section maps to a subset of ``Sandbox`` fields: + + [config] → http_ca, http_key, fs_storage, workdir + [determinism] → random_seed, time_start, deterministic_dirs, + no_randomize_memory + [program] → env, cwd, uid, clean_env, no_coredump, no_huge_pages + (``exec`` and ``args`` are runtime program identity + and are silently ignored — pass them to + ``sandbox.run(cmd)`` instead) + [filesystem] → fs_readable (read), fs_writable (write), + fs_denied (deny), fs_isolation (isolation), chroot, + fs_mount (mount), on_exit, on_error + [network] → net_bind (bind), net_allow (allow), port_remap + [http] → http_ports (ports), http_allow (allow), + http_deny (deny) + [syscalls] → extra_allow_syscalls (extra_allow), + extra_deny_syscalls (extra_deny) + [limits] → max_memory (memory), max_processes (processes), + max_open_files (open_files), max_cpu (cpu), + max_disk (disk), gpu_devices, cpu_cores, num_cpus """ from __future__ import annotations @@ -13,55 +32,79 @@ import tomllib else: import tomli as tomllib + from pathlib import Path +from typing import Any from .exceptions import PolicyError -from .policy import Policy, FsIsolation, BranchAction +from .sandbox import BranchAction, FsIsolation, Sandbox _PROFILES_DIR = Path("~/.config/sandlock/profiles").expanduser() -# Policy fields settable from TOML (excludes notif_policy which needs Python objects). -_SIMPLE_FIELDS: dict[str, type] = { - # Filesystem - "fs_writable": list, - "fs_readable": list, - "fs_denied": list, - # Extra syscall blocklist entries - "block_syscalls": list, - # Network - "net_allow": list, - "net_bind": list, - # Resources - "max_memory": str, - "max_processes": int, - "max_open_files": int, - "max_cpu": int, - "num_cpus": int, - "cpu_cores": list, - # Chroot - "chroot": str, - "fs_mount": dict, - # Environment - "clean_env": bool, - "env": dict, - # Deterministic - "random_seed": int, - "no_randomize_memory": bool, - "no_huge_pages": bool, - "deterministic_dirs": bool, - "no_coredump": bool, - # Misc - "port_remap": bool, - "uid": int, - # Workdir - "workdir": str, - # COW isolation - "fs_isolation": str, - "fs_storage": str, - "max_disk": str, - "on_exit": str, - "on_error": str, + +# Per-section schema. Each entry maps a TOML field name to +# (sandbox-attribute name, expected python type). A sandbox-attribute +# name of ``None`` means the field is recognised but silently ignored +# (used for [program].exec and [program].args, which are runtime +# program identity, not Sandbox config). +_SECTIONS: dict[str, dict[str, tuple[str | None, type]]] = { + "config": { + "http_ca": ("http_ca", str), + "http_key": ("http_key", str), + "fs_storage": ("fs_storage", str), + "workdir": ("workdir", str), + }, + "determinism": { + "random_seed": ("random_seed", int), + "time_start": ("time_start", str), + "deterministic_dirs": ("deterministic_dirs", bool), + "no_randomize_memory": ("no_randomize_memory", bool), + }, + "program": { + "exec": (None, str), + "args": (None, list), + "env": ("env", dict), + "cwd": ("cwd", str), + "uid": ("uid", int), + "clean_env": ("clean_env", bool), + "no_coredump": ("no_coredump", bool), + "no_huge_pages": ("no_huge_pages", bool), + }, + "filesystem": { + "read": ("fs_readable", list), + "write": ("fs_writable", list), + "deny": ("fs_denied", list), + "isolation": ("fs_isolation", str), + "chroot": ("chroot", str), + "mount": ("fs_mount", list), + "on_exit": ("on_exit", str), + "on_error": ("on_error", str), + }, + "network": { + "bind": ("net_bind", list), + "allow": ("net_allow", list), + "port_remap": ("port_remap", bool), + }, + "http": { + "ports": ("http_ports", list), + "allow": ("http_allow", list), + "deny": ("http_deny", list), + }, + "syscalls": { + "extra_allow": ("extra_allow_syscalls", list), + "extra_deny": ("extra_deny_syscalls", list), + }, + "limits": { + "memory": ("max_memory", str), + "processes": ("max_processes", int), + "open_files": ("max_open_files", int), + "cpu": ("max_cpu", int), + "disk": ("max_disk", str), + "gpu_devices": ("gpu_devices", list), + "cpu_cores": ("cpu_cores", list), + "num_cpus": ("num_cpus", int), + }, } @@ -79,11 +122,8 @@ def list_profiles() -> list[str]: ) -def load_profile(name: str) -> Policy: - """Load a named profile and return a Policy. - - Args: - name: Profile name (without .toml extension). +def load_profile(name: str) -> Sandbox: + """Load a named profile and return a Sandbox. Raises: PolicyError: If the profile doesn't exist or has invalid fields. @@ -94,8 +134,8 @@ def load_profile(name: str) -> Policy: return load_profile_path(path) -def load_profile_path(path: Path) -> Policy: - """Load a profile from a file path and return a Policy. +def load_profile_path(path: Path) -> Sandbox: + """Load a profile from a file path and return a Sandbox. Raises: PolicyError: If the file can't be parsed or has invalid fields. @@ -109,72 +149,121 @@ def load_profile_path(path: Path) -> Policy: return policy_from_dict(data, source=str(path)) -def policy_from_dict(data: dict, source: str = "") -> Policy: - """Construct a Policy from a parsed TOML dict. +def policy_from_dict(data: dict, source: str = "") -> Sandbox: + """Construct a Sandbox from a parsed sectioned-TOML dict. + + Each top-level key must be a known schema section (``config``, + ``determinism``, ``program``, ``filesystem``, ``network``, ``http``, + ``syscalls``, ``limits``). Within each section, only the documented + fields are accepted. Raises: - PolicyError: If unknown keys or type mismatches are found. + PolicyError: If unknown section / field names appear or types mismatch. """ - if "name" in data: + if not isinstance(data, dict): raise PolicyError( - f"{source}: field 'name' is not policy; pass the sandbox name to Sandbox(..., name=...)" + f"{source}: expected a TOML table at the top level, " + f"got {type(data).__name__}" ) - unknown = set(data.keys()) - set(_SIMPLE_FIELDS.keys()) - if unknown: + + unknown_sections = set(data.keys()) - set(_SECTIONS.keys()) + if unknown_sections: raise PolicyError( - f"unknown fields in {source}: {', '.join(sorted(unknown))}" + f"{source}: unknown section(s): " + f"{', '.join(sorted(unknown_sections))}" ) - kwargs: dict = {} - for key, value in data.items(): - expected = _SIMPLE_FIELDS[key] - - # Enum conversions - if key == "fs_isolation": - try: - kwargs[key] = FsIsolation(value) - except ValueError: - raise PolicyError( - f"{source}: fs_isolation must be 'none', 'branchfs', or 'overlayfs', " - f"got {value!r}" - ) - continue - if key in ("on_exit", "on_error"): - try: - kwargs[key] = BranchAction(value) - except ValueError: - raise PolicyError( - f"{source}: {key} must be 'commit', 'abort', or 'keep', " - f"got {value!r}" - ) - continue - # Type checking - if not isinstance(value, expected): + + kwargs: dict[str, Any] = {} + + for section_name, section_data in data.items(): + if not isinstance(section_data, dict): + raise PolicyError( + f"{source}: [{section_name}] must be a TOML table, " + f"got {type(section_data).__name__}" + ) + schema = _SECTIONS[section_name] + unknown_fields = set(section_data.keys()) - set(schema.keys()) + if unknown_fields: raise PolicyError( - f"{source}: field '{key}' expected {expected.__name__}, " - f"got {type(value).__name__}" + f"{source}: unknown field(s) in [{section_name}]: " + f"{', '.join(sorted(unknown_fields))}" ) + for toml_key, value in section_data.items(): + sandbox_key, expected_type = schema[toml_key] + if sandbox_key is None: + # [program].exec / [program].args — silently ignored. + continue + if not isinstance(value, expected_type): + raise PolicyError( + f"{source}: [{section_name}].{toml_key} expected " + f"{expected_type.__name__}, got {type(value).__name__}" + ) + value = _coerce(section_name, toml_key, sandbox_key, value, source) + kwargs[sandbox_key] = value - # Coerce TOML integers in lists to strings for port specs - if key in ("net_bind",) and isinstance(value, list): - value = [str(v) if isinstance(v, int) else v for v in value] + return Sandbox(**kwargs) - kwargs[key] = value - return Policy(**kwargs) +def _coerce( + section: str, toml_key: str, sandbox_key: str, value: Any, source: str +) -> Any: + """Per-field value coercion (enums, mount-spec parsing, port lists).""" + if sandbox_key == "fs_isolation": + try: + return FsIsolation(value) + except ValueError: + raise PolicyError( + f"{source}: [{section}].{toml_key} must be " + f"'none', 'overlayfs', or 'branchfs', got {value!r}" + ) + if sandbox_key in ("on_exit", "on_error"): + try: + return BranchAction(value) + except ValueError: + raise PolicyError( + f"{source}: [{section}].{toml_key} must be " + f"'commit', 'abort', or 'keep', got {value!r}" + ) + if sandbox_key == "fs_mount": + # TOML form is ``["VIRTUAL:HOST", ...]``; + # Sandbox.fs_mount is dict[str, str]. + mount: dict[str, str] = {} + for spec in value: + if not isinstance(spec, str): + raise PolicyError( + f"{source}: [{section}].{toml_key} entries must be " + f"'VIRTUAL:HOST' strings, got {type(spec).__name__}" + ) + if ":" not in spec: + raise PolicyError( + f"{source}: [{section}].{toml_key} entry {spec!r} " + "must be 'VIRTUAL:HOST'" + ) + virt, host = spec.split(":", 1) + if not virt or not host: + raise PolicyError( + f"{source}: [{section}].{toml_key} entry {spec!r} " + "requires both VIRTUAL and HOST to be non-empty" + ) + mount[virt] = host + return mount + if sandbox_key == "net_bind": + # Coerce TOML integers to strings for port specs (existing behaviour). + return [str(v) if isinstance(v, int) else v for v in value] + return value -def merge_cli_overrides(policy: Policy, overrides: dict) -> Policy: - """Return a new Policy with CLI overrides applied on top of a profile. +def merge_cli_overrides(policy: Sandbox, overrides: dict) -> Sandbox: + """Return a new Sandbox with CLI overrides applied on top of a profile. - List fields from CLI are appended to profile values. - Scalar fields from CLI replace profile values. + List fields from the CLI are appended to profile values. + Scalar fields from the CLI replace profile values. """ import dataclasses - merged = {} + merged: dict[str, Any] = {} for key, value in overrides.items(): current = getattr(policy, key, None) - # Append lists, replace everything else if isinstance(current, (list, tuple)) and isinstance(value, list): merged[key] = list(current) + value else: diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 8b2b139..37626e9 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -9,10 +9,9 @@ import sys from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Sequence, TYPE_CHECKING +from typing import Any, Sequence -if TYPE_CHECKING: - from .policy import Policy as PolicyDataclass +from .sandbox import Sandbox as PolicyDataclass # ---------------------------------------------------------------- # Load the shared library @@ -61,9 +60,9 @@ def _find_lib() -> str: _c_result_p = ctypes.c_void_p _c_pipeline_p = ctypes.c_void_p -# Policy builder -_lib.sandlock_policy_builder_new.restype = _c_builder_p -_lib.sandlock_policy_builder_new.argtypes = [] +# Sandbox builder +_lib.sandlock_sandbox_builder_new.restype = _c_builder_p +_lib.sandlock_sandbox_builder_new.argtypes = [] def _builder_fn(name, *extra_args): fn = getattr(_lib, name) @@ -71,43 +70,44 @@ def _builder_fn(name, *extra_args): fn.argtypes = [_c_builder_p] + list(extra_args) return fn -_b_fs_read = _builder_fn("sandlock_policy_builder_fs_read", ctypes.c_char_p) -_b_fs_write = _builder_fn("sandlock_policy_builder_fs_write", ctypes.c_char_p) -_b_fs_deny = _builder_fn("sandlock_policy_builder_fs_deny", ctypes.c_char_p) -_b_fs_storage = _builder_fn("sandlock_policy_builder_fs_storage", ctypes.c_char_p) -_b_fs_isolation = _builder_fn("sandlock_policy_builder_fs_isolation", ctypes.c_uint8) -_b_gpu_devices = _builder_fn("sandlock_policy_builder_gpu_devices", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) -_b_workdir = _builder_fn("sandlock_policy_builder_workdir", ctypes.c_char_p) -_b_cwd = _builder_fn("sandlock_policy_builder_cwd", ctypes.c_char_p) -_b_chroot = _builder_fn("sandlock_policy_builder_chroot", ctypes.c_char_p) -_b_fs_mount = _builder_fn("sandlock_policy_builder_fs_mount", ctypes.c_char_p, ctypes.c_char_p) -_b_on_exit = _builder_fn("sandlock_policy_builder_on_exit", ctypes.c_uint8) -_b_on_error = _builder_fn("sandlock_policy_builder_on_error", ctypes.c_uint8) -_b_max_memory = _builder_fn("sandlock_policy_builder_max_memory", ctypes.c_uint64) -_b_max_disk = _builder_fn("sandlock_policy_builder_max_disk", ctypes.c_uint64) -_b_max_processes = _builder_fn("sandlock_policy_builder_max_processes", ctypes.c_uint32) -_b_max_cpu = _builder_fn("sandlock_policy_builder_max_cpu", ctypes.c_uint8) -_b_num_cpus = _builder_fn("sandlock_policy_builder_num_cpus", ctypes.c_uint32) -_b_net_allow = _builder_fn("sandlock_policy_builder_net_allow", ctypes.c_char_p) -_b_net_bind_port = _builder_fn("sandlock_policy_builder_net_bind_port", ctypes.c_uint16) -_b_port_remap = _builder_fn("sandlock_policy_builder_port_remap", ctypes.c_bool) -_b_http_allow = _builder_fn("sandlock_policy_builder_http_allow", ctypes.c_char_p) -_b_http_deny = _builder_fn("sandlock_policy_builder_http_deny", ctypes.c_char_p) -_b_http_port = _builder_fn("sandlock_policy_builder_http_port", ctypes.c_uint16) -_b_https_ca = _builder_fn("sandlock_policy_builder_https_ca", ctypes.c_char_p) -_b_https_key = _builder_fn("sandlock_policy_builder_https_key", ctypes.c_char_p) -_b_uid = _builder_fn("sandlock_policy_builder_uid", ctypes.c_uint32) -_b_random_seed = _builder_fn("sandlock_policy_builder_random_seed", ctypes.c_uint64) -_b_clean_env = _builder_fn("sandlock_policy_builder_clean_env", ctypes.c_bool) -_b_env_var = _builder_fn("sandlock_policy_builder_env_var", ctypes.c_char_p, ctypes.c_char_p) -_b_time_start = _builder_fn("sandlock_policy_builder_time_start", ctypes.c_uint64) -_b_block_syscalls = _builder_fn("sandlock_policy_builder_block_syscalls", ctypes.c_char_p) -_b_max_open_files = _builder_fn("sandlock_policy_builder_max_open_files", ctypes.c_uint32) -_b_no_randomize_memory = _builder_fn("sandlock_policy_builder_no_randomize_memory", ctypes.c_bool) -_b_no_huge_pages = _builder_fn("sandlock_policy_builder_no_huge_pages", ctypes.c_bool) -_b_no_coredump = _builder_fn("sandlock_policy_builder_no_coredump", ctypes.c_bool) -_b_deterministic_dirs = _builder_fn("sandlock_policy_builder_deterministic_dirs", ctypes.c_bool) -_b_cpu_cores = _builder_fn("sandlock_policy_builder_cpu_cores", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) +_b_fs_read = _builder_fn("sandlock_sandbox_builder_fs_read", ctypes.c_char_p) +_b_fs_write = _builder_fn("sandlock_sandbox_builder_fs_write", ctypes.c_char_p) +_b_fs_deny = _builder_fn("sandlock_sandbox_builder_fs_deny", ctypes.c_char_p) +_b_fs_storage = _builder_fn("sandlock_sandbox_builder_fs_storage", ctypes.c_char_p) +_b_fs_isolation = _builder_fn("sandlock_sandbox_builder_fs_isolation", ctypes.c_uint8) +_b_gpu_devices = _builder_fn("sandlock_sandbox_builder_gpu_devices", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) +_b_workdir = _builder_fn("sandlock_sandbox_builder_workdir", ctypes.c_char_p) +_b_cwd = _builder_fn("sandlock_sandbox_builder_cwd", ctypes.c_char_p) +_b_chroot = _builder_fn("sandlock_sandbox_builder_chroot", ctypes.c_char_p) +_b_fs_mount = _builder_fn("sandlock_sandbox_builder_fs_mount", ctypes.c_char_p, ctypes.c_char_p) +_b_on_exit = _builder_fn("sandlock_sandbox_builder_on_exit", ctypes.c_uint8) +_b_on_error = _builder_fn("sandlock_sandbox_builder_on_error", ctypes.c_uint8) +_b_max_memory = _builder_fn("sandlock_sandbox_builder_max_memory", ctypes.c_uint64) +_b_max_disk = _builder_fn("sandlock_sandbox_builder_max_disk", ctypes.c_uint64) +_b_max_processes = _builder_fn("sandlock_sandbox_builder_max_processes", ctypes.c_uint32) +_b_max_cpu = _builder_fn("sandlock_sandbox_builder_max_cpu", ctypes.c_uint8) +_b_num_cpus = _builder_fn("sandlock_sandbox_builder_num_cpus", ctypes.c_uint32) +_b_net_allow = _builder_fn("sandlock_sandbox_builder_net_allow", ctypes.c_char_p) +_b_net_bind_port = _builder_fn("sandlock_sandbox_builder_net_bind_port", ctypes.c_uint16) +_b_port_remap = _builder_fn("sandlock_sandbox_builder_port_remap", ctypes.c_bool) +_b_http_allow = _builder_fn("sandlock_sandbox_builder_http_allow", ctypes.c_char_p) +_b_http_deny = _builder_fn("sandlock_sandbox_builder_http_deny", ctypes.c_char_p) +_b_http_port = _builder_fn("sandlock_sandbox_builder_http_port", ctypes.c_uint16) +_b_http_ca = _builder_fn("sandlock_sandbox_builder_http_ca", ctypes.c_char_p) +_b_http_key = _builder_fn("sandlock_sandbox_builder_http_key", ctypes.c_char_p) +_b_uid = _builder_fn("sandlock_sandbox_builder_uid", ctypes.c_uint32) +_b_random_seed = _builder_fn("sandlock_sandbox_builder_random_seed", ctypes.c_uint64) +_b_clean_env = _builder_fn("sandlock_sandbox_builder_clean_env", ctypes.c_bool) +_b_env_var = _builder_fn("sandlock_sandbox_builder_env_var", ctypes.c_char_p, ctypes.c_char_p) +_b_time_start = _builder_fn("sandlock_sandbox_builder_time_start", ctypes.c_uint64) +_b_extra_deny_syscalls = _builder_fn("sandlock_sandbox_builder_extra_deny_syscalls", ctypes.c_char_p) +_b_extra_allow_syscalls = _builder_fn("sandlock_sandbox_builder_extra_allow_syscalls", ctypes.c_char_p) +_b_max_open_files = _builder_fn("sandlock_sandbox_builder_max_open_files", ctypes.c_uint32) +_b_no_randomize_memory = _builder_fn("sandlock_sandbox_builder_no_randomize_memory", ctypes.c_bool) +_b_no_huge_pages = _builder_fn("sandlock_sandbox_builder_no_huge_pages", ctypes.c_bool) +_b_no_coredump = _builder_fn("sandlock_sandbox_builder_no_coredump", ctypes.c_bool) +_b_deterministic_dirs = _builder_fn("sandlock_sandbox_builder_deterministic_dirs", ctypes.c_bool) +_b_cpu_cores = _builder_fn("sandlock_sandbox_builder_cpu_cores", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) # Policy callback (policy_fn). # Path strings absent (issue #27 — path-based control belongs in Landlock). @@ -128,8 +128,8 @@ class _CEvent(ctypes.Structure): _c_ctx_p = ctypes.c_void_p _POLICY_FN_TYPE = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.POINTER(_CEvent), _c_ctx_p) -_lib.sandlock_policy_builder_policy_fn.restype = _c_builder_p -_lib.sandlock_policy_builder_policy_fn.argtypes = [_c_builder_p, _POLICY_FN_TYPE] +_lib.sandlock_sandbox_builder_policy_fn.restype = _c_builder_p +_lib.sandlock_sandbox_builder_policy_fn.argtypes = [_c_builder_p, _POLICY_FN_TYPE] _lib.sandlock_ctx_restrict_network.restype = None _lib.sandlock_ctx_restrict_network.argtypes = [_c_ctx_p, ctypes.POINTER(ctypes.c_char_p), ctypes.c_uint32] @@ -203,18 +203,18 @@ def confine(policy: "PolicyDataclass") -> None: raise ConfinementError("confine failed") -_lib.sandlock_policy_build.restype = _c_policy_p -_lib.sandlock_policy_build.argtypes = [ +_lib.sandlock_sandbox_build.restype = _c_policy_p +_lib.sandlock_sandbox_build.argtypes = [ _c_builder_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_char_p), ] -_lib.sandlock_policy_free.restype = None -_lib.sandlock_policy_free.argtypes = [_c_policy_p] +_lib.sandlock_sandbox_free.restype = None +_lib.sandlock_sandbox_free.argtypes = [_c_policy_p] # String-out-param release. The FFI returns CString::into_raw pointers -# for error messages from sandlock_policy_build; we must free them via +# for error messages from sandlock_sandbox_build; we must free them via # this function rather than ctypes' own deallocator. _lib.sandlock_string_free.restype = None _lib.sandlock_string_free.argtypes = [ctypes.c_char_p] @@ -524,8 +524,8 @@ class Checkpoint: Usage:: - sb = Sandbox(policy) - sb.run_bg(["sleep", "60"]) # or use spawn via handle + sb = Sandbox(fs_readable=["/usr", "/lib"]) + sb.start(["sleep", "60"]) cp = sb.checkpoint() cp.save("my-checkpoint") @@ -721,7 +721,7 @@ def __del__(self): # ---------------------------------------------------------------- class _NativePolicy: - """Wraps a native sandlock_policy_t pointer.""" + """Wraps a native sandlock_policy_t (Sandbox config) pointer.""" def __init__(self, ptr: int): self._ptr = ptr @@ -732,7 +732,7 @@ def ptr(self): def __del__(self): if self._ptr: - _lib.sandlock_policy_free(self._ptr) + _lib.sandlock_sandbox_free(self._ptr) self._ptr = None # Fields handled by _build_from_policy (sent to FFI) or intentionally @@ -745,21 +745,23 @@ def __del__(self): "cpu_cores", "gpu_devices", "net_allow", "net_bind", "port_remap", - "http_allow", "http_deny", "http_ports", "https_ca", "https_key", + "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", "random_seed", "time_start", "clean_env", "env", - "block_syscalls", "max_open_files", + "extra_deny_syscalls", "extra_allow_syscalls", "max_open_files", "no_randomize_memory", "no_huge_pages", "no_coredump", "deterministic_dirs", # Managed outside _build_from_policy: "notif_policy", + # Runtime-only kwargs — not sent to FFI: + "name", "policy_fn", "init_fn", "work_fn", } @staticmethod def _build_from_policy(policy: PolicyDataclass): - """Build a native builder from a Python Policy dataclass. Returns builder pointer.""" - from .policy import parse_memory_size, parse_ports + """Build a native builder from a Python Sandbox dataclass. Returns builder pointer.""" + from .sandbox import parse_memory_size, parse_ports - b = _lib.sandlock_policy_builder_new() + b = _lib.sandlock_sandbox_builder_new() for p in (policy.fs_readable or []): if str(p) == "/lib64" and not os.path.exists("/lib64"): @@ -773,7 +775,7 @@ def _build_from_policy(policy: PolicyDataclass): if policy.fs_storage: b = _b_fs_storage(b, _encode(str(policy.fs_storage))) - from .policy import FsIsolation + from .sandbox import FsIsolation _iso_map = { FsIsolation.NONE: 0, FsIsolation.OVERLAYFS: 1, @@ -841,10 +843,10 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_http_deny(b, _encode(str(rule))) for port in (policy.http_ports or []): b = _b_http_port(b, int(port)) - if policy.https_ca: - b = _b_https_ca(b, _encode(str(policy.https_ca))) - if policy.https_key: - b = _b_https_key(b, _encode(str(policy.https_key))) + if policy.http_ca: + b = _b_http_ca(b, _encode(str(policy.http_ca))) + if policy.http_key: + b = _b_http_key(b, _encode(str(policy.http_key))) if policy.port_remap: b = _b_port_remap(b, True) @@ -862,8 +864,10 @@ def _build_from_policy(policy: PolicyDataclass): for k, v in (policy.env or {}).items(): b = _b_env_var(b, _encode(k), _encode(v)) - if policy.block_syscalls: - b = _b_block_syscalls(b, _encode(",".join(policy.block_syscalls or []))) + if policy.extra_deny_syscalls: + b = _b_extra_deny_syscalls(b, _encode(",".join(policy.extra_deny_syscalls or []))) + if policy.extra_allow_syscalls: + b = _b_extra_allow_syscalls(b, _encode(",".join(policy.extra_allow_syscalls or []))) if policy.max_open_files is not None: b = _b_max_open_files(b, policy.max_open_files) @@ -879,8 +883,8 @@ def _build_from_policy(policy: PolicyDataclass): # but is not in _HANDLED_FIELDS (i.e. silently dropped). import dataclasses as _dc import warnings as _w - from .policy import Policy as _Policy - _defaults = _Policy() + from .sandbox import Sandbox as _Sandbox + _defaults = _Sandbox() for f in _dc.fields(policy): if f.name in _NativePolicy._HANDLED_FIELDS: continue @@ -942,11 +946,11 @@ def _c_callback(event_p, ctx_p): return 0 c_callback = _POLICY_FN_TYPE(_c_callback) - b = _lib.sandlock_policy_builder_policy_fn(b, c_callback) + b = _lib.sandlock_sandbox_builder_policy_fn(b, c_callback) err = ctypes.c_int(0) err_msg = ctypes.c_char_p() - ptr = _lib.sandlock_policy_build(b, ctypes.byref(err), ctypes.byref(err_msg)) + ptr = _lib.sandlock_sandbox_build(b, ctypes.byref(err), ctypes.byref(err_msg)) if not ptr or err.value != 0: # err_msg.value is a copy of the C string's bytes; the # underlying allocation still needs releasing afterwards. @@ -961,326 +965,6 @@ def _c_callback(event_p, ctx_p): return native -# ---------------------------------------------------------------- -# Sandbox -# ---------------------------------------------------------------- - -class Sandbox: - """Run commands in a sandlock sandbox. - - Usage:: - - from sandlock import Sandbox, Policy - sb = Sandbox(Policy(fs_readable=["/usr", "/lib"], fs_writable=["/tmp"])) - result = sb.run(["echo", "hello"]) - assert result.success - assert b"hello" in result.stdout - """ - - def __init__(self, policy: PolicyDataclass, policy_fn=None, - init_fn=None, work_fn=None, name: str | None = None): - if name is not None: - if not name: - raise ValueError("sandbox name must not be empty") - if "\0" in name: - raise ValueError("sandbox name must not contain NUL bytes") - if len(name.encode()) > 64: - raise ValueError("sandbox name must be at most 64 bytes") - self._policy_dc = policy - self._policy_fn = policy_fn - self._init_fn = init_fn - self._work_fn = work_fn - self._name = name - self._native = _NativePolicy.from_dataclass(policy, policy_fn=policy_fn) - self._handle = None # live sandbox handle during run() - - def _resolve_name(self): - """Resolve sandbox name: explicit > auto-generated.""" - if self._name is not None: - return self._name - return f"sandbox-{os.getpid()}" - - @property - def name(self) -> str | None: - """Sandbox name.""" - return self._name - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self._handle is not None: - _lib.sandlock_handle_free(self._handle) - self._handle = None - return False - - @property - def pid(self) -> int | None: - """Child PID while running, None otherwise.""" - if self._handle is None: - return None - return _lib.sandlock_handle_pid(self._handle) or None - - def ports(self) -> dict[int, int]: - """Return current port mappings {virtual_port: real_port}. - - Only contains entries where the real port differs from the virtual - port (i.e., where a remap occurred). Empty if port_remap is disabled - or no ports have been remapped. Requires the sandbox to be running. - """ - if self._handle is None: - return {} - c_str = _lib.sandlock_handle_port_mappings(self._handle) - if not c_str: - return {} - try: - import json - raw = json.loads(c_str.decode()) - return {int(k): v for k, v in raw.items()} - finally: - _lib.sandlock_string_free(c_str) - - def pause(self) -> None: - """Send SIGSTOP to the sandbox process group.""" - pid = self.pid - if pid is None: - raise RuntimeError("sandbox is not running") - os.killpg(pid, signal.SIGSTOP) - - def resume(self) -> None: - """Send SIGCONT to the sandbox process group.""" - pid = self.pid - if pid is None: - raise RuntimeError("sandbox is not running") - os.killpg(pid, signal.SIGCONT) - - def checkpoint( - self, - save_fn: "Callable[[], bytes] | None" = None, - ) -> Checkpoint: - """Capture a checkpoint of the running sandbox. - - The sandbox is frozen (SIGSTOP + fork-hold), state is captured - via ptrace + /proc, then thawed. - - Args: - save_fn: Optional callback that returns application-level - state bytes. Called after OS-level capture; the result - is stored in ``checkpoint.app_state``. Use this for - state that ptrace can't see (caches, session data, etc.). - - Returns: - Checkpoint with process state, memory, fds, and optional app state. - - Raises: - RuntimeError: If the sandbox is not running or capture fails. - """ - if self._handle is None: - raise RuntimeError("sandbox is not running (use spawn first)") - ptr = _lib.sandlock_handle_checkpoint(self._handle) - if not ptr: - raise RuntimeError("checkpoint capture failed") - cp = Checkpoint(ptr) - if save_fn is not None: - cp.app_state = save_fn() - return cp - - def run(self, cmd: list[str], timeout: float | None = None) -> Result: - """Run a command in the sandbox, capturing stdout and stderr. - - Args: - cmd: Command and arguments to execute. - timeout: Maximum execution time in seconds. The process is - killed and a timeout result is returned if exceeded. - None means no timeout. - """ - argv, argc = _make_argv(cmd) - - # Resolve sandbox name. Sandlock exposes this as the sandbox's - # virtual hostname inside the child. - resolved_name = self._resolve_name() - - # Spawn (non-blocking) so PID is available for pause/resume - self._handle = _lib.sandlock_spawn(self._native.ptr, _encode(resolved_name), argv, argc) - if not self._handle: - return Result(success=False, exit_code=-1, error="sandlock_spawn failed") - - try: - timeout_ms = int(timeout * 1000) if timeout else 0 - result_p = _lib.sandlock_handle_wait_timeout(self._handle, timeout_ms) - finally: - _lib.sandlock_handle_free(self._handle) - self._handle = None - - if not result_p: - return Result(success=False, exit_code=-1, error="sandlock_handle_wait failed") - - exit_code = _lib.sandlock_result_exit_code(result_p) - success = _lib.sandlock_result_success(result_p) - stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) - stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) - _lib.sandlock_result_free(result_p) - - return Result( - success=bool(success), - exit_code=exit_code, - stdout=stdout, - stderr=stderr, - ) - - def dry_run(self, cmd: list[str], timeout: float | None = None) -> "DryRunResult": - """Dry-run: run a command, collect filesystem changes, then discard. - - Args: - cmd: Command and arguments to execute. - timeout: Maximum execution time in seconds. None means no timeout. - - Returns: - DryRunResult with exit info and list of filesystem changes. - """ - from .policy import Change, DryRunResult - - argv, argc = _make_argv(cmd) - result_p = _lib.sandlock_dry_run( - self._native.ptr, _encode(self._resolve_name()), argv, argc, - ) - - if not result_p: - return DryRunResult(success=False, exit_code=-1, error="sandlock_dry_run failed") - - try: - exit_code = _lib.sandlock_dry_run_result_exit_code(result_p) - success = _lib.sandlock_dry_run_result_success(result_p) - stdout = _read_result_bytes(result_p, _lib.sandlock_dry_run_result_stdout_bytes) - stderr = _read_result_bytes(result_p, _lib.sandlock_dry_run_result_stderr_bytes) - - n = _lib.sandlock_dry_run_result_changes_len(result_p) - changes = [] - for i in range(n): - kind_byte = _lib.sandlock_dry_run_result_change_kind(result_p, i) - kind = kind_byte.decode("ascii") - path_p = _lib.sandlock_dry_run_result_change_path(result_p, i) - if path_p: - path = ctypes.c_char_p(path_p).value.decode("utf-8") - _lib.sandlock_string_free(ctypes.cast(path_p, ctypes.c_char_p)) - else: - path = "" - changes.append(Change(kind=kind, path=path)) - finally: - _lib.sandlock_dry_run_result_free(result_p) - - return DryRunResult( - success=bool(success), - exit_code=exit_code, - stdout=stdout, - stderr=stderr, - changes=changes, - ) - - def run_interactive(self, cmd: list[str]) -> int: - """Run with inherited stdio. Returns exit code.""" - argv, argc = _make_argv(cmd) - return _lib.sandlock_run_interactive( - self._native.ptr, _encode(self._resolve_name()), argv, argc, - ) - - def cmd(self, args: list[str]) -> Stage: - """Bind a command to this sandbox, returning a lazy Stage.""" - return Stage(self, args) - - def fork(self, n: int) -> list[int]: - """Create N COW clones. init_fn runs once, work_fn in each clone. - - Requires init_fn and work_fn passed to Sandbox(). - - Returns list of clone PIDs. - - Example:: - - sb = Sandbox(policy, - init_fn=lambda: load_model(), - work_fn=lambda clone_id: rollout(clone_id), - ) - pids = sb.fork(1000) - """ - if self._init_fn is None or self._work_fn is None: - raise RuntimeError("fork() requires init_fn and work_fn in Sandbox()") - - c_init = _INIT_FN_TYPE(self._init_fn) - _user_work = self._work_fn - def _flushing_work(clone_id): - import sys, os, io - # After dup2, Python's sys.stdout still points to old fd. - # Replace it with a fresh wrapper on fd 1. - sys.stdout = io.TextIOWrapper(io.FileIO(1, 'w', closefd=False), line_buffering=True) - _user_work(clone_id) - sys.stdout.flush() - c_work = _WORK_FN_TYPE(_flushing_work) - self._c_init = c_init # prevent GC - self._c_work = c_work - - sb_ptr = _lib.sandlock_new_with_fns( - self._native.ptr, _encode(self._resolve_name()), c_init, c_work, - ) - if not sb_ptr: - raise RuntimeError("sandlock_new_with_fns failed") - - # Fork N clones — returns opaque handle with pipes - fork_result = _lib.sandlock_fork(sb_ptr, n) - - # Wait for template - _lib.sandlock_wait(sb_ptr) - _lib.sandlock_sandbox_free(sb_ptr) - - if not fork_result: - return ForkResult(None, [], self._native) - - count = _lib.sandlock_fork_result_count(fork_result) - pids = [_lib.sandlock_fork_result_pid(fork_result, i) for i in range(count)] - - return ForkResult(fork_result, pids, self._native) - - def reduce(self, cmd: list[str], fork_result: "ForkResult") -> Result: - """Reduce: read clone stdout pipes, feed to reducer stdin. - - Args: - cmd: Reducer command (receives combined clone output on stdin). - fork_result: ForkResult from fork(). - - Returns: - Result with reducer's stdout/stderr. - - Example:: - - clones = mapper.fork(4) - result = reducer.reduce(["python3", "sum.py"], clones) - """ - if fork_result._ptr is None: - return Result(success=False, exit_code=-1, error="no fork result") - - argv, argc = _make_argv(cmd) - result_p = _lib.sandlock_reduce( - fork_result._ptr, self._native.ptr, _encode(self._resolve_name()), argv, argc, - ) - fork_result._ptr = None # consumed by reduce - - if not result_p: - return Result(success=False, exit_code=-1, error="reduce failed") - - exit_code = _lib.sandlock_result_exit_code(result_p) - success = _lib.sandlock_result_success(result_p) - stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) - stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) - _lib.sandlock_result_free(result_p) - - return Result( - success=bool(success), - exit_code=exit_code, - stdout=stdout, - stderr=stderr, - ) - - # ---------------------------------------------------------------- # ForkResult (holds clone handles with pipes for reduce) # ---------------------------------------------------------------- @@ -1316,7 +1000,7 @@ def __del__(self): class Stage: """A lazy command bound to a Sandbox. Not executed until .run().""" - def __init__(self, sandbox: Sandbox, args: list[str]): + def __init__(self, sandbox: PolicyDataclass, args: list[str]): self.sandbox = sandbox self.args = args @@ -1353,9 +1037,9 @@ class Gather: Usage:: result = ( - Sandbox(policy_a).cmd(["produce_code"]).as_("code") - + Sandbox(policy_b).cmd(["produce_data"]).as_("data") - | Sandbox(policy_c).cmd(["python3", "consume.py"]) + Sandbox(...).cmd(["produce_code"]).as_("code") + + Sandbox(...).cmd(["produce_data"]).as_("data") + | Sandbox(...).cmd(["python3", "consume.py"]) ).run() The consumer script imports ``from sandlock import inputs`` to read @@ -1401,14 +1085,14 @@ def run(self, timeout: float | None = None) -> Result: _lib.sandlock_gather_add_source( gather_p, ctypes.c_char_p(name_b), - stage.sandbox._native.ptr, + stage.sandbox._ensure_native().ptr, argv, argc, ) consumer_argv, consumer_argc = _make_argv(self.consumer.args) _lib.sandlock_gather_set_consumer( gather_p, - self.consumer.sandbox._native.ptr, + self.consumer.sandbox._ensure_native().ptr, consumer_argv, consumer_argc, ) @@ -1444,8 +1128,8 @@ class Pipeline: Usage:: result = ( - Sandbox(policy_a).cmd(["echo", "hello"]) - | Sandbox(policy_b).cmd(["tr", "a-z", "A-Z"]) + Sandbox(...).cmd(["echo", "hello"]) + | Sandbox(...).cmd(["tr", "a-z", "A-Z"]) ).run() assert b"HELLO" in result.stdout """ @@ -1475,7 +1159,7 @@ def run( for stage in self.stages: argv, argc = _make_argv(stage.args) _lib.sandlock_pipeline_add_stage( - pipe_p, stage.sandbox._native.ptr, argv, argc, + pipe_p, stage.sandbox._ensure_native().ptr, argv, argc, ) timeout_ms = int(timeout * 1000) if timeout else 0 diff --git a/python/src/sandlock/mcp/_policy.py b/python/src/sandlock/mcp/_policy.py index 98f1e58..801abca 100644 --- a/python/src/sandlock/mcp/_policy.py +++ b/python/src/sandlock/mcp/_policy.py @@ -20,7 +20,7 @@ from dataclasses import fields from typing import Any, Mapping, Sequence -from ..policy import Policy +from ..sandbox import Sandbox # Resolve the Python interpreter's installation prefix so that sandboxed # processes can always exec the current interpreter, even when it lives @@ -28,7 +28,7 @@ _PYTHON_PREFIX = sys.prefix -_POLICY_FIELDS = frozenset(f.name for f in fields(Policy)) +_POLICY_FIELDS = frozenset(f.name for f in fields(Sandbox)) _SANDLOCK_PREFIX = "sandlock:" @@ -36,8 +36,8 @@ def policy_for_tool( *, workspace: str = "/tmp/sandlock", capabilities: Mapping[str, Any] | None = None, -) -> Policy: - """Build a :class:`Policy` from explicit capabilities. +) -> Sandbox: + """Build a :class:`Sandbox` from explicit capabilities. **Deny by default**: no capabilities = read-only access to system paths and the workspace. Every permission must be granted. @@ -49,7 +49,7 @@ def policy_for_tool( Args: workspace: Filesystem path the sandbox can read. - capabilities: Grants keyed by Policy field name. Common keys: + capabilities: Grants keyed by Sandbox field name. Common keys: - ``fs_writable: ["/tmp/workspace"]`` - ``net_allow: ["api.example.com:443"]`` @@ -57,7 +57,7 @@ def policy_for_tool( - ``max_memory: "256M"`` Returns: - A frozen :class:`Policy` instance. + A frozen :class:`Sandbox` instance. """ # Fields that users cannot override — always enforced. _ENFORCED = {"clean_env"} @@ -78,7 +78,7 @@ def policy_for_tool( if key in _POLICY_FIELDS and key not in _ENFORCED: kwargs[key] = value - return Policy(**kwargs) + return Sandbox(**kwargs) def capabilities_from_mcp_tool(tool: Any) -> dict[str, Any]: diff --git a/python/src/sandlock/mcp/_sandbox.py b/python/src/sandlock/mcp/_sandbox.py index 92b732e..aff76e6 100644 --- a/python/src/sandlock/mcp/_sandbox.py +++ b/python/src/sandlock/mcp/_sandbox.py @@ -24,8 +24,7 @@ from types import SimpleNamespace from typing import Any, Callable, Mapping -from ..policy import Policy -from .._sdk import Sandbox +from ..sandbox import Sandbox from ._policy import policy_for_tool, capabilities_from_mcp_tool @@ -61,10 +60,10 @@ def __init__( self._workspace = workspace self._timeout = timeout self._local_tools: dict[str, _LocalTool] = {} - self._local_policies: dict[str, Policy] = {} + self._local_policies: dict[str, Sandbox] = {} self._mcp_tools: dict[str, Any] = {} self._mcp_tool_session: dict[str, Any] = {} - self._mcp_policies: dict[str, Policy] = {} + self._mcp_policies: dict[str, Sandbox] = {} # --- Local tools --- @@ -147,8 +146,8 @@ def tool_definitions_openai(self) -> list[dict[str, Any]]: result.append({"type": "function", "function": fn}) return result - def get_policy(self, tool_name: str) -> Policy: - """Return the policy for a tool.""" + def get_policy(self, tool_name: str) -> Sandbox: + """Return the sandbox config for a tool.""" if tool_name in self._local_policies: return self._local_policies[tool_name] if tool_name in self._mcp_policies: @@ -203,7 +202,7 @@ async def _call_local(self, name: str, args: dict, timeout: float | None) -> str loop = asyncio.get_running_loop() result = await loop.run_in_executor( None, - lambda: Sandbox(policy).run( + lambda: policy.run( [sys.executable, "-c", script], timeout=timeout, ), ) diff --git a/python/src/sandlock/policy.py b/python/src/sandlock/policy.py deleted file mode 100644 index c32480e..0000000 --- a/python/src/sandlock/policy.py +++ /dev/null @@ -1,358 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Policy dataclasses for Sandlock sandbox configuration. - -A Policy is frozen after creation — live updates go through -BPF maps + seccomp notif, not Policy mutation. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from enum import Enum -from typing import TYPE_CHECKING, Mapping, Sequence - -if TYPE_CHECKING: - from ._notif_policy import NotifPolicy - - -# --- Memory size parsing (from branching/process/limits.py) --- - -_UNITS = { - "K": 1024, - "M": 1024 ** 2, - "G": 1024 ** 3, - "T": 1024 ** 4, -} - -_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([KMGT])?\s*$", re.IGNORECASE) - - -def parse_memory_size(s: str) -> int: - """Parse a human-friendly memory size string to bytes. - - Accepts plain integers (bytes) or suffixed values: ``'512M'``, ``'1G'``, - ``'100K'``. The suffix is case-insensitive. - - Returns: - Size in bytes (integer). - - Raises: - ValueError: If the string cannot be parsed. - """ - m = _SIZE_RE.match(s) - if m is None: - raise ValueError(f"invalid memory size: {s!r}") - value = float(m.group(1)) - suffix = m.group(2) - if suffix is not None: - value *= _UNITS[suffix.upper()] - return int(value) - - -_PORT_RANGE_RE = re.compile(r"^(\d+)(?:-(\d+))?$") - - -def parse_ports(specs: Sequence[int | str]) -> list[int]: - """Parse port specifications into a sorted list of unique port numbers. - - Each spec is an int (single port) or a string like ``"80"``, - ``"8000-9000"``. Raises ValueError on out-of-range or bad format. - """ - ports: set[int] = set() - for spec in specs: - if isinstance(spec, int): - if not 0 <= spec <= 65535: - raise ValueError(f"port out of range: {spec}") - ports.add(spec) - continue - m = _PORT_RANGE_RE.match(spec.strip()) - if m is None: - raise ValueError(f"invalid port spec: {spec!r}") - lo = int(m.group(1)) - hi = int(m.group(2)) if m.group(2) else lo - if lo > hi or not 0 <= lo <= 65535 or not 0 <= hi <= 65535: - raise ValueError(f"invalid port range: {spec!r}") - ports.update(range(lo, hi + 1)) - return sorted(ports) - - -class FsIsolation(Enum): - """Filesystem mutation isolation mode.""" - - NONE = "none" # Direct host writes (default) - BRANCHFS = "branchfs" # BranchFS COW isolation - OVERLAYFS = "overlayfs" # OverlayFS COW (kernel built-in, no dependencies) - - -class BranchAction(Enum): - """Action to take on a BranchFS branch when sandbox exits.""" - - COMMIT = "commit" # Merge writes into parent branch - ABORT = "abort" # Discard all writes - KEEP = "keep" # Leave branch as-is (caller decides) - - -@dataclass(frozen=True) -class Change: - """A single filesystem change detected by dry-run.""" - - kind: str - """Change kind: A=added, M=modified, D=deleted.""" - - path: str - """Path relative to workdir.""" - - -@dataclass -class DryRunResult: - """Result of a dry-run execution.""" - - success: bool - exit_code: int = 0 - stdout: bytes = field(default=b"", repr=False) - stderr: bytes = field(default=b"", repr=False) - changes: list = field(default_factory=list) - error: str | None = None - - -@dataclass(frozen=True) -class Policy: - """Immutable sandbox policy. - - Most fields are optional — unset fields mean "no restriction". Sandlock's - default syscall blocklist is always applied. - """ - - # Filesystem (Landlock) - fs_writable: Sequence[str] = field(default_factory=list) - """Paths the sandbox can write to.""" - - fs_readable: Sequence[str] = field(default_factory=list) - """Paths the sandbox can read (in addition to writable paths).""" - - fs_denied: Sequence[str] = field(default_factory=list) - """Paths explicitly denied (neither read nor write).""" - - block_syscalls: Sequence[str] = field(default_factory=list) - """Additional syscall names to block on top of Sandlock's default blocklist.""" - - # Network — endpoint allowlist (protocol × IP × port via seccomp on-behalf path) - net_allow: Sequence[str] = field(default_factory=list) - """Outbound endpoint rules. Each entry is a string. The bare form is - TCP; other protocols use a scheme prefix: - - * ``"host:port"`` — TCP to one host on one port (e.g. ``"api.openai.com:443"``) - * ``"host:port,port,..."`` — TCP, multiple ports (e.g. ``"github.com:22,443"``) - * ``":port"`` / ``"*:port"`` — TCP on any IP (e.g. ``":53"``) - * ``"tcp://host:port"`` — explicit TCP (same suffix grammar as bare form) - * ``"udp://host:port"`` — UDP to a host - * ``"udp://*:*"`` — any UDP (matches the previous ``allow_udp=True`` behavior) - * ``"icmp://host"`` — kernel ping socket (SOCK_DGRAM + IPPROTO_ICMP) to a host - * ``"icmp://*"`` — any ICMP echo destination - - Sandlock does not expose raw ICMP (SOCK_RAW). Workloads that need - ping should rely on the host's ``net.ipv4.ping_group_range`` and - use the dgram path above. - - Protocol gating falls out of rule presence: with no UDP/ICMP rules, - UDP and ICMP socket creation are denied at the seccomp layer. - Hostnames are resolved at sandbox-creation time and pinned via a - synthetic ``/etc/hosts``. Empty = deny all outbound. HTTP rules with - concrete hosts auto-add a matching TCP entry on :attr:`http_ports`. - See README "Network Model" for details.""" - - no_coredump: bool = False - """Disable core dumps and restrict /proc/pid access from other - processes. Applied via prctl(PR_SET_DUMPABLE, 0). Prevents - leaking sandbox memory contents but breaks gdb/strace/perf.""" - - # Network — bind allowlist (Landlock ABI v4+, TCP only) - net_bind: Sequence[int | str] = field(default_factory=list) - """TCP ports the sandbox may bind. Empty = deny all. Each entry is - a port number or a ``"lo-hi"`` range string. Landlock's port hooks - are TCP-only — UDP bind is not separately gated.""" - - # HTTP ACL - http_allow: Sequence[str] = field(default_factory=list) - """HTTP allow rules. Format: "METHOD host/path" with glob matching. - When non-empty, all other HTTP requests are denied by default. - A transparent MITM proxy is spawned in the supervisor.""" - - http_deny: Sequence[str] = field(default_factory=list) - """HTTP block rules. Checked before allow rules. Format: "METHOD host/path".""" - - http_ports: Sequence[int] = field(default_factory=list) - """TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 with - https_ca). Override to intercept custom ports like 8080.""" - - https_ca: str | None = None - """PEM CA certificate path for HTTPS MITM. When set, port 443 is also - intercepted by the HTTP ACL proxy.""" - - https_key: str | None = None - """PEM CA private key path for HTTPS MITM. Required with https_ca.""" - - # Resource limits - max_memory: str | int | None = None - """Memory limit. String like '512M' or int bytes.""" - - max_processes: int = 64 - """Maximum total forks allowed in the sandbox (lifetime count, - not concurrent). Enforced by the seccomp notif supervisor. - Also enables fork interception needed for checkpoint freeze.""" - - max_open_files: int | None = None - """Maximum number of open file descriptors. Enforced via - RLIMIT_NOFILE — kernel-enforced, survives exec. Prevents fd - exhaustion attacks. None = inherit system default.""" - - max_cpu: int | None = None - """CPU throttle as a percentage of one core (1–100). E.g. ``50`` - means the sandbox process group gets at most 50% of one core. - Enforced by the parent via SIGSTOP/SIGCONT cycling on the process - group — applies to all processes in the sandbox collectively.""" - - cpu_cores: Sequence[int] | None = None - """CPU cores to pin the sandbox to. When set, sched_setaffinity() - is called in the child to restrict it to the specified cores. - None = inherit parent affinity (unrestricted).""" - - num_cpus: int | None = None - """Visible CPU count in /proc/cpuinfo. When set, the sandbox sees - a synthetic /proc/cpuinfo with only this many processor entries - (renumbered 0..N-1). Also virtualizes /proc/meminfo when - max_memory is set. Requires seccomp user notification (automatic).""" - - port_remap: bool = False - """Enable transparent TCP port virtualization. Each sandbox gets a - full virtual port space — bind(3000) is silently remapped to a unique - real port. Inbound traffic to the virtual port is proxied to the - real port automatically. No network namespaces or root required.""" - - # Deterministic execution - random_seed: int | None = None - """Seed for deterministic randomness. When set, getrandom() returns - deterministic bytes from a seeded PRNG. Same seed = same output.""" - - time_start: float | str | None = None - """Start timestamp for time virtualization. When set, clock_gettime() - and gettimeofday() return shifted time starting from this epoch. - Accepts a Unix timestamp (float) or ISO 8601 string. - Time ticks at real speed from the given start point.""" - - no_randomize_memory: bool = False - """Disable Address Space Layout Randomization (ASLR) inside the sandbox. - When set, stack, heap, mmap, and shared library addresses are - deterministic across runs. Useful for reproducible builds and tests. - Applied via personality(ADDR_NO_RANDOMIZE) — per-process, no root.""" - - no_huge_pages: bool = False - """Disable Transparent Huge Pages (THP) inside the sandbox. - Prevents the kernel from silently promoting 4KB pages to 2MB huge - pages, which causes nondeterministic memory layout, RSS measurements, - and page fault timing. Applied via prctl(PR_SET_THP_DISABLE).""" - - deterministic_dirs: bool = False - """Sort directory entries lexicographically for deterministic readdir(). - Ensures ls, glob, os.listdir etc. return the same order regardless of - filesystem internals.""" - - # GPU access - gpu_devices: Sequence[int] | None = None - """GPU device indices visible to the sandbox. When set, Landlock - rules are added for GPU device files (/dev/nvidia*, /dev/dri/*) and - driver paths (/proc/driver/nvidia, /sys/bus/pci/devices), and - ``CUDA_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` are set. - ``None`` = no GPU access. ``[]`` (empty list) = all GPUs visible.""" - - # Optional chroot - chroot: str | None = None - """Path to chroot into before applying other confinement.""" - - fs_mount: Mapping[str, str] = field(default_factory=dict) - """Map virtual paths to host directories inside chroot. - Example: {"/work": "/host/sandbox/work"} makes /work inside the - chroot resolve to /host/sandbox/work on the host.""" - - # Environment - clean_env: bool = False - """If True, start with a minimal environment (PATH, HOME, USER, TERM, LANG). - If False (default), inherit the parent's full environment.""" - - env: Mapping[str, str] = field(default_factory=dict) - """Variables to set or override in the child. Applied after clean_env.""" - - - uid: int | None = None - """Map to the given UID inside a user namespace. For example, - ``uid=0`` gives fake root, ``uid=1000`` maps to UID 1000. - The child has no real host privileges regardless of the mapped UID. - Only effective when user namespaces are available.""" - - # Seccomp user notification (filesystem virtualization) - notif_policy: NotifPolicy | None = None - """If set, enables a seccomp user notification supervisor that - intercepts open/openat syscalls and applies path-based rules - for /proc and /sys virtualization. Requires Linux 5.6+.""" - - # Working directory - workdir: str | None = None - """COW root directory. Only controls which directory COW tracks — - does NOT set the child's working directory. Use ``cwd`` for that.""" - - cwd: str | None = None - """Child working directory (chdir target). The child process starts - in this directory. Independent of ``workdir`` (COW root).""" - - # COW filesystem isolation - fs_isolation: FsIsolation = FsIsolation.NONE - """Filesystem isolation mode. Auto-set to OVERLAYFS when workdir is set.""" - - fs_storage: str | None = None - """Separate storage directory for BranchFS COW deltas. - If set, passed as ``--storage`` to ``branchfs mount``.""" - - max_disk: str | None = None - """Disk quota for BranchFS storage (e.g. ``'1G'``). - Passed as ``--max-storage`` to ``branchfs mount``. - Enforced by BranchFS FUSE layer (returns ENOSPC).""" - - on_exit: BranchAction = BranchAction.COMMIT - """Branch action on normal sandbox exit.""" - - on_error: BranchAction = BranchAction.ABORT - """Branch action on sandbox error/exception.""" - - def bind_ports(self) -> list[int]: - """Return parsed bind port list, or empty if unrestricted.""" - return parse_ports(self.net_bind) if self.net_bind else [] - - def memory_bytes(self) -> int | None: - """Return max_memory as bytes, or None if unset.""" - if self.max_memory is None: - return None - if isinstance(self.max_memory, int): - return self.max_memory - return parse_memory_size(self.max_memory) - - def time_start_timestamp(self) -> float | None: - """Return time_start as a Unix timestamp float, or None if unset.""" - if self.time_start is None: - return None - if isinstance(self.time_start, (int, float)): - return float(self.time_start) - from datetime import datetime, timezone - s = self.time_start - if s.endswith("Z"): - s = s[:-1] + "+00:00" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.timestamp() - - def cpu_pct(self) -> int | None: - """Return max_cpu as a clamped percentage (1–100), or None.""" - if self.max_cpu is None: - return None - return max(1, min(100, self.max_cpu)) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py new file mode 100644 index 0000000..1f7e6c3 --- /dev/null +++ b/python/src/sandlock/sandbox.py @@ -0,0 +1,834 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Sandbox dataclass for Sandlock sandbox configuration and runtime. + +A Sandbox holds both the configuration (policy fields) and the runtime +state for executing commands. Configuration fields are set at construction +time; runtime state (``_native``, ``_handle``) is initialized lazily. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Callable, Mapping, Sequence + +if TYPE_CHECKING: + from ._notif_policy import NotifPolicy + + +# --- Memory size parsing (from branching/process/limits.py) --- + +_UNITS = { + "K": 1024, + "M": 1024 ** 2, + "G": 1024 ** 3, + "T": 1024 ** 4, +} + +_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([KMGT])?\s*$", re.IGNORECASE) + + +def parse_memory_size(s: str) -> int: + """Parse a human-friendly memory size string to bytes. + + Accepts plain integers (bytes) or suffixed values: ``'512M'``, ``'1G'``, + ``'100K'``. The suffix is case-insensitive. + + Returns: + Size in bytes (integer). + + Raises: + ValueError: If the string cannot be parsed. + """ + m = _SIZE_RE.match(s) + if m is None: + raise ValueError(f"invalid memory size: {s!r}") + value = float(m.group(1)) + suffix = m.group(2) + if suffix is not None: + value *= _UNITS[suffix.upper()] + return int(value) + + +_PORT_RANGE_RE = re.compile(r"^(\d+)(?:-(\d+))?$") + + +def parse_ports(specs: Sequence[int | str]) -> list[int]: + """Parse port specifications into a sorted list of unique port numbers. + + Each spec is an int (single port) or a string like ``"80"``, + ``"8000-9000"``. Raises ValueError on out-of-range or bad format. + """ + ports: set[int] = set() + for spec in specs: + if isinstance(spec, int): + if not 0 <= spec <= 65535: + raise ValueError(f"port out of range: {spec}") + ports.add(spec) + continue + m = _PORT_RANGE_RE.match(spec.strip()) + if m is None: + raise ValueError(f"invalid port spec: {spec!r}") + lo = int(m.group(1)) + hi = int(m.group(2)) if m.group(2) else lo + if lo > hi or not 0 <= lo <= 65535 or not 0 <= hi <= 65535: + raise ValueError(f"invalid port range: {spec!r}") + ports.update(range(lo, hi + 1)) + return sorted(ports) + + +class FsIsolation(Enum): + """Filesystem mutation isolation mode.""" + + NONE = "none" # Direct host writes (default) + BRANCHFS = "branchfs" # BranchFS COW isolation + OVERLAYFS = "overlayfs" # OverlayFS COW (kernel built-in, no dependencies) + + +class BranchAction(Enum): + """Action to take on a BranchFS branch when sandbox exits.""" + + COMMIT = "commit" # Merge writes into parent branch + ABORT = "abort" # Discard all writes + KEEP = "keep" # Leave branch as-is (caller decides) + + +@dataclass(frozen=True) +class Change: + """A single filesystem change detected by dry-run.""" + + kind: str + """Change kind: A=added, M=modified, D=deleted.""" + + path: str + """Path relative to workdir.""" + + +@dataclass +class DryRunResult: + """Result of a dry-run execution.""" + + success: bool + exit_code: int = 0 + stdout: bytes = field(default=b"", repr=False) + stderr: bytes = field(default=b"", repr=False) + changes: list = field(default_factory=list) + error: str | None = None + + +@dataclass +class Sandbox: + """Sandbox configuration and runtime handle. + + Holds both the policy configuration (filesystem, network, resource limits, + etc.) and the runtime state for executing commands. Construct once, + call ``run()``, ``start()`` + lifecycle methods, or use as a context manager. + + A single ``Sandbox`` instance holds at most one running process at a time. + For concurrent execution, create multiple ``Sandbox`` instances. + + Most config fields are optional — unset fields mean "no restriction". + Sandlock's default syscall blocklist is always applied. + + Runtime kwargs (``name``, ``policy_fn``, ``init_fn``, ``work_fn``) have + ``metadata={"runtime": True}`` so serializers can skip them. + """ + + # Filesystem (Landlock) + fs_writable: Sequence[str] = field(default_factory=list) + """Paths the sandbox can write to.""" + + fs_readable: Sequence[str] = field(default_factory=list) + """Paths the sandbox can read (in addition to writable paths).""" + + fs_denied: Sequence[str] = field(default_factory=list) + """Paths explicitly denied (neither read nor write).""" + + extra_deny_syscalls: Sequence[str] = field(default_factory=list) + """Additional syscall names to block on top of Sandlock's default blocklist.""" + + extra_allow_syscalls: Sequence[str] = field(default_factory=list) + """Syscall group names to allow (e.g. ``'sysv_ipc'``).""" + + # Network — endpoint allowlist (protocol × IP × port via seccomp on-behalf path) + net_allow: Sequence[str] = field(default_factory=list) + """Outbound endpoint rules. Each entry is a string. The bare form is + TCP; other protocols use a scheme prefix: + + * ``"host:port"`` — TCP to one host on one port (e.g. ``"api.openai.com:443"``) + * ``"host:port,port,..."`` — TCP, multiple ports (e.g. ``"github.com:22,443"``) + * ``":port"`` / ``"*:port"`` — TCP on any IP (e.g. ``":53"``) + * ``"tcp://host:port"`` — explicit TCP (same suffix grammar as bare form) + * ``"udp://host:port"`` — UDP to a host + * ``"udp://*:*"`` — any UDP (matches the previous ``allow_udp=True`` behavior) + * ``"icmp://host"`` — kernel ping socket (SOCK_DGRAM + IPPROTO_ICMP) to a host + * ``"icmp://*"`` — any ICMP echo destination + + Sandlock does not expose raw ICMP (SOCK_RAW). Workloads that need + ping should rely on the host's ``net.ipv4.ping_group_range`` and + use the dgram path above. + + Protocol gating falls out of rule presence: with no UDP/ICMP rules, + UDP and ICMP socket creation are denied at the seccomp layer. + Hostnames are resolved at sandbox-creation time and pinned via a + synthetic ``/etc/hosts``. Empty = deny all outbound. HTTP rules with + concrete hosts auto-add a matching TCP entry on :attr:`http_ports`. + See README "Network Model" for details.""" + + no_coredump: bool = False + """Disable core dumps and restrict /proc/pid access from other + processes. Applied via prctl(PR_SET_DUMPABLE, 0). Prevents + leaking sandbox memory contents but breaks gdb/strace/perf.""" + + # Network — bind allowlist (Landlock ABI v4+, TCP only) + net_bind: Sequence[int | str] = field(default_factory=list) + """TCP ports the sandbox may bind. Empty = deny all. Each entry is + a port number or a ``"lo-hi"`` range string. Landlock's port hooks + are TCP-only — UDP bind is not separately gated.""" + + # HTTP ACL + http_allow: Sequence[str] = field(default_factory=list) + """HTTP allow rules. Format: "METHOD host/path" with glob matching. + When non-empty, all other HTTP requests are denied by default. + A transparent MITM proxy is spawned in the supervisor.""" + + http_deny: Sequence[str] = field(default_factory=list) + """HTTP block rules. Checked before allow rules. Format: "METHOD host/path".""" + + http_ports: Sequence[int] = field(default_factory=list) + """TCP ports to intercept for HTTP ACL. Defaults to [80] (plus 443 with + http_ca). Override to intercept custom ports like 8080.""" + + http_ca: str | None = None + """PEM CA certificate path for HTTPS MITM. When set, port 443 is also + intercepted by the HTTP ACL proxy.""" + + http_key: str | None = None + """PEM CA private key path for HTTPS MITM. Required with http_ca.""" + + # Resource limits + max_memory: str | int | None = None + """Memory limit. String like '512M' or int bytes.""" + + max_processes: int = 64 + """Maximum total forks allowed in the sandbox (lifetime count, + not concurrent). Enforced by the seccomp notif supervisor. + Also enables fork interception needed for checkpoint freeze.""" + + max_open_files: int | None = None + """Maximum number of open file descriptors. Enforced via + RLIMIT_NOFILE — kernel-enforced, survives exec. Prevents fd + exhaustion attacks. None = inherit system default.""" + + max_cpu: int | None = None + """CPU throttle as a percentage of one core (1–100). E.g. ``50`` + means the sandbox process group gets at most 50% of one core. + Enforced by the parent via SIGSTOP/SIGCONT cycling on the process + group — applies to all processes in the sandbox collectively.""" + + cpu_cores: Sequence[int] | None = None + """CPU cores to pin the sandbox to. When set, sched_setaffinity() + is called in the child to restrict it to the specified cores. + None = inherit parent affinity (unrestricted).""" + + num_cpus: int | None = None + """Visible CPU count in /proc/cpuinfo. When set, the sandbox sees + a synthetic /proc/cpuinfo with only this many processor entries + (renumbered 0..N-1). Also virtualizes /proc/meminfo when + max_memory is set. Requires seccomp user notification (automatic).""" + + port_remap: bool = False + """Enable transparent TCP port virtualization. Each sandbox gets a + full virtual port space — bind(3000) is silently remapped to a unique + real port. Inbound traffic to the virtual port is proxied to the + real port automatically. No network namespaces or root required.""" + + # Deterministic execution + random_seed: int | None = None + """Seed for deterministic randomness. When set, getrandom() returns + deterministic bytes from a seeded PRNG. Same seed = same output.""" + + time_start: float | str | None = None + """Start timestamp for time virtualization. When set, clock_gettime() + and gettimeofday() return shifted time starting from this epoch. + Accepts a Unix timestamp (float) or ISO 8601 string. + Time ticks at real speed from the given start point.""" + + no_randomize_memory: bool = False + """Disable Address Space Layout Randomization (ASLR) inside the sandbox. + When set, stack, heap, mmap, and shared library addresses are + deterministic across runs. Useful for reproducible builds and tests. + Applied via personality(ADDR_NO_RANDOMIZE) — per-process, no root.""" + + no_huge_pages: bool = False + """Disable Transparent Huge Pages (THP) inside the sandbox. + Prevents the kernel from silently promoting 4KB pages to 2MB huge + pages, which causes nondeterministic memory layout, RSS measurements, + and page fault timing. Applied via prctl(PR_SET_THP_DISABLE).""" + + deterministic_dirs: bool = False + """Sort directory entries lexicographically for deterministic readdir(). + Ensures ls, glob, os.listdir etc. return the same order regardless of + filesystem internals.""" + + # GPU access + gpu_devices: Sequence[int] | None = None + """GPU device indices visible to the sandbox. When set, Landlock + rules are added for GPU device files (/dev/nvidia*, /dev/dri/*) and + driver paths (/proc/driver/nvidia, /sys/bus/pci/devices), and + ``CUDA_VISIBLE_DEVICES`` / ``ROCR_VISIBLE_DEVICES`` are set. + ``None`` = no GPU access. ``[]`` (empty list) = all GPUs visible.""" + + # Optional chroot + chroot: str | None = None + """Path to chroot into before applying other confinement.""" + + fs_mount: Mapping[str, str] = field(default_factory=dict) + """Map virtual paths to host directories inside chroot. + Example: {"/work": "/host/sandbox/work"} makes /work inside the + chroot resolve to /host/sandbox/work on the host.""" + + # Environment + clean_env: bool = False + """If True, start with a minimal environment (PATH, HOME, USER, TERM, LANG). + If False (default), inherit the parent's full environment.""" + + env: Mapping[str, str] = field(default_factory=dict) + """Variables to set or override in the child. Applied after clean_env.""" + + + uid: int | None = None + """Map to the given UID inside a user namespace. For example, + ``uid=0`` gives fake root, ``uid=1000`` maps to UID 1000. + The child has no real host privileges regardless of the mapped UID. + Only effective when user namespaces are available.""" + + # Seccomp user notification (filesystem virtualization) + notif_policy: NotifPolicy | None = None + """If set, enables a seccomp user notification supervisor that + intercepts open/openat syscalls and applies path-based rules + for /proc and /sys virtualization. Requires Linux 5.6+.""" + + # Working directory + workdir: str | None = None + """COW root directory. Only controls which directory COW tracks — + does NOT set the child's working directory. Use ``cwd`` for that.""" + + cwd: str | None = None + """Child working directory (chdir target). The child process starts + in this directory. Independent of ``workdir`` (COW root).""" + + # COW filesystem isolation + fs_isolation: FsIsolation = FsIsolation.NONE + """Filesystem isolation mode. Auto-set to OVERLAYFS when workdir is set.""" + + fs_storage: str | None = None + """Separate storage directory for BranchFS COW deltas. + If set, passed as ``--storage`` to ``branchfs mount``.""" + + max_disk: str | None = None + """Disk quota for BranchFS storage (e.g. ``'1G'``). + Passed as ``--max-storage`` to ``branchfs mount``. + Enforced by BranchFS FUSE layer (returns ENOSPC).""" + + on_exit: BranchAction = BranchAction.COMMIT + """Branch action on normal sandbox exit.""" + + on_error: BranchAction = BranchAction.ABORT + """Branch action on sandbox error/exception.""" + + # Runtime kwargs — not part of policy serialization. + name: str | None = field(default=None, repr=False, metadata={"runtime": True}) + """Sandbox name (also exposed as the virtual hostname inside the sandbox). + Auto-generated as ``sandbox-{pid}`` when omitted.""" + + policy_fn: Callable | None = field(default=None, repr=False, metadata={"runtime": True}) + """Optional callback for dynamic per-event policy decisions.""" + + init_fn: Callable | None = field(default=None, repr=False, metadata={"runtime": True}) + """Callback run once in the template process before COW fork.""" + + work_fn: Callable | None = field(default=None, repr=False, metadata={"runtime": True}) + """Callback run in each COW clone, receives clone_id as argument.""" + + def __post_init__(self): + # Validate name + if self.name is not None: + if not self.name: + raise ValueError("sandbox name must not be empty") + if "\0" in self.name: + raise ValueError("sandbox name must not contain NUL bytes") + if len(self.name.encode()) > 64: + raise ValueError("sandbox name must be at most 64 bytes") + # Runtime state — not dataclass fields, not serialized + self._native = None # _NativePolicy created lazily on first use + self._handle = None # live sandbox handle during start()/run() + + def _resolve_name(self) -> str: + """Resolve sandbox name: explicit > auto-generated.""" + import os + if self.name is not None: + return self.name + return f"sandbox-{os.getpid()}" + + def _ensure_native(self): + """Build a fresh native policy from this dataclass. + + Rebuilds on every call so that mutations to config fields + between lifecycle invocations (e.g. ``run()`` → mutate + ``fs_readable`` → ``run()`` again) take effect on the next + run. The Sandbox class is not frozen; a stale native cache + would silently apply outdated config. + """ + from ._sdk import _NativePolicy + self._native = _NativePolicy.from_dataclass(self, policy_fn=self.policy_fn) + return self._native + + # ------------------------------------------------------------------ + # Config helper methods + # ------------------------------------------------------------------ + + def bind_ports(self) -> list[int]: + """Return parsed bind port list, or empty if unrestricted.""" + return parse_ports(self.net_bind) if self.net_bind else [] + + def memory_bytes(self) -> int | None: + """Return max_memory as bytes, or None if unset.""" + if self.max_memory is None: + return None + if isinstance(self.max_memory, int): + return self.max_memory + return parse_memory_size(self.max_memory) + + def time_start_timestamp(self) -> float | None: + """Return time_start as a Unix timestamp float, or None if unset.""" + if self.time_start is None: + return None + if isinstance(self.time_start, (int, float)): + return float(self.time_start) + from datetime import datetime, timezone + s = self.time_start + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + + def cpu_pct(self) -> int | None: + """Return max_cpu as a clamped percentage (1–100), or None.""" + if self.max_cpu is None: + return None + return max(1, min(100, self.max_cpu)) + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._handle is not None: + from ._sdk import _lib + try: + _lib.sandlock_handle_free(self._handle) + except Exception: + pass + self._handle = None + return False + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def pid(self) -> int | None: + """Child PID while running, None otherwise.""" + if self._handle is None: + return None + from ._sdk import _lib + return _lib.sandlock_handle_pid(self._handle) or None + + @property + def is_running(self) -> bool: + """True if a process is currently running in this sandbox.""" + return self._handle is not None + + # ------------------------------------------------------------------ + # Execution methods + # ------------------------------------------------------------------ + + def run(self, cmd: Sequence[str], timeout: float | None = None): + """Run ``cmd`` in this sandbox and return a ``Result``. + + Spawns the command, waits for it to complete (optionally with a + timeout), and returns the result. This is the common one-shot case. + + For explicit lifecycle control (``pause`` / ``resume`` / ``kill``), + use ``start()`` then the lifecycle methods. + + Args: + cmd: Command and arguments to execute. + timeout: Maximum execution time in seconds. The process is + killed and a timeout result is returned if exceeded. + None means no timeout. + """ + from ._sdk import _lib, _make_argv, _read_result_bytes, Result + + if self._handle is not None: + raise RuntimeError("sandbox is already running") + + native = self._ensure_native() + argv, argc = _make_argv(list(cmd)) + resolved_name = self._resolve_name() + + # Spawn (non-blocking) so PID is available for pause/resume + self._handle = _lib.sandlock_spawn( + native.ptr, _encode(resolved_name), argv, argc, + ) + if not self._handle: + return Result(success=False, exit_code=-1, error="sandlock_spawn failed") + + try: + timeout_ms = int(timeout * 1000) if timeout else 0 + result_p = _lib.sandlock_handle_wait_timeout(self._handle, timeout_ms) + finally: + _lib.sandlock_handle_free(self._handle) + self._handle = None + + if not result_p: + return Result(success=False, exit_code=-1, error="sandlock_handle_wait failed") + + exit_code = _lib.sandlock_result_exit_code(result_p) + success = _lib.sandlock_result_success(result_p) + stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) + stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) + _lib.sandlock_result_free(result_p) + + return Result( + success=bool(success), + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ) + + def start(self, cmd: Sequence[str]) -> None: + """Spawn ``cmd`` in the sandbox without waiting for it to finish. + + After calling ``start()``, use ``pid``, ``pause()``, ``resume()``, + ``kill()``, and ``wait()`` to manage the process lifecycle. + + Raises: + RuntimeError: If a process is already running. + """ + from ._sdk import _lib, _make_argv + + if self._handle is not None: + raise RuntimeError("sandbox is already running") + + native = self._ensure_native() + argv, argc = _make_argv(list(cmd)) + resolved_name = self._resolve_name() + + self._handle = _lib.sandlock_spawn( + native.ptr, _encode(resolved_name), argv, argc, + ) + if not self._handle: + raise RuntimeError("sandlock_spawn failed") + + def wait(self): + """Wait for the running process to finish and return its Result. + + Raises: + RuntimeError: If the sandbox is not running. + """ + from ._sdk import _lib, _read_result_bytes, Result + + if self._handle is None: + raise RuntimeError("sandbox is not running") + + try: + result_p = _lib.sandlock_handle_wait_timeout(self._handle, 0) + finally: + _lib.sandlock_handle_free(self._handle) + self._handle = None + + if not result_p: + return Result(success=False, exit_code=-1, error="sandlock_handle_wait failed") + + exit_code = _lib.sandlock_result_exit_code(result_p) + success = _lib.sandlock_result_success(result_p) + stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) + stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) + _lib.sandlock_result_free(result_p) + + return Result( + success=bool(success), + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ) + + def dry_run(self, cmd: Sequence[str], timeout: float | None = None) -> "DryRunResult": + """Dry-run: run a command, collect filesystem changes, then discard. + + Args: + cmd: Command and arguments to execute. + timeout: Maximum execution time in seconds. None means no timeout. + + Returns: + DryRunResult with exit info and list of filesystem changes. + """ + from ._sdk import _lib, _make_argv, _read_result_bytes + + native = self._ensure_native() + argv, argc = _make_argv(list(cmd)) + result_p = _lib.sandlock_dry_run( + native.ptr, _encode(self._resolve_name()), argv, argc, + ) + + if not result_p: + return DryRunResult(success=False, exit_code=-1, error="sandlock_dry_run failed") + + try: + exit_code = _lib.sandlock_dry_run_result_exit_code(result_p) + success = _lib.sandlock_dry_run_result_success(result_p) + stdout = _read_result_bytes(result_p, _lib.sandlock_dry_run_result_stdout_bytes) + stderr = _read_result_bytes(result_p, _lib.sandlock_dry_run_result_stderr_bytes) + + import ctypes + n = _lib.sandlock_dry_run_result_changes_len(result_p) + changes = [] + for i in range(n): + kind_byte = _lib.sandlock_dry_run_result_change_kind(result_p, i) + kind = kind_byte.decode("ascii") + path_p = _lib.sandlock_dry_run_result_change_path(result_p, i) + if path_p: + path = ctypes.c_char_p(path_p).value.decode("utf-8") + _lib.sandlock_string_free(ctypes.cast(path_p, ctypes.c_char_p)) + else: + path = "" + changes.append(Change(kind=kind, path=path)) + finally: + _lib.sandlock_dry_run_result_free(result_p) + + return DryRunResult( + success=bool(success), + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + changes=changes, + ) + + def run_interactive(self, cmd: Sequence[str]) -> int: + """Run with inherited stdio. Returns exit code.""" + from ._sdk import _lib, _make_argv + + native = self._ensure_native() + argv, argc = _make_argv(list(cmd)) + return _lib.sandlock_run_interactive( + native.ptr, _encode(self._resolve_name()), argv, argc, + ) + + def cmd(self, args: list[str]) -> "Stage": + """Bind a command to this sandbox, returning a lazy Stage.""" + from ._sdk import Stage + return Stage(self, args) + + def fork(self, n: int) -> "ForkResult": + """Create N COW clones. init_fn runs once, work_fn in each clone. + + Requires ``init_fn`` and ``work_fn`` passed to ``Sandbox()``. + + Returns ForkResult with clone PIDs. + + Example:: + + sb = Sandbox( + fs_readable=[...], + init_fn=lambda: load_model(), + work_fn=lambda clone_id: rollout(clone_id), + ) + clones = sb.fork(1000) + """ + from ._sdk import _lib, _INIT_FN_TYPE, _WORK_FN_TYPE, ForkResult, _make_argv, _encode as _sdk_encode + + if self.init_fn is None or self.work_fn is None: + raise RuntimeError("fork() requires init_fn and work_fn in Sandbox()") + + native = self._ensure_native() + + c_init = _INIT_FN_TYPE(self.init_fn) + _user_work = self.work_fn + def _flushing_work(clone_id): + import sys, os, io + sys.stdout = io.TextIOWrapper(io.FileIO(1, 'w', closefd=False), line_buffering=True) + _user_work(clone_id) + sys.stdout.flush() + c_work = _WORK_FN_TYPE(_flushing_work) + self._c_init = c_init # prevent GC + self._c_work = c_work + + sb_ptr = _lib.sandlock_new_with_fns( + native.ptr, _encode(self._resolve_name()), c_init, c_work, + ) + if not sb_ptr: + raise RuntimeError("sandlock_new_with_fns failed") + + fork_result = _lib.sandlock_fork(sb_ptr, n) + + _lib.sandlock_wait(sb_ptr) + _lib.sandlock_sandbox_free(sb_ptr) + + if not fork_result: + return ForkResult(None, [], native) + + count = _lib.sandlock_fork_result_count(fork_result) + pids = [_lib.sandlock_fork_result_pid(fork_result, i) for i in range(count)] + + return ForkResult(fork_result, pids, native) + + def reduce(self, cmd: list[str], fork_result: "ForkResult") -> "Result": + """Reduce: read clone stdout pipes, feed to reducer stdin. + + Args: + cmd: Reducer command (receives combined clone output on stdin). + fork_result: ForkResult from fork(). + + Returns: + Result with reducer's stdout/stderr. + + Example:: + + clones = mapper.fork(4) + result = reducer.reduce(["python3", "sum.py"], clones) + """ + from ._sdk import _lib, _make_argv, _read_result_bytes, Result + + if fork_result._ptr is None: + return Result(success=False, exit_code=-1, error="no fork result") + + native = self._ensure_native() + argv, argc = _make_argv(cmd) + result_p = _lib.sandlock_reduce( + fork_result._ptr, native.ptr, _encode(self._resolve_name()), argv, argc, + ) + fork_result._ptr = None # consumed by reduce + + if not result_p: + return Result(success=False, exit_code=-1, error="reduce failed") + + exit_code = _lib.sandlock_result_exit_code(result_p) + success = _lib.sandlock_result_success(result_p) + stdout = _read_result_bytes(result_p, _lib.sandlock_result_stdout_bytes) + stderr = _read_result_bytes(result_p, _lib.sandlock_result_stderr_bytes) + _lib.sandlock_result_free(result_p) + + return Result( + success=bool(success), + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + ) + + # ------------------------------------------------------------------ + # Lifecycle methods + # ------------------------------------------------------------------ + + def pause(self) -> None: + """Send SIGSTOP to the sandbox process group.""" + import signal + pid = self.pid + if pid is None: + raise RuntimeError("sandbox is not running") + import os + os.killpg(pid, signal.SIGSTOP) + + def resume(self) -> None: + """Send SIGCONT to the sandbox process group.""" + import signal + pid = self.pid + if pid is None: + raise RuntimeError("sandbox is not running") + import os + os.killpg(pid, signal.SIGCONT) + + def kill(self) -> None: + """Send SIGKILL to the sandbox process group.""" + import signal + pid = self.pid + if pid is None: + raise RuntimeError("sandbox is not running") + import os + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + def ports(self) -> dict[int, int]: + """Return current port mappings {virtual_port: real_port}. + + Only contains entries where the real port differs from the virtual + port (i.e., where a remap occurred). Empty if port_remap is disabled + or no ports have been remapped. Requires the sandbox to be running. + """ + if self._handle is None: + return {} + from ._sdk import _lib + c_str = _lib.sandlock_handle_port_mappings(self._handle) + if not c_str: + return {} + try: + import json + raw = json.loads(c_str.decode()) + return {int(k): v for k, v in raw.items()} + finally: + _lib.sandlock_string_free(c_str) + + def checkpoint( + self, + save_fn: "Callable[[], bytes] | None" = None, + ) -> "Checkpoint": + """Capture a checkpoint of the running sandbox. + + The sandbox is frozen (SIGSTOP + fork-hold), state is captured + via ptrace + /proc, then thawed. + + Args: + save_fn: Optional callback that returns application-level + state bytes. Called after OS-level capture; the result + is stored in ``checkpoint.app_state``. Use this for + state that ptrace can't see (caches, session data, etc.). + + Returns: + Checkpoint with process state, memory, fds, and optional app state. + + Raises: + RuntimeError: If the sandbox is not running or capture fails. + """ + from ._sdk import _lib, Checkpoint + + if self._handle is None: + raise RuntimeError("sandbox is not running (use start() or run() first)") + ptr = _lib.sandlock_handle_checkpoint(self._handle) + if not ptr: + raise RuntimeError("checkpoint capture failed") + cp = Checkpoint(ptr) + if save_fn is not None: + cp.app_state = save_fn() + return cp + + +def _encode(s: str) -> bytes: + """Encode a string to UTF-8 bytes, rejecting NUL bytes.""" + if isinstance(s, str): + result = s.encode("utf-8") + elif isinstance(s, bytes): + result = s + else: + result = str(s).encode("utf-8") + if b'\x00' in result: + raise ValueError(f"NUL byte in string argument: {result!r}") + return result diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index 7826d3a..7979b8e 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -6,7 +6,7 @@ import pytest -from sandlock import Sandbox, Policy, Checkpoint +from sandlock import Sandbox, Checkpoint from sandlock._sdk import _encode, _lib, _make_argv @@ -19,16 +19,17 @@ def _policy(**overrides): defaults = {"fs_readable": _PYTHON_READABLE} defaults.update(overrides) - return Policy(**defaults) + return Sandbox(**defaults) @pytest.fixture def running_sandbox(): """A sandbox with a long-running process for checkpoint tests.""" - sb = Sandbox(_policy()) + sb = _policy() argv, argc = _make_argv(["sleep", "60"]) + native = sb._ensure_native() sb._handle = _lib.sandlock_spawn( - sb._native.ptr, + native.ptr, _encode(sb._resolve_name()), argv, argc, @@ -58,7 +59,7 @@ def test_checkpoint_save_fn_none_by_default(self, running_sandbox): assert cp.app_state is None def test_checkpoint_not_running_raises(self): - sb = Sandbox(_policy()) + sb = _policy() with pytest.raises(RuntimeError, match="not running"): sb.checkpoint() diff --git a/python/tests/test_chroot_cow.py b/python/tests/test_chroot_cow.py index f9e3c81..0d2432c 100644 --- a/python/tests/test_chroot_cow.py +++ b/python/tests/test_chroot_cow.py @@ -14,8 +14,8 @@ import pytest -from sandlock import Policy, Sandbox -from sandlock.policy import FsIsolation +from sandlock import Sandbox +from sandlock.sandbox import FsIsolation _HELPER_BIN = Path(__file__).resolve().parent.parent.parent / "tests" / "rootfs-helper" @@ -30,7 +30,7 @@ def _overlayfs_available(): """Check if unprivileged overlayfs is usable (needs user+mount ns).""" try: - p = Policy( + p = Sandbox( chroot=None, workdir="/tmp", cwd="/tmp", @@ -41,7 +41,7 @@ def _overlayfs_available(): clean_env=True, env={"PATH": "/bin:/usr/bin"}, ) - r = Sandbox(p).run(["true"]) + r = p.run(["true"]) return r.success except Exception: return False @@ -96,7 +96,7 @@ def _cow_policy(rootfs, on_exit="abort", fs_storage=None, backend="seccomp"): # seccomp backend: fs_isolation left as NONE -- workdir triggers # the seccomp COW path. overlayfs: explicit. fs_isolation = FsIsolation.OVERLAYFS if backend == "overlayfs" else FsIsolation.NONE - return Policy( + return Sandbox( chroot=str(rootfs), workdir=str(rootfs), cwd="/", @@ -119,7 +119,7 @@ class TestCowAbort: def test_abort_no_leak(self, rootfs, backend): p = _cow_policy(rootfs, on_exit="abort", backend=backend) - r = Sandbox(p).run(["sh", "-c", "echo marker > /tmp/marker.txt"]) + r = p.run(["sh", "-c", "echo marker > /tmp/marker.txt"]) assert r.success, f"[{backend}] failed: {r.stderr}" assert not (rootfs / "tmp" / "marker.txt").exists(), \ f"[{backend}] file should not leak to rootfs with on_exit=abort" @@ -128,7 +128,7 @@ def test_abort_cleans_storage(self, rootfs, backend): """Abort must actually remove the upper dir, not just skip cleanup.""" storage = tempfile.mkdtemp() p = _cow_policy(rootfs, on_exit="abort", fs_storage=storage, backend=backend) - r = Sandbox(p).run(["sh", "-c", "echo data > /tmp/data.txt"]) + r = p.run(["sh", "-c", "echo data > /tmp/data.txt"]) assert r.success, f"[{backend}] failed: {r.stderr}" # The storage dir should be empty (UUID subdir removed by abort). remaining = os.listdir(storage) @@ -138,7 +138,7 @@ def test_abort_cleans_storage(self, rootfs, backend): def test_abort_write_visible_during_run(self, rootfs, backend): """Writes should be visible to the child during execution.""" p = _cow_policy(rootfs, on_exit="abort", backend=backend) - r = Sandbox(p).run([ + r = p.run([ "sh", "-c", "echo hello > /tmp/test.txt && cat /tmp/test.txt" ]) assert r.success, f"[{backend}] failed: {r.stderr}" @@ -146,7 +146,7 @@ def test_abort_write_visible_during_run(self, rootfs, backend): def test_abort_multiple_files(self, rootfs, backend): p = _cow_policy(rootfs, on_exit="abort", backend=backend) - r = Sandbox(p).run([ + r = p.run([ "sh", "-c", "echo a > /tmp/a.txt && echo b > /tmp/b.txt && cat /tmp/a.txt /tmp/b.txt" ]) @@ -166,7 +166,7 @@ class TestCowCommit: def test_commit_persists(self, rootfs, backend): p = _cow_policy(rootfs, on_exit="commit", backend=backend) - r = Sandbox(p).run(["sh", "-c", "echo persisted > /tmp/persist.txt"]) + r = p.run(["sh", "-c", "echo persisted > /tmp/persist.txt"]) assert r.success, f"[{backend}] failed: {r.stderr}" assert (rootfs / "tmp" / "persist.txt").exists(), \ f"[{backend}] file should persist to rootfs with on_exit=commit" @@ -178,7 +178,7 @@ def test_commit_cleans_storage(self, rootfs, backend): """Commit must copy to rootfs AND remove the upper dir afterward.""" storage = tempfile.mkdtemp() p = _cow_policy(rootfs, on_exit="commit", fs_storage=storage, backend=backend) - r = Sandbox(p).run(["sh", "-c", "echo committed > /tmp/committed.txt"]) + r = p.run(["sh", "-c", "echo committed > /tmp/committed.txt"]) assert r.success, f"[{backend}] failed: {r.stderr}" # File must be in rootfs (commit actually ran) assert (rootfs / "tmp" / "committed.txt").exists(), \ @@ -202,7 +202,7 @@ class TestCowKeep: def test_keep_not_in_rootfs(self, rootfs, backend): storage = tempfile.mkdtemp() p = _cow_policy(rootfs, on_exit="keep", fs_storage=storage, backend=backend) - r = Sandbox(p).run(["sh", "-c", "echo kept > /tmp/kept.txt"]) + r = p.run(["sh", "-c", "echo kept > /tmp/kept.txt"]) assert r.success, f"[{backend}] failed: {r.stderr}" assert not (rootfs / "tmp" / "kept.txt").exists(), \ f"[{backend}] file should not be in rootfs with on_exit=keep" @@ -211,7 +211,7 @@ def test_keep_write_visible_during_run(self, rootfs, backend): """With keep, writes should be visible during execution.""" storage = tempfile.mkdtemp() p = _cow_policy(rootfs, on_exit="keep", fs_storage=storage, backend=backend) - r = Sandbox(p).run([ + r = p.run([ "sh", "-c", "echo kept > /tmp/kept.txt && cat /tmp/kept.txt" ]) assert r.success, f"[{backend}] failed: {r.stderr}" @@ -235,7 +235,7 @@ def run_sandbox(name): try: storage = tempfile.mkdtemp() p = _cow_policy(rootfs, on_exit="abort", fs_storage=storage, backend=backend) - r = Sandbox(p).run([ + r = p.run([ "sh", "-c", f"echo {name} > /tmp/id.txt && cat /tmp/id.txt" ]) @@ -263,7 +263,7 @@ def test_concurrent_no_rootfs_leak(self, rootfs, backend): def run_sandbox(name): storage = tempfile.mkdtemp() p = _cow_policy(rootfs, on_exit="abort", fs_storage=storage, backend=backend) - Sandbox(p).run([ + p.run([ "sh", "-c", f"echo {name} > /tmp/{name}.txt" ]) diff --git a/python/tests/test_chroot_legacy_syscalls.py b/python/tests/test_chroot_legacy_syscalls.py index a60b912..d589f03 100644 --- a/python/tests/test_chroot_legacy_syscalls.py +++ b/python/tests/test_chroot_legacy_syscalls.py @@ -14,7 +14,7 @@ import pytest -from sandlock import Policy, Sandbox +from sandlock import Sandbox # ── helpers ────────────────────────────────────────────────────── @@ -33,7 +33,7 @@ def _chroot_policy(rootfs, **overrides): env={"PATH": "/usr/bin:/bin"}, ) defaults.update(overrides) - return Policy(**defaults) + return Sandbox(**defaults) @pytest.fixture @@ -79,7 +79,7 @@ def _run_helper(rootfs, args, *, fs_writable=None): fs_readable=_FS_READABLE + ["/", "/work"], fs_writable=fs_writable or [], ) - return Sandbox(policy).run(["rootfs-helper"] + args) + return policy.run(["rootfs-helper"] + args) # ── SYS_stat (nr 4) ───────────────────────────────────────────── diff --git a/python/tests/test_fs_mount.py b/python/tests/test_fs_mount.py index 059543d..e183959 100644 --- a/python/tests/test_fs_mount.py +++ b/python/tests/test_fs_mount.py @@ -8,8 +8,8 @@ import pytest -from sandlock import Policy, Sandbox -from sandlock.policy import FsIsolation +from sandlock import Sandbox +from sandlock.sandbox import FsIsolation _HELPER_BIN = Path(__file__).resolve().parent.parent.parent / "tests" / "rootfs-helper" @@ -50,7 +50,7 @@ def _mount_policy(rootfs, work_dir, cwd="/", extra_fs_readable=None): readable = list(_FS_READABLE) if extra_fs_readable: readable.extend(extra_fs_readable) - return Policy( + return Sandbox( chroot=str(rootfs), fs_mount={"/work": str(work_dir)}, fs_readable=readable, @@ -69,7 +69,7 @@ def test_fs_mount_read_file(self, rootfs, tmp_path): (work_dir / "hello.txt").write_text("hello from host\n") policy = _mount_policy(rootfs, work_dir) - result = Sandbox(policy).run(["cat", "/work/hello.txt"]) + result = policy.run(["cat", "/work/hello.txt"]) assert result.success, f"failed: {result.stderr}" assert b"hello from host" in result.stdout @@ -79,7 +79,7 @@ def test_fs_mount_write_file(self, rootfs, tmp_path): work_dir.mkdir() policy = _mount_policy(rootfs, work_dir) - result = Sandbox(policy).run(["write", "/work/output.txt", "sandbox wrote this"]) + result = policy.run(["write", "/work/output.txt", "sandbox wrote this"]) assert result.success, f"failed: {result.stderr}" assert (work_dir / "output.txt").exists() assert "sandbox wrote this" in (work_dir / "output.txt").read_text() @@ -92,7 +92,7 @@ def test_fs_mount_ls_directory(self, rootfs, tmp_path): (work_dir / "bbb.txt").write_text("b") policy = _mount_policy(rootfs, work_dir) - result = Sandbox(policy).run(["ls", "/work"]) + result = policy.run(["ls", "/work"]) assert result.success, f"failed: {result.stderr}" assert b"aaa.txt" in result.stdout assert b"bbb.txt" in result.stdout @@ -106,7 +106,7 @@ def test_fs_mount_cwd(self, rootfs, tmp_path): (rootfs / "work").mkdir(exist_ok=True) policy = _mount_policy(rootfs, work_dir, cwd="/work") - result = Sandbox(policy).run(["cat", "file.txt"]) + result = policy.run(["cat", "file.txt"]) assert result.success, f"failed: {result.stderr}" assert b"relative access" in result.stdout @@ -118,11 +118,11 @@ def test_fs_mount_survives_across_runs(self, rootfs, tmp_path): policy = _mount_policy(rootfs, work_dir) # Run 1: write - r1 = Sandbox(policy).run(["write", "/work/persist.txt", "persisted data"]) + r1 = policy.run(["write", "/work/persist.txt", "persisted data"]) assert r1.success, f"run 1 failed: {r1.stderr}" # Run 2: read - r2 = Sandbox(policy).run(["cat", "/work/persist.txt"]) + r2 = policy.run(["cat", "/work/persist.txt"]) assert r2.success, f"run 2 failed: {r2.stderr}" assert b"persisted data" in r2.stdout @@ -136,10 +136,10 @@ def test_fs_mount_isolation(self, rootfs, tmp_path): policy_a = _mount_policy(rootfs, work_a) policy_b = _mount_policy(rootfs, work_b) - ra = Sandbox(policy_a).run(["write", "/work/id.txt", "sandbox_a"]) + ra = policy_a.run(["write", "/work/id.txt", "sandbox_a"]) assert ra.success, f"sandbox A failed: {ra.stderr}" - rb = Sandbox(policy_b).run(["write", "/work/id.txt", "sandbox_b"]) + rb = policy_b.run(["write", "/work/id.txt", "sandbox_b"]) assert rb.success, f"sandbox B failed: {rb.stderr}" assert (work_a / "id.txt").read_text().strip() == "sandbox_a" @@ -156,7 +156,7 @@ def test_fs_mount_rootfs_untouched(self, rootfs, tmp_path): work_dir.mkdir() policy = _mount_policy(rootfs, work_dir) - result = Sandbox(policy).run(["write", "/work/new.txt", "from sandbox"]) + result = policy.run(["write", "/work/new.txt", "from sandbox"]) assert result.success, f"failed: {result.stderr}" # The write should go to work_dir, not rootfs /work @@ -188,7 +188,7 @@ def _cow_mount_policy(self, rootfs, work_dir, storage_dir, ) if max_disk is not None: kwargs["max_disk"] = max_disk - return Policy(**kwargs) + return Sandbox(**kwargs) def test_fs_mount_cow_commit(self, rootfs, tmp_path): """Write via fs_mount + COW with on_exit=commit, verify file persists.""" @@ -199,7 +199,7 @@ def test_fs_mount_cow_commit(self, rootfs, tmp_path): policy = self._cow_mount_policy(rootfs, work_dir, storage_dir, on_exit="commit") - result = Sandbox(policy).run(["write", "/work/committed.txt", + result = policy.run(["write", "/work/committed.txt", "cow commit data"]) assert result.success, f"failed: {result.stderr}" assert (work_dir / "committed.txt").exists(), \ @@ -216,7 +216,7 @@ def test_fs_mount_cow_abort(self, rootfs, tmp_path): policy = self._cow_mount_policy(rootfs, work_dir, storage_dir, on_exit="abort") - result = Sandbox(policy).run(["write", "/work/new_file.txt", + result = policy.run(["write", "/work/new_file.txt", "should be discarded"]) assert result.success, f"failed: {result.stderr}" assert not (work_dir / "new_file.txt").exists(), \ @@ -240,6 +240,6 @@ def test_fs_mount_cow_quota(self, rootfs, tmp_path): on_exit="abort", max_disk="1K") # The write applet opens the file with O_WRONLY|O_CREAT|O_TRUNC, # triggering a COW copy of the 8 KiB file against a 1 KiB quota. - result = Sandbox(policy).run(["write", "/work/big.bin", "overwrite"]) + result = policy.run(["write", "/work/big.bin", "overwrite"]) assert not result.success, \ "Writing to a file exceeding COW quota should fail" diff --git a/python/tests/test_mcp.py b/python/tests/test_mcp.py index 0a1c583..171589c 100644 --- a/python/tests/test_mcp.py +++ b/python/tests/test_mcp.py @@ -4,7 +4,7 @@ import pytest from types import SimpleNamespace -from sandlock.policy import Policy +from sandlock.sandbox import Sandbox from sandlock.mcp._policy import policy_for_tool, capabilities_from_mcp_tool diff --git a/python/tests/test_mcp_integration.py b/python/tests/test_mcp_integration.py index fa00acf..28758f9 100644 --- a/python/tests/test_mcp_integration.py +++ b/python/tests/test_mcp_integration.py @@ -9,7 +9,6 @@ import pytest -from sandlock import Sandbox, Policy from sandlock.mcp import policy_for_tool, McpSandbox @@ -18,7 +17,7 @@ def _run_in_sandbox(capabilities, script, workspace, timeout=15.0): """Run a script in a sandbox with given capabilities.""" policy = policy_for_tool(workspace=workspace, capabilities=capabilities) - return Sandbox(policy).run([sys.executable, "-c", script], timeout=timeout) + return policy.run([sys.executable, "-c", script], timeout=timeout) # -- Tool functions for McpSandbox tests -- diff --git a/python/tests/test_pipeline.py b/python/tests/test_pipeline.py index 867bdfd..f88fc7f 100644 --- a/python/tests/test_pipeline.py +++ b/python/tests/test_pipeline.py @@ -7,7 +7,7 @@ import pytest -from sandlock import Sandbox, Policy, Stage, Pipeline, NamedStage, Gather, GatherPipeline +from sandlock import Sandbox, Stage, Pipeline, NamedStage, Gather, GatherPipeline # --- Helpers --- @@ -24,35 +24,35 @@ def _policy(**overrides): "clean_env": True, } defaults.update(overrides) - return Policy(**defaults) + return Sandbox(**defaults) # --- Stage --- class TestStage: def test_cmd_returns_stage(self): - sb = Sandbox(_policy()) + sb = _policy() stage = sb.cmd(["echo", "hello"]) assert isinstance(stage, Stage) assert stage.sandbox is sb assert stage.args == ["echo", "hello"] def test_stage_run(self): - result = Sandbox(_policy()).cmd(["echo", "hello"]).run() + result = _policy().cmd(["echo", "hello"]).run() assert result.success assert b"hello" in result.stdout def test_stage_or_stage_returns_pipeline(self): - a = Sandbox(_policy()).cmd(["echo", "hello"]) - b = Sandbox(_policy()).cmd(["cat"]) + a = _policy().cmd(["echo", "hello"]) + b = _policy().cmd(["cat"]) p = a | b assert isinstance(p, Pipeline) assert len(p.stages) == 2 def test_stage_or_pipeline(self): - a = Sandbox(_policy()).cmd(["echo", "a"]) - b = Sandbox(_policy()).cmd(["cat"]) - c = Sandbox(_policy()).cmd(["cat"]) + a = _policy().cmd(["echo", "a"]) + b = _policy().cmd(["cat"]) + c = _policy().cmd(["cat"]) p = a | b | c assert isinstance(p, Pipeline) assert len(p.stages) == 3 @@ -64,8 +64,8 @@ class TestPipeline: def test_two_stage_pipe(self): """echo | cat: basic data flow through pipe.""" result = ( - Sandbox(_policy()).cmd(["echo", "hello pipeline"]) - | Sandbox(_policy()).cmd(["cat"]) + _policy().cmd(["echo", "hello pipeline"]) + | _policy().cmd(["cat"]) ).run() assert result.success assert b"hello pipeline" in result.stdout @@ -73,9 +73,9 @@ def test_two_stage_pipe(self): def test_three_stage_pipe(self): """echo | tr | cat: data flows through multiple stages.""" result = ( - Sandbox(_policy()).cmd(["echo", "hello"]) - | Sandbox(_policy()).cmd(["tr", "a-z", "A-Z"]) - | Sandbox(_policy()).cmd(["cat"]) + _policy().cmd(["echo", "hello"]) + | _policy().cmd(["tr", "a-z", "A-Z"]) + | _policy().cmd(["cat"]) ).run() assert result.success assert b"HELLO" in result.stdout @@ -95,8 +95,8 @@ def test_disjoint_policies(self): processor_policy = _policy() result = ( - Sandbox(reader_policy).cmd(["cat", secret]) - | Sandbox(processor_policy).cmd(["tr", "a-z", "A-Z"]) + reader_policy.cmd(["cat", secret]) + | processor_policy.cmd(["tr", "a-z", "A-Z"]) ).run() assert result.success assert b"SENSITIVE DATA" in result.stdout @@ -104,8 +104,8 @@ def test_disjoint_policies(self): def test_pipeline_captures_last_stderr(self): """Stderr from last stage is captured.""" result = ( - Sandbox(_policy()).cmd(["echo", "hello"]) - | Sandbox(_policy()).cmd( + _policy().cmd(["echo", "hello"]) + | _policy().cmd( [sys.executable, "-c", "import sys; sys.stderr.write('err msg\\n'); " "print(sys.stdin.read().strip())"] @@ -122,8 +122,8 @@ def test_pipeline_stdout_to_fd(self): out_fd = os.open(out_path, os.O_WRONLY | os.O_CREAT, 0o644) try: result = ( - Sandbox(_policy()).cmd(["echo", "to fd"]) - | Sandbox(_policy()).cmd(["cat"]) + _policy().cmd(["echo", "to fd"]) + | _policy().cmd(["cat"]) ).run(stdout=out_fd) finally: os.close(out_fd) @@ -136,8 +136,8 @@ def test_pipeline_stdout_to_fd(self): def test_first_stage_failure(self): """Pipeline reports failure when first stage fails.""" result = ( - Sandbox(_policy()).cmd(["/nonexistent"]) - | Sandbox(_policy()).cmd(["cat"]) + _policy().cmd(["/nonexistent"]) + | _policy().cmd(["cat"]) ).run() # Last stage reads EOF from pipe → exits 0, but first stage failed. # We report last stage's exit code. @@ -146,8 +146,8 @@ def test_first_stage_failure(self): def test_last_stage_failure(self): """Pipeline reports failure of the last stage.""" result = ( - Sandbox(_policy()).cmd(["echo", "hello"]) - | Sandbox(_policy()).cmd( + _policy().cmd(["echo", "hello"]) + | _policy().cmd( [sys.executable, "-c", "import sys; sys.exit(42)"] ) ).run() @@ -156,15 +156,15 @@ def test_last_stage_failure(self): def test_pipeline_requires_two_stages(self): with pytest.raises(ValueError, match="at least 2"): - Pipeline([Sandbox(_policy()).cmd(["echo"])]) + Pipeline([_policy().cmd(["echo"])]) def test_pipeline_timeout(self): """Pipeline times out if a stage hangs.""" result = ( - Sandbox(_policy()).cmd( + _policy().cmd( [sys.executable, "-c", "import time; time.sleep(60)"] ) - | Sandbox(_policy()).cmd(["cat"]) + | _policy().cmd(["cat"]) ).run(timeout=1) assert not result.success assert "timed out" in (result.error or "").lower() @@ -204,8 +204,8 @@ def test_xoa_data_flow(self): ] result = ( - Sandbox(planner_policy).cmd(planner_cmd) - | Sandbox(executor_policy).cmd( + planner_policy.cmd(planner_cmd) + | executor_policy.cmd( [sys.executable, "-"] # reads script from stdin ) ).run() @@ -219,12 +219,12 @@ def test_xoa_executor_no_network(self): executor_policy = _policy(net_allow=[]) result = ( - Sandbox(_policy()).cmd( + _policy().cmd( [sys.executable, "-c", "print('import socket; " "socket.create_connection((\"1.1.1.1\", 80), timeout=1)')"] ) - | Sandbox(executor_policy).cmd( + | executor_policy.cmd( [sys.executable, "-c", "-"] ) ).run() @@ -237,32 +237,32 @@ def test_xoa_executor_no_network(self): class TestGather: def test_as_returns_named_stage(self): - stage = Sandbox(_policy()).cmd(["echo", "hello"]) + stage = _policy().cmd(["echo", "hello"]) named = stage.as_("greeting") assert isinstance(named, NamedStage) assert named.name == "greeting" def test_add_returns_gather(self): - a = Sandbox(_policy()).cmd(["echo", "a"]).as_("a") - b = Sandbox(_policy()).cmd(["echo", "b"]).as_("b") + a = _policy().cmd(["echo", "a"]).as_("a") + b = _policy().cmd(["echo", "b"]).as_("b") g = a + b assert isinstance(g, Gather) assert len(g.sources) == 2 def test_gather_or_stage_returns_pipeline(self): g = ( - Sandbox(_policy()).cmd(["echo", "a"]).as_("a") - + Sandbox(_policy()).cmd(["echo", "b"]).as_("b") + _policy().cmd(["echo", "a"]).as_("a") + + _policy().cmd(["echo", "b"]).as_("b") ) - gp = g | Sandbox(_policy()).cmd(["cat"]) + gp = g | _policy().cmd(["cat"]) assert isinstance(gp, GatherPipeline) def test_gather_two_sources(self): """Two producers pipe into one consumer via gather.""" result = ( - Sandbox(_policy()).cmd(["echo", "hello"]).as_("greeting") - + Sandbox(_policy()).cmd(["echo", "world"]).as_("name") - | Sandbox(_policy()).cmd( + _policy().cmd(["echo", "hello"]).as_("greeting") + + _policy().cmd(["echo", "world"]).as_("name") + | _policy().cmd( ["sh", "-c", 'read name; greeting=$(cat <&3); echo "$greeting $name"'] ) @@ -280,13 +280,13 @@ def test_gather_with_python_inputs(self): ] + python_paths))) result = ( - Sandbox(policy).cmd( + policy.cmd( [sys.executable, "-c", "print('DATA_CONTENT')"] ).as_("data") - + Sandbox(policy).cmd( + + policy.cmd( [sys.executable, "-c", "print('CODE_CONTENT')"] ).as_("code") - | Sandbox(policy).cmd( + | policy.cmd( [sys.executable, "-c", "from sandlock import inputs; " "print(f'code={inputs[\"code\"].strip()}'); " @@ -313,11 +313,11 @@ def test_gather_disjoint_policies(self): consumer_policy = _policy() result = ( - Sandbox(data_policy).cmd(["cat", secret]).as_("data") - + Sandbox(code_policy).cmd( + data_policy.cmd(["cat", secret]).as_("data") + + code_policy.cmd( ["echo", "upper"] ).as_("code") - | Sandbox(consumer_policy).cmd( + | consumer_policy.cmd( [sys.executable, "-c", "import os, sys\n" "code = sys.stdin.read().strip()\n" @@ -335,10 +335,10 @@ def test_gather_disjoint_policies(self): def test_gather_three_sources(self): """Three producers fan into one consumer.""" result = ( - Sandbox(_policy()).cmd(["echo", "aaa"]).as_("a") - + Sandbox(_policy()).cmd(["echo", "bbb"]).as_("b") - + Sandbox(_policy()).cmd(["echo", "ccc"]).as_("c") - | Sandbox(_policy()).cmd( + _policy().cmd(["echo", "aaa"]).as_("a") + + _policy().cmd(["echo", "bbb"]).as_("b") + + _policy().cmd(["echo", "ccc"]).as_("c") + | _policy().cmd( ["sh", "-c", # c on stdin, a on fd 3, b on fd 4 # Use read builtin (no fork) instead of $(cat) to avoid diff --git a/python/tests/test_policy_fn.py b/python/tests/test_policy_fn.py index f60f73c..06c863a 100644 --- a/python/tests/test_policy_fn.py +++ b/python/tests/test_policy_fn.py @@ -7,7 +7,7 @@ import pytest -from sandlock import Sandbox, Policy, SyscallEvent, PolicyContext +from sandlock import Sandbox, SyscallEvent, PolicyContext _PYTHON_READABLE = list(dict.fromkeys([ @@ -19,7 +19,7 @@ def _policy(**overrides): defaults = {"fs_readable": _PYTHON_READABLE, "fs_writable": ["/tmp"]} defaults.update(overrides) - return Policy(**defaults) + return Sandbox(**defaults) # --------------------------------------------------------------------------- @@ -74,7 +74,7 @@ def test_receives_events(self): def on_event(event, ctx): events.append(event.syscall) - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["python3", "-c", "print('hello')"] ) assert result.success @@ -88,7 +88,7 @@ def on_event(event, ctx): if event.syscall in ("execve", "execveat"): exec_events.append(event) - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["python3", "-c", "print('hello')"] ) assert result.success @@ -101,7 +101,7 @@ def test_passthrough_no_modification(self): def on_event(event, ctx): count["n"] += 1 - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["python3", "-c", "print(6 * 7)"] ) assert result.success @@ -115,7 +115,7 @@ def test_multiple_runs_isolated(self): def on_event(event, ctx): pass # just needs to not crash - sb = Sandbox(_policy(), policy_fn=on_event) + sb = _policy(policy_fn=on_event) r1 = sb.run(["echo", "a"]) r2 = sb.run(["echo", "b"]) assert r1.success @@ -133,10 +133,7 @@ def on_event(event, ctx): if event.syscall in ("execve", "execveat"): ctx.restrict_network([]) - result = Sandbox( - _policy(net_allow=["127.0.0.1:443"]), - policy_fn=on_event, - ).run(["python3", "-c", "print('restricted')"]) + result = _policy(net_allow=["127.0.0.1:443"], policy_fn=on_event).run(["python3", "-c", "print('restricted')"]) assert result.success assert b"restricted" in result.stdout @@ -146,7 +143,7 @@ def on_event(event, ctx): if event.syscall in ("execve", "execveat"): ctx.restrict_max_memory(32 * 1024 * 1024) - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["echo", "ok"] ) # Should still run (echo uses very little memory) @@ -157,7 +154,7 @@ def on_event(event, ctx): if event.syscall in ("execve", "execveat"): ctx.restrict_max_processes(1) - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["echo", "ok"] ) @@ -179,10 +176,7 @@ def on_event(event, ctx): return errno.EACCES # 13 return 0 - result = Sandbox( - _policy(net_allow=["127.0.0.1:443"]), - policy_fn=on_event, - ).run(["python3", "-c", + result = _policy(net_allow=["127.0.0.1:443"], policy_fn=on_event).run(["python3", "-c", f"import socket\n" f"s = socket.socket(); s.settimeout(0.5)\n" f"try:\n" @@ -205,7 +199,7 @@ def on_event(event, ctx): return "audit" return 0 - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["cat", "/etc/hostname"] ) assert result.success, "audit should allow the syscall" @@ -217,10 +211,7 @@ def on_event(event, ctx): return True return False - result = Sandbox( - _policy(net_allow=["127.0.0.1:443"]), - policy_fn=on_event, - ).run(["python3", "-c", + result = _policy(net_allow=["127.0.0.1:443"], policy_fn=on_event).run(["python3", "-c", "import socket; s=socket.socket(); s.settimeout(0.5); " "s.connect_ex(('127.0.0.1', 1)); s.close(); print('ok')" ]) @@ -238,7 +229,7 @@ def on_event(event, ctx): ctx.deny_path("/etc/hostname") return 0 - result = Sandbox(_policy(), policy_fn=on_event).run( + result = _policy(policy_fn=on_event).run( ["python3", "-c", f"try:\n" f" open('/etc/hostname').read()\n" @@ -261,7 +252,4 @@ def on_event(event, ctx): if event.syscall in ("execve", "execveat"): ctx.restrict_pid_network(event.pid, ["127.0.0.1"]) - result = Sandbox( - _policy(net_allow=["127.0.0.1:443"]), - policy_fn=on_event, - ).run(["echo", "ok"]) + result = _policy(net_allow=["127.0.0.1:443"], policy_fn=on_event).run(["echo", "ok"]) diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index 2f244de..cbd9f67 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -1,105 +1,218 @@ # SPDX-License-Identifier: Apache-2.0 -"""Tests for sandlock._profile.""" +"""Tests for sandlock._profile (sectioned schema).""" import textwrap import pytest from sandlock._profile import ( + list_profiles, load_profile_path, + merge_cli_overrides, policy_from_dict, - list_profiles, profiles_dir, ) from sandlock.exceptions import PolicyError -from sandlock.policy import Policy, FsIsolation, BranchAction +from sandlock.sandbox import BranchAction, FsIsolation, Sandbox class TestPolicyFromDict: def test_empty_dict(self): p = policy_from_dict({}) - assert p == Policy() + assert p == Sandbox() - def test_simple_fields(self): + def test_filesystem_section(self): p = policy_from_dict({ - "fs_writable": ["/tmp"], - "fs_readable": ["/usr", "/lib"], - "clean_env": True, - "max_memory": "512M", - "max_processes": 10, + "filesystem": { + "read": ["/usr", "/lib"], + "write": ["/tmp"], + "deny": ["/proc/sys"], + }, }) - assert p.fs_writable == ["/tmp"] assert p.fs_readable == ["/usr", "/lib"] + assert p.fs_writable == ["/tmp"] + assert p.fs_denied == ["/proc/sys"] + + def test_program_section(self): + p = policy_from_dict({ + "program": { + "env": {"FOO": "bar", "BAZ": "qux"}, + "uid": 0, + "clean_env": True, + "no_coredump": True, + }, + }) + assert p.env == {"FOO": "bar", "BAZ": "qux"} + assert p.uid == 0 assert p.clean_env is True + assert p.no_coredump is True + + def test_program_exec_and_args_are_silently_ignored(self): + # exec/args are runtime program identity, not Sandbox config. + # Loading a profile with them should succeed but not place them + # anywhere on the resulting Sandbox. + p = policy_from_dict({ + "program": { + "exec": "/bin/true", + "args": ["--flag"], + "uid": 1000, + }, + }) + assert p.uid == 1000 + # No side-effect on Sandbox itself; we just need the load to succeed. + assert isinstance(p, Sandbox) + + def test_limits_section(self): + p = policy_from_dict({ + "limits": { + "memory": "512M", + "processes": 10, + "open_files": 256, + "cpu": 80, + "disk": "256M", + "cpu_cores": [0, 1], + }, + }) assert p.max_memory == "512M" assert p.max_processes == 10 + assert p.max_open_files == 256 + assert p.max_cpu == 80 + assert p.max_disk == "256M" + assert list(p.cpu_cores) == [0, 1] - def test_env_dict(self): - p = policy_from_dict({"env": {"FOO": "bar", "BAZ": "qux"}}) - assert p.env == {"FOO": "bar", "BAZ": "qux"} + def test_network_section(self): + p = policy_from_dict({ + "network": { + "bind": [8080], + "allow": ["api.example.com:443", ":8080"], + "port_remap": True, + }, + }) + assert p.net_bind == ["8080"] # ints coerced to strings + assert list(p.net_allow) == ["api.example.com:443", ":8080"] + assert p.port_remap is True - def test_boolean_and_uid_fields(self): + def test_http_section(self): p = policy_from_dict({ - "uid": 0, + "http": { + "ports": [80, 443], + "allow": ["GET api.internal/v1/*"], + "deny": ["* */admin/*"], + }, }) - assert p.uid == 0 + assert list(p.http_ports) == [80, 443] + assert list(p.http_allow) == ["GET api.internal/v1/*"] + assert list(p.http_deny) == ["* */admin/*"] - def test_net_ports(self): + def test_syscalls_section(self): p = policy_from_dict({ - "net_bind": ["8080"], - "net_allow": ["api.example.com:443", ":8080"], + "syscalls": { + "extra_allow": ["sysv_ipc"], + "extra_deny": ["ptrace"], + }, }) - assert p.net_bind == ["8080"] - assert list(p.net_allow) == ["api.example.com:443", ":8080"] + assert list(p.extra_allow_syscalls) == ["sysv_ipc"] + assert list(p.extra_deny_syscalls) == ["ptrace"] + + def test_config_section(self): + p = policy_from_dict({ + "config": { + "http_ca": "/etc/sandlock/ca.pem", + "http_key": "/etc/sandlock/ca.key", + "fs_storage": "/var/sandlock/store", + "workdir": "/var/sandlock/work", + }, + }) + assert p.http_ca == "/etc/sandlock/ca.pem" + assert p.http_key == "/etc/sandlock/ca.key" + assert p.fs_storage == "/var/sandlock/store" + assert p.workdir == "/var/sandlock/work" + + def test_determinism_section(self): + p = policy_from_dict({ + "determinism": { + "random_seed": 42, + "deterministic_dirs": True, + "no_randomize_memory": True, + }, + }) + assert p.random_seed == 42 + assert p.deterministic_dirs is True + assert p.no_randomize_memory is True - def test_fs_isolation_enum(self): - p = policy_from_dict({"fs_isolation": "branchfs"}) + def test_filesystem_isolation_enum(self): + p = policy_from_dict({ + "filesystem": {"isolation": "branchfs"}, + }) assert p.fs_isolation == FsIsolation.BRANCHFS - def test_branch_action_enums(self): - p = policy_from_dict({"on_exit": "abort", "on_error": "keep"}) + def test_filesystem_branch_actions(self): + p = policy_from_dict({ + "filesystem": {"on_exit": "abort", "on_error": "keep"}, + }) assert p.on_exit == BranchAction.ABORT assert p.on_error == BranchAction.KEEP - def test_unknown_field_raises(self): - with pytest.raises(PolicyError, match="unknown fields.*bogus"): - policy_from_dict({"bogus": True}) + def test_filesystem_mount_strings_to_dict(self): + p = policy_from_dict({ + "filesystem": {"mount": ["/data:/srv/redis-data", "/cache:/srv/cache"]}, + }) + assert p.fs_mount == {"/data": "/srv/redis-data", "/cache": "/srv/cache"} + + def test_unknown_section_raises(self): + with pytest.raises(PolicyError, match="unknown section"): + policy_from_dict({"bogus": {}}) + + def test_unknown_field_in_section_raises(self): + with pytest.raises(PolicyError, match=r"unknown field\(s\) in \[filesystem\]"): + policy_from_dict({"filesystem": {"bogus": True}}) - def test_name_field_raises(self): - with pytest.raises(PolicyError, match="name.*not policy"): - policy_from_dict({"name": "api.local"}) + def test_section_must_be_table(self): + with pytest.raises(PolicyError, match=r"\[filesystem\] must be a TOML table"): + policy_from_dict({"filesystem": "not-a-table"}) def test_type_mismatch_raises(self): - with pytest.raises(PolicyError, match="expected bool.*got str"): - policy_from_dict({"clean_env": "yes"}) + with pytest.raises(PolicyError, match=r"\[program\]\.clean_env expected bool"): + policy_from_dict({"program": {"clean_env": "yes"}}) def test_invalid_fs_isolation_raises(self): - with pytest.raises(PolicyError, match="fs_isolation"): - policy_from_dict({"fs_isolation": "invalid"}) + with pytest.raises(PolicyError, match=r"\[filesystem\]\.isolation must be"): + policy_from_dict({"filesystem": {"isolation": "invalid"}}) def test_invalid_branch_action_raises(self): - with pytest.raises(PolicyError, match="on_exit"): - policy_from_dict({"on_exit": "invalid"}) + with pytest.raises(PolicyError, match=r"\[filesystem\]\.on_exit must be"): + policy_from_dict({"filesystem": {"on_exit": "invalid"}}) + + def test_mount_missing_colon_raises(self): + with pytest.raises(PolicyError, match=r"must be 'VIRTUAL:HOST'"): + policy_from_dict({"filesystem": {"mount": ["nocolon"]}}) + + def test_mount_empty_half_raises(self): + with pytest.raises(PolicyError, match=r"both VIRTUAL and HOST"): + policy_from_dict({"filesystem": {"mount": [":/host"]}}) class TestLoadProfilePath: def test_load_valid_toml(self, tmp_path): profile = tmp_path / "test.toml" profile.write_text(textwrap.dedent("""\ - fs_writable = ["/tmp/work"] - fs_readable = ["/usr", "/lib"] + [filesystem] + read = ["/usr", "/lib"] + write = ["/tmp/work"] + + [program] clean_env = true - max_memory = "256M" + env = { CC = "gcc" } - [env] - CC = "gcc" + [limits] + memory = "256M" """)) p = load_profile_path(profile) - assert p.fs_writable == ["/tmp/work"] assert p.fs_readable == ["/usr", "/lib"] + assert p.fs_writable == ["/tmp/work"] assert p.clean_env is True - assert p.max_memory == "256M" assert p.env == {"CC": "gcc"} + assert p.max_memory == "256M" def test_invalid_toml_raises(self, tmp_path): profile = tmp_path / "bad.toml" @@ -107,10 +220,18 @@ def test_invalid_toml_raises(self, tmp_path): with pytest.raises(PolicyError, match="invalid TOML"): load_profile_path(profile) - def test_unknown_field_in_file_raises(self, tmp_path): + def test_unknown_section_in_file_raises(self, tmp_path): profile = tmp_path / "bad.toml" - profile.write_text('typo_field = true\n') - with pytest.raises(PolicyError, match="unknown fields"): + profile.write_text("[typo]\n") + with pytest.raises(PolicyError, match="unknown section"): + load_profile_path(profile) + + def test_old_flat_format_rejected(self, tmp_path): + # Pre-Phase-3 profiles used flat top-level keys. They are now + # rejected (sectioned schema only). Pre-1.0 hard break. + profile = tmp_path / "old.toml" + profile.write_text('fs_readable = ["/usr"]\n') + with pytest.raises(PolicyError, match="unknown section"): load_profile_path(profile) @@ -119,9 +240,9 @@ def test_list_profiles(self, tmp_path, monkeypatch): import sandlock._profile as mod monkeypatch.setattr(mod, "_PROFILES_DIR", tmp_path) - (tmp_path / "build.toml").write_text('uid = 0\n') - (tmp_path / "dev.toml").write_text('clean_env = true\n') - (tmp_path / "not-toml.txt").write_text('ignored') + (tmp_path / "build.toml").write_text("[program]\nuid = 0\n") + (tmp_path / "dev.toml").write_text("[program]\nclean_env = true\n") + (tmp_path / "not-toml.txt").write_text("ignored") assert list_profiles() == ["build", "dev"] @@ -138,20 +259,21 @@ def test_list_profiles_no_dir(self, tmp_path, monkeypatch): class TestMergeCliOverrides: def test_scalar_override(self): - from sandlock._profile import merge_cli_overrides - base = Policy(max_memory="256M", uid=0) + base = Sandbox(max_memory="256M", uid=0) result = merge_cli_overrides(base, {"max_memory": "1G"}) assert result.max_memory == "1G" assert result.uid == 0 # unchanged def test_list_append(self): - from sandlock._profile import merge_cli_overrides - base = Policy(fs_readable=["/usr", "/lib"]) + base = Sandbox(fs_readable=["/usr", "/lib"]) result = merge_cli_overrides(base, {"fs_readable": ["/etc"]}) assert result.fs_readable == ["/usr", "/lib", "/etc"] def test_bool_override(self): - from sandlock._profile import merge_cli_overrides - base = Policy(clean_env=False) + base = Sandbox(clean_env=False) result = merge_cli_overrides(base, {"clean_env": True}) assert result.clean_env is True + + +def test_profiles_dir_is_a_path(): + assert profiles_dir().is_absolute() or str(profiles_dir()).startswith("~") diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index 5fd5263..3813628 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -10,7 +10,7 @@ import pytest -from sandlock import Sandbox, Policy, Change, DryRunResult +from sandlock import Sandbox, Change, DryRunResult _PYTHON_READABLE = list(dict.fromkeys([ @@ -22,45 +22,45 @@ def _policy(**overrides): """Minimal policy with standard readable paths.""" defaults = {"fs_readable": _PYTHON_READABLE} defaults.update(overrides) - return Policy(**defaults) + return Sandbox(**defaults) class TestSandboxRun: def test_simple_command(self): - result = Sandbox(_policy()).run(["echo", "hello"]) + result = _policy().run(["echo", "hello"]) assert result.success assert b"hello" in result.stdout def test_python_expression(self): - result = Sandbox(_policy()).run(["python3", "-c", "print(42)"]) + result = _policy().run(["python3", "-c", "print(42)"]) assert result.success assert result.stdout.strip() == b"42" def test_command_failure(self): - result = Sandbox(_policy()).run(["false"]) + result = _policy().run(["false"]) assert not result.success assert result.exit_code != 0 def test_command_not_found(self): - result = Sandbox(_policy()).run(["nonexistent_command_xyz"]) + result = _policy().run(["nonexistent_command_xyz"]) assert not result.success def test_invalid_sandbox_name(self): with pytest.raises(ValueError, match="must not be empty"): - Sandbox(_policy(), name="") + _policy(name="") with pytest.raises(ValueError, match="NUL"): - Sandbox(_policy(), name="bad\0name") + _policy(name="bad\0name") with pytest.raises(ValueError, match="64 bytes"): - Sandbox(_policy(), name="x" * 65) + _policy(name="x" * 65) def test_stderr_captured(self): - result = Sandbox(_policy()).run( + result = _policy().run( ["python3", "-c", "import sys; sys.stderr.write('err\\n')"] ) assert b"err" in result.stderr def test_exit_code_preserved(self): - result = Sandbox(_policy()).run(["sh", "-c", "exit 42"]) + result = _policy().run(["sh", "-c", "exit 42"]) assert result.exit_code == 42 def test_fs_denied_blocks_read(self, tmp_dir): @@ -71,7 +71,7 @@ def test_fs_denied_blocks_read(self, tmp_dir): fs_readable=[*_PYTHON_READABLE, str(tmp_dir)], fs_denied=[str(secret)], ) - result = Sandbox(policy).run(["cat", str(secret)]) + result = policy.run(["cat", str(secret)]) assert not result.success @@ -90,8 +90,8 @@ def test_two_sandboxes_same_virtual_port(self): ) policy = _policy(port_remap=True) - r1 = Sandbox(policy).run(["python3", "-c", code]) - r2 = Sandbox(policy).run(["python3", "-c", code]) + r1 = policy.run(["python3", "-c", code]) + r2 = policy.run(["python3", "-c", code]) assert r1.success assert r2.success @@ -109,7 +109,7 @@ def test_getsockname_returns_virtual_port(self): "s.close()" ) policy = _policy(port_remap=True) - result = Sandbox(policy).run(["python3", "-c", code]) + result = policy.run(["python3", "-c", code]) assert result.success parts = result.stdout.strip().split() @@ -126,7 +126,7 @@ def test_ephemeral_port_not_remapped(self): "s.close()" ) policy = _policy(port_remap=True) - result = Sandbox(policy).run(["python3", "-c", code]) + result = policy.run(["python3", "-c", code]) assert result.success assert int(result.stdout.strip()) > 0 @@ -141,7 +141,7 @@ def test_ipv6_bind_remapped(self): "s.close()" ) policy = _policy(port_remap=True) - result = Sandbox(policy).run(["python3", "-c", code]) + result = policy.run(["python3", "-c", code]) assert result.success assert result.stdout.strip() == b"5000" @@ -161,7 +161,7 @@ def test_slow_path_host_holds_virtual_port(self): holder.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) holder.bind(("127.0.0.1", 8080)) try: - result = Sandbox(policy).run(["python3", "-c", code]) + result = policy.run(["python3", "-c", code]) finally: holder.close() @@ -226,7 +226,7 @@ def test_tcp_sendmsg_2mb_with_port_remap(self): "'received': len(received), 'data_ok': bytes(received) == payload}))" ) policy = _policy(port_remap=True, net_bind=[7070], net_allow=["127.0.0.1:7070"]) - result = Sandbox(policy).run(["python3", "-c", code]) + result = policy.run(["python3", "-c", code]) assert result.success, f"Sandbox failed: {result}" data = json.loads(result.stdout.strip()) @@ -245,11 +245,11 @@ class TestCpuThrottle: def test_throttle_slows_execution(self): t0 = time.monotonic() - Sandbox(_policy()).run(["python3", "-c", self._BURN_CODE]) + _policy().run(["python3", "-c", self._BURN_CODE]) base = time.monotonic() - t0 t0 = time.monotonic() - result = Sandbox(_policy(max_cpu=50)).run(["python3", "-c", self._BURN_CODE]) + result = _policy(max_cpu=50).run(["python3", "-c", self._BURN_CODE]) throttled = time.monotonic() - t0 assert result.success @@ -257,18 +257,18 @@ def test_throttle_slows_execution(self): assert 1.5 <= ratio <= 3.0, f"ratio={ratio:.1f}, expected ~2.0" def test_throttle_100_is_noop(self): - result = Sandbox(_policy(max_cpu=100)).run(["python3", "-c", self._BURN_CODE]) + result = _policy(max_cpu=100).run(["python3", "-c", self._BURN_CODE]) assert result.success def test_throttle_result_correct(self): - result = Sandbox(_policy(max_cpu=50)).run(["python3", "-c", self._BURN_CODE]) + result = _policy(max_cpu=50).run(["python3", "-c", self._BURN_CODE]) assert result.success assert result.stdout.strip() == b"20000000" class TestPauseResume: def test_pause_resume_from_thread(self): - sb = Sandbox(_policy()) + sb = _policy() def run_in_thread(): return sb.run(["python3", "-c", @@ -290,7 +290,7 @@ def run_in_thread(): # Process should have completed after resume def test_pid_available_during_run(self): - sb = Sandbox(_policy()) + sb = _policy() pid_seen = [] def run_in_thread(): @@ -310,12 +310,12 @@ def run_in_thread(): assert sb.pid is None def test_pause_not_running_raises(self): - sb = Sandbox(_policy()) + sb = _policy() with pytest.raises(RuntimeError): sb.pause() def test_resume_not_running_raises(self): - sb = Sandbox(_policy()) + sb = _policy() with pytest.raises(RuntimeError): sb.resume() @@ -329,7 +329,7 @@ def test_dry_run_reports_added_file(self, tmp_path): (workdir / "existing.txt").write_text("hello") p = _policy(fs_writable=[str(workdir)], workdir=str(workdir)) - result = Sandbox(p).dry_run( + result = p.dry_run( ["sh", "-c", f"touch {workdir}/new.txt"] ) assert result.success @@ -343,7 +343,7 @@ def test_dry_run_reports_modified_file(self, tmp_path): (workdir / "data.txt").write_text("original") p = _policy(fs_writable=[str(workdir)], workdir=str(workdir)) - result = Sandbox(p).dry_run( + result = p.dry_run( ["sh", "-c", f"echo changed > {workdir}/data.txt"] ) assert result.success @@ -357,7 +357,7 @@ def test_dry_run_reports_deleted_file(self, tmp_path): (workdir / "victim.txt").write_text("delete me") p = _policy(fs_writable=[str(workdir)], workdir=str(workdir)) - result = Sandbox(p).dry_run( + result = p.dry_run( ["sh", "-c", f"rm {workdir}/victim.txt"] ) assert result.success @@ -370,7 +370,7 @@ def test_dry_run_no_changes(self, tmp_path): workdir.mkdir() p = _policy(fs_writable=[str(workdir)], workdir=str(workdir)) - result = Sandbox(p).dry_run(["echo", "hello"]) + result = p.dry_run(["echo", "hello"]) assert result.success assert result.changes == [] @@ -380,7 +380,7 @@ def test_dry_run_returns_structured_result(self, tmp_path): (workdir / "f.txt").write_text("x") p = _policy(fs_writable=[str(workdir)], workdir=str(workdir)) - result = Sandbox(p).dry_run( + result = p.dry_run( ["sh", "-c", f"echo y > {workdir}/f.txt; touch {workdir}/new.txt"] ) assert isinstance(result, DryRunResult) @@ -399,13 +399,13 @@ def test_time_start(self): # Freeze time to 2000-06-15 t = datetime(2000, 6, 15, tzinfo=timezone.utc) p = _policy(time_start=t) - result = Sandbox(p).run(["date", "+%Y"]) + result = p.run(["date", "+%Y"]) assert result.success assert result.stdout.strip() == b"2000" - def test_block_syscalls(self): - p = _policy(block_syscalls=["mount"]) - result = Sandbox(p).run(["echo", "ok"]) + def test_extra_deny_syscalls(self): + p = _policy(extra_deny_syscalls=["mount"]) + result = p.run(["echo", "ok"]) assert result.success assert result.stdout.strip() == b"ok" @@ -413,7 +413,7 @@ def test_max_open_files(self): # max_open_files is accepted by the policy but not yet enforced # in the sandbox — just verify it doesn't crash. p = _policy(max_open_files=64) - result = Sandbox(p).run(["echo", "ok"]) + result = p.run(["echo", "ok"]) assert result.success @@ -423,15 +423,15 @@ class TestFsIsolation: def test_fs_isolation_none_runs(self): """Default FsIsolation.NONE should work normally.""" - from sandlock.policy import FsIsolation + from sandlock.sandbox import FsIsolation p = _policy() assert p.fs_isolation == FsIsolation.NONE - result = Sandbox(p).run(["echo", "ok"]) + result = p.run(["echo", "ok"]) assert result.success def test_fs_isolation_value_roundtrips(self): """Non-default values are accepted by the FFI builder without error.""" - from sandlock.policy import FsIsolation + from sandlock.sandbox import FsIsolation import warnings with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -450,12 +450,12 @@ class TestGpuDevices: def test_gpu_devices_accepted(self): p = _policy(gpu_devices=[0]) - result = Sandbox(p).run(["echo", "ok"]) + result = p.run(["echo", "ok"]) assert result.success def test_gpu_devices_empty_list(self): p = _policy(gpu_devices=[]) - result = Sandbox(p).run(["echo", "ok"]) + result = p.run(["echo", "ok"]) assert result.success @@ -470,7 +470,7 @@ def test_no_coredump_rlimit_zero(self): "print(f'{soft} {hard}')" ) p = _policy(no_coredump=True) - result = Sandbox(p).run(["python3", "-c", code]) + result = p.run(["python3", "-c", code]) assert result.success assert result.stdout.strip() == b"0 0" @@ -482,7 +482,7 @@ def test_no_coredump_default_off(self): "print(f'{soft} {hard}')" ) p = _policy(no_coredump=False) - result = Sandbox(p).run(["python3", "-c", code]) + result = p.run(["python3", "-c", code]) assert result.success # Default RLIMIT_CORE is typically unlimited (very large number), not "0 0" assert result.stdout.strip() != b"0 0" @@ -502,7 +502,7 @@ def test_warns_on_unwired_field(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") p = _policy(no_coredump=True) - Sandbox(p).run(["echo", "ok"]) + p.run(["echo", "ok"]) matched = [x for x in w if "no_coredump" in str(x.message)] assert len(matched) >= 1 @@ -511,14 +511,14 @@ def test_warns_on_unwired_field(self): _NativePolicy._HANDLED_FIELDS.add("no_coredump") def test_sandbox_name_parameter(self): - sb = Sandbox(_policy(), name="test-host") + sb = _policy(name="test-host") assert sb.name == "test-host" def test_no_warning_on_default_values(self): import warnings with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - Sandbox(_policy()).run(["echo", "ok"]) + _policy().run(["echo", "ok"]) unwired = [x for x in w if "not wired through FFI" in str(x.message)] assert unwired == [] @@ -545,7 +545,7 @@ def test_cow_copy_within_quota(self, tmp_path): max_disk="1M", ) # Opening for write triggers COW copy of the 5-byte file. - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo world >> {workdir}/small.txt"] ) assert result.success @@ -562,7 +562,7 @@ def test_cow_copy_exceeds_quota(self, tmp_path): max_disk="1K", # 1024 bytes — smaller than the 8 KiB file ) # Trying to open big.bin for write triggers COW copy → ENOSPC. - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo x >> {workdir}/big.bin"] ) assert not result.success @@ -580,7 +580,7 @@ def test_cumulative_cow_copies_exceed_quota(self, tmp_path): max_disk="1000", ) # First open succeeds (600 <= 1000), second fails (600+600 > 1000). - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo x >> {workdir}/a.bin && echo x >> {workdir}/b.bin"] ) @@ -596,7 +596,7 @@ def test_enospc_in_stderr(self, tmp_path): workdir=str(workdir), max_disk="512", ) - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo x >> {workdir}/big.bin 2>&1"] ) assert not result.success @@ -612,7 +612,7 @@ def test_quota_none_is_unlimited(self, tmp_path): fs_writable=[str(workdir)], workdir=str(workdir), ) - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo x >> {workdir}/data.bin"] ) assert result.success @@ -627,7 +627,7 @@ def test_quota_dry_run_enforced(self, tmp_path): workdir=str(workdir), max_disk="1K", ) - result = Sandbox(p).dry_run( + result = p.dry_run( ["sh", "-c", f"echo x >> {workdir}/big.bin"] ) assert not result.success @@ -642,7 +642,7 @@ def test_quota_accepts_various_units(self, tmp_path): workdir=str(workdir), max_disk=size, ) - result = Sandbox(p).run(["echo", "ok"]) + result = p.run(["echo", "ok"]) assert result.success, f"max_disk={size!r} should be accepted" def test_read_does_not_consume_quota(self, tmp_path): @@ -655,7 +655,7 @@ def test_read_does_not_consume_quota(self, tmp_path): workdir=str(workdir), max_disk="100", # tiny quota ) - result = Sandbox(p).run( + result = p.run( ["cat", f"{workdir}/big.bin"] ) assert result.success @@ -673,7 +673,7 @@ def test_fs_storage_directs_cow_deltas(self, tmp_path): workdir=str(workdir), fs_storage=str(storage), ) - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo modified > {workdir}/file.txt"] ) assert result.success @@ -696,7 +696,7 @@ def test_fs_storage_with_quota(self, tmp_path): fs_storage=str(storage), max_disk="512", ) - result = Sandbox(p).run( + result = p.run( ["sh", "-c", f"echo x >> {workdir}/big.bin"] ) assert not result.success diff --git a/python/tests/test_policy.py b/python/tests/test_sandbox_config.py similarity index 65% rename from python/tests/test_policy.py rename to python/tests/test_sandbox_config.py index 1956ba6..f9d1ac2 100644 --- a/python/tests/test_policy.py +++ b/python/tests/test_sandbox_config.py @@ -1,10 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 -"""Tests for sandlock.policy.""" +"""Tests for sandlock.sandbox.""" import pytest -from sandlock.policy import ( - Policy, +from sandlock.sandbox import ( + Sandbox, parse_memory_size, parse_ports, ) @@ -44,62 +44,87 @@ def test_empty(self): parse_memory_size("") +class TestEnsureNative: + """``_ensure_native`` rebuilds on every call so that mutations to + config fields between lifecycle invocations are not silently + masked by a stale native cache.""" + + def test_rebuilds_on_each_call(self): + sb = Sandbox(fs_readable=["/usr"]) + first = sb._ensure_native() + second = sb._ensure_native() + # Two distinct native objects (rebuild, not cache hit). + assert first is not second + + def test_picks_up_post_construction_mutation(self): + sb = Sandbox(fs_readable=["/usr"]) + sb._ensure_native() # first build + sb.fs_readable = ["/usr", "/etc"] # user mutates after first run + rebuilt = sb._ensure_native() # second build sees mutation + # The rebuilt native is a fresh object; the cached self._native + # was replaced, not retained from the pre-mutation state. + assert rebuilt is sb._native + + class TestPolicy: def test_defaults(self): - p = Policy() + p = Sandbox() assert p.fs_writable == [] assert p.fs_readable == [] assert p.fs_denied == [] - assert p.block_syscalls == [] + assert p.extra_deny_syscalls == [] + assert p.extra_allow_syscalls == [] assert p.net_bind == [] assert p.net_allow == [] assert p.max_memory is None assert p.max_processes == 64 assert p.max_cpu is None - def test_frozen(self): - p = Policy(max_memory="512M") - with pytest.raises(AttributeError): - p.max_memory = "1G" # type: ignore + def test_mutable_config(self): + # Sandbox is no longer frozen — it holds runtime state too. + p = Sandbox(max_memory="512M") + p.max_memory = "1G" + assert p.max_memory == "1G" def test_memory_bytes_string(self): - p = Policy(max_memory="512M") + p = Sandbox(max_memory="512M") assert p.memory_bytes() == 512 * 1024 ** 2 def test_memory_bytes_int(self): - p = Policy(max_memory=1024) + p = Sandbox(max_memory=1024) assert p.memory_bytes() == 1024 def test_memory_bytes_none(self): - p = Policy() + p = Sandbox() assert p.memory_bytes() is None def test_cpu_pct(self): - p = Policy(max_cpu=50) + p = Sandbox(max_cpu=50) assert p.cpu_pct() == 50 def test_cpu_pct_none(self): - p = Policy() + p = Sandbox() assert p.cpu_pct() is None def test_cpu_pct_clamped(self): - assert Policy(max_cpu=0).cpu_pct() == 1 - assert Policy(max_cpu=200).cpu_pct() == 100 + assert Sandbox(max_cpu=0).cpu_pct() == 1 + assert Sandbox(max_cpu=200).cpu_pct() == 100 class TestDiskQuotaPolicy: def test_default_none(self): - p = Policy() + p = Sandbox() assert p.max_disk is None def test_string_value(self): - p = Policy(max_disk="1G") + p = Sandbox(max_disk="1G") assert p.max_disk == "1G" - def test_frozen(self): - p = Policy(max_disk="512M") - with pytest.raises(AttributeError): - p.max_disk = "1G" # type: ignore + def test_mutable_config(self): + # Sandbox is no longer frozen — it holds runtime state too. + p = Sandbox(max_disk="512M") + p.max_disk = "1G" + assert p.max_disk == "1G" def test_parse_memory_size_for_disk(self): assert parse_memory_size("1G") == 1024 ** 3 @@ -141,54 +166,54 @@ def test_empty(self): class TestNetPolicy: def test_bind_ports(self): - p = Policy(net_bind=[80, "443", "8000-8002"]) + p = Sandbox(net_bind=[80, "443", "8000-8002"]) assert p.bind_ports() == [80, 443, 8000, 8001, 8002] def test_unrestricted_by_default(self): - p = Policy() + p = Sandbox() assert p.bind_ports() == [] assert p.net_allow == [] class TestEnvControl: def test_clean_env_default_off(self): - p = Policy() + p = Sandbox() assert p.clean_env is False def test_env_default_empty(self): - p = Policy() + p = Sandbox() assert p.env == {} def test_clean_env_on(self): - p = Policy(clean_env=True) + p = Sandbox(clean_env=True) assert p.clean_env is True def test_env_set(self): - p = Policy(env={"FOO": "bar", "BAZ": "qux"}) + p = Sandbox(env={"FOO": "bar", "BAZ": "qux"}) assert p.env == {"FOO": "bar", "BAZ": "qux"} class TestGpuDevices: def test_default_none(self): - p = Policy() + p = Sandbox() assert p.gpu_devices is None def test_specific_devices(self): - p = Policy(gpu_devices=[0, 2]) + p = Sandbox(gpu_devices=[0, 2]) assert p.gpu_devices == [0, 2] def test_all_gpus(self): - p = Policy(gpu_devices=[]) + p = Sandbox(gpu_devices=[]) assert p.gpu_devices == [] class TestCpuCores: def test_default_none(self): - p = Policy() + p = Sandbox() assert p.cpu_cores is None def test_specific_cores(self): - p = Policy(cpu_cores=[0, 2, 3]) + p = Sandbox(cpu_cores=[0, 2, 3]) assert p.cpu_cores == [0, 2, 3] @@ -200,11 +225,11 @@ class TestNetAllow: """ def test_default_is_empty(self): - p = Policy() + p = Sandbox() assert p.net_allow == [] def test_specs_preserved_as_strings(self): - p = Policy(net_allow=["api.example.com:443", "github.com:22,443", ":8080"]) + p = Sandbox(net_allow=["api.example.com:443", "github.com:22,443", ":8080"]) assert list(p.net_allow) == [ "api.example.com:443", "github.com:22,443",