Skip to content

Latest commit

 

History

History
2588 lines (1890 loc) · 176 KB

File metadata and controls

2588 lines (1890 loc) · 176 KB

Sandy Specification

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.

Supported Agents

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.


Table of Contents

  1. Command-Line Interface
  2. Configuration System
  3. Versioning
  4. Per-Project Sandboxes
  5. Docker Image Build Pipeline
  6. Skill Pack System
  7. Container Runtime
  8. Network Isolation
  9. Protected Files
  10. SSH Agent Relay
  11. Credential Management
  12. Session Management
  13. Workspace Path Mapping
  14. Environment Detection
  15. Plugin Marketplace Management
  16. Channel Integration
  17. Auto-Update
  18. Security Model
  19. Test Suite
  20. Installation
  21. File Inventory

Appendices (Implementation Detail)


1. Command-Line Interface

Usage Modes

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)

Administrative Flags

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)

Maintenance Flags

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.

Introspection Flags (machine-readable JSON)

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).

Verbosity Flags

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

Argument Forwarding

All unrecognized arguments (including -p "prompt", --resume, --continue) are forwarded to the claude binary inside the container.

Flag Parsing

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.


2. Configuration System

Load Order

  1. $SANDY_HOME/config — user-level defaults (typically ~/.sandy/config) — privileged tier
  2. $SANDY_HOME/.secrets — user-level credentials — privileged tier
  3. .sandy/config — per-project overrides — passive tier
  4. .sandy/.secrets — per-project credentials — passive tier

Later files override earlier values, subject to tier restrictions below.

Parser

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.

Config Tiers (1.0-rc1)

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

SANDY_ALLOW_LAN_HOSTS Sanity Check

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.

Allowlisted Variables

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.

Model Validation

SANDY_MODEL is validated against ^[a-zA-Z0-9._-]+$ to prevent injection.


3. Versioning

Version Variables

  • SANDY_VERSION: X.Y.Z for releases, X.Y.(Z+1)-dev for post-release development
  • SANDY_COMMIT: Empty in source; baked in by install.sh for local installs; detected from git at runtime if empty

Full Version String

sandy_full_version() produces strings like 0.7.11-dev-a1b2c3d by combining SANDY_VERSION and the commit hash.

Update Check

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.


4. Per-Project Sandboxes

Naming

Each project directory gets a sandbox at ~/.sandy/sandboxes/<NAME>-<HASH>/:

  • <NAME>: Sanitized basename of project directory (alphanumeric, dots, hyphens)
  • <HASH>: First 8 characters of SHA256 of the canonicalized project path (via pwd -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.

Directory Layout

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.

Seeding

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:

  1. Base: read host ~/.claude/settings.json (or start from {} if absent).
  2. Overlay: read the previous sandbox <NAME>/claude/settings.json (if it exists) and preserve enabledPlugins from it onto the base, so plugin installs survive across launches.
  3. Merge sandy-required defaults (teammateMode, spinnerTipsEnabled, skipDangerousModePermissionPrompt) if not already present. When SANDY_SKIP_PERMISSIONS=true (the default), also set permissions.defaultMode = "bypassPermissions" (overwrite, not merge — toggling SANDY_SKIP_PERMISSIONS between launches reliably propagates). When SANDY_SKIP_PERMISSIONS=false, the key is removed if previously sandy-set. permissions.disableBypassPermissionsMode (host policy) is left alone.
  4. Merge extraKnownMarketplaces entries for claude-plugins-official and sandy-plugins; scrub deprecated entries (thinkkit, ait, pka-skills).
  5. Write the merged result back to <NAME>/claude/settings.json.
  6. (First run only) Copy host ~/.claude/.claude.json → sandbox <NAME>.claude.json, stripping the projects key.
  7. (First run only) Copy host ~/.claude/statsig/ → sandbox claude/statsig/ (refreshed on every launch from a separate "always-refresh statsig" block).
  8. (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 = true

The 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.

OpenCode Config Seeding

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:

  1. Host config exists at $HOME/.config/opencode/opencode.jsoncp it into the sandbox. Preferred path; the user's explicit provider/model preferences win.

  2. No host config but SANDY_LOCAL_LLM_HOST is set → auto-generate. Sandy probes http://${SANDY_LOCAL_LLM_HOST}/v1/models (3-second timeout, host-side curl) for the served model id, prefers jq for JSON parsing and falls back to a grep/sed regex extracting the first "id":"…". If the probe yields a model id, sandy writes a single-provider, single-model opencode.json using the @ai-sdk/openai-compatible SDK package, with baseURL set to http://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 parses provider/model on the first slash, so local/<model-id> works regardless. If the probe fails, sandy emits a warning and proceeds without writing a config.

  3. Neither → opencode would silently fall back to its built-in default model (currently gemini-3-pro-preview), which fails on first request without GOOGLE_GENERATIVE_AI_API_KEY. Sandy emits a warn-level banner enumerating the three resolutions: export an API key, write the host config, or set SANDY_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.


5. Docker Image Build Pipeline

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.

Phase 1: Base Image (sandy-base)

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/rustup and /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)

Phase 2: Claude Code Image (sandy-claude-code)

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/claude and /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

Phase 2 (alt): Gemini CLI Image (sandy-gemini-cli)

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.

Phase 2 (alt): Codex CLI Image (sandy-codex)

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, md2email are 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).

