Skip to content

Sectioned profile schema and Sandbox API consolidation#42

Merged
congwang-mk merged 10 commits into
mainfrom
policy-schema
May 8, 2026
Merged

Sectioned profile schema and Sandbox API consolidation#42
congwang-mk merged 10 commits into
mainfrom
policy-schema

Conversation

@congwang-mk
Copy link
Copy Markdown
Contributor

@congwang-mk congwang-mk commented May 7, 2026

Summary

Replaces sandlock's flat-keyed TOML profile format with a sectioned schema, then realigns the rest of the project — Rust types, CLI flags, FFI accessors, Python bindings, and READMEs — so the API has one type per concept and the vocabulary matches the schema. Pre-1.0 hard break: existing user profiles and downstream callers using the old field/flag/type names need to be rewritten.

Four commits, one per phase:

  • ab4b0c3 Phase 1 — sectioned profile parser. New ProfileInput + 8 section structs ([config], [determinism], [program], [filesystem], [network], [http], [syscalls], [limits]). New parse_input translator producing (Sandbox, ProgramSpec). parse_profile / load_profile rewritten as thin wrappers; legacy flat-keyed parser deleted. CLI gains --profile-file and honors [program].exec/args from a profile when no trailing positional command is given (trailing command overrides).

  • 10b1508 Phase 2 — vocabulary alignment + canonical CLI input. Renames https_ca/https_key/block_syscallshttp_ca/http_key/extra_deny_syscalls; drops allow_sysv_ipc (folded into a new extra_allow_syscalls: Vec<String> plus an allows_sysv_ipc() helper). CLI flags renamed to match (--http-ca, --http-key, --extra-deny-syscall, new --extra-allow-syscall). New validate() owns cross-section invariants; build_unchecked() runs per-field validation and build() collapses to build_unchecked()? + validate()?. The builder carries a feature-gated clap::Args derive; the CLI's RunArgs flattens it via #[clap(flatten)], eliminating the duplicated parallel args struct.

  • 637a1de Phase 3 — rename PolicySandbox and merge SandboxProcess into Sandbox. The data struct was overloaded — it mixed policy-shaped fields with config-shaped ones (TLS keys, GPU IDs, host paths, determinism knobs); Sandbox describes the contents honestly and matches the schema's role as a complete spec for one sandboxed run. The runtime type (SandboxProcess) is folded into Sandbox itself: runtime state lives in a private Runtime struct held as Option<Box<Runtime>> on Sandbox, heap-allocated only when a lifecycle method runs. crate::process is reduced to nesting-detection globals; SandboxProcess is gone from the public API in both Rust and Python. Construction is Sandbox::builder()…build()?; lifecycle methods (run, spawn, start, pause, resume, kill, wait, dry_run, fork, reduce, checkpoint) take &mut self and lazily initialize the runtime via ensure_runtime(). Sandbox::with_name(self, name) -> Self supports pipeline-style fan-out (template.clone().with_name("worker-1")). Python Sandbox absorbs all the runtime methods so user code is sandbox.run(["python3", "task.py"]).

  • ff30e3f Phase 3 follow-up — error rename. SandboxProcessErrorSandboxRuntimeError; SandlockError::ProcessSandlockError::Runtime. The split between config-validation and process-runtime errors is preserved (different audiences, different recovery paths); the rename only fixes the name's reference to a deleted struct.

The TOML schema format itself is unchanged across all four commits; section names and field names were chosen at Phase 1 and stay stable. Public CLI flag names are unchanged from Phase 2 onward.

