Version: 1.0.0-rc1
Date: 2026-07-02
Source: ~6,100-line bash script (sandy), installer (install.sh), egress proxy (proxy/, Go), test suites (test/run-tests.sh, test/run-integration-tests.sh)
Sandy is a self-contained command that runs an AI coding agent (Claude Code, Gemini CLI, OpenAI Codex CLI, OpenCode, or any comma-separated multi-agent combo) in a Docker container with filesystem isolation, network isolation, resource limits, and per-project credential sandboxes. One script, one command, zero configuration required.
SANDY_AGENT |
Image | Description |
|---|---|---|
claude (default) |
sandy-claude-code |
Claude Code — full feature support (channels, skill packs, synthkit, remote-control) |
gemini |
sandy-gemini-cli |
Gemini CLI — Google OAuth / ADC / Vertex AI / API key auth |
codex |
sandy-codex |
OpenAI Codex CLI — OPENAI_API_KEY (materialized as ephemeral auth.json) or ChatGPT OAuth; both as read-only mounts |
opencode |
sandy-opencode |
OpenCode (sst/opencode) — provider-agnostic; reads ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY natively, plus optional OAuth from ~/.local/share/opencode/auth.json. Local-LLM passthrough via SANDY_LOCAL_LLM_HOST. |
grok |
sandy-grok |
Grok Build (xAI) — installed from x.ai/cli/install.sh (prebuilt binary, relocated to /usr/local/bin); authenticates headless from XAI_API_KEY (or an in-container grok login OAuth session in ~/.grok). Model via GROK_MODEL (-m, default grok-4.5). Not in the all alias. |
<a>,<b>[,<c>[,<d>]] (e.g. claude,gemini, claude,codex, claude,gemini,codex,opencode) |
sandy-full |
Multi-agent combo — one tmux pane per agent, in the order listed |
all |
sandy-full |
Alias for claude,gemini,codex,opencode — all four agents in a 4-pane tmux session |
The previous both alias (= claude,gemini) was removed in v0.12. Using it now exits with an error pointing at the comma-separated syntax.
- Command-Line Interface
- Configuration System
- Versioning
- Per-Project Sandboxes
- Docker Image Build Pipeline
- Skill Pack System
- Container Runtime
- Network Isolation
- Protected Files
- SSH Agent Relay
- Credential Management
- Session Management
- Workspace Path Mapping
- Environment Detection
- Plugin Marketplace Management
- Channel Integration
- Auto-Update
- Security Model
- Test Suite
- Installation
- File Inventory
Appendices (Implementation Detail)
- A. Generated File Templates
- B. Runtime Parameters
- C. JSON Schemas
- D. Platform-Specific Behavior
- E. Container Launch Assembly
sandy # Interactive session (resume last or start new)
sandy -p "prompt" # One-shot prompt (no interactive session)
sandy --new # Force fresh session
sandy --resume # Open session picker (forwarded to claude)
sandy --remote # Remote-control server mode (headless)
| Flag | Behavior |
|---|---|
--agent A[,B,…] |
Select agent(s) for this launch (claude, gemini, codex, opencode, comma-combos, or all). Highest-precedence source for SANDY_AGENT — beats env var and both config tiers |
--rebuild |
Force rebuild all Docker images |
--build-only |
Build images and exit (for CI/prewarming) |
--upgrade |
Self-update sandy from GitHub |
--version |
Print version string (e.g. 1.0.1-dev-a1b2c3d) |
--help |
Show help text |
--print-protected-paths |
List protected files/dirs as file:/dir: lines (test-harness surface; see §9) |
Same pre-preflight family as the introspection flags below — these run before config load, mutex acquisition, or any image build, needing only a reachable Docker daemon. Human-readable stdout (action verbs), not introspection JSON.
| Flag | Behavior |
|---|---|
--prune-orphans |
Reap orphaned sandy_* networks (dead-owner, no attached container) and exit. Exit 0 (including "found none"), 1 only if Docker is unreachable. |
--gc [--dry-run] [--yes] |
One-shot global reclaim (#36, milestone 1.3.0): dead-owner sandy-*/sandy-proxy-* containers, orphaned sandy_* networks (delegates to the same lister/reaper --prune-orphans uses — one gate, no drift), orphaned per-project images (sandy-project-<x>), orphaned skill-pack images (sandy-skills(-base)?-<x>), and dangling sandy images (<none>:<none> scoped via the sandy.managed=1 build label). Prints a plan, then: nothing to reclaim → exit 0; --dry-run → prints the plan and exits 0 before any confirm; --yes skips the interactive y/N; a TTY without --yes is prompted; non-TTY without --yes errors and exits 1. Reap order: containers → networks → project images → skills images → dangling images last. Exit 0 on success, 1 if Docker is unreachable or an unrecognized sub-argument was given. See "Unified Resource Reclaim (sandy --gc, #36)" in §5 for the container-liveness predicate and provenance-label details. |
All introspection flags are fast-path handlers: they run before image builds, sandbox setup, mutex acquisition, and docker availability checks, and exit immediately without side effects. This makes them safe to call from non-privileged UI processes, CI tooling, and headless contexts. Output is single-line JSON on stdout with schema_version: 1.
| Flag | Behavior |
|---|---|
--print-schema |
Emit the static sandy schema: version, config keys (by tier with type/default/description), CLI flags, agents and their credential probe orders, protected path lists, skill packs, schema compatibility declaration. Always exits 0. |
--print-state |
Emit runtime state: sandy_home, installed sandy images, per-sandbox metadata (.sandy_created_version, .sandy_last_version, size if cheaply obtainable), approval files (one per workspace hash), docker_reachable (bool), running sandy containers (filtered by image name prefix), orphan_networks (int, reap-eligible sandy_* network count), and — full mode only (#36, milestone 1.3.0) — dangling_images and orphaned_containers (ints; null in light mode even when real orphans exist, mirroring image_stale's full-mode-only convention). When Docker is unreachable, docker_reachable: false, running_containers: null, orphan_networks: null, dangling_images: null, orphaned_containers: null. Always exits 0. |
--validate-config PATH |
Parse a config file, classify it as privileged (path under $SANDY_HOME/) or passive (anywhere else), and emit {schema_version, path, source_tier, errors[], warnings[], unknown_keys[], privileged_keys_requiring_approval[], approval_status, approval_file_path}. Exits 1 if the file does not exist or the flag was called with no argument; exits 0 otherwise (a "pending" approval is not an error — it's the normal state before first interactive approval). |
See SPEC_INTROSPECTION.md for field-by-field documentation and the stability contract (additive changes within schema_version=1, breaking changes bump the version).
| Flag | Effect |
|---|---|
-v |
Show startup section headers, pause on exit |
-vv |
Add bash trace to user-setup.sh |
-vvv |
Also trace entrypoint.sh and show docker run flags |
All unrecognized arguments (including -p "prompt", --resume, --continue) are forwarded to the claude binary inside the container.
Flags are parsed with a while [ $# -gt 0 ] loop with shift. Sandy's flags are consumed; everything else is collected into REMAINING_ARGS and forwarded to claude.
$SANDY_HOME/config— user-level defaults (typically~/.sandy/config) — privileged tier$SANDY_HOME/.secrets— user-level credentials — privileged tier.sandy/config— per-project overrides — passive tier.sandy/.secrets— per-project credentials — passive tier
Later files override earlier values, subject to tier restrictions below.
The config parser does not use source. It reads lines via grep -E '^[A-Z_]+=.+', strips leading/trailing single and double quotes from values (in order: double then single), validates the key against a tier-specific allowlist, and exports only recognized keys. Lines not matching the grep pattern are silently ignored — this includes comments (#), blank lines, and lowercase keys. If the config file is unreadable or missing, loading silently succeeds.
Env-var precedence. Before any _load_sandy_config call, sandy snapshots which keys are already set in the process environment (_sandy_snapshot_env_keys populates _SANDY_ENV_SET_KEYS). The loader checks every key against the snapshot and skips the export if the key was env-set. This guarantees env-var precedence: SANDY_AGENT=codex sandy ... and shell-level export win over both privileged (host) and passive (workspace) config files. Without the snapshot the first config load would export values that the second load would see as "already set," collapsing the workspace-overrides-host semantic. Final precedence: --agent CLI flag > env var > workspace passive > host privileged > sandy default.
Each call to _load_sandy_config takes a tier argument (privileged or passive). Privileged-tier sources may set any recognized key immediately. Passive-tier sources (the two workspace files) may set passive-safe keys immediately; any privileged-only key found in a passive source is collected into _PASSIVE_PRIVILEGED_PENDING rather than exported. After both passive sources load, _resolve_passive_privileged_approval() runs: it hashes the sorted KEY=VALUE set, checks $SANDY_HOME/approvals/passive-<wd-hash>.list (first line is the sha256 of the approved set), and either (a) silently exports if the hash matches, (b) prompts y/N on /dev/tty the first time with the exact KEY=VALUE list plus a rationale about repo-committed configs, or (c) fails closed in non-interactive mode (_sandy_is_headless=true or non-TTY stdin) with a pointer to "launch sandy interactively from this directory to approve." On approval, the file is written with the hash, a # workspace: comment, a # approved: timestamp, and the sorted KEY=VALUE lines, mode 600. Any edit to the workspace config that adds, removes, or changes a privileged key invalidates the hash and re-prompts on the next launch. Revocation is rm of the approval file. This prevents a malicious .sandy/config committed to a repo from disabling isolation, forwarding an SSH agent, or exfiltrating credentials without a deliberate, workspace-scoped user opt-in.
CI / test-harness escape hatch: SANDY_AUTO_APPROVE_PRIVILEGED=1 in the process environment bypasses the prompt and exports the pending keys in-memory without writing an approval file. This is intentionally env-only — SANDY_AUTO_APPROVE_PRIVILEGED is not in the passive allowlist, so a committed .sandy/config cannot set it. Sandy's own test/run-tests.sh and test/run-integration-tests.sh set this flag at the top of each harness so the suites can run from the sandy repo directory (which carries a real GEMINI_API_KEY in .sandy/.secrets for integration testing) without blocking on stdin.
Privileged-only keys (allowed only from $SANDY_HOME/config and $SANDY_HOME/.secrets):
SANDY_SSH, SANDY_SKIP_PERMISSIONS, SANDY_ALLOW_NO_ISOLATION, SANDY_ALLOW_LAN_HOSTS, SANDY_LOCAL_LLM_HOST, SANDY_ALLOW_HOSTS, SANDY_EXTRA_ENV, SANDY_AGENT_ARGS, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, GEMINI_API_KEY, OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, SANDY_SCREENSHOT_DIR, SANDY_GEMINI_EXTENSIONS, TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_SENDERS, DISCORD_BOT_TOKEN, DISCORD_ALLOWED_SENDERS
Passive-safe keys (allowed from any source):
SANDY_AGENT, SANDY_MODEL, SANDY_EFFORT, SANDY_CPUS, SANDY_MEM, SANDY_GPU, SANDY_SKILL_PACKS, SANDY_CHANNELS, SANDY_CHANNEL_TARGET_PANE, SANDY_VERBOSE, SANDY_VENV_OVERLAY, SANDY_EGRESS_PROXY, SANDY_EGRESS_NO_ISOLATION, SANDY_EGRESS_STRICT, SANDY_EGRESS_LOG, SANDY_ALLOW_WORKFLOW_EDIT, CLAUDE_CODE_MAX_OUTPUT_TOKENS, GEMINI_MODEL, SANDY_GEMINI_AUTH, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION, GOOGLE_GENAI_USE_VERTEXAI, CODEX_MODEL, SANDY_CODEX_AUTH, OPENCODE_MODEL, SANDY_OPENCODE_AUTH, GROK_MODEL, SANDY_GROK_AUTH, SANDY_TOOL_AUDIT
After all config sources are loaded, SANDY_ALLOW_LAN_HOSTS (if set) is split on , and each entry is validated. Any entry matching 0.0.0.0/0 or ::/0 causes a hard error (exit 1) with a clear message. This check runs even against privileged-tier values — a user-level config with a world-open allowlist is almost always a mistake, and the launch refusal prevents silent negation of LAN isolation.
The table below is generated from sandy --print-schema (the _sandy_key_metadata heredoc in the sandy script is the source of truth). Run test/regen-config-docs.sh after editing, adding, or retiering a key — test/run-tests.sh asserts the blocks are in sync.
| Variable | Tier | Default | Since | Stability | Description |
|---|---|---|---|---|---|
SANDY_SSH |
privileged | token |
0.1.0 | stable | SSH auth mode: 'token' uses gh CLI (HTTPS); 'agent' forwards the host SSH agent. |
SANDY_SKIP_PERMISSIONS |
privileged | true |
0.1.0 | stable | Skip Claude Code's in-session permission prompts (default: true). |
SANDY_ALLOW_NO_ISOLATION |
privileged | 0 |
0.1.0 | stable | Allow launch when iptables rules cannot be applied (Linux only). |
SANDY_ALLOW_LAN_HOSTS |
privileged | unset | 0.7.9 | stable | Comma-separated IPs/CIDRs to allow through LAN isolation. World-open entries rejected. |
SANDY_LOCAL_LLM_HOST |
privileged | unset | 0.12.0 | stable | Single host:port (e.g. '127.0.0.1:11434') to allow through LAN isolation, typically for a local LLM. With the egress proxy on (default) the proxy's forward listener relays host.docker.internal: to the host; with the proxy off (=0, Linux) it inserts one iptables ACCEPT and maps host.docker.internal. |
SANDY_ALLOW_HOSTS |
privileged | unset | 0.14.0 | stable | Comma-separated extra egress-proxy allowlist entries (exact host, '*.suffix' wildcard, or 'host:port' for CONNECT/SSH). Appended to the built-in default allowlist. In strict mode (SANDY_EGRESS_PROXY=2) these are the only hosts reachable beyond defaults; in permissive mode (=1) they are LAN-exceptions reachable despite the private-IP block. Privileged tier so workspace config requires approval. |
SANDY_EXTRA_ENV |
privileged | unset | 0.12.0 | stable | Comma-separated env-var names to forward into the container (e.g. 'HA_TOKEN,FOO_API_KEY'). Values resolve env > workspace .sandy/.secrets > workspace .sandy/config > ~/.sandy/.secrets > ~/.sandy/config. The privileged tier gates the NAME list (workspace config setting SANDY_EXTRA_ENV itself requires approval); once approved, values may come from any source. |
SANDY_AGENT_ARGS |
privileged | unset | 1.3.0 | stable | Extra command-line arguments appended to the agent command (claude/codex/gemini/opencode) on EVERY launch — bare sandy, headless -p, the --start daemon, and sandy-ui alike. Whitespace-split into argv (no embedded-space/quoting support in v1) and forwarded through the same per-agent translation as command-line pass-through args, ordered AFTER sandy's own flags and BEFORE any command-line args. Privileged tier: free from host ~/.sandy/config, but from a workspace .sandy/config it triggers the per-workspace approval prompt (headless/non-TTY drops it). Never eval'd. Typical use: a fixed --mcp-config or project feature flags. In multi-agent combos the same args go to every pane; per-agent variants are a possible follow-up. |
ANTHROPIC_API_KEY |
privileged | unset | 0.1.0 | stable | Anthropic API key for Claude Code. Not required when using Claude Max OAuth. |
CLAUDE_CODE_OAUTH_TOKEN |
privileged | unset | 0.7.0 | stable | Claude Code OAuth token (alternative to ANTHROPIC_API_KEY). |
GEMINI_API_KEY |
privileged | unset | 0.9.0 | stable | Google API key for Gemini CLI. |
OPENAI_API_KEY |
privileged | unset | 0.10.0 | stable | OpenAI API key for Codex CLI. |
XAI_API_KEY |
privileged | unset | 1.5.0 | stable | xAI API key for Grok Build (docs.x.ai). Enables fully-headless auth (resolution: model.api_key > env_key > session token > XAI_API_KEY); alternative is an interactive 'grok login' OAuth session inside the container. |
GOOGLE_API_KEY |
privileged | unset | 0.9.0 | stable | Google API key for Vertex AI / ADC. |
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS |
privileged | unset | 0.1.0 | experimental | Enable Claude Code experimental agent-teams feature. |
SANDY_SCREENSHOT_DIR |
privileged | unset | 0.12.0 | stable | Host directory containing screenshots; mounted read-only at /home/claude/screenshots and exposed as $SANDY_SCREENSHOTS_PATH inside the container. Enables /ss skill across agents. |
SANDY_GEMINI_EXTENSIONS |
privileged | unset | 0.9.0 | stable | Comma-separated Gemini extensions to enable. |
TELEGRAM_BOT_TOKEN |
privileged | unset | 0.7.6 | stable | Telegram bot token for the channel relay. |
TELEGRAM_ALLOWED_SENDERS |
privileged | unset | 0.7.6 | stable | Comma-separated Telegram user IDs allowed to send messages. |
DISCORD_BOT_TOKEN |
privileged | unset | 0.7.6 | stable | Discord bot token for the channel relay. |
DISCORD_ALLOWED_SENDERS |
privileged | unset | 0.7.6 | stable | Comma-separated Discord user IDs allowed to send messages. |
SANDY_AGENT |
passive | claude |
0.9.0 | stable | Agent(s) to launch. Comma-separated (e.g. 'claude,codex'). 'all' = 'claude,gemini,codex,opencode'. |
SANDY_MODEL |
passive | claude-opus-4-8 |
0.1.0 | stable | Model ID for the Claude agent. |
SANDY_EFFORT |
passive | unset | 1.6.0 | stable | Reasoning effort for the Claude agent (claude only), applied as 'claude --effort '. Empty leaves Claude Code's own default (currently 'high'). Levels are model-dependent; an unsupported level falls back to the highest supported at or below it. Passive-safe (effort does not affect isolation). Recorded in sandy-session.json. |
SANDY_CPUS |
passive | unset | 0.1.0 | stable | CPU limit for container (default: auto-detected). |
SANDY_MEM |
passive | unset | 0.1.0 | stable | Memory limit for container (e.g. '8g'; default: auto-detected). |
SANDY_GPU |
passive | unset | 0.7.5 | stable | GPU passthrough: 'all', or device IDs like '0' / '0,1'. |
SANDY_SKILL_PACKS |
passive | unset | 0.7.10 | stable | Comma-separated skill pack names (e.g. 'gstack'). |
SANDY_CHANNELS |
passive | unset | 0.7.6 | stable | Comma-separated channel names (e.g. 'telegram,discord'). |
SANDY_CHANNEL_TARGET_PANE |
passive | 0 |
0.9.0 | stable | Which tmux pane in multi-agent mode receives channel messages. |
SANDY_VERBOSE |
passive | 0 |
0.8.0 | stable | Verbosity (0=quiet, 1=verbose, 2=debug, 3=full trace). |
SANDY_VENV_OVERLAY |
passive | 1 |
0.10.0 | stable | Bind-mount a sandbox-owned .venv over the workspace's .venv inside the container. |
SANDY_EGRESS_PROXY |
passive | 1 |
0.14.0 | stable | DEPRECATED — use SANDY_EGRESS_NO_ISOLATION / SANDY_EGRESS_STRICT. Kept as a back-compat alias: 0->NO_ISOLATION=1 (off), 1->permissive (default), 2->STRICT=1 (strict). From a workspace .sandy/config, =0 is approval-gated (weakening), matching the new keys. |
SANDY_EGRESS_NO_ISOLATION |
passive | 0 |
1.0.0 | stable | Turn the egress proxy OFF — legacy path (Linux iptables-only; NO network isolation on macOS). WEAKENS isolation, so from a workspace .sandy/config it is quarantined to the per-workspace approval prompt (a committed config cannot silently disable isolation). Mutually exclusive with SANDY_EGRESS_STRICT. Default 0 (proxy on). |
SANDY_EGRESS_STRICT |
passive | 0 |
1.0.0 | stable | Run the egress proxy in strict mode (allow only the built-in default allowlist + SANDY_ALLOW_HOSTS; deny all other internet). STRENGTHENS isolation, so =1 is passive-safe from any source; =0 (downgrading a host-configured strict) is approval-gated from a workspace source. Mutually exclusive with SANDY_EGRESS_NO_ISOLATION. Default 0 (permissive). |
SANDY_EGRESS_LOG |
passive | 0 |
1.4.0 | stable | Log which hosts the agent's egress actually reached (HF-incident Issue 4). The proxy logs each DISTINCT allowed host:port once (deduped) to proxy.log; at session end sandy prints an egress summary (distinct hosts reached + denial count). 0=off (deny-only, the pre-1.4 behavior); 1=per-connection allow lines + session-end summary; summary=session-end summary only (the proxy still records allows to build it). Passive-safe: it only ADDS visibility. Hostnames only — TLS is never terminated, no payload; the log stays in $SANDBOX_DIR. |
SANDY_ALLOW_WORKFLOW_EDIT |
passive | 0 |
0.11.1 | stable | Remove .github/workflows from the read-only protection list. |
CLAUDE_CODE_MAX_OUTPUT_TOKENS |
passive | 128000 |
0.6.0 | stable | Max output tokens per Claude response. |
GEMINI_MODEL |
passive | unset | 0.9.0 | stable | Gemini model override. |
SANDY_GEMINI_AUTH |
passive | auto |
0.9.0 | stable | Gemini credential probe strategy. |
GOOGLE_CLOUD_PROJECT |
passive | unset | 0.9.0 | stable | Google Cloud project for Vertex AI. |
GOOGLE_CLOUD_LOCATION |
passive | unset | 0.9.0 | stable | Google Cloud location for Vertex AI. |
GOOGLE_GENAI_USE_VERTEXAI |
passive | unset | 0.9.0 | stable | Use Vertex AI backend for Gemini. |
CODEX_MODEL |
passive | unset | 0.10.0 | stable | Codex model override. |
SANDY_CODEX_AUTH |
passive | auto |
0.10.0 | stable | Codex credential probe strategy. |
OPENCODE_MODEL |
passive | unset | 0.12.0 | stable | OpenCode model override (provider/model format, e.g. 'anthropic/claude-sonnet-4'). |
SANDY_OPENCODE_AUTH |
passive | auto |
0.12.0 | stable | OpenCode credential probe strategy. |
GROK_MODEL |
passive | unset | 1.5.0 | stable | Grok Build model override (passed as -m; default grok-4.5). |
SANDY_GROK_AUTH |
passive | auto |
1.5.0 | stable | Grok Build credential probe strategy. |
SANDY_TOOL_AUDIT |
passive | 0 |
1.4.0 | stable | Seed a Claude Code PreToolUse audit hook (HF-incident Issue 6) that appends {ts,tool,args} JSONL to ~/.claude/tool-audit.jsonl for per-session tool-use telemetry — instrumenting the agent harness itself, not just the box. Only-if-absent: a user's own PreToolUse hook is never clobbered. Passive-safe (only ADDS visibility). Claude-only (no equivalent seam for codex/gemini/opencode). Not tamper-proof against a determined agent (runs in-box) — telemetry for the primary wrong-but-not-evil adversary. Default 0 (off). |
SANDY_AUTO_APPROVE_PRIVILEGED |
env-only | unset | 0.11.2 | internal | Bypass the passive-privileged approval prompt. Intended for CI / test harnesses only. |
SANDY_DEBUG_CLEANUP |
env-only | unset | 0.11.4 | internal | Print session-stub cleanup diagnostics on exit. |
SANDY_MODEL is validated against ^[a-zA-Z0-9._-]+$ to prevent injection.
SANDY_VERSION:X.Y.Zfor releases,X.Y.(Z+1)-devfor post-release developmentSANDY_COMMIT: Empty in source; baked in byinstall.shfor local installs; detected from git at runtime if empty
sandy_full_version() produces strings like 0.7.11-dev-a1b2c3d by combining SANDY_VERSION and the commit hash.
Compares only SANDY_VERSION (not hash) against GitHub release tags via https://api.github.com/repos/rappdw/sandy/releases/latest. Result cached in ~/.sandy/.update_check with 24-hour TTL.
Each project directory gets a sandbox at ~/.sandy/sandboxes/<NAME>-<HASH>/:
<NAME>: Sanitizedbasenameof project directory (alphanumeric, dots, hyphens)<HASH>: First 8 characters of SHA256 of the canonicalized project path (viapwd -P— resolves symlinks and folds case-collisions on case-insensitive filesystems)
Each launch also writes $SANDBOX_DIR/WORKSPACE.json (non-hidden) — a structured forensic record of which workspace the sandbox belongs to. Fields:
{
"schema_version": 1,
"sandbox_name": "myproject-a1b2c3d4",
"workspace_path": "/Users/dan/dev/myproject",
"workspace_path_uncanonicalized": "/Users/dan/dev/MyProject", // optional
"first_seen_at": "2026-04-26T17:33:01Z",
"last_seen_at": "2026-04-27T09:14:22Z",
"sandy_version_first": "0.11.5-dev",
"sandy_version_last": "0.12.0-dev"
}workspace_path_uncanonicalized is present only when the user's typed pwd differs from the canonical pwd -P (a case-collision or symlinked alias) — useful when diagnosing why two sandboxes ended up with overlapping state. first_seen_at is preserved across launches; the _last fields refresh on every launch.
On launch, sandy scans sibling sandbox directories: any whose workspace_path field matches the current WORK_DIR is reported as a likely duplicate via warn. For legacy sandboxes lacking WORKSPACE.json entirely, sandy falls back to a heuristic: case-insensitive <NAME> match with a different <HASH>. Detection is read-only — sandy never auto-merges sandbox state, since accumulated settings/plugins/package caches make manual review the right call.
As of v0.9.0, the sandbox directory contains sibling per-agent subdirs (claude/, gemini/, codex/ — the last added in v0.10.0 — and opencode/ — added in v0.13.0) so any multi-agent combo can coexist in the same sandbox. The first three are mounted at ~/.claude, ~/.gemini, and ~/.codex inside the container; OpenCode straddles two XDG paths and uses sibling opencode/config/ and opencode/share/ subdirs mounted at ~/.config/opencode and ~/.local/share/opencode respectively.
~/.sandy/sandboxes/<name>-<hash>/
├── claude/ # → /home/claude/.claude
│ ├── settings.json
│ ├── projects/
│ ├── plugins/
│ ├── statsig/
│ ├── channels/
│ ├── hooks/
│ └── history.jsonl
├── gemini/ # → /home/claude/.gemini
│ ├── settings.json
│ ├── commands/ # TOML slash commands
│ ├── extensions/
│ └── tmp/ # session history
├── codex/ # → /home/claude/.codex
│ ├── config.toml # sandbox_mode + [notice] + [projects] trust
│ ├── log/
│ ├── memories/
│ └── skills/ # SKILL.md files (synthkit seeds md2pdf etc.)
├── pip/ # → /home/claude/.pip-packages
├── uv/ # → /home/claude/.local/share/uv
├── npm-global/ # → /home/claude/.npm-global
├── go/ # → /home/claude/go
├── cargo/ # → /home/claude/.cargo
├── gstack/ # legacy gstack state location; renamed to gstack.migrated/ on first 0.12+ launch
├── gstack.migrated/ # post-migration breadcrumb — safe to delete after verifying $WORK_DIR/.gstack/ works
├── workspace-commands/ # → .claude/commands/ (writable overlay)
├── workspace-agents/ # → .claude/agents/
└── workspace-plugins/ # → .claude/plugins/
<NAME>.claude.json is stored at ~/.sandy/sandboxes/<NAME>.claude.json (outside the sandbox dir) to avoid mount conflicts.
Layout migration (v1 → v1.5): On each launch, sandy detects the v1 layout marker (settings.json at the sandbox top level with no claude/ subdir) and moves Claude-owned entries into claude/. Idempotent; pre-existing pkg-persistence and workspace-* directories are untouched.
Whenever claude is in SANDY_AGENT, sandy regenerates <NAME>/claude/settings.json on every launch (not just first run). As of 0.11.3 this is a plain rw file inside the sandbox mount — the pre-0.11.3 :ro sidecar overlay was reverted because it broke /plugin install with EROFS. The steps are:
- Base: read host
~/.claude/settings.json(or start from{}if absent). - Overlay: read the previous sandbox
<NAME>/claude/settings.json(if it exists) and preserveenabledPluginsfrom it onto the base, so plugin installs survive across launches. - Merge sandy-required defaults (
teammateMode,spinnerTipsEnabled,skipDangerousModePermissionPrompt) if not already present. WhenSANDY_SKIP_PERMISSIONS=true(the default), also setpermissions.defaultMode = "bypassPermissions"(overwrite, not merge — togglingSANDY_SKIP_PERMISSIONSbetween launches reliably propagates). WhenSANDY_SKIP_PERMISSIONS=false, the key is removed if previously sandy-set.permissions.disableBypassPermissionsMode(host policy) is left alone. - Merge
extraKnownMarketplacesentries forclaude-plugins-officialandsandy-plugins; scrub deprecated entries (thinkkit,ait,pka-skills). - Write the merged result back to
<NAME>/claude/settings.json. - (First run only) Copy host
~/.claude/.claude.json→ sandbox<NAME>.claude.json, stripping theprojectskey. - (First run only) Copy host
~/.claude/statsig/→ sandboxclaude/statsig/(refreshed on every launch from a separate "always-refresh statsig" block). - (First run only) Create all persistent subdirectories.
At container launch, <NAME>/claude is bind-mounted rw at /home/claude/.claude — there is no child :ro overlay on settings.json. The agent can write to it (required for /plugin install), but sandy-managed keys are re-overwritten on the next launch.
Consequence: host-side edits to ~/.claude/settings.json are picked up automatically on the next sandy launch, and the sandy-managed keys are always re-derived. Agent-owned state (enabledPlugins) is preserved across launches. The trade-off vs a strict reset: the agent can modify its own settings mid-session, and those modifications (to keys sandy doesn't manage) persist into the next session as well — the merge overlays rather than wipes.
Whenever gemini is in SANDY_AGENT, sandy creates gemini/ and its commands/, extensions/, tmp/ subdirs. Gemini settings.json is not seeded from the host (Gemini has no direct host-settings equivalent for sandy to copy).
For SANDY_AGENT=codex, sandy creates codex/ and seeds codex/config.toml (first run only) with:
model = "gpt-5.5"
sandbox_mode = "danger-full-access"
[notice]
hide_full_access_warning = true
hide_gpt5_1_migration_prompt = true
"hide_gpt-5.1-codex-max_migration_prompt" = true
hide_rate_limit_model_nudge = true
hide_world_writable_warning = trueThe model = "gpt-5.5" line sets a stable default model; users can override via CODEX_MODEL env var. The sandbox_mode = "danger-full-access" line is required — codex's Landlock sandbox does not nest cleanly inside sandy's Docker container. Sandy provides the outer isolation, and the CLI is additionally invoked with --sandbox danger-full-access as belt-and-suspenders in build_codex_cmd. The [notice] block suppresses first-run prompts; all five documented keys are seeded even if codex adds more over time.
One-shot model migration: existing sandboxes seeded with the old default (model = "gpt-5.4") are auto-bumped to gpt-5.5 on next launch. The migration matches the exact previous default line — any user-customized model (anything other than "gpt-5.4") is preserved untouched.
The [projects."<workspace>"] trust_level = "trusted" entry is appended at session start by user-setup.sh (not at host-time) because it needs the container-side $SANDY_WORKSPACE path. Re-launches are idempotent: the entry is only appended if a matching line is not already present.
When opencode is in SANDY_AGENT, sandy creates opencode/config/ and opencode/share/ (mounted at ~/.config/opencode and ~/.local/share/opencode inside the container respectively). The seed logic for opencode/config/opencode.json runs whenever that file is missing in the sandbox and resolves three input states:
-
Host config exists at
$HOME/.config/opencode/opencode.json→cpit into the sandbox. Preferred path; the user's explicit provider/model preferences win. -
No host config but
SANDY_LOCAL_LLM_HOSTis set → auto-generate. Sandy probeshttp://${SANDY_LOCAL_LLM_HOST}/v1/models(3-second timeout, host-sidecurl) for the served model id, prefersjqfor JSON parsing and falls back to agrep/sedregex extracting the first"id":"…". If the probe yields a model id, sandy writes a single-provider, single-modelopencode.jsonusing the@ai-sdk/openai-compatibleSDK package, withbaseURLset tohttp://host.docker.internal:<port>/v1, the model id registered, and"model": "local/<model-id>"pinned as the default. The model id may contain slashes (e.g.RedHatAI/gemma-4-31B-it-FP8-block); opencode parsesprovider/modelon the first slash, solocal/<model-id>works regardless. If the probe fails, sandy emits a warning and proceeds without writing a config. -
Neither → opencode would silently fall back to its built-in default model (currently
gemini-3-pro-preview), which fails on first request withoutGOOGLE_GENERATIVE_AI_API_KEY. Sandy emits awarn-level banner enumerating the three resolutions: export an API key, write the host config, or setSANDY_LOCAL_LLM_HOST. Launch proceeds — the user may have an out-of-band auth path the warning didn't anticipate.
The auto-generated config is sandbox-scoped only — sandy never writes to $HOME/.config/opencode/. To customize, the user copies the generated file to $HOME/.config/opencode/opencode.json, after which the next sandbox creation will prefer state 1.
Sandy generates all Dockerfiles, entrypoint scripts, and config files at runtime in $SANDY_HOME/. Each phase has content-hash-based caching — images only rebuild when their inputs change.
Dockerfile: Dockerfile.base
Rebuild trigger: Content hash of Dockerfile.base changes, or --rebuild flag
Contents:
- OS: Debian trixie-slim
- System tools: build-essential, git, git-lfs, jq, ripgrep, socat, tmux, curl, cmake, openssh-client, less, pkg-config, gosu
- GitHub CLI:
gh - Node.js 24 LTS: Via NodeSource
- Go 1.26: Multi-arch binary from go.dev (latest 1.26.x resolved at build time)
- Rust stable: Via rustup (installed to
/usr/local/rustupand/usr/local/cargo) - Bun: Via
curl https://bun.sh/install - uv: Via
curl https://astral.sh/uv/install.sh(installed to/usr/local/bin) - Python 3: Debian system Python + python3-venv
- Libraries: libcairo2, libgdk-pixbuf-2.0-0, libpango-1.0-0, libssl-dev, ncurses-term
- User:
claude(UID 1001, shell/bin/bash)
Dockerfile: Dockerfile
Rebuild trigger: Content hash of (Dockerfile + entrypoint.sh + user-setup.sh + tmux.conf) changes, base image rebuilt, Claude Code version update, or --rebuild flag
Contents:
FROM sandy-base- Claude Code: Native binary installed via
curl https://claude.ai/install.sh, relocated to/usr/local/bin/claudeand/opt/claude-code - synthkit dependencies: libpango1.0-dev, libcairo2-dev, libgdk-pixbuf-2.0-dev (WeasyPrint needs these)
- synthkit: Installed via
UV_TOOL_DIR=/opt/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install synthkit COPY: entrypoint.sh, user-setup.sh, tmux.conf- Claude Code version cached at
/opt/claude-code/.version
Dockerfile: Dockerfile.gemini
Rebuild trigger: Content hash changes, base image rebuilt, or --rebuild flag
FROM sandy-base + npm install -g @google/gemini-cli + synthkit. Used only when SANDY_AGENT=gemini.
Dockerfile: Dockerfile.codex
Rebuild trigger: Content hash changes, base image rebuilt, Codex CLI version update detected, or --rebuild flag
Contents:
FROM sandy-base- Codex CLI:
npm install -g @openai/codex(ships a prebuilt Rust binary per platform; Node is only the install vehicle) - Version cached at
/opt/codex/.version - synthkit deps (libpango/cairo/gdk-pixbuf) + synthkit itself (so
md2pdf,md2doc,md2html,md2emailare on PATH) COPY: entrypoint.sh, user-setup.sh, tmux.conf
Used only when SANDY_AGENT=codex. The update check hits https://api.github.com/repos/openai/codex/releases/latest (not /releases) — upstream flags stable releases there, so sandy inherits their judgment rather than inventing a prerelease filter. The tag name rust-vX.Y.Z is stripped with sed -E 's/.*"rust-v?([0-9][^"]*)"$/\1/'. On parse failure the check returns no-update (stale but working).
Dockerfile: Dockerfile.opencode
Rebuild trigger: Content hash changes, base image rebuilt, OpenCode version update detected, or --rebuild flag
Contents:
FROM sandy-base- OpenCode:
npm install -g opencode-ai(theopencode-aipackage ships per-platform binaries viaoptionalDependenciesplus a postinstall script that selects the right one) - Version cached at
/opt/opencode/.version - synthkit deps (libpango/cairo/gdk-pixbuf) + synthkit itself (so
md2pdf,md2doc,md2html,md2emailare on PATH; OpenCode does not yet auto-discover skills, but synthkit is useful as a general-purpose toolkit in the session) COPY: entrypoint.sh, user-setup.sh, tmux.conf
Used only when SANDY_AGENT=opencode. The update check hits https://registry.npmjs.org/opencode-ai/latest and parses "version":"X.Y.Z" with the same sed -E 's/.*"([^"]+)"$/\1/' shape as the gemini check.
Dockerfile: Dockerfile.skills-base
Rebuild trigger: Content hash changes, or Phase 2 image rebuilt
Only generated when: SANDY_SKILL_PACKS is set and a pack requires heavy base dependencies
Contents (for gstack):
FROM sandy-claude-code- Playwright installed via npm
- Chromium browser installed via
npx playwright install chromium - System deps for Chromium via
npx playwright install-deps chromium
This image changes rarely (only when Playwright version changes) and caches the ~400MB Chromium download.
Dockerfile: Dockerfile.skills
Rebuild trigger: Content hash changes (new version SHA in download URL), base skills image rebuilt, or Phase 2 image rebuilt
Contents (for gstack):
FROM sandy-skills-base-<pack>- Download gstack source tarball at pinned version/SHA
bun install+bun run build- Make
bin/*executable
This image rebuilds whenever a new commit is detected on the skill pack repo (fast, since Chromium is cached in the base).
Dockerfile: .sandy/Dockerfile in project directory
Rebuild trigger: Content hash changes, any upstream image rebuilt
Build context: .sandy/ directory
User-provided Dockerfile must declare ARG BASE_IMAGE and use FROM ${BASE_IMAGE}. Sandy invokes docker build --build-arg BASE_IMAGE=<IMAGE_NAME> -t sandy-project-<name>-<hash> .sandy/ where <IMAGE_NAME> is the most-derived image from the build chain (skills image if skill packs enabled, otherwise sandy-claude-code).
Per-workspace approval gate (_sandy_project_dockerfile_approved, HF-incident Issue 7). Building .sandy/Dockerfile runs its RUN commands on the host docker daemon with unfiltered network (the build predates and bypasses the egress proxy) and takes all of .sandy/ as context — so an agent that writes $WORKSPACE/.sandy/Dockerfile in one session would get host code-execution on the next launch. Before building, sandy gates on explicit per-workspace approval: a sha256 of the Dockerfile content is checked against $SANDY_HOME/approvals/dockerfile-<workspace-hash>.list (same machinery/format as the passive-privileged config gate). An unchanged, already-approved Dockerfile proceeds silently; a new or edited one prints the Dockerfile and a warning and prompts y/N on an interactive TTY. Fail-closed when non-interactive (_sandy_is_headless or no tty): sandy skips the project build entirely and runs the base agent image, with a pointer to approve interactively — so a committed/agent-written Dockerfile can never build unattended (in CI, --start, or sandy-ui). SANDY_AUTO_APPROVE_PRIVILEGED=1 (env-only, same as the config gate) bypasses the prompt for trusted test harnesses. Approval persists per workspace; any Dockerfile edit re-prompts; revoke with rm of the approval file. The .sandy/ directory is additionally in the protected-dirs list (§9), so an existing one is :ro in-session.
Each phase stores its content hash in $SANDY_HOME/:
| File | Phase |
|---|---|
.base_build_hash |
Phase 1 |
.build_hash |
Phase 2 (claude) |
.build_hash_gemini |
Phase 2 (gemini) |
.build_hash_codex |
Phase 2 (codex) |
.build_hash_both |
Phase 2 (claude+gemini) |
.skills_base_build_hash |
Phase 2.5a |
.skills_build_hash |
Phase 2.5b |
<sandbox>/.project_build_hash |
Phase 3 |
A phase rebuilds if: hash differs from stored, upstream phase was rebuilt, Docker image doesn't exist locally, or --rebuild flag is set.
Provenance label. Every docker build sandy runs — all six sites: base, proxy, agent (final), skills-base, skills (final), per-project — stamps --label sandy.managed=1. This is the scoping filter sandy --gc's three image listers use so a dangling <none>:<none> image left by an unrelated tool's build churn is never mistaken for sandy's own. Retroactive gap: images built by a pre-1.3.0 sandy lack the label and are invisible to the dangling-image lister; the gap closes naturally as images get rebuilt going forward (predecessor-image GC, above, still reclaims those one-off via _sandy_prune_old_image). The running agent container (RUN_FLAGS) and the proxy sidecar (proxy_run) also carry an analogous --label sandy.managed=true — additive future-proofing, not (yet) the operative container-liveness predicate below.
Container-liveness predicate. --gc's dead-owner-container lister (_sandy_dead_owner_containers_list) reuses the daemon-mode D6/D9 rule verbatim: the container is truth only with a LIVE inner tmux session. One docker ps -a --filter 'name=^/sandy-' enumerates every candidate; classification is a two-pass walk over the captured output, because a proxy sidecar's liveness is a function of its paired agent's liveness, which is only known once every agent has been classified. Agent-vs-proxy is decided by image, never by name prefix — a workspace whose sanitized basename happens to be proxy produces a container literally named sandy-proxy-<hash> running a real agent image, and a name-prefix test would misclassify it as a proxy sidecar (a real pre-release regression, fixed before 1.3.0 shipped).
Pass 1 — every AGENT container (any recognized sandy image other than sandy-proxy: sandy-base, sandy-claude-code, sandy-gemini-cli, sandy-codex, sandy-opencode, sandy-grok, sandy-full, sandy-project-*, sandy-skills*; the image-name gate is best-effort and deliberately name-based rather than label-based so it works retroactively against containers a pre-1.3.0 sandy started):
sandy.daemon=truelabeled: not running → dead, reap. Running → probedocker exec -u "$(id -u)" <cid> tmux has-session -t sandy, retried 5x with a 1s sleep between attempts (mirroring--start's own D6 idempotency retry) so a container whose supervisor hasn't created the tmux session yet isn't misread as a zombie mid-startup: success → ALIVE, KEPT — never touch, regardless ofsandy.daemon_pidliveness (the D9 defense: a rebooted host can resurrect a--restart unless-stoppedcontainer on a dead supervisor pid while the session itself is healthy); failure (all 5 attempts) → zombie, reap. The retry is gated to the destructive reap path only (_sandy_dead_owner_containers_list reap) —--print-state'sorphaned_containersCOUNT uses a single probe, since a momentarily-stale count is informational and a 5s stall per mid-startup container would defeat its cheap-poll budget.- Not daemon-labeled (a foreground/interactive agent container): SANDBOX_NAME is derived by stripping only the
sandy-prefix (neversandy-proxy-— this branch only ever sees a real agent image). Not running → dead, reap. Running → check$SANDY_HOME/sandboxes/.<name>.lock/pid: missing/non-numeric/dead → dead-owner, reap; live pid → KEPT, skip. (Reuses the samelock_holder_aliveliveness test--print-state/the #14 workspace mutex use.)
Every KEPT agent's sandbox name is recorded in a set for pass 2.
Pass 2 — every PROXY container (image sandy-proxy, named sandy-proxy-<name>): the proxy's own running state and lock file are ignored — its fate is decided purely by whether its paired agent (sandy-<name>) is in the pass-1 KEPT set. Paired agent KEPT → KEPT, skip. Paired agent absent, dead, or reaped → reap. This is deliberate: a daemon session's proxy never carries sandy.daemon=true, so judging it by its own lock file (which holds the supervisor's pid) would misjudge it dead the moment a live session's supervisor is SIGKILL/OOM-killed — reaping the proxy would then strand a perfectly healthy agent on a routeless --internal sidecar (the exact failure the atomic agent+proxy teardown in cleanup() exists to prevent). This was also a real pre-release regression, fixed before 1.3.0 shipped.
The dedicated reaper re-invokes the lister (with reap mode) at call time (not a cached earlier snapshot) immediately before each destructive docker rm -f — its own authoritative re-probe, since the operation is destructive.
Image listers. _sandy_orphaned_project_images_list candidates come from docker images -f label=sandy.managed=1 --format '{{.Repository}}' | grep -E '^sandy-project-'; the in-use set is a pure filesystem walk of $SANDY_HOME/sandboxes/*/ recomputing sandy-project-<basename> lowercased — the identical transform Phase 3's build path applies, so it can never drift. _sandy_orphaned_skills_images_list candidates match ^sandy-skills(-base)?-; the in-use set is built from three sources, all recomputing the suffix via the shared _sandy_skill_pack_suffix() helper (also used by both build call sites in Phase 2.5a/2.5b, extracted here to prevent drift): (1) SANDY_SKILL_PACKS already set in the --gc process's own environment; (2) host $SANDY_HOME/config (~/.sandy/config), consulted directly since SANDY_SKILL_PACKS is passive-safe and commonly set once, globally rather than per-workspace — without this source, a host-global default with no per-workspace override would see an empty in-use set and reap the actively-used skills images on every --gc run (a real pre-release regression, fixed before 1.3.0 shipped); (3) each sandbox's WORKSPACE.json for a still-existing workspace_path, greping that workspace's own .sandy/config for SANDY_SKILL_PACKS=. Sources 1 and 2 aren't tied to any one sandbox, so they only widen the in-use set, never narrow it. Known imprecision: a temporarily-unmounted workspace, an edited-but-not-yet-relaunched .sandy/config, or a host config sandy can't read for some other reason under-detects as orphaned — a read/parse failure on any of the three sources simply contributes nothing to the in-use set rather than crashing the lister. This residual imprecision is safe because skills/project images are reproducible artifacts — a false-positive reap costs a full skills-base (Chromium/bun) rebuild on the next launch that needs it, not data loss. _sandy_dangling_images_list is docker images -f dangling=true -f label=sandy.managed=1 --format '{{.ID}}' — no PID/liveness gate needed, since a dangling image is referenced by no tag and docker rmi without -f is itself the safety net for anything still referenced by a child image.
Reap order: containers → networks (delegates to _sandy_reap_orphan_networks/_sandy_orphan_networks_list, unchanged — one gate shared with --prune-orphans) → project images → skills images → dangling images last.
Flow: sandy --gc [--dry-run] [--yes] computes all five lists up front, prints a human-readable plan, then: nothing to reclaim → exit 0 immediately; --dry-run → prints the plan and exits 0 before any confirm step; otherwise --yes skips the interactive y/N, a TTY without it is prompted, and non-TTY without it errors "pass --yes" and exits 1. A before/after re-count (not the attempt count) drives the final "Reclaimed: N container(s), M network(s), P project image(s), Q skills image(s), R dangling image(s)." summary, so a resource that raced back to life between the plan and the reap isn't falsely claimed. --dry-run/--yes are parsed by --gc's own trailing-argument loop (distinct locals, not the unrelated SANDY_UPDATE_DRY_RUN/SANDY_UPDATE_YES --update-sessions uses), since the main flag-parsing loop runs after this fast-path dispatch already exits.
--prune-orphans remains unchanged as a documented subset of --gc (both share the same network lister/reaper) — no deprecation.
Four parallel arrays define available skill packs:
SKILL_PACK_NAMES=(gstack)
SKILL_PACK_REPOS=("https://github.com/garrytan/gstack")
SKILL_PACK_VERSIONS=("main") # Fallback only
SKILL_PACK_TAG_PREFIXES=("") # Empty = use commit SHAOn each launch, skill_pack_resolve_versions() runs for each enabled pack:
- GitHub releases API (5-second timeout): If
tag_prefixis set, fetch latest non-draft, non-prerelease tag matching the prefix - GitHub commits API (5-second timeout): If no releases or no prefix, fetch latest commit SHA on default branch (truncated to 12 chars)
- Local cache:
~/.sandy/.skill_version_<pack>stores last successfully resolved version - Hardcoded fallback:
SKILL_PACK_VERSIONSarray entry, used only on first run if GitHub is unreachable
The resolved version is embedded in the generated Dockerfile. A new version = different Dockerfile content = hash mismatch = rebuild triggered.
At container startup, user-setup.sh:
- Symlinks
/opt/skills/<pack>/→~/.claude/skills/<pack> - Symlinks individual skill directories (those containing
SKILL.md) into~/.claude/skills/ - Adds
/opt/skills/<pack>/binto PATH - Sets
PLAYWRIGHT_BROWSERS_PATH=/opt/skills/gstack/.browsers
When gstack is enabled, ~/.gstack/ inside the container is bind-mounted from <workspace>/.gstack/ on the host (auto-created if missing). This makes gstack state workspace-scoped — visible alongside .git/ and .venv/, persisted independently of the sandbox identity.
A one-shot migration runs on the first 0.12+ launch: if $SANDBOX_DIR/gstack/ (the legacy location) has content but <workspace>/.gstack/ is absent, sandy cp -a's the contents to the workspace and renames the legacy dir to gstack.migrated/ (left in place; manual cleanup after verification).
A launch-time nudge prints a warning when the workspace is a git repo and .gstack/ is not gitignored. Detection prefers git check-ignore (so it honors .git/info/exclude and parent .gitignores); falls back to a literal grep of the workspace's .gitignore when git is unavailable. The warning is informational only — sandy launches normally either way.
Add entries to all four arrays (SKILL_PACK_NAMES, SKILL_PACK_REPOS, SKILL_PACK_VERSIONS, SKILL_PACK_TAG_PREFIXES) and add a build recipe case in generate_skill_pack_dockerfiles().
--rm -it
--name sandy-<SANDBOX_NAME>
--cpus <SANDY_CPUS>
--memory <SANDY_MEM>
--security-opt no-new-privileges:true
--cap-drop ALL
--cap-add SETUID --cap-add SETGID --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER
--pids-limit 512
--read-only
--tmpfs /tmp:exec,size=1G
--tmpfs /home/claude:exec,size=2G,uid=1001,gid=1001
--network <NETWORK_NAME>
Optional: --gpus <SANDY_GPU> if GPU passthrough is enabled.
entrypoint.sh runs as root and performs:
- Fix tmpfs home directory ownership to match host UID/GID
- Seed
~/.ssh/known_hostsfrom host mount - SSH agent relay setup (macOS: socat TCP→Unix relay; Linux: socket permissions fix)
- Copy host SSH config from
/tmp/host-sshto~/.ssh/(dereferences symlinks, sets correct permissions) - Fix ownership of sandbox-backed persistent mount directories (pip, uv, npm, go, cargo).
~/.gstack/is intentionally not chowned here — it's a workspace bind, so chown'ing inside the container would write through to the host workspace's ownership. - Symlink Claude Code binary and data dir into home
- Create pip/pip3 wrapper scripts (auto-add
--userwhen outside virtualenvs) - Drop privileges:
exec gosu $RUN_UID:$RUN_GID /usr/local/bin/user-setup.sh "$@"
user-setup.sh runs as the claude user:
- Set environment variables (HOME, CARGO_HOME, GOPATH, NPM_CONFIG_PREFIX, PYTHONUSERBASE, PATH)
- Symlink system Rust toolchain binaries into
~/.cargo/bin - Activate skill packs (symlink into
~/.claude/skills/) - Create synthkit slash commands (
/md2pdf,/md2doc,/md2html,/md2email) 4a. Create/ssscreenshot skill files whenSANDY_SCREENSHOTS_PATHis set (claude~/.claude/commands/ss.md, gemini~/.gemini/commands/ss.toml, codex~/.codex/skills/screenshot/SKILL.md). All call/usr/local/bin/sandy-ss-pathsinternally. Opencode has no slash-command surface in v0; the helper is on PATH for manual invocation. See Appendix E.11a for the host-side mount + env-var pipeline. - Remap ANSI color 4 (dark blue → bright blue) for readability
- Ensure the Claude projects dir for the workspace exists (
settings.jsonitself is seeded/merged host-side beforedocker run— see §4 Seeding; moved out of user-setup in 0.11.3) - Configure git (safe.directory, user name/email)
- Environment detection (.python-version, broken .venv, foreign native modules, git-lfs)
- Git auth setup (token mode: URL rewriting + gh auth; agent mode: SSH config)
- Plugin marketplace refresh (daily, or forced when channels configured)
- Channel credential seeding (Telegram, Discord: write
.envandaccess.json) - Launch Claude Code via tmux (or remote-control mode)
If the host UID differs from the image default (1001), sandy generates custom passwd and group files with the host UID/GID and mounts them read-only. The entrypoint then uses gosu with the remapped UID/GID.
Claude Code config: SANDY_WORKSPACE, SANDY_PROJECT_NAME, SANDY_MODEL, SANDY_SKIP_PERMISSIONS, SANDY_NEW_SESSION, SANDY_REMOTE_CONTROL, SANDY_VERBOSE, SANDY_CHANNELS, CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
Credentials: CLAUDE_CODE_OAUTH_TOKEN (explicitly emptied if not set, to prevent host env leakage) and ANTHROPIC_API_KEY — but at most one Claude key reaches the container. Claude Code's own auth precedence resolves ANTHROPIC_API_KEY ahead of CLAUDE_CODE_OAUTH_TOKEN, so forwarding both would silently route to per-use API billing and bypass the OAuth/subscription path. To honor sandy's documented OAuth-first preference, when an OAuth token is configured sandy suppresses ANTHROPIC_API_KEY (forwarding only the token, with a launch warning); the API key is forwarded only when no OAuth token is set.
Channel credentials: TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_SENDERS, DISCORD_BOT_TOKEN, DISCORD_ALLOWED_SENDERS
Git: GIT_USER_NAME, GIT_USER_EMAIL, GIT_TOKEN, GH_ACCOUNTS, SANDY_SSH, SSH_RELAY_PORT
System: HOST_UID, HOST_GID, DISABLE_AUTOUPDATER=1, FORCE_AUTOUPDATE_PLUGINS=true
- CPU: Auto-detected from
docker info(number of CPUs), overridable viaSANDY_CPUS - Memory: Auto-detected as
available - 1GB(minimum 2GB), overridable viaSANDY_MEM - PIDs: Hard limit of 512 processes
- Tmpfs:
/tmp= 1GB,/home/claude= 2GB (persistent mounts bypass tmpfs)
Per-instance Docker bridge networks are created with names keyed on PID (sandy_net_$$) to avoid races between concurrent sessions.
iptables rules inserted into the DOCKER-USER chain:
| Range | Purpose |
|---|---|
10.0.0.0/8 |
Home/office LANs, VPNs |
172.16.0.0/12 |
Docker internals, some LANs |
192.168.0.0/16 |
Home/office LANs |
169.254.0.0/16 |
Link-local |
100.64.0.0/10 |
CGNAT, Tailscale |
The container's own subnet is allowed. Additional hosts/CIDRs can be allowed via SANDY_ALLOW_LAN_HOSTS. A single host:port to a local LLM listening on the Docker host can be allowed via SANDY_LOCAL_LLM_HOST (see below).
Rule insertion order (rules evaluated top-to-bottom):
- Allow container's own subnet (inserted last, evaluated first)
- Allow
host.docker.internal:<port>forSANDY_LOCAL_LLM_HOST(tcp, dst = bridge gateway, dport = configured port) - Allow specific LAN hosts (if
SANDY_ALLOW_LAN_HOSTSset) - DROP all private ranges
Cleanup: Rules and network removed on exit via trap handler.
Fail-closed: If iptables is not available, sandy aborts unless SANDY_ALLOW_NO_ISOLATION=1.
SANDY_LOCAL_LLM_HOST=<ip>:<port> (e.g. 127.0.0.1:11434 for Ollama) lets an in-container agent reach a local LLM server running on the Docker host without disabling sandy's broader LAN-isolation posture.
Validation (post-config-load, before agent resolve):
- Format:
^[^[:space:]]+:[0-9]+$— must behost:port. Bare IPs are rejected. - Host portion:
0.0.0.0,::, and empty rejected (world-open). - Port: integer in
1..65535.
Linux behavior:
ensure_networkcapturesCONTAINER_GATEWAY(the bridge gateway IP, equivalent tohost.docker.internal) fromdocker network inspect.apply_network_isolationinserts a singleiptables -I DOCKER-USER -i $BRIDGE -p tcp -d $CONTAINER_GATEWAY --dport $PORT -j ACCEPTrule. The destination is the gateway IP — opencode (or the user's CLI) connects tohost.docker.internal:<port>, which Linux Docker resolves to the gateway.RUN_FLAGSadds--add-host=host.docker.internal:host-gateway(Linux Docker does NOT auto-resolve this hostname; without the explicit map, the container can't reach the gateway by name).cleanup_network_isolationremoves the rule on exit.
macOS behavior: Docker Desktop already auto-resolves host.docker.internal. Sandy normally nullifies the hostname (mapping to 127.0.0.1) when SANDY_SSH != agent; setting SANDY_LOCAL_LLM_HOST suppresses that nullification. Macros LAN isolation is not active on macOS regardless (see below), so no iptables rule is added.
Container-side: SANDY_LOCAL_LLM_HOST is forwarded into the container via -e so the agent / user shell can introspect the configured target.
Network isolation is NOT active on macOS when the egress proxy is explicitly turned off (SANDY_EGRESS_PROXY=0). (The default is 1 — permissive — so this applies only when a user opts out.) Docker Desktop's VM does not provide LAN isolation. Containers can reach host.docker.internal (→ host gateway), the host's localhost services, and any device on the user's physical LAN (192.168.x.x, home router, NAS, printers, internal dashboards). Linux iptables DROP rules do not apply and cannot be applied from macOS. (Stress test April 2026 opened a live TCP connection to host SSHD and read its banner — see ISOLATION_STRESS.md finding F2.) Setting SANDY_EGRESS_PROXY=1 (or =2) applies real isolation on macOS — see "Egress Proxy" below.
Launch warning: On non-Linux hosts with the proxy off, apply_network_isolation prints a warning banner informing the user that network isolation is not active and pointing at SANDY_EGRESS_PROXY=1. In proxy mode apply_network_isolation is not called (the --internal topology is the isolation), so no banner fires.
Defense-in-depth (--add-host): sandy appends the following flags to RUN_FLAGS on macOS to nullify Docker Desktop's magic hostnames:
| Hostname | Mapped to | Condition |
|---|---|---|
gateway.docker.internal |
127.0.0.1 |
always |
metadata.google.internal |
127.0.0.1 |
always |
host.docker.internal |
127.0.0.1 |
only when SANDY_SSH != agent |
When SANDY_SSH=agent, host.docker.internal is not nullified because sandy's own in-container SSH agent relay (socat … TCP:host.docker.internal:$SSH_RELAY_PORT) depends on that hostname reaching the host. In that mode, sandy emits an extra warn line noting the exception.
This is defense-in-depth, not a fix — with the proxy off, raw-IP access (curl http://192.168.1.1) is unaffected. The fix is SANDY_EGRESS_PROXY (below), which applies uniform isolation on both platforms.
Two mutually-exclusive boolean keys route the agent through a sandy-proxy sidecar on a Docker --internal network. Because it relies on --internal routing rather than iptables, it is the only network isolation that works on macOS, and behaves identically on both platforms. Value-aware tiering: from a workspace source, a strengthening value is passive-safe but a weakening value is quarantined to the per-workspace approval prompt (_sandy_passive_value_privileged), so a committed .sandy/config cannot silently disable isolation.
| Setting | Mode (mode field in proxy config) |
Egress policy | Workspace-config tiering |
|---|---|---|---|
| (neither) | permissive (default) |
Block private/LAN/link-local/CGNAT/169.254.169.254 metadata; allow all internet. Resolve-then-check also defeats DNS rebinding. |
n/a |
SANDY_EGRESS_STRICT=1 |
strict |
Deny all except the built-in default allowlist + SANDY_ALLOW_HOSTS; fail closed. |
passive-safe (strengthens); =0 downgrade is approval-gated |
SANDY_EGRESS_NO_ISOLATION=1 |
— (proxy off) | Linux: legacy iptables. macOS: none. | approval-gated (weakens) |
Deprecated alias SANDY_EGRESS_PROXY (0→off, 1→permissive, 2→strict) is still honored with a migration warning; from a workspace source its =0 is approval-gated identically. Pre-1.0 this key was a plain passive tri-state — a committed SANDY_EGRESS_PROXY=0 could disable isolation with no prompt (fixed in the 1.0 rc window; guarded by run-tests.sh §65).
The launcher normalizes the value once into _SANDY_PROXY_ON (bool) and _SANDY_PROXY_MODE (permissive/strict).
Topology (ensure_proxy_networks / start_proxy_sidecar):
- Two per-session networks:
sandy_sidecar_$$(--internal, agent + proxy) andsandy_egress_$$(normal bridge, proxy only). The agent'sNETWORK_NAMEis the sidecar. - The sidecar is created with an explicit
--subnet/--gateway(first non-overlapping/24) so the proxy can be pinned to a fixed--ip(<subnet>.2). The candidate pool (_sandy_proxy_subnet_candidates) is every/24in10.200.0.0/16and10.201.0.0/16(512 subnets) plus two legacy/24s (172.31.250.0/24,192.168.231.0/24) — so the practical ceiling on concurrent proxy-mode sessions is in the hundreds (was a hardcoded 4-entry list capping at four). If every candidate overlaps,ensure_proxy_networksfirst calls_sandy_reap_orphan_proxy_networks— which removes anysandy_sidecar_*/sandy_egress_*network with no attached container (an orphan left by a SIGKILL'd/OOM'd/closed-terminal session that couldn't run its cleanup trap; live sessions are never touched) — then retries once. Only if that still fails is it a hard launch error. - The proxy container is named
sandy-proxy-${SANDBOX_NAME}(mirrors the agent containersandy-${SANDBOX_NAME}), making an orphan traceable to its workspace indocker ps. The workspace mutex ⇒ one session per workspace ⇒ the name is unique among live sessions; a stale same-named proxy isdocker rm -f'd before (re)launch (same as the agent container at §E). - The proxy runs
--read-only --cap-drop ALL --security-opt no-new-privileges:true --pids-limit 128 --memory 256m --restart on-failure:5(HF-incident Issue 2 — hardened at least as much as the agent it protects;--userdeclined because the binary binds privileged ports on a scratch image), with/etc/sandy-proxy.jsonbind-mounted read-only from$SANDBOX_DIR/sandy-proxy.json. In-proxy, all accept loops shareacceptLoop(proxy/accept.go), which bounds concurrent connections atmaxConnsvia a semaphore acquired beforeAccept, so a connection storm is bounded by backpressure rather than by the--memoryOOM-killer. After start, it is connected to the egress network with the fixed sidecar--ip. Restart policy: the proxy is the agent's only route off the--internalsidecar, so a mid-session proxy death (crash/OOM/reap) would otherwise strand the agent (every requestFailedToOpenSocket) until the next launch.--restart on-failure:5lets the daemon resurrect it on the same fixed--ip, so the agent self-heals without a session restart; bounded to 5 so a genuinely broken proxy still gives up.cleanup()force-removes it regardless of policy (no zombie). Readiness gate (#37): the proxy image bakes a DockerHEALTHCHECK(--interval=1s --start-period=0s --timeout=2s --retries=3) that re-invokes the binary assandy-proxy -healthcheck— scratch has no shell, so the binary is the probe; it dials its own:443/:80/:3128TCP listeners (loopback, valid before the sidecar--ipattach) and issues one DNS query on:53(UDP has noAccept, so a bind is proven by a real reply — NXDOMAIN counts). The launch gate polls.State.Health.Status(up to ~15 s) and proceeds only onhealthy, so it waits for the listeners to bind, not merely for the process to start (the pre-#37 gate polled bare.State.Running, which flips true beforenet.Listen, leaving a transient "connection refused" window on the agent's first request). It falls back to the legacy.State.Runninggate when.State.Healthis absent ({{if .State.Health}}…{{else}}none{{end}}— an older HEALTHCHECK-less cached image still launches), fails fast (dumpingdocker logs) if it never comes up, and a non-zero.RestartCountshort-circuits the poll and is surfaced as a crash-loop warning (a clean proxy never exits). Regressions:run-tests.sh §56(legacy self-heal),§78(HEALTHCHECK wiring),proxy/healthcheck_test.go(probe logic). - Death diagnostics. Two mechanisms make a proxy death root-causable. (1) Persistent log: after the readiness gate, sandy streams
docker logs -f "$PROXY_CONTAINER"to$SANDBOX_DIR/proxy.login the background (PIDPROXY_LOG_PID, reaped incleanup()), truncated per launch. This survives thedocker rm -fthat erasesdocker logs, so a guard() panic stack, deny lines, and restart boundaries persist;cleanup()appends the container's finaldocker inspectstate (exit/oom/restarts/status/finished) so an OOM (oom=true, exit 137) is distinguishable from a panic (non-zero exit + stack) or an external kill. (2) Panic recovery in the proxy binary: an unrecovered panic in any goroutine crashes the whole Go process, and the proxy runs one goroutine per connection over untrusted wire bytes (TLS ClientHello / HTTP Host). Each per-connection handler (transparent,connect,forward) is wrapped inguard()(proxy/guard.go), whichrecover()s and logs the panic value +debug.Stack()instead of letting one malformed connection take down the agent's only egress route. Regression:run-tests.sh §57+proxy/guard_test.go. - Config JSON:
{"mode", "proxy_ip", "allow":[…], "local_llm"?}.allow= built-in defaults + validatedSANDY_ALLOW_HOSTS(+host.docker.internalwhen a local LLM is set). - Agent
RUN_FLAGS:--network <sidecar>+--dns <proxy_ip>+-e SANDY_PROXY_IP=<ip>+-e SANDY_EGRESS_MODE=<off|permissive|strict>(the resolved posture, forwarded for in-container introspection — informational only; noteSANDY_EGRESS_MODEis forwarded in all modes includingoff, not just proxy mode — see E.16). The per-OS magic-hostname--add-hostblock is bypassed in proxy mode (all resolution goes through the proxy DNS).apply_network_isolation(iptables) is skipped. cleanup()removes the agent container first (docker rm -f "$CONTAINER_NAME"), then the proxy container, then the egress network, then the sidecar. Removing the agent first is load-bearing in proxy mode: the agent runsdocker run --rmin the foreground, but the container's lifetime belongs to the daemon, not thedocker runclient — so if that client is killed without the container stopping (closed terminal, killed session, dropped SSH, SIGHUP), the daemon keeps the agent running. Without the explicit agent removal, the rest ofcleanup()would tear down the proxy and egress route, stranding the agent on a routeless--internalsidecar (every API request failsFailedToOpenSocketuntil the next launch). It also lets the sidecarnetwork rmsucceed instead of failing on the still-attached orphan (which would leak the subnet). Guarded with${CONTAINER_NAME:-}because the trap is armed before that var is assigned. Regression:run-tests.sh §55.
Non-TCP backstop (proxy is TCP-only by design). The proxy speaks only TCP; it does not proxy UDP/QUIC/ICMP. The --internal network is the protocol-agnostic backstop — an L3 FORWARD drop with no MASQUERADE — so all non-TCP egress off the sidecar is dropped before reaching the proxy: raw UDP, QUIC/HTTP-3 over UDP/443 (which would otherwise bypass SNI inspection; it fails closed and clients fall back to TCP-through-proxy), ICMP, and IPv6 (networks are --ipv6=false, no v6 route). Verified on macOS Docker Desktop 2026-06-11 and guarded by test/spike/macos-internal-network-spike.sh (A1d) and run-integration-tests.sh §13b (Linux). Invariant: if the proxy ever becomes the only egress mechanism (e.g. retiring iptables), a non-TCP block must be re-added or this protection regresses.
Default allowlist: api.anthropic.com/*.anthropic.com, api.openai.com/*.openai.com, *.googleapis.com/accounts.google.com/oauth2.googleapis.com, GitHub (github.com, github.com:22, api.github.com, codeload.github.com, *.githubusercontent.com, ssh.github.com:22), npm (registry.npmjs.org, *.npmjs.org), PyPI (pypi.org, files.pythonhosted.org), crates (crates.io, static.crates.io, index.crates.io), Go (proxy.golang.org, sum.golang.org, *.golang.org), Debian (deb.debian.org, security.debian.org).
ssh ProxyCommand: in proxy mode the entrypoint prepends Host *\n ProxyCommand socat - PROXY:<proxy-ip>:%h:%p,proxyport=3128 to ~/.ssh/config, tunneling git-over-SSH through the proxy CONNECT listener. On Linux the SSH-agent socket is a direct bind mount and signing works; on macOS the agent socket relay can't cross --internal, so signing is unavailable under the proxy (git-over-SSH still works) — sandy warns and recommends SANDY_SSH=token.
Local LLM: SANDY_LOCAL_LLM_HOST is served by the proxy's forward listener (the local_llm config field), not an iptables hole. The proxy is given --add-host host.docker.internal:host-gateway on Linux.
Certain files and directories in the workspace are overlaid at container launch to prevent modification. The lists of protected paths are emitted by three helper functions defined at the top of the sandy script (_sandy_protected_files, _sandy_protected_git_files, _sandy_protected_dirs). The test harness reads the same lists via sandy --print-protected-paths — single source of truth.
Files (existence-gated — only mounted when present on host):
| Path | Threat mitigated |
|---|---|
.bashrc, .bash_profile, .zshrc, .zprofile, .profile |
Shell config injection (aliases, PATH hijacking, env poisoning) |
.gitconfig |
Credential helper injection, alias hijacking |
.ripgreprc |
Search config injection |
.mcp.json |
MCP server config tampering |
.envrc |
direnv auto-sourcing on cd |
.tool-versions |
asdf toolchain version hijacking |
.mise.toml |
mise toolchain hijacking |
.nvmrc, .node-version |
Node version manager hijacking |
.python-version |
pyenv / uv auto-install hijacking |
.ruby-version |
rbenv/chruby hijacking |
.npmrc, .yarnrc, .yarnrc.yml |
npm/yarn registry hijacking, auth-token exfiltration |
.pypirc |
Python package index auth-token exfiltration |
.netrc |
HTTP credential exfiltration (curl/git/wget) |
.pre-commit-config.yaml |
pre-commit hook injection |
.claude/settings.json, .claude/settings.local.json |
Claude Code project-settings hooks injection later executed by a host-side Claude Code run on the same workspace (trust-handoff; cf. Cursor CVE-2026-48124). settings.local.json is normally writable — :ro here means in-session edits to a pre-existing one won't persist. |
Git-tree files (existence-gated — only mounted when present on host):
| Path | Threat mitigated |
|---|---|
.git/config |
Remote path manipulation, core.fsmonitor injection, core.hooksPath redirect |
.gitmodules |
Submodule URL hijacking |
.git/packed-refs |
Bulk ref spoofing / git gc repack |
.git/HEAD is intentionally left read-write as of 1.5.0 (#80) so git switch/checkout/checkout -b work inside the container — a symref is not a host-code-execution vector. A HEAD left on an unexpected branch at session end is surfaced by a yellow notice (detection-not-prevention, mirroring the protected-dirs hybrid).
Directories (existence-gated — mounted read-only when present on host):
| Path | Threat mitigated |
|---|---|
.git/hooks/ |
Pre-commit, post-checkout, push hook injection |
.git/info/ |
.git/info/attributes filter-driver injection (arbitrary-command on checkout/add) |
.vscode/, .idea/ |
IDE task/launch config injection |
.circleci/ |
CircleCI pipeline escape |
.devcontainer/ |
Devcontainer auto-open escape |
.sandy/ |
sandy's own control dir (Dockerfile, config, .secrets) — :ro so the agent can't modify build inputs mid-session (HF-incident Issue 7; pairs with the .sandy/Dockerfile build approval gate, §5 Phase 3) |
.claude/hooks/ |
Claude Code hook-script injection later executed by a host-side Claude Code run on the same workspace (trust-handoff). Distinct from the commands/agents/plugins overlay, which is writable-in-sandbox by design. |
.github/workflows/ |
GitHub Actions pipeline escape on git push. Omitted from the list when SANDY_ALLOW_WORKFLOW_EDIT=1. |
Submodule gitdirs (recursive walk):
_protect_submodule_gitdirs walks $WORK_DIR/.git/modules/ (plus, for --separate-git-dir / worktree-of-submodule layouts, $GITDIR_HOST/modules/) up to maxdepth 6, matches -type f -name config, and for each submodule directory mounts:
<submodule>/config→ read-only<submodule>/hooks/→ read-only (only if present on host)<submodule>/info/→ read-only (only if present on host)
This uses while read -r -d '' and shell-side dirname to avoid GNU-only find -printf '%h\0' — portable across macOS/BSD and GNU find.
Non-default git hooks (core.hooksPath). .git/hooks/ is protected, but a repo (or the user's global git config) can redirect hooks elsewhere via core.hooksPath (e.g. core.hooksPath = .githooks). _sandy_extra_hooks_dir resolves the effective value at launch (git -C "$WORK_DIR" config --path --get core.hooksPath, so git's own tilde-expansion applies) and, when it resolves to a directory inside the workspace that is neither the default .git/hooks nor the workspace root, mounts it :ro (existence-gated at the call site). It canonicalizes (pwd -P) only to run the containment check — a hooks dir resolving outside the workspace, or the workspace root itself, is rejected — but mounts the path git actually consults (the configured value for a relative hooksPath), not the resolved target. Mounting the target would :ro the real directory while leaving a symlinked hooksPath (.githooks -> real) writable in the rw workspace: an agent could rm .githooks && mkdir .githooks && write a hook, redirecting host git into a fresh unprotected dir. Locking the configured path instead makes it a :ro mount point the agent cannot swap. A hooksPath that resolves onto a directory already in the static protected-dirs list is skipped (a duplicate -v would make Docker refuse to launch). This complements the .git/config :ro mount, which blocks injecting a new core.hooksPath; this handles a pre-existing one (Pillar "Week of Sandbox Escapes" #4 variant). Create-fresh case (now covered — sandbox-escape eval Issue F): hooksPath set but its directory absent at launch is existence-gated to no :ro mount, so it is caught by session-end detection instead. _sandy_configured_hooks_rel resolves the configured hooks path existence-independently (unlike _sandy_extra_hooks_dir, which requires the dir to exist for the mount); the launch snapshot records it only if it existed, and the exit sweep flags a newly-appeared hooks dir with content. Standing rule this reinforces: never gate a trust decision on a filename/path pattern without a symlink/indirection check (also applies to a symlinked .git or an external GIT_DIR).
Mount policy (hybrid, existence-gated symmetric model): both files and directories are existence-gated. If the host has the path, sandy bind-mounts it :ro (kernel-level write prevention, no host-side artifact because Docker is mounting over an existing target). If the host doesn't have the path, sandy adds no mount — the agent can write there during the session, and detection runs on session exit.
The earlier "always-mount with empty fixture" pattern for directories left empty stub dirs on the host workspace every session, required heroic cleanup-on-exit and pre-existing-debris preflight logic, and produced user-visible weirdness (file managers, IDE scanners, ls, ripgrep all saw the stubs during sessions). For files the same approach was strictly worse — 0-byte stubs broke direnv, polluted git status, and tripped every tool that checks for file presence. The 0.13 cleanup unifies both behaviors on the existence-gated model.
Session-end detection (replacement defense for absent paths). Sandy snapshots which protected dirs and files existed at launch into $SANDBOX_DIR/.protected-existed-at-launch (one path per line), plus the configured core.hooksPath target if it existed. On session exit, cleanup() walks _sandy_protected_dirs, _sandy_protected_files, and the resolved core.hooksPath target (_sandy_configured_hooks_rel) again; for each path that's now a non-empty directory (or, for files, present) but wasn't in the launch snapshot, it emits a yellow warning naming the path and showing the rm -rf remediation. No automatic deletion — we don't know whether the agent's write was legitimate (the user may have asked for .vscode/settings.json) or a prompt injection, so we surface it and let the user decide.
This is detection, not prevention. The threat window is "between session end and the user's next operation that auto-executes those paths" (git pull for hooks, git push for CI workflows, opening the project in VS Code/JetBrains for IDE configs). The trade-off is conscious: prevention required the workspace pollution that drove the redesign. For the realistic threat model (agent occasionally wrong via injection or skill bug; user attentive enough to read launch/exit messages), detection is sufficient.
Long-term direction: fanotify with FAN_OPEN_PERM. The "right" answer is to intercept write attempts at the syscall level before they hit the filesystem. A small container-side watcher daemon registers FAN_OPEN_PERM / FAN_ACCESS_PERM on the protected paths; the kernel suspends each open-for-write until the userspace handler responds; sandy returns FAN_DENY → caller gets -EPERM, no host artifact ever produced. Properties:
- True prevention with no host pollution, even for absent paths
- Honest to the agent (real
-EPERM, not silent failure or post-hoc cleanup) - Works in containers on macOS Docker Desktop (the VM kernel is Linux 5.x with fanotify support)
- Requires
CAP_SYS_ADMINfor fanotify setup; sandy currently--cap-drop ALL, so an entrypoint-phase grant + drop-before-agent-runs is needed - Watcher death blocks all watched-path I/O until a kernel-side timeout — needs supervisor + restart
Estimated implementation: 80-120 lines of Python or C, plus integration into the entrypoint. On the roadmap, unscoped until/unless detection-only proves insufficient against a real attack path.
Stub cleanup preflight (legacy). Workspaces touched by pre-0.13 sandy may still have empty stub dirs left over. On launch, sandy walks _sandy_protected_dirs and rmdirs any that are empty. In a git repo it additionally requires the dir isn't git-tracked. Name-match against the small protected-dirs list + the empty check is a sufficient safety bar; the git-tracked exclusion is an additional guard in repos. Under SANDY_DEBUG_CLEANUP=1, the trap prints the number of stubs processed plus any rmdir failures with errno messages. The session-scoped stub-tracking file ($SANDBOX_DIR/.session-created-stubs) is still used for the .claude/{commands,agents,plugins} and .gemini/{extensions,commands} sandbox overlays (which legitimately need stub creation for the writable-overlay pattern). The protected-dirs path no longer contributes to it.
Stub cleanup preflight (files): sandy scans the workspace on launch for 0-byte files matching the protected-files list that are untracked by git. If any are found (typically leftover stubs from a workspace that ran a pre-0.11.2 always-mount build), sandy prints a one-shot rm remediation command. File stubs are not auto-removed — a 0-byte file could be intentional, and the git-untracked heuristic is only a best-effort safety check. Directory stubs are auto-removed (see above) because the name-match + empty gate is stronger.
Intentionally excluded from the protected list: package manifests (Makefile, justfile, package.json, pyproject.toml, setup.py, Cargo.toml, build.rs). The agent legitimately edits these as project source, and they are invoked explicitly by name rather than sourced on cd or filesystem scan.
| Workspace path | Sandbox source | Behavior |
|---|---|---|
.claude/commands/ |
workspace-commands/ |
Starts empty; Claude can create/modify freely |
.claude/agents/ |
workspace-agents/ |
Starts empty; Claude can create/modify freely |
.claude/plugins/ |
workspace-plugins/ |
Starts empty; managed via /plugin install |
Host content at these paths is hidden (not visible inside container). Changes persist in the sandbox across sessions. No changes to host filesystem.
Before container launch, sandy scans the workspace (up to 8 levels deep, skipping node_modules/, .venv*/, .git/) for symlinks pointing outside the project directory. If any are found, sandy consults the persisted approval list at <NAME>/.sandy-approved-symlinks.list before proceeding:
- First launch (no approval list): the user is prompted (
Proceed anyway? [y/N]). Ony, sandy writes the current set to.sandy-approved-symlinks.listand proceeds. On anything else, sandy aborts. - Identical or reduced set: proceed silently. Removed entries are pruned from the list silently (deletion of a symlink is always benign).
- New entry present: hard error. Sandy names the new symlink in the error message and refuses to start. There is no second-chance prompt — the rationale is that a y/N prompt can be trained past ("I'll click yes again"), but a hard error forces an explicit user action to reapprove (delete the symlink and relaunch, or
rm <NAME>/.sandy-approved-symlinks.listto clear the persisted set and re-prompt).
When the user accepts, sandy automatically mounts each symlink target into the container so the symlinks resolve correctly:
- Absolute symlinks (
data -> /home/user/shared/data): Target is mounted at the raw symlink path (the literal path the OS looks up inside the container). - Relative symlinks (
data -> ../../shared/data): Target is mounted at its$HOME-relative container path, which is where the relative traversal lands from the container's workspace location.
Duplicate targets are deduplicated by container mount path.
- Query
gh auth tokenon host for the active account's token (GIT_TOKEN) - Enumerate all authenticated accounts via
gh auth status, collect each account's token viagh auth token --user <account> - Pass
GIT_TOKENto container (used for git URL rewriting) - Pass
GH_ACCOUNTSto container as comma-separateduser:tokenpairs (e.g.user1:tok1,user2:tok2) - In container: configure
git config --global url."https://oauth2:<TOKEN>@github.com/".insteadOf "git@github.com:"(token mode only) - In container: authenticate
ghCLI with all accounts fromGH_ACCOUNTS(works in both token and agent modes)
Multi-account support: Users with multiple GitHub accounts (e.g. personal + enterprise) authenticated via gh auth login will have all accounts available inside the container. The gh CLI can then access repos from any authenticated account.
Fallback: If gh auth token fails, warn that git push/pull may not work.
Linux: Direct socket mount. Host SSH_AUTH_SOCK socket mounted at /tmp/ssh-agent.sock inside container.
macOS: Two-hop relay.
- Host side:
socat TCP-LISTEN:<PORT>,bind=127.0.0.1,fork,reuseaddr UNIX-CONNECT:<SSH_AUTH_SOCK> - Container side (in entrypoint):
socat UNIX-LISTEN:/tmp/ssh-agent.sock,fork,mode=0600,uid=$RUN_UID TCP:host.docker.internal:<PORT> - Wait for socket to appear (retry loop, 50 attempts x 0.1s)
SSH config: Host ~/.ssh/ is mounted read-only at /tmp/host-ssh. The entrypoint copies each file (dereferencing symlinks, skipping dangling ones) to ~/.ssh/ with correct ownership and permissions (600 for keys, 644 for .pub/config/known_hosts).
- Long-lived token (
CLAUDE_CODE_OAUTH_TOKEN): Valid 1 year, generated viaclaude setup-token. Recommended for headless servers. When set, this handles regular API calls; the credential file is still loaded alongside it (without token-refresh logic) so that cloud features like/ultrareviewhave access to the full OAuth credential object. When the token is set, sandy does not forwardANTHROPIC_API_KEY(Claude Code would resolve the API key ahead of the token and bill per-use) — a warning fires if both are configured. - OAuth credentials: From host
~/.claude/.credentials.json(or macOS Keychain). Token expiry checked; refresh attempted on macOS viaclaude auth login. - Fallback: Skip credential setup; user directed to
/logininside session.
token_needs_refresh() checks if claudeAiOauth.expiresAt is within 5 minutes of current time. Uses Node.js (preferred) or Python 3 (fallback) for timestamp comparison.
Credentials are loaded into a temporary file, mounted into the container at ~/.claude/.credentials.json, and discarded on exit. They are never persisted in the sandbox.
CLAUDE_CODE_OAUTH_TOKEN is explicitly set to empty string in the container's environment when not configured, preventing accidental leakage from the host environment.
Sandy's load_gemini_credentials() tries the following sources, controlled by SANDY_GEMINI_AUTH (auto | api_key | oauth | adc):
| Mode | Source | Container mount / env |
|---|---|---|
api_key |
GEMINI_API_KEY env var on host |
Forwarded via -e GEMINI_API_KEY=… |
oauth |
Host ~/.gemini/oauth_creds.json (Gemini CLI ≥0.30), falling back to legacy ~/.gemini/tokens.json |
Ephemeral copy of whichever was found, mounted at the same filename under /home/claude/.gemini/ read-only (1.0-rc1) |
adc |
~/.config/gcloud/application_default_credentials.json |
Mounted read-only + GOOGLE_APPLICATION_CREDENTIALS env var |
In auto mode, all three are probed; a warning is emitted if none are found. OAuth tokens are copied to a tmpdir each launch and discarded on exit (same pattern as Claude credentials). Gemini's OAuth refresh is handled inside the CLI itself, so sandy does not run a refresh check.
oauthfree-tier is deprecated upstream (issue #21). Google retired the free-tiergemini-cliOAuth login ("Gemini Code Assist for individuals") in mid-2026; a session using it fails at Google's tier check withIneligibleTierErrorregardless of sandy (sandy loads/forwards the creds correctly — the CLI's own:ro-mount refresh-write also EROFS-errors, but the tier error is fatal first). Theoauthpath remains wired for any still-valid refreshing tier, butapi_key/adc(Vertex) are the recommended paths. See also the deferred rw-ephemeral-copy idea indocs/POST_1.0_IDEAS.md.
Vertex AI routing is enabled by setting GOOGLE_GENAI_USE_VERTEXAI=true with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION; all three are forwarded into the container when set.
Note: gemini auth (browser OAuth) must be run on the host — the container is headless and cannot open a browser.
Sandy's load_codex_credentials() tries the following sources, controlled by SANDY_CODEX_AUTH (auto | api_key | oauth):
| Mode | Source | Container mount / env |
|---|---|---|
api_key |
OPENAI_API_KEY env var on host |
Materialized as an ephemeral auth.json ({"OPENAI_API_KEY":"…"} — what codex login --with-api-key writes) mounted at /home/claude/.codex/auth.json read-only; the env var is also forwarded via -e OPENAI_API_KEY=… for other in-container tooling |
oauth |
Host ~/.codex/auth.json (ChatGPT login) |
Ephemeral copy mounted at /home/claude/.codex/auth.json read-only |
In auto mode (default), OPENAI_API_KEY wins if set; otherwise an auth.json ephemeral mount is used if present; otherwise a warning is emitted. The api_key path materializes a file (rather than relying on env passthrough) because codex 0.139+ no longer reads OPENAI_API_KEY from the environment for first-party auth — requests go out with no Authorization header at all and fail with 401 "Missing bearer or basic authentication in header".
The auth.json mount is read-only by design. If codex needs to refresh an expired OAuth token mid-session, the write fails and codex falls back to an in-session re-login flow. This is the safer default: it prevents refreshed tokens from leaking back to the host, and prevents stale-token-on-exit races. Users who want fresh credentials on every launch get that automatically — sandy re-copies auth.json from the host at each launch.
Note: codex login (browser OAuth) must be run on the host — the container is headless and cannot open a browser.
Sandy wraps Claude Code in a tmux session:
- Session name:
sandy(fixed) - Window name:
sandy: <PROJECT_NAME> - Auto-resume: If session files (
.jsonl) exist in~/.claude/projects/<WORKSPACE_KEY>/and no overriding flags (--new,-p,--resume,--continue), sandy automatically adds--continueto resume the last session.WORKSPACE_KEYis the container workspace path with all/replaced by-(e.g.,/home/claude/dev/sandy→-home-claude-dev-sandy) - Fallback: If
--continuefails (stale session), retry without it
- History: 10,000 lines
- Mouse support enabled
- 256-color + RGB
- Escape time: 0ms
- OSC passthrough:
allow-passthrough on(enables terminal notifications and clipboard) - OSC 52 clipboard support for mouse selections
- Status bar: launch/session-scoped info bar — egress posture (color-coded), agent, workspace, attached-client count, daemon marker, clock (see Appendix A.7 for the exact
#{E:}env-driven format; see also "Status Lines" in CLAUDE.md for the split with Claude Code's own live statusLine)
When SANDY_AGENT contains more than one agent (e.g. claude,gemini, claude,codex, claude,gemini,codex,opencode, or the alias all), the user-setup script creates a tmux session with one pane per agent, in the order listed. Layouts: 2 agents → side-by-side; 3 agents → left half + top-right + bottom-right; 4 agents → 2×2 grid (top-left, top-right, bottom-right, bottom-left in pane-index order). The launch logic is factored into per-agent helpers (build_claude_cmd(), build_gemini_cmd(), build_codex_cmd(), build_opencode_cmd(), build_grok_cmd()) so single-agent and multi-agent paths share the same command construction. Each pane is an independent process; exiting one leaves the others running.
The previous both alias (= claude,gemini) was removed in v0.12 once the comma-separated syntax supported every combination. Using it now exits early with an error message pointing at the new syntax.
build_codex_cmd() inspects the positional args for -p/--print/--prompt. If present, it emits codex exec --sandbox danger-full-access --skip-git-repo-check <prompt> (interactive becomes headless); otherwise codex --sandbox danger-full-access (TUI). --skip-git-repo-check is required because codex 0.139+ refuses exec outside a trusted directory / git repo ("Not inside a trusted directory and --skip-git-repo-check was not specified"); sandy provides the outer isolation, so the gate is redundant and would break headless runs from non-git workspaces. Interactive mode omits the flag — the [projects."…"] trust_level = "trusted" entry in config.toml covers the TUI path. The sandy -p/--print/--prompt flags are dropped and the remaining arg is passed as the positional prompt, because codex exec takes the prompt as a positional argument, not a flag. --continue/-c is silently dropped (codex has codex resume but no headless --continue equivalent — matches the gemini behavior).
codex exec uses only exit codes 0 (success) and 1 (failure). Sandy does not attempt to emulate Claude's richer exit-code semantics (no tool-denied, no context-exhausted signals) for codex. --sandbox danger-full-access on the CLI is belt-and-suspenders alongside the sandbox_mode in config.toml; do not remove either.
With --remote: no tmux wrapper, launches claude remote-control --name "sandy: <PROJECT_NAME>". Browser/phone can connect to control the session.
Only supported with SANDY_AGENT=claude. Gemini CLI has no native WebSocket/daemon mode, codex's mcp-server/app-server modes don't map cleanly to Claude's session-based --remote contract, and OpenCode has no equivalent yet; --remote with any other value — gemini, codex, opencode, or any multi-agent combo — exits with an error. Tracked as a future enhancement pending upstream support.
Sandy passes through OSC escape sequences (9/99/777) from Claude Code to the outer terminal. When running inside cmux (detected via CMUX_WORKSPACE_ID), sandy auto-installs a notification hook that emits OSC 777 sequences.
Host-side hooks (~/.claude/hooks/) are mounted read-only into the container. Host hooks take precedence over auto-setup.
The workspace is mounted inside the container at a path that mirrors the host's $HOME-relative location:
If host path starts with $HOME:
container path = /home/claude/<relative-to-HOME>
Else:
container path = host path (fallback for paths outside $HOME)
For example, ~/dev/sandy on the host becomes /home/claude/dev/sandy inside the container. This preserves the relative path relationship needed for git submodules.
When .git is a file (submodule), sandy:
- Reads the relative gitdir path from the
.gitfile - Resolves absolute host path for both worktree and gitdir
- Computes container paths using the same
$HOME-relative mapping - Mounts both at the correct container paths, preserving the relative relationship
On every session start, user-setup.sh checks the workspace:
If present, auto-installs the specified Python version via uv python install (idempotent, persists in sandbox's uv/ directory).
If .venv/bin/python is a broken symlink (host/container Python version mismatch):
- Extracts version from symlink target
- Auto-installs matching version via
uv python install - Warns user with fix command
Scans node_modules/ for .node files. If they're not ELF binaries (e.g., Mach-O from macOS host), warns with npm rebuild as the fix.
PYTHONUSERBASE (the persistent pip/ sandbox mount, ~/.pip-packages) stores pip install --user packages under lib/python3.<minor>/site-packages. A base-image system-Python bump (e.g. 3.11 → 3.13 with the trixie move) leaves an older lib/python3.<minor>/ tree on disk but invisible to the new interpreter. Warn-only: for each lib/python3.* dir under $PYTHONUSERBASE whose minor version doesn't match the running python3's, prints the stale path and a reinstall/rm -rf pointer. Never fails the session.
If workspace is a git repo and .gitattributes contains filter=lfs (checked up to 3 levels deep), runs git lfs install (idempotent).
Sandy configures three plugin marketplaces in settings.json via extraKnownMarketplaces:
| Name | Source |
|---|---|
claude-plugins-official |
{ source: "github", repo: "anthropics/claude-plugins-official" } |
sandy-plugins |
{ source: "github", repo: "rappdw/sandy-plugins" } |
The marketplace entries are merged into $SANDBOX_DIR/claude/settings.json host-side on every launch, next to the other sandy defaults (see §4 Seeding and §C.2). The merge happens before the container launches — as of 0.11.3 the settings file is rw inside the container (the pre-0.11.3 :ro sidecar broke /plugin install), so the marketplace merge could in principle live in user-setup.sh, but keeping it host-side avoids duplicating the node/jq/fallback triple across entrypoints.
The thinkkit, ait, and pka-skills marketplaces are automatically removed on startup if present (same host-side merge block).
- Marketplace catalogs refreshed daily (24-hour cache via
~/.claude/plugins/.marketplace_updatedtimestamp) - Force refresh when channels are configured (channel plugins may need installing)
- Runs
claude plugin marketplace updatefor each marketplace
If synthkit is installed, user-setup.sh creates four slash commands in ~/.claude/commands/ (Claude, Markdown), ~/.gemini/commands/ (Gemini, TOML), and/or ~/.codex/skills/<name>/SKILL.md (Codex, Markdown with YAML frontmatter). OpenCode does not yet have synthkit auto-discovery in v0.13 — md2pdf/md2doc/md2html/md2email are still on PATH inside the sandy-opencode image, but no commands or skill files are created for OpenCode automatically.
/md2pdf— Convert markdown to PDF/md2doc— Convert markdown to Word (.docx)/md2html— Convert markdown to HTML/md2email— Convert markdown to email HTML (clipboard)
For Gemini, the TOML files use description and prompt fields; the prompt embeds !{md2pdf {{args}}} shell execution and {{args}} argument substitution per Gemini's command format.
For Codex, skills are drop-in directories: ~/.codex/skills/<name>/SKILL.md. The file requires YAML frontmatter with name and description keys delimited by ---, followed by the skill body. Sandy writes one directory per tool (md2pdf/, md2doc/, md2html/, md2email/). Codex discovers these on launch and exposes them via /skills.
When set, user-setup.sh iterates comma-separated URLs/local paths and runs gemini extensions install <url> for each, skipping any extension that already exists in ~/.gemini/extensions/. Extensions persist across sessions via the gemini/extensions/ sandbox mount.
Sandy supports Claude Code channels (Telegram, Discord) via two distinct paths:
- In-container plugin path (
SANDY_AGENT=claudeonly) — auto-installs the Claude channel plugin from the marketplace and seeds credentials into~/.claude/channels/. - Host-side tmux-inject relay (any other
SANDY_AGENTvalue — singlegemini/codexor any multi-agent combo) — agent-agnostic, runs on the host and injects messages into the container's tmux session viadocker exec ... tmux send-keys.
Support matrix:
| Channel | claude |
gemini |
codex |
opencode |
multi-agent |
|---|---|---|---|---|---|
| Telegram | in-container plugin | host relay | host relay | host relay (untested in v0.13) | host relay |
| Discord | in-container plugin | — | — | — | — |
For each configured channel:
- Auto-install the channel plugin from the marketplace
- Create
~/.claude/channels/<channel>/directory - Write
.envwith bot token - Write
access.jsonwith either:"dmPolicy": "allowlist"+ populatedallowFrom(ifALLOWED_SENDERSset)"dmPolicy": "pairing"(if no allowlist, user pairs via/telegram:access pair <code>)
$SANDY_HOME/channel-relay.sh is a generated bash script that long-polls the Telegram Bot API (getUpdates), filters messages by TELEGRAM_ALLOWED_SENDERS, and injects them into the container tmux session via:
docker exec -u <host-uid> <CONTAINER_NAME> tmux send-keys -t sandy.<PANE> "<text>" Enter
The -u "$(id -u)" is required: docker exec defaults to the image user (root, since sandy sets no USER), but the in-container tmux server runs as the gosu-dropped host uid, so its socket lives at /tmp/tmux-<uid>/. Without -u, root can't see that socket and both has-session and send-keys silently fail — the host-side relay (gemini/codex/opencode channels) never delivers. (Claude channels use an in-container plugin and are unaffected.)
Launched as a background process before docker run, tracked via CHANNEL_RELAY_PID, and killed in the cleanup trap. The target pane is SANDY_CHANNEL_TARGET_PANE (default 0 = the first agent listed in SANDY_AGENT, or the sole pane in single-agent mode).
Scope: Telegram only in v0.9.0; Discord via relay is deferred. The relay is stateless — no chat threading, no attachment support, no edit-message reactions. For rich features, use the claude plugin path.
Both Telegram and Discord can be enabled simultaneously with SANDY_AGENT=claude:
SANDY_CHANNELS=plugin:telegram@claude-plugins-official plugin:discord@claude-plugins-official
With any non-claude value (single gemini/codex/opencode or any multi-agent combo), only Telegram is currently supported through the relay; SANDY_CHANNELS=discord exits with an error.
On each launch, sandy checks the installed Claude Code version (cached at /opt/claude-code/.version) against the latest release. If an update is available, the Phase 2 image is rebuilt with --no-cache. Inside the container, DISABLE_AUTOUPDATER=1 prevents Claude Code from attempting self-updates against the read-only filesystem.
For SANDY_AGENT=gemini, _check_gemini_update compares the in-image gemini --version against the npm registry's latest tag for @google/gemini-cli.
For SANDY_AGENT=codex, _check_codex_update compares the in-image /opt/codex/.version against https://api.github.com/repos/openai/codex/releases/latest. The tag format is rust-vX.Y.Z; sandy strips the prefix with sed -E 's/.*"rust-v?([0-9][^"]*)"$/\1/'. The /releases/latest endpoint returns only the release GitHub marks as "latest" (excludes prereleases by convention), so sandy inherits upstream's stable flagging instead of inventing its own policy — important because codex ships 30+ releases/month, most as prereleases. On parse failure the check returns no-update (stale but working, logged once).
sandy --upgrade downloads the latest sandy script from GitHub and replaces the local copy. Includes pre-flight check for write permissions.
| Control | Setting |
|---|---|
| Root filesystem | --read-only |
| User | Non-root (claude, mapped to host UID) |
| Privilege escalation | --security-opt no-new-privileges:true |
| Capabilities | --cap-drop ALL, add back only SETUID, SETGID, CHOWN, DAC_OVERRIDE, FOWNER |
| Process limit | --pids-limit 512 |
| Network | Per-instance isolated bridge, LAN blocked |
| Tmpfs | /tmp (1GB), /home/claude (2GB) |
| Threat | Mitigation |
|---|---|
| File access outside workspace | Read-only root, bind-mount only workspace |
| LAN/internal network access | Egress proxy on an --internal network (default, both platforms); legacy iptables DROP rules when the proxy is off (SANDY_EGRESS_PROXY=0, Linux only — macOS has no isolation with the proxy off) |
| Shell config injection | .bashrc, .zshrc, etc. mounted read-only |
| Git hook injection | .git/hooks/ mounted read-only |
| IDE config tampering | .vscode/, .idea/ mounted read-only |
| Plugin state pollution of host | Sandbox overlay for .claude/plugins/; sandbox-local settings.json (host copy never written; enabledPlugins preserved per-sandbox since 0.11.3) |
| Symlink escape | Pre-launch scan with interactive prompt |
| OAuth token leakage | Ephemeral credentials, explicit env var blocking |
| Fork bomb | PID limit of 512 |
| Privilege escalation | no-new-privileges, capability dropping |
- DNS/outbound exfiltration: In the default permissive mode (
SANDY_EGRESS_PROXY=1), public internet is intentionally available — exfil to arbitrary public hosts is not blocked. Strict mode (=2) narrows egress to the default allowlist +SANDY_ALLOW_HOSTS(domain filtering), but does not stop exfil to an allowlisted host (host-relay broker is POST_1.0). - Data exfiltration via workspace files: Workspace is read-write (by design).
Location: test/run-tests.sh (~4,300 lines, sections §1–§60) plus test/run-integration-tests.sh (~1,500 lines, headless end-to-end, needs Docker + API keys) and proxy/*_test.go (Go unit tests, run by §58)
Prerequisites: Docker, sandy images already built (run on the host, not inside sandy)
Framework: Custom bash test harness with check, pass, fail helpers
The category list below covers the founding sections and is not exhaustive — later sections (numbered through §60) are documented next to the features they guard (config tiers §, egress proxy §49–50 and §55–59, sandbox compat floor §51 and §60, OAuth-first auth §52, failure-mode guards §53, multi-agent matrix §54). The regen scripts (test/regen-config-docs.sh --check, test/regen-template.sh --check) run as part of the suite.
Toolchain availability (8 tests): python3, node, go, rustc, cargo, uv, gcc, git
Persistent packages (3 tests): pip, npm -g, go install survive across sessions
pip behavior (2 tests): Installs to venv when active, --user when not; wrapper script creation
PATH order (1 test): ~/.local/bin is first
Read-only filesystem (3 tests): Cannot write to /usr, can write to /tmp and home
Dev environment detection (3 tests): .python-version auto-install, broken .venv detection, foreign native module warning
Sandbox isolation (1 test): Packages don't leak between project sandboxes
Protected files (12 tests): Cannot write to .bashrc, .zshrc, .git/hooks/, .git/config, .gitmodules; sandbox overlays for commands/agents/plugins work correctly
Git LFS (2 tests): Available, auto-configured when .gitattributes has filter=lfs
UID remapping (2 tests): Container UID matches host, passwd overlay for non-default UID
Config parser (3 tests): Config loaded before SSH setup, doesn't use source, uses variable allowlist
Container naming (1 test): Name includes sandbox name
Symlink protection (3 tests): Detects escaping symlinks, ignores safe internal symlinks, runs before docker
Terminal notifications (6 tests): tmux passthrough, host hooks mounted, cmux detection/hook/dedup
Skill packs (14 tests): Registration, repo config, Dockerfile generation, build phases, user-setup activation
- Preflight warnings (non-blocking): Docker installed? Node.js installed? GitHub CLI authenticated?
- Create install directory: Default
~/.local/bin - Download or copy: If
LOCAL_INSTALLenv var set, copy local file; otherwise download from GitHub - Bake commit hash: If installing from a git repo, detect and bake
SANDY_COMMITinto the script (BSD/GNU sed compatible) - Set executable:
chmod +x - PATH check: Warn if
~/.local/binnot in PATH, suggest shell-specific config
- Validate Docker installed
- Generate build files in
$SANDY_HOME/ - Build Phase 1 (base) and Phase 2 (Claude Code) images (~15-25 min)
- Build skill pack images if enabled (~5-10 min additional for Chromium)
- Create sandbox directory structure and seed from host
- Create Docker network and apply iptables rules
- Load credentials
- Launch container
- Check image hashes — skip builds if unchanged (~0 sec)
- Refresh statsig feature flags from host
- Create network and iptables rules (~1 sec)
- Load credentials
- Launch container (~3-4 sec total startup)
| File | Lines (at 1.0.0-rc1) | Purpose |
|---|---|---|
sandy |
~6,100 | Main launcher script |
install.sh |
~95 | Installer |
doctor.sh |
~280 | Environment preflight / diagnosis |
CLAUDE.md |
~515 | Claude Code agent guidance |
README.md |
~670 | User documentation |
RELEASE_NOTES.md |
~635 | Version history (v0.6.0–v1.0.0-rc1) |
SPECIFICATION.md |
this file | Technical specification |
SPEC_INTROSPECTION.md |
— | Introspection JSON stability contract |
proxy/ |
— | Egress proxy (Go: listeners, policy, DNS, guard + unit tests) |
templates/user-setup.sh.tmpl |
— | Shellcheck-lintable mirror of the user-setup heredoc |
test/run-tests.sh |
~4,300 | Pure-script test suite (§1–§60) |
test/run-integration-tests.sh |
~1,500 | Headless end-to-end suite (needs Docker + keys) |
test/fixtures/frozen-sandbox-1.0/ |
— | Frozen 1.0 sandbox snapshot (forward-compat guard, §60) |
docs/ |
— | Roadmap, post-1.0 ideas, testing plan, security docs |
examples/gpu/Dockerfile |
40 | Per-project GPU Dockerfile example |
examples/quarto-typst/.sandy/Dockerfile |
16 | Per-project Quarto+Typst example |
analysis/ |
— | Security and architecture audit documents |
research/ |
— | Feature analysis and design sketches |
Line counts are indicative, refreshed at release cuts — not maintained per-commit.
| File | Purpose |
|---|---|
Dockerfile.base |
Phase 1 base image definition |
Dockerfile |
Phase 2 Claude Code image definition |
Dockerfile.skills-base |
Phase 2.5a skill pack base definition |
Dockerfile.skills |
Phase 2.5b skill pack code definition |
entrypoint.sh |
Container root-phase entrypoint |
user-setup.sh |
Container user-phase setup script |
tmux.conf |
Tmux configuration |
passwd / group |
UID/GID remapping files (if needed) |
.base_build_hash |
Phase 1 content hash |
.build_hash |
Phase 2 content hash |
.skills_base_build_hash |
Phase 2.5a content hash |
.skills_build_hash |
Phase 2.5b content hash |
.update_check |
Cached update check result (24-hour TTL) |
.skill_version_<pack> |
Cached skill pack version |
Dockerfile.gemini / .codex / .opencode / .full |
Per-agent / multi-agent image definitions |
Dockerfile.proxy |
Egress proxy image definition |
channel-relay.sh |
Host-side Telegram relay (agent-agnostic tmux injection) |
approvals/ |
Per-workspace passive-privileged-key approval files |
config |
User-level configuration |
.secrets |
User-level credentials |
sandboxes/ |
Per-project sandbox directories |
Sandy generates all build and runtime files as heredocs embedded in the script. Each function writes one or more files to $SANDY_HOME/. Variable expansion is noted for each template.
Generator: generate_dockerfile_base() — quoted heredoc (<<'DOCKERFILE_BASE'), no variable expansion.
FROM debian:trixie-slim
# Some Docker Desktop versions prevent the _apt user from reading the
# temp files apt stages for gpgv, producing spurious "invalid signature"
# errors on apt-get update. Run gpgv as root to sidestep the sandbox.
RUN echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/99-no-sandbox
# System tools + C/C++ toolchain
RUN apt-get update && apt-get install -y \
build-essential \
ca-certificates \
cmake \
curl \
git \
git-lfs \
gosu \
jq \
less \
libcairo2 \
libgdk-pixbuf-2.0-0 \
libpango-1.0-0 \
libssl-dev \
ncurses-term \
openssh-client \
pkg-config \
python3 \
python3-pip \
python3-venv \
ripgrep \
socat \
tmux \
unzip \
&& rm -rf /var/lib/apt/lists/*
# GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*
# Node.js 24 LTS via NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Go (arch-aware). GO_VERSION pins the minor line and is the offline fallback;
# each rebuild resolves the newest patch on that line from go.dev so base
# rebuilds pick up Go security fixes (same build-time-latest semantics as the
# Node/Rust/Bun/uv installs above). When this line leaves Go's 2-release
# support window, dl/?mode=json stops listing it and the fallback pin is used --
# bump GO_VERSION to the new supported minor at that point.
ARG GO_VERSION=1.26.5
RUN ARCH="$(dpkg --print-architecture)" \
&& GO_LATEST="$(curl -fsSL --max-time 10 'https://go.dev/dl/?mode=json' \
| jq -r --arg m "go${GO_VERSION%.*}." \
'.[].version | select(startswith($m))' \
| sed 's/^go//' | sort -rV | head -1)" \
&& case "$GO_LATEST" in ''|*[!0-9.]*) GO_LATEST="" ;; esac \
&& curl -fsSL "https://go.dev/dl/go${GO_LATEST:-$GO_VERSION}.linux-${ARCH}.tar.gz" \
| tar -C /usr/local -xz
# Rust stable (system-wide)
ENV RUSTUP_HOME=/usr/local/rustup
ENV CARGO_HOME=/usr/local/cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --default-toolchain stable \
&& chmod -R a+rX /usr/local/rustup /usr/local/cargo
# Bun
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash
# uv — fast Python package/version manager
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_UNMANAGED_INSTALL=/usr/local/bin sh
# sandy-ss-paths: agent-agnostic helper for the /ss screenshot skill.
# Lists newest N image paths from $SANDY_SCREENSHOTS_PATH (default 1).
RUN cat > /usr/local/bin/sandy-ss-paths <<'SS_HELPER' \
&& chmod +x /usr/local/bin/sandy-ss-paths
#!/bin/bash
# (body — see Appendix E.11a for the host-side mount that defines $SANDY_SCREENSHOTS_PATH)
SS_HELPER
# sandy-claude-statusline: Claude Code native statusLine command (#67).
# Reads the statusLine JSON payload on stdin, emits model/effort/context%.
RUN cat > /usr/local/bin/sandy-claude-statusline <<'STATUSLINE_HELPER' \
&& chmod +x /usr/local/bin/sandy-claude-statusline
#!/bin/bash
# (body — see Appendix C.2 for the seeding side and the output format)
STATUSLINE_HELPER
# sandy-tool-audit: PreToolUse audit hook (HF-incident Issue 6). Seeded into
# settings.json only when SANDY_TOOL_AUDIT=1; reads Claude Code's PreToolUse JSON
# on stdin and appends {ts,tool,args} JSONL to ~/.claude/tool-audit.jsonl. Always
# exits 0 (a non-zero PreToolUse hook would block the tool call).
RUN cat > /usr/local/bin/sandy-tool-audit <<'TOOL_AUDIT_HELPER' \
&& chmod +x /usr/local/bin/sandy-tool-audit
#!/bin/bash
# (body — jq-extracts tool_name + truncated tool_input, appends one JSONL line)
TOOL_AUDIT_HELPER
# User
RUN useradd -m -s /bin/bash -u 1001 claude
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV PATH="/home/claude/.local/bin:/usr/local/cargo/bin:/usr/local/go/bin:$PATH"Generator: generate_dockerfile() — unquoted heredoc (<<DOCKERFILE), expands ${BASE_IMAGE_NAME}.
FROM ${BASE_IMAGE_NAME}
RUN HOME=/home/claude su -s /bin/bash claude -c \
"curl -fsSL https://claude.ai/install.sh | bash" \
&& cp -L /home/claude/.local/bin/claude /usr/local/bin/claude \
&& mv /home/claude/.local/share/claude /opt/claude-code \
&& { /usr/local/bin/claude --version 2>/dev/null \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' > /opt/claude-code/.version || true; }
# synthkit dependencies (WeasyPrint needs pango/cairo/gdk-pixbuf)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev \
&& rm -rf /var/lib/apt/lists/*
RUN UV_TOOL_DIR=/opt/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin \
uv tool install --python-preference system synthkit
COPY tmux.conf /etc/tmux.conf
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY user-setup.sh /usr/local/bin/user-setup.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/user-setup.sh
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]Key details:
- Claude Code is installed as user
claude, then relocated to/usr/local/bin/claude(binary) and/opt/claude-code(data) so it survives the tmpfs overlay on/home/claude. UV_TOOL_DIR=/opt/uv-toolsensures synthkit's venv goes to an accessible location (not/root/).- Version is cached at
/opt/claude-code/.versionfor update detection.
Generator: generate_dockerfile_codex() — unquoted heredoc (<<DOCKERFILE), expands ${BASE_IMAGE_NAME}.
FROM ${BASE_IMAGE_NAME}
# Install Codex CLI as a global npm package. The @openai/codex package ships
# a prebuilt Rust binary per platform; Node is only the installation vehicle.
RUN npm install -g @openai/codex \
&& mkdir -p /opt/codex \
&& { codex --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' > /opt/codex/.version || true; }
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev \
&& rm -rf /var/lib/apt/lists/*
RUN UV_TOOL_DIR=/opt/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install --python-preference system synthkit
COPY tmux.conf /etc/tmux.conf
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY user-setup.sh /usr/local/bin/user-setup.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/user-setup.sh
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]Key details:
- Version is cached at
/opt/codex/.versionfor update detection. - synthkit deps and synthkit itself are baked in so
md2pdf/md2doc/md2html/md2emailare on PATH regardless of whether Step 7's skill-seeding fires (e.g., ifsynthkitisn't installed at user-setup time).
Generator: generate_skill_pack_dockerfiles() — mixed heredocs. Header is quoted (<<'SKILLS_BASE_HEADER'), gstack block is quoted (<<'GSTACK_BASE_BLOCK'). No variable expansion.
FROM sandy-claude-code
# --- gstack base: Playwright + Chromium (rarely changes) ---
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/skills/gstack/.browsers
RUN mkdir -p /opt/skills/gstack \
&& cd /opt/skills/gstack \
&& npm init -y >/dev/null 2>&1 \
&& npm install playwright@latest --save >/dev/null 2>&1 \
&& npx playwright install-deps chromium \
&& npx playwright install chromium \
&& rm -rf node_modules package.json package-lock.jsonOnly generated when a pack requires heavy base dependencies (needs_base=true). The temporary npm project bootstraps Playwright just enough to install Chromium, then cleans up.
Generator: generate_skill_pack_dockerfiles() — header uses unquoted heredoc (<<SKILLS_HEADER, expands ${skills_base_name}), gstack block uses unquoted heredoc (<<GSTACK_BLOCK, expands ${repo} and ${version}).
FROM sandy-skills-base-gstack
# --- gstack skill pack (${version}) ---
RUN mkdir -p /opt/skills/gstack \
&& curl -fsSL "${repo}/archive/${version}.tar.gz" \
| tar -xz --strip-components=1 -C /opt/skills/gstack
RUN cd /opt/skills/gstack \
&& (bun install --frozen-lockfile 2>/dev/null || bun install) \
&& bun run build \
&& echo "${version}" > browse/dist/.version \
&& rm -rf node_modules/.cache
RUN chmod +x /opt/skills/gstack/bin/*Image naming convention: sandy-skills-base-<packs> and sandy-skills-<packs> where <packs> is the sorted, lowercased, hyphen-joined pack list (e.g., gstack).
Generator: generate_entrypoint() — quoted heredoc (<<'ENTRYPOINT'), no variable expansion. Inner heredocs (PIPWRAP) also quoted.
The entrypoint runs as root and performs:
#!/bin/bash
# Verbose tracing at level 3+
if [ "${SANDY_VERBOSE:-0}" -ge 3 ]; then set -x; fi
# UID/GID from host (default 1001)
RUN_UID="${HOST_UID:-1001}"
RUN_GID="${HOST_GID:-1001}"
# 1. Fix tmpfs ownership
chown "$RUN_UID:$RUN_GID" /home/claude
# 2. Seed known_hosts
# Copies from /tmp/host-ssh-known_hosts if present
# Permissions: dir 700, file 644
# 3. SSH agent relay (if SANDY_SSH=agent)
# macOS: socat UNIX-LISTEN → TCP:host.docker.internal:$SSH_RELAY_PORT
# Wait: 50 attempts × 0.1s = 5s timeout for socket
# Linux: chmod 600 + chown on mounted socket
# 4. Copy host SSH config
# From /tmp/host-ssh → ~/.ssh/ (cp -aL to dereference symlinks)
# Permissions: dir 700, keys 600, .pub/config/known_hosts 644
# 5. Fix persistent mount ownership
# Dirs: .pip-packages, .local/share/uv, .npm-global, go, .cargo, .gstack
# 6. Symlink Claude Code
# /usr/local/bin/claude → ~/.local/bin/claude
# /opt/claude-code → ~/.local/share/claude
# 7. pip/pip3 wrappers
# Auto-add --user when outside virtualenvs:
# if [ -z "$VIRTUAL_ENV" ] && [ "${1:-}" = "install" ]; then
# exec python3 -m pip install --user "$@"
# fi
# 8. Drop privileges
exec gosu "$RUN_UID:$RUN_GID" /usr/local/bin/user-setup.sh "$@"Generator: generate_user_setup() — quoted heredoc (<<'USERSETUP'), no outer variable expansion. Inner heredocs for slash commands use quoted <<'SKMD'. Channel access.json uses both quoted and unquoted heredocs depending on mode.
Key implementation details not covered in the main spec:
Settings.json: user-setup.sh no longer merges settings.json — since 0.11.3 the merge-preserving regeneration happens host-side every launch (see §4 Seeding and Appendix C.2: host copy re-read, sandy-managed keys re-overwritten, enabledPlugins preserved from the previous session). Container-side, user-setup only ensures the Claude projects directory for the workspace exists (path-slug transform, required for --continue session lookup).
ANSI color remap: printf "\033]4;4;rgb:61/8f/ff\033\\" (dark blue → bright blue), restored on EXIT trap.
Marketplace update cache: Epoch timestamp written to ~/.claude/plugins/.marketplace_updated. Stale after 86400 seconds (24 hours).
Channel credential seeding (_seed_channel function):
- Creates
~/.claude/channels/<chan>/directory - Writes
.env(chmod 600) withTOKEN_VAR=value - Writes
access.jsononly if it doesn't already exist (preserves user edits) - Allowlist computed by splitting comma-separated senders:
tr ',' '\n'→ awk to produce JSON array
Claude Code launch:
- Tmux mode:
tmux new-session -s sandy -n "sandy: <project>" -- bash -c "<cmd>" - Auto-continue: injects
--continueif session files exist and no conflicting flags - Fallback:
$CMD_WITH_CONTINUE || $CMD_WITHOUT_CONTINUE(retries without--continueon failure) - Remote mode:
claude remote-control --name "sandy: <project>"
Codex-specific user-setup additions:
- Helper predicate
_sandy_has_codex() { [ "${SANDY_AGENT:-claude}" = "codex" ]; }alongside_sandy_has_claude/_sandy_has_gemini. - Synthkit seeding block (conditional on
command -v synthkit) writes~/.codex/skills/<name>/SKILL.mdwith YAML frontmatter formd2pdf,md2doc,md2html,md2email. - Trust-entry appending: after config.toml is in place, if
[projects."$SANDY_WORKSPACE"]is not already present, append:This must happen container-side because it needs the in-container workspace path.[projects."<workspace>"] trust_level = "trusted"
build_codex_cmd(): translates sandy's-p/--print/--promptintocodex execwith a positional prompt; drops--continue/-c; injects--sandbox danger-full-access,--skip-git-repo-check(headless only), and optional--model.- Launch dispatch: the
codexcase sits alongsideclaudeandgeminiin the per-agent dispatch; multi-agent combos iterate over the parsed_SANDY_AGENTSarray and call eachbuild_*_cmdin pane order.
/ss screenshot-skill seeding:
- Gated on
[ -n "${SANDY_SCREENSHOTS_PATH:-}" ]— sandy only sets that env var whenSANDY_SCREENSHOT_DIRwas provided on the host and passed validation. - For each enabled agent, writes the native skill format:
_sandy_has_claude:~/.claude/commands/ss.md(markdown frontmatter,$ARGUMENTSparsed inside!\bash`` for an optional leading count)._sandy_has_gemini:~/.gemini/commands/ss.toml(TOML,{{args}}parsed inside!{bash})._sandy_has_codex:~/.codex/skills/screenshot/SKILL.md(YAML frontmatter; codex matches by description).
- All three call
/usr/local/bin/sandy-ss-paths(baked into Phase 1 base image — see Appendix A.1) to list newest N image paths. - Opencode has no slash-command/skill surface in v0; the helper is on PATH for manual invocation in a prompt (e.g.
opencode "explain $(sandy-ss-paths 1)").
Generator: generate_tmux_conf() — quoted heredoc (<<'TMUXCONF'), no variable expansion.
set -g history-limit 10000
set -g mouse on
set -g default-terminal "tmux-256color"
set -as terminal-features ",tmux-256color:RGB"
set -as terminal-overrides ",*:U8=1"
set -sg escape-time 0
set -g pane-border-lines single
set -g pane-border-style "fg=colour240"
set -g pane-active-border-style "fg=colour51"
set -g pane-border-status top
set -g pane-border-format " #[fg=colour51]#{?@sandy_pane_agent,#{@sandy_pane_agent},#{window_name}}#[default] "
set -g status-position bottom
set -g status-style "bg=colour235,fg=colour248"
set -g status-left "#[fg=colour51,bold] sandy #[default]#[fg=colour248]·#[default] #{?#{==:#{E:SANDY_EGRESS_MODE},strict},#[fg=colour2]★ strict#[default],#{?#{==:#{E:SANDY_EGRESS_MODE},off},#[fg=colour208]★ no-net-iso#[default],#[fg=colour51]★ permissive#[default]}} #[fg=colour248]·#[default] #[fg=colour250]#{E:SANDY_AGENT}#[default] "
set -g status-left-length 70
set -g status-right "#[fg=colour250]#{E:SANDY_PROJECT_NAME}#[default] #[fg=colour248]·#[default] #[fg=colour250]#{session_attached} #{?#{==:#{session_attached},1},client,clients}#[default] #[fg=colour248]·#[default] #{?#{E:SANDY_DAEMON},#[fg=colour214]daemon#[default],#[fg=colour248]session#[default]} #[fg=colour248]·#[default] #[fg=colour248]%H:%M#[default] "
set -g status-right-length 80
set -g window-status-format ""
set -g window-status-current-format ""
set -g window-status-separator ""
set -g window-status-style "bg=colour235,fg=colour235"
set -g window-status-current-style "bg=colour235,fg=colour235"
set -g allow-passthrough on
set -g set-clipboard on
set -g focus-events on
setw -g aggressive-resize on
The status-left/status-right fields read runtime env at display time via
tmux's #{E:VAR} interpolation (not baked in at heredoc-generation time —
the heredoc is quoted, so nothing is substituted when the file is written).
SANDY_EGRESS_MODE drives the egress-posture segment, color-coded:
green (colour2) = strict, cyan (colour51) = permissive, orange
(colour208) = off/no-net-iso (a deliberate warning color — this posture
means no network isolation on macOS). SANDY_AGENT, SANDY_PROJECT_NAME,
and SANDY_DAEMON are forwarded container-side env vars (Appendix E);
session_attached is tmux's own built-in format variable, not env-derived.
The window-status lines are blanked ("") because sandy's tmux sessions are
single- or multi-pane within one window, not multi-window — a window list
in the status bar would be dead chrome. See CLAUDE.md "Status Lines" for how
this outer bar (launch/session-scoped) complements Claude Code's own native
statusLine (Appendix C.2, live per-request model/effort/context%).
All magic numbers, thresholds, timeouts, and limits used in the sandy script.
| Parameter | Value | Context |
|---|---|---|
| Container memory | available_GB - 1, min 2GB |
Auto-detected from docker info --format '{{.MemTotal}}', converted via /1073741824; falls back to 3g if detection fails (4GB assumed − 1) |
| Container CPUs | All available (from docker info --format '{{.NCPU}}') |
Default 2 if detection fails |
| PID limit | 512 | --pids-limit 512 |
tmpfs /tmp |
1 GB, exec | --tmpfs /tmp:exec,size=1G |
tmpfs /home/claude |
2 GB, exec | --tmpfs /home/claude:exec,size=2G,uid=1001,gid=1001 |
| tmux history | 10,000 lines | set -g history-limit 10000 |
| Operation | Timeout | Context |
|---|---|---|
| GitHub releases API | 5 seconds | curl --max-time 5 in skill_pack_latest_release() |
| GitHub commits API | 5 seconds | curl --max-time 5 in skill_pack_latest_release() |
| Sandy update check API | 3 seconds | curl --max-time 3 in sandy_check_update() |
| Claude Code version check | 5 seconds | curl --max-time 5 against Google Cloud Storage |
| SSH socket wait (macOS) | 5 seconds | 50 iterations × 0.1s sleep in entrypoint |
| OAuth token expiry buffer | 5 minutes | 300,000 ms buffer before expiresAt |
| Parameter | Value | Context |
|---|---|---|
SANDY_SANDBOX_MIN_COMPAT |
0.7.10 |
Hard floor on sandbox layout compatibility. A sandbox whose .sandy_created_version is known and below this is refused at launch (error + recreation command; sandy exits before docker run). Unknown/unreadable markers warn but launch. Classified by _sandbox_compat_classify(). 1.x forward-compat promise: this must never advance above 1.0.0 within the 1.x series (a breaking layout change is a 2.0 change). |
.sandy_created_version |
Written once on sandbox creation | Records the sandy version that created the sandbox. Missing on sandboxes created before 0.10.1. |
.sandy_last_version |
Refreshed every launch | Records the most-recent sandy version that touched the sandbox. |
| Cache | TTL | File |
|---|---|---|
| Update check | 86,400 seconds (24 hours) | $SANDY_HOME/.update_check |
| Marketplace refresh | 86,400 seconds (24 hours) | ~/.claude/plugins/.marketplace_updated |
| Skill pack version | Indefinite (refreshed each launch) | $SANDY_HOME/.skill_version_<pack> |
| Path | Mode | Reason |
|---|---|---|
~/.ssh/ |
700 | SSH requires restrictive dir permissions |
~/.ssh/* (private keys) |
600 | SSH refuses keys with group/other access |
~/.ssh/*.pub |
644 | Public keys are non-sensitive |
~/.ssh/config |
644 | SSH config readable |
~/.ssh/known_hosts |
644 | Host fingerprints non-sensitive |
| SSH agent socket | 600 | Only owner should access agent |
Channel .env files |
600 | Contains bot tokens |
.credentials.json (ephemeral) |
600 | Contains OAuth tokens |
| pip/pip3 wrappers | +x | Must be executable |
Skill pack bin/* |
+x | Must be executable |
| cmux notification hook | +x | Must be executable |
| Rust/Cargo directories | a+rX | System-wide install, readable by all |
| Scan | Max Depth | Excludes |
|---|---|---|
| Symlink protection | 8 levels | node_modules/, .venv*/, .git/ |
| Submodule gitdir walk | 6 levels | — |
| Git LFS detection | 3 levels | — |
--security-opt no-new-privileges:true
--cap-drop ALL
--cap-add SETUID
--cap-add SETGID
--cap-add CHOWN
--cap-add DAC_OVERRIDE
--cap-add FOWNER
--read-only
Capabilities SETUID/SETGID are needed for gosu privilege drop. CHOWN/DAC_OVERRIDE/FOWNER are needed for the entrypoint to fix ownership of tmpfs and persistent mounts.
| CIDR | Purpose |
|---|---|
10.0.0.0/8 |
Class A private (home/office LANs, VPNs) |
172.16.0.0/12 |
Class B private (Docker internals, some LANs) |
192.168.0.0/16 |
Class C private (home/office LANs) |
169.254.0.0/16 |
Link-local |
100.64.0.0/10 |
CGNAT / Tailscale |
| Variable | Default | Notes |
|---|---|---|
SANDY_MODEL |
claude-opus-4-8 |
Passed to claude --model |
SANDY_SSH |
token |
Git authentication mode |
SANDY_SKIP_PERMISSIONS |
true |
Skip trust dialog |
SANDY_VERBOSE |
0 |
No extra output |
CLAUDE_CODE_MAX_OUTPUT_TOKENS |
128000 |
Max tokens per response |
HOST_UID / HOST_GID |
1001 |
Default container user if not remapped |
| Container user | claude |
UID 1001, shell /bin/bash |
| Tool | Version | Install Method |
|---|---|---|
| Go | 1.26 (latest patch at build; fallback pin 1.26.5) | Multi-arch binary from go.dev |
| Node.js | 24 LTS | NodeSource setup_24.x |
| Rust | stable (latest) | rustup |
| Bun | latest | curl https://bun.sh/install |
| uv | latest | curl https://astral.sh/uv/install.sh |
| Python | Debian trixie system default (3.13) | apt-get install python3 |
Created at ~/.claude/channels/<channel>/access.json. Two modes:
Allowlist mode (when <CHANNEL>_ALLOWED_SENDERS is set):
{
"dmPolicy": "allowlist",
"allowFrom": ["user_id_1", "user_id_2"],
"groups": {},
"pending": {}
}Pairing mode (when no allowlist configured):
{
"dmPolicy": "pairing",
"allowFrom": [],
"groups": {},
"pending": {}
}allowFrom is computed from the comma-separated env var: split on commas, trim whitespace, wrap each in quotes, join with commas.
The file is only written on first run — if it already exists, it's preserved to respect user edits.
Destination. As of 0.11.3, the seeded settings file lives at $SANDBOX_DIR/claude/settings.json — inside the rw sandbox mount, no :ro overlay. It is regenerated from the host on every launch with merge-preserving semantics (agent-owned enabledPlugins is carried over from the previous sandbox session). The pre-0.11.3 approach used a :ro sidecar at $SANDBOX_DIR/.seed-settings.json, but that blocked /plugin install with EROFS and was reverted. See §4 Seeding for the full flow.
Marketplace structure (added idempotently to extraKnownMarketplaces):
{
"extraKnownMarketplaces": {
"claude-plugins-official": {
"source": { "source": "github", "repo": "anthropics/claude-plugins-official" }
},
"sandy-plugins": {
"source": { "source": "github", "repo": "rappdw/sandy-plugins" }
}
}
}Note the double-nested source — the outer key is the extraKnownMarketplaces schema, the inner object describes the repository.
Sandy defaults merged on every launch (Node.js tier):
{
"teammateMode": "tmux",
"spinnerTipsEnabled": false,
"skipDangerousModePermissionPrompt": true,
"statusLine": { "type": "command", "command": "/usr/local/bin/sandy-claude-statusline", "padding": 0 }
}enabledPlugins is preserved from the previous sandbox session (and inherited from the host copy on first launch) so /plugin install survives relaunches. The file is read-write inside the container — the pre-0.11.3 read-only sidecar was reverted because it broke /plugin install with EROFS. Host-side edits to ~/.claude/settings.json still propagate on the next launch (sandy re-reads the host copy every launch), and the sandy-managed keys are re-overwritten every launch regardless of in-session mutations.
statusLine (#67) is set only if absent — all three seeding branches (Node if (!(k in s)), jq //=, and the last-resort printf literals) use an only-if-absent guard, so a user's own statusLine in ~/.claude/settings.json is never overwritten. When absent, sandy points it at /usr/local/bin/sandy-claude-statusline (baked into the base image, Appendix A.1-adjacent — see the Dockerfile.base RUN cat > ... STATUSLINE_HELPER block), a small script that reads Claude Code's statusLine JSON payload from stdin and emits <model> · [effort: <level> · ]<context%>% ctx, falling back to a bare sandy line on any empty/malformed/wrong-shape input so the TUI never shows an error. This is a live, per-request complement to the tmux status bar (Appendix A.7), which is launch/session-scoped and structurally cannot show per-request model/effort/context — see CLAUDE.md "Status Lines".
JSON repair applied before parsing (handles common hand-editing errors):
- Remove trailing commas: regex
,(\s*[}\]])→$1 - Add missing commas between keys: regex
("key")\s*\n(\s*"nextkey")→$1,\n$2 - If parsing still fails, fall back to empty object
{}
Stored at $SANDY_HOME/sandboxes/<NAME>.claude.json (outside the sandbox dir to avoid mount conflicts). Mounted into the container at /home/claude/.claude.json.
Seeding from host (Node.js):
let d = JSON.parse(fs.readFileSync(hostPath));
delete d.projects; // strip host project paths
fs.writeFileSync(sandboxPath, JSON.stringify(d, null, 2) + "\n");Falls back to cp if Node.js parsing fails.
Fallback if no host copy exists:
{
"tipsDisabled": true,
"installMethod": "native"
}Post-seed merge: Always ensures tipsDisabled: true and installMethod: "native" are set.
Loaded ephemerally from the host, never persisted in the sandbox.
Expected structure for token expiry check:
{
"claudeAiOauth": {
"expiresAt": 1234567890000
}
}expiresAt is milliseconds since epoch. The refresh check uses Date.now() + 300000 > expiresAt (5-minute buffer).
Plain KEY=VALUE format at ~/.claude/channels/<channel>/.env:
TELEGRAM_BOT_TOKEN=<token>
or
DISCORD_BOT_TOKEN=<token>
Permissions: 600 (owner read-write only).
Auto-generated at ~/.claude/hooks/cmux-notify.sh when cmux is detected. Merged into $SANDBOX_DIR/claude/settings.json host-side during the seed regeneration (see §C.2):
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/home/claude/.claude/hooks/cmux-notify.sh"
}
]
}
]
}
}The hook script emits \033]777;notify;<title>;<body>\033\\ OSC sequences.
Plain text file at $SANDY_HOME/.update_check:
<epoch_timestamp> <latest_version>
Example: 1711843200 0.8.0. Stale after 86,400 seconds.
Written to $SANDBOX_DIR/codex/config.toml on first launch of a new sandbox with SANDY_AGENT=codex. Mounted into the container at /home/claude/.codex/config.toml.
# Written by sandy on first launch. Safe to edit, but sandbox_mode must stay
# "danger-full-access" — sandy provides outer isolation; codex's Landlock
# sandbox does not nest cleanly in Docker containers.
model = "gpt-5.5"
sandbox_mode = "danger-full-access"
[notice]
hide_full_access_warning = true
hide_gpt5_1_migration_prompt = true
"hide_gpt-5.1-codex-max_migration_prompt" = true
hide_rate_limit_model_nudge = true
hide_world_writable_warning = true
# [projects."<workspace>"] trust_level = "trusted" appended at session start
# by user-setup.sh, where $SANDY_WORKSPACE is known.The file is created exactly once per sandbox — re-runs preserve user edits. The [notice] list may grow upstream; sandy seeds all five documented keys as cheap insurance. Source-of-truth reference: codex-rs/core/src/config.rs in the openai/codex repository.
After the first session start, the file additionally contains the trust entry:
[projects."/home/claude/dev/myproject"]
trust_level = "trusted"Appended by user-setup.sh only if a matching ^[projects."<workspace>"] line is not already present (idempotent).
Both codex auth paths produce an ephemeral auth.json mounted read-only into the container at /home/claude/.codex/auth.json; the tmpdir is removed on exit (cleanup trap):
- api_key (
OPENAI_API_KEYset,SANDY_CODEX_AUTHisautoorapi_key): sandy generates the file itself as{"OPENAI_API_KEY":"<key>"}(with"and\JSON-escaped) — the same shapecodex login --with-api-keywrites. Required because codex 0.139+ no longer reads the env var for first-party auth. - oauth (host has
~/.codex/auth.json, modeautooroauth): sandy copies the host file to the tmpdir. Schema is opaque to sandy — the file is produced bycodex loginon the host.
Plain text file at $SANDY_HOME/.skill_version_<pack>:
<version_or_sha>
Example: a1b2c3d4e5f6 (commit SHA) or v1.2.3 (release tag). Updated whenever a newer version is resolved from GitHub.
Written to $SANDBOX_DIR/sandy-session.json on every launch and bind-mounted read-only at /etc/sandy-session.json (see Appendix E.16a). The single authoritative in-container proof that the agent is inside sandy:
{
"schema": 1,
"sandy_version": "0.14.1-dev-a1b2c3d",
"egress_mode": "off",
"workspace": "/home/claude/dev/myproject",
"host_uid": 501,
"host_gid": 20,
"launched_at": "2026-06-11T12:00:00Z",
"session_nonce": "3f1c…",
"effort": "high"
}| Field | Meaning |
|---|---|
schema |
Marker schema version (currently 1; the effort field is additive). |
sandy_version |
Full version incl. git short hash (sandy_full_version()). |
egress_mode |
Resolved posture: off | permissive | strict. |
workspace |
Container-side workspace path (matches SANDY_WORKSPACE). |
host_uid / host_gid |
Host identity sandy mapped the container to. |
launched_at |
UTC ISO-8601 launch timestamp (host clock). |
session_nonce |
Per-launch random hex; printed host-side under SANDY_VERBOSE!=0 so an external verifier can match the file to a specific launch. Not exported as an env var. |
effort |
Reasoning effort sandy PINNED for the claude agent via SANDY_EFFORT (JSON string, e.g. "high"), or null when sandy did not pin it (agent ran at Claude Code's own default). Makes a run's effort provable after teardown (1.6.0). |
Because the file is a :ro bind mount, a committed workspace .sandy/config cannot forge it. In-container tooling (the sandy-isolation-test kit, CI) should assert on this file rather than on env vars or uid/cap heuristics.
Sandy runs on both Linux and macOS. The following sections document every point where behavior diverges.
Scope: this table describes the legacy
SANDY_EGRESS_PROXY=0path. The default is1(permissive), under which the egress proxy provides uniform isolation on both platforms (see the "Cross-platform fix" note below and the "Egress Proxy" section) — so this table applies only when a user explicitly opts out of the proxy.
| Aspect | Linux | macOS |
|---|---|---|
| Mechanism | iptables DOCKER-USER chain |
None (only under opt-out =0; Docker Desktop does not provide LAN isolation) |
| Rules applied | DROP for 5 private ranges; ACCEPT for container subnet and allowed hosts | None — LAN, host.docker.internal, and host localhost are all reachable |
| Fail-closed | Aborts if iptables unavailable (unless SANDY_ALLOW_NO_ISOLATION=1) |
Prints loud launch warning banner; proceeds without isolation |
| Defense-in-depth | n/a | --add-host gateway.docker.internal:127.0.0.1, --add-host metadata.google.internal:127.0.0.1, and (conditionally) --add-host host.docker.internal:127.0.0.1 |
| Cleanup | Rules and bridge network deleted on exit | Bridge network deleted on exit |
macOS --add-host condition: host.docker.internal is only nullified when SANDY_SSH != agent. In agent mode, sandy's in-container SSH agent relay uses that hostname to reach the host-side socat relay (see §10); nullifying it would break SSH. An additional warn line is emitted in that case.
Cross-platform fix — SANDY_EGRESS_PROXY (M2.7): the egress proxy sidecar (transparent SNI/Host + CONNECT + DNS) implements uniform outbound isolation on both platforms via a Docker --internal network. 1=permissive (block LAN/host, allow internet — the default), 2=strict (allowlist only). Since the proxy is on by default, this entire table describes only the opt-out =0 path. See the "Egress Proxy" section above, ISOLATION_STRESS.md finding F2, and proxy/ for the implementation.
Linux iptables flow:
- Test
sudo iptables -L DOCKER-USER -n— if fails, abort (or allow with override) - Insert DROP rules for each private range (inserted first = evaluated last)
- Insert ACCEPT for
SANDY_ALLOW_LAN_HOSTSentries (if set) - Insert ACCEPT for container's own subnet (inserted last = evaluated first)
- On exit: delete rules in reverse, remove Docker network
| Aspect | Linux | macOS |
|---|---|---|
| Host → container | Direct Unix socket mount (-v $SSH_AUTH_SOCK:/tmp/ssh-agent.sock) |
TCP relay via socat |
| Port allocation | N/A | python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); ..." |
| Host relay | N/A | socat TCP-LISTEN:<port>,bind=127.0.0.1,fork,reuseaddr UNIX-CONNECT:<SSH_AUTH_SOCK> |
| Container relay | N/A (direct socket) | socat UNIX-LISTEN:/tmp/ssh-agent.sock,fork,mode=0600 TCP:host.docker.internal:<port> |
| Socket wait | N/A | 50 × 0.1s = 5s timeout |
| Dependency | None extra | Requires socat and python3 on host (checked, error with brew install suggestion) |
| Aspect | Linux | macOS |
|---|---|---|
| Primary source | ~/.claude/.credentials.json (file) |
Same file |
| Fallback source | None | macOS Keychain: security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w |
| Token refresh | Skip (no browser available on headless Linux) | claude auth login (can open browser) |
| Browser detection | can_open_browser() always returns 1 (false) |
Always returns 0 (true) |
sha256() { shasum -a 256 2>/dev/null || sha256sum; }- macOS:
shasum -a 256(Perl-based, ships with macOS) - Linux: Falls through to
sha256sum(coreutils)
| Aspect | Linux | macOS |
|---|---|---|
| Host UID detection | id -u (typically non-root, e.g. 1000) |
id -u (typically 501) |
| Image default UID | 1001 | 1001 |
| Remapping needed | Usually yes (1000 ≠ 1001) | Usually yes (501 ≠ 1001) |
| Implementation | Custom passwd/group files mounted read-only |
Same |
passwd sed pattern: sed "s/^claude:x:1001:1001:/claude:x:${HOST_UID}:${HOST_GID}:/"
group sed pattern: sed "s/^claude:x:1001:/claude:x:${HOST_GID}:/"
settings.json merge (3 tiers, tried in order — target is $SANDBOX_DIR/claude/settings.json, rebuilt every launch with merge-preserving semantics):
- Node.js: JSON repair → parse host → read previous sandbox → preserve
enabledPluginsfrom previous → merge defaults → merge marketplaces → scrub deprecated → write - jq: Same shape via
--argjson prev "$_prev_plugins"read from the previous sandbox settings - printf: Only if no file exists yet, writes minimal JSON
.claude.json seeding (2 tiers):
- Node.js: Parse, delete
projectskey, write pretty-printed - cp: Raw copy if Node.js fails (host projects key preserved — less clean but functional)
Token expiry check (2 tiers):
- Node.js: Parse credentials JSON, check
claudeAiOauth.expiresAtagainstDate.now() + 300000 - Python 3: Same logic via
json.loadsandtime.time() * 1000 - If neither available: warn and return "no refresh needed" (fail-open — the existing credentials are used as-is rather than forcing a re-login)
Skill pack version resolution (3 tiers):
- GitHub releases API: 5s timeout, looks for tags matching prefix
- GitHub commits API: 5s timeout, gets latest commit SHA (truncated to 12 chars)
- Local cache file:
$SANDY_HOME/.skill_version_<pack> - Hardcoded fallback:
SKILL_PACK_VERSIONSarray entry
The docker run command is assembled incrementally in a RUN_FLAGS array. This appendix documents the complete assembly in order.
Only one sandy may run against a given workspace at a time. Early in launch (after config loading, before sandbox seeding), sandy takes a per-workspace mutex:
mkdir -p "$SANDY_HOME/sandboxes"
SANDY_WORKSPACE_LOCK="$SANDY_HOME/sandboxes/.${SANDBOX_NAME}.lock"
if ! mkdir "$SANDY_WORKSPACE_LOCK" 2>/dev/null; then
holder_pid="$(cat "$SANDY_WORKSPACE_LOCK/pid" 2>/dev/null || true)"
if numeric_and_dead "$holder_pid"; then
# Auto-clear stale lock; rm -rf + retry mkdir
else
# error: another sandy is already running in this workspace (pid <holder>)
exit 1
fi
fi
echo "$$" > "$SANDY_WORKSPACE_LOCK/pid"mkdir is used as the lock primitive because it is atomic on every POSIX filesystem and requires no external dependency (unlike flock(1), which is not shipped on macOS by default). The lock dir is released by the cleanup trap (trap cleanup EXIT INT TERM HUP) on normal exit, Ctrl-C, or sandy crash. A SIGKILL (OOM, kill -9) leaves the lock dir behind, but sandy's next launch reads $LOCK/pid, probes liveness via kill -0 <pid>, and auto-clears the lock when the holder is gone (and reacquires via a second mkdir). PID reuse is a theoretical concern — if the OS recycled the holder's PID to an unrelated process, kill -0 returns true and sandy errors out (false-positive "still held"); the user clears manually. The conservative default is preferred over a false negative that would clobber an active session.
A non-numeric or empty $LOCK/pid (corrupt — sandy died mid-write) is left for the user to inspect; auto-clear refuses to act on it.
Rationale: two agents editing the same codebase would step on each other's edits, and the sandbox-seeding / venv-materialization code paths assume exclusive ownership. Deliberate parallelism should use separate workspaces.
Preflight failure-mode guards (M4 PR 4.4). After the no-Docker-needed fast paths (--version/--help/--upgrade/--print-*/--validate-config) have exited, the launch path fails fast with a specific, actionable message (non-zero exit) rather than dying later with a raw error:
| Condition | Check | Message (substring) |
|---|---|---|
| Docker client absent | command -v docker |
"Docker is not installed or not in PATH." |
| Docker daemon down | docker info (after the binary check, so "not installed" vs "daemon down" stay distinct) |
"Docker is installed but the daemon isn't responding." |
$SANDY_HOME not writable |
write-probe (: > "$SANDY_HOME/.sandy-write-test") |
"SANDY_HOME (…) is not writable" + chmod u+rwx hint |
Corrupt host ~/.claude/.credentials.json (claude path) |
_creds_is_valid_json before the OAuth-token branch — empty is valid (absent creds is a legitimate env-var-auth state); skipped if no host JSON parser |
with a token: warn + drop the file + use the token; without: "credentials are corrupt … Re-authenticate on the host" + exit |
Each message is asserted by run-tests.sh §53 (validator unit test + source-level message lock-in) and run-integration-tests.sh §15 (read-only SANDY_HOME and corrupt-creds exercise the real launch path). Already-clean modes: a partial sandbox self-repairs (mkdir -p "$SANDBOX_DIR/claude" runs unconditionally), and a missing image rebuilds via the build gate.
Stale container removal: Before starting, any container with the same name is force-removed to handle unclean previous exits:
docker rm -f "sandy-<SANDBOX_NAME>" 2>/dev/null || true--rm -it
--name sandy-<SANDBOX_NAME>
--cpus <SANDY_CPUS>
--memory <SANDY_MEM>
--security-opt no-new-privileges:true
--cap-drop ALL
--cap-add SETUID --cap-add SETGID
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER
--pids-limit 512
--read-only
--tmpfs /tmp:exec,size=1G
--tmpfs /home/claude:exec,size=2G,uid=1001,gid=1001
--network <NETWORK_NAME>If SANDY_GPU is set and Docker supports GPUs (docker info --format '{{.Runtimes}}' contains nvidia or cdi):
--gpus <SANDY_GPU> # e.g., "all" or "device=0,1"If credentials were loaded (OAuth token or credentials file):
-v "<CRED_TMPDIR>/.credentials.json:/home/claude/.claude/.credentials.json"The temporary directory is created per-launch and cleaned up on exit. The mount is read-write — Claude Code cloud features (e.g., /ultrareview) need to write refreshed or scoped tokens back to the credentials file during a session. The tmpdir is ephemeral (fresh each launch, rm -rf on exit), so in-session writes do not persist to the host. Codex auth.json and Gemini OAuth mounts remain :ro (these agents don't have equivalent cloud features that require token write-back). See §11 for credential loading rules per agent.
Cleanup trap: the cleanup function that removes *_CRED_TMPDIR directories is registered on EXIT INT TERM HUP QUIT ABRT. SIGKILL cannot be trapped, so a residual cleanup window exists in that case alone.
-v "<SANDY_HOME>/sandboxes/<NAME>.claude.json:/home/claude/.claude.json"Always mounted — this file is seeded on first run and persists across sessions.
If ~/.claude/hooks/ exists on the host:
-v "$HOME/.claude/hooks:/home/claude/.claude/hooks:ro"-v "<HOST_PATH>:<CONTAINER_PATH>"Where CONTAINER_PATH follows the workspace path mapping rules (Section 13).
If $WORK_DIR/.venv exists on the host, is not a symlink, and SANDY_VENV_OVERLAY is not 0, sandy bind-mounts a sandbox-owned dir over the workspace venv path:
-v "<SANDBOX_DIR>/venv:<CONTAINER_PATH>/.venv"
-e "SANDY_VENV_OVERLAY_ACTIVE=1"
-e "SANDY_VENV_PYTHON_VERSION=<major.minor>" # if parseable from pyvenv.cfgMust appear after the workspace mount (E.7) so Docker can overlay it on top. The sandbox venv/ dir is created on the host side before docker run.
Python version resolution (host side), in order:
$WORK_DIR/.python-version— authoritative, user-maintained.$WORK_DIR/.venv/pyvenv.cfgversion/version_infoline — fallback.
The result is normalized to major.minor (cut -d. -f1-2) and validated against ^[0-9]+\.[0-9]+$. Values that don't match are discarded and SANDY_VENV_PYTHON_VERSION is left unset — the container then defaults to 3.12 in user-setup.sh.
Symlinked .venv/ is explicitly skipped on the host side; an info message fires instead of silently proceeding. Rationale: the symlink target may be a path outside $WORK_DIR and overlaying it would shadow unpredictable host state.
Inside the container, user-setup.sh:
- If
$WORKSPACE/.venv/pyvenv.cfgdoes not exist, materializes a fresh venv viauv venv --clear --python <version> $WORKSPACE/.venv. The--clearflag is required: the overlay bind-mount target always exists as a directory, anduv venvotherwise refuses with "A directory already exists at: .venv". No in-container locking is needed — the host-side workspace mutex (§E.0) guarantees exclusive access. - After materialization (or on subsequent launches), compares the overlay's actual
pyvenv.cfgversion againstSANDY_VENV_PYTHON_VERSION. Mismatch → prints a drift warning with the recreate command. No auto-recreate (would silently nuke installed packages). - Activates unconditionally if
$WORKSPACE/.venv/bin/pythonexists (VIRTUAL_ENV+ PATH prepend).
The host .venv/ is never read or written by sandy — it is shadowed by the bind mount inside the container only.
If .git is a file (submodule), the gitdir is also mounted:
-v "<HOST_GITDIR>:<CONTAINER_GITDIR>"Both paths use the same $HOME-relative mapping to preserve the relative relationship.
Before assembling mounts, sandy scans the workspace for symlinks escaping the project directory:
find <WORKSPACE> -maxdepth 8 \
-path '*/node_modules' -prune -o \
-path '*/.venv*' -prune -o \
-path '*/.git' -prune -o \
-type l -printEach symlink's real path is checked against the workspace root. If any escape, sandy consults the persisted approval list <SANDBOX_DIR>/.sandy-approved-symlinks.list (one <link>\t<target> per line). The handling is one of three paths:
-
No approval list yet (first launch): prompt the user with
These could allow Claude to access files outside the sandbox. Proceed anyway? [y/N]On
y/Y, sandy writes the current set to the approval list and proceeds. Anything else aborts with exit 1. -
Current set is a subset of the approved list: proceed silently. Sandy rewrites the list to drop entries the user has deleted (symlink removal is benign).
-
Current set contains an entry not in the approved list: hard error, naming the new symlink(s), with no re-prompt. Rationale: a y/N that fires every session can be trained past; a hard error forces a deliberate user action. Remediation is
rmthe offending link (restoring the approved state), orrm <SANDBOX_DIR>/.sandy-approved-symlinks.listto clear the persisted approval and get a fresh prompt on the next launch.
When the user accepts, each symlink target is mounted into the container (see Section 9, Symlink Protection). Mount path depends on symlink type:
- Absolute:
-v "<resolved_host_path>:<raw_symlink_value>" - Relative:
-v "<resolved_host_path>:<HOME_relative_container_path>"
Deduplicated by container mount path.
Three static categories, sourced from the single-source-of-truth helpers in sandy (_sandy_protected_files, _sandy_protected_git_files, _sandy_protected_dirs) and exposed to the test harness via sandy --print-protected-paths, which emits file:<path>, gitfile:<path>, and dir:<path> lines — plus one dynamic mount (a redirected core.hooksPath, resolved per-workspace at launch) that is not part of --print-protected-paths, because it depends on the workspace's git config, which the pure pre-workspace fast-path handler can't see. See §9 for the full path list and threat model.
Regular files — existence-gated (0.11.2):
while IFS= read -r f; do
[ -e "<WORKSPACE>/$f" ] && -v "<WORKSPACE>/$f:<CONTAINER_WORKSPACE>/$f:ro"
done < <(_sandy_protected_files)The always-mount-with-empty-fixture pattern was reverted for files in 0.11.2 because Docker creates mount targets on the host inside the rw workspace bind, causing 0-byte stub files to appear in the user's workspace (breaking direnv and polluting git status). See §9 for the residual F3 gap and its host-side detection mitigation.
Git-tree files — existence-gated (meaningless without a real git repo):
while IFS= read -r f; do
[ -f "<WORKSPACE>/$f" ] && -v "<WORKSPACE>/$f:<CONTAINER_WORKSPACE>/$f:ro"
done < <(_sandy_protected_git_files)Directories — existence-gated (same model as files; absent dirs are covered by session-end detection, §9):
while IFS= read -r d; do
[ -d "<WORKSPACE>/$d" ] && -v "<WORKSPACE>/$d:<CONTAINER_WORKSPACE>/$d:ro"
done < <(_sandy_protected_dirs)(The --print-schema JSON field listing these dirs is still named dirs_always_mount — the name is historical, kept for introspection-schema stability; see SPEC_INTROSPECTION.md.)
Dynamic — redirected core.hooksPath (per-workspace, resolved at launch; see §9):
_extra_hooks="$(_sandy_extra_hooks_dir "<WORK_DIR>")"
[ -n "$_extra_hooks" ] && [ -d "<WORK_DIR>/$_extra_hooks" ] && \
-v "<WORK_DIR>/$_extra_hooks:<CONTAINER_WORKSPACE>/$_extra_hooks:ro"Mounts the git-consulted hooks directory — the configured path, not its canonical target, so a symlinked hooksPath can't be swapped — when it resolves inside the workspace and is neither .git/hooks, the workspace root, nor an already-static-protected dir. Not emitted by --print-protected-paths (workspace-git-config dependent).
Submodule gitdir walk — after the above loops:
_protect_submodule_gitdirs "<WORK_DIR>/.git/modules" "<CONTAINER_WORKSPACE>/.git/modules"
# When .git is a file (submodule worktree / --separate-git-dir):
[ -d "<GITDIR_HOST>/modules" ] && \
_protect_submodule_gitdirs "<GITDIR_HOST>/modules" "<GITDIR_CONTAINER>/modules"For each config sentinel file found under the root (up to maxdepth 6), the helper emits three mounts: config:ro, hooks:ro (empty fixture if absent), and info:ro (only if present). Uses -print0 and shell-side dirname for macOS/BSD portability.
$SANDY_HOME/.empty-ro-file (zero-byte) and $SANDY_HOME/.empty-ro-dir/ (empty) are created idempotently by ensure_build_files() on every launch and live alongside the generated Dockerfiles.
For each of commands, agents, plugins:
# Only mount if the workspace has .claude/<subdir> OR the sandbox already has data
if [ -d "<WORKSPACE>/.claude/<subdir>" ] || [ -d "<SANDBOX>/workspace-<subdir>" ]; then
mkdir -p "<SANDBOX>/workspace-<subdir>"
-v "<SANDBOX>/workspace-<subdir>:<CONTAINER_WORKSPACE>/.claude/<subdir>"
fiThis hides host content at these paths and provides a writable overlay from the sandbox.
If SANDY_SCREENSHOT_DIR is set on the host (and passes validation):
-v "<SANDY_SCREENSHOT_DIR>:/home/claude/screenshots:ro"
-e "SANDY_SCREENSHOTS_PATH=/home/claude/screenshots"Read-only by design — the agent must never mutate the host's screenshot folder. The container-side path is fixed (/home/claude/screenshots) so the /ss slash command files generated by user-setup.sh and the sandy-ss-paths helper baked into the base image can hardcode it.
Validation (run at launch, before any docker run):
- Reject shell metacharacters (
; $ \& | < >`). - Reject literal
$HOMEand/after canonicalization (pwd -P). - Missing directory → warn-and-skip (clear
SANDY_SCREENSHOT_DIR, no mount). Hard-erroring would be noisy; auto-creating the host dir would silently materialize an empty folder where the user expected content.
SANDY_SCREENSHOT_DIR has no default. Unset = no mount, no env var, no skill files generated. See §7 step 4a (user-setup.sh) for the per-agent skill file generation that runs container-side once the mount is in place.
If SANDY_EXTRA_ENV is set (privileged tier; comma-separated env-var names):
# For each name listed:
-e "<NAME>=<VALUE>"Source resolution for each <VALUE> (env wins absolutely; among files, last-match-wins iteration in standard precedence order):
env > $WORK_DIR/.sandy/.secrets > $WORK_DIR/.sandy/config
> $SANDY_HOME/.secrets > $SANDY_HOME/config
Workspace sources are consulted for values, matching the standard _load_sandy_config precedence (workspace overrides host). The security boundary lives on the names: SANDY_EXTRA_ENV is privileged-tier, so a workspace setting it triggers the passive-privileged approval prompt. Once a name is approved, the value can come from any of the four files (or env).
SANDY_AGENT_ARGS (privileged tier, since 1.3.0): extra CLI arguments appended to the resolved agent command on every launch. After the passive-privileged approval resolves (so an unapproved workspace value is empty and injects nothing), the value is whitespace-split into argv (read -ra — never eval) and prepended to the script's forwarded positional args (set -- "${parsed[@]}" "$@"), a single top-level injection reached by every launcher because they all funnel through the one docker run … "$@". It is deliberately not added to the --start re-exec argv — the daemon supervisor re-loads config itself and would otherwise double-apply. Each token is printf %q-quoted downstream by _sandy_translate_args exactly like a command-line pass-through arg, so the final per-agent command line is sandy's own flags → SANDY_AGENT_ARGS tokens → command-line pass-through args. v1 limitation: no embedded-space/quoted-arg support (a value is split on runs of whitespace); schema_version stays 1 (additive).
Validation rules:
- Names must match
^[A-Z_][A-Z0-9_]*$(POSIX env-var convention) — invalid names are skipped with a warning. - Names that collide with
SANDY_PRIVILEGED_KEYSorSANDY_PASSIVE_KEYSare skipped (those have their own typed path). - A listed name with no value anywhere produces a launch-time warning, not a failure (the user may have intended a per-host or shell-defined value that's currently absent on this machine).
Use case: tokens for user-installed MCP servers / agent tooling that sandy doesn't know about (Home Assistant API, internal corp APIs, etc.). Without this, users would have to hardcode tokens in <workspace>/.mcp.json (less secret-management-friendly) or fork the sandy script.
-v "<SANDBOX>/pip:<HOME>/.pip-packages"
-v "<SANDBOX>/uv:<HOME>/.local/share/uv"
-v "<SANDBOX>/npm-global:<HOME>/.npm-global"
-v "<SANDBOX>/go:<HOME>/go"
-v "<SANDBOX>/cargo:<HOME>/.cargo"If gstack is in SANDY_SKILL_PACKS:
-v "<WORKSPACE>/.gstack:<HOME>/.gstack"Note: gstack mounts from the workspace, not the sandbox — see §6 "Workspace State (gstack)" for rationale and the one-shot migration from the legacy <SANDBOX>/gstack/ location.
The sandbox directory itself becomes ~/.claude inside the container:
-v "<SANDBOX_DIR>:/home/claude/.claude"As of 0.11.3, there is no child overlay on settings.json. The file lives at <SANDBOX_DIR>/claude/settings.json inside the rw sandbox mount (E.13) and is regenerated host-side by the pre-launch seed step (§4 Seeding) every launch. The regeneration re-reads the host ~/.claude/settings.json, overlays sandy defaults and marketplaces, and preserves enabledPlugins from the previous sandbox session. No additional mount flag is emitted.
Rationale: the pre-0.11.3 approach used a :ro child overlay (<SANDBOX_DIR>/.seed-settings.json → /home/claude/.claude/settings.json:ro), but that caused /plugin install to fail with EROFS because Claude Code writes the plugin list to settings.json at install time. The merge-preserving rw approach trades strict F6 reset-on-launch for functional plugin installs, while still guaranteeing sandy-managed keys are re-overwritten every launch.
Token mode (SANDY_SSH=token): No SSH mounts. Git token passed via environment variable.
Agent mode (SANDY_SSH=agent):
Linux:
-v "<SSH_AUTH_SOCK>:/tmp/ssh-agent.sock"
-e "SSH_AUTH_SOCK=/tmp/ssh-agent.sock"macOS: Port passed via environment variable (relay handled by entrypoint):
-e "SSH_RELAY_PORT=<port>"Both platforms (if ~/.ssh exists):
-v "$HOME/.ssh:/tmp/host-ssh:ro"If ~/.ssh/known_hosts exists (mounted separately for token mode too):
-v "$HOME/.ssh/known_hosts:/tmp/host-ssh-known_hosts:ro"If host UID ≠ 1001:
-e "HOST_UID=<uid>"
-e "HOST_GID=<gid>"
-v "<SANDY_HOME>/passwd:/etc/passwd:ro"
-v "<SANDY_HOME>/group:/etc/group:ro"The passwd/group files are generated by sed:
sed "s/^claude:x:1001:1001:/claude:x:${HOST_UID}:${HOST_GID}:/" /etc/passwd > passwd
sed "s/^claude:x:1001:/claude:x:${HOST_GID}:/" /etc/group > groupAll passed via -e KEY=VALUE:
# Workspace identity
SANDY_WORKSPACE=<container_path>
SANDY_PROJECT_NAME=<basename>
# Claude Code config
SANDY_MODEL=<model>
SANDY_SKIP_PERMISSIONS=<true|false>
SANDY_NEW_SESSION=<true|false>
SANDY_REMOTE_CONTROL=<true|false>
SANDY_VERBOSE=<0-3>
CLAUDE_CODE_MAX_OUTPUT_TOKENS=<128000>
# Channels (if configured)
SANDY_CHANNELS=<channel_spec>
TELEGRAM_BOT_TOKEN=<token>
TELEGRAM_ALLOWED_SENDERS=<ids>
DISCORD_BOT_TOKEN=<token>
DISCORD_ALLOWED_SENDERS=<ids>
# Git identity (auto-detected from host git config if not set)
GIT_USER_NAME=<name>
GIT_USER_EMAIL=<email>
SANDY_SSH=<token|agent>
GIT_TOKEN=<token> # token mode only
GH_ACCOUNTS=<user1:tok1,user2:tok2> # all gh-authenticated accounts
# Claude credentials (OAuth-first since 0.15.2; block gated on claude ∈ agent set):
# CLAUDE_CODE_OAUTH_TOKEN set → forward ONLY the token; ANTHROPIC_API_KEY is
# suppressed entirely (launch warning if both set)
# no OAuth token → forward ANTHROPIC_API_KEY only if non-empty, plus
# CLAUDE_CODE_OAUTH_TOKEN= (emptied vs host-env leak)
CLAUDE_CODE_OAUTH_TOKEN=<token> # or ANTHROPIC_API_KEY=<key> — never both
# Agent teams (if configured)
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=<0|1>
# System
HOST_UID=<uid>
HOST_GID=<gid>
SANDY_AGENT=<agent[,agent…]> # resolved agent selection (drives entrypoint pane layout)
SANDY_EGRESS_MODE=<off|permissive|strict> # posture introspection — forwarded in ALL modes (informational)DISABLE_AUTOUPDATER=1 and FORCE_AUTOUPDATE_PLUGINS=true are exported by the entrypoint inside the container, not passed via docker -e.
Git identity fallback: if GIT_USER_NAME/GIT_USER_EMAIL are not set via config, they are read from the host's git config user.name and git config user.email.
Gemini-specific env (whenever gemini is in SANDY_AGENT):
GEMINI_API_KEY=<key> # if set
GEMINI_MODEL=<model> # if set
SANDY_GEMINI_AUTH=<auto|api_key|oauth|adc>
GEMINI_SANDBOX=false # gemini's own sandbox off — sandy provides isolation
GOOGLE_CLOUD_PROJECT=<proj> # Vertex AI
GOOGLE_CLOUD_LOCATION=<region>
GOOGLE_GENAI_USE_VERTEXAI=<true>
GOOGLE_API_KEY=<key>
GOOGLE_APPLICATION_CREDENTIALS=/home/claude/.config/gcloud/application_default_credentials.json # adc modeCodex-specific env (SANDY_AGENT=codex):
OPENAI_API_KEY=<key> # if set
CODEX_MODEL=<model> # if set
SANDY_CODEX_AUTH=<auto|api_key|oauth>CODEX_HOME is not a sandy config key and is never forwarded — sandy owns the in-container path (/home/claude/.codex) via the sandbox mount, and overriding it would break the mount. (Removed from the passive allowlist in the PR 4.1 surface audit, where it was found declared-but-never-consumed.)
Codex-specific mounts (SANDY_AGENT=codex):
-v "$SANDBOX_DIR/codex:/home/claude/.codex"
# if OAuth path active:
-v "$CODEX_CRED_TMPDIR/auth.json:/home/claude/.codex/auth.json:ro"The codex sandbox dir is writable (codex needs log/, memories/, session rollouts, sqlite state), but the auth.json file inside it is shadowed by a read-only overlay bind when either auth path is active (OAuth copy or api-key materialization — see C.7c). See §11 for the rationale of the read-only overlay.
OpenCode-specific env (whenever opencode is in SANDY_AGENT):
OPENCODE_MODEL=<model> # if set
SANDY_OPENCODE_AUTH=<auto|api_key|oauth>
SANDY_LOCAL_LLM_HOST=<host:port> # if set (local-LLM passthrough — proxy forward listener / iptables hole)
# Provider keys forwarded natively for opencode's provider-agnostic auth (each only if set):
ANTHROPIC_API_KEY=<key>
OPENAI_API_KEY=<key>
GEMINI_API_KEY=<key>OpenCode mounts: $SANDBOX_DIR/opencode/config → ~/.config/opencode and $SANDBOX_DIR/opencode/share → ~/.local/share/opencode; the OAuth path additionally mounts host ~/.local/share/opencode/auth.json read-only when present.
Immediately after forwarding SANDY_EGRESS_MODE, sandy writes a marker file and mounts it read-only:
_sandy_egress_mode=<off|permissive|strict> # captured once, reused for the env var + marker
_sandy_session_nonce=$(openssl rand -hex 16 || head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
printf '{...}' > "$SANDBOX_DIR/sandy-session.json" # schema in Appendix C.9
RUN_FLAGS+=(-v "$SANDBOX_DIR/sandy-session.json:/etc/sandy-session.json:ro)The nonce is printed host-side only under SANDY_VERBOSE!=0 and is not exported as an env var — the read-only file is the trust root. This is the one authoritative in-container signal of "running inside sandy, at egress mode X." Rationale in CLAUDE.md → Self-Attestation Marker.
docker run "${RUN_FLAGS[@]}" <IMAGE_NAME> "${REMAINING_ARGS[@]}"Where <IMAGE_NAME> is the most-derived image in the build chain:
sandy-project-<name>-<hash>if Phase 3 existssandy-skills-<packs>if skill packs enabledsandy-claude-codeotherwise