Lightweight and declarative sandboxing for AI agents on Linux and macOS using Nix.
Shell
162
377 commits
updated Sep 18, 2026
Declarative sandboxing for AI agents on Linux and macOS.
Stop your agents in YOLO mode from deleting your $HOME, force pushing to main, or publishing your ssh keys on reddit. The sandbox works with any CLI-based AI agent. The repository provides templates for common agent CLIs.
The sandbox uses bubblewrap on Linux and sandbox-exec on macOS.
See Security for the threat model and the known limits.
rwDirs / rwFiles, or read-only access through roDirs / roFiles.allowedPackages are on the agent's PATH, together with bash and cacert.allowedDomains. Ports are closed by default, but may be optionally exposed or connected to.Everything else is denied. Only changes to the launch directory and declared rwDirs/files are persisted - the agent's home directory and anything else it writes are discarded upon exit.
To get started quickly, use a flake template. If you do not use flakes, shells/claude.shell.nix is the same setup written as a plain shell.nix. The rest of shells/ holds worked examples for narrower setups, linked from the sections they illustrate.
The following flake templates are provided for quick setup:
| Template | Agent | Wrapped binary |
|---|---|---|
claude | Claude Code | claude-sandboxed |
codex | Codex | codex-sandboxed |
copilot | GitHub Copilot CLI | copilot-sandboxed |
gemini | Gemini | gemini-sandboxed |
opencode | OpenCode | opencode-sandboxed |
pi | Pi | pi-sandboxed |
To initialize a template in your project directory:
nix flake init -t github:archie-judd/agent-sandbox.nix#<template>
The command creates a flake.nix in your project. Edit the file for your needs, and export your access token. Then enter the dev shell and run your wrapped binary:
nix develop
claude-sandboxed
To keep the original command name as the alias, change the outName value, for example to "claude".
If your preferred agent does not have a template, please adapt one to your needs and consider contributing it to the repository!
pkg, binName, outName, allowedDomains and the rwDirs the agent needs for its config and cache.templates in flake.nix.Most agents need nothing beyond their template. This table details agent-specific gotchas.
| Agent | Note |
|---|---|
| Claude Code | Set CLAUDE_CONFIG_DIR to the rwDir that holds Claude's state, rather than declaring ~/.claude.json as an rwFile. See the note below the table. |
| Codex | Codex sandboxes itself, and the two sandboxes cannot nest. Run codex-sandboxed -s danger-full-access and let this sandbox do the work. Without it, every command fails with Failed to create unified exec process: Operation not permitted. |
CLAUDE_CONFIG_DIR and not add ~/.claude.json as a rwFile?Set CLAUDE_CONFIG_DIR to $HOME/.claude, so that Claude writes ~/.claude.json inside the read/write rwDir. If you add ~/.claude.json as a rwFile instead, Claude writes temporary files to the ephemeral home root when it updates its configuration. Claude then tries to rename these files to ~/.claude.json. The rename can fail, or behave in an unexpected way, because the temporary files land outside every declared rwDir and rwFile. This can sometimes corrupt the ~/.claude.json file.
Note: If you also run Claude outside the sandbox, set
CLAUDE_CONFIG_DIR=$HOME/.claudeglobally too. Otherwise the two use different config locations and diverge.
mkSandbox, the library's entrypoint, accepts the following arguments:
| Argument | Required | Description |
|---|---|---|
pkg | yes | Package that contains the binary to wrap |
binName | yes | Name of the binary inside pkg/bin/ |
outName | yes | Name of the wrapped binary, and the command that runs it |
allowedPackages | yes | Packages the agent can execute and build against |
rwDirs | no | Directories the agent can read and write (for example ~/.config/claude, or a package manager's cache: see shells/claude-uv.shell.nix) |
rwFiles | no | Individual files the agent can read and write |
roDirs | no | Directories the agent can read but not write (for example signed binaries, reference source trees, secret stores) |
roFiles | no | Individual files the agent can read but not write (for example ~/.config/git/config for the git identity, see Setting your git identity |
env | no | Additional environment variables, as an attrset |
allowedDomains | no | Limits the domains the sandbox can reach. Leave it unset for open internet. Accepts a list of domains (all methods allowed), or an attrset that maps each domain to "*" or to a list of HTTP methods. [ ] blocks all internet access. |
allowUnixSockets | no | If true, the agent can create and connect to UNIX-domain (AF_UNIX) sockets. It can connect in directories it can read, and bind in directories it can write. Defaults to false. See UNIX-domain sockets. |
allowedHostPorts | no | Host-local TCP ports the sandbox can reach. Defaults to [ ]. Set it to null to allow all host-local TCP ports. Otherwise, entries must be integers from 1 to 65535. |
publishedPorts | no | Host TCP ports forwarded INTO the sandbox, so services the agent runs are reachable from outside. Defaults to [ ]. Entries are an integer port (bound to 127.0.0.1) or { port = <int>; bindAddr = "<ipv4>"; }. There is no null form. See Published ports. |
allowNix | no | If true, the sandbox exposes the host's nix-daemon socket and the full Nix store. The agent can then run nix build, nix run, nix develop, and similar commands. The sandbox adds pkgs.nix to PATH. Requires allowUnixSockets = true and a running nix-daemon. The launch is refused if you are one of the daemon's trusted-users, and asks for confirmation if the daemon does not sandbox its builds. Defaults to false. See Using Nix inside the sandbox. |
The library also exports commonTools, a list of standard CLI tools. See default.nix for the full list.
A minimal example. The arguments are the same for a flake and for a shell.nix:
mkSandbox {
pkg = pkgs.claude-code;
binName = "claude";
outName = "claude-sandboxed";
allowedPackages = commonTools; # or e.g. commonTools ++ [ pkgs.nodejs ]
rwDirs = [ "$HOME/.claude" ];
roFiles = [ "$HOME/.config/git/config" ];
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN";
CLAUDE_CONFIG_DIR = "$HOME/.claude";
};
allowedDomains = {
"anthropic.com" = "*";
"claude.com" = "*";
"github.com" = ["GET" "HEAD"];
"githubusercontent.com" = ["GET" "HEAD"];
};
}
Why the example sets CLAUDE_CONFIG_DIR is explained in Agent notes.
A template dev shell configures the sandbox per project. To have one sandboxed agent everywhere instead, build the wrapper in your NixOS, Nix Darwin or Home Manager configuration and install it into your profile. The sandbox scopes itself to the directory you launch it in, so a single wrapper serves every project. The tradeoff is one configuration for all of them: rwDirs, allowedPackages and allowedDomains no longer vary by project.
Add the flake as an input:
inputs.agent-sandbox.url = "github:archie-judd/agent-sandbox.nix";
Pass inputs to your modules (specialArgs for NixOS and Nix Darwin, extraSpecialArgs for Home Manager), then build the wrapper and install it:
{ pkgs, inputs, ... }:
let
mkSandbox = inputs.agent-sandbox.lib.${pkgs.system}.mkSandbox;
commonTools = inputs.agent-sandbox.lib.${pkgs.system}.commonTools;
claude-sandboxed = mkSandbox {
pkg = pkgs.claude-code;
binName = "claude";
outName = "claude-sandboxed";
allowedPackages = commonTools;
rwDirs = [ "$HOME/.claude" ];
roFiles = [ "$HOME/.config/git/config" ];
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN";
CLAUDE_CONFIG_DIR = "$HOME/.claude";
};
};
in
{
home.packages = [ claude-sandboxed ];
}
On NixOS and Nix Darwin, use environment.systemPackages in place of home.packages. See Arguments for the full argument list.
Values in env are shell expressions that expand when the wrapper launches, so $CLAUDE_CODE_OAUTH_TOKEN has to be set in the shell you run claude-sandboxed from. There is no dev shell to set it here, so export it from your shell profile, or read the secret at runtime as described in Authentication.
The sandbox controls network access with three independent settings. allowedDomains controls outbound internet access. allowedHostPorts controls access to host-local TCP services, such as databases and dev servers. publishedPorts controls which sandbox-hosted TCP services are reachable from outside.
By default, internet access is open, all host-local services are blocked, and nothing inside the sandbox is reachable from outside.
To restrict internet access, set allowedDomains, which routes HTTP and HTTPS traffic through a filtering proxy. The sandbox can then reach only the domains you list. Leave it unset for open internet, or set it to [ ] to block all internet access.
allowedDomains accepts two formats:
"*" (all HTTP methods allowed) or to a list of permitted methods (for example [ "GET" "HEAD" ]).[ "anthropic.com" "sentry.io" ]. This allows all methods for each domain.Domains must be ASCII. Write an internationalized domain in its punycode form (xn--...): the proxy refuses a request whose host is not ASCII, and logs a warning at startup for an allowlist entry that is not, since such an entry can never match.
The sandbox matches domains by suffix, so "anthropic.com" also matches all *.anthropic.com subdomains. One entry decides each request, and entries never combine: the exact domain, else the longest matching suffix, else "*", else blocked. So in { "github.com" = [ "GET" "HEAD" "POST" ]; "*" = [ "GET" "HEAD" ]; }, github.com matches its own entry and may POST, while every other domain on the internet gets GET and HEAD.
To route everything through the proxy and allow everything, use { "*" = "*"; }. This is useful for deriving a network allowlist, but it is not a restriction. The proxy warns at startup when a "*" entry is present, naming what it permits.
WebSocket connections are permitted only to a domain whose policy is "*".
The proxy logs each allowed or denied host contact to proxy.log in the sandbox's session directory. Only the first allow is logged to reduce noise.
Host-local services (databases, dev servers, the SSH agent, the Docker socket, and similar) are blocked by default. Use allowedHostPorts to permit access to specific ports:
allowedHostPorts = [ 3000 5432 ];
Set allowedHostPorts = null; to allow all host-local TCP ports.
For a worked example, see shells/opencode-ollama.shell.nix, where the agent has no internet access at all and reaches only Ollama running on the host.
On macOS, a service started inside the sandbox also needs its port listed here, because sandbox-exec shares localhost with the host and cannot tell the two apart. See Linux vs macOS.
Sometimes something outside the sandbox must call INTO it. For example, an integration-test suite that hosts a callback server needs this, and so does a dev server you want to open in the host browser. Declare the ports with publishedPorts:
publishedPorts = [
3000 # host 127.0.0.1:3000 → sandbox :3000
{ port = 8000; bindAddr = "0.0.0.0"; } # reachable from anything that can reach the host
];
The default bindAddr is 127.0.0.1 which is reachable from host processes only. A wider address exposes whatever the agent runs on that port to everything that can reach that address. Use the narrowest bindAddr that serves the caller.
For a worked example, see shells/claude-docker.shell.nix, where a docker container on the host reaches a dev server running in the sandbox.
UNIX-domain sockets are denied by default, because a sandboxed process could use host sockets to reach your SSH agent or other per-user services. Set allowUnixSockets = true to permit them. Build tools that communicate over a domain socket (sbt/BSP, metals, nailgun) need this setting.
Socket access then follows the filesystem grants on both platforms. In paths the agent can write (the launch directory and rwDirs), the agent can create sockets and connect to them. In read-only paths (roDirs, roFiles, and the repository root when you launch from a subdirectory), the agent can only connect.
allowNix = true requires allowUnixSockets = true, because the agent reaches the nix daemon over a UNIX-domain socket.
The sandbox masks $HOME, so agents cannot reach your system keychain, browser sessions, or SSH keys. A launch from your home directory is the exception, and exposes all of it. The recommended method is to authenticate with an environment variable. Interactive login flows (for example claude /login and gh auth login) may not work inside the sandbox.
Export your token in the host terminal before you launch the sandbox. The sandbox reads tokens at runtime, so they do not leak into the Nix store:
# Claude Code
export CLAUDE_CODE_OAUTH_TOKEN="<your_token_here>"
# GitHub Copilot CLI
export GITHUB_TOKEN="<your_token_here>"
Pass the variable reference, not the value, into env:
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN";
...
};
If you store your secret in a file instead (for example with sops), you can set a command that reads the secret at runtime:
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$(${pkgs.coreutils}/bin/cat /run/secrets/claude-code-oauth-token)";
...
};
rwDirsIf your agent stores credentials in files (Claude Code uses ~/.claude/), run the login flow outside the sandbox first. Then expose the credential directory with rwDirs. The sandboxed agent reads the cached credentials.
On macOS, Claude Code stores credentials in the system Keychain, not in files. The sandbox cannot read the Keychain, so the environment variable method above is the simplest option.
If you cannot use an environment variable token, you can export the Keychain credentials to a file that the sandbox can read:
# Log in outside the sandbox first
claude /login
# Then export credentials from Keychain to a file the sandbox can read
security dump-keychain 2>&1 \
| grep -o 'Claude Code-credentials[^"]*' \
| sort -u \
| while read entry; do
security find-generic-password -a "$USER" -s "$entry" -w 2>/dev/null
done \
| python3 -c "
import sys, json
most_recent = None
for line in sys.stdin:
try:
creds = json.loads(line.strip())
exp = creds.get('claudeAiOauth', {}).get('expiresAt', 0)
if most_recent is None or exp > most_recent[1]:
most_recent = (line.strip(), exp)
except: pass
if most_recent: print(most_recent[0])
" > ~/.claude/.credentials.json
This finds all Claude Code credential entries in the Keychain and exports the entry with the most recent expiry.
Then expose ~/.claude with rwDirs. The sandboxed agent reads credentials from ~/.claude/.credentials.json when it cannot reach the Keychain.
Note: OAuth access tokens expire. Run the export command again from time to time to refresh the credentials file.
Local git operations work with no extra configuration. The agent can switch branches, read history, and commit. A commit needs a declared git identity.
The sandbox masks $HOME, so git cannot read your global gitconfig, and user.name and user.email are unset. If you declare no identity, git commit fails loudly (fatal: ... auto-detection is disabled).
You can declare a git identity in one of two ways:
Bind your host gitconfig read-only with roFiles (recommended). Set your identity on the host (git config --global user.name "..."; git config --global user.email "..."), then add:
roFiles = [ "$HOME/.config/git/config" ]; # or "$HOME/.gitconfig"
With env (to set a custom identity, or if you cannot bind a host file):
env = {
GIT_AUTHOR_NAME = "Your Name";
GIT_AUTHOR_EMAIL = "you@example.com";
GIT_COMMITTER_NAME = "Your Name";
GIT_COMMITTER_EMAIL = "you@example.com";
};
If there is a repo, the sandbox exposes these paths:
| Path | Access |
|---|---|
| The launch directory | read-write |
| The root .git directory | read-write, except the read-only paths |
| The working tree root | read-only, and only when it is not the launch directory |
The working tree root is the root of the tree you launched in. It is not the root of the repo above it:
When launched in a subdirectory of the working tree, readonly access to the whole worktree is required to let git report on files above the launch directory. Without it, git status and git diff report those files as deleted.
Remote operations need authentication. Use HTTPS remotes rather than SSH remotes. The simplest method to authenticate HTTPS operations is to provide the GITHUB_TOKEN environment variable. You can also configure a git credential helper that stores your token for reuse, so that you do not need to pass it through an environment variable.
SSH remotes (for example git@github.com:...) do not work by default. The sandbox masks $HOME, so the agent cannot read your SSH keys. When you set allowedDomains, the proxy handles only HTTP and HTTPS, so it blocks all SSH traffic. To use SSH remotes, expose your SSH directory with rwDirs (for example $HOME/.ssh), and leave allowedDomains unset for open network access. This is not recommended.
Some paths inside the git directory are read-only inside the sandbox: hooks/, config, config.worktree, objects/info/alternates, and the pointer files that record the location of a worktree's or a submodule's git directory. This is a security measure. See Security.
All other paths stay writable, so commits, fetches, branch switches and history reads work as normal. Two operations do not work:
git config cannot write to the repo config. Set repo-level config on the host instead.git worktree remove and git worktree prune fail, because the protected pointer file makes the worktree directory impossible to remove. Run these commands on the host.Set allowNix = true to let the agent run nix commands inside the sandbox. The sandbox gives the agent access to the host's nix daemon and the full nix store. pkgs.nix is added to the agent's PATH, so you do not put it in allowedPackages. The agent reaches the daemon over a UNIX-domain socket, so allowNix = true requires allowUnixSockets = true. See UNIX-domain sockets.
This needs a multi-user nix install with the daemon running. The launcher looks for the daemon socket at /nix/var/nix/daemon-socket/socket, or at $NIX_DAEMON_SOCKET_PATH when you set it, and refuses the launch if there is no socket there. A single-user install cannot be supported: building without a daemon would need the store bound read-write, which would let the agent rewrite any package the host runs.
The launcher then asks the host two questions about that daemon. Both are about the host's own nix configuration, not this sandbox's, and both are read with $NIX_CONFIG, $NIX_CONF_DIR and your own nix.conf ignored, because the daemon never read them either.
Are you a trusted user? The sandbox keeps your uid, and the daemon authenticates its socket by uid, so an agent that reaches the daemon has whatever trust you have. Nix documents membership of trusted-users as "essentially equivalent to giving that user root access to the system", because a trusted client can set daemon settings such as sandbox and builders. The launch is refused, and is refused the same way if the daemon cannot be asked. Remove yourself from trusted-users, or launch without allowNix.
Does the daemon sandbox its builds? With sandbox = false a builder runs outside this sandbox with the build user's access to the host filesystem; with sandbox = relaxed a derivation can opt out, and the agent is the one writing the derivations. Either way the launcher warns and asks for confirmation on /dev/tty, and refuses when there is no terminal to ask on. sandbox defaults to true on Linux and false everywhere else, so on macOS this asks until you set sandbox = true on the host. It is a daemon setting: a client cannot override it, so it has to be set in the host's nix configuration and the daemon restarted.
What you need to configure:
Flake CLI features: the sandbox does not expose your nix config. Bind it with roFiles = [ "/etc/nix/nix.conf" ] to inherit your whole config. Alternatively, set env.NIX_CONFIG = "experimental-features = nix-command flakes" to enable only the flake CLI.
Nix state directories: the client caches the flake registry and downloaded tarballs in $HOME/.cache/nix. It writes registry overrides to $HOME/.config/nix. It stores per-user profiles in $HOME/.local/share/nix. Add these directories to rwDirs if you want that state to persist between launches.
Allowed domains: when you set allowedDomains, the nix client itself needs channels.nixos.org, github.com, raw.githubusercontent.com, and cache.nixos.org to fetch packages and flakes reliably.
A complete example is at shells/claude-nix.shell.nix.
Security note:
allowNix = trueweakens the security posture of the sandbox. The full Nix store is exposed, and the agent can run any executable in it.allowedPackagesthen limits only what is onPATH, not what the agent can execute. Thenix-daemonruns outside the sandbox, so its own network activity does not obeyallowedDomains. This activity includes downloads of prebuilt packages from the caches in the daemon's configuration.
If you have a problem, or you think the agent cannot access a file or folder that the defaults should permit, please raise an issue. The most useful attachment is the session directory described below.
Every launch writes a directory that records what it did. The location is $XDG_STATE_HOME/agent-sandbox, or ~/.local/state/agent-sandbox if $XDG_STATE_HOME is unset. The name of each directory is <timestamp>-<pid>-<outName>, so the newest is last:
ls -t ~/.local/state/agent-sandbox | head
Read launch.log first. It records:
The other files hold the configuration that the launch was assembled from, so they also show what was allowed:
| File | Platform | What it holds |
|---|---|---|
launch.log | both | What was requested, what was decided, how it ended |
proxy.log | both | The filtering proxy's output: the hosts it allowed, and everything it blocked |
seatbelt.sb | macOS | The seatbelt profile that sandbox-exec enforced |
bwrap.args | Linux | The bubblewrap arguments, including every bind |
network.json | Linux | The firewall rules and the routing applied to the sandbox |
The sandbox keeps the directories of the newest 25 launches, and prunes the others at the next launch. It never prunes the directory of a session whose sandbox still runs, whatever its age.
To watch the proxy reject domains as they happen:
tail -f "$(ls -dt ~/.local/state/agent-sandbox/* | head -1)/proxy.log"
A session directory holds no secrets, so it is safe to attach to an issue.
launch.log records what the sandbox was configured to allow. To see what a process actually hits, wrap bash itself with the same config as your agent, and explore. debug/bash.shell.nix is a template you can use directly. Copy your agent's rwDirs, rwFiles, allowedPackages, and allowedDomains into it, then run nix-shell debug/bash.shell.nix.
The shell has exactly the same filesystem view and the same restrictions as your agent. Try these:
ls $HOME/.claude # should work if in rwDirs (symlinked)
cat ~/.ssh/id_ed25519 # should fail: undeclared files in $HOME are not readable
which git # allowedPackages should be on PATH
curl https://example.com # should fail if not in allowedDomains
If a path the agent needs is blocked, add it to rwDirs or rwFiles, or to roDirs or roFiles for read-only access.
The proxy logs all of the domains it allows or blocks, so you can build an allowlist from a real session rather than by guesswork. Watch the log while you use the agent:
tail -f "$(ls -dt ~/.local/state/agent-sandbox/* | head -1)/proxy.log"
The quickest way to see the whole picture is to allow all domains:
allowedDomains = { "*" = "*"; };
The agent works normally, and the log names each host on first contact. Replace the "*" entry with what you saw, then run again and confirm nothing is blocked.
Keep the methods as narrow as the agent allows. Use [ "GET" "HEAD" ] for anything it only reads from. An agent that uses WebSockets (Codex does) needs "*" for that domain.
launch.log and seatbelt.sb show the profile that sandbox-exec enforced, not which rule a process tripped. For that, query the system log after a failure:
log show --predicate 'eventMessage CONTAINS "deny"' --last 1m
Nothing in the session directory records this, so pair the log with seatbelt.sb when something your config should allow is blocked.
On macOS, when you set allowedDomains, gh (the GitHub CLI) fails HTTPS requests with a certificate error. The filtering proxy uses its own certificate. git accepts this certificate, but gh and other Go tools reject it on macOS. There is an issue here for this on the gh repo. Linux is unaffected. The workaround is to use curl instead - most agents will figure this out themselves.
This section describes what the sandbox protects against, and what it does not protect against, so that you can decide whether it fits your situation. It assumes that you launch the agent from a project directory. A launch from $HOME turns off home masking entirely, and the sandbox asks for your permission first.
The agent can do something it should not do. It can run a bad prompt, process a malicious file, use a compromised dependency, or invent a destructive command. In each case, the sandbox keeps the damage inside the project directory. In detail:
rwDirs and rwFiles.allowedDomains.allowedHostPorts.hooks/, a core.hooksPath or alias.* entry in a config file, or a pointer file aimed at a git directory the agent controls.objects/info/alternates tells git to look for objects in another directory as well as your own and is not writable.allowedPackages, unless you set allowNix = true. See Using Nix inside the sandbox.allowedPackages, unless you set allowNix = true.The sandbox is an isolation boundary. It is not an anonymity boundary, and it is not a defense against an attacker who has already taken over your machine in some other way.
$HOME/.claude resolves to. If your username is itself sensitive, this is not the right tool.allowNix = true, all of /nix/store is readable and executable, not only your allowed packages, so the agent can list every package you have built. The Nix store is normally world-readable on any system, so this matches existing behavior.See Using Nix inside the sandbox.~/.claude directory (or any credential file) through rwDirs, or pass a token through env, the agent can read it. That is how it logs in. A compromised agent has the same access to those credentials as your shell. Treat this the way you would treat handing the token to any other CLI tool you did not write yourself.flake.nix lives inside the project directory, and the sandbox permits writes to it. An agent could weaken its own restrictions for the next session. The changes take effect only when you enter the dev shell again, so it is worth reading git diff first.Both platforms enforce the same default protections. The one practical difference is localhost. On Linux, bubblewrap gives the sandbox its own network namespace, so services started inside the sandbox can reach each other on any localhost port. On macOS, sandbox-exec shares localhost with the host. Localhost communication inside the sandbox therefore needs the port in allowedHostPorts, or all host-local ports allowed with allowedHostPorts = null;. The same access also opens those host-local ports.
If your threat model is "I want my AI agent to not accidentally destroy my work, leak my private files, or talk to random places on the internet," this sandbox is a good fit.
If your threat model is "I assume the agent is actively malicious and need it to be unable to identify my specific machine or my real user account," you want a VM with a throwaway user account, or a separate machine.
sandbox-exec is deprecated on macOS. It remains the only native unprivileged sandboxing mechanism. It currently works on macOS 26 (Tahoe) and older, but a future release may break it.There are several other tools for sandboxing AI agents. Here are a few:
Anthropic sandbox-runtime (srt): an npm package that also uses bubblewrap on Linux and sandbox-exec on macOS.
jail.nix: a nix library that builds bubblewrap sandboxes. It is not agent-specific, but you can use it to sandbox agents. Linux only.
jailed-agents: a nix library that provides pre-configured per-agent sandboxes with bubblewrap. Linux only.
agent-box: a Rust CLI that uses disposable containers with Jujutsu or Git worktrees. macOS and Linux.
ai-jail: a Rust CLI that sandboxes agents with bubblewrap (with Landlock and seccomp) on Linux and sandbox-exec on macOS. It is configured with a TOML file in the project directory.
Shell
45.1%
Python
30.9%
Nix
12.8%
Go
11.2%
Lightweight and declarative sandboxing for AI agents on Linux and macOS using Nix.
Shell
162
377 commits
updated Sep 18, 2026
Declarative sandboxing for AI agents on Linux and macOS.
Stop your agents in YOLO mode from deleting your $HOME, force pushing to main, or publishing your ssh keys on reddit. The sandbox works with any CLI-based AI agent. The repository provides templates for common agent CLIs.
The sandbox uses bubblewrap on Linux and sandbox-exec on macOS.
See Security for the threat model and the known limits.
rwDirs / rwFiles, or read-only access through roDirs / roFiles.allowedPackages are on the agent's PATH, together with bash and cacert.allowedDomains. Ports are closed by default, but may be optionally exposed or connected to.Everything else is denied. Only changes to the launch directory and declared rwDirs/files are persisted - the agent's home directory and anything else it writes are discarded upon exit.
To get started quickly, use a flake template. If you do not use flakes, shells/claude.shell.nix is the same setup written as a plain shell.nix. The rest of shells/ holds worked examples for narrower setups, linked from the sections they illustrate.
The following flake templates are provided for quick setup:
| Template | Agent | Wrapped binary |
|---|---|---|
claude | Claude Code | claude-sandboxed |
codex | Codex | codex-sandboxed |
copilot | GitHub Copilot CLI | copilot-sandboxed |
gemini | Gemini | gemini-sandboxed |
opencode | OpenCode | opencode-sandboxed |
pi | Pi | pi-sandboxed |
To initialize a template in your project directory:
nix flake init -t github:archie-judd/agent-sandbox.nix#<template>
The command creates a flake.nix in your project. Edit the file for your needs, and export your access token. Then enter the dev shell and run your wrapped binary:
nix develop
claude-sandboxed
To keep the original command name as the alias, change the outName value, for example to "claude".
If your preferred agent does not have a template, please adapt one to your needs and consider contributing it to the repository!
pkg, binName, outName, allowedDomains and the rwDirs the agent needs for its config and cache.templates in flake.nix.Most agents need nothing beyond their template. This table details agent-specific gotchas.
| Agent | Note |
|---|---|
| Claude Code | Set CLAUDE_CONFIG_DIR to the rwDir that holds Claude's state, rather than declaring ~/.claude.json as an rwFile. See the note below the table. |
| Codex | Codex sandboxes itself, and the two sandboxes cannot nest. Run codex-sandboxed -s danger-full-access and let this sandbox do the work. Without it, every command fails with Failed to create unified exec process: Operation not permitted. |
CLAUDE_CONFIG_DIR and not add ~/.claude.json as a rwFile?Set CLAUDE_CONFIG_DIR to $HOME/.claude, so that Claude writes ~/.claude.json inside the read/write rwDir. If you add ~/.claude.json as a rwFile instead, Claude writes temporary files to the ephemeral home root when it updates its configuration. Claude then tries to rename these files to ~/.claude.json. The rename can fail, or behave in an unexpected way, because the temporary files land outside every declared rwDir and rwFile. This can sometimes corrupt the ~/.claude.json file.
Note: If you also run Claude outside the sandbox, set
CLAUDE_CONFIG_DIR=$HOME/.claudeglobally too. Otherwise the two use different config locations and diverge.
mkSandbox, the library's entrypoint, accepts the following arguments:
| Argument | Required | Description |
|---|---|---|
pkg | yes | Package that contains the binary to wrap |
binName | yes | Name of the binary inside pkg/bin/ |
outName | yes | Name of the wrapped binary, and the command that runs it |
allowedPackages | yes | Packages the agent can execute and build against |
rwDirs | no | Directories the agent can read and write (for example ~/.config/claude, or a package manager's cache: see shells/claude-uv.shell.nix) |
rwFiles | no | Individual files the agent can read and write |
roDirs | no | Directories the agent can read but not write (for example signed binaries, reference source trees, secret stores) |
roFiles | no | Individual files the agent can read but not write (for example ~/.config/git/config for the git identity, see Setting your git identity |
env | no | Additional environment variables, as an attrset |
allowedDomains | no | Limits the domains the sandbox can reach. Leave it unset for open internet. Accepts a list of domains (all methods allowed), or an attrset that maps each domain to "*" or to a list of HTTP methods. [ ] blocks all internet access. |
allowUnixSockets | no | If true, the agent can create and connect to UNIX-domain (AF_UNIX) sockets. It can connect in directories it can read, and bind in directories it can write. Defaults to false. See UNIX-domain sockets. |
allowedHostPorts | no | Host-local TCP ports the sandbox can reach. Defaults to [ ]. Set it to null to allow all host-local TCP ports. Otherwise, entries must be integers from 1 to 65535. |
publishedPorts | no | Host TCP ports forwarded INTO the sandbox, so services the agent runs are reachable from outside. Defaults to [ ]. Entries are an integer port (bound to 127.0.0.1) or { port = <int>; bindAddr = "<ipv4>"; }. There is no null form. See Published ports. |
allowNix | no | If true, the sandbox exposes the host's nix-daemon socket and the full Nix store. The agent can then run nix build, nix run, nix develop, and similar commands. The sandbox adds pkgs.nix to PATH. Requires allowUnixSockets = true and a running nix-daemon. The launch is refused if you are one of the daemon's trusted-users, and asks for confirmation if the daemon does not sandbox its builds. Defaults to false. See Using Nix inside the sandbox. |
The library also exports commonTools, a list of standard CLI tools. See default.nix for the full list.
A minimal example. The arguments are the same for a flake and for a shell.nix:
mkSandbox {
pkg = pkgs.claude-code;
binName = "claude";
outName = "claude-sandboxed";
allowedPackages = commonTools; # or e.g. commonTools ++ [ pkgs.nodejs ]
rwDirs = [ "$HOME/.claude" ];
roFiles = [ "$HOME/.config/git/config" ];
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN";
CLAUDE_CONFIG_DIR = "$HOME/.claude";
};
allowedDomains = {
"anthropic.com" = "*";
"claude.com" = "*";
"github.com" = ["GET" "HEAD"];
"githubusercontent.com" = ["GET" "HEAD"];
};
}
Why the example sets CLAUDE_CONFIG_DIR is explained in Agent notes.
A template dev shell configures the sandbox per project. To have one sandboxed agent everywhere instead, build the wrapper in your NixOS, Nix Darwin or Home Manager configuration and install it into your profile. The sandbox scopes itself to the directory you launch it in, so a single wrapper serves every project. The tradeoff is one configuration for all of them: rwDirs, allowedPackages and allowedDomains no longer vary by project.
Add the flake as an input:
inputs.agent-sandbox.url = "github:archie-judd/agent-sandbox.nix";
Pass inputs to your modules (specialArgs for NixOS and Nix Darwin, extraSpecialArgs for Home Manager), then build the wrapper and install it:
{ pkgs, inputs, ... }:
let
mkSandbox = inputs.agent-sandbox.lib.${pkgs.system}.mkSandbox;
commonTools = inputs.agent-sandbox.lib.${pkgs.system}.commonTools;
claude-sandboxed = mkSandbox {
pkg = pkgs.claude-code;
binName = "claude";
outName = "claude-sandboxed";
allowedPackages = commonTools;
rwDirs = [ "$HOME/.claude" ];
roFiles = [ "$HOME/.config/git/config" ];
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN";
CLAUDE_CONFIG_DIR = "$HOME/.claude";
};
};
in
{
home.packages = [ claude-sandboxed ];
}
On NixOS and Nix Darwin, use environment.systemPackages in place of home.packages. See Arguments for the full argument list.
Values in env are shell expressions that expand when the wrapper launches, so $CLAUDE_CODE_OAUTH_TOKEN has to be set in the shell you run claude-sandboxed from. There is no dev shell to set it here, so export it from your shell profile, or read the secret at runtime as described in Authentication.
The sandbox controls network access with three independent settings. allowedDomains controls outbound internet access. allowedHostPorts controls access to host-local TCP services, such as databases and dev servers. publishedPorts controls which sandbox-hosted TCP services are reachable from outside.
By default, internet access is open, all host-local services are blocked, and nothing inside the sandbox is reachable from outside.
To restrict internet access, set allowedDomains, which routes HTTP and HTTPS traffic through a filtering proxy. The sandbox can then reach only the domains you list. Leave it unset for open internet, or set it to [ ] to block all internet access.
allowedDomains accepts two formats:
"*" (all HTTP methods allowed) or to a list of permitted methods (for example [ "GET" "HEAD" ]).[ "anthropic.com" "sentry.io" ]. This allows all methods for each domain.Domains must be ASCII. Write an internationalized domain in its punycode form (xn--...): the proxy refuses a request whose host is not ASCII, and logs a warning at startup for an allowlist entry that is not, since such an entry can never match.
The sandbox matches domains by suffix, so "anthropic.com" also matches all *.anthropic.com subdomains. One entry decides each request, and entries never combine: the exact domain, else the longest matching suffix, else "*", else blocked. So in { "github.com" = [ "GET" "HEAD" "POST" ]; "*" = [ "GET" "HEAD" ]; }, github.com matches its own entry and may POST, while every other domain on the internet gets GET and HEAD.
To route everything through the proxy and allow everything, use { "*" = "*"; }. This is useful for deriving a network allowlist, but it is not a restriction. The proxy warns at startup when a "*" entry is present, naming what it permits.
WebSocket connections are permitted only to a domain whose policy is "*".
The proxy logs each allowed or denied host contact to proxy.log in the sandbox's session directory. Only the first allow is logged to reduce noise.
Host-local services (databases, dev servers, the SSH agent, the Docker socket, and similar) are blocked by default. Use allowedHostPorts to permit access to specific ports:
allowedHostPorts = [ 3000 5432 ];
Set allowedHostPorts = null; to allow all host-local TCP ports.
For a worked example, see shells/opencode-ollama.shell.nix, where the agent has no internet access at all and reaches only Ollama running on the host.
On macOS, a service started inside the sandbox also needs its port listed here, because sandbox-exec shares localhost with the host and cannot tell the two apart. See Linux vs macOS.
Sometimes something outside the sandbox must call INTO it. For example, an integration-test suite that hosts a callback server needs this, and so does a dev server you want to open in the host browser. Declare the ports with publishedPorts:
publishedPorts = [
3000 # host 127.0.0.1:3000 → sandbox :3000
{ port = 8000; bindAddr = "0.0.0.0"; } # reachable from anything that can reach the host
];
The default bindAddr is 127.0.0.1 which is reachable from host processes only. A wider address exposes whatever the agent runs on that port to everything that can reach that address. Use the narrowest bindAddr that serves the caller.
For a worked example, see shells/claude-docker.shell.nix, where a docker container on the host reaches a dev server running in the sandbox.
UNIX-domain sockets are denied by default, because a sandboxed process could use host sockets to reach your SSH agent or other per-user services. Set allowUnixSockets = true to permit them. Build tools that communicate over a domain socket (sbt/BSP, metals, nailgun) need this setting.
Socket access then follows the filesystem grants on both platforms. In paths the agent can write (the launch directory and rwDirs), the agent can create sockets and connect to them. In read-only paths (roDirs, roFiles, and the repository root when you launch from a subdirectory), the agent can only connect.
allowNix = true requires allowUnixSockets = true, because the agent reaches the nix daemon over a UNIX-domain socket.
The sandbox masks $HOME, so agents cannot reach your system keychain, browser sessions, or SSH keys. A launch from your home directory is the exception, and exposes all of it. The recommended method is to authenticate with an environment variable. Interactive login flows (for example claude /login and gh auth login) may not work inside the sandbox.
Export your token in the host terminal before you launch the sandbox. The sandbox reads tokens at runtime, so they do not leak into the Nix store:
# Claude Code
export CLAUDE_CODE_OAUTH_TOKEN="<your_token_here>"
# GitHub Copilot CLI
export GITHUB_TOKEN="<your_token_here>"
Pass the variable reference, not the value, into env:
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN";
...
};
If you store your secret in a file instead (for example with sops), you can set a command that reads the secret at runtime:
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$(${pkgs.coreutils}/bin/cat /run/secrets/claude-code-oauth-token)";
...
};
rwDirsIf your agent stores credentials in files (Claude Code uses ~/.claude/), run the login flow outside the sandbox first. Then expose the credential directory with rwDirs. The sandboxed agent reads the cached credentials.
On macOS, Claude Code stores credentials in the system Keychain, not in files. The sandbox cannot read the Keychain, so the environment variable method above is the simplest option.
If you cannot use an environment variable token, you can export the Keychain credentials to a file that the sandbox can read:
# Log in outside the sandbox first
claude /login
# Then export credentials from Keychain to a file the sandbox can read
security dump-keychain 2>&1 \
| grep -o 'Claude Code-credentials[^"]*' \
| sort -u \
| while read entry; do
security find-generic-password -a "$USER" -s "$entry" -w 2>/dev/null
done \
| python3 -c "
import sys, json
most_recent = None
for line in sys.stdin:
try:
creds = json.loads(line.strip())
exp = creds.get('claudeAiOauth', {}).get('expiresAt', 0)
if most_recent is None or exp > most_recent[1]:
most_recent = (line.strip(), exp)
except: pass
if most_recent: print(most_recent[0])
" > ~/.claude/.credentials.json
This finds all Claude Code credential entries in the Keychain and exports the entry with the most recent expiry.
Then expose ~/.claude with rwDirs. The sandboxed agent reads credentials from ~/.claude/.credentials.json when it cannot reach the Keychain.
Note: OAuth access tokens expire. Run the export command again from time to time to refresh the credentials file.
Local git operations work with no extra configuration. The agent can switch branches, read history, and commit. A commit needs a declared git identity.
The sandbox masks $HOME, so git cannot read your global gitconfig, and user.name and user.email are unset. If you declare no identity, git commit fails loudly (fatal: ... auto-detection is disabled).
You can declare a git identity in one of two ways:
Bind your host gitconfig read-only with roFiles (recommended). Set your identity on the host (git config --global user.name "..."; git config --global user.email "..."), then add:
roFiles = [ "$HOME/.config/git/config" ]; # or "$HOME/.gitconfig"
With env (to set a custom identity, or if you cannot bind a host file):
env = {
GIT_AUTHOR_NAME = "Your Name";
GIT_AUTHOR_EMAIL = "you@example.com";
GIT_COMMITTER_NAME = "Your Name";
GIT_COMMITTER_EMAIL = "you@example.com";
};
If there is a repo, the sandbox exposes these paths:
| Path | Access |
|---|---|
| The launch directory | read-write |
| The root .git directory | read-write, except the read-only paths |
| The working tree root | read-only, and only when it is not the launch directory |
The working tree root is the root of the tree you launched in. It is not the root of the repo above it:
When launched in a subdirectory of the working tree, readonly access to the whole worktree is required to let git report on files above the launch directory. Without it, git status and git diff report those files as deleted.
Remote operations need authentication. Use HTTPS remotes rather than SSH remotes. The simplest method to authenticate HTTPS operations is to provide the GITHUB_TOKEN environment variable. You can also configure a git credential helper that stores your token for reuse, so that you do not need to pass it through an environment variable.
SSH remotes (for example git@github.com:...) do not work by default. The sandbox masks $HOME, so the agent cannot read your SSH keys. When you set allowedDomains, the proxy handles only HTTP and HTTPS, so it blocks all SSH traffic. To use SSH remotes, expose your SSH directory with rwDirs (for example $HOME/.ssh), and leave allowedDomains unset for open network access. This is not recommended.
Some paths inside the git directory are read-only inside the sandbox: hooks/, config, config.worktree, objects/info/alternates, and the pointer files that record the location of a worktree's or a submodule's git directory. This is a security measure. See Security.
All other paths stay writable, so commits, fetches, branch switches and history reads work as normal. Two operations do not work:
git config cannot write to the repo config. Set repo-level config on the host instead.git worktree remove and git worktree prune fail, because the protected pointer file makes the worktree directory impossible to remove. Run these commands on the host.Set allowNix = true to let the agent run nix commands inside the sandbox. The sandbox gives the agent access to the host's nix daemon and the full nix store. pkgs.nix is added to the agent's PATH, so you do not put it in allowedPackages. The agent reaches the daemon over a UNIX-domain socket, so allowNix = true requires allowUnixSockets = true. See UNIX-domain sockets.
This needs a multi-user nix install with the daemon running. The launcher looks for the daemon socket at /nix/var/nix/daemon-socket/socket, or at $NIX_DAEMON_SOCKET_PATH when you set it, and refuses the launch if there is no socket there. A single-user install cannot be supported: building without a daemon would need the store bound read-write, which would let the agent rewrite any package the host runs.
The launcher then asks the host two questions about that daemon. Both are about the host's own nix configuration, not this sandbox's, and both are read with $NIX_CONFIG, $NIX_CONF_DIR and your own nix.conf ignored, because the daemon never read them either.
Are you a trusted user? The sandbox keeps your uid, and the daemon authenticates its socket by uid, so an agent that reaches the daemon has whatever trust you have. Nix documents membership of trusted-users as "essentially equivalent to giving that user root access to the system", because a trusted client can set daemon settings such as sandbox and builders. The launch is refused, and is refused the same way if the daemon cannot be asked. Remove yourself from trusted-users, or launch without allowNix.
Does the daemon sandbox its builds? With sandbox = false a builder runs outside this sandbox with the build user's access to the host filesystem; with sandbox = relaxed a derivation can opt out, and the agent is the one writing the derivations. Either way the launcher warns and asks for confirmation on /dev/tty, and refuses when there is no terminal to ask on. sandbox defaults to true on Linux and false everywhere else, so on macOS this asks until you set sandbox = true on the host. It is a daemon setting: a client cannot override it, so it has to be set in the host's nix configuration and the daemon restarted.
What you need to configure:
Flake CLI features: the sandbox does not expose your nix config. Bind it with roFiles = [ "/etc/nix/nix.conf" ] to inherit your whole config. Alternatively, set env.NIX_CONFIG = "experimental-features = nix-command flakes" to enable only the flake CLI.
Nix state directories: the client caches the flake registry and downloaded tarballs in $HOME/.cache/nix. It writes registry overrides to $HOME/.config/nix. It stores per-user profiles in $HOME/.local/share/nix. Add these directories to rwDirs if you want that state to persist between launches.
Allowed domains: when you set allowedDomains, the nix client itself needs channels.nixos.org, github.com, raw.githubusercontent.com, and cache.nixos.org to fetch packages and flakes reliably.
A complete example is at shells/claude-nix.shell.nix.
Security note:
allowNix = trueweakens the security posture of the sandbox. The full Nix store is exposed, and the agent can run any executable in it.allowedPackagesthen limits only what is onPATH, not what the agent can execute. Thenix-daemonruns outside the sandbox, so its own network activity does not obeyallowedDomains. This activity includes downloads of prebuilt packages from the caches in the daemon's configuration.
If you have a problem, or you think the agent cannot access a file or folder that the defaults should permit, please raise an issue. The most useful attachment is the session directory described below.
Every launch writes a directory that records what it did. The location is $XDG_STATE_HOME/agent-sandbox, or ~/.local/state/agent-sandbox if $XDG_STATE_HOME is unset. The name of each directory is <timestamp>-<pid>-<outName>, so the newest is last:
ls -t ~/.local/state/agent-sandbox | head
Read launch.log first. It records:
The other files hold the configuration that the launch was assembled from, so they also show what was allowed:
| File | Platform | What it holds |
|---|---|---|
launch.log | both | What was requested, what was decided, how it ended |
proxy.log | both | The filtering proxy's output: the hosts it allowed, and everything it blocked |
seatbelt.sb | macOS | The seatbelt profile that sandbox-exec enforced |
bwrap.args | Linux | The bubblewrap arguments, including every bind |
network.json | Linux | The firewall rules and the routing applied to the sandbox |
The sandbox keeps the directories of the newest 25 launches, and prunes the others at the next launch. It never prunes the directory of a session whose sandbox still runs, whatever its age.
To watch the proxy reject domains as they happen:
tail -f "$(ls -dt ~/.local/state/agent-sandbox/* | head -1)/proxy.log"
A session directory holds no secrets, so it is safe to attach to an issue.
launch.log records what the sandbox was configured to allow. To see what a process actually hits, wrap bash itself with the same config as your agent, and explore. debug/bash.shell.nix is a template you can use directly. Copy your agent's rwDirs, rwFiles, allowedPackages, and allowedDomains into it, then run nix-shell debug/bash.shell.nix.
The shell has exactly the same filesystem view and the same restrictions as your agent. Try these:
ls $HOME/.claude # should work if in rwDirs (symlinked)
cat ~/.ssh/id_ed25519 # should fail: undeclared files in $HOME are not readable
which git # allowedPackages should be on PATH
curl https://example.com # should fail if not in allowedDomains
If a path the agent needs is blocked, add it to rwDirs or rwFiles, or to roDirs or roFiles for read-only access.
The proxy logs all of the domains it allows or blocks, so you can build an allowlist from a real session rather than by guesswork. Watch the log while you use the agent:
tail -f "$(ls -dt ~/.local/state/agent-sandbox/* | head -1)/proxy.log"
The quickest way to see the whole picture is to allow all domains:
allowedDomains = { "*" = "*"; };
The agent works normally, and the log names each host on first contact. Replace the "*" entry with what you saw, then run again and confirm nothing is blocked.
Keep the methods as narrow as the agent allows. Use [ "GET" "HEAD" ] for anything it only reads from. An agent that uses WebSockets (Codex does) needs "*" for that domain.
launch.log and seatbelt.sb show the profile that sandbox-exec enforced, not which rule a process tripped. For that, query the system log after a failure:
log show --predicate 'eventMessage CONTAINS "deny"' --last 1m
Nothing in the session directory records this, so pair the log with seatbelt.sb when something your config should allow is blocked.
On macOS, when you set allowedDomains, gh (the GitHub CLI) fails HTTPS requests with a certificate error. The filtering proxy uses its own certificate. git accepts this certificate, but gh and other Go tools reject it on macOS. There is an issue here for this on the gh repo. Linux is unaffected. The workaround is to use curl instead - most agents will figure this out themselves.
This section describes what the sandbox protects against, and what it does not protect against, so that you can decide whether it fits your situation. It assumes that you launch the agent from a project directory. A launch from $HOME turns off home masking entirely, and the sandbox asks for your permission first.
The agent can do something it should not do. It can run a bad prompt, process a malicious file, use a compromised dependency, or invent a destructive command. In each case, the sandbox keeps the damage inside the project directory. In detail:
rwDirs and rwFiles.allowedDomains.allowedHostPorts.hooks/, a core.hooksPath or alias.* entry in a config file, or a pointer file aimed at a git directory the agent controls.objects/info/alternates tells git to look for objects in another directory as well as your own and is not writable.allowedPackages, unless you set allowNix = true. See Using Nix inside the sandbox.allowedPackages, unless you set allowNix = true.The sandbox is an isolation boundary. It is not an anonymity boundary, and it is not a defense against an attacker who has already taken over your machine in some other way.
$HOME/.claude resolves to. If your username is itself sensitive, this is not the right tool.allowNix = true, all of /nix/store is readable and executable, not only your allowed packages, so the agent can list every package you have built. The Nix store is normally world-readable on any system, so this matches existing behavior.See Using Nix inside the sandbox.~/.claude directory (or any credential file) through rwDirs, or pass a token through env, the agent can read it. That is how it logs in. A compromised agent has the same access to those credentials as your shell. Treat this the way you would treat handing the token to any other CLI tool you did not write yourself.flake.nix lives inside the project directory, and the sandbox permits writes to it. An agent could weaken its own restrictions for the next session. The changes take effect only when you enter the dev shell again, so it is worth reading git diff first.Both platforms enforce the same default protections. The one practical difference is localhost. On Linux, bubblewrap gives the sandbox its own network namespace, so services started inside the sandbox can reach each other on any localhost port. On macOS, sandbox-exec shares localhost with the host. Localhost communication inside the sandbox therefore needs the port in allowedHostPorts, or all host-local ports allowed with allowedHostPorts = null;. The same access also opens those host-local ports.
If your threat model is "I want my AI agent to not accidentally destroy my work, leak my private files, or talk to random places on the internet," this sandbox is a good fit.
If your threat model is "I assume the agent is actively malicious and need it to be unable to identify my specific machine or my real user account," you want a VM with a throwaway user account, or a separate machine.
sandbox-exec is deprecated on macOS. It remains the only native unprivileged sandboxing mechanism. It currently works on macOS 26 (Tahoe) and older, but a future release may break it.There are several other tools for sandboxing AI agents. Here are a few:
Anthropic sandbox-runtime (srt): an npm package that also uses bubblewrap on Linux and sandbox-exec on macOS.
jail.nix: a nix library that builds bubblewrap sandboxes. It is not agent-specific, but you can use it to sandbox agents. Linux only.
jailed-agents: a nix library that provides pre-configured per-agent sandboxes with bubblewrap. Linux only.
agent-box: a Rust CLI that uses disposable containers with Jujutsu or Git worktrees. macOS and Linux.
ai-jail: a Rust CLI that sandboxes agents with bubblewrap (with Landlock and seccomp) on Linux and sandbox-exec on macOS. It is configured with a TOML file in the project directory.
Shell
45.1%
Python
30.9%
Nix
12.8%
Go
11.2%