Test Plan

  • cargo test --workspace passes — 514 tests total (278 core unit + 212 integration + 17 CLI + 5 profile integration + 2 sandbox-validate).
  • pip install -e python/ && pytest python/tests/ passes — 228 Python tests.
  • cargo check --workspace --all-targets is clean.
  • Sectioned profile loads end-to-end: sandlock run --profile-file <profile>.toml exits 0 with a representative profile.
  • Trailing command override: sandlock run --profile-file <profile>.toml -- /bin/echo override runs the trailing command, not the profile's [program].exec.
  • Renamed CLI flags work: --http-ca, --http-key, --extra-deny-syscall, --extra-allow-syscall sysv_ipc.
  • Old CLI flags rejected: --https-ca, --https-key, --block-syscall, --allow-sysv-ipc produce clap "unexpected argument" errors.
  • Cross-section invariant: [filesystem].isolation = "overlayfs" without [config].workdir returns the documented error mentioning workdir.
  • Single-class API: in Python, from sandlock import Sandbox; sandbox.run(["echo", "hi"]) works without ever referencing SandboxProcess. In Rust, Sandbox::builder()...name("x").build()?.run(&cmd)? works without referencing Sandbox::new or any second type.
  • FFI symbol surface: nm -D --defined-only target/release/libsandlock_ffi.so | grep ' T sandlock_' declares only sandlock_sandbox_* (no sandlock_policy_*, no sandlock_process_*); the public C header agrees.

🤖 Generated with Claude Code

Signed-off-by: Cong Wang <cwang@multikernel.io>
Phase 2 of the sectioned-policy-schema effort. After Phase 1 introduced
the sectioned profile parser (translating into the legacy field names),
this phase aligns the rest of the project — Policy struct, CLI flags,
FFI, and Python bindings — with the schema's intent-shaped vocabulary,
and reshapes the Policy/PolicyBuilder/CLI relationship so a single
canonical input definition feeds both TOML profiles and clap argv
parsing.

Field and flag renames:
- Policy::https_ca       → Policy::http_ca   (CLI: --https-ca → --http-ca)
- Policy::https_key      → Policy::http_key  (CLI: --https-key → --http-key)
- Policy::block_syscalls → Policy::extra_deny_syscalls
                                            (CLI: --block-syscall → --extra-deny-syscall)
- Policy::allow_sysv_ipc removed; folded into
  Policy::extra_allow_syscalls = ["sysv_ipc"]
                                            (CLI: --allow-sysv-ipc → --extra-allow-syscall)

Signed-off-by: Cong Wang <cwang@multikernel.io>
Two related restructures landed together. The first renames the data
struct that describes one sandboxed run from `Policy` to `Sandbox`; the
second merges the formerly-separate runtime type (`SandboxProcess`)
into `Sandbox` so there is exactly one sandbox concept in both Python
and Rust.

The trigger for the rename was that "Policy" had become overloaded —
the struct mixed policy-shaped fields (filesystem allows, network
rules, syscall allow/deny) with config-shaped fields (TLS keys, GPU
IDs, host paths, determinism knobs). "Sandbox" describes the contents
honestly and matches the schema's role as a complete spec for one
sandboxed run. The schema's TOML format never said "Policy" so it does
not change; CLI flag names also do not change.

Type renames:
- Policy            → Sandbox
- PolicyBuilder     → SandboxBuilder
- ConfinePolicy     → Confinement
- ConfinePolicyBuilder → ConfinementBuilder
- PolicyError       → SandboxError
  (config-validation errors)
- pre-existing runtime SandboxError → SandboxProcessError
  (process-runtime errors; later subsumed — see below)

Sandbox merge — Python:
The Python `SandboxProcess` class is deleted entirely. All of its
methods (run, run_interactive, dry_run, cmd, reduce, start, pause,
resume, kill, wait, the context-manager protocol, the `pid` /
`is_running` properties) move onto `Sandbox`. The four runtime kwargs
(`name`, `policy_fn`, `init_fn`, `work_fn`) join the constructor
alongside config kwargs. `Sandbox` is no longer `frozen=True` because
it now carries optional runtime state initialized in `__post_init__`.

The natural Python idiom is now one-class:

    sandbox = Sandbox(fs_readable=["/usr"], extra_allow_syscalls=["sysv_ipc"])
    result = sandbox.run(["python3", "task.py"])

