Purpose: specify CrustCore's execution sandboxing — the execution tiers, per-OS backend order, network posture, environment sanitation, the
CommandSpec/CommandResultcontract with bounded output and process-tree kill, theSandboxExecCaprequirement, and the red-team tests that prove containment.
This is a contract file: changes are serialized and require maintainer approval. Source of truth:
ROADMAP.md§10 (sandbox model),§1.2 NullClaw lesson 5(path-env lesson),§18 Phase 4(tasks/acceptance). Governs invariant 9 (and supports 2, 7, 8, 14).
Siblings:
docs/security-model.md(taint/redaction) ·docs/secrets.md(no inherited secrets) ·docs/policy.md(capability/approval tokens) ·THREAT_MODEL.md·INVARIANTS.md.
Every execution-capable operation runs in an explicit sandbox profile. There is no host-shell escape hatch.
run_commandrequires aSandboxExecCapbound to a profile; no capability, no execution (invariant 9).
fn run_command(cap: &SandboxExecCap, spec: CommandSpec) -> Result<CommandResult>;The SandboxExecCap is a capability token issued by the policy engine
(docs/policy.md); it names the SandboxProfileRef and a scope.
Because the only execution entry point takes this token, there is no way to "just
run a shell" — execution is always profiled and always governed (invariant 8).
pub struct SandboxExecCap { profile: SandboxProfileRef, scope: ScopeId }From ROADMAP.md §10.1`. The tier sets the containment strength;
risk classification chooses the tier.
| Tier | Name | Examples | Containment |
|---|---|---|---|
| 0 | No execution | planning, review, summarization, policy evaluation | none needed — nothing runs |
| 1 | Structured host-side | read_file, search, apply_patch, write_file, git status, git diff |
typed confined paths; no arbitrary execution |
| 2 | Sandboxed execution | tests, builds, shell, package managers, Codex CLI, Claude Code CLI, MCP code-mode glue | OS sandbox profile + deny-all egress + sanitized env |
| 3 | Hostile execution | untrusted generated code, unknown repos, risky install scripts | microVM / container / hard sandbox, network denied |
Notes:
- Tier 1 is not "trusted execution" — it executes nothing arbitrary. It is
structured host-side operations that require typed confined paths
(
ConfinedReadPath/ConfinedWritePath,ROADMAP.md§8.2`). Git wrappers use fixed subcommands and must not execute hooks or read model-written config (Phase 3 acceptance). - Tier 2 is the default for any arbitrary command. Tests/builds/package managers are arbitrary code and run here.
- Tier 3 is for hostile or unknown code — escalate to a microVM/container with network denied. Unknown repos and risky install scripts belong here.
The launcher selects the strongest available backend for the OS
(ROADMAP.md §10.2`). Linux bubblewrap and macOS Seatbelt are
implemented; hostile Tier-3 execution still requires Firecracker.
Linux:
1. Landlock / namespaces where available (in-process LSM + user namespaces)
2. bubblewrap (unprivileged container helper)
3. Firecracker (microVM for hostile / Tier 3 tasks)
4. container CLI fallback (docker/podman style)
macOS:
1. seatbelt sandbox profile (sandbox-exec style profile)
2. network proxy (egress mediation)
3. container fallback
Windows:
1. WSL2 / container initially
2. AppContainer / job objects later
The launcher records which backend was selected (for the event log /
crustcore inspect). If no acceptable backend is available for the required tier,
execution is refused — there is no "run unsandboxed" degrade path.
The macOS Tier-2 backend (SeatbeltBackend, compiled only under
#[cfg(target_os = "macos")], std-only) wraps the command in
/usr/bin/sandbox-exec under a generated SBPL profile, matching the bubblewrap
backend's security posture: deny-all network egress and writes confined to
the worktree. It is detected at runtime (probing /usr/bin/sandbox-exec, which
ships on every macOS); a host without it refuses rather than degrading to
unsandboxed execution. Like bubblewrap, allowlisted egress is not granted here in
v1 — an Allowlist profile is refused until the trusted egress proxy exists.
The generated profile keeps process services available, then denies network, filesystem reads, and filesystem writes before reopening only explicit runtime, toolchain, worktree, and private-scratch roots:
(version 1)
(allow default)
(deny network*) ; deny-all egress — mirrors bubblewrap --unshare-all
(deny file-read*) ; reopen only system runtime + toolchain + task roots
(deny file-write*) ; reopen only the worktree and per-run private scratch
(allow file-read*
(subpath "/System") (subpath "/usr")
(subpath "/private/etc") (subpath "/Library/Developer")
(subpath "<CANONICAL_WORKTREE>")
(subpath "<PRIVATE_TMPDIR>")
(subpath "<CARGO_HOME_WITH_CREDENTIAL_FILES_RE-DENIED>")
(subpath "<RUSTUP_HOME>"))
(allow file-write*
(subpath "<CANONICAL_WORKTREE>")
(subpath "<PRIVATE_TMPDIR>")
(literal "/dev/null") (literal "/dev/zero")
(literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/tty")
(literal "/dev/dtracehelper") (literal "/dev/urandom"))(deny network*)is the deny-all-egress guarantee. Read and write denial are both default-closed. Shared/private/tmpand/private/var/tmpare not reopened; each run gets a mode-0700 scratch directory that is removed after execution. Live tests prove outside reads, outside writes, shared-temp writes, and network access are refused.- Paths are canonicalized. macOS symlinks
/tmp→/private/tmp,/var→/private/var, and worktrees under/var/folders/...; SBPLsubpathmatches the kernel's resolved path. The backend thereforecanonicalizes the worktree and provisioned privateTMPDIRbefore embedding them. If a path cannot be canonicalized the backend fails closed (SandboxError::Setup) rather than embedding an unresolved path that would either fail open or block legitimate worktree writes. Embedded paths are escaped ("and\) to prevent breaking out of the SBPL string literal. - The child runs with
cwdset to the worktree and the env sanitized at the launch boundary (§5), exactly as the bubblewrap backend does.
Firecracker (Tier 3) and the network-proxy / container fallbacks remain future work; a Tier-3 (hostile) task on macOS is still refused without a microVM.
Default network policy (ROADMAP.md §10.3`):
deny all egress (default)
allowlist per task / profile (explicit opt-in only)
GitHub and model access through trusted sidecar/proxy
package install requires approval
new host requires approval
This implements NullClaw's allowlist discipline (ROADMAP.md §1.2](../ROADMAP.md)): an empty allowlist means **deny all**; * is an explicit opt-in, never a default. Secrets never enter the sandbox to reach the network — GitHub/model traffic goes through the credential proxy / sidecar ([docs/secrets.md](./secrets.md), [docs/github.md`), so a sandboxed process cannot exfiltrate a token
even if it reaches an allowed host.
Network proxy records (per connection), for audit and the event log:
task id
job id
process id
domain
port
protocol
bytes in / out
approval id (if applicable)
Adding a host or installing a package is an approval-gated action (invariant 14):
the proxy ties the connection to the approval_id that authorized it.
Sandbox env rules (ROADMAP.md §10.4`):
minimal env by default
no inherited secrets
no inherited SSH agent unless explicit
no inherited cloud credentials
no arbitrary PATH
validate path-list env vars component-by-component
strip dangerous variables by default
Stripped before a sandboxed process starts (non-exhaustive; the implementation strips this class):
LD_PRELOAD DYLD_* GIT_CONFIG_*
SSH_AUTH_SOCK AWS_* GCP_*
AZURE_* NPM_TOKEN (and similar credential/loader vars)
LD_PRELOAD/DYLD_* are loader-injection vectors (a path to attacker code);
GIT_CONFIG_* can redirect git to attacker config/hooks; SSH_AUTH_SOCK would
forward the agent's SSH identity into untrusted execution; the cloud-credential
vars (AWS_*/GCP_*/AZURE_*/NPM_TOKEN) are exactly the secrets we refuse to
inherit (invariant 2, docs/secrets.md).
ROADMAP.md §1.2 (NullClaw lesson 5) records the lesson:
path-list env vars must be validated component-by-component before crossing
into a sandbox.
This applies to:
PATH LD_LIBRARY_PATH DYLD_* PYTHONPATH NODE_PATH GIT_* paths (etc.)
The validator splits the variable on the OS path separator and checks each component:
- reject empty components (an empty PATH entry means "current directory")
- reject relative components (must be absolute, normalized)
- reject components that escape allowed roots / point at untrusted writable dirs
- reject symlink-escaping components
- reject components containing null bytes
- a single bad component fails the whole variable (no silent drop-and-continue)
Validating the whole string is insufficient — a single injected component (e.g.
a writable worktree dir prepended to PATH, or a malicious dir in
LD_LIBRARY_PATH) is a code-execution vector. Component-wise validation is what
blocks the LD_PRELOAD/path-env escape red-team scenario (R11 in
THREAT_MODEL.md §6`).
The execution contract (Phase 4, [ROADMAP.md §18](../ROADMAP.md)). A CommandSpecis a fully-specified, non-shell-interpreted command; aCommandResult` is bounded and captured.
fn run_command(cap: &SandboxExecCap, spec: CommandSpec) -> Result<CommandResult>;CommandSpec carries (conceptually):
program + argv (explicit; not a shell string to interpret)
working directory (a confined path inside the worktree)
sanitized environment (built from scratch; see §5)
sandbox profile ref (matches the SandboxExecCap; sets tier/network/fs)
resource limits (wall time, CPU, memory, disk, output size)
network policy (deny-all by default; allowlist if profile permits)
CommandResult carries:
exit status / signal
bounded stdout (truncated at the output-size limit, marked truncated)
bounded stderr (same)
duration
whether it was killed / timed out
resource usage summary
- Each command has a wall-time timeout; on expiry it is killed.
- A command is cancellable (tied to the job's cancellation token / process handle,
invariant 12).
- Kill terminates the WHOLE process tree, not just the direct child:
Linux: run the child in its own process group / pid namespace and signal the
group (SIGTERM then SIGKILL) so orphaned grandchildren cannot survive.
A lingering grandchild could keep network connections or hold the worktree;
killing the tree closes that gap.
Sandbox-specific red-team fixtures (Phase 4 acceptance + ROADMAP.md §19.3](../ROADMAP.md); mapped in [THREAT_MODEL.md §6). Each must pass before v0.1:
| Scenario | Asserts |
|---|---|
| Dependency postinstall attempts network (R5) | deny-all egress blocks it; install needed approval |
| External worker / code writes outside worktree (R6) | confined paths reject; outside-root writes fail |
| Symlink escape path (R10) | path resolver rejects symlink escape; no-follow writes |
LD_PRELOAD / path-env escape (R11) |
env sanitizer strips loader vars; path-list components validated |
| Secret/env inheritance | secrets and SSH/cloud creds are not inherited (§5) |
| Output flood | bounded capture truncates; no unbounded read into context |
| Runaway command | timeout fires; whole process tree is killed |
Phase 4 acceptance (ROADMAP.md §18`):
- Commands run with bounded output and timeout.
- Secrets/env are not inherited by default.
- Network is denied by default in supported sandbox.
- Path-list env escapes are blocked.
P4.1 Implement CommandSpec and CommandResult.
P4.2 Implement bounded stdout/stderr capture.
P4.3 Implement timeout/cancel/kill process tree.
P4.4 Implement environment sanitizer.
P4.5 Implement path-env-var validator.
P4.6 Implement Linux sandbox backend v1.
P4.7 Add sandbox red-team tests.
Scope note. v0.1 ships Linux sandbox backend v1; Firecracker (Tier 3 microVM) and the Windows native sandbox are explicitly out of scope for v0.1 (
ROADMAP.md§21`). Until those land, hostile (Tier 3) tasks on unsupported platforms must be refused rather than downgraded to a weaker tier — consistent with the "no run-unsandboxed degrade path" rule in §3.
Explicit sandbox profile for all execution ......... invariant 9 (primary)
No inherited secrets / redacted output ............. invariants 2, (1)
Untrusted execution output is data ................. invariant 7
Execution gated by capability token ................ invariant 8
Network/install/new-host require approval .......... invariant 14
Bounded output / timeouts (budgets) ................ invariant 11
Cancellation / kill tied to job lifecycle .......... invariant 12