cd /news/ai-agents/show-hn-drydock-vm-sandboxes-for-mac… · home topics ai-agents article
[ARTICLE · art-33394] src=github.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Show HN: Drydock – VM Sandboxes for macOS Autonomous Coding Agents

Drydock, a new open-source tool, runs autonomous coding agents like Claude Code and OpenAI Codex in hardware-isolated VMs on macOS, preventing compromised agents from accessing API keys, filesystems, or the internet. The alpha release (v0.1.4) requires macOS 26+ on Apple silicon and uses a deny-by-default egress policy with short-lived, budget-capped tokens. The project is single-maintainer and has not undergone a third-party security audit.

read11 min views1 publishedJun 19, 2026

drydock runs autonomous coding agents (Claude Code or OpenAI Codex, per-task selectable) on your own Mac — not someone's cloud — each task sealed in its own hardware-isolated VM. It starts from the assumption that the agent is already compromised: your real API key never enters the sandbox (a host-side gateway hands it short-lived, budget-capped tokens), egress is deny-by-default, and the only thing that crosses back out is a git diff

you approve before it reaches origin.

Most agent tooling tries to keep the agent well-behaved — permission prompts, output filters, policy. drydock takes the opposite stance: contain the blast radius so a hostile agent (a poisoned repo, a malicious dependency, a prompt-injection that turns a fetched URL into a shell command) can't reach your key, your filesystem, your push credentials, or the open internet — regardless of what it tries.

Status: working alpha (v0.1.4).The full task lifecycle works end-to-end — submit → isolated VM → gated diff → push — and drydock ships through a Homebrew tap. It is pre-1.0 and single-maintainer: onlymain

is supported, behavior and config can change between minor versions, and it hasn't been hardened by real-world use.There has been no third-party security audit— the security model is written down in detail in the[threat model], so read that and decide for yourself before trusting it.Hard requirement: macOS 26+ on Apple silicon— it runs on Apple'scontainer

runtime (itself 1.0), so it won't run anywhere else.

Security claims: THREAT_MODEL.md.

Website:

https://sricola.github.io/drydock/

brew install --cask container
brew install squid

The PR/MR adapters call gh

, glab

, or tea

— install whichever your repos use, and run their respective auth login

before submitting a task.

brew tap sricola/drydock
brew trust sricola/drydock     # personal taps require explicit trust
brew install drydock
drydock init

Pulls a pre-built Apple-silicon binary from the latest tagged release (currently v0.1.4

); no Go toolchain required.

brew install go
git clone https://github.com/sricola/drydock && cd drydock
make install                             # PREFIX=/usr/local by default
make install PREFIX=$HOME/.local         # …or a user-owned prefix
drydock init

Either way, drydock init

walks the remaining prereqs (container service, drydock-egress

network, sandbox + anchor images) and reports per-step status. Idempotent — re-run any time.

At least one vendor key is required. Both are host-only — they never go to disk and never enter the VM:

export ANTHROPIC_API_KEY=sk-ant-...   # required for Claude Code tasks
export OPENAI_API_KEY=sk-...          # required for Codex tasks
drydock start              # foreground; ^C to stop. backgrounds via & or your launchd plist.

Quick liveness:

drydock status

First time? Walk through examples/hello-task.md — a copy-paste first task that exercises every layer, fits inside the default budget, and tells you exactly what each step proves.

In one shell, fire the task. It blocks until the agent runs and you approve the diff (typical: a few seconds to a few minutes, plus your review time):

drydock submit \
  --repo git@github.com:your-org/your-repo \
  --instruction "Add a one-line comment to README.md explaining the project."

A macOS notification fires when the diff is ready. In another shell:

drydock pending               # awaiting tasks (egress + diff gates both shown)
drydock review <id>           # diff in $PAGER, then prompt y/N — the one-shot path
less ~/.drydock/audit/<id>.diff
drydock approve <id>          # … or: drydock deny <id>

The submit shell unblocks with the push outcome:

task ab12cd34: pushed agent/ab12cd34 (github)
drydock status                # brokerd up?, breakdown (running · egress · diff · pushing)
drydock tasks                 # recent runs: id, age, duration, cost, outcome
drydock logs <id> [-f]        # stream-json audit (use -f to follow)
drydock kill <id>             # cancel the in-flight task (VM down + gate unblocked)
drydock doctor                # smoke-test the sandbox setup (no API spend)
drydock submit --repo … --instruction "…" --agent codex

drydock submit --repo … --instruction-file ./task.md

echo "Refactor the egress compiler" | drydock submit --repo … -

drydock submit --repo … --instruction "…" --model claude-sonnet-4-6

drydock submit --repo … --instruction "…" --auto-approve

drydock submit --repo … --instruction "…" \
  --egress-extra internal.example.com:443 \
  --egress-extra files.example.com:443,8443

drydock submit --repo … --instruction "…" --json | jq .branch

If you'd rather hit the HTTP API directly:

SOCK=$TMPDIR/drydock-$(id -u)/drydock.sock
curl --unix-socket "$SOCK" http://_/tasks \
  -H 'content-type: application/json' \
  -d '{ "repo_ref": "git@github.com:o/r", "instruction": "..." }'

Notifications opt-out: DRYDOCK_NO_NOTIFY=1

.

repo_ref

must be a git URL (https://

, git@

, or ssh://

); local paths are rejected because adapters can't operate on filesystem origins. The PR/MR adapter is chosen by platform

:

"platform": "github"

gh pr create --head <branch> --fill

(needsgh

authed)"platform": "gitlab"

glab mr create --fill --yes

(needsglab

authed)"platform": "gitea"

(aliasforgejo

) →tea pr create --head <branch>

(needstea

authed)"platform": "none"

→ push only; no PR/MRomitted→ hostname autodetect (github.com

,gitlab.com

,gitea.com

/codeberg.org

; else push-only — covers Bitbucket and other self-hosted)

Self-hosted GitLab and Gitea need explicit "platform"

. Bitbucket has no widely-adopted CLI to wrap and falls back to push-only; contributions welcome. The push response includes "platform"

so the caller can see which adapter ran. "auto_approve": true

skips the gate — see the threat model before using it.

drydock init

creates ~/.drydock/

at mode 0700

and seeds two files:

Path What
~/.drydock/config.yaml
Operator settings (network name, gateway IP, per-task budget + timeout, max concurrent tasks, paths, broker listener, behavior flags)
~/.drydock/egress.yaml
Squid + gateway allowlist (hosts and ports the sandbox may reach)

Both files are seeded from defaults the first time; drydock init

never overwrites them. Env vars still win over file values (e.g. BROKER_ADDR=…

in the shell overrides broker.addr

in the YAML), so existing scripts keep working. ANTHROPIC_API_KEY

is intentionally not in either file — by design, it never goes to disk.

~/.drydock/egress.yaml

is the source of truth (seed template lives at $HOMEBREW_PREFIX/share/drydock/config/egress.yaml

). The default:

default:
  domains:
    - { host: api.anthropic.com,      ports: [443] }   # routed via gateway
    - { host: registry.npmjs.org,     ports: [443] }   # routed via squid
    - { host: pypi.org,               ports: [443] }   # routed via squid
    - { host: files.pythonhosted.org, ports: [443] }   # routed via squid
    - { host: proxy.golang.org,       ports: [443] }   # routed via squid
    - { host: sum.golang.org,         ports: [443] }   # routed via squid
per_task_widening:
  requires_approval: true

The sandbox image ships Node 22, Python 3.11, and Go 1.26 so JS, Python, and Go tasks work without operator customization. Other toolchains can be added by extending image/Dockerfile

and rebuilding via make image

(or drydock init

, which detects stale images and rebuilds).

api.anthropic.com

is intentionally excluded from the squid allowlist — it routes through the credential gateway, not the proxy. Per-task widening via egress_extra

goes through the same human-driven gate as the diff push (when per_task_widening.requires_approval: true

, which is the default): brokerd blocks the request, writes the requested hosts to AUDIT_ROOT/<id>.widen.json

, and shows the task in drydock pending

under gate egress

. Approve with drydock approve <id>

once you've reviewed the request. Restart brokerd after editing the default allowlist.

The canonical location is ~/.drydock/config.yaml

— seeded by drydock init

with the defaults below as a commented template. Edit and re-run drydock start

. Env vars still override file values for ops/scripting:

Field (config.yaml ) | Env override | Default | Meaning | |---|---|---|---| | — | ANTHROPIC_API_KEY | (at least one required) | Real Anthropic key; host-only, never goes to disk | | — | OPENAI_API_KEY | (at least one required) | Real OpenAI key; host-only, never goes to disk | default_agent | DRYDOCK_DEFAULT_AGENT | claude | Agent to use when --agent is not passed; allowed values: claude | codex | network | DRYDOCK_NETWORK | drydock-egress | vmnet network name | gateway_ip | DRYDOCK_GW_IP | 192.168.66.1 | gateway + squid bind here | sandbox_image | SANDBOX_IMAGE | drydock-sandbox:latest | per-task agent VM image | anchor_image | DRYDOCK_ANCHOR_IMAGE | drydock-anchor:latest | minimal sleep-forever image holding the vmnet gateway IP | task_budget_usd | DRYDOCK_TASK_BUDGET_USD | 2.0 | per-task USD ceiling | max_concurrent_tasks | DRYDOCK_MAX_CONCURRENT_TASKS | 2 | excess POSTs to /tasks get HTTP 503 | task_timeout | — | 30m | wall-clock per task | default_model | DRYDOCK_DEFAULT_MODEL | (empty) | claude --model fallback for tasks that don't pass --model ; empty = claude picks | stage_root / audit_root / squid_run_dir | STAGE_ROOT / AUDIT_ROOT / SQUID_RUN_DIR | ~/.drydock/{stage,audit,squid} | per-task scratch (audit dir is 0700 , audit log + diff are 0600 ). Pre-v0.1.4 used /tmp/broker/ ; drydock tasks and friends still surface that history while it exists. | broker.socket | BROKER_SOCKET | $TMPDIR/drydock-$UID/drydock.sock | Unix socket (per-user parent dir at 0700 , socket at 0600 ) | broker.addr | BROKER_ADDR | (empty) | set host:port to expose over TCP (warns at boot — no auth; see | notifications | DRYDOCK_NO_NOTIFY=1 (off) | true | macOS notifications on pending approval | log_json | DRYDOCK_LOG_JSON=1 | false | force JSON logs even on a TTY (default: terse text on TTY, JSON otherwise) | strict_container_version | DRYDOCK_STRICT_CONTAINER_VERSION=1 | false | fail closed when container major drifts from the tested range | | — | EGRESS_CONFIG | ~/.drydock/egress.yaml | path override for the egress YAML |

Gateway port 8088

and squid port 3128

are hard-coded in cmd/brokerd/main.go

and image/entrypoint.sh

; change both together.

Symptom First place to look
192.168.66.1 never became bindable
container ls -a (anchor running?), container network inspect drydock-egress (gateway IP?)
Image build fails on npm install
Transient registry timeout; rerun container build
Squid CONNECT 403 to an expected host cat ~/.drydock/squid/squid-allow.txt ; add via egress.yaml or egress_extra
Stale anchor after a crash container rm -f drydock-anchor ; next brokerd start does this for you
Gateway 401 Key wrong or placeholder (sk-ant-fake is expected to 401)
VM reaches a host it shouldn't Confirm init-firewall.sh ran inside the VM — overriding --entrypoint skips it

Per-task stream-json from the agent lands in $AUDIT_ROOT/<id>.jsonl

; the diff lands in $AUDIT_ROOT/<id>.diff

.

cmd/brokerd/      # broker daemon
cmd/drydock/      # operator CLI (init|start|submit|status|tasks|pending|review|approve|deny|kill|logs)
internal/
  broker/         # /tasks + admin handlers, approval + egress gates, concurrency, cancellation
  creds/          # Grant/Provider interfaces
  egress/         # YAML  + allowlist compilation + host/port validation
  gateway/        # credential gateway (mint/serve/account/revoke), constant-time token check
  netfw/          # squid conf + allowlist compiler
  remote/         # PR/MR adapters: github (gh), gitlab (glab), gitea (tea), push-only
  runner/         # `container run` argv builder
  sockpath/       # shared per-uid socket path discovery for brokerd + CLI
  stage/          # work tree, host-side commit + push, curated adapter env
image/            # drydock-sandbox: Dockerfile + entrypoint.sh + init-firewall.sh
image/anchor/     # drydock-anchor: FROM scratch + static Go sleep binary
tests/integration # //go:build integration — boots brokerd against real container CLI
config/           # egress.yaml
site/             # narrative explainer + launch post
docs/superpowers/ # historical design specs
LICENSE           # MIT
SECURITY.md       # how to report a security bug + documented residuals
THREAT_MODEL.md   # what drydock defends — and doesn't
Makefile          # build, install, test, test-integration, image, image-anchor, network, init, clean
make build              # bin/brokerd, bin/drydock
make test               # go test -race ./...
make image              # both images
make image-sandbox      # per-task agent image
make image-anchor       # minimal anchor image (FROM scratch + static binary)
make test-integration   # boot brokerd as subprocess; macOS only, needs container runtime

GitHub Actions runs go build

, go test -race

, and go vet

on every push/PR. Integration (make test-integration

) requires container

and is macOS-only — runs locally, not in CI. No real Anthropic spend.

  • Pricing in internal/gateway/pricing.go

covers the 4.x families (Opus, Sonnet, Haiku) with an Opus-priced default fallback; bump when Anthropic publishes new rates. - Audit dir ( ~/.drydock/audit/

) grows unbounded — old<id>.{jsonl,diff}

files aren't pruned. Nodrydock tasks --prune

yet. - Up to DRYDOCK_MAX_CONCURRENT_TASKS

tasks in flight per brokerd (default 2); raise on bigger hardware. - No Slack/web approval adapters yet — only the local CLI + macOS notifications.

  • Bitbucket PR/MR opening: push-only fallback (no widely-adopted CLI to wrap). Contribution slot.
  • Apple container

is v1.0; flag drift is the most likely breakage source.DRYDOCK_STRICT_CONTAINER_VERSION=1

fails closed on drift.

── more in #ai-agents 4 stories · sorted by recency
── more on @drydock 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-hn-drydock-vm-s…] indexed:0 read:11min 2026-06-19 ·