Sandbox merge — Rust:
The runtime struct that was `SandboxProcess` is collapsed into
`Sandbox`. Runtime state lives in a private `Runtime` struct held as
`Option<Box<Runtime>>` on `Sandbox` — heap-allocated only when a
lifecycle method is called, freed on completion. The `crate::process`
module is reduced to just the nesting-detection globals (`CONFINED`,
`is_nested`); the `SandboxProcess` type is gone from the public API.

Construction is `Sandbox::builder()...build()?` — the `Sandbox::new`
family of constructors that took `&Sandbox` and cloned-into-runtime is
deleted; that pattern recreated the two-class shape the merge was
meant to eliminate. Lifecycle methods (run, spawn, start, pause,
resume, kill, wait, dry_run, fork, reduce, checkpoint) take `&mut
self` and lazily initialize the `Runtime` via a private
`ensure_runtime()` helper. Validation that previously lived on
`SandboxProcess::new` (name format, length) moves to `set_name` /
`with_name` (fail-fast) and `ensure_runtime` (final guard).

Builder gains methods for the runtime kwargs:
    SandboxBuilder::name(s)
    SandboxBuilder::init_fn(f)
    SandboxBuilder::work_fn(f)
    SandboxBuilder::policy_fn(f)

`Sandbox::with_name(self, name) -> Self` is added for pipeline-style
fan-out where one config feeds many runs:
    let template = Sandbox::builder()...build()?;
    let mut s1 = template.clone().with_name("worker-1");
    let mut s2 = template.clone().with_name("worker-2");

`Clone` clones config + name + work_fn + policy_fn (Arc-wrapped); it
drops `init_fn` (FnOnce can't clone) and always sets `runtime: None`
on the clone so multiple Sandboxes never share runtime state.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk congwang-mk changed the title Sectioned policy schema for profiles, CLI, and bindings Sectioned profile schema and Sandbox API consolidation May 7, 2026
After SandboxProcess was merged into Sandbox, the runtime error type
and its SandlockError variant still bore the deleted struct's name.
Rename for accuracy:

- SandboxProcessError      → SandboxRuntimeError
- SandlockError::Process   → SandlockError::Runtime

The split between config-validation errors (SandboxError) and
process-runtime errors (SandboxRuntimeError) is preserved — the two
have different audiences (user-actionable vs environmental) and
recovery paths. The rename only fixes the name's reference to a
deleted struct.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Three correctness fixes that belong together:

1. --no-supervisor base block now transfers extra_deny_syscalls,
   clean_env, env, no_coredump, no_huge_pages, no_randomize_memory,
   uid, cwd, max_memory, max_processes, max_open_files, random_seed
   (every field enforceable without the supervisor).

2. supervisor base block now transfers extra_deny_syscalls,
   max_open_files, max_disk, env, http_ca, http_key, chroot,
   fs_storage, fs_isolation, fs_mount, on_exit, on_error,
   deterministic_dirs, no_randomize_memory, no_huge_pages,
   no_coredump, time_start, port_remap, uid, gpu_devices, cpu_cores.

3. sandlock.h declarations of sandlock_run / sandlock_run_interactive
   gain the `name` parameter to match the Rust FFI signatures
   (NULL auto-generates "sandbox-{pid}").

Without (1) and (2), profile-supplied policy was silently dropped
between TOML parse and Sandbox build. Without (3), C consumers
hit a link/ABI mismatch.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The Python profile loader and the Rust profile parser had diverged:
Python accepted a flat-keyed TOML format (`fs_readable = [...]` at the
top level) while Rust required the sectioned schema (`[program]`,
`[filesystem]`, `[network]`, …). The same `.toml` file would load
in one and fail in the other.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Signed-off-by: Cong Wang <cwang@multikernel.io>
Signed-off-by: Cong Wang <cwang@multikernel.io>
Signed-off-by: Cong Wang <cwang@multikernel.io>
Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk congwang-mk merged commit bb820fe into main May 8, 2026
8 checks passed
@congwang-mk congwang-mk deleted the policy-schema branch May 8, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant