Skip to content

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.

seal agent (WASM sandbox)
▼ WIT call into the host
seal daemon (native)
▼ capability check (manifest)
▼ OS sandbox (bwrap / sandbox-exec)
your command

The 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.

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.

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.

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 a none baseline.
  • "permissive"system plus $HOME read-only.
  • "all" — entire filesystem read-only.

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] patterns declares which command patterns can run AND which domains they can reach. Bare-string entries ("cargo:*") inherit the section’s default_domains; inline-table entries ({ command, domains }) union with the default unless inherit_default_domains = false opts 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/jj get github.com by default; cargo gets crates.io; npm/bun/yarn/pnpm get 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.

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.

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.

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.com for git, crates.io for 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.

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.

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.

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.