Skip to content

Architecture

Overview

The container is designed around three principles: no secrets on disk, no unrestricted egress, and full visibility into what the container is doing.

graph TD
    subgraph "macOS Host"
        A["1Password Desktop"] -->|SSH agent socket| B["Colima VM"]
        B -->|bind mount| C["Container"]
    end

    subgraph "Container (vscode user)"
        C --> D["entrypoint.sh<br/>(setup + start)"]
        D --> E["firewall.sh<br/>(iptables)"]
        D --> F["privoxy :8888"]
        D --> G["mitmproxy :8080"]
        D --> H["dev-monitor :9090"]
    end

    subgraph "Egress path"
        I["App (claude, terraform…)"] -->|HTTP_PROXY=:8080| G
        G -->|upstream proxy| F
        F -->|domain allowlist| E
        E -->|IP allowlist| J["Internet"]
    end

SSH agent forwarding chain

Private keys never exist on disk inside the container. The chain:

graph LR
    A["1Password Desktop<br/>(macOS)"] -->|SSH Agent Socket| B["macOS SSH_AUTH_SOCK<br/>(must point to 1Password)"]
    B -->|"colima start --ssh-agent"| C["Colima/Lima VM<br/>/run/host-services/ssh-auth.sock"]
    C -->|"bind mount"| D["Container<br/>/run/host-services/ssh-auth.sock"]
    D --> E["ssh-add -l<br/>git push<br/>ssh user@host"]
    D --> F["op CLI<br/>(biometric auth)"]

The key invariant: SSH_AUTH_SOCK on macOS must point to the 1Password agent socket (not the default system socket). Colima's --ssh-agent flag forwards whatever SSH_AUTH_SOCK points to into the VM. See Getting Started for the setup steps.

Network egress

The container enforces egress at two independent layers:

graph TD
    A["App HTTP/HTTPS request"] --> G["mitmproxy :8080"]
    G --> B{Domain in privoxy<br/>allowlist?}
    B -->|No| E["Proxy BLOCK"]
    B -->|Yes| C{IP in iptables<br/>allowlist?}
    C -->|No| D["REJECT<br/>(icmp-port-unreachable)"]
    C -->|Yes| F["ACCEPT → internet"]

Layer 1 — iptables (firewall.sh): an IP-level allowlist built from .devcontainer/egress-allowlist.txt. It resolves allowlisted hostnames to IPs at apply-time. OUTPUT chain policy is DROP. All blocked traffic is rejected (not silently dropped — immediate ICMP error). There is no default allowlist baked into the image; each project defines its own. This layer covers all TCP/UDP, including DNS and SSH.

Layer 2 — privoxy (HTTP/HTTPS domain filter): a domain-level allowlist for HTTP/HTTPS only, compiled from .devcontainer/proxy.yaml into /tmp/privoxy-user.action by generate-proxy-filter (privoxy does not read proxy.yaml directly). Hot-reloadable via generate-proxy-filter without a firewall flush.

HTTP/HTTPS must be permitted by both layers. DNS and SSH are gated by iptables only (not privoxy). See Manage Egress for the step-by-step workflow.

Observability stack

Three services start unconditionally — they cannot be disabled (security constraint):

Service Port Purpose
privoxy 127.0.0.1:8888 HTTP/HTTPS domain-allowlist filtering
mitmproxy 127.0.0.1:8080 TLS interception — LLM token/model tracking
dev-monitor 127.0.0.1:9090 Real-time dashboard (published to a host port chosen by env.sh; see below)

All outbound traffic routes through app → mitm:8080 → privoxy:8888 → internet.

dev-monitor dashboard

Inside the container the dashboard always binds http://127.0.0.1:9090. Because a host runs more than one dev-container, the host-published port cannot be fixed: .devcontainer/env.sh scans host ports 9090–9190, exports the first free one as MY_PROJECT_MONITOR_PORT, and devcontainer.json maps it to the container's 9090. The host URL appears in the VS Code Ports panel.

The dashboard exposes four panels:

  • Proxy Feed — live HTTP/HTTPS requests: domain, action, LLM badge, model, token counts
  • Firewall — iptables chain rules and effective policy, with a Connection Failures sub-section listing blocked outbound connections attributed to processes
  • AI Activity — merged Claude Code + Copilot feed with per-agent token totals and cost estimate (this-month + all-time USD)
  • Processes — full ps aux output

LLM cost estimation

The AI Activity panel shows two USD figures — this month (calendar month-to-date, resetting on the 1st in UTC) and total (all-time) — estimated from the accumulated token counts.

Formula:

cost = (input_tokens  / 1_000_000) × input_$/MTok
     + (output_tokens / 1_000_000) × output_$/MTok

Token source (whichever fires first):

  • Claude JSONL~/.claude/projects/**/*.jsonl assistant messages carry usage.input_tokens / usage.output_tokens. Scanned every 3 s. Works without mitmproxy.
  • mitmproxymitm/addon.py intercepts the raw API response and parses SSE event-stream chunks or JSON body for token counts.

Persistence: all-time totals are stored per agent in token-usage.json; per-month token counts live in monthly-usage.json next to it. The dashboard reads the current month's bucket, so the "this month" figure resets on the 1st without any explicit rotation.

Pricing table (USD per million tokens). This mirrors the PRICING table in monitor/web/index.html — update both together:

Model Input $/MTok Output $/MTok
Claude Fable 5 $10 $50
Claude Mythos 5 $10 $50
Claude Opus 4.x (4.5+) $5 $25
Claude Opus 4 (4/4.1) $15 $75
Claude Sonnet 4 $3 $15
Claude Haiku 4 $1 $5
Claude Opus 3 $15 $75
Claude Sonnet 3.5 $3 $15
Claude Sonnet 3 $3 $15
Claude Haiku 3.5 $0.80 $4
Claude Haiku 3 $0.25 $1.25
GPT-4o $2.50 $10
GPT-4 Turbo $10 $30
GPT-4 $10 $30
GPT-3.5 $0.50 $1.50

The figure is labelled because batch API discounts, prompt caching, and enterprise pricing are not modelled. Update the PRICING table in monitor/web/index.html when rates change.

mitmproxy CA certificate

On first start, mitmproxy generates a CA certificate at ~/.mitmproxy/mitmproxy-ca-cert.pem and installs it into the system trust store. The certificate is persisted in the mitm-home named volume so tools that trusted it once do not need to re-trust after a container rebuild.

See ADR 0009 for the full design rationale.