# Sandbox threat model > 阅读简体中文版:[zh_hans/SANDBOX.md](zh_hans/SANDBOX.md)。 Codewhale can launch shell commands proposed by a model. Approval policy, workspace-aware tools, and an operating-system command wrapper are separate controls: an approval is not a sandbox, and selecting `workspace-write` does not prove that the current platform has an OS wrapper available. This document describes only behavior wired into the command execution path. See [Authorization order](AUTHORIZATION_ORDER.md) for the policy layers that run before execution reaches this boundary. ## Platform overview | Mechanism | Platform | Selection | What Codewhale reports | |---|---|---|---| | Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` | | Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` | | No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` | | No OS wrapper | Windows | Current implementation | `none` | | OpenSandbox-compatible service | Any supported host | `sandbox_backend = "opensandbox"` | External execution path | The repository also contains a future Windows helper contract. It is not wired into child-command launch, so Codewhale does not advertise it as an active sandbox. Source-only sandbox code is not evidence that a command was restricted. ## macOS: Seatbelt Codewhale probes `/usr/bin/sandbox-exec` by running a minimal profile. When the probe succeeds and the selected `SandboxPolicy` requests a sandbox, the child command is wrapped with a generated Seatbelt profile. The profile can provide: - broad filesystem reads; - writes limited by the selected policy, including the workspace and specific runtime/cache paths needed by supported tools; - network access only when the policy enables it. A `read-only` policy with network (the agent `read_only: true` shell mode) gets outbound IPv4 and the system DNS resolver only. IPv6 and the host's own addresses, loopback included, are denied, except the port of a local proxy named in `HTTP_PROXY`, `HTTPS_PROXY` or `ALL_PROXY`, and other local sockets stay denied. If the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS sandbox and launches the command without a Seatbelt wrapper. It does not set a Seatbelt marker on that fallback. ## Linux: default bubblewrap Linux command sandboxing is on by default whenever bubblewrap works. Set the top-level configuration key to opt out: ```toml prefer_bwrap = false ``` Codewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular executable file AND a wrapped probe run proves it can create its namespaces on this host — the exec bit alone lies where user namespaces are restricted (e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`), where every wrapped command would fail rather than run unsandboxed. The wrapper derives its mounts and network namespace from the resolved `SandboxPolicy`: ```text /usr/bin/bwrap \ --unshare-all \ [--share-net] \ --ro-bind / / \ --dev /dev \ --proc /proc \ --tmpfs /tmp \ [--dev-bind ...] \ --bind ... \ --ro-bind ... \ [--ro-bind ...] \ --chdir \ -- ``` The sandbox always gets a private `/dev` (fresh device nodes, so `>/dev/null` works), a private `/proc`, and a tmpfs `/tmp` (#5410). A `workspace-write` policy then binds the host `/tmp` (and `TMPDIR`) over that tmpfs unless it excludes them, so by default a sandboxed command shares the host's `/tmp`, writable, with the usual sticky-bit rules. Two optional top-level config keys extend the mounts: `bwrap_ro_roots` (extra host paths bind-mounted read-only, applied last so they can narrow a policy-writable path) and `bwrap_dev_roots` (host character/block device nodes bind-mounted read-write; directories are never honored). Missing paths are skipped silently. That gives the child a read-only root view. For `workspace-write`, every safe, existing policy root is mounted read-write: the working directory, configured additional roots, `/tmp` and `TMPDIR` unless excluded, and verified Git worktree metadata roots. Existing `.codewhale` and `.deepseek` descendants are remounted read-only after their writable parent. Missing paths, non-directory paths, and `/` are not promoted to writable mounts. For `read-only`, there are no writable binds, so the working directory remains inside the read-only root view. `--unshare-all` isolates the network namespace by default. Codewhale adds `--share-net` only when the policy's `network_access` is true. The agent `read_only: true` shell mode turns it on only in Full Access (see [Subagents](SUBAGENTS.md)); it then shares the host network namespace, including loopback and abstract unix sockets, like a networked `workspace-write` shell. Bubblewrap does not filter either. `danger-full-access` and `external-sandbox` bypass the local wrapper entirely. If the user opts out, or `/usr/bin/bwrap` is missing, non-executable, or cannot actually confine a child, Codewhale reports `none` and launches the command without a Linux OS wrapper. There is no marker-only fallback to a different Linux sandbox. Install bubblewrap separately to get enforcement on Linux: - Ubuntu/Debian: `apt install bubblewrap` - Fedora: `dnf install bubblewrap` - Arch: `pacman -S bubblewrap` Codewhale does not vendor bubblewrap. ### Running tools as another user A host that must keep tool commands away from the Engine's own files (a cloud computer, for example) can run every sandboxed command as a separate Linux user. The managed configuration sets an argv prefix that changes user, and pins a sandboxed mode beside it: ```toml sandbox_mode = "workspace-write" tool_launcher = ["/usr/bin/sudo", "-n", "-u", "agent", "--", "/usr/local/bin/agent-exec"] ``` The launcher goes in front of bubblewrap, not inside it: every unprivileged confinement sets no-new-privileges, and a setuid program started under it cannot switch user. The command becomes ` /usr/bin/bwrap ... -- `, so the tool runs as the other user, with no-new-privileges, inside the same mounts, masks and network namespace a local command gets. `--die-with-parent` stays, so a cancel or timeout that kills the launcher ends the whole sandbox. The key is read only from the managed configuration file named by `CODEWHALE_MANAGED_CONFIG_PATH`, or from the default managed path, only on Linux, and only when neither that file nor any directory above it can be changed by the Engine's own user. A managed file at any other path (for example one a user config points at through `managed_config_path`) is refused. In a user `config.toml` or a profile the key is a load error. The managed file must also set `sandbox_mode` itself; a sandboxed mode from a user config does not count. Without the key, nothing on this page changes. A host that relies on the launcher puts the managed file at the default managed path, owned by root, and also sets `CODEWHALE_MANAGED_CONFIG_PATH` to that same path for the Engine's service. The variable stops a user config from pointing `managed_config_path` at a missing file; the default path is needed because a Fleet local worker (a child `codewhale exec`) is started with a cleaned environment and does not get the variable; it reads the user config's `managed_config_path` and then the default path, so a user config can still point a worker at a missing file. A file in another ordinary user's home also passes the "Engine's user cannot change it" check, so the file should be root-owned. Check `sandbox.tool_launcher` in `GET /v1/runtime/info`: the route reads the live configuration, but a check made once at start does not cover a later reload, so ask again after a reload. The launcher's first program runs as the Engine's user with the Engine's allow-listed environment, the model-chosen working directory and stdin, so it must ignore all three (a setuid switcher such as `sudo -n`, not a shell script). With a launcher configured: - **Fail closed.** A command runs under bubblewrap or not at all. The Engine probes the launcher (its user and groups, then one wrapped `/bin/true` with the deny masks) before the first command; a failed probe, `prefer_bwrap = false`, or a policy that would skip the sandbox (`danger-full-access`, `external-sandbox`, including an approved per-command escalation) refuses the command with the reason, and nothing runs. The managed `sandbox_mode` must be `workspace-write` or `read-only`. - **Masks for the other user.** Bubblewrap creates each mask as the launcher's user, so every command's masks and binds are computed for that user: a denied path below a directory it cannot search is masked at the highest such directory, and a bind it cannot reach is dropped. The built-in deny list stays on; it also guards the Engine's own file tools. - **Private `/tmp`.** Each command gets a fresh tmpfs `/tmp`; the host `/tmp` and `TMPDIR` are not bound, so the tool user cannot leave files there for the Engine's user. Scratch files that must outlive one command belong in the workspace. - **Network** follows the policy as everywhere else: a managed `sandbox_network_access = true` adds `--share-net`, and then only the host's own firewall rules for the tool user fence it. - **Runtime info.** `GET /v1/runtime/info` reports `sandbox.kind` (`linux-bwrap` once the launcher probe passed) and `sandbox.tool_launcher`. Limits: only commands the sandbox manager prepares (shell commands, terminal sessions, workspace runners) go through the launcher. In-process file tools, MCP servers, hooks, LSP servers, the extension host, Fleet local workers, external ACP agents and the Engine's own `git`, formatter and diagnostic helpers still run as the Engine's user. `codewhale serve --mcp` builds its own shell manager with no launcher and no deny list, so do not expose `exec_shell` there. With `sandbox_backend = "opensandbox"` a shell command goes to the remote backend before the launcher is consulted, so a launcher host pins `sandbox_backend = "none"`, `prefer_bwrap = true` and `sandbox_network_access` in the managed file. A `tool_launcher` in a project config stops an interactive launch with an error; `serve` and `exec` do not read project config, so there the key is ignored. Deny-list exceptions are not applied under a launcher. Search rights come from mode bits and group membership, not ACLs. Processes keep running after the Engine itself dies, until bubblewrap's parent exits. ## Windows: no advertised OS sandbox The Windows command path currently reports no OS sandbox. The source tree has a future helper contract for Job Object process-tree cleanup, but it is not wired into selection and must not be described as any of the following: - read-only filesystem or workspace-write enforcement; - network blocking; - registry isolation; - restricted-token or AppContainer isolation. Windows host permissions and approval policy still apply, but they are not a Codewhale OS command sandbox. ## Linux process hardening is not a command sandbox At startup on Linux, Codewhale best-effort applies `PR_SET_DUMPABLE=0`, `PR_SET_NO_NEW_PRIVS=1`, and `RLIMIT_CORE=0` to its own process. Each failure is logged and startup continues. These controls reduce process-inspection, privilege-escalation, and core-dump risk; they do not create filesystem or network isolation for a child command and are not listed as a sandbox backend. The one exception is the startup posture itself: when the startup sandbox mode resolves to `danger-full-access` (via `CODEWHALE_SANDBOX_MODE` or the config file's `sandbox_mode` key), `PR_SET_NO_NEW_PRIVS` is skipped so that `sudo`/`su`/setuid helpers keep working from the agent shell (#5723) — "full access" means it. Every narrower posture keeps the flag as defense-in-depth, and `CODEWHALE_NO_NEW_PRIVS` overrides the posture in both directions (#5413): a falsey value always skips the flag, a truthy value always sets it. The flag is irreversible for the process tree, so the decision can only be made at launch; per-call sandbox escalation inside a session cannot lift it. ## External OpenSandbox execution When `sandbox_backend = "opensandbox"` is configured, shell execution is sent to the configured OpenSandbox-compatible HTTP endpoint instead of starting a local child. Codewhale validates the request/response contract, but isolation guarantees belong to the configured service and its operator. ```toml sandbox_backend = "opensandbox" sandbox_url = "http://localhost:8080" sandbox_api_key = "YOUR_API_KEY" ``` `sandbox_backend = "none"` (or omitting the key) keeps local execution. Unsupported backend settings refuse shell execution; they never silently select local execution. Choose a supported backend or explicitly select `none`. ## Policies and fallbacks The local `sandbox_mode` values are: ```toml sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox ``` - `read-only` and `workspace-write` are enforced by Seatbelt or bubblewrap only when that wrapper is selected and available. - The configured value can only narrow the sandbox the permission mode picks: Full Access (`yolo`) runs without the wrapper, Ask and Auto-Review use `workspace-write`, and Plan uses `read-only`. A configured `danger-full-access` or `external-sandbox` therefore takes effect only in Full Access; it never loosens Ask or Auto-Review. - `danger-full-access` deliberately bypasses the local OS wrapper. On Linux it also skips the `PR_SET_NO_NEW_PRIVS` process-hardening flag at startup so `sudo`/setuid workflows keep running (#5723); see the process-hardening section above. - `external-sandbox` declares that execution is already externally isolated and bypasses a second local wrapper. - When no wrapper is selected, the shell command runs without Codewhale OS isolation. Approval rules and workspace-aware native file tools remain separate controls. Canonical environment overrides exist for `sandbox_mode` and the external backend: - `CODEWHALE_SANDBOX_MODE` - `CODEWHALE_SANDBOX_BACKEND` - `CODEWHALE_SANDBOX_URL` - `CODEWHALE_SANDBOX_API_KEY` `CODEWHALE_PREFER_BWRAP` (legacy alias `DEEPSEEK_PREFER_BWRAP`) overrides the preference explicitly; the top-level `prefer_bwrap` config key is the durable setting. ## Diagnostics and failure attribution `codewhale setup --status`, `codewhale doctor`, `codewhale doctor --json`, and the `diagnostics` tool report the locally available wrapper after applying the resolved bubblewrap preference. An individual command can still bypass that wrapper when its policy does not request sandboxing. On Linux, merely finding a sandbox-related syscall or source module does not make `sandbox_available` true. Denial attribution is intentionally conservative: - Seatbelt uses its wrapper-specific denial patterns. - Bubblewrap setup errors must be prefixed by `bwrap:`; a read-only-filesystem error from the bwrap filesystem view can also identify the boundary. - A child command's generic `Permission denied` or `Operation not permitted` is not, by itself, proof that Codewhale's sandbox blocked it. - Unsandboxed command failures are never labeled sandbox denials. ## Limitations - Availability is checked before launch; the selected wrapper can still fail because of host policy, container restrictions, or a race after the probe. - Bubblewrap ignores a configured writable root if it is missing, is not a directory, or canonicalizes to `/`; a path can also disappear between policy resolution and wrapper launch. - Seatbelt profiles are generated at runtime and must be tested against the commands they are expected to support. - No current local wrapper is advertised on Windows. - An external sandbox backend is only as strong as its configured service. - No sandbox protects against kernel vulnerabilities or all resource-exhaustion and side-channel attacks. ## Implementation references - `crates/sandbox/src/lib.rs` — truthful selection and public capability markers - `crates/sandbox/src/seatbelt.rs` — macOS wrapper and availability probe - `crates/sandbox/src/bwrap.rs` — Linux wrapper and functional availability probe - `crates/sandbox/src/process_hardening.rs` — Linux parent-process hardening - `crates/sandbox/src/backend.rs` — external backend selection - `crates/engine/src/tools/diagnostics.rs` — machine-readable diagnostics