Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cli/lib/composefile.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions cli/libexec/init/devcontainer
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 18 additions & 1 deletion cli/libexec/init/init
Original file line number Diff line number Diff line change
Expand Up @@ -304,16 +304,20 @@ 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)"
"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)"
)
local selected_features
selected_features=$(select_multiple "Select optional features (comma-separated numbers, empty for none):" "${available_features[@]}")
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions cli/templates/devcontainer/Dockerfile.app
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions cli/templates/devcontainer/sandcat/compose-docker.yml
Original file line number Diff line number Diff line change
@@ -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:
50 changes: 50 additions & 0 deletions cli/templates/devcontainer/sandcat/scripts/dind-init.sh
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions cli/templates/devcontainer/sandcat/scripts/docker-env.sh
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions cli/test/composefile/composefile.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
32 changes: 32 additions & 0 deletions cli/test/init/extensions.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading