Sandbox Setup
Wave provides defense-in-depth isolation for AI agent sessions through two complementary layers:
- Outer sandbox (Nix + bubblewrap) — isolates the entire development session at the OS level
- Adapter sandbox (manifest-driven) — projects persona permissions into Claude Code's
settings.jsonandCLAUDE.md
Quick Start
# Install Nix if you haven't already
# https://nixos.org/download.html
# Enter sandboxed development shell
nix develop
# Everything you run inside is sandboxed:
wave run impl-speckit "add user authentication"Nix Dev Shell (Outer Sandbox)
The flake.nix at the project root defines two dev shells:
Default Shell (Sandboxed)
nix developOn Linux, this automatically enters a bubblewrap sandbox with:
| Protection | Detail |
|---|---|
| Read-only filesystem | --ro-bind / / — entire root is mounted read-only |
| Hidden home directory | --tmpfs $HOME — ~/.aws, ~/.gnupg, etc. are invisible |
| Selective home read-only | ~/.ssh, ~/.gitconfig, ~/.config/gh, ~/.npmrc mounted read-only |
| Writable project dir | --bind $PROJECT_DIR — the project directory is writable |
| Writable Go cache | --bind ~/go — Go module cache persists across steps |
| Writable Wave binary | --bind ~/.local/bin/wave — build target for go build |
| Writable Claude config | --bind ~/.claude — Claude Code session state persists |
| Shared temp | --bind /tmp — shared with host (Nix tooling needs it) |
| Inherited environment | Nix-provided environment inherited (no --clearenv) |
| Process isolation | --die-with-parent ensures sandbox dies with the shell |
On macOS, bwrap requires kernel namespaces not available on Darwin. The default shell runs unsandboxed — Claude Code's built-in Seatbelt sandbox provides OS-level isolation for Bash commands.
Yolo Shell (No Sandbox)
nix develop .#yoloUse this when you need Docker (can't run inside bwrap), or for debugging sandbox-related issues.
What the Sandbox Protects Against
| Attack Vector | Protected? | Mechanism |
|---|---|---|
| Write outside project | Yes | --ro-bind / makes filesystem read-only |
Access ~/.aws credentials | Yes | --tmpfs $HOME hides home directory |
Access ~/.gnupg keys | Yes | --tmpfs $HOME hides home directory |
Modify ~/.ssh keys | Partial | --ro-bind-try mounts read-only (readable for git) |
| Modify git config | Partial | --ro-bind-try mounts read-only |
| Process escape | Yes | --unshare-all + --die-with-parent |
What the Sandbox Allows
The sandbox is intentionally permissive for things AI agents need:
| Access | Why |
|---|---|
~/.ssh (read-only) | Git push/pull over SSH |
~/.gitconfig (read-only) | Git commit identity |
~/.config/gh (read-only) | GitHub CLI authentication |
~/.local/bin/wave (read-write) | Build target for go build / make install |
~/.local/bin/notesium (read-only) | Local tooling |
~/.local/bin/claudit (read-only) | Local tooling |
~/go (read-write) | Go module cache avoids re-downloads |
~/.claude (read-write) | Claude Code session state and OAuth |
/tmp (shared) | Nix store paths and tooling |
| Full network | API calls, package downloads, git remotes |
Passed Environment Variables
The sandbox inherits the Nix-provided environment (no --clearenv), which includes:
- All Nix dev shell variables (
PATH,GOPATH, etc.) SANDBOX_ACTIVE=1(set inside sandbox)ANTHROPIC_API_KEY(if set beforenix develop)GH_TOKEN(if set, or derived fromgh auth token)
Docker Sandbox Backend
In addition to the Nix + bubblewrap outer sandbox, Wave supports a Docker-based sandbox backend for isolating adapter subprocesses inside containers. This is useful when bubblewrap is unavailable (e.g., macOS, CI runners without user namespace support) or when you need container-level isolation for individual pipeline steps.
How It Works
Wave wraps each adapter subprocess in a docker run command with security-hardened defaults:
| Protection | Detail |
|---|---|
| Read-only root filesystem | --read-only prevents writes outside bind mounts |
| Temporary directories | /tmp, /var/run, /home/wave mounted as tmpfs |
| All capabilities dropped | --cap-drop=ALL removes all Linux capabilities |
| No privilege escalation | --security-opt=no-new-privileges |
| Network disabled | --network=none by default |
| UID/GID mapping | Runs as the host user, not root |
| Auto-cleanup | --rm removes the container on exit |
Workspace, artifact, and output directories are bind-mounted into the container. The adapter binary itself is mounted read-only.
Configuration
Set the sandbox backend in your manifest:
runtime:
sandbox:
backend: docker # Options: none, docker, bubblewrap
docker_image: ubuntu:24.04 # Base image (default: ubuntu:24.04)
env_passthrough:
- ANTHROPIC_API_KEY
- GH_TOKENAutomatic Detection
Wave's sandbox factory (internal/sandbox/factory.go) selects the backend based on the runtime.sandbox.backend field:
| Value | Behavior |
|---|---|
none (or empty) | No sandbox wrapping — adapter runs directly |
docker | Docker container isolation (requires docker on PATH and a running daemon) |
bubblewrap | Handled by the Nix flake dev shell, not this package |
Validation
Wave validates the Docker backend during preflight:
wave doctorIf the Docker daemon is not running, you will see:
docker daemon not available
Hint: Start Docker with: systemctl start docker (Linux) or open Docker Desktop (macOS/Windows)When to Use Docker vs Bubblewrap
| Scenario | Recommended Backend |
|---|---|
| Linux with Nix | bubblewrap (via nix develop) |
| macOS | docker (bwrap requires kernel namespaces not on Darwin) |
| CI/CD runners | docker (commonly available, no namespace requirements) |
| Local development without Nix | docker |
| Debugging sandbox issues | none (or nix develop .#yolo) |
Key sources: internal/sandbox/docker.go, internal/sandbox/factory.go, internal/sandbox/types.go
Manifest-Driven Adapter Config
The second layer projects manifest permissions into Claude Code's configuration files.
Persona Sandbox Configuration
personas:
navigator:
adapter: claude
permissions:
allowed_tools: [Read, Glob, Grep, "Bash(git log*)"]
deny: ["Write(*)", "Edit(*)"]
sandbox:
allowed_domains:
- api.anthropic.com
implementer:
adapter: claude
permissions:
allowed_tools: [Read, Write, Edit, Bash, Glob, Grep]
sandbox:
allowed_domains:
- api.anthropic.com
- github.com
- "*.github.com"
- proxy.golang.org
- sum.golang.orgRuntime Sandbox Configuration
runtime:
sandbox:
enabled: true
default_allowed_domains:
- api.anthropic.com
- github.com
env_passthrough:
- ANTHROPIC_API_KEY
- GH_TOKEN| Field | Description |
|---|---|
enabled | Enable sandbox configuration generation |
default_allowed_domains | Default network domains for all personas (persona config overrides) |
env_passthrough | Environment variables to pass to adapter subprocesses |
How Permissions Flow
wave.yaml → settings.json → CLAUDE.md
─────────────────────────────────────────────────────────────────────────
persona.permissions.allowed → permissions.allow → "Allowed Tools" section
persona.permissions.deny → permissions.deny → "Denied Tools" section
persona.sandbox.allowed_domains → sandbox.network.allowedDomains → "Network Access" section
runtime.sandbox.env_passthrough → curated subprocess env → (not in CLAUDE.md)Claude Code reads both settings.json (enforced) and CLAUDE.md (model awareness), providing defense-in-depth.
Environment Hygiene
The adapter constructs a curated environment instead of passing the full host environment:
Always included:
HOME,PATH,TERM,TMPDIR=/tmpDISABLE_TELEMETRY=1,DISABLE_ERROR_REPORTING=1CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1,DISABLE_BUG_COMMAND=1
Conditionally included:
- Variables listed in
runtime.sandbox.env_passthrough(if set in host env) - Step-specific env vars from pipeline configuration
Never included:
- Any other host environment variable (e.g.,
AWS_SECRET_ACCESS_KEY,DATABASE_PASSWORD)
Note: The curated environment model applies to the Claude adapter. Other adapters using the ProcessGroupRunner currently inherit the full host environment.
Defense-in-Depth Summary
From innermost to outermost:
| Layer | What It Does | Enforced By |
|---|---|---|
CLAUDE.md restrictions | Model is told what it can/cannot do | Prompt-level (advisory) |
settings.json permissions | Allow/deny rules enforced by Claude Code | Claude Code runtime |
| Claude Code sandbox | Bubblewrap/Seatbelt restricts Bash commands | OS-level (Bash only) |
| Docker container sandbox | Read-only container, cap-drop=ALL, network=none, no-new-privileges | Container-level (alternative to Nix bwrap) |
| Nix dev shell sandbox | Bubblewrap restricts the entire session | OS-level (everything) |
| Manifest | Single source of truth for all above | Wave configuration |
Troubleshooting
"bwrap: No permissions to create new namespace"
Your kernel may not support unprivileged user namespaces. Check:
cat /proc/sys/kernel/unprivileged_userns_clone
# Should be 1If 0, enable it (requires root):
sudo sysctl -w kernel.unprivileged_userns_clone=1Or use the yolo shell: nix develop .#yolo
"command not found" inside sandbox
The sandbox inherits the Nix-provided PATH. If a tool isn't found, add the package to commonPackages in flake.nix.
Docker doesn't work inside sandbox
Docker cannot run inside bubblewrap (it needs its own namespaces). Use nix develop .#yolo for steps that require Docker.