Skip to content

Shell Sandbox

Every shell command the engine runs passes through the wcore-sandbox crate before reaching the OS. The crate selects the strongest available backend for the current platform, wraps the command in that backend’s isolation primitives, and either returns a SandboxOutput or - if no real backend is available and you have not explicitly opted out - refuses execution entirely rather than silently degrading.

default_for_platform() in wcore-sandbox/src/lib.rs walks the following decision tree at spawn time.

ConditionBackend chosen
WAYLAND_SANDBOX=docker and Docker socket reachableDockerBackend
WAYLAND_SANDBOX=docker and Docker socket not reachableFailClosedBackend (refuses)
Linux, bwrap in PATHBubblewrapBackend
macOS, sandbox-exec probe passesSandboxExecBackend
Windows, AppContainer real-spawn probe passesAppContainerBackend
None of the above, WAYLAND_ALLOW_NO_SANDBOX=1 setNoSandboxBackend (warn, allow)
None of the above, opt-in not setFailClosedBackend (refuses)

WAYLAND_SANDBOX=none is also recognized, but it requires WAYLAND_ALLOW_NO_SANDBOX=1 to be simultaneously set; without it, WAYLAND_SANDBOX=none alone fails closed. This prevents a stray environment variable from silently stripping isolation.

BubblewrapBackend wraps the bwrap binary. The command receives:

  • --die-with-parent - kills the namespace tree if the engine process dies
  • --unshare-all - separates PID, IPC, UTS, cgroup, user, and network namespaces
  • --share-net added back only when NetworkPolicy::Inherit is requested
  • --clearenv - drops the entire host environment; only manifest-declared vars are re-injected via --setenv
  • --new-session - blocks terminal-escape vectors
  • A minimal filesystem skeleton: --tmpfs /tmp, --proc /proc, --dev /dev, and read-only binds for /usr, /lib, /lib64, /bin, /sbin, /etc where they exist on the host
  • --ro-bind <path> <path> for every entry in fs_read_allow
  • --bind <path> <path> for every entry in fs_write_allow
  • --rlimit-as <bytes> when max_memory_bytes is set

The default timeout for a bwrap child is 30 seconds when the manifest does not specify one. Resource limits are classified as BestEffort because Linux setrlimit is subject to OOM-killer races and overcommit semantics.

Optional hardening layers (feature-gated):

  • Landlock (landlock cargo feature, Linux 5.13+): bwrap_landlock.rs builds a Ruleset from fs_read_allow / fs_write_allow and calls restrict_self() in a pre_exec closure. Landlock rules propagate across execve, so the bwrap child inherits them. If the running kernel does not support Landlock (pre-5.13 or LSM disabled), the build degrades gracefully with a warn-once log rather than refusing to spawn. The kernel ABI target is V2 (Linux 5.19+) with best_effort semantics for older kernels.
  • seccomp-bpf (seccomp cargo feature, Linux only): bwrap_seccomp.rs compiles a libseccomp filter for SyscallPolicy::Strict and exports it to a tempfile; bwrap --seccomp <fd> applies the filter atomically in the child after PR_SET_NO_NEW_PRIVS, avoiding the TOCTOU race between execve and prctl. The default allowlist covers I/O, filesystem metadata, memory management, process lifecycle, signal handling, synchronisation, clone, and time syscalls; anything outside it is killed with SECCOMP_RET_KILL_PROCESS.

If the build does not have the seccomp feature compiled in and a manifest requests SyscallPolicy::Strict, the engine logs a one-time warning and continues with bwrap namespace isolation only.

SandboxExecBackend invokes /usr/bin/sandbox-exec -f <profile> with a deny-default SBPL profile constructed from the manifest.

The profile always includes:

  • (deny default)
  • (allow process-fork), (allow process-exec), (allow signal (target self))
  • Root inode probe for dyld bootstrap: (allow file-read* (literal "/"))
  • Standard system subpath reads: /usr, /System, /Library, /bin, /sbin, /private/var/db/dyld
  • /dev/null, /dev/urandom, /dev/random, /dev/dtracehelper
  • Tahoe (macOS 26.x) fix baked in: (allow sysctl-read (sysctl-name-prefix "hw.")) and (allow sysctl-read (sysctl-name-prefix "kern.")) - without these, zsh 5.9 on Tahoe reads new hw.* sysctls at shell init and the deny-default profile kills it before main() runs
  • (allow mach-lookup) - intentionally unfiltered; see the caution below

Paths from fs_read_allow are emitted as (allow file-read* (subpath "<escaped>")) and paths from fs_write_allow additionally get (allow file-write* (subpath "<escaped>")). Path strings are SBPL-escaped before interpolation (double-quotes and backslashes escaped, NUL/newline-bearing paths rejected outright) to prevent profile-injection via a crafted manifest path.

Network policy: NetworkPolicy::Inherit emits (allow network*); NetworkPolicy::Deny adds no network rule (deny-default covers it); NetworkPolicy::AllowHosts returns PolicyNotSupported because SBPL has no DNS-name allowlist primitive.

Resource limits: SBPL has no rlimit primitive. The backend reports ResourceLimitEnforcement::None. A max_memory_bytes setting in the manifest will have no effect on macOS.

The backend probes availability at first use by running sandbox-exec -p "(version 1)(allow default)" /usr/bin/true. If the probe fails, execution is refused.

