Skip to main content

Module sandbox

Module sandbox 

Source
Expand description

P5-10 (COMPOSABLE-HARNESS-DESIGN.md §2 module 12 permissions.sandbox, ~row 462): the OS-level enforcement BACKSTOP permissions.rules’ rule-layer floor and the file-tool crate::tools::SandboxPolicy both defer to for full coverage (crate::permissions module doc: “complete OS-level write confinement of arbitrary bash… is capabilities. permissions.sandbox’s job (P5 module 10, a later unit), not this one’s” — this IS that unit).

§What this module adds

  • Real Linux fs enforcement via Landlock (landlock_available, apply_linux_confinement): the spawned bash/shell subprocess (and its own children) is kernel-confined to the configured tier’s writable set — a genuine EPERM from the kernel on a disallowed write, not a path string comparison. Applied via a pre_exec closure that runs in the FORKED CHILD after fork(), before exec() — [crate::agent:: Agent]/supercode itself is never confined, only the subprocess tree the tool spawns.
  • Coarse network cut-off (netns_available, apply_linux_confinement): when network.enabled is set with no domain allow/deny lists, the subprocess is placed in a fresh, isolated network namespace (unshare(CLONE_NEWUSER|CLONE_NEWNET), self-mapped so file-permission checks are unaffected) — a real kernel-level all-network cutoff. Domain-level allow/deny is OUT OF REACH on this kernel class (that needs the out-of-scope TLS-MITM proxy, or Landlock ABI v4 network scoping, kernel ≥6.7) and is surfaced as an honest gap, never silently dropped.
  • Fail-closed, never silently-unsandboxed (decide_fs): a confining tier this platform/kernel genuinely cannot enforce refuses to run the subprocess at all (escalation = "deny", the default), unless escalation explicitly says otherwise ("ask" routes through crate::permissions::PermissionsApprovalHandler; "allow" runs unconfined with a loud, one-time warning). The worst defect class named for this unit — “a tier claiming enforcement but silently running unconfined” — is structurally impossible here: decide_fs only ever returns FsDecision::Confine when the caller already told it enforcement IS available; every other input funnels through the escalation gate.
  • env_policy (apply_env_policy): inherit (today’s behavior, byte-identical), filtered (strip a sensitive-var denylist), none (bare PATH + a couple of universally-needed variables).

§Pure decision, real effect

decide_fs/decide_net are pure functions — every availability/ approval input is a PARAMETER, never an internal cfg!/probe call — so the fail-closed/ask/allow/monotonic-tightening branches are all unit- testable without touching a real kernel or spawning a process. The real call sites (crate::tools::builtins::BashTool::execute et al.) supply real inputs via landlock_available/netns_available (cached, real-kernel probes) and the installed crate::permissions::PermissionsApprovalHandler.

Structs§

SandboxApprovalHandler
A thin, Clone + Debug wrapper around Arc<dyn PermissionsApprovalHandler> so crate::tools::ToolContext (which derives both) can carry one as an ambient field — mirroring the existing write_observer: Option<Arc<dyn WriteObserver>> precedent, except PermissionsApprovalHandler (a pre-existing P5-1 public trait) doesn’t itself require Debug as a supertrait, so this newtype supplies a placeholder Debug impl instead of widening that trait’s contract for every existing implementor.

Enums§

FsDecision
What to do about filesystem confinement for one subprocess spawn.
NetDecision
What to do about network confinement for one subprocess spawn. Unlike FsDecision, this never refuses the whole call — network.enabled is an independent, best-effort axis (§build brief item 3): “surface the gap, never claim enforcement you lack”, not a hard fs-style gate. A caller that ALSO has an FsDecision::Refuse for the same call still refuses (that decision wins), but a network-only gap never blocks a call that has no fs confinement problem.
SandboxEnvPolicy
capabilities.permissions.sandbox.env_policy (§3.1): child-process environment sanitization for the spawned bash/shell subprocess. Inherit (the default) is today’s behavior — the parent’s environment (plus core.shell_env_snapshot, if configured) passes through unchanged. Filtered strips a sensitive-variable denylist (tokens, keys, cloud credentials). None keeps only PATH and a couple of universally-needed variables (HOME, TERM, LANG) — nearest to a bare-metal shell with nothing extra.
SandboxEscalation
capabilities.permissions.sandbox.escalation (§3.1): what happens when a confining fs tier is requested but this platform/kernel cannot actually enforce it. Deny (the default) refuses to run the subprocess at all — the cardinal “never silently unsandboxed” rule. Ask routes the decision through crate::permissions::PermissionsApprovalHandler (P5-1’s permissions.approvals seam, wired here per this module’s build brief). Allow auto-permits an unconfined run with a loud, one-time warning.

