Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Sandboxed Agents

Nix flake that wraps AI coding agents in gVisor (Linux, default), bubblewrap (Linux), microVM (Linux/aarch64-darwin), or seatbelt (macOS) sandboxes. The agent runs with filesystem isolation, optional network isolation via SOCKS proxy, and no access to your API keys.

Agents

Agent Source
Claude Code Fetched binary from Anthropic GCS
Hermes Flake input (hermes-agent)
OpenCode Built from GitHub (anomalyco/opencode)
Codex Prebuilt binary from openai/codex releases
Gemini CLI Built from GitHub (google-gemini/gemini-cli)

All agents run under gVisor by default on Linux and seatbelt on macOS.

When the flake is given a cccp package, the hermes wrapper also puts cccp on the sandbox PATH and ships the cccp transcript plugin at ~/.hermes/plugins/cccp (bind-mounted from the store on Linux, symlinked on microvm/macOS); a pre-existing ~/.hermes/config.yaml still needs a one-time hermes plugins enable cccp (the launcher warns).

The codex wrapper likewise merges the cccp hooks into the effective ~/.codex/hooks.json (your own entries stay) and creates ~/.codex/cccp/; codex asks you to trust the two hooks once in the TUI. hooks.json and rules/ are read-only inside the sandbox; cccp reads its client config from ~/.config/cccp/codex.json (outside the sandbox) before ~/.codex/cccp/config.json. The opencode wrapper copies cccp's cccp.js into its config's plugin/.

Extra hooks and plugins

lib.mkAgent { system, name, backend ? <default>, args } builds one variant with extra spec arguments; inside the overlay it is pkgs.sandboxedAgents.mkAgentWith { name, backend, args }. The flake names no consumer, so a caller brings its own files:

Agent args key Shape At launch
codex extraHookSets [ { name; file; marker; } ] merged into $CODEX_HOME/hooks.json with cccp's
opencode extraPlugins [ { name = "x.js"; file; } ] copied to <config>/opencode/plugin/x.js beside cccp.js
codex, opencode extraPackages [ drv ] on the sandbox PATH (and in the microvm guest)

Codex reads one root hooks.json, so every tool that hooks it shares the file. Each hook set names a marker, a string all its commands contain. At launch the wrapper drops every hook whose command contains a current marker, appends the current sets, and writes only if the result changed. User entries and other keys stay, a stale entry of a set is replaced, and a second launch leaves the file alone. The build fails when a command lacks its set's marker, because the next merge could not find it and would add it again at every launch. A set removed from the build leaves its old entries behind, since nothing names them any more. A hooks.json that is not one JSON object, or is a symlink, is left alone with a warning.

An extra opencode plugin gets a first line // @sandboxed-agents-plugin opencode x.js. A file of that name without the line belongs to the user and is left alone with a warning, the same guard as cccp's // @cccp-plugin opencode. A symlink is never replaced; opencode skips symlinked plugins anyway.

inputs.sandboxed-agents.lib.mkAgent {
  system = pkgs.stdenv.hostPlatform.system;
  name = "codex";
  args = {
    extraHookSets = [
      { name = "mytool"; file = "${mytool}/share/mytool/codex-hooks.json"; marker = "mytool hook codex"; }
    ];
    extraPackages = [ mytool ];
  };
}

checks.<linux>.codex-hooks-merge and opencode-plugins-seed run the real bwrap and runsc wrappers with --sandbox-show-config (their init lines run before the dry-run exit) against fixtures.

Quick Start

nix run github:eordano/sandboxed-agents                   # Claude Code (gVisor on Linux, seatbelt on macOS)
nix run github:eordano/sandboxed-agents#hermes            # or any agent from the table
nix run github:eordano/sandboxed-agents#claude-runsc      # gVisor backend (Linux; alias of the default)
nix run github:eordano/sandboxed-agents#claude-bwrap      # bubblewrap backend (Linux)
nix run github:eordano/sandboxed-agents#claude-microvm    # microVM backend (Linux + aarch64-darwin for claude)

The wrapper binary is named after the agent (claude, hermes, etc.). The unsandboxed binary is <agent>-achtung-achtung. Each agent is exposed in four package names on Linux: <agent> (gVisor -- the default), <agent>-runsc (explicit alias of the default), <agent>-bwrap (bubblewrap), and <agent>-microvm (microVM). On macOS the unsuffixed <agent> is the seatbelt build, and <agent>-microvm is offered only for claude on aarch64-darwin.

All variants of an agent install the same bin/<agent> (and bin/<agent>-achtung-achtung), so only one variant per agent can live in a profile at a time. Use nix run .#<agent>-bwrap / .#<agent>-microvm to invoke a non-default backend ad-hoc, or pick the variant you want with home.packages = [ pkgs.sandboxedAgents.<agent>-bwrap ] etc.

Bash, zsh, and fish completions are installed alongside each wrapper and cover all sandbox flags (with --no-... and value completion). They are picked up automatically when the package is on XDG_DATA_DIRS (e.g. via home-manager or nix profile install).

Home-Manager

Use your sandboxed binary with their options by setting e.g.:

programs.claude-code = {
  enable = true;
  package = sandboxed-agents.packages.${system}.claude;
  settings = { ... };
  mcpServers = { ... };
};

What the Sandbox Does

Filesystem

Fresh $HOME with only these paths mounted by default: current directory (rw), /nix (immutable), /etc (ro), and agent-specific config dirs/state. Shared developer caches and toolchain homes are explicit mount flags: --mount-home-cache and --mount-common-home-folders (or config "mountHomeCache": true, "mountCommonHomeFolders": true).

Credential Isolation

API keys are never passed in. Environment whitelist: PATH, HOME, USER, LOGNAME, MAIL, TERM, SHELL, LANG, TZ, plus SSL cert paths pointing to the Nix cacert store path. Provide credentials via a SOCKS proxy (e.g. mitmproxy) that injects API headers at the proxy layer.

Privacy filtering

The optional privacy-filter plugin adds fail-open, reversible PII pseudonymization to that external proxy. Because enforcement is outside the sandbox, one host-side mapping database works for Claude Code, Hermes, OpenCode, and Codex without exposing the original values to any agent. On classifier or format failures it logs a local warning and forwards the original request, prioritizing availability rather than providing a hard DLP guarantee.

nix run .#privacy-filter
# In another terminal:
claude --privacy-filter

Then enable it with --privacy-filter, SANDBOX_PRIVACY_FILTER=1, or "privacyFilter": true in the sandbox config. The setting is off by default and uses an explicit socksProxy when configured, otherwise the proxy on host port 1080. Mount mitmproxy's CA as documented in the plugin README. The classifier is a Speaches-plus /v1/pii/classify endpoint named by SANDBOX_PRIVACY_CLASSIFIER_URL (required); point it at a loopback instance when even the classifier must remain on-device.

The two platforms reach the same "no credentials leak" outcome via different mechanisms:

  • Linux (bwrap/runsc/microvm) -- the sandbox gets a fresh ephemeral $HOME, so paths like ~/.aws, ~/.ssh, ~/.gnupg, ~/.kube, browser keyrings, etc. do not exist inside it. No explicit deny-list is needed.
  • macOS (seatbelt) -- seatbelt has no filesystem namespace; the sandbox shares the real $HOME. An explicit deny-list blocks reads of ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.config/gcloud, ~/.docker, ~/.gitconfig, ~/.git-credentials, ~/.netrc, ~/.kube, ~/.terraform.d, ~/.config/gh, ~/.local/share/keyrings, ~/.gnome-keyring, ~/.password-store, ~/.1password, ~/.bitwarden. --allow-ssh/--allow-gpg/--allow-git/--allow-docker remove the corresponding entry when those specific credentials are intentionally granted.

Decision daemon

The laya plugin is a Rust runtime for the Laya System 1 decision models, kept warm on the host: prompt guardrails, model routing, moderation and triage as typed answers with calibrated probabilities, in one forward pass and a few milliseconds on a GPU. The weights are pinned store paths, so the sandbox never needs to reach a model hub.

nix run .#laya-cuda -- serve        # .#laya for CPU
laya predict --server http://127.0.0.1:8187 --state "$PROMPT" --preset guard

Network