Phase 2 (alt): OpenCode Image (sandy-opencode)

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 (the opencode-ai package ships per-platform binaries via optionalDependencies plus 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, md2email are 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.

Phase 2.5a: Skill Pack Base Image (sandy-skills-base-<pack>)

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.

Phase 2.5b: Skill Pack Code Image (sandy-skills-<pack>)

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).

Phase 3: Per-Project Image (optional, sandy-project-<name>-<hash>)

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.

Build Hash Caching

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.

Unified Resource Reclaim (sandy --gc, #36, milestone 1.3.0)

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):

  1. sandy.daemon=true labeled: not running → dead, reap. Running → probe docker 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 of sandy.daemon_pid liveness (the D9 defense: a rebooted host can resurrect a --restart unless-stopped container 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's orphaned_containers COUNT 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.
  2. Not daemon-labeled (a foreground/interactive agent container): SANDBOX_NAME is derived by stripping only the sandy- prefix (never sandy-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 same lock_holder_alive liveness 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.


6. Skill Pack System

Registry

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 SHA

Version Resolution

On each launch, skill_pack_resolve_versions() runs for each enabled pack:

  1. GitHub releases API (5-second timeout): If tag_prefix is set, fetch latest non-draft, non-prerelease tag matching the prefix
  2. GitHub commits API (5-second timeout): If no releases or no prefix, fetch latest commit SHA on default branch (truncated to 12 chars)
  3. Local cache: ~/.sandy/.skill_version_<pack> stores last successfully resolved version
  4. Hardcoded fallback: SKILL_PACK_VERSIONS array 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.

Container Activation

At container startup, user-setup.sh:

  1. Symlinks /opt/skills/<pack>/~/.claude/skills/<pack>
  2. Symlinks individual skill directories (those containing SKILL.md) into ~/.claude/skills/
  3. Adds /opt/skills/<pack>/bin to PATH
  4. Sets PLAYWRIGHT_BROWSERS_PATH=/opt/skills/gstack/.browsers

Workspace State (gstack)

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.

Adding New Packs

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().


7. Container Runtime

Docker Run Flags

--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 Flow (Root Phase)

entrypoint.sh runs as root and performs:

  1. Fix tmpfs home directory ownership to match host UID/GID
  2. Seed ~/.ssh/known_hosts from host mount
  3. SSH agent relay setup (macOS: socat TCP→Unix relay; Linux: socket permissions fix)
  4. Copy host SSH config from /tmp/host-ssh to ~/.ssh/ (dereferences symlinks, sets correct permissions)
  5. 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.
  6. Symlink Claude Code binary and data dir into home
  7. Create pip/pip3 wrapper scripts (auto-add --user when outside virtualenvs)
  8. Drop privileges: exec gosu $RUN_UID:$RUN_GID /usr/local/bin/user-setup.sh "$@"

User Setup Flow (User Phase)

user-setup.sh runs as the claude user:

  1. Set environment variables (HOME, CARGO_HOME, GOPATH, NPM_CONFIG_PREFIX, PYTHONUSERBASE, PATH)
  2. Symlink system Rust toolchain binaries into ~/.cargo/bin
  3. Activate skill packs (symlink into ~/.claude/skills/)
  4. Create synthkit slash commands (/md2pdf, /md2doc, /md2html, /md2email) 4a. Create /ss screenshot skill files when SANDY_SCREENSHOTS_PATH is set (claude ~/.claude/commands/ss.md, gemini ~/.gemini/commands/ss.toml, codex ~/.codex/skills/screenshot/SKILL.md). All call /usr/local/bin/sandy-ss-paths internally. 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.
  5. Remap ANSI color 4 (dark blue → bright blue) for readability
  6. Ensure the Claude projects dir for the workspace exists (settings.json itself is seeded/merged host-side before docker run — see §4 Seeding; moved out of user-setup in 0.11.3)
  7. Configure git (safe.directory, user name/email)
  8. Environment detection (.python-version, broken .venv, foreign native modules, git-lfs)
  9. Git auth setup (token mode: URL rewriting + gh auth; agent mode: SSH config)
  10. Plugin marketplace refresh (daily, or forced when channels configured)
  11. Channel credential seeding (Telegram, Discord: write .env and access.json)
  12. Launch Claude Code via tmux (or remote-control mode)

UID/GID Remapping

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.

Environment Variables Passed to Container

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

Resource Limits

  • CPU: Auto-detected from docker info (number of CPUs), overridable via SANDY_CPUS
  • Memory: Auto-detected as available - 1GB (minimum 2GB), overridable via SANDY_MEM
  • PIDs: Hard limit of 512 processes
  • Tmpfs: /tmp = 1GB, /home/claude = 2GB (persistent mounts bypass tmpfs)

8. Network Isolation

Linux

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):

  1. Allow container's own subnet (inserted last, evaluated first)
  2. Allow host.docker.internal:<port> for SANDY_LOCAL_LLM_HOST (tcp, dst = bridge gateway, dport = configured port)
  3. Allow specific LAN hosts (if SANDY_ALLOW_LAN_HOSTS set)
  4. 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 — local LLM passthrough

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 be host:port. Bare IPs are rejected.
  • Host portion: 0.0.0.0, ::, and empty rejected (world-open).
  • Port: integer in 1..65535.

Linux behavior:

  • ensure_network captures CONTAINER_GATEWAY (the bridge gateway IP, equivalent to host.docker.internal) from docker network inspect.
  • apply_network_isolation inserts a single iptables -I DOCKER-USER -i $BRIDGE -p tcp -d $CONTAINER_GATEWAY --dport $PORT -j ACCEPT rule. The destination is the gateway IP — opencode (or the user's CLI) connects to host.docker.internal:<port>, which Linux Docker resolves to the gateway.
  • RUN_FLAGS adds --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_isolation removes 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.

macOS

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.

Egress Proxy (SANDY_EGRESS_NO_ISOLATION / SANDY_EGRESS_STRICT, M2.7)

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) and sandy_egress_$$ (normal bridge, proxy only). The agent's NETWORK_NAME is 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 /24 in 10.200.0.0/16 and 10.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_networks first calls _sandy_reap_orphan_proxy_networks — which removes any sandy_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 container sandy-${SANDBOX_NAME}), making an orphan traceable to its workspace in docker ps. The workspace mutex ⇒ one session per workspace ⇒ the name is unique among live sessions; a stale same-named proxy is docker 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; --user declined because the binary binds privileged ports on a scratch image), with /etc/sandy-proxy.json bind-mounted read-only from $SANDBOX_DIR/sandy-proxy.json. In-proxy, all accept loops share acceptLoop (proxy/accept.go), which bounds concurrent connections at maxConns via a semaphore acquired before Accept, so a connection storm is bounded by backpressure rather than by the --memory OOM-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 --internal sidecar, so a mid-session proxy death (crash/OOM/reap) would otherwise strand the agent (every request FailedToOpenSocket) until the next launch. --restart on-failure:5 lets 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 Docker HEALTHCHECK (--interval=1s --start-period=0s --timeout=2s --retries=3) that re-invokes the binary as sandy-proxy -healthcheck — scratch has no shell, so the binary is the probe; it dials its own :443/:80/:3128 TCP listeners (loopback, valid before the sidecar --ip attach) and issues one DNS query on :53 (UDP has no Accept, 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 on healthy, 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 before net.Listen, leaving a transient "connection refused" window on the agent's first request). It falls back to the legacy .State.Running gate when .State.Health is absent ({{if .State.Health}}…{{else}}none{{end}} — an older HEALTHCHECK-less cached image still launches), fails fast (dumping docker logs) if it never comes up, and a non-zero .RestartCount short-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.log in the background (PID PROXY_LOG_PID, reaped in cleanup()), truncated per launch. This survives the docker rm -f that erases docker logs, so a guard() panic stack, deny lines, and restart boundaries persist; cleanup() appends the container's final docker inspect state (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 in guard() (proxy/guard.go), which recover()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 + validated SANDY_ALLOW_HOSTS (+ host.docker.internal when 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; note SANDY_EGRESS_MODE is forwarded in all modes including off, not just proxy mode — see E.16). The per-OS magic-hostname --add-host block 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 runs docker run --rm in the foreground, but the container's lifetime belongs to the daemon, not the docker run client — 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 of cleanup() would tear down the proxy and egress route, stranding the agent on a routeless --internal sidecar (every API request fails FailedToOpenSocket until the next launch). It also lets the sidecar network rm succeed 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.


