From 404e278d1367d7de7142aef906cf28ae9c1fe155 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Thu, 10 Sep 2026 15:23:39 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20Docker=20in=20the=20sandbox=20?= =?UTF-8?q?=E2=80=94=20nested=20daemon=20with=20proxied=20egress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cli/lib/composefile.bash | 16 +++++ cli/libexec/init/devcontainer | 4 ++ cli/libexec/init/init | 12 +++- cli/templates/devcontainer/Dockerfile.app | 1 + .../devcontainer/sandcat/compose-docker.yml | 54 +++++++++++++++ .../devcontainer/sandcat/scripts/dind-init.sh | 45 +++++++++++++ .../sandcat/scripts/docker-env.sh | 13 ++++ .../sandcat/scripts/wg-client-init.sh | 21 ++++++ cli/test/composefile/composefile.bats | 23 +++++++ cli/test/init/extensions.bats | 21 ++++++ cli/test/init/init.bats | 6 +- cli/test/wg-client/dind_gateway.bats | 22 ++++++ docs/configuration/docker.md | 67 +++++++++++++++++++ docs/index.md | 1 + docs/reference/cli.md | 2 +- 15 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 cli/templates/devcontainer/sandcat/compose-docker.yml create mode 100755 cli/templates/devcontainer/sandcat/scripts/dind-init.sh create mode 100644 cli/templates/devcontainer/sandcat/scripts/docker-env.sh create mode 100644 cli/test/wg-client/dind_gateway.bats create mode 100644 docs/configuration/docker.md diff --git a/cli/lib/composefile.bash b/cli/lib/composefile.bash index c510ea6f..6e3433e8 100644 --- a/cli/lib/composefile.bash +++ b/cli/lib/composefile.bash @@ -590,3 +590,19 @@ apply_upstream_ca_bundles() { '.services.mitmproxy.entrypoint = ["/bin/sh", "-c", strenv(new_entrypoint), "sh"]' \ "$compose_file" } + +# Enables Docker-in-the-sandbox (#70): adds sandcat/compose-docker.yml to the +# compose include list. That single include wires everything — the dind +# service, the agent's socket-volume mount, and the wg-client gateway flag. +# Idempotent: re-running init keeps a single entry. +# Args: +# $1 - Path to compose-all.yml +enable_docker() { + require yq + local compose_file=$1 + local present + present=$(yq '[.include[] | select(.path == "sandcat/compose-docker.yml" or . == "sandcat/compose-docker.yml")] | length' "$compose_file") + if [[ "$present" -eq 0 ]]; then + yq -i '.include += [{"path": "sandcat/compose-docker.yml"}]' "$compose_file" + fi +} diff --git a/cli/libexec/init/devcontainer b/cli/libexec/init/devcontainer index 4dd307cf..bf2bf650 100755 --- a/cli/libexec/init/devcontainer +++ b/cli/libexec/init/devcontainer @@ -120,6 +120,10 @@ devcontainer() { apply_upstream_ca_bundles "$devcontainer_dir/sandcat/compose-proxy.yml" "$project_path" customize_compose_file "$rel_settings_file" "$compose_file" "$agent" "$ide" "$project_name" "$stacks" + + if [[ "${SANDCAT_DOCKER:-false}" == "true" ]]; then + enable_docker "$compose_file" + fi set_project_name "$compose_file" "$project_name" customize_devcontainer_json "$devcontainer_dir/devcontainer.json" "$project_name" "$ide" diff --git a/cli/libexec/init/init b/cli/libexec/init/init index ca9a97f3..b8fb7dfd 100755 --- a/cli/libexec/init/init +++ b/cli/libexec/init/init @@ -304,9 +304,12 @@ init() { # strict-network → project settings get stack network presets instead # of the allow-all-GET wildcard (default deny beyond # the presets and the user-settings layer) + # docker → nested Docker daemon in the sandbox (dind service); + # inner-container egress stays on the mitmproxy path local gitignore_enabled=${SANDCAT_GITIGNORE:-true} local rtk_enabled=${SANDCAT_RTK:-true} local strict_network=${SANDCAT_STRICT_NETWORK:-false} + local docker_enabled=${SANDCAT_DOCKER:-false} if [[ "$features_provided" != "true" ]]; then local available_features=( "tui (mitmproxy console instead of web UI)" @@ -314,6 +317,7 @@ init() { "no-gitignore (do not append Sandcat block to .gitignore)" "no-rtk (do not install rtk shell hook)" "strict-network (stack presets instead of allow-all-GET wildcard)" + "docker (nested Docker daemon inside the sandbox)" ) local selected_features selected_features=$(select_multiple "Select optional features (comma-separated numbers, empty for none):" "${available_features[@]}") @@ -327,6 +331,7 @@ init() { no-gitignore) gitignore_enabled=false ;; no-rtk) rtk_enabled=false ;; strict-network) strict_network=true ;; + docker) docker_enabled=true ;; esac done fi @@ -340,11 +345,12 @@ init() { no-gitignore) gitignore_enabled=false ;; no-rtk) rtk_enabled=false ;; strict-network) strict_network=true ;; + docker) docker_enabled=true ;; 1password) echo "Use --secret-provider 1password instead of --features 1password" | error return 1 ;; - *) echo "Unknown feature: $f (expected: tui, no-shared-cache, no-gitignore, no-rtk, strict-network)" | error; return 1 ;; + *) echo "Unknown feature: $f (expected: tui, no-shared-cache, no-gitignore, no-rtk, strict-network, docker)" | error; return 1 ;; esac done fi @@ -395,10 +401,14 @@ init() { add_secret_provider_tokens_to_user_settings "$secret_provider" local settings_args=() + if [[ "$docker_enabled" == "true" ]]; then + echo " Docker: enabled — nested daemon (dind); agent uses DOCKER_HOST=unix:///docker-sock/docker.sock" | info + fi if [[ "$strict_network" == "true" ]]; then settings_args+=(--strict-network --stacks "$stacks_resolved") fi settings "${settings_args[@]+"${settings_args[@]}"}" "$project_path/$settings_file" "${services[@]}" + export SANDCAT_DOCKER="$docker_enabled" local devcontainer_args=( --settings-file "$settings_file" --project-path "$project_path" diff --git a/cli/templates/devcontainer/Dockerfile.app b/cli/templates/devcontainer/Dockerfile.app index 9bb586ed..9458bd61 100644 --- a/cli/templates/devcontainer/Dockerfile.app +++ b/cli/templates/devcontainer/Dockerfile.app @@ -30,6 +30,7 @@ RUN rm -f /etc/sudoers.d/vscode COPY --chmod=755 sandcat/scripts/app-init.sh /usr/local/bin/app-init.sh COPY --chmod=755 sandcat/scripts/app-user-init.sh /usr/local/bin/app-user-init.sh COPY --chmod=644 sandcat/scripts/java-env.sh /etc/profile.d/sandcat-java.sh +COPY --chmod=644 sandcat/scripts/docker-env.sh /etc/profile.d/sandcat-docker.sh COPY --chown=vscode:vscode sandcat/tmux.conf /home/vscode/.tmux.conf USER vscode diff --git a/cli/templates/devcontainer/sandcat/compose-docker.yml b/cli/templates/devcontainer/sandcat/compose-docker.yml new file mode 100644 index 00000000..ffeaa5ae --- /dev/null +++ b/cli/templates/devcontainer/sandcat/compose-docker.yml @@ -0,0 +1,54 @@ +# Docker-in-the-sandbox (issue #70). Included from compose-all.yml when the +# project was initialized with `sandcat init --features docker`. +# +# A dedicated dind (Docker-in-Docker) service hosts a NESTED dockerd. The +# agent talks to it over a unix socket on a shared volume — the host daemon +# is never exposed. The dind service lives in its OWN network namespace and +# routes its default via wg-client (see scripts/dind-init.sh), so egress +# from agent-launched containers transits wg0 → mitmproxy: network policy, +# CA bumping, and secret substitution all apply two levels deep. +# +# `privileged` is required for the nested daemon (cgroups, overlayfs, +# iptables — all inside dind's own namespaces). The blast radius is bounded +# by dind's separate netns: the agent reaches only the socket, and a +# container that escaped dind still sits outside wg-client's namespace, so +# it cannot touch the kill switch. +services: + dind: + image: docker:27.5.1-dind + privileged: true + environment: + # Socket-only access on a shared volume — TLS adds nothing here and + # the cert bootstrap would complicate startup ordering. + - DOCKER_TLS_CERTDIR= + entrypoint: ["sh", "/opt/sandcat/dind-init.sh"] + volumes: + - ./scripts/dind-init.sh:/opt/sandcat/dind-init.sh:ro + - docker-sock:/docker-sock + - dind-storage:/var/lib/docker + - mitmproxy-public:/mitmproxy-config:ro + depends_on: + wg-client: + condition: service_healthy + healthcheck: + test: ["CMD", "test", "-S", "/docker-sock/docker.sock"] + interval: 2s + timeout: 2s + retries: 15 + start_period: 60s + restart: unless-stopped + + # The agent gets the socket (and the docker CLI dind publishes next to it). + agent: + volumes: + - docker-sock:/docker-sock + + # Turns on the gateway rules in wg-client-init.sh: NAT from the compose + # network into wg0 for containers that route through wg-client. + wg-client: + environment: + - SANDCAT_DIND_GATEWAY=true + +volumes: + docker-sock: + dind-storage: diff --git a/cli/templates/devcontainer/sandcat/scripts/dind-init.sh b/cli/templates/devcontainer/sandcat/scripts/dind-init.sh new file mode 100755 index 00000000..0884566c --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/dind-init.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Entrypoint wrapper for the dind service (Docker in the sandbox, #70). +# Runs BEFORE the nested dockerd: +# 1. installs the mitmproxy CA into the system store — must happen before +# dockerd starts, because Go loads the x509 pool once per process; +# without it `docker pull` cannot pass the TLS bump on the registry, +# 2. replaces the default route with wg-client, so all egress from this +# namespace (and every inner container NATed through it) transits +# wg0 → mitmproxy — fail-loud: no route means no unproxied fallback, +# 3. publishes the docker CLI onto the shared socket volume for the agent, +# 4. makes the socket accessible to the agent's vscode user (gid 1000). +set -eu + +cp /mitmproxy-config/mitmproxy-ca-cert.pem \ + /usr/local/share/ca-certificates/sandcat-mitm-ca.crt +update-ca-certificates >/dev/null 2>&1 + +WG_IP="" +i=0 +while [ -z "$WG_IP" ]; do + WG_IP=$(getent hosts wg-client 2>/dev/null | awk '{print $1; exit}') || true + if [ -z "$WG_IP" ]; then + WG_IP=$(nslookup wg-client 2>/dev/null \ + | awk '/^Address/ && $2 !~ /#|:53/ {ip=$2} END {print ip}') || true + fi + [ -n "$WG_IP" ] && break + i=$((i + 1)) + if [ "$i" -ge 30 ]; then + echo "[dind] cannot resolve wg-client; refusing to start with an unproxied route" >&2 + exit 1 + fi + sleep 1 +done +ip route replace default via "$WG_IP" +echo "[dind] default route via wg-client ($WG_IP)" + +mkdir -p /docker-sock/bin +cp /usr/local/bin/docker /docker-sock/bin/docker + +# Group 1000 matches the agent's vscode gid; dockerd chgrps the socket to it. +addgroup -g 1000 sandcat 2>/dev/null || true + +exec dockerd-entrypoint.sh dockerd \ + --host=unix:///docker-sock/docker.sock \ + --group sandcat diff --git a/cli/templates/devcontainer/sandcat/scripts/docker-env.sh b/cli/templates/devcontainer/sandcat/scripts/docker-env.sh new file mode 100644 index 00000000..1805f687 --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/docker-env.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Exposes the sandboxed Docker daemon to shells when the dind service is +# present (sandcat init --features docker). Guarded, so images built without +# the feature are unaffected. Same pattern as sandcat-java.sh. +if [ -S /docker-sock/docker.sock ]; then + export DOCKER_HOST="unix:///docker-sock/docker.sock" +fi +if [ -d /docker-sock/bin ]; then + case ":$PATH:" in + *":/docker-sock/bin:"*) ;; + *) export PATH="/docker-sock/bin:$PATH" ;; + esac +fi diff --git a/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh b/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh index 820b6036..8c3099d0 100644 --- a/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh +++ b/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh @@ -130,6 +130,23 @@ write_resolv_conf() { } > "$resolv_conf" } +# Gateway mode for the dind service (sandcat init --features docker, #70). +# Containers that set their default route to this container get NATed into +# wg0 — i.e. INTO the mitmproxy policy path, never around it. Forwarding to +# any other interface is dropped, so the gateway cannot be used to reach +# the docker network or the host directly. ip_forward needs no sysctl: +# Docker enables it in bridge-network namespaces. +# +# Args: +# $1 - compose network CIDR allowed to route through us +setup_dind_gateway() { + local src_network="$1" + iptables -t nat -A POSTROUTING -s "$src_network" -o wg0 -j MASQUERADE + iptables -A FORWARD -s "$src_network" -o wg0 -j ACCEPT + iptables -A FORWARD -i wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + iptables -A FORWARD -j DROP +} + main() { # Production behavior is errexit; kept inside main() so sourcing the file # (e.g. from bats tests) doesn't enable errexit in the caller's shell. @@ -248,6 +265,10 @@ main() { fi ip6tables -A OUTPUT -o eth0 -j DROP + if [[ "${SANDCAT_DIND_GATEWAY:-false}" == "true" ]]; then + setup_dind_gateway "$docker_network" + fi + # ── Local DNS forwarder ──────────────────────────────────────────────────── # Run dnsmasq on 127.0.0.1 so: # - sibling-container queries (matched via the compose project's search diff --git a/cli/test/composefile/composefile.bats b/cli/test/composefile/composefile.bats index a34dacf6..dd5ec169 100644 --- a/cli/test/composefile/composefile.bats +++ b/cli/test/composefile/composefile.bats @@ -611,3 +611,26 @@ YAML yq -e '.services.mitmproxy.image == "mitmproxy/mitmproxy:latest"' "$proxy_compose" } + +# --------------------------------------------------- docker in the sandbox + +@test "enable_docker adds the compose-docker include" { + local f="$BATS_TEST_TMPDIR/compose-all.yml" + printf 'include:\n - path: sandcat/compose-proxy.yml\n - path: sandcat/compose-agent.yml\nservices:\n agent: {}\n' > "$f" + + enable_docker "$f" + + run yq '[.include[] | select(.path == "sandcat/compose-docker.yml")] | length' "$f" + assert_output "1" +} + +@test "enable_docker is idempotent" { + local f="$BATS_TEST_TMPDIR/compose-all.yml" + printf 'include:\n - path: sandcat/compose-proxy.yml\nservices:\n agent: {}\n' > "$f" + + enable_docker "$f" + enable_docker "$f" + + run yq '[.include[] | select(.path == "sandcat/compose-docker.yml")] | length' "$f" + assert_output "1" +} diff --git a/cli/test/init/extensions.bats b/cli/test/init/extensions.bats index 014c3a2e..f20a12c5 100644 --- a/cli/test/init/extensions.bats +++ b/cli/test/init/extensions.bats @@ -457,3 +457,24 @@ EOF after=$(cat "$BATS_TEST_TMPDIR/sandcat/compose-proxy.yml") [ "$before" = "$after" ] } + +# --------------------------------------------------- compose-docker template + +@test "compose-docker.yml wires dind, agent socket, and the gateway flag" { + local t="$SCT_TEMPLATEDIR/devcontainer/sandcat/compose-docker.yml" + yq -e '.services.dind.privileged == true' "$t" + yq -e '.services.dind.depends_on["wg-client"].condition == "service_healthy"' "$t" + yq -e '.services.dind.volumes[] | select(. == "docker-sock:/docker-sock")' "$t" + yq -e '.services.dind.volumes[] | select(. == "mitmproxy-public:/mitmproxy-config:ro")' "$t" + yq -e '.services.agent.volumes[] | select(. == "docker-sock:/docker-sock")' "$t" + yq -e '.services["wg-client"].environment[] | select(. == "SANDCAT_DIND_GATEWAY=true")' "$t" + # The host daemon socket must never appear anywhere in the template. + run grep -F "/var/run/docker.sock:" "$t" + [ "$status" -ne 0 ] +} + +@test "Dockerfile.app installs the guarded docker-env profile script" { + run grep -F "COPY --chmod=644 sandcat/scripts/docker-env.sh /etc/profile.d/sandcat-docker.sh" \ + "$SCT_TEMPLATEDIR/devcontainer/Dockerfile.app" + assert_success +} diff --git a/cli/test/init/init.bats b/cli/test/init/init.bats index 315a8e39..39f3160d 100644 --- a/cli/test/init/init.bats +++ b/cli/test/init/init.bats @@ -262,7 +262,7 @@ EOF "'Select IDE:' vscode jetbrains none : echo vscode" \ "'Select secret provider:' 1password none protonpass : echo 1password" stub select_multiple \ - "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' : echo ''" \ + "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' 'docker (nested Docker daemon inside the sandbox)' : echo ''" \ "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name @@ -343,7 +343,7 @@ EOF "'Select IDE:' vscode jetbrains none : echo vscode" \ "'Select secret provider:' none 1password protonpass : echo none" stub select_multiple \ - "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' : echo ''" \ + "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' 'docker (nested Docker daemon inside the sandbox)' : echo ''" \ "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name @@ -422,7 +422,7 @@ EOF "'Select IDE:' vscode jetbrains none : echo vscode" \ "'Select secret provider:' none 1password protonpass : echo none" stub select_multiple \ - "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' : echo 'tui (mitmproxy console instead of web UI)'" \ + "'Select optional features (comma-separated numbers, empty for none):' 'tui (mitmproxy console instead of web UI)' 'no-shared-cache (per-project dep cache instead of shared)' 'no-gitignore (do not append Sandcat block to .gitignore)' 'no-rtk (do not install rtk shell hook)' 'strict-network (stack presets instead of allow-all-GET wildcard)' 'docker (nested Docker daemon inside the sandbox)' : echo 'tui (mitmproxy console instead of web UI)'" \ "'Select development stacks (comma-separated numbers, empty for none):' node python java rust go scala ruby dotnet zig : echo ''" local expected_name diff --git a/cli/test/wg-client/dind_gateway.bats b/cli/test/wg-client/dind_gateway.bats new file mode 100644 index 00000000..394c0cd8 --- /dev/null +++ b/cli/test/wg-client/dind_gateway.bats @@ -0,0 +1,22 @@ +#!/usr/bin/env bats + +setup() { + load test_helper + # shellcheck source=../../../cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh + source "$SCT_TEMPLATEDIR/devcontainer/sandcat/scripts/wg-client-init.sh" +} + +teardown() { + unstub_all +} + +@test "setup_dind_gateway NATs the compose subnet into wg0 and drops other forwards" { + stub iptables \ + "-t nat -A POSTROUTING -s 172.26.0.0/16 -o wg0 -j MASQUERADE : :" \ + "-A FORWARD -s 172.26.0.0/16 -o wg0 -j ACCEPT : :" \ + "-A FORWARD -i wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT : :" \ + "-A FORWARD -j DROP : :" + + run setup_dind_gateway 172.26.0.0/16 + assert_success +} diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md new file mode 100644 index 00000000..754af20f --- /dev/null +++ b/docs/configuration/docker.md @@ -0,0 +1,67 @@ +# Docker in the sandbox + +Many agent workflows need a working Docker engine — Testcontainers, building +images, `docker compose` integration stacks. Mounting the host's +`docker.sock` would hand the agent host-root and a proxy bypass, so sandcat +takes a different route: an opt-in **nested Docker daemon** whose traffic +stays on the mitmproxy path. + +```bash +sandcat init --features docker ... +# or: SANDCAT_DOCKER=true sandcat init ... +``` + +## What you get + +Inside the sandbox, `docker` just works: + +```bash +docker run --rm alpine echo hello # runs on the NESTED daemon +docker build -t myimage . +docker compose up -d # project's own stacks +``` + +`DOCKER_HOST` points at a unix socket on a shared volume +(`/docker-sock/docker.sock`), and the matching `docker` CLI is published on +`PATH` by the dind service itself — versions can't drift. + +## How it works + +A dedicated `dind` compose service hosts the nested `dockerd`: + +- **Own network namespace** with its **default route via wg-client**, which + NATs it into the WireGuard tunnel. Egress from every container the agent + launches transits mitmproxy: the network policy, TLS bumping, and secret + substitution apply **two levels deep**. The route is fail-loud — if it + cannot be installed, the daemon refuses to start rather than fall back to + an unproxied path. +- The **mitmproxy CA is installed before the nested daemon starts** (Go + loads its trust pool once per process), so `docker pull` works through + the proxy out of the box. +- The **host daemon is never exposed**: no `docker.sock` bind-mount exists + anywhere in the generated project, and inner containers cannot reach + wg-client's namespace — forwarding anywhere except into the tunnel is + dropped. + +## Caveats + +- The `dind` service is `privileged` — required by the nested daemon + (cgroups, overlayfs, iptables in its own namespaces). The agent only ever + sees the socket; a hostile inner container that escaped dind would still + be outside wg-client's namespace and unable to touch the kill switch. +- **HTTPS inside inner containers** needs the mitmproxy CA like everywhere + else in the sandbox. The nested daemon trusts it (pulls work), but your + own containers must mount/trust it too, e.g.: + + ```bash + docker run -v /usr/local/share/ca-certificates/sandcat-mitm-ca.crt:/etc/ssl/certs/mitm.pem ... + ``` + + For JVM/Testcontainers workloads, point the truststore at the CA the same + way the agent's Java setup does (see + [Notes → TLS and CA certificates](../reference/notes.md)). +- Secret placeholders are **not** propagated into inner containers by + default — pass the env var explicitly if a container should authenticate + through the proxy. +- Inner-container state lives on the `dind-storage` volume; `sandcat + destroy` removes it with everything else. diff --git a/docs/index.md b/docs/index.md index 550794e4..f4176cd4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,6 +66,7 @@ VirtusLab's AI-native SDLC platform. configuration/volume-mounts configuration/caches configuration/gitignore + configuration/docker .. toctree:: :maxdepth: 2 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 10d4c7d1..690fe304 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,7 +23,7 @@ Options: - `--proxy` - Proxy UI mode: `web` (default, mitmweb browser UI) or `tui` (mitmproxy console, use with `sandcat proxy` to attach) - `--secret-provider` / `--sp` - Secret backend: `none` (default), `1password`, `protonpass` (skips prompt when set) - `--1password` - Deprecated alias for `--secret-provider 1password` -- `--features` - Comma-separated optional non-provider features: `tui` (proxy console mode; prefer `--proxy tui`), `no-gitignore` (skip appending the `# Sandcat` block to the project's `.gitignore`; equivalent to `SANDCAT_GITIGNORE=false`), `no-rtk` (skip RTK installation; equivalent to `SANDCAT_RTK=false`), `strict-network` (project settings get network presets for the selected stacks instead of the allow-all-GET wildcard; equivalent to `SANDCAT_STRICT_NETWORK=true`) +- `--features` - Comma-separated optional non-provider features: `tui` (proxy console mode; prefer `--proxy tui`), `no-gitignore` (skip appending the `# Sandcat` block to the project's `.gitignore`; equivalent to `SANDCAT_GITIGNORE=false`), `no-rtk` (skip RTK installation; equivalent to `SANDCAT_RTK=false`), `strict-network` (project settings get network presets for the selected stacks instead of the allow-all-GET wildcard; equivalent to `SANDCAT_STRICT_NETWORK=true`), `docker` (nested Docker daemon inside the sandbox — see [Docker in the sandbox](../configuration/docker.md); equivalent to `SANDCAT_DOCKER=true`) - `--name` - Project name for Docker Compose (default: derived from directory name) - `--path` - Project directory (default: current directory) From c9c7f5f1992b607ca00c6c73cde78bfafb0d4430 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Thu, 10 Sep 2026 15:55:05 +0200 Subject: [PATCH 2/3] fix(dind): publish CLI plugins and the Testcontainers host override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../devcontainer/sandcat/scripts/dind-init.sh | 5 +++++ .../devcontainer/sandcat/scripts/docker-env.sh | 13 +++++++++++++ cli/test/init/extensions.bats | 11 +++++++++++ 3 files changed, 29 insertions(+) diff --git a/cli/templates/devcontainer/sandcat/scripts/dind-init.sh b/cli/templates/devcontainer/sandcat/scripts/dind-init.sh index 0884566c..0235bf06 100755 --- a/cli/templates/devcontainer/sandcat/scripts/dind-init.sh +++ b/cli/templates/devcontainer/sandcat/scripts/dind-init.sh @@ -36,6 +36,11 @@ echo "[dind] default route via wg-client ($WG_IP)" mkdir -p /docker-sock/bin cp /usr/local/bin/docker /docker-sock/bin/docker +# CLI plugins (compose, buildx) are client-side binaries — without them the +# agent's `docker compose` / `docker build` would fail even though the +# daemon is fine. Publish them next to the CLI so versions cannot drift. +mkdir -p /docker-sock/cli-plugins +cp /usr/local/libexec/docker/cli-plugins/* /docker-sock/cli-plugins/ # Group 1000 matches the agent's vscode gid; dockerd chgrps the socket to it. addgroup -g 1000 sandcat 2>/dev/null || true diff --git a/cli/templates/devcontainer/sandcat/scripts/docker-env.sh b/cli/templates/devcontainer/sandcat/scripts/docker-env.sh index 1805f687..48ff4167 100644 --- a/cli/templates/devcontainer/sandcat/scripts/docker-env.sh +++ b/cli/templates/devcontainer/sandcat/scripts/docker-env.sh @@ -11,3 +11,16 @@ if [ -d /docker-sock/bin ]; then *) export PATH="/docker-sock/bin:$PATH" ;; esac fi +# The docker CLI discovers plugins client-side (~/.docker/cli-plugins); +# link the set the dind service published so `docker compose` / `docker +# buildx` work. agent-home is a writable volume, so the link persists; +# the guard keeps this a one-time, idempotent action. +if [ -d /docker-sock/cli-plugins ] && [ ! -e "$HOME/.docker/cli-plugins" ]; then + mkdir -p "$HOME/.docker" 2>/dev/null \ + && ln -s /docker-sock/cli-plugins "$HOME/.docker/cli-plugins" 2>/dev/null +fi +# Testcontainers assumes "docker host = localhost" for a unix socket, but +# inner ports publish in the dind service's namespace — point it there. +if [ -S /docker-sock/docker.sock ]; then + export TESTCONTAINERS_HOST_OVERRIDE="dind" +fi diff --git a/cli/test/init/extensions.bats b/cli/test/init/extensions.bats index f20a12c5..286eaa35 100644 --- a/cli/test/init/extensions.bats +++ b/cli/test/init/extensions.bats @@ -473,6 +473,17 @@ EOF [ "$status" -ne 0 ] } +@test "dind-init publishes the docker CLI and its plugins for the agent" { + local s="$SCT_TEMPLATEDIR/devcontainer/sandcat/scripts/dind-init.sh" + run grep -F "cp /usr/local/bin/docker /docker-sock/bin/docker" "$s" + assert_success + run grep -F "cp /usr/local/libexec/docker/cli-plugins/* /docker-sock/cli-plugins/" "$s" + assert_success + run grep -F "TESTCONTAINERS_HOST_OVERRIDE" \ + "$SCT_TEMPLATEDIR/devcontainer/sandcat/scripts/docker-env.sh" + assert_success +} + @test "Dockerfile.app installs the guarded docker-env profile script" { run grep -F "COPY --chmod=644 sandcat/scripts/docker-env.sh /etc/profile.d/sandcat-docker.sh" \ "$SCT_TEMPLATEDIR/devcontainer/Dockerfile.app" From 4f76ce9e07b44abc06a0fc45c36eac36409e6002 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Fri, 11 Sep 2026 09:25:43 +0200 Subject: [PATCH 3/3] feat(dind): docker-registry network preset, seeded by --features docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cli/libexec/init/init | 7 +++++++ .../sandcat/scripts/mitmproxy_addon_common.py | 10 ++++++++++ cli/test/init/init.bats | 18 ++++++++++++++++++ docs/configuration/docker.md | 9 +++++++++ docs/configuration/network-rules.md | 1 + 5 files changed, 45 insertions(+) diff --git a/cli/libexec/init/init b/cli/libexec/init/init index b8fb7dfd..ddfd3fa1 100755 --- a/cli/libexec/init/init +++ b/cli/libexec/init/init @@ -408,6 +408,13 @@ init() { settings_args+=(--strict-network --stacks "$stacks_resolved") fi settings "${settings_args[@]+"${settings_args[@]}"}" "$project_path/$settings_file" "${services[@]}" + if [[ "$docker_enabled" == "true" && -f "$project_path/$settings_file" ]]; then + # BuildKit resolves registry manifests with HEAD, which the default + # allow-*-GET wildcard blocks — seed the registry preset so + # `docker build` works out of the box (all-method, host-scoped; + # expanded by the mitmproxy addon at proxy start). + yq -i -o=json '.network = [{"preset": "docker-registry"}] + .network' "$project_path/$settings_file" + fi export SANDCAT_DOCKER="$docker_enabled" local devcontainer_args=( --settings-file "$settings_file" diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py index b5a81ab9..48d43ab5 100644 --- a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py +++ b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon_common.py @@ -164,6 +164,16 @@ "plugins.jetbrains.com", "downloads.marketplace.jetbrains.com", ], + # OCI registries — used by the docker feature (#70): BuildKit resolves + # manifests with HEAD requests, which the default allow-*-GET wildcard + # does not cover; a host-only preset allows all methods on these hosts. + "docker-registry": [ + "registry-1.docker.io", + "auth.docker.io", + "index.docker.io", + "production.cloudflare.docker.com", + "ghcr.io", + ], "github": [ "github.com", "*.github.com", diff --git a/cli/test/init/init.bats b/cli/test/init/init.bats index 39f3160d..5bc7e3ee 100644 --- a/cli/test/init/init.bats +++ b/cli/test/init/init.bats @@ -675,6 +675,24 @@ EOF assert_output --partial "Network: strict — stack presets: python" } +@test "init --features docker seeds the docker-registry preset into project settings" { + # Real settings-file semantics needed: the stub reproduces what the + # settings command does (copy the template), so the seeding step that + # follows in init has a file to mutate. + stub settings \ + "$PROJECT_DIR/.sandcat/settings.json claude vscode : mkdir -p $PROJECT_DIR/.sandcat && cp $SCT_TEMPLATEDIR/settings.json $PROJECT_DIR/.sandcat/settings.json" + stub devcontainer \ + "--settings-file .sandcat/settings.json --project-path * --agent claude --ide vscode --name test --stacks * --proxy web --secret-provider none : :" + + run init --agent claude --ide vscode --name test --path "$PROJECT_DIR" --stacks "" --features "docker" --secret-provider none + assert_success + run yq -r '.network[0].preset' "$PROJECT_DIR/.sandcat/settings.json" + assert_output "docker-registry" + # wildcard z szablonu zostaje za presetem + run yq -r '.network[1].host' "$PROJECT_DIR/.sandcat/settings.json" + assert_output "*" +} + @test "init without strict-network reports the default network policy" { stub settings "$PROJECT_DIR/.sandcat/settings.json claude vscode : :" stub devcontainer \ diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 754af20f..3fc99d3a 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -43,6 +43,15 @@ A dedicated `dind` compose service hosts the nested `dockerd`: wg-client's namespace — forwarding anywhere except into the tunnel is dropped. +## Network policy + +`--features docker` seeds the [`docker-registry` network +preset](network-rules.md#network-presets) into the project settings: BuildKit +resolves registry manifests with **HEAD** requests, which the default +allow-`*`-GET wildcard does not cover. The preset is host-scoped +(Docker Hub + ghcr.io, all methods); add other registries your builds pull +from the same way. + ## Caveats - The `dind` service is `privileged` — required by the nested daemon diff --git a/docs/configuration/network-rules.md b/docs/configuration/network-rules.md index e29e8ca3..34b320d3 100644 --- a/docs/configuration/network-rules.md +++ b/docs/configuration/network-rules.md @@ -47,6 +47,7 @@ sandcat): | `nix` | cache/channels/releases.nixos.org, search.devbox.sh (runtime devbox installs) | | `vscode` | update/marketplace.visualstudio.com, *.vsassets.io, main.vscode-cdn.net | | `jetbrains` | plugins.jetbrains.com, downloads.marketplace.jetbrains.com | +| `docker-registry` | registry-1.docker.io, auth/index.docker.io, production.cloudflare.docker.com, ghcr.io | | `github` | github.com, *.github.com, *.githubusercontent.com | | `anthropic` | *.anthropic.com, *.claude.ai, *.claude.com | | `openai` | api.openai.com, *.openai.com |