Run and sandbox

launchsafe-firewall run starts any agent inside the operating system's own sandbox, with a policy-driven proxy

Edit on GitHub
launchsafe-firewall run -- aider --model sonnet
launchsafe-firewall run --send-host api.anthropic.com -- my-agent
launchsafe-firewall run --dry-run -- opencode      # print the sandbox and the host list, run nothing

run starts any command (an agent CLI, a script, a build) inside the operating system's own sandbox: on macOS sandbox-exec (Seatbelt), on Linux bubblewrap. The command can write only to the workspace and a private temporary directory, cannot change the firewall's own files, and reaches the network only through a small proxy on 127.0.0.1 that run starts and that admits the hosts your firewall policy lists. Every refusal, the first connection to each host and a summary at the end go to the firewall's signed decision log.

It is for agents that have no built-in sandbox and often no hooks either (Aider, opencode, goose, home-made scripts). For Claude Code use its own sandbox (doctor --fix writes the profile from the same policy); Codex, Gemini CLI and Cursor have sandboxes of their own too.

Why it exists

The hooks decide whether a tool runs; they cannot contain a program that does run, and they see nothing at all for an agent that has no hooks. The gap is a compiled binary, a script that opens a socket, or a whole agent the firewall cannot see into. run closes the part of that gap the operating system can enforce: where the process may write and which hosts it may reach, whatever the program is.

The host list

run does not have a policy of its own. It uses the same derivation doctor --fix uses for Claude Code's sandbox, so both sandboxes admit exactly the same hosts:

  • fetchAllowHosts and sendAllowHosts, in the sandbox's syntax ("=x" only that host, "*.x" its subdomains, "x" both);
  • never a raw IP address (any spelling), loopback, a cloud metadata endpoint, a capture or tunnel host, anything in denyHosts, or a wildcard that would cover a metadata name or IP addresses, even when listed;
  • denyHosts win over every allowed pattern;
  • hosts you named in a prompt are not added (they are per session and run has no session).

--send-host LIST adds hosts for this run only, as send hosts (for example the agent's model API, which every agent needs and no default lists). The same exclusions apply: an IP address, loopback, a metadata or capture host, or a host in denyHosts is refused and run does not start.

The proxy

A local proxy outside the sandbox, on 127.0.0.1 and a random port (on Linux a Unix socket in a private directory, bridged into the sandbox). The child's environment points every proxy variable at it (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY and the lower-case forms; NODE_USE_ENV_PROXY=1 for Node's own fetch); NO_PROXY keeps loopback traffic local. A program that ignores the variables simply cannot connect: the sandbox admits no other address.

For each CONNECT host:port (HTTPS) or absolute-form GET http://host/... (plain HTTP), in order:

CheckRefused whenLogged as
PortNot 443 or 80why: "port"
Address formA raw IP address or loopbackFW-RAW-IP or why: "loopback"
MetadataA cloud metadata endpointFW-METADATA
Capture hostsA request catcher or tunnel serviceFW-CAPTURE-HOST
denyHostsListedwhy: "denied-host"
The listNot admitted by the derived host list or --send-hostwhy: "not-listed"
DNSAny answer of the proxy's own lookup is private (10/8, 172.16/12, 192.168/16), carrier-grade NAT (100.64/10), unique local (fc00::/7), loopback, link-local, unspecified, multicast or a metadata address, in IPv4, IPv6 or IPv4-mapped form (DNS rebinding). Every answer is checked, and the proxy connects to the address it checked, never a second lookup. A private answer is admitted only for a name in run.privateHosts, a loopback answer only with --allow-localhost on macOSwhy: "resolves-internal"
TLS nameThe client's TLS ClientHello names a different server (SNI) than the CONNECT line, or port 443 carries something that is not TLSwhy: "sni-mismatch" or "not-tls"
Host headerPlain HTTP only: the Host header names a different server or port than the admitted URLwhy: "bad-host"
BudgetsSee belowFW-BUDGET-BYTES or FW-BUDGET-REQUESTS