9. Protected Files

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.

Read-Only Bind Mounts

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_ADMIN for 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.

Writable Sandbox Overlays

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.

Symlink Protection

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]). On y, sandy writes the current set to .sandy-approved-symlinks.list and 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.list to 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.


10. SSH Agent Relay

Token Mode (default, SANDY_SSH=token)

  1. Query gh auth token on host for the active account's token (GIT_TOKEN)
  2. Enumerate all authenticated accounts via gh auth status, collect each account's token via gh auth token --user <account>
  3. Pass GIT_TOKEN to container (used for git URL rewriting)
  4. Pass GH_ACCOUNTS to container as comma-separated user:token pairs (e.g. user1:tok1,user2:tok2)
  5. In container: configure git config --global url."https://oauth2:<TOKEN>@github.com/".insteadOf "git@github.com:" (token mode only)
  6. In container: authenticate gh CLI with all accounts from GH_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.

Agent Mode (SANDY_SSH=agent)

Linux: Direct socket mount. Host SSH_AUTH_SOCK socket mounted at /tmp/ssh-agent.sock inside container.

macOS: Two-hop relay.

  1. Host side: socat TCP-LISTEN:<PORT>,bind=127.0.0.1,fork,reuseaddr UNIX-CONNECT:<SSH_AUTH_SOCK>
  2. Container side (in entrypoint): socat UNIX-LISTEN:/tmp/ssh-agent.sock,fork,mode=0600,uid=$RUN_UID TCP:host.docker.internal:<PORT>
  3. 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).


