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.
Backend selection
Section titled “Backend selection”default_for_platform() in wcore-sandbox/src/lib.rs walks the following decision tree at spawn time.
| Condition | Backend chosen |
|---|---|
WAYLAND_SANDBOX=docker and Docker socket reachable | DockerBackend |
WAYLAND_SANDBOX=docker and Docker socket not reachable | FailClosedBackend (refuses) |
Linux, bwrap in PATH | BubblewrapBackend |
macOS, sandbox-exec probe passes | SandboxExecBackend |
| Windows, AppContainer real-spawn probe passes | AppContainerBackend |
None of the above, WAYLAND_ALLOW_NO_SANDBOX=1 set | NoSandboxBackend (warn, allow) |
| None of the above, opt-in not set | FailClosedBackend (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.
Linux: Bubblewrap
Section titled “Linux: Bubblewrap”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-netadded back only whenNetworkPolicy::Inheritis 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,/etcwhere they exist on the host --ro-bind <path> <path>for every entry infs_read_allow--bind <path> <path>for every entry infs_write_allow--rlimit-as <bytes>whenmax_memory_bytesis 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 (
landlockcargo feature, Linux 5.13+):bwrap_landlock.rsbuilds aRulesetfromfs_read_allow/fs_write_allowand callsrestrict_self()in apre_execclosure. Landlock rules propagate acrossexecve, 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+) withbest_effortsemantics for older kernels. - seccomp-bpf (
seccompcargo feature, Linux only):bwrap_seccomp.rscompiles alibseccompfilter forSyscallPolicy::Strictand exports it to a tempfile;bwrap --seccomp <fd>applies the filter atomically in the child afterPR_SET_NO_NEW_PRIVS, avoiding the TOCTOU race betweenexecveandprctl. The default allowlist covers I/O, filesystem metadata, memory management, process lifecycle, signal handling, synchronisation, clone, and time syscalls; anything outside it is killed withSECCOMP_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.
macOS: sandbox-exec
Section titled “macOS: sandbox-exec”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,zsh5.9 on Tahoe reads newhw.*sysctls at shell init and the deny-default profile kills it beforemain()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.
Windows: AppContainer
Section titled “Windows: AppContainer”AppContainerBackend runs the child in a per-process AppContainer profile combined with a Job Object. The spawn pipeline:
- Create (or derive) a per-PID AppContainer SID via
CreateAppContainerProfile/DeriveAppContainerSidFromAppContainerName. - Open the engine process token with
OpenProcessToken, then callCreateRestrictedToken(DISABLE_MAX_PRIVILEGE)disablingBUILTIN\Administrators,BUILTIN\Users, andAuthenticated UsersSIDs so an elevated parent does not give the child group-based access. - Pin the restricted token to Low integrity level via
SetTokenInformation(TokenIntegrityLevel, S-1-16-4096). - 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. - Create stdout/stderr pipes with inheritable write-ends; add
PROC_THREAD_ATTRIBUTE_HANDLE_LISTso only those two handles are inherited. - Spawn with
CreateProcessAsUserW(CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT). - Assign to the Job Object before resuming the thread.
- After spawn, query the child’s token integrity level via
query_process_integrity_ridand 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.
Docker (opt-in)
Section titled “Docker (opt-in)”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>:robind mountsfs_write_allow→<host>:<container>:rwbind mountsNetworkPolicy::Deny→network_mode: "none"max_memory_bytes→--memory(enforced by cgroups)max_cpu_secs→--nano-cpus(fractional CPU quota)env→ container env; host env is not inheritedimage→ defaultghcr.io/tradecanyon/wcore-sandbox:base
Resource limits are classified as ResourceLimitEnforcement::Enforced. NetworkPolicy::AllowHosts returns PolicyNotSupported because Docker has no DNS gate.
SandboxManifest
Section titled “SandboxManifest”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 MiBmax_cpu_secs = 10env = [["LANG", "en_US.UTF-8"]]# image = "ghcr.io/tradecanyon/wcore-sandbox:base" # Docker onlyFields:
| Field | Type | Default | Notes |
|---|---|---|---|
fs_read_allow | Vec<PathBuf> | empty | Absolute paths only. Backends translate to bind mounts or profile rules. |
fs_write_allow | Vec<PathBuf> | empty | Absolute paths only. |
network | NetworkPolicy | Inherit | Inherit, Deny, or AllowHosts([...]). |
syscall_policy | SyscallPolicy | Inherit | Strict applies seccomp-bpf on Linux when the feature is compiled in; ignored elsewhere. |
timeout | Option<Duration> | backend default | Bwrap default: 30s. AppContainer default: 60s. |
max_memory_bytes | Option<u64> | none | BestEffort on Linux (rlimit), Enforced on Windows/Docker. None on macOS. |
max_cpu_secs | Option<u64> | none | Same enforcement tiers as memory. |
env | Vec<(String, String)> | empty | All backends scrub host env first. Only these pairs reach the child. |
image | String | ghcr.io/tradecanyon/wcore-sandbox:base | Docker 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"] }- returnsPolicyNotSupportedon 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.
Fail-closed behavior
Section titled “Fail-closed behavior”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.
Opting out
Section titled “Opting out”Two environment variables control the fallback path. Both must be set for unsandboxed execution to proceed.
export WAYLAND_SANDBOX=noneexport WAYLAND_ALLOW_NO_SANDBOX=1When 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.