A refused request gets 403 with one plain line that says why and which policy key to change, so the agent (and you, reading its output) can tell a firewall refusal from a network failure. A CONNECT to port 80 that carries HTTP is read request by request. An HTTP upgrade (a WebSocket) is admitted with the same checks and then relayed both ways, with the bytes the client sends counted against the budgets. The proxy never decrypts TLS: it sees the host name, the port and the SNI, never the path, the method or the body.

Budgets in run mode

In run nobody can be asked in the middle of a connection, and the firewall cannot see what the agent read, so the run is treated like an untrusted session from the start and a reached budget refuses (fail closed) instead of asking.

DestinationUpload bytes (client to server)Connections and requests
Hosts in sendAllowHosts, --send-host or budgets.exemptHostsNot countedNot counted
Package registries and code hosts (registry.npmjs.org, pypi.org, github.com, and the like)budgets.trusted.bytesTotal together (4 MB)budgets.trusted.requestsPerHost10m per host (300)
Every other listed hostbudgets.untrusted.bytesPerHost each (64 KB) and budgets.untrusted.bytesTotal together (256 KB)budgets.untrusted.requestsPerHost10m per host (40)

The middle row exists because the proxy cannot tell an install from an upload: with the untrusted numbers every npm install would stop at 64 KB of request headers, and with no limit a fooled agent could push your code to its own repository on github.com without bound. Bytes are counted on the wire (TLS records and headers included), so they are an upper bound of what was sent. Once a budget is reached, new connections to that host (or, for a total, to every counted host) are refused for the rest of the run, and a tunnel that crosses the limit is closed. budgets.enabled: false switches them off. To raise one for an agent you trust, change the budgets key in your user policy or add the host to sendAllowHosts.

The sandbox profile

Both platforms get the same rules from one plan:

  • Writes: the workspace (the current directory, or --workspace DIR) and a private temporary directory created for the run (TMPDIR points at it; it is removed afterwards), plus --allow-write LIST. --allow-write refuses the disk root, system directories, the home directory or anything above it, credential stores, shell start-up files, ~/.config and ~/Library themselves, the agents' own configuration directories (~/.claude, ~/.codex, ~/.gemini, ~/.cursor) and the firewall's data directory. /tmp is not writable by default: other programs' sockets and files live there.

  • Read-only, always, even inside a writable directory, and whether they exist yet or not: the firewall's data directory, its installed package, the settings files its install record names, and in the workspace everything that starts a program later, outside the sandbox:

    • agent settings (.claude/settings.json, .claude/settings.local.json, .codex/config.toml, .codex/hooks.json, .gemini/settings.json, .cursor/hooks.json) and Claude Code's .claude/skills/, .claude/agents/, .claude/commands/ and .claude/hooks/ whole;
    • MCP server lists, editor tasks and settings, dev containers (.mcp.json, .cursor/mcp.json, .vscode/tasks.json, .vscode/settings.json, .devcontainer/ whole);
    • git: .git/hooks, .git/config, .git/config.worktree, .git/commondir, .git/modules, .git/worktrees, .husky, and a .git file (a linked worktree's or submodule's pointer). .git/objects/info/alternates stays writable;
    • what the workspace names itself: the directory core.hooksPath points to, files the git config names, nested repositories' hooks and config, and the script files the agent settings above run as hooks, MCP servers or tasks (also when such a file does not exist yet, as long as the command names it as a program), so the agent cannot create a missing hook script that runs in your next session. run names these scripts when it starts. When the agent must change one (build output an MCP server you are developing runs), add its path to run.allowWritePaths in your user policy; no entry ever exempts git's own files;
    • the project policy .launchsafe-firewall.json.

    Each path is protected as written and with symlinks resolved. The directories that lead to a protected path cannot be moved, removed or replaced (.git, .claude, .cursor, .codex, .gemini, .vscode, .husky, the workspace itself), so renaming .git away and putting a prepared copy in its place is refused. Their contents stay writable (git works as usual). Hard links to a protected file are refused too.

  • Unreadable: the firewall's keys and vault directories, ~/.ssh, ~/.aws and ~/.gnupg.

  • Network: nothing but the proxy. No DNS (so no DNS exfiltration). On macOS loopback is the host's, so connecting to other local ports is refused unless you pass --allow-localhost (every service listening on your machine's 127.0.0.1 becomes reachable). On Linux the sandbox has its own loopback: local test servers work, the host's services stay unreachable.

  • Unix sockets: only inside the workspace and the run's temporary directory. A socket elsewhere (tmux, ssh-agent, Docker, the D-Bus session bus) is a way out of any sandbox.

  • Processes: a sandboxed process cannot signal processes outside the sandbox (so it cannot stop the proxy).