AppContainerBackend runs the child in a per-process AppContainer profile combined with a Job Object. The spawn pipeline:

  1. Create (or derive) a per-PID AppContainer SID via CreateAppContainerProfile / DeriveAppContainerSidFromAppContainerName.
  2. Open the engine process token with OpenProcessToken, then call CreateRestrictedToken(DISABLE_MAX_PRIVILEGE) disabling BUILTIN\Administrators, BUILTIN\Users, and Authenticated Users SIDs so an elevated parent does not give the child group-based access.
  3. Pin the restricted token to Low integrity level via SetTokenInformation(TokenIntegrityLevel, S-1-16-4096).
  4. Create a Job Object with KILL_ON_JOB_CLOSE, ACTIVE_PROCESS=1 (fork-bomb prevention), DIE_ON_UNHANDLED_EXCEPTION, PRIORITY_CLASS=BELOW_NORMAL, breakaway disabled. UI restrictions: clipboard read/write, USER handle inheritance, system-parameter changes, display settings, global atoms, desktop switches, and shutdown are all denied.
  5. Create stdout/stderr pipes with inheritable write-ends; add PROC_THREAD_ATTRIBUTE_HANDLE_LIST so only those two handles are inherited.
  6. Spawn with CreateProcessAsUserW(CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT).
  7. Assign to the Job Object before resuming the thread.
  8. After spawn, query the child’s token integrity level via query_process_integrity_rid and terminate the child immediately if the kernel did not honor Low IL.

The default timeout is 60 seconds when the manifest does not specify one. Resource limits via Job Objects (ProcessMemoryLimit, PerProcessUserTimeLimit) are classified as ResourceLimitEnforcement::Enforced because the Windows kernel enforces them rather than relying on advisory setrlimit.

The AppContainerBackend caches a successful is_available() probe permanently for the process lifetime; failed probes are not cached, so a transient failure at startup does not permanently disable sandboxing.

Activated by WAYLAND_SANDBOX=docker. Requires the live-docker cargo feature; without it, is_available() logs an error and returns false. When available, DockerBackend translates SandboxManifest fields to container configuration:

  • fs_read_allow<host>:<container>:ro bind mounts
  • fs_write_allow<host>:<container>:rw bind mounts
  • NetworkPolicy::Denynetwork_mode: "none"
  • max_memory_bytes--memory (enforced by cgroups)
  • max_cpu_secs--nano-cpus (fractional CPU quota)
  • env → container env; host env is not inherited
  • image → default ghcr.io/tradecanyon/wcore-sandbox:base

Resource limits are classified as ResourceLimitEnforcement::Enforced. NetworkPolicy::AllowHosts returns PolicyNotSupported because Docker has no DNS gate.

Every backend receives a SandboxManifest that declares what the child is allowed to do.

# Example: a tool that reads one directory and writes to a scratch area
[sandbox]
fs_read_allow = ["/home/user/project/src"]
fs_write_allow = ["/tmp/tool-output"]
network = { kind = "deny" }
syscall_policy = "inherit" # or "strict" (Linux + seccomp feature only)
timeout = { secs = 30, nanos = 0 }
max_memory_bytes = 268435456 # 256 MiB
max_cpu_secs = 10
env = [["LANG", "en_US.UTF-8"]]
# image = "ghcr.io/tradecanyon/wcore-sandbox:base" # Docker only

Fields:

FieldTypeDefaultNotes
fs_read_allowVec<PathBuf>emptyAbsolute paths only. Backends translate to bind mounts or profile rules.
fs_write_allowVec<PathBuf>emptyAbsolute paths only.
networkNetworkPolicyInheritInherit, Deny, or AllowHosts([...]).
syscall_policySyscallPolicyInheritStrict applies seccomp-bpf on Linux when the feature is compiled in; ignored elsewhere.
timeoutOption<Duration>backend defaultBwrap default: 30s. AppContainer default: 60s.
max_memory_bytesOption<u64>noneBestEffort on Linux (rlimit), Enforced on Windows/Docker. None on macOS.
max_cpu_secsOption<u64>noneSame enforcement tiers as memory.
envVec<(String, String)>emptyAll backends scrub host env first. Only these pairs reach the child.
imageStringghcr.io/tradecanyon/wcore-sandbox:baseDocker only; ignored by other backends.

NetworkPolicy variants:

  • { kind = "inherit" } - child uses the host network (default, pre-sandbox parity)
  • { kind = "deny" } - network namespace unshared (bwrap) or no network rule (sandbox-exec)
  • { kind = "allow_hosts", hosts = ["api.example.com"] } - returns PolicyNotSupported on all current backends; planned for bwrap + nftables in a future release

All manifest paths must be absolute. Relative paths are rejected with SandboxError::PathDenied before any backend code runs.

When no real backend is available and WAYLAND_ALLOW_NO_SANDBOX is not set, default_for_platform() returns a FailClosedBackend. Every execute() call on it returns:

SandboxError::ExecFailed(
"sandbox UNAVAILABLE and unsandboxed execution is not permitted - \
refusing to run with host permissions. Install bubblewrap (Linux), \
set WAYLAND_SANDBOX=docker, or explicitly opt in with \
WAYLAND_ALLOW_NO_SANDBOX=1 to accept running with NO isolation."
)

The backend reports is_available() = true so selection resolves; the refusal surfaces at execution time with the remediation steps in the message.

Two environment variables control the fallback path. Both must be set for unsandboxed execution to proceed.

Terminal window
export WAYLAND_SANDBOX=none
export WAYLAND_ALLOW_NO_SANDBOX=1

When WAYLAND_ALLOW_NO_SANDBOX=1 is set and no real backend is available, NoSandboxBackend runs. It still scrubs the host environment (env_clear()) and re-injects only manifest-declared vars, so the env-isolation contract is preserved even without OS-level sandbox primitives. A rate-limited warning fires at most once per 60 seconds for the lifetime of the session.

Accepted values for WAYLAND_ALLOW_NO_SANDBOX: 1 or true (case-insensitive). Any other value, or the variable being unset, leaves the fail-closed behavior active.