11. Credential Management

Priority Order

  1. Long-lived token (CLAUDE_CODE_OAUTH_TOKEN): Valid 1 year, generated via claude 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 /ultrareview have access to the full OAuth credential object. When the token is set, sandy does not forward ANTHROPIC_API_KEY (Claude Code would resolve the API key ahead of the token and bill per-use) — a warning fires if both are configured.
  2. OAuth credentials: From host ~/.claude/.credentials.json (or macOS Keychain). Token expiry checked; refresh attempted on macOS via claude auth login.
  3. Fallback: Skip credential setup; user directed to /login inside session.

Token Expiry Check

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.

Ephemeral Credential Loading

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.

OAuth Token Isolation

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.

Gemini Credentials (whenever gemini is in SANDY_AGENT)

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.

oauth free-tier is deprecated upstream (issue #21). Google retired the free-tier gemini-cli OAuth login ("Gemini Code Assist for individuals") in mid-2026; a session using it fails at Google's tier check with IneligibleTierError regardless 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). The oauth path remains wired for any still-valid refreshing tier, but api_key / adc (Vertex) are the recommended paths. See also the deferred rw-ephemeral-copy idea in docs/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.

Codex Credentials (SANDY_AGENT=codex)

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.


12. Session Management

Tmux Integration

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 --continue to resume the last session. WORKSPACE_KEY is the container workspace path with all / replaced by - (e.g., /home/claude/dev/sandy-home-claude-dev-sandy)
  • Fallback: If --continue fails (stale session), retry without it

Tmux Configuration

  • 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)