Functions§

apply_env_policy
Build the environment the subprocess should see, starting from base (the process’s own inherited environment, or ctx.shell_env’s snapshot when one is configured — the caller decides base, this function only applies the POLICY on top of it). Inherit returns base unchanged (byte-identical to pre-P5-10 behavior — the common case, since env_policy defaults to Inherit).
apply_linux_confinement
Real fs/net confinement, installed on cmd as a pre_exec closure that runs in the FORKED CHILD (after fork(), before exec()crate::agent::Agent/supercode itself is never touched; only the spawned subprocess tree is). cwd/extra_write_dirs MUST already be resolved via [crate::safe_path::resolve_real] (real, symlink-resolved paths) — Landlock rules operate on directory file descriptors opened from these exact paths, so the same dual lexical+resolved discipline every other containment check in this crate uses applies here too (a symlink’d cwd must grant the REAL target directory, not the symlink’s lexical location).
decide_fs
Decide what to do about FILESYSTEM confinement for one subprocess spawn. Pure — fs_available is the caller’s REAL probe result (landlock_available on Linux, true on macOS via the existing seatbelt path which this function is not consulted for — see tools::builtins::build_sandboxed_sh’s doc comment), never computed internally, so every branch (including the platform-can’t-enforce ones) is directly testable without touching a kernel.
decide_net
Decide what to do about NETWORK confinement for one subprocess spawn. Pure — net_available is the caller’s real probe result (netns_available on Linux). Never gates on escalation (see NetDecision’s doc comment) — a network gap is always a warn, never a refuse, so this needs no approval handler at all.
landlock_available
Whether real Landlock filesystem confinement is available on THIS process’s kernel — a genuine, side-effect-free (beyond dropping one ruleset file descriptor) PARENT-PROCESS probe: it builds a CompatLevel::HardRequirement ruleset requiring exactly the write-access rights apply_linux_confinement would later request and checks whether Ruleset::create() succeeds — it deliberately never calls restrict_self() (that confines the CALLING process/thread permanently and every future child of it — calling it here would confine supercode ITSELF, exactly the “confinement targets the child, not supercode” invariant this module must never violate). Cached for the process lifetime (the kernel’s Landlock support can’t change at runtime).
netns_available
Whether an unprivileged, self-contained network namespace cut-off is available on THIS process’s kernel — a real probe, but one that (unlike landlock_available) genuinely can’t be done risk-free in the calling process itself (unshare(2) acts on the CALLING process/thread, so probing it directly would isolate supercode’s own network, not just check availability). Instead this forks a disposable, single-purpose child that does nothing but attempt the unshare and immediately _exit() with the result — never touches the allocator, locks, or any other state the parent might hold mid-fork (the standard safe shape for a post-fork child that never execs), so it carries none of pre_exec’s usual multi-threaded-fork hazards. Cached for the process lifetime.
os_sandbox_active
Whether the OS-level backstop is ACTIVE for tier/os_enabled at all — the “turn the sandbox ENUM into an enabled/disabled OS engagement” half of §3.1’s table. DangerFullAccess is an absolute opt-out (never confine, regardless of os_enabled — §3.1 item 1: “no confinement (opt-out)”). Otherwise: an EXPLICIT os_enabled value wins; None (never set — the bare sandbox = "<tier>" shorthand, or a CLI --sandbox flag, neither of which touch the table’s enabled key at all) preserves the PRE-P5-10 trigger this crate already shipped (tools/builtins.rs’s macOS seatbelt firing off ctx.sandbox alone, no separate gate) — so an existing CLI user or the cx-parity preset (bare sandbox = "workspace_write", no enabled key) keeps its current confining behavior byte-for-byte, while cc-parity’s explicit table-form enabled = false (§3.1’s own “OS sandbox OFF… opt-in” comment) is honored as a real, independent off-switch.
warn_once
Print reason to stderr ONCE per distinct reason string, for the lifetime of this process — the “loud, one-time persistent warning” the build brief calls for on an escalation = "allow"/approved-ask run, and on a network gap. Deduped by exact text (not a blanket once-per-process Once) so a DIFFERENT gap later in the same run still gets its own warning — only an EXACT repeat is suppressed.