# OS Sandbox

URL: https://cogitator.app/docs/tools/os-sandbox

> Run a local workspace's commands inside the operating system's sandbox, Seatbelt on macOS and bubblewrap on Linux, with the files, network hosts and settings of Claude Code's sandbox, and let the sandboxed ones run without an approval.

## Overview

A [`LocalWorkspace`](/docs/tools/workspace) runs commands as your process: confined to its directory, but able to read and write anything you can and to reach any host. `OsSandbox` from `@cogitator-ai/sandbox` puts every command it runs inside the operating system's own sandbox instead, the boundary Claude Code's sandboxed Bash tool uses:

* **macOS**: Seatbelt, `sandbox-exec` with a profile generated for each command, network allowed only to the sandbox's proxy on loopback. It ships with the system.
* **Linux**: bubblewrap, `bwrap` with a network namespace of its own, the proxy reached through a Unix socket and `socat` inside. The command itself runs in a second `bwrap` inside the first, which keeps it from making user or mount namespaces of its own (where it would hold every capability over a copy of the mount tree): `--disable-userns` (bubblewrap 0.8.0 and later) and a seccomp filter on x86\*64 and arm64 that refuses `unshare`, `setns`, mounting, `clone` with a namespace flag, `clone3`, io\_uring and Unix sockets (datagram socket pairs too, whose ends could be connected to any path, while the stream pairs child process pipes use stay). The seccomp filter is required, since it is also what keeps commands from the Docker socket or an SSH agent, which the network namespace does not hide: without it (another CPU, a kernel that refuses it) the sandbox is unavailable, unless `network.allowAllUnixSockets` lets commands reach every socket anyway, when `--disable-userns` alone will do. Loader variables (`LD_PRELOAD` and the other `LD*\*`, `GCONV_PATH`) never reach `bwrap`itself, where they would run code before any restriction holds: the inner`bwrap`sets them for the command alone. Install both:`apt-get install bubblewrap socat`(or`dnf install bubblewrap socat`).

The operating system enforces the boundary on the running process and on everything it starts, so it holds whatever the command looks like, which the permission rules, matching command text, cannot promise. That is why a command that runs inside can skip its approval.

```typescript
import { Agent, LocalWorkspace, loadSandboxPolicy, workspaceTools } from '@cogitator-ai/core';
import { OsSandbox } from '@cogitator-ai/sandbox';

const { policy } = await loadSandboxPolicy('./project');
const sandbox = new OsSandbox({ policy });
const workspace = new LocalWorkspace({ root: './project', exec: { sandbox } });
await workspace.start();

const coder = new Agent({
  name: 'coder',
  model: 'anthropic/claude-sonnet-5-5',
  instructions: 'Fix the failing test.',
  tools: workspaceTools(),
  workspace,
});
```