Multi-Agent Mode (comma-separated SANDY_AGENT)

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.

Codex Headless Translation (SANDY_AGENT=codex)

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.

Remote Control Mode

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.

Terminal Notifications

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.


13. Workspace Path Mapping

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.

Git Submodule Support

When .git is a file (submodule), sandy:

  1. Reads the relative gitdir path from the .git file
  2. Resolves absolute host path for both worktree and gitdir
  3. Computes container paths using the same $HOME-relative mapping
  4. Mounts both at the correct container paths, preserving the relative relationship

14. Environment Detection

On every session start, user-setup.sh checks the workspace:

.python-version

If present, auto-installs the specified Python version via uv python install (idempotent, persists in sandbox's uv/ directory).

Broken .venv

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

Foreign Native Modules

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.

Orphaned pip user-site

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.

Git LFS

If workspace is a git repo and .gitattributes contains filter=lfs (checked up to 3 levels deep), runs git lfs install (idempotent).


15. Plugin Marketplace Management

Configured Marketplaces

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.

Deprecated Marketplace Removal

The thinkkit, ait, and pka-skills marketplaces are automatically removed on startup if present (same host-side merge block).

Refresh Logic

  • Marketplace catalogs refreshed daily (24-hour cache via ~/.claude/plugins/.marketplace_updated timestamp)
  • Force refresh when channels are configured (channel plugins may need installing)
  • Runs claude plugin marketplace update for each marketplace

Built-in Slash Commands (synthkit)

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.

Gemini Extensions (SANDY_GEMINI_EXTENSIONS)

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.


16. Channel Integration

Sandy supports Claude Code channels (Telegram, Discord) via two distinct paths:

  1. In-container plugin path (SANDY_AGENT=claude only) — auto-installs the Claude channel plugin from the marketplace and seeds credentials into ~/.claude/channels/.
  2. Host-side tmux-inject relay (any other SANDY_AGENT value — single gemini/codex or any multi-agent combo) — agent-agnostic, runs on the host and injects messages into the container's tmux session via docker 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

In-Container Plugin Setup (Claude)

For each configured channel:

  1. Auto-install the channel plugin from the marketplace
  2. Create ~/.claude/channels/<channel>/ directory
  3. Write .env with bot token
  4. Write access.json with either:
    • "dmPolicy": "allowlist" + populated allowFrom (if ALLOWED_SENDERS set)
    • "dmPolicy": "pairing" (if no allowlist, user pairs via /telegram:access pair <code>)

Host-Side Channel Relay (Gemini / Codex / OpenCode / Multi-agent)

$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.

Multiple Channels

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.


17. Auto-Update

Claude Code Updates

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.

Gemini / Codex Updates

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 Self-Update

sandy --upgrade downloads the latest sandy script from GitHub and replaces the local copy. Includes pre-flight check for write permissions.


18. Security Model

Container Hardening

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 Mitigations

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

Not Mitigated

  • 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).

