Lightweight and declarative sandboxing for AI agents on Linux and macOS.
Prevent your agents in YOLO mode from deleting your $HOME, force pushing to main, or publishing your ssh keys on reddit. Works with any CLI-based AI agent — tested with Claude Code and GitHub Copilot CLI (see Supported agents).
The sandbox uses bubblewrap on Linux and sandbox-exec on macOS. See Security for the threat model and known limits.
- Project directory — read/write access to the directory you launch the agent from.
- Declared state — read/write access to anything you list in
rwDirs/rwFiles, or read-only access viaroDirs/roFiles. - Allowed packages — the binaries you list in
allowedPackagesare on the agent's PATH (plusbashandcacert). - Network — unrestricted internet by default, with host-local services blocked. Set
allowedDomainsto limit internet domains, and useallowedLocalPortsfor explicit host-local TCP port access. - Environment — only variables you pass via
envreach the agent; the host environment is otherwise cleared. - Git — the repo's
.gitdirectory is exposed, including when it sits outside the project tree (worktrees). - Nix — disabled by default. Optionally allow the agent to run nix commands.
Everything else is denied. $HOME is an ephemeral writable tmpfs that disappears when the sandbox exits.
- Usage and configuration
- Authentication
- Git
- Using Nix inside the sandbox
- Common patterns / recipes
- Troubleshooting
- Security
- Caveats
- Similar projects
The quickest way to get started is with a flake template. If you prefer a shell.nix, see shells/ for ready-to-use examples. Authentication is covered below.
V0.x to V1.x migration guide
A few arguments were renamed, and restrictNetwork was removed. If you use an old name you'll get a clear error telling you the new one. Update your config like this:
| Old | New |
|---|---|
extraEnv = { … } |
env = { … } |
stateDirs = [ … ] |
rwDirs = [ … ] |
stateFiles = [ … ] |
rwFiles = [ … ] |
restrictNetwork = true; allowedDomains = … |
allowedDomains = … |
restrictNetwork = true; allowedDomains = [ ] |
allowedDomains = [ ] |
restrictNetwork = false |
remove it — just don't set allowedDomains |
Network access is now controlled by allowedDomains on its own: leave it unset for open internet, list the domains you want to allow, or use [ ] to block everything.
If you relied on host loopback reachability: previously, leaving restrictNetwork unset let the agent reach host-local services (Ollama, a local database, a local MCP server, etc.). That no longer works by default — host loopback is blocked unless you explicitly opt in with allowedLocalPorts.
Flake templates for Claude Code and GitHub Copilot CLI are provided for quick project setup, but you can alter either to work with any other CLI tool.
To initialize a template in your project directory:
nix flake init -t github:archie-judd/agent-sandbox.nix#claude
# or
nix flake init -t github:archie-judd/agent-sandbox.nix#copilotThis creates a flake.nix in your project (see templates/claude/flake.nix for what you get). Edit it to suit your needs, then enter the dev shell:
NIXPKGS_ALLOW_UNFREE=1 nix develop --impureNote: Claude Code and most other AI CLI tools are not FOSS. You will need to set
NIXPKGS_ALLOW_UNFREE=1and invoke the shell with--impure.
And invoke your wrapped binary:
claude-sandboxed --dangerously-skip-permissions # Claude Code's "YOLO mode"
# or
copilot-sandboxed --yoloIf you want to keep the original command name as the alias, change the outName value (e.g. to "claude" or "copilot").
mkSandbox, the library's entrypoint, accepts the following arguments:
| Argument | Required | Description |
|---|---|---|
pkg |
yes | Package containing the binary to wrap |
binName |
yes | Name of the binary inside pkg/bin/ |
outName |
yes | Name for the resulting wrapped binary and the command to invoke it with |
allowedPackages |
yes | Packages whose bin/ dirs form the sandbox PATH (see the note below the table) |
rwDirs |
no | Directories the agent can read/write (e.g. ~/.config/claude) |
rwFiles |
no | Individual files the agent can read/write |
roDirs |
no | Directories the agent can read but not write (e.g. signed binaries, reference source trees, secret stores) |
roFiles |
no | Individual files the agent can read but not write (e.g. ~/.config/git/config for git identity — see Git identity) |
allowNix |
no | If true, expose the host's nix-daemon socket and the full Nix store so the agent can run nix build, nix run, nix develop, etc. pkgs.nix is added to PATH automatically. Defaults to false. See Using Nix inside the sandbox. |
env |
no | Additional environment variables as an attrset |
allowedDomains |
no | Limits which domains the sandbox can reach. Leave unset for open internet. Accepts a list of domains (all methods allowed), or an attrset mapping each domain to "*" or a list of HTTP methods. [ ] blocks all internet access. |
allowedLocalPorts |
no | Host-local TCP ports the sandbox may reach. Defaults to [ ]. Set to null to allow all host-local TCP ports. Otherwise, entries must be integers from 1 to 65535. |
For allowedPackages, bash and cacert are provided by default — the sandbox needs a shell to run, and cacert is required for HTTPS to work. The library also exports commonTools (a list of standard CLI tools) for convenience; see default.nix for the full list.
Paths declared in rwDirs / rwFiles / roDirs / roFiles must exist on the host before launch — the sandbox exits with a clear error if any are missing.
A minimal example — the arguments are the same whether you use a flake or 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 set CLAUDE_CONFIG_DIR and not add ~/.claude.json as a rwFile?
CLAUDE_CONFIG_DIR is set to $HOME/.claude so that ~/.claude.json is written inside the read/write rwDir. If you instead add ~/.claude.json as a rwFile, when Claude updates configuration it writes temporary files to the ephemeral home root. It then tries to rename these to ~/.claude.json, which can fail or behave unexpectedly because the temporary files land outside any declared rwDir or rwFile. This can occasionally corrupt the ~/.claude.json file.
Note: If you also run Claude outside the sandbox, set
CLAUDE_CONFIG_DIR=$HOME/.claudeglobally too, otherwise the two will use different config locations and diverge.
The sandbox controls network access along two independent axes. allowedDomains governs outbound internet access; allowedLocalPorts governs access to host-local TCP services (databases, dev servers, and similar). They don't interact: allowing a domain never grants loopback access, and vice versa. By default internet access is unrestricted and all host-local services are blocked.
To restrict internet access, set allowedDomains — the sandbox can then only reach the domains you list. Leave it unset for open internet, or set it to [ ] to block all internet access.
allowedDomains accepts two formats:
- Attrset (recommended): map each domain to
"*"(all HTTP methods allowed) or a list of permitted methods (e.g.[ "GET" "HEAD" ]). - List:
[ "anthropic.com" "sentry.io" ]— equivalent to allowing all methods for each domain.
Domains are suffix-matched, so "anthropic.com" will capture all *.anthropic.com subdomains.
When allowedDomains is set, all HTTP/HTTPS traffic is routed through a filtering proxy that inspects requests by domain and HTTP method. The sandbox cannot bypass the proxy and DNS resolution is blocked. WebSocket connections are not permitted. Blocked requests are logged to /tmp/sandbox-proxy.log.
Known limitations when the proxy is active:
- SSH-based git remotes: see Git.
- On macOS,
ghand some other tools can't connect through the proxy: see Caveats.
Host-local services (databases, dev servers, SSH agent, Docker socket, etc.) are blocked by default, and remain blocked even when allowedDomains is set. Use allowedLocalPorts to grant access to specific ports:
allowedLocalPorts = [ 3000 5432 ];Set allowedLocalPorts = null; to allow all host-local TCP ports. Keep explicit port lists as narrow as possible; broad access can expose host-local services.
The sandbox has been tested with claude-code and copilot-cli. Other agents should work as long as they support token-based auth via an environment variable — see Authentication.
Because $HOME is masked, agents cannot reach your system keychain, browser sessions, or SSH keys. The recommended approach is to authenticate via environment variable. Interactive login flows (e.g. claude /login, gh auth login) may not work inside the sandbox.
Export your token in the host terminal before launching the sandbox — tokens are evaluated at runtime to prevent them from leaking 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";
...
};Alternatively, if you store your secret in a file (for example if you use sops), you can set a command that will read the secret at runtime:
env = {
CLAUDE_CODE_OAUTH_TOKEN = "$(${pkgs.coreutils}/bin/cat /run/secrets/claude-code-oauth-token)";
...
};If your agent stores credentials in files (e.g. Claude Code uses ~/.claude/), you can run the login flow unsandboxed first, then expose the credential directory via rwDirs. The sandboxed agent will pick up the cached credentials.
On macOS you will need to export the credentials from the Keychain first
On macOS, Claude Code stores credentials in the system Keychain rather than in files. Since the sandbox cannot access the Keychain, the environment variable approach above is the simplest option.
If you can't use an environment variable token, you can export the Keychain credentials to a file that the sandbox can read:
# First 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.jsonThis finds all Claude Code credential entries in the Keychain and exports the one with the most recent expiry.
Then expose ~/.claude via rwDirs. The sandboxed agent will read credentials from ~/.claude/.credentials.json when Keychain access is unavailable.
Note: OAuth access tokens expire. You will need to re-run the export command periodically to refresh the credentials file.
The sandbox allows access to the local git directory, including from within worktrees. Switching branches, reading history and other local operations work without any extra configuration. Committing requires a declared git identity — see Git identity.
Interacting with remotes requires authentication. The recommended approach is to use HTTPS rather than SSH based remotes. The simplest way to authenticate is by passing a token via env (e.g. GITHUB_TOKEN), but you can also configure a git credential helper to store your token for reuse so you don't have to pass it via environment variable.
SSH based remotes (e.g. git@github.com:...) won't work by default — SSH keys are not accessible because $HOME is masked, and when allowedDomains is set the proxy only handles HTTP/HTTPS so SSH traffic is blocked entirely. You can expose your SSH directory via rwDirs (e.g. $HOME/.ssh) and leave allowedDomains unset (open network) to enable SSH based git remotes, but this is not recommended.
$HOME is masked inside the sandbox, so your global gitconfig is not visible and git's user.name / user.email are unset. The sandbox never fabricates an identity if none are provided. This means git commit without a declared identity fails loudly (fatal: ... auto-detection is disabled).
To get correctly-attributed commits, declare a real identity in one of two ways:
-
Bind your host gitconfig read-only via
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"
git reads
[user]through its normal global-config lookup. Because the file is read-only inside the sandbox, the agent can't setcore.hooksPath,core.fsmonitor, oralias.*entries that would otherwise fire host code on the next hostgitinvocation. -
Via
env(fully self-contained, useful when you can't 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"; };
Note: do not run
git config --global ...inside the sandbox —$HOMEis an ephemeral tmpfs there, so it won't persist. Set your identity on the host and bind it, or useenv.
Set allowNix = true to let the agent invoke nix commands from inside the sandbox. The agent is given access to the host's nix daemon and the full nix store. pkgs.nix is added to the agent's PATH automatically — you don't need to put it in allowedPackages.
What you need to configure:
-
Flake CLI features: Your nix config is not visible inside the sandbox. Either bind it via
roFiles = [ "/etc/nix/nix.conf" ]to inherit your whole config or setenv.NIX_CONFIG = "experimental-features = nix-command flakes"to enable just the flake CLI. -
Nix state directories: The client caches the flake registry and downloaded tarballs under
$HOME/.cache/nix, writes registry overrides to$HOME/.config/nix, and stores per-user profiles under$HOME/.local/share/nix. Add these torwDirsif you want any of that to persist across invocations. -
Allowed domains: When
allowedDomainsis set, the nix client itself needschannels.nixos.org,github.com+raw.githubusercontent.com, andcache.nixos.orgto reliably fetch packages and flakes.
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 any executable in the nix store can be run by the agent —allowedPackagesno longer restricts what the agent can execute, only what's onPATH. Thenix-daemonruns outside the sandbox, so its own network activity — downloads of prebuilt packages from the caches it's configured to use — bypassesallowedDomains.
uv needs access to its cache dirs via rwDirs, otherwise it will re-download dependencies on every invocation. On NixOS, pre-compiled wheels will also fail to find glibc unless you thread LD_LIBRARY_PATH through from the host and use a nix-managed Python instead of a uv-managed one. See shells/claude-uv.shell.nix for the full setup.
For Node, you can simply add the npm cache as a rwDir.
allowedPackages = [ pkgs.nodejs pkgs.npm ];
rwDirs = [ "$HOME/.npm" ]; # Allow npm cacheIf you get stuck, or suspect the agent can't access a file or folder it should have access to by default, please raise an issue.
If the agent fails to perform a tool call, or file read/write, the sandbox is likely blocking a path that needs to be added to rwDirs / rwFiles (or roDirs / roFiles for read-only access).
The easiest way to explore the sandbox environment is to wrap bash itself with the same config as your agent and poke around interactively.
# mirror your agent's config
bash-sandboxed = sandbox.mkSandbox {
pkg = pkgs.bashInteractive;
binName = "bash";
outName = "bash-sandboxed";
allowedPackages = [ pkgs.coreutils ];
rwDirs = [ "$HOME/.claude" ];
rwFiles = [];
allowedDomains = { "httpbin.org" = "*"; };
};Running bash-sandboxed drops you into a shell with exactly the same filesystem view and restrictions your agent will see. Try:
touch /tmp/test && rm /tmp/test # /tmp should be writable
curl https://example.com # depends on your allowedDomains setting
which git # allowedPackages should be on PATH
ls /some/other/path # should fail — confirming sandbox is active
cat ~/.ssh/id_ed25519 # should fail - shouldn't be able to read unspecified files in $HOME
ls $HOME # empty dir with symlinks to rwDirs
touch $HOME/.test && rm $HOME/.test # writes allowed (but ephemeral)
ls $HOME/.claude # should work if in rwDirs (symlinked)
curl https://httpbin.org/get # allowed domain — should work
curl https://example.com # blocked domain — should failSee debug/bash.shell.nix for a ready-to-use template (has allowedDomains set to httpbin.org for testing).
If you've set allowedDomains and requests are failing, check which domains are being blocked:
tail -f /tmp/sandbox-proxy.logYou may need to add them to allowedDomains.
On macOS, if gh (or another Go-based tool) fails with a certificate error rather than a blocked request, that's the filtering proxy's certificate being rejected rather than a domain problem; see Caveats.
After a failure, you can query the system log for sandbox denials:
log show --predicate 'eventMessage CONTAINS "deny"' --last 1mIf something is blocked that should have been allowed by your sandbox config, this log can show which path or operation sandbox-exec denied.
If a sandboxed process can't reach another sandboxed process on localhost:<port>, add that port to allowedLocalPorts (or allow all host-local TCP ports with allowedLocalPorts = null;). This is macOS-only: sandbox-exec shares localhost with the host, so it can't tell sandbox-internal services apart from host-local ones — see Linux vs macOS for the full explanation. The same access also opens those host-local ports, so keep explicit lists narrow.
This section explains what the sandbox is and isn't designed to protect against, so you can decide whether it fits your situation.
If the agent does something it shouldn't — runs a bad prompt, processes a malicious file, picks up a compromised dependency, or hallucinates a destructive command — the sandbox stops the damage from spreading outside the project directory. Concretely:
- It can't read your SSH keys, browser sessions, password manager, other projects' source code, or anything else in your home directory outside the paths you explicitly expose.
- It can't delete or modify files outside the project directory and your declared
rwDirs/rwFiles. - It can't reach the internet outside the domains you allow (when
allowedDomainsis set). - It can't talk to local services on your laptop — databases, dev servers, the SSH agent, other terminal windows, etc. — unless you explicitly allow host-local TCP ports with
allowedLocalPorts. - It can only run the tools you list in
allowedPackages(unless you setallowNix = true— see Using Nix inside the sandbox). - It can't see your other running programs, read environment variables they have set, or interfere with other terminals you have open.
The sandbox is an isolation boundary, not an anonymity boundary, and not a defense against an attacker who has already taken over your machine in some other way.
- The agent can fingerprint your machine. It can see your hostname, hardware model, CPU, RAM, OS version, and rough network details. If you need the agent to not know which machine it's running on, this isn't the tool — you want a VM or a separate device.
- Anything you hand the agent, it has. If you expose your
~/.claudedirectory (or any credential file) viarwDirs, or pass a token throughenv, the agent can read it — that's how it logs in. A compromised agent has the same access to those credentials as your shell does. Treat this the way you'd treat handing the token to any other CLI tool you didn't write yourself. - The agent can edit its own sandbox config.
flake.nixlives inside the project directory and is writable from inside the sandbox. An agent could weaken its own restrictions for the next session. Changes don't take effect until you re-enter the dev shell, so it's worth reviewinggit diffbefore you do. - No defense against root or kernel bugs. If something on your machine has already gained administrator-level access, or there's a deeper bug in the operating system itself, this sandbox can't stop it.
- Your username and home directory path are visible to the agent. This is unavoidable — the agent needs to know where
$HOME/.clauderesolves to. If your username is itself sensitive, this isn't the right tool. - All of
/nix/storeis readable, not just your allowed packages. Only execution is restricted to your allowlist. The Nix store is normally world-readable on any system, so this matches existing behavior, but it does mean the agent can list every package you've built. /tmpis shared with the host. The agent can see (but not connect to) sockets and files other programs leave there. Don't put secrets in/tmpwhile the sandbox is running.
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 freely. On macOS, sandbox-exec shares localhost with the host, so sandbox-internal localhost communication requires the port to be listed in allowedLocalPorts or all host-local ports to be allowed with allowedLocalPorts = 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'll want a VM with a throwaway user account or a separate machine.
sandbox-execis deprecated on macOS. It remains the only native unprivileged sandboxing mechanism and currently works on macOS 26 (Tahoe) and older, but may break in a future release.- Symlinks inside
rwDirs,rwFiles,roDirs, androFilesare only followed to already-permitted paths. A symlink is usable only if its target is the Nix store, the working directory, the Git directory, or another declared bind. Anything else is blocked — this prevents an agent from planting a symlink during a session to expand its own sandbox on the next startup (e.g.~/.claude/evil -> /etc/shadow). To expose a non-permitted path that's currently reached via a symlink, declare it explicitly as arwDir/rwFile/roDir/roFile. Symlinks into the Nix store are read-only. - On macOS, when
allowedDomainsis set,gh(the GitHub CLI) fails HTTPS requests with a certificate error: the filtering proxy uses its own certificate, whichgitaccepts butgh(and other Go tools) reject on macOS. Linux is unaffected. - Tested on x86_64-linux and aarch64-darwin. Other architectures should work but are untested.
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 for building bubblewrap sandboxes. It's not built to be agent-specific but can be used for agent sandboxing. Linux only.
jailed-agents — A nix library that provides pre-configured per-agent sandboxes using 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 using bubblewrap (with Landlock and seccomp) on Linux and sandbox-exec on macOS. Configured via a TOML file in the project directory.