macOS (/usr/bin/sandbox-exec). A Seatbelt profile with every path passed as a parameter: network denied except the proxy port and local binding; file writes denied except the plan's directories, with the read-only paths denied last; reads denied for the unreadable paths; Unix sockets only under the workspace and the run directory; signals only within the same sandbox; Apple Events denied; and the system services that would act for the process outside the sandbox denied by name: LaunchServices, the background URL-session daemon, the pasteboard and, unless --allow-keychain, the keychain (so a vault item stored with confirm: never is out of reach too). When run has a terminal, the command runs under /usr/bin/script with a pseudo-terminal of its own, so a keystroke the sandboxed program pushes into its terminal stays inside it.

Linux (bubblewrap). bwrap is used only from /usr/bin, /usr/local/bin or /bin, root-owned and not writable by others (never through PATH). A read-only bind of /, a fresh /dev, /proc, a private /tmp and /run (hiding the host's sockets, including the D-Bus session bus), binds of the writable directories, read-only binds of the protected paths, and an empty tmpfs or /dev/null over the unreadable ones. It also uses --unshare-net, --unshare-pid, --new-session (no controlling terminal) and --die-with-parent.

Protected paths that do not exist yet cannot be mounted on, and run does not create files in your repository to make one. Instead a missing protected directory gets an empty read-only tmpfs (nothing can be created below it), a missing file in an existing agent configuration directory makes that whole directory read-only, and the few files git insists on reading get a file stand-in with the content git assumes when the file is absent. These are empty mount points that exist on disk only while the run lasts. A symlink inside the workspace that points at a protected path cannot be protected on Linux, and run refuses to start and says which link. If run itself is killed, an empty directory or a stand-in can stay behind (.mcp.json/, for example); remove it.

Who may start it

run is started by a person. It is declared as changing state, so the hooks deny it (FW-TAMPER) when an agent they watch tries to start it, and the CLI refuses when it runs inside a Claude Code session. It does not require a terminal, so a script or CI job you wrote can use it. Nothing inside the sandbox can change the policy, the log or the firewall's files.

What is logged

Signed, hash-chained entries in the decision log (log tail, log verify):

  • run_start: the run id, the program's name and its argument count (never the arguments: prompts and keys are often passed there), the workspace, the platform, the number of hosts and the run's flags;
  • run_net: the first allowed connection to each host, and every refusal (up to 20 per host and reason; the rest are counted in run_end);
  • run_budget: a budget reached;
  • run_end: the exit status, the duration, and per host the connections, bytes up and down and refusals.

At the end run prints the refusals to standard error. With --verbose it also prints each refusal as it happens.

Secrets by reference (--env)

run --env NAME=vault://<name>[#field] -- <command> puts a vaulted secret in the command's environment as NAME. Two ways in, and only these:

  • A person's run (no ticket): every reference must name a secret bound to this exact command with vault allow <name> --process "<argv>" (a trailing * allows further arguments). Anything else exits 77, logs vault_denied (FW-VAULT-UNBOUND) and starts nothing. --dry-run names the variables and reads no value. PATH, HOME, LD_*, DYLD_* and NODE_OPTIONS are refused as names.
  • The hook's ticket (--ticket T): Claude Code's rewrite of a shell command with references (see Vault). The ticket is single-use, expires, and is bound to the exact --env list and argv. This path runs the command as the hook decided it, in the agent's own shell, with no sandbox (which would change what was decided). The child's output is filtered back to the references before the agent reads it.

An agent typing launchsafe-firewall run itself is denied (FW-TAMPER): an agent may not start its own sandbox or pick its own secrets.

Policy

run.allowWritePaths keeps the scripts the workspace's agent settings run (hooks, MCP servers, editor tasks) read-only by default, because the agent could otherwise replace a program your next session starts outside the sandbox. When that is build output the agent must rebuild (an MCP server you are developing, started as node dist/index.js), list the path, relative to the workspace or absolute: "run": { "allowWritePaths": ["dist"] }. It exempts only scripts named by agent settings, never the fixed list or the firewall's files. Git's files are judged by what they are: any path with a .git component stays read-only. Only user or managed policy may set it.

run.privateHosts lists company hosts that really live in a private range, in the fetchAllowHosts syntax: "run": { "privateHosts": ["corp.example"] }. The host must still be admitted by fetchAllowHosts or sendAllowHosts; the key only widens which answers count. Link-local, metadata, unspecified and multicast answers are never admitted. Only user or managed policy may set it. See Policy.

Limits (read these)

  • Hosts, not methods. A host admitted for fetching (github.com, registry.npmjs.org) accepts uploads too. The budgets bound how much, but a fooled agent with an attacker's token can still push a small amount of your code to an attacker's repository on an admitted host. Keep sendAllowHosts short and treat upload-capable hosts as such (doctor labels them).
  • Domain fronting through a CDN. The SNI check stops a client that opens a tunnel to an admitted host and then names another server in TLS. It cannot see the HTTP Host header inside TLS, so a CDN that routes on that header could still serve another customer's site through an admitted host's address. Most large CDNs now refuse mismatched names; not all do.
  • No hooks means no taint tracking. In run the firewall sees connections, not tool calls: no proposals, no queue, no task scope, no secret fingerprints in payloads, no decoy tripwire on reads. An agent that has hooks should keep them; run is the outer wall.
  • Hooks inside run cannot write state. An agent whose firewall hooks run as its own child processes (Codex, Gemini CLI, Cursor) runs them inside the sandbox, where the firewall's directory is read-only: every risky action then fails closed. run warns when it sees such an agent with the hooks installed. Use that agent's own sandbox instead, or run without the hooks.
  • macOS uses a deny list for system services. A system service not on that list that performs network or file work for its caller would not be stopped. Claude Code's own macOS sandbox starts from "deny" with an allow list, which is stricter. sandbox-exec is also deprecated by Apple (it still works on macOS 26 and Apple's own tools use the same engine).
  • macOS flags. --allow-keychain gives the sandboxed process the keychain, so confirm: never vault items become readable through /usr/bin/security. --allow-localhost exposes every service on 127.0.0.1.
  • Linux needs unprivileged user namespaces. Some distributions restrict them; doctor runs a probe and prints bubblewrap's error. Sockets reachable through a path outside the hidden directories stay reachable. It was verified live on Debian 12 with bubblewrap 0.8, as an unprivileged user in a Docker container.
  • Linux has no controlling terminal. A program that opens /dev/tty for a prompt gets an error. Interactive agents that read their standard input work.
  • What the run keeps read-only for git and agents is a list. A module that a hook script imports is not followed. A repository the agent creates inside the run is its own: check it before you run git there. Submodule and linked-worktree git directories are read-only as a whole, so git submodule update and git worktree add fail inside the run.
  • Commands that change .git/config, and git init, run outside run. git config, git remote add, git push -u, git branch --set-upstream-to and git checkout --track fail inside the run with git's own message. In a workspace that is not a repository yet, .git itself cannot be created, so git init fails. Run those commands outside run, or start run --git-init, which runs git init in a workspace without .git (as you, outside the sandbox) before the run starts.
  • Caches and agent state in your home are read-only. npm, pip and many agents write caches or history under your home. They fail, fall back, or need --allow-write ~/.npm (for example). /tmp too: tools that ignore TMPDIR need --allow-write /tmp.
  • No upstream proxy. A proxy you already use (HTTPS_PROXY in your environment) is replaced, and run's proxy connects directly. Corporate proxies are not chained in this release.
  • Other local processes can use the proxy. On macOS it listens on 127.0.0.1 without credentials. It only reaches the hosts your policy lists, so this changes nothing about where data can go; it can use up the run's budgets.
  • Budgets are per run and counted on the wire; what stays under them is not seen.
  • Vault values reach only the child's environment. A program that transforms a value before printing it (reverses it, say) is outside the output filter, which is why a ticketed run from the hook is only ever a single curl or wget command and a person's run needs a process binding.

On this page