19. Test Suite

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.

Test Categories (founding sections)

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


20. Installation

install.sh Flow

  1. Preflight warnings (non-blocking): Docker installed? Node.js installed? GitHub CLI authenticated?
  2. Create install directory: Default ~/.local/bin
  3. Download or copy: If LOCAL_INSTALL env var set, copy local file; otherwise download from GitHub
  4. Bake commit hash: If installing from a git repo, detect and bake SANDY_COMMIT into the script (BSD/GNU sed compatible)
  5. Set executable: chmod +x
  6. PATH check: Warn if ~/.local/bin not in PATH, suggest shell-specific config

First Run

  1. Validate Docker installed
  2. Generate build files in $SANDY_HOME/
  3. Build Phase 1 (base) and Phase 2 (Claude Code) images (~15-25 min)
  4. Build skill pack images if enabled (~5-10 min additional for Chromium)
  5. Create sandbox directory structure and seed from host
  6. Create Docker network and apply iptables rules
  7. Load credentials
  8. Launch container

Subsequent Runs

  1. Check image hashes — skip builds if unchanged (~0 sec)
  2. Refresh statsig feature flags from host
  3. Create network and iptables rules (~1 sec)
  4. Load credentials
  5. Launch container (~3-4 sec total startup)

21. File Inventory

Repository Files

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.

Runtime-Generated Files ($SANDY_HOME/)

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

Appendix A: Generated File Templates

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.

A.1 Dockerfile.base (Phase 1)

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"

A.2 Dockerfile (Phase 2)

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-tools ensures synthkit's venv goes to an accessible location (not /root/).
  • Version is cached at /opt/claude-code/.version for update detection.

A.2b Dockerfile.codex (Phase 2, alt)

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/.version for update detection.
  • synthkit deps and synthkit itself are baked in so md2pdf/md2doc/md2html/md2email are on PATH regardless of whether Step 7's skill-seeding fires (e.g., if synthkit isn't installed at user-setup time).

A.3 Dockerfile.skills-base (Phase 2.5a)

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.json

Only 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.

A.4 Dockerfile.skills (Phase 2.5b)

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).

A.5 entrypoint.sh

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 "$@"

A.6 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) with TOKEN_VAR=value
  • Writes access.json only 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 --continue if session files exist and no conflicting flags
  • Fallback: $CMD_WITH_CONTINUE || $CMD_WITHOUT_CONTINUE (retries without --continue on 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.md with YAML frontmatter for md2pdf, md2doc, md2html, md2email.
  • Trust-entry appending: after config.toml is in place, if [projects."$SANDY_WORKSPACE"] is not already present, append:
    [projects."<workspace>"]
    trust_level = "trusted"
    This must happen container-side because it needs the in-container workspace path.
  • build_codex_cmd(): translates sandy's -p/--print/--prompt into codex exec with a positional prompt; drops --continue/-c; injects --sandbox danger-full-access, --skip-git-repo-check (headless only), and optional --model.
  • Launch dispatch: the codex case sits alongside claude and gemini in the per-agent dispatch; multi-agent combos iterate over the parsed _SANDY_AGENTS array and call each build_*_cmd in pane order.

/ss screenshot-skill seeding:

  • Gated on [ -n "${SANDY_SCREENSHOTS_PATH:-}" ] — sandy only sets that env var when SANDY_SCREENSHOT_DIR was 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, $ARGUMENTS parsed 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)").

