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..ddfd3fa1 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,21 @@ 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[@]}" + 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" --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..0235bf06 --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/dind-init.sh @@ -0,0 +1,50 @@ +#!/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 +# 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 + +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..48ff4167 --- /dev/null +++ b/cli/templates/devcontainer/sandcat/scripts/docker-env.sh @@ -0,0 +1,26 @@ +#!/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 +# 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/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/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..286eaa35 100644 --- a/cli/test/init/extensions.bats +++ b/cli/test/init/extensions.bats @@ -457,3 +457,35 @@ 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 "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" + assert_success +} diff --git a/cli/test/init/init.bats b/cli/test/init/init.bats index 315a8e39..5bc7e3ee 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 @@ -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/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..3fc99d3a --- /dev/null +++ b/docs/configuration/docker.md @@ -0,0 +1,76 @@ +# 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. + +## 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 + (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/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 | 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)