Skip to content

0009. In-Container Proxy, MITM, and Monitor Services

Status

Accepted

Context

The existing egress firewall (firewall.sh + iptables) operates at the TCP/IP level: it resolves hostnames to IPs at apply-time and writes ACCEPT rules per IP. This works well for non-HTTP traffic (SSH, DNS) but is fragile for HTTP/HTTPS traffic because cloud service IPs rotate frequently.

The tmp/aicapsule reference project ships three observability features that address this gap:

  1. Proxy (privoxy): Domain-name allowlisting for HTTP/HTTPS traffic, hot-reloadable without iptables flush.
  2. MITM (mitmproxy): Transparent TLS interception in upstream mode; extracts LLM model names and token counts from Claude and Copilot traffic.
  3. Monitor (Go binary): Real-time browser dashboard showing proxy requests, firewall drops, and Claude Code activity.

In aicapsule these run as three separate Docker containers on isolated networks. The dev-container uses a single-image architecture (ADR 0006) where consuming projects reference a pre-built image via "image": in devcontainer.json, not "dockerComposeFile:".

Decision

Embed all three services as in-container background processes started from entrypoint.sh, rather than as sidecar containers.

All three services start unconditionally and cannot be disabled — this is a security constraint. The old ENABLE_PROXY / ENABLE_MITM / ENABLE_MONITOR toggles were removed in v2.0.0.

Traffic flow:

application → 127.0.0.1:8080 (mitm) → 127.0.0.1:8888 (proxy) → internet
The iptables firewall remains the outer TCP-level layer. The proxy adds inner HTTP domain-level filtering. For HTTP/HTTPS traffic, both layers must permit a destination for it to succeed. DNS and SSH are gated by iptables only.

The monitor/main.go is adapted from the aicapsule reference to work without Docker socket access:

aicapsule (Docker socket) dev-container (in-process)
Docker container logs API Tail /tmp/privoxy.log and /tmp/mitm.log
docker exec iptables-nft -L sudo /usr/local/bin/iptables-list.sh
Docker stats API Read /proc/<pid>/status from PID files
Docker top API Local ps aux
Docker mounts for Claude files Direct read of $HOME/.claude/projects/

Consequences

Good: - All three services are always-on and cannot be disabled — a deliberate security constraint, so egress visibility and filtering can never be silently turned off. - Single-image model preserved; consuming projects keep "image": without needing "dockerComposeFile:". - mitmproxy installed via apk add mitmproxy (Alpine 3.23 community repo — native musl binary, no glibc shim, no Rust source build, no separate Docker stage). - CA cert generation moved to postCreateCommand (setup_mitm_ca) so the cert is in the system trust store before the container is marked ready. No timing race for Node.js or other tools. - NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt baked into the Dockerfile so all Node.js processes (including Claude Code) automatically trust the mitmproxy CA after update-ca-certificates runs. - HTTP_PROXY / http_proxy / HTTPS_PROXY / https_proxy injected via devcontainer.json remoteEnv — set before any shell opens, no rc-file sourcing race. - Monitor reads /proc directly (same Linux namespace) — simpler than Docker API calls and avoids Docker socket security concerns. - The CA cert volume (mitm-home) persists across container rebuilds so tools never need to re-trust after a rebuild. - Token counts for Claude Code extracted from JSONL session files (~/.claude/projects/**/*.jsonl), which is more reliable than MITM interception for that specific agent.

Trade-offs: - No process isolation between services (acceptable for a dev tool; all run as the same vscode user). - Monitor cannot observe other containers (not needed in single-container model). - HTTP/HTTPS domains must be listed in BOTH egress-allowlist.txt (iptables, IP-level) AND .devcontainer/proxy.yaml (privoxy, domain-level). The dual-allowlist requirement is documented in examples/proxy.yaml and examples/AGENTS.md. - mitmproxy version tied to Alpine 3.23's community repo; upgrade by bumping the Alpine base image version. - The iptables OUTPUT chain uses iptables -P OUTPUT DROP (policy) combined with a catch-all REJECT rule for ICMP feedback — shown as "default DROP" in the monitor dashboard. INPUT chain is similarly managed.

Alternatives Considered

Multi-container via dockerComposeFile:: Would preserve process isolation but breaks the single-image "image": pattern used by all consuming projects. Rejected.

glibc virtualenv (python:3.12-slim builder stage): Attempted first. The virtualenv copy approach fails silently at runtime because python3.12 in the virtualenv is dynamically linked against /usr/lib/x86_64-linux-gnu/libpython3.12.so.1.0 (Debian system library not included in the COPY). Alpine's gcompat shim provides the ELF loader and glibc calls but cannot conjure a missing .so. Replaced by apk add mitmproxy which installs a native musl binary.

Run proxy and mitm as root services: Would avoid sudo for CA cert installation but is inconsistent with the least-privilege approach (vscode user + scoped sudo). Rejected.

Feature-flag in image build: Rejected because it would require different image variants for the same security baseline. The services are now unconditionally always-on (the runtime opt-out toggles were removed in v2.0.0), so a build-time flag would only let a consumer weaken that baseline.