A.7 tmux.conf

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%).


Appendix B: Runtime Parameters

All magic numbers, thresholds, timeouts, and limits used in the sandy script.

B.1 Resource Limits

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

B.2 Timeouts

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

B.2a Sandbox Compatibility

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.

B.3 Cache TTLs

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>

B.4 File Permissions

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

B.5 Scan Depth Limits

Scan Max Depth Excludes
Symlink protection 8 levels node_modules/, .venv*/, .git/
Submodule gitdir walk 6 levels
Git LFS detection 3 levels

B.6 Docker Security Flags

--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.

B.7 Network Ranges (Linux iptables)

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

B.8 Default Values

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

B.9 Tool Versions

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

Appendix C: JSON Schemas

C.1 access.json (Channel Configuration)

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.

C.2 settings.json (Claude Code Configuration)

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 {}

C.3 .claude.json (User Setup State)

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.

C.4 .credentials.json (OAuth Credentials)

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).

C.5 Channel .env Files

Plain KEY=VALUE format at ~/.claude/channels/<channel>/.env:

TELEGRAM_BOT_TOKEN=<token>

or

DISCORD_BOT_TOKEN=<token>

Permissions: 600 (owner read-write only).

C.6 cmux Notification Hook

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.

C.7 .update_check Cache

Plain text file at $SANDY_HOME/.update_check:

<epoch_timestamp> <latest_version>

Example: 1711843200 0.8.0. Stale after 86,400 seconds.

C.7b Codex config.toml (seeded by sandy)

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).

C.7c Codex auth.json (ephemeral mount, both auth paths)

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_KEY set, SANDY_CODEX_AUTH is auto or api_key): sandy generates the file itself as {"OPENAI_API_KEY":"<key>"} (with " and \ JSON-escaped) — the same shape codex login --with-api-key writes. Required because codex 0.139+ no longer reads the env var for first-party auth.
  • oauth (host has ~/.codex/auth.json, mode auto or oauth): sandy copies the host file to the tmpdir. Schema is opaque to sandy — the file is produced by codex login on the host.

C.8 .skill_version_<pack> Cache

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.

C.9 sandy-session.json (Self-Attestation Marker)

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.


Appendix D: Platform-Specific Behavior

Sandy runs on both Linux and macOS. The following sections document every point where behavior diverges.

D.1 Network Isolation

Scope: this table describes the legacy SANDY_EGRESS_PROXY=0 path. The default is 1 (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:

  1. Test sudo iptables -L DOCKER-USER -n — if fails, abort (or allow with override)
  2. Insert DROP rules for each private range (inserted first = evaluated last)
  3. Insert ACCEPT for SANDY_ALLOW_LAN_HOSTS entries (if set)
  4. Insert ACCEPT for container's own subnet (inserted last = evaluated first)
  5. On exit: delete rules in reverse, remove Docker network

D.2 SSH Agent Relay

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)

D.3 Credential Loading

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)

D.4 SHA256 Hash

sha256() { shasum -a 256 2>/dev/null || sha256sum; }
  • macOS: shasum -a 256 (Perl-based, ships with macOS)
  • Linux: Falls through to sha256sum (coreutils)

D.5 UID/GID Remapping

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}:/"

D.6 Error Recovery & Fallback Chains

settings.json merge (3 tiers, tried in order — target is $SANDBOX_DIR/claude/settings.json, rebuilt every launch with merge-preserving semantics):

  1. Node.js: JSON repair → parse host → read previous sandbox → preserve enabledPlugins from previous → merge defaults → merge marketplaces → scrub deprecated → write
  2. jq: Same shape via --argjson prev "$_prev_plugins" read from the previous sandbox settings
  3. printf: Only if no file exists yet, writes minimal JSON

.claude.json seeding (2 tiers):

  1. Node.js: Parse, delete projects key, write pretty-printed
  2. cp: Raw copy if Node.js fails (host projects key preserved — less clean but functional)

Token expiry check (2 tiers):

  1. Node.js: Parse credentials JSON, check claudeAiOauth.expiresAt against Date.now() + 300000
  2. Python 3: Same logic via json.loads and time.time() * 1000
  3. 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):

  1. GitHub releases API: 5s timeout, looks for tags matching prefix
  2. GitHub commits API: 5s timeout, gets latest commit SHA (truncated to 12 chars)
  3. Local cache file: $SANDY_HOME/.skill_version_<pack>
  4. Hardcoded fallback: SKILL_PACK_VERSIONS array entry

Appendix E: Container Launch Assembly

The docker run command is assembled incrementally in a RUN_FLAGS array. This appendix documents the complete assembly in order.

E.0 Workspace Mutex

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.

E.1 Pre-Launch

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

E.2 Base Flags

--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>

E.3 GPU Passthrough (conditional)

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"

E.4 Credential Mount (conditional)

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.

E.5 .claude.json Mount

-v "<SANDY_HOME>/sandboxes/<NAME>.claude.json:/home/claude/.claude.json"

Always mounted — this file is seeded on first run and persists across sessions.

E.6 Host Hooks Mount (conditional)

If ~/.claude/hooks/ exists on the host:

-v "$HOME/.claude/hooks:/home/claude/.claude/hooks:ro"

E.7 Workspace Mount

-v "<HOST_PATH>:<CONTAINER_PATH>"

Where CONTAINER_PATH follows the workspace path mapping rules (Section 13).

E.7a Workspace .venv Overlay (conditional)

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.cfg

Must 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:

  1. $WORK_DIR/.python-version — authoritative, user-maintained.
  2. $WORK_DIR/.venv/pyvenv.cfg version / version_info line — 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:

  1. If $WORKSPACE/.venv/pyvenv.cfg does not exist, materializes a fresh venv via uv venv --clear --python <version> $WORKSPACE/.venv. The --clear flag is required: the overlay bind-mount target always exists as a directory, and uv venv otherwise refuses with "A directory already exists at: .venv". No in-container locking is needed — the host-side workspace mutex (§E.0) guarantees exclusive access.
  2. After materialization (or on subsequent launches), compares the overlay's actual pyvenv.cfg version against SANDY_VENV_PYTHON_VERSION. Mismatch → prints a drift warning with the recreate command. No auto-recreate (would silently nuke installed packages).
  3. Activates unconditionally if $WORKSPACE/.venv/bin/python exists (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.

E.8 Git Submodule Mount (conditional)

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.

E.9 Symlink Protection Scan

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 -print

Each 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:

  1. 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.

  2. 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).

  3. 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 rm the offending link (restoring the approved state), or rm <SANDBOX_DIR>/.sandy-approved-symlinks.list to 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.

E.10 Protected File Mounts

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.

E.11 Writable Sandbox Overlays

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>"
fi

This hides host content at these paths and provides a writable overlay from the sandbox.

E.11a Screenshot Mount (conditional)

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 $HOME and / 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.

E.11b User-defined Env Passthrough (conditional)

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_KEYS or SANDY_PASSIVE_KEYS are 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.

E.12 Persistent Package Mounts

-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.

E.13 Sandbox Mount

The sandbox directory itself becomes ~/.claude inside the container:

-v "<SANDBOX_DIR>:/home/claude/.claude"

E.13a Seed settings.json (conditional on claude agent)

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.

E.14 SSH Mounts (conditional on SANDY_SSH)

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"

E.15 UID/GID Remapping (conditional)

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 > group

E.16 Environment Variables

All 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 mode

Codex-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.

E.16a Self-Attestation Marker (all modes)

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.mdSelf-Attestation Marker.

E.17 Final Command

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 exists
  • sandy-skills-<packs> if skill packs enabled
  • sandy-claude-code otherwise