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 auxoutput
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:
Token source (whichever fires first):
- Claude JSONL —
~/.claude/projects/**/*.jsonlassistant messages carryusage.input_tokens/usage.output_tokens. Scanned every 3 s. Works without mitmproxy. - mitmproxy —
mitm/addon.pyintercepts 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.