Default: shared host network unless a socksProxy is configured. With --socks-proxy or config "socksProxy", Linux enters an isolated network namespace (unshare --user --net + slirp4netns + tun2socks): non-private traffic goes through the proxy, while local/private network ranges stay reachable directly. --no-internet-access (or "internetAccess": false) drops non-private egress at iptables. --disable-networking blocks all non-localhost traffic. --allow-host HOST punches through for specific hosts.

XDG Base Directory Remapping

On Linux, the sandbox can bind-mount $XDG_CONFIG_HOME/<agent>/ to ~/.<agent>/ inside the sandbox. Off by default; enable with "xdgRemap": true in config or SANDBOX_XDG_REMAP=1. Respects $XDG_CONFIG_HOME, falls back to dotfile paths. No effect on macOS.

Independently of that opt-in remap, every home-allow entry under .config/, .local/share/ or .cache/ (the agent's built-in list and the common-tools mount group; homePatterns stay literal $HOME-relative) is always resolved against the host's $XDG_CONFIG_HOME / $XDG_DATA_HOME / $XDG_CACHE_HOME, falling back to ~/.config, ~/.local/share, ~/.cache. On Linux the resolved host path is bound at the conventional ~/... location inside the ephemeral home; on macOS (no ephemeral home) the resolved path is what gets write-allowed and the three variables are forwarded into the sandbox so the agent writes where the launcher looked. Setting XDG_DATA_HOME=~/.local/share/opencode-scratch therefore gives that run its own session store on both platforms.

Agent Legacy path XDG path (host)
Claude Code ~/.claude/, ~/.claude.json ~/.config/claude/
Hermes ~/.hermes/ ~/.config/hermes/
OpenCode -- ~/.config/opencode/
Codex ~/.codex/ ~/.config/codex/
Gemini CLI ~/.gemini/ ~/.config/gemini/

Configuration

Each agent reads its policy from --sandbox-config FILE, else $SANDBOX_CONFIG_FILE, else ${XDG_CONFIG_HOME:-~/.config}/<agent>-sandbox.json (e.g. claude-sandbox.json). A <agent>-sandbox.json found walking up from the current directory is layered on top of that policy: its toggles and strings override it and its lists add to it, so it can widen as well as narrow. The working directory is writable by the agent, so a repo, or the agent itself, can grant a later launch more access. Pass a file with --sandbox-config to use it as the whole policy. The global file is not writable from inside the sandbox, and hooks/settings/plugin config of claude and codex stay read-only inside it (see docs/flags.md).

All agents accept the same CLI flags. Every boolean toggle has a --no-X counterpart (e.g. --no-allow-ssh, alias --no-ssh) and a matching SANDBOX_X env var (e.g. SANDBOX_ALLOW_SSH={0,1,true,false,yes,no,on,off}). Precedence: CLI > env > config > default. See docs/flags.md for the full rules. Unsupported flags on macOS/microvm print a warning. Unrecognized flags are forwarded to the agent.

Flag Config key Type Description
--allow-ssh ssh bool Mount ~/.ssh (read-only) + SSH_AUTH_SOCK
--allow-ssh-write sshWrite bool Upgrade ~/.ssh mount to read-write
--allow-gpg gpg bool Mount ~/.gnupg + bridge gpg-agent socket
--allow-git git bool Mount ~/.gitconfig, ~/.git-credentials
--allow-docker docker bool Mount Docker socket
--allow-fuse fuse bool Mount /dev/fuse and user runtime dir
--allow-gui gui bool Mount X11/Wayland, DRI, fonts, themes, audio
--allow-nvidia nvidia bool Mount NVIDIA devices and OpenGL driver
--allow-kvm kvm bool Mount /dev/kvm and /dev/vfio
--allow-audio audio bool Mount PulseAudio/PipeWire and /dev/snd
--allow-libvirt libvirt bool Mount libvirt sockets
--allow-home-access allowHome bool Allow running from $HOME (weakens isolation)
--allow-internet-access internetAccess bool Allow internet access (default true; env SANDBOX_INTERNET_ACCESS)
--no-internet-access internetAccess: false bool Block non-private egress (RFC1918 / loopback / link-local stay reachable)
--allow-host HOST string Allow traffic to a specific host (with --disable-networking)
--disable-networking disableNetworking bool Block all non-localhost connections
--socks-proxy HOST:PORT socksProxy string SOCKS5 proxy (Linux only)
--privacy-filter privacyFilter bool Route through the host privacy-filter proxy (Linux only; env SANDBOX_PRIVACY_FILTER)
--backend BACKEND backend string Re-exec under bwrap|runsc|microvm. CLI > SANDBOX_BACKEND env > config > wrapper's own backend. Targets the <agent>-{sandbox,runsc,microvm} symlink on PATH; errors if not installed.
--sandbox-config FILENAME string Use a specific config file
--mount-home-cache mountHomeCache bool Mount ~/.cache/* dirs for common dev tools
--mount-common-home-folders mountCommonHomeFolders bool Mount common toolchain homes (~/.cargo, ~/.npm, etc.)
--mount-tmp mountTmp bool Mount the real /tmp instead of ephemeral sandbox tmp (Linux only; no-op on macOS where /tmp is always writable)
--mount PATH paths string[] Transparent bind-mount (supports ro:/path, /host:/guest)
--env KEY=VALUE extraEnvs string[] Extra env vars (repeatable)
--forward-host-loopback PORT port[] Expose host 127.0.0.1:PORT at guest 10.0.2.100:PORT (microvm only, repeatable)
--extra-bubblewrap-args ARG extraBubblewrapArgs string[] Pass ARG verbatim to bwrap (Linux/bwrap only)
--extra-runsc-args ARG extraRunscArgs string[] Pass ARG verbatim to runsc (Linux/runsc only)
--extra-qemu-args ARG extraQemuArgs string[] Pass ARG verbatim to QEMU (microvm only)
--extra-sandbox-exec-args ARG extraSandboxExecArgs string[] Pass ARG verbatim to sandbox-exec (macOS only)
--sandbox-help Print wrapper path and packaged README path
--sandbox-show-config Print sandbox command without executing
--sandbox-open-shell Drop into a shell inside the sandbox
--yolo yolo bool Claude only: --dangerously-skip-permissions
paths string[] Extra rw directories
homePatterns string[] Relative $HOME paths to mount
xdgRemap / noXdgRemap bool Toggle XDG remapping (Linux/microvm only; default off). Also SANDBOX_XDG_REMAP env.
cleanTmp bool Remove sandbox temp dirs on exit

Example config (flat)

{
  "paths": ["~/projects/mylib", "/data/datasets"],
  "homePatterns": [".rustup", ".poetry"],
  "mountHomeCache": true,
  "mountCommonHomeFolders": true,
  "gui": false,
  "docker": true,
  "audio": false,
  "nvidia": false,
  "kvm": false,
  "socksProxy": "127.0.0.1:1080",
  "extraEnvs": ["RUST_LOG=debug"],
  "yolo": false
}

Multiple profiles

Config files are flat -- keep one file per profile and pick one with --sandbox-config path/to/config.json. See docs/flags.md for details.

Mount groups (config)

The config "mounts" key still supports named mount groups for backward compatibility:

  • common-tools: common local toolchain homes such as ~/.cargo, ~/.npm, ~/.yarn, ~/.go, ~/.java, and Nix user profile metadata.
  • caches: ~/.cache/* directories for common developer tools such as pip, pnpm, npm, uv, deno, bun, gradle, go, gopls, huggingface, prisma, and related language/tool caches.

Prefer the CLI flags --mount-home-cache and --mount-common-home-folders or the config booleans mountHomeCache and mountCommonHomeFolders.

Helper Scripts

Each agent installs <agent>-allow-dir (allow cwd) and <agent>-forget-dir (remove cwd).

Debugging

claude --sandbox-show-config    # print bwrap command / seatbelt profile
claude --sandbox-open-shell     # drop into a shell inside the sandbox
claude --sandbox-help           # print wrapper path and packaged README path

Updating Agents

nix run .#update-all      # all agents
nix run .#update-claude   # single agent

main takes version bumps through reviewed PRs. The auto-update branch is rebuilt every 12 hours as main plus the newest upstream pins, pushed only if all five agents still build:

nix run github:eordano/sandboxed-agents/auto-update

Running Tests

NixOS VM tests with a 4-node topology (machine, server, blocked, another):

nix flake check                                          # all tests
nix build .#checks.x86_64-linux.claude-sandbox-test      # single agent

Recordings

The Recordings workflow (manual dispatch) records each agent x backend combination with asciinema and publishes the player site to GitHub Pages. It needs the OPENROUTER_API_KEY, ANTHROPIC_API_KEY, and GEMINI_API_KEY repository secrets; OPENAI_API_KEY is optional (codex is skipped without it). The same run works locally: scripts/record-agents.sh <backend> [outdir] (needs asciinema and tmux), then scripts/build-recordings-site.sh renders the static site.

Architecture

Each agent directory: default.nix (calls lib/mk-sandbox.nix) and tests/sandbox.nix, plus where applicable a *-binary.nix (version-pinned source), a config/ dir, and an agent-specific update.sh (agents without one are updated by the generic lib/update.sh).

Shared library (lib/): mk-sandbox.nix (builder), sandbox-{linux,darwin}.nix (platform wrappers), shell-blocks.nix (config parsing, home allow, XDG remap), data.nix (constants), update-lib.sh (shared update functions), tests/ (parameterized VM test + mock API server).

mk-sandbox.nix generates the wrapper shell script with an @agent_binary@ placeholder, then substituteInPlace rewrites it to the resolved ${agentBinaryDrv}/${agentBinaryRelPath} at install time. This keeps the wrapper source identical across agents and lets Nix fail the build if the agent derivation path doesn't match.

The unsandboxed binary is available as <agent>-achtung-achtung for emergencies. It's a symlink to the underlying agent derivation, installed when the builder is called with enableEscapeHatch = true (the default). Agents that shouldn't offer an unsandboxed entry point can set it to false.

An agent whose upstream binary looks for helper executables beside itself lists them in extraBinaries (names under the agent derivation's bin/); the builder symlinks each into the package's own bin/. Codex needs this: it resolves codex-code-mode-host as a sibling of the path it was invoked as, without following symlinks, so the escape hatch in a profile bin/ finds nothing unless the helper is linked there too. The sandbox wrapper itself is unaffected -- it execs the agent by store path, where the sibling always exists.

macOS (Darwin) Support

All five agents opt in with supportsDarwin = true and build under seatbelt. Only aarch64-darwin is supported (nixpkgs unstable dropped x86_64-darwin in 26.11). On macOS the <agent>-bwrap package name is an alias of the seatbelt build, kept so profiles and scripts stay portable across platforms. On aarch64-darwin, claude additionally has a microvm variant (no other agent does -- see docs/microvm.md). Seatbelt profile: deny-default, allows exec in /nix//usr/bin//bin, outbound network, read of system libs//etc//dev, rw to project dir/agent config/caches and an ephemeral per-invocation TMPDIR under /var/folders. /tmp and /private/tmp are always writable (required for agent tool calls); --mount-tmp and config mountTmp are no-ops on macOS and warn. Linux-only flags (e.g. --allow-gui, --allow-kvm, --socks-proxy, --disable-networking) print a warning and are ignored.

Backend Notes

Four backends implementing the same flag surface:

  • gVisor / runsc (Linux) -- Default on Linux. Agent runs as an OCI bundle under gVisor, whose user-space Sentry kernel intercepts syscalls and re-issues a small audited subset to the host kernel. Selected as the unsuffixed <agent> package. See docs/runsc.md.
  • bubblewrap (Linux) -- direct bind mounts. Selected as <agent>-bwrap. See docs/bubblewrap.md.
  • microVM (Linux) -- NixOS guest via microvm.nix; host resources shared via virtiofs or socat bridges. Selected as <agent>-microvm. See docs/microvm.md.
  • microVM (aarch64-darwin, claude only) -- same NixOS guest built as aarch64-linux via a nix-darwin linux-builder, booted under QEMU+HVF. Shares use 9p instead of virtiofs (virtiofsd has no darwin build). See docs/microvm.md#macos-aarch64-darwin-support.
  • seatbelt (macOS) -- Default on macOS (the only Darwin backend besides claude-only microvm). Allow/deny-listed paths, no filesystem namespace. See docs/sandbox-exec.md.

Per-flag behavior differences across backends are documented in the flag reference.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages