feat(cli): Docker in the sandbox — nested daemon with proxied egress - #118
Draft
shejnowicz wants to merge 3 commits into
Draft
shejnowicz wants to merge 3 commits into
shejnowicz wants to merge 3 commits into
Conversation
Fixes #70. Opt-in via `sandcat init --features docker` (or SANDCAT_DOCKER=true). Design validated by a manual routing spike before implementation. A dedicated dind compose service hosts a nested dockerd in its OWN network namespace, reachable from the agent only over a unix socket on a shared volume — the host daemon is never exposed. dind's entrypoint wrapper installs the mitmproxy CA before dockerd starts (Go loads the x509 pool once, so pulls pass the TLS bump), replaces the default route with wg-client fail-loud (no route → no daemon, never an unproxied fallback), publishes the matching docker CLI for the agent, and chgrps the socket to the vscode gid. wg-client, behind the SANDCAT_DIND_GATEWAY flag the same include sets, NATs the compose subnet into wg0 and drops every other forward — so egress from agent-launched containers transits mitmproxy (policy, CA, secret substitution) two levels deep, and the gateway cannot be used to reach the docker network or host directly. No sysctl needed: Docker enables ip_forward in bridge-network namespaces. The whole integration is one include: compose-docker.yml extends dind, the agent's socket mount, and the wg-client flag together, so enable_docker() only appends the include entry (idempotent). The agent sees DOCKER_HOST/PATH via a guarded /etc/profile.d/sandcat-docker.sh — same pattern as sandcat-java.sh — so images built without the feature are unaffected. dind image pinned (docker:27.5.1-dind), per #100 policy. Tests: enable_docker idempotency, template contract (privileged wiring, no host docker.sock anywhere), setup_dind_gateway rules, init feature parsing; docs page under Configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught during self-review against #70's acceptance criteria: only the bare docker binary was published, but compose and buildx are CLIENT-side plugin binaries — `docker compose` / `docker build` from the agent would fail even though the daemon was fine. dind-init now publishes /usr/local/libexec/docker/cli-plugins/ onto the socket volume, and the guarded profile script links it into ~/.docker/cli-plugins (agent-home is a writable volume, so the link persists; the guard keeps it idempotent). Also export TESTCONTAINERS_HOST_OVERRIDE=dind: Testcontainers assumes "docker host = localhost" for a unix socket, but inner ports publish in the dind service's namespace, which the agent reaches by service name. Template-contract test extended accordingly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Integration testing surfaced it: BuildKit resolves registry manifests with HEAD requests, which the default allow-*-GET wildcard blocks — so `docker build` failed 403 out of the box while pulls (GET path) worked. New host-scoped preset `docker-registry` (Docker Hub endpoints + ghcr.io, all methods, expanded by the addon like every preset); `sandcat init --features docker` seeds it at the top of the project's network list, in both the default and strict-network modes. Covered by an init test with real settings-file semantics; the preset registry guard tests pick the new entry up automatically; docs updated (preset table + a Network policy section on the docker page). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shejnowicz
marked this pull request as draft
September 11, 2026 08:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #70. Implements the sidecar-daemon approach from the issue's design draft, concretized after a manual routing spike: dind in its own network namespace with wg-client as its gateway, so egress from agent-launched containers provably transits mitmproxy.
Why not the draft's preferred option
The Sysbox runtime swap conflicts with sandcat's topology: the agent lives in
network_mode: service:wg-client, which Sysbox does not support (it requires its own namespaces). Rootless dockerd in the agent conflicts withno-new-privileges(#90) — setuidnewuidmapis blocked. The privileged sidecar is the option that works today, on stock Docker Desktop, with the current hardening intact.Design
One include does everything:
sandcat init --features dockerappendssandcat/compose-docker.yml, which extends three services at once — thedindservice, the agent's socket-volume mount, and wg-client'sSANDCAT_DIND_GATEWAYflag.docker:27.5.1-dind, per security: pin mitmproxy image chain + rtk installer (Phase 1 of #20) #100 policy) runs the nested dockerd in its own netns. Its entrypoint wrapper installs the mitmproxy CA before dockerd starts (Go loads the x509 pool once — pulls pass the TLS bump), replaces the default route with wg-client fail-loud (no route → no daemon, never an unproxied fallback), publishes the matching docker CLI onto the socket volume (no version drift), and chgrps the socket to the vscode gid.ip_forwardin bridge-network namespaces;/proc/sysstays RO).DOCKER_HOST+ CLI via a guarded/etc/profile.d/sandcat-docker.sh(the fix(cli): export Java trust-store settings to all shells #109 pattern) — images built without the feature are unaffected.docker.sockappears nowhere in the generated project.Answers to the issue's open questions: baseline = sidecar (Sysbox blocked by the netns constraint, not just Colima); placeholders are not propagated into inner containers by default; the dind service participates in normal compose lifecycle (
depends_onwg-client healthy, restart: unless-stopped).Test plan
enable_dockeridempotency, compose-docker template contract incl. a no-host-socket guard,setup_dind_gatewayrules, feature parsing)sandcat init --features docker(no manual steps):DOCKER_HOSTand the published CLI (27.5.1)docker run --rm alpinefrom the agent works on the nested daemonhttp://example.com→ 200;httpswith the mitm CA → 200; POST → "Blocked by network policy" — the proof that inner egress transits mitmproxydocker.sockanywhere; agent baseline unregressed (GET 200/POST 403, no sudoers, no CA private key)Follow-up commits after further integration testing (both issues found by exercising the acceptance criteria, not by review):
docker compose/docker buildare client-side plugin binaries. dind now publishescli-plugins/onto the socket volume and the profile script links it into~/.docker/cli-plugins. Verified live:docker compose2.33.0 anddocker buildxv0.20.1 from the agent.allow * GETblocks —docker buildfailed 403 while pulls (GET path) worked. New host-scopeddocker-registrynetwork preset (Docker Hub + ghcr.io), seeded at the top of the project's network list by--features docker. Verified live withdocker build --pull --no-cache(forces the HEAD): image builds, marker runs, and the POST-from-inner-container check still returns "Blocked by network policy". AlsoTESTCONTAINERS_HOST_OVERRIDE=dindis exported (inner ports publish in dind's namespace).Not covered (noted for follow-up): a Testcontainers-based JVM suite run (acceptance item in #70) and the VS Code Reopen-in-Container pass — the compose project is IDE-agnostic so it should hold, but it hasn't been exercised in this PR.
🤖 Generated with Claude Code