Skip to content

feat(cli): Docker in the sandbox — nested daemon with proxied egress - #118

Draft
shejnowicz wants to merge 3 commits into
masterfrom
feat/70-docker-in-sandbox
Draft

shejnowicz wants to merge 3 commits into
masterfrom
feat/70-docker-in-sandbox

Conversation

@shejnowicz

@shejnowicz shejnowicz commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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 with no-new-privileges (#90) — setuid newuidmap is 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 docker appends sandcat/compose-docker.yml, which extends three services at once — the dind service, the agent's socket-volume mount, and wg-client's SANDCAT_DIND_GATEWAY flag.

  • dind (pinned 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.
  • wg-client NATs the compose subnet into wg0 and drops every other forward — routing through the gateway leads only INTO the policy path; the docker network and host are unreachable through it. No sysctl needed (Docker enables ip_forward in bridge-network namespaces; /proc/sys stays RO).
  • Agent sees 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.
  • The host docker.sock appears 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_on wg-client healthy, restart: unless-stopped).

Test plan

  • bats: full 16/16 suites (new: enable_docker idempotency, compose-docker template contract incl. a no-host-socket guard, setup_dind_gateway rules, feature parsing)
  • Hands-on integration, everything wired by sandcat init --features docker (no manual steps):
    • stack up with dind healthy; agent has DOCKER_HOST and the published CLI (27.5.1)
    • docker run --rm alpine from the agent works on the nested daemon
    • image pull through the tunnel (registry over the TLS bump with the injected CA)
    • from an inner container: http://example.com → 200; https with the mitm CA → 200; POST → "Blocked by network policy" — the proof that inner egress transits mitmproxy
    • dind's default route via wg-client confirmed; no host docker.sock anywhere; agent baseline unregressed (GET 200/POST 403, no sudoers, no CA private key)
  • Docs: new Configuration → Docker in the sandbox page (mechanism, caveats: privileged rationale, inner-container CA trust, placeholder policy)

Follow-up commits after further integration testing (both issues found by exercising the acceptance criteria, not by review):

  • CLI plugins: only the bare docker binary was published — docker compose/docker build are client-side plugin binaries. dind now publishes cli-plugins/ onto the socket volume and the profile script links it into ~/.docker/cli-plugins. Verified live: docker compose 2.33.0 and docker buildx v0.20.1 from the agent.
  • BuildKit vs the GET wildcard: BuildKit resolves manifests with HEAD, which allow * GET blocks — docker build failed 403 while pulls (GET path) worked. New host-scoped docker-registry network preset (Docker Hub + ghcr.io), seeded at the top of the project's network list by --features docker. Verified live with docker build --pull --no-cache (forces the HEAD): image builds, marker runs, and the POST-from-inner-container check still returns "Blocked by network policy". Also TESTCONTAINERS_HOST_OVERRIDE=dind is exported (inner ports publish in dind's namespace).
  • A satisfying side-note: while re-testing, the pre-preset addon in the test project hit the feat(mitmproxy): predefined network allowlist presets #105 fail-loud path exactly as designed — mitmproxy refused to start on the unknown preset instead of running a weaker policy.

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

shejnowicz and others added 3 commits September 10, 2026 15:23
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
shejnowicz marked this pull request as draft September 11, 2026 08:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: support running Docker *inside* the sandbox (without exposing the host daemon)

1 participant