OS sandbox
The OS sandbox is the isolation envelope seal wraps around every command_run subprocess — bwrap on Linux, sandbox-exec on macOS. It’s one of two sandboxes in seal; the WASM sandbox wraps the agent itself, and this one wraps the commands the agent spawns.
Where the OS sandbox sits
Section titled “Where the OS sandbox sits”seal agent (WASM sandbox) │ ▼ WIT call into the hostseal daemon (native) │ ▼ capability check (manifest) │ ▼ OS sandbox (bwrap / sandbox-exec)your commandThe agent runs inside the WASM sandbox; everything it wants to do leaves the WASM envelope through a typed WIT call into the daemon. The daemon checks each call against the signed manifest — that’s the capability layer, the source of permission prompts. If the call is command_run, the spawned subprocess enters the OS sandbox before it can touch the kernel.
The capability layer tells you what the agent is asking for and gives you a chance to refuse. The OS sandbox is what enforces the answer at the kernel boundary — even if a command tried to read a file the manifest didn’t grant, the kernel would tell it the path doesn’t exist.
The OS sandbox is configured via the [sandbox.os] section of seal.toml.
There is no off-switch
Section titled “There is no off-switch”Every command_run invocation goes through the OS sandbox. Setting command_fs = "all" and command_tools = [] just removes the per-path filtering; it doesn’t remove the namespace isolation. The seal agent cannot run commands outside the sandbox under any circumstances.
If you need to run a command outside the sandbox, run it yourself.
What the OS sandbox blocks
Section titled “What the OS sandbox blocks”Filesystem
Section titled “Filesystem”The default command_fs = "system" baseline binds the standard system paths (/usr, /bin, /sbin, /lib, /lib64, /etc, /nix if present) read-only. Everything else is invisible unless the manifest names it:
[capabilities.allow.read.paths]adds read-only binds.[capabilities.allow.write.paths]adds read-write binds.[capabilities.allow.additional_directories]extends the project root with outside-the-project paths the manifest can reference.
$HOME is not bound by default in the system baseline. A command that tries to read ~/.cargo/config.toml without a matching grant gets ENOENT — the path isn’t there, from the command’s point of view.
On macOS, the profile additionally allows metadata-only reads (lstat, no contents) on the ancestor directories of every granted path — /Users, /private, and so on up the tree. sandbox-exec filters syscalls against real host paths with no synthesized parents, so without these rules any tool that canonicalizes its working directory at startup (jj, Node, nix) fails before it can use the grant. The rules are exact-path (literal, not subpath). They never expose file contents or directory listings.
Three other baselines tune this:
"none"— nothing bound by default. The strictest posture: every path the agent’s commands need has to be allowlisted explicitly, either through grants under[capabilities.allow.read]/[capabilities.allow.write]or through curated tool bundles. Tools that depend on/usr/bin,/bin, or library paths will fail to start unless you add those paths yourself or list a bundle that supplies them. Bundles work the same way they do under any other baseline —command_tools = ["cargo"]still contributes its read binds, env-var passthroughs, and network allowlist on top of anonebaseline."permissive"—systemplus$HOMEread-only."all"— entire filesystem read-only.
Network
Section titled “Network”Outbound traffic from a sandboxed command is routed through a per-session MITM proxy with a session-issued CA. The proxy enforces the host allowlist:
[capabilities.allow.commands] patternsdeclares which command patterns can run AND which domains they can reach. Bare-string entries ("cargo:*") inherit the section’sdefault_domains; inline-table entries ({ command, domains }) union with the default unlessinherit_default_domains = falseopts out.- Domains can be literal (
api.github.com) or*.<registrable>wildcards (*.github.com). - Curated tool bundles (see below) pre-populate the most common allowlists —
git/gh/jjgetgithub.comby default;cargogetscrates.io;npm/bun/yarn/pnpmget the npm registry.
Traffic to disallowed hosts fails with a connection-refused error from inside the sandbox. The proxy logs every denied attempt for the session audit log.
The proxy chokepoint works the same way on both platforms; only the plumbing differs. On Linux the proxy listens on a per-spawn unix socket bridged into the command’s network namespace. On macOS, sandbox-exec children share the host’s network stack, so the proxy listens on a loopback port and the profile authorizes outbound traffic to exactly that port — everything else, DNS included, stays denied (hostnames resolve at the proxy, never in the sandboxed command).
Sensitive files
Section titled “Sensitive files”Even when a filesystem baseline would otherwise expose them, the standard sensitive-file masklist hides specific paths when command_mask_secrets = true (the default):
/etc/shadow,/etc/sudoers, SSH host keys.~/.ssh/*,~/.aws/credentials,~/.gnupg/*,~/.config/op/.- Cloud-provider credential dirs, browser cookie stores.
A command attempting to read one of these sees ENOENT — the same response as a non-existent file — regardless of which baseline is active.
Project secrets
Section titled “Project secrets”Linux (bwrap) only today — macOS parity ships in SEA-761. On macOS the project-secret mask is not yet wired into the sandbox-exec SBPL profile, so this release does not provide an equivalent command_run subprocess mitigation there. [capabilities.deny.read] still affects tool-layer file_read, but it is not a kernel-layer substitute for subprocess access.
In addition to the system-path mask above, the Linux sandbox also masks known-secret-shape files inside the project root when command_mask_secrets = true (the default). The masked basename patterns:
.env,.env.*(environment files).*.key,*.pem,*.seed,*.pfx,*.p12,*.jks,*.keystore(X.509 / PKCS keys + cert+key bundles).id_rsa,id_ed25519,id_ecdsa,id_dsa,*_rsa,*_ed25519(SSH private keys)..npmrc,.pypirc,.netrc,.htpasswd(registry / publish auth dotfiles).
User-defined deny patterns join the mask list. Any [capabilities.deny.read] default_files entry of the *.<ext> shape (e.g. *.sqlite, *.tfstate) is unioned with the built-in patterns above and enforced through the same walk — so an extension you deny at the file_read tool layer is also masked for command_run subprocesses. Bare names (.credentials) and path-scoped denies (secrets/*.pem) stay tool-layer-only. Write-side denies ([capabilities.deny.write]) do not read-mask.
Each match becomes --ro-bind-try /dev/null <path> on Linux (the same shape the system mask uses), so command_run("cat .env") cannot read the real secret bytes from inside the sandbox even when the project root is otherwise bound. In the common case that path resolution reaches the bind, the read behaves like EOF from /dev/null; depending on ancestor-mount perms (notably bwrap’s per-session 0700 $TMPDIR), the masked path can also surface as unreadable before the read reaches the bind — either failure shape preserves the security contract.
The mask runs whenever the sandbox would have made the file reachable — i.e. whenever a grant’s path resolves to a directory that contains the secret. Under the dominant paths = ["**"] + default_files = ["*"] shape, that’s the entire project tree.
Granting explicit access to a known-secret file
Section titled “Granting explicit access to a known-secret file”The mask runs unconditionally unless an [capabilities.allow.read] grant explicitly names the secret pattern. Catchall basenames (* / **) do NOT count as explicit opt-in for known-secret patterns — the user must name the secret directly:
# Grants `command_run` subprocesses real access to `.env`:[capabilities.allow.read]paths = [{ path = ".", files = [".env"] }]or, alongside the catchall:
[capabilities.allow.read]default_files = [".env", "*"] # the literal `.env` opts inpaths = ["**"]Either shape produces a grant whose basename portion is .env (not *), which the mask layer treats as explicit opt-in. The catchall * covers everything else; the literal .env opts that one file out of the mask.
Adding a non-catchall extension glob opts in every file of that shape:
# All *.key files in the project become readable to subprocesses:[capabilities.allow.read]default_files = ["*.key", "*"]paths = ["**"]Walk limits
Section titled “Walk limits”The mask walker is bounded so a pathological project tree (large monorepo, vendored dependencies) can’t stall the per-spawn dispatcher:
- Depth limit: 5 directories below the project root. Secrets buried beyond depth 5 are NOT masked for
command_runsubprocesses — this applies to the built-in patterns and user-defined deny extensions alike.[capabilities.deny.read]is not a substitute here — beyond the walk frontier it only enforces at thefile_readtool layer, not for subprocess access through bind-mounted dirs. To protect deeply-nested secrets fromcommand_run: either (a) narrow the read grants so those directories aren’t reachable in the first place, or (b) move the secrets to a path the manifest doesn’t grant. - Time budget: 500ms wall clock. Walks that overrun log a
warn-level event with the partial mask count and continue with whatever was collected; partial coverage is preferred to no coverage. - Noise-dir skip:
target/,node_modules/,.git/,.jj/,dist/,build/,.next/,.nuxt/,.cache/,vendor/,__pycache__/,.venv/,venv/,.tox/,.gradle/,.idea/,.vscode/are descended-into never. Files matching the secret patterns inside these dirs are NOT masked — the false-positive rate (e.g.node_modules/<package>/.npmrcis usually a published-package config, not user credentials) outweighs the security gain.
If you have a legitimate case for the walker to descend into one of the skipped directories, that’s a separate Linear ticket — the skip list is hardcoded today.
Symlinks
Section titled “Symlinks”Secret-named symlinks at the project root (.env -> app-config.txt, common in Docker / multi-env setups) are masked by resolving the link to its canonical target and emitting the mask against the target rather than the link itself — bwrap can’t bind-mount over an existing symlink, but it can mount over the regular file the symlink resolves to. cat .env follows the link at VFS time, lands on the masked target, and reads /dev/null.
Targets outside the project root are masked too (fail closed). A link like .env -> ~/credentials/prod.env masks the escaped target inside the sandbox namespace — without this, wide baselines (command_fs = "all" / permissive) would bind the target and cat .env would read real bytes. The mask is namespace-local and read-only; the host file is untouched. The only opt-out is a grant naming the symlink’s own project-relative path (e.g. default_files = [".env", "*"] — the literal entry opts in). additional_directories covering the target does NOT opt in; name the secret directly.
One intentional carve-out:
- Broken symlinks, symlink cycles,
canonicalizefailures, and non-file targets (directories) are skipped. The walker doesn’t fall back to masking the link’s own path — that would fail at bwrap startup and block the user’scommand_runentirely. Skipped entries bumpskipped_countfor operator visibility.
Process and IPC
Section titled “Process and IPC”A sandboxed subprocess can’t ptrace, can’t share IPC namespaces with non-sandboxed processes, and runs in its own PID + network namespace on Linux. It can fork children, but those children inherit the same envelope.
Curated tool bundles
Section titled “Curated tool bundles”Most common dev tools have config dirs, env vars, and registries they need to function. Listing each requirement by hand is error-prone, so Seal ships bundles — pre-baked configurations for each tool that you opt into with one entry:
[sandbox.os]command_tools = ["git", "gh", "cargo", "bun"]Each bundle contributes:
- Read binds for the tool’s config dirs (e.g.
~/.gitconfig,~/.cargo/config.toml). - Env-var passthroughs for the variables the tool reads (e.g.
GITHUB_TOKEN,CARGO_*). - Default network allowlist for the tool’s canonical hosts (e.g.
github.comfor git,crates.iofor cargo). - Optionally, write binds for caches and install dirs (cargo’s registry cache, npm’s install cache, …).
Each bundle has knobs you can tune via the expanded form:
[sandbox.os]command_tools = [ "git", { tool = "cargo", fetch = false, install = true }, { tool = "node", domains = ["api.openai.com"], default_domains = true },]The full per-bundle reference is at sandbox.os.command_tools.
Wrappers
Section titled “Wrappers”Build orchestrators (just, make, mise) commonly spawn the real tool as a child inside the same sandbox namespace. Without a hint, just ci (which internally runs cargo test) wouldn’t pick up the cargo bundle’s network allowlist — the matched_pattern is just ci:*, not cargo build:*.
The wrappers knob fixes this:
[sandbox.os]command_tools = ["cargo"]wrappers = ["just", "make"]Now the cargo bundle applies whenever the parent command pattern starts with just or make. Per-bundle overrides work too: { tool = "cargo", wrappers = ["just"] } only widens the cargo bundle, not the others.
wrappers is for build orchestrators that intentionally run the real tool as a child. It is not a way to make arbitrary shell wrappers transparent: sh -c "cargo build" and bash -c "cargo build" still classify as the shell, so the cargo bundle does not lift. See Agent runtime: why agents don’t wrap commands in sh -c or bash -c for the tool-call convention.
Audit log
Section titled “Audit log”Every sandbox-enforced denial — filesystem, network, or masked secret — lands in the per-session audit log at ~/.seal/audit/<session-id>.jsonl. Each entry carries the command pattern that triggered the denial, the path or host that was blocked, and the timestamp.
Useful for after-the-fact “wait, what did the agent try to do?” review, and for tuning the manifest: if you see the same denial in the log five times in a row, that’s a signal to either add the grant explicitly or refuse the workflow.
Composing with the capability layer
Section titled “Composing with the capability layer”The two layers don’t duplicate each other — they enforce different shapes:
- The capability layer asks the user before letting the agent do something. Its job is to surface intent.
- The OS sandbox enforces the answer at the kernel boundary. Its job is to make refusal stick.
A grant in [capabilities.allow.commands] makes the daemon let the call through; that’s necessary but not sufficient. The OS sandbox still has to be able to see the relevant files and reach the relevant hosts. If your manifest grants cargo build:* but doesn’t bind ~/.cargo/registry, the build fails inside the sandbox with a “couldn’t fetch dependencies” error — the capability check passed, but the OS layer had nothing for cargo to read.
This is where the curated bundles earn their keep: instead of you naming ~/.cargo/registry, ~/.cargo/config.toml, every CARGO_* env var, and the crates.io domain by hand, command_tools = ["cargo"] supplies the whole bundle of OS-side bindings. You still add the command pattern (cargo:* under [capabilities.allow.commands], or via a permission prompt) — the bundle covers the filesystem and network surface that pattern’s command will need.
See also
Section titled “See also”- Manifest reference:
[sandbox.os]— every knob. - Manifest reference:
[sandbox.os.command_tools]— every curated bundle. - Troubleshooting — common sandbox-related error messages and how to read them.