`workspace.start()` probes the sandbox: when it cannot run here (no `sandbox-exec`, no `bwrap` or `socat`, user namespaces blocked in a container or by Ubuntu's AppArmor restriction) it fails with a `WorkspaceError` of code `UNAVAILABLE`, caused by a `SANDBOX_UNAVAILABLE` error that names the reason. Nothing ever runs unprotected silently: only `failIfUnavailable: false` lets commands run without the sandbox, with a warning, and each result then says `sandbox: { sandboxed: false, unsandboxedReason: 'unavailable' }`.

## What a command can reach

| Access                | Default                                                                                                                            | Change it with                                                     |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Writes                | The workspace root and a temporary directory for that command alone, which `TMPDIR` points to and which is removed once it is over | `filesystem.allowWrite`, `filesystem.denyWrite`                    |
| Reads                 | Everywhere, credential files such as `~/.ssh` and `~/.aws` included                                                                | `filesystem.denyRead`, `filesystem.allowRead`, `credentials.files` |
| Network               | No route out but the sandbox's proxy, whose allowed hosts start empty                                                              | `network.allowedDomains`, `network.deniedDomains`, `onUnknownHost` |
| Environment variables | What the workspace passes, without API keys by default (`exec.inheritEnv`)                                                         | `credentials.envVars`                                              |

### Files

Writes are allowed only in the writable roots, and a `denyWrite` wins over every allow. Reads are allowed everywhere but `denyRead`, and between a read deny and a read allow the narrower one wins. An `allowRead` reopens only a path it names inside the denied region: one that reaches in only through a symlink, made beforehand or by an earlier command, is ignored with a warning. A file a read deny names, matches or holds (or `credentials.files`) with more than one hard link fails the launch, since the deny holds only for the names it covers (a denied directory is looked through up to 5,000 entries, with a warning past that):

| Rules                                               | Result                                                            |
| --------------------------------------------------- | ----------------------------------------------------------------- |
| `"denyRead": ["~/"]`, `"allowRead": ["~/projects"]` | `~/projects` readable, the rest of the home directory hidden      |
| `"allowRead": ["~/"]`, `"denyRead": ["~/.env"]`     | `~/.env` hidden, the rest readable: a broad allow re-exposes none |
| `"allowRead": ["~/"]`, `"denyRead": ["~/**/.env"]`  | every `.env` under the home directory hidden                      |

Paths are absolute, `~/` for the home directory, or relative to the workspace root (to the settings file's project or directory when read from settings). `*`, `?`, `**` and `[...]` work in every list on macOS. On Linux bubblewrap mounts concrete paths: a trailing `/**` works everywhere, other wildcards in the read lists, expanded to what exists when the command starts. A denied path reached through a symlink is denied under its real location too.

### Protected paths

Inside the writable roots a command still cannot write what Cogitator and Claude Code load configuration and code from: the settings files, `hooks`, `skills`, `agents`, `commands` and `plugins` of `.claude` and `.cogitator` in the root and every directory above it, `~/.claude`, `~/.cogitator` and `~/.claude.json`, the whole `.cogitator` directory of the root (the workspace's trash and snapshots), `.mcp.json`, shell startup files (`.bashrc`, `.zshrc`, `.profile`...), `.gitconfig`, `.gitmodules`, `.vscode`, `.idea`, the files of the repository's git directory that git runs or follows (`hooks`, `config`, `commondir`, `config.worktree`, of the main repository too for a linked worktree, of its linked worktrees and submodules, and where they would go in a root that is no repository yet), the root's `.git` entry itself (no command can remove, rename or replace it, so none swaps it for a `gitdir:` file pointing at a git directory of its own, while git still writes the index and objects inside), and a `HEAD` file at the root, without which no command can turn the root into a bare repository whose config git, run outside the sandbox later, would execute. A symlink at a protected path protects its target as well. On macOS, whose rules follow the path an access goes through, no protected or read-denied path, nor any directory above one, can be renamed or removed, so none is moved somewhere the rules do not reach (on Linux every directory between a writable root and such a path is bound over itself, and a mount point cannot be renamed or removed). A protected file with more than one hard link (the hook scripts directly in a protected `hooks` directory included) makes the launch fail, since the sandbox cannot know where its other names are. An `allowWrite` cannot lift them, only `filesystem.disabled`, which turns filesystem isolation off altogether while keeping the network confined. On Linux a protected file that does not exist yet is covered by an empty read-only file while the command runs, removed afterwards, except a missing `commondir` or `config.worktree`, whose mere presence changes what git does: one a command makes is removed once the command is over.

### Network

Commands get `HTTP_PROXY`, `HTTPS_PROXY` and `ALL_PROXY` (SOCKS5) pointing at a proxy that serves that command alone, so every connection is attributed to it. For each host:

1. a `deniedDomains` entry refuses it, whatever allows it
2. an `allowedDomains` entry lets it through: `github.com` that host only, `*.npmjs.org` its subdomains, `*` every host, an IPv4 address or a bracketed IPv6 one (`[::1]`), each with an optional `:port`
3. the hosts the command lists for itself (`allowedDomains` of the call) let it through, and with such a list any other host is refused without asking
4. `strictAllowlist` refuses it
5. the call's `onUnknownHost` decides, else the sandbox's own `onUnknownHost`, else it is refused

A host name that resolves only to loopback, link-local, a cloud metadata endpoint or an address of this machine is refused (a public name pointing at `127.0.0.1` reaches nothing local), `localhost` and `*.localhost` aside. Allow the address itself, such as `127.0.0.1:3000`, to reach a local server by any name. The address that passed is the one dialed. UDP, QUIC and ICMP do not leave the sandbox, and tools that ignore the proxy variables (plain `ssh`, most database drivers) cannot connect at all. On macOS `allowLocalBinding` lets commands listen on local ports and reach `localhost` directly, `allowUnixSockets` opens the Unix sockets it names and `allowAllUnixSockets` every one. On Linux the seccomp filter cannot tell socket paths apart: any `allowUnixSockets` entry, or `allowAllUnixSockets`, lets commands create every Unix socket.

### What the sandbox refused

The proxy's refusals and the file accesses the system refused (`Operation not permitted` on macOS, `Read-only file system` and `Permission denied` on Linux) are attributed to the command: they are in `result.sandbox.violations`, and a sanitized block appended to its stderr tells the model why the command failed:

```text
<sandbox_violations>
deny network-outbound example.com:443 (not on the allow list, and nothing decides other hosts)
deny file-access .claude/settings.json (/bin/sh: .claude/settings.json: Operation not permitted)
</sandbox_violations>
```

`ignoreViolations` silences the expected ones: keys are substrings of the command (`*` for every command), values substrings of the violation.

Network violations come from the proxy and can be trusted. File violations are advisory: they are read from the errors the command wrote, so a command can hide one (`2>/dev/null`) or print one that never happened. The access is blocked either way. A `<sandbox_violations>` tag a command prints itself comes back neutralized (`&lt;sandbox_violations>`) in the result, so only the block the sandbox appends reads as one.

## Permissions

A command that runs inside the sandbox skips the approval it would need: `run_command`'s own (`requiresApproval`), and a bare `Bash` ask rule (or `Bash(*)`). The decision is logged as decided by `sandbox`. Only `run_command` of `workspaceTools()` gets this, and only when it runs in the sandboxed workspace (bound to it, or unbound in a run that uses it): the core `exec` tool, a `run_command` bound to another workspace and any other command tool keep their approval. What still applies:

* deny rules, which refuse as before
* ask rules with a pattern, such as `Bash(git push *)`
* plan mode, where nothing gets wider: a bare `Bash` ask rule still asks for read-only commands
* the guardrails, when they want an approval: a bare `Bash` ask rule then still asks a person, no hook or `auto` classifier answers it
* a command with a variable in front (`FOO=bar npm test`, `env FOO=bar ...`), `eval`, `make` with a variable (`make CC=clang`), `source` and `.`, `alias`, `export`, `declare -x`, `set -a`, `trap`, `enable`, `hash -p`, and a shell that reads its code from stdin (`... | sh`, `bash -s`, `sh < script`), any of which can change which program runs or run code the line does not show: they go through the normal flow, and an allow rule such as `Bash(make *)` approves them
* a line a deny or ask rule could not read in full (a substitution, an expansion where a `Read` rule guards files): a bare `Bash` ask rule still asks
* a command that names hosts of its own (`allowedDomains`): no allow rule approves it, it asks (the `auto` classifier sees the hosts with the command)
* a command outside the sandbox: one `excludedCommands` takes out, one whose `dangerouslyDisableSandbox` the policy honors

`autoAllow: false` (`autoAllowBashIfSandboxed` in settings) sends sandboxed commands through the normal flow too.

Two kinds of removal ask whatever the sandbox:

* `rm` and `rmdir` of a critical path, an `mv` that moves one away and an `rsync --delete` into one, behind wrappers too (`env -i`, `nice`, `timeout`, `caffeinate`...): the workspace root or a directory above it, the home directory, `/`, a `.git` directory or anything in it (without case where the filesystem ignores it, so `.GIT` is `.git` on macOS), an operand the shell or another program decides (`$DIR`, `~user`, `~+`, a brace expansion such as `{a,b}` or `{1..3}`, the `{}` of `find -exec`), a removal whose operands come from elsewhere (`xargs rm`, `find -delete`, a remover under `find -exec`), a wildcard over one of those (`rm -rf *` in the root). They ask in every mode, `bypassPermissions` and `acceptEdits` included, over any allow rule, a person deciding rather than the `auto` classifier (`dontAsk` refuses them).
* a removal outside the workspace (and its extra directories) by a command that runs without the sandbox, and a `file_delete` of a path outside it: they ask in every mode but `bypassPermissions`.

These removal checks apply to tool calls (an agent's, or `invokeTool`). The commands of plugin hooks keep their own checks, as in Claude Code.

A host no sandbox list decides is decided by the run as a `WebFetch(domain:...)` call: a `WebFetch` deny rule refuses it, an allow rule lets it through, `bypassPermissions` allows it, `auto` and `dontAsk` refuse it, and the other modes ask a person through the run's `onApproval` (a request with `toolName: 'WebFetch'` and the host's URL). A host a person allowed stays allowed for the rest of the session. A run without permissions set up leaves it to the sandbox's own `onUnknownHost`.

## Settings

`loadSandboxPolicy(root, options)` reads the `sandbox` section of the same files as the [permission settings](/docs/tools/permissions#settings-files), so Claude Code's `.claude/settings.json` configures it as it is:

```json
{
  "sandbox": {
    "enabled": true,
    "excludedCommands": ["docker *"],
    "filesystem": {
      "allowWrite": ["~/.kube", "/tmp/build"],
      "denyRead": ["~/.aws/credentials"]
    },
    "network": {
      "allowedDomains": ["github.com", "*.npmjs.org"],
      "deniedDomains": ["uploads.github.com"]
    }
  }
}
```

It returns `{ policy, warnings }`: hand `policy` to `OsSandbox` when `policy.enabled`. As in Claude Code, a boolean comes from the highest layer that sets it (managed, local, project, user), lists are combined across the layers, and the `Edit` allow and deny rules, `Read` deny rules and `WebFetch(domain:...)` rules of the files add their paths and hosts. The managed locks hold:

* `network.allowManagedDomainsOnly` (managed) keeps only the managed `allowedDomains` and refuses other hosts
* `filesystem.allowManagedReadPathsOnly` (managed) keeps only the managed `allowRead`
* the sandbox is admin-required when managed settings set `allowUnsandboxedCommands: false` or `allowManagedDomainsOnly`: a repository's files cannot loosen it then (`excludedCommands`, `ignoreViolations`, `allowedDomains`, `allowUnixSockets`, `allowWrite`, `Edit` allow rules, `additionalDirectories`, a `true` of `allowLocalBinding` or `allowAllUnixSockets`, a `false` of `enabled` against the user's `true`, any `false` of `failIfUnavailable`)
* `network.strictAllowlist` and `filesystem.disabled` count from user and managed settings only (`filesystem.disabled` from managed ones only once they configure the filesystem), `bwrapPath` and `socatPath` from managed settings only
* a `false` of `allowUnsandboxedCommands` in user or managed settings holds against a repository's `true`
* a repository's `allowRead`, `allowWrite` and extra directories at or under a path that managed or user settings deny reading are left out, their symlinks resolved, so a link in the repository does not reach a denied directory
* an untrusted project (`trusted: false`) keeps only what tightens the sandbox

Two defaults differ from Claude Code, on the safe side: `failIfUnavailable` is true once `enabled` is, and `allowUnsandboxedCommands` is false. Keys Cogitator does not support are left out with a warning: `enableWeakerNestedSandbox`, `enableWeakerNetworkIsolation`, `allowAppleEvents`, `allowNestedUserNamespaces`, `network.httpProxyPort`, `network.socksProxyPort`, `network.tlsTerminate`, `network.allowMachLookup`, `ripgrep`. A `credentials` entry with `mode: "mask"` is denied instead, since masking needs TLS termination.

## The policy in code

| Option                        | Default | Meaning                                                                                      |
| ----------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| `failIfUnavailable`           | `true`  | Refuse to start when the sandbox cannot run, instead of running commands without it          |
| `autoAllow`                   | `true`  | Sandboxed commands skip the approval a command would need                                    |
| `allowUnsandboxed`            | `false` | A call may run outside the sandbox with `dangerouslyDisableSandbox`, through the normal flow |
| `excludedCommands`            | none    | `Bash(...)` patterns of commands that run outside the sandbox                                |
| `ignoreViolations`            | none    | Violations not reported, by command substring                                                |
| `filesystem.allowWrite`       | none    | Writable besides the root and `TMPDIR`                                                       |
| `filesystem.denyWrite`        | none    | Not writable even inside a writable root                                                     |
| `filesystem.denyRead`         | none    | Hidden from reads                                                                            |
| `filesystem.allowRead`        | none    | Readable again inside a `denyRead`                                                           |
| `filesystem.disabled`         | `false` | No filesystem isolation (protected paths included), the network still confined               |
| `network.allowedDomains`      | none    | Hosts reached without asking                                                                 |
| `network.deniedDomains`       | none    | Hosts refused whatever allows them                                                           |
| `network.strictAllowlist`     | `false` | Refuse other hosts without asking, and calls that list hosts of their own                    |
| `network.allowLocalBinding`   | `false` | macOS: listen on local ports, reach `localhost` directly                                     |
| `network.allowUnixSockets`    | none    | Unix sockets commands may connect to (on Linux any entry opens every one)                    |
| `network.allowAllUnixSockets` | `false` | Every Unix socket                                                                            |
| `credentials.envVars`         | none    | Variables removed from the environment of sandboxed commands                                 |
| `credentials.files`           | none    | Files hidden from reads                                                                      |
| `bwrapPath`, `socatPath`      | `PATH`  | Linux: the binaries to use                                                                   |

`new OsSandbox({ policy, onUnknownHost, onWarning })` takes the policy, a decider for hosts no list decides and where warnings go (default `console.warn`). `OsSandbox.probe()` says whether the sandbox runs on this machine and why not, `OsSandbox.create(options)` returns a started sandbox, `route(command, request)` says how a command would run, and `dispose()` closes the proxies of commands still running and removes the sandbox's directories. One sandbox can serve several workspaces: each command is confined to the root of the workspace that runs it.

A workspace call passes its own sandbox options: `exec(command, { allowedDomains, dangerouslyDisableSandbox, onUnknownHost })`, and `run_command` takes `allowedDomains` and `dangerouslyDisableSandbox` from the model. The `os` type of the `SandboxManager` runs an execution under the same sandbox, see [Sandbox](/docs/deployment/sandbox#os-sandbox). Its writable root is `workdir` (default: the process's working directory), never the `cwd` of a request, and a root at `/` is refused. It never runs unprotected: `failIfUnavailable: false` is ignored there, and a tool with `sandbox: { type: 'os' }` fails with `SANDBOX_UNAVAILABLE` rather than running natively when no sandbox manager is available.

## Limitations

* `rm` in a command deletes for good: the trash belongs to the `delete_file` tool, see [Workspace](/docs/tools/workspace#trash).
* Processes a command leaves running in the background lose the network and their `TMPDIR` once it exits, and on Linux they stop with it (the sandbox has a process namespace of its own).
* File violations are read from the command's errors, not from the system log, so they are advisory (see above).
* On Linux a wildcard read deny covers what exists when the command starts, and a protected path behind a symlinked directory inside the root is protected where the link leads, not at the link: a command can replace the link with a directory of its own (macOS closes the path as written too).
* Git repositories below the root (a nested repository, a submodule a command makes without `.gitmodules`) keep their hooks and config writable: run git in them only after a look.
* The proxy checks host names, it does not inspect TLS: an allowed domain carries whatever is sent to it, so allow narrowly.
* Hooks, MCP servers and the file tools run outside the sandbox and follow the [permission rules](/docs/tools/permissions) instead.
