# Spec: Enforced network interception (egress lockdown)

Status: Phases 1–2 IMPLEMENTED (enforced-egress network topology + DNS capture). Phases 3–5 (non-HTTP opt-in passthrough, e2e negative tests, full docs pass) still pending. Post-audit hardening IMPLEMENTED: `CONNECT` tunnels through the proxy are now domain-checked (fail closed) and `rawtcp` is disabled, so the proxy is not an open non-HTTP tunnel (see §7a).

## 1. Problem

Spens advertises "full network interception", but proxy routing is **advisory, not enforced**:

- Routing to the interceptor is done purely via `HTTP_PROXY`/`HTTPS_PROXY` env vars:
  - `spens/builder.py:250-255` (nono profile `set_vars`)
  - `spens/builder.py:351-356` (entrypoint `export` lines)
- There are **no iptables/nftables REDIRECT rules and no egress restriction** anywhere in the codebase.
- Worse, the agent container runs with `--network container:<interceptor>` (`spens/runner.py:283`), i.e. it **shares the interceptor's network namespace**. The interceptor is attached to the default bridge with unrestricted internet access — so the agent inherits that unrestricted access.

Escape vectors today (all leave with **zero capture and zero policy enforcement**):

| Vector | Why it escapes |
|---|---|
| `unset HTTPS_PROXY` | Env vars are the only routing mechanism |
| `curl --noproxy '*'` | Same |
| Raw sockets / non-proxy-aware clients | Never consult env vars |
| `git` over SSH (port 22) | mitmproxy only handles proxied HTTP(S) on 9090 |
| DNS to arbitrary resolvers | Never touches the proxy |
| ICMP / any non-TCP-9090 traffic | Unrestricted netns egress |

`domain_rules`, secret substitution, and trace capture are all enforced *inside* the addon — but only for traffic that cooperates and arrives at port 9090. "Full network interception" is currently true for cooperating clients only.

## 2. Goals

1. **Enforced, not advisory**: an agent that ignores/unsets proxy env vars must have **no egress path** except through the interceptor (fail-closed).
2. **Works on Windows and Linux hosts** (Docker Desktop / OrbStack on Windows+macOS, native Docker on Linux). No host-level firewall changes, no host admin privileges.
3. Preserve the existing UX: readiness gating (addon marker), cert volume, `pre_sandbox_commands`, `domain_rules`, `inject_headers`, trace/viewer formats.
4. Keep the agent unprivileged; don't hand the agent container new capabilities.

Non-goals: capturing non-HTTP protocols' payloads (SSH etc.) — they are *blocked*, not proxied. Perfect DNS exfil prevention is a hardening phase, not a blocker (see §7).

## 3. Design options considered

### Option A — iptables REDIRECT in a shared netns (transparent proxy)

Keep `--network container:<interceptor>`, add `--cap-add NET_ADMIN` to the interceptor, run iptables/nat REDIRECT rules in the shared netns pointing all outbound TCP at mitmproxy `--mode transparent`.

Pros: transparently captures non-proxy-aware HTTP clients; classic approach.

Cons:
- Requires NET_ADMIN on the interceptor and an iptables binary in both images (apt vs apk variance across the 6 built-in environments).
- mitmproxy transparent mode relies on `SO_ORIGINAL_DST` conntrack lookups — workable on Linux, but fragile across Docker Desktop VM kernel versions and awkward to test.
- Non-HTTP TLS (SSH) fails inside the proxy with confusing protocol errors rather than a clean "no route".
- Rule lifecycle must exactly match the netns lifecycle (netns dies with the interceptor container).

### Option B — Docker-native network isolation (dual-homed interceptor) ✅ RECOMMENDED

Stop sharing the netns. Per session:

- Create `spens-internal-<sid>`: a user-defined bridge with `--internal` (Docker installs host-side rules that drop all routed egress from that network).
- Create `spens-egress-<sid>`: a normal (non-internal) user-defined bridge.
- Interceptor joins **both** networks (dual-homed): proxy listener reachable from the internal net, upstream egress via the egress net.
- Agent joins **only** `spens-internal-<sid>`. Its sole L3 neighbor is the interceptor.

Enforcement properties:
- `unset HTTPS_PROXY` + raw socket + `curl --noproxy` + git-SSH + any direct egress → **no route, connection blocked at the Docker bridge** (fail-closed). This is Docker's own, well-tested mechanism, identical on Linux and Docker Desktop for Windows (everything executes in the Linux VM; the host OS is irrelevant).
- The only reachable service is mitmproxy on 9090, so every byte that leaves the agent is subject to `domain_rules`, secret substitution, and capture.
- No new capabilities needed for the agent; we can additionally *drop* `NET_RAW` (see §5.4).

Cons: non-proxy-aware HTTP clients no longer work at all (they fail instead of being transparently captured). For an audit/sandbox tool, "blocked" is the correct policy answer — the alternative is silently-uncaptured traffic, which is the bug we're fixing.

### Option C — Host firewall rules (Windows Firewall / nftables on the host)

Rejected: requires host admin rights, completely different tooling per OS, can't scope rules to a single session's containers, and breaks the "just works on any machine" model.

## 4. Implementation plan (Option B)

### Phase 1 — Core topology change

**`spens/runner.py` (`run_session`)**

1. After building images, create the two networks:
   - `docker network create --internal spens-internal-<sid>`
   - `docker network create spens-egress-<sid>`
2. Interceptor launch: replace the current plain `docker run -d` network (default bridge) with:
   - `--network spens-internal-<sid> --network-alias spens-interceptor`
   - then `docker network connect spens-egress-<sid> <interceptor_name>` immediately after start (before waiting on the readiness marker, so upstream egress exists before any agent traffic can arrive — the existing marker gate already ensures the agent starts after the addon is up).
3. Agent launch: replace `--network container:<interceptor_name>` (runner.py:283) with `--network spens-internal-<sid>`.
4. `finally` block: `docker network rm` both networks (after container removal; Docker refuses to remove networks with active endpoints, so order matters).
5. Keep everything else: cert volume, readiness wait, mounts, env plumbing, tmpfs.

**`spens/builder.py`**

6. Entrypoint (builder.py:351-356): change proxy URLs from `http://localhost:9090` to `http://spens-interceptor:9090` (resolved via Docker's embedded DNS on the user-defined network using the network alias). `NO_PROXY` becomes `localhost,127.0.0.1,spens-interceptor`.
7. Nono profile `set_vars` (builder.py:250-255): same substitution. `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS` unchanged.
8. Interceptor image entrypoint (builder.py:479): add `--set block_global=false` to the `mitmdump` invocation. mitmproxy blocks non-local clients by default; the agent now connects from a different container IP rather than localhost, so without this every request 502s. (Explicitly note: `block_global=false` is safe here because the internal network contains only the agent and the interceptor.)

**Config surface**

9. No new required config. Optional new `.spens.config.json` key `"egress": "enforced" | "legacy"` (default `enforced`) to fall back to the old shared-netns behavior during a transition period; `legacy` prints a loud warning that interception is advisory-only.

### Phase 2 — DNS control (hardening, recommended second step)

With only Phase 1, the agent's `/etc/resolv.conf` still points at Docker's embedded resolver (127.0.0.11), which is special-cased past the internal-net egress drop: **name resolution still works for any external domain and is not captured**. Connections remain blocked, but arbitrary-name DNS queries are a low-bandwidth exfil/metadata channel (queries traverse the daemon's upstream resolver; a malicious domain's authoritative NS sees the lookups).

1. Interceptor image: add a small DNS forwarder (dnsproxy or dnsmasq) listening on UDP/TCP 53, forwarding to the daemon's resolvers, **logging every query** to `/app/traces/dns_log.jsonl`.
2. `runner.py`: after connecting the interceptor to the internal network, `docker inspect` its IP on `spens-internal-<sid>` and launch the agent with `--dns <that-ip>` (and `--dns-search .`). Setting `--dns` overrides 127.0.0.11, so all resolution flows through the interceptor over the internal net (container-to-container traffic is allowed) — captured, and subject to an optional domain allowlist.
3. Optional: reuse the `domain_rules` patterns to refuse resolution of non-allowed domains (refuse = NXDOMAIN), making DNS policy-consistent with HTTP policy.

### Phase 3 — Non-HTTP egress policy (documentation + optional opt-in)

Default policy after Phase 1/2: **all non-proxied-HTTP(S) egress is denied** (fail-closed). This is now enforced *inside* the proxy as well, not just by the network topology:

- The addon hooks `http_connect` and denies any `CONNECT` target whose hostname matches no `domain_rules` pattern (fail closed; hostname-only check — the methods that matter are those of the HTTP requests sent through the tunnel, which are still checked in `request`). Every `CONNECT` decision is written to `request_log.jsonl` (previously CONNECTs left **no** log entry, because the connect response does not pass through the `response` hook).
- mitmdump runs with `--set rawtcp=false` (it defaults to on): mitmproxy's layer selector routes any payload that doesn't look like HTTP — explicitly including SSH — to a raw TCP forwarding layer where **no addon hook runs**. With it off, tunneled bytes must parse as HTTP, so SSH and other non-HTTP protocols die inside the tunnel even to otherwise-allowed hosts.

Consequences to document:

- `git` SSH remotes stop working → users must use `https://` remotes (git CLI honors `HTTPS_PROXY`).
- `npm`/`pip`/`curl`/`wget` continue to work (proxy-aware, and `pre_sandbox_commands` inherit the proxy env vars and the same enforced network).

Optional opt-in escape hatch (defer, separate PR): mitmproxy `--mode socks5` second listener or a TCP forwarder with an explicit per-session allowlist, for users who genuinely need SSH/passthrough — always audited, never silent.

### Phase 4 — Tests

**Unit** (extend `tests/unit/test_builder.py`, `test_runner.py`):
- Generated entrypoint and nono profile contain `http://spens-interceptor:9090` and the expanded `NO_PROXY`.
- Interceptor entrypoint contains `block_global=false`.
- Runner's docker command construction: agent on `spens-internal-<sid>`, no `--network container:`; networks created with `--internal` only on the internal one; cleanup removes both networks.
- `egress: legacy` mode preserves old flags.

**E2E** (new negative/positive harness alongside `tests/e2e/runner.py`, using a scratch workspace + `pre_sandbox_commands` so no LLM key is needed):
- ✅ `curl -fsS https://example.com` through the proxy succeeds **and** appears in `traces/request_log.jsonl`.
- ❌ `env -u HTTPS_PROXY -u https_proxy curl -fsS https://example.com` fails (no route).
- ❌ `curl --noproxy '*' -fsS https://example.com` fails.
- ❌ Python raw socket to `1.1.1.1:443` fails.
- ❌ `ssh -T git@github.com` / `git ls-remote git@github.com:...` fails.
- ❌ (Phase 2) `dig @8.8.8.8 example.com` fails; `dig example.com` succeeds and is logged in `dns_log.jsonl`.

**Platform matrix** (manual, at least once per release): native Linux Docker, Docker Desktop on Windows, OrbStack on macOS — confirm `--internal` networks, `docker network connect`, and network aliases behave identically (they're all daemon-side features, but verify).

### Phase 5 — Documentation

- README: replace "full network interception" with an accurate statement ("all egress is forced through the interceptor; non-proxied protocols are blocked"), add a topology diagram (agent → internal net → interceptor → egress net → internet), document the egress policy table, the DNS behavior, and the SSH/git migration note.
- `.spens.config.json` reference: document `egress` key.
- Update `specs/specfication.md` workflow section (step 3/4: networks instead of shared netns).

## 5. Edge cases & details

1. **Dual-homing order**: interceptor must be *created* on the internal network (its primary), then connected to egress. The addon readiness marker gate stays as-is, so the agent cannot start before the proxy is listening; egress attach happens before that gate resolves.
2. **mitmproxy `block_global`**: must be disabled (Phase 1 item 8) or every request from the agent 502s — this is the #1 integration test item.
3. **Name stability**: use the `--network-alias spens-interceptor` (session-independent) in env vars, not the session-suffixed container name.
4. **Agent hardening**: add `--cap-drop NET_RAW` to the agent container. Default Docker capabilities include NET_RAW, which enables raw sockets/ARP spoofing on the shared internal L2; dropping it closes that (cost: `ping` no longer works — document it).
5. **`host.docker.internal` / host services**: unreachable from an `--internal` network. Intentional; document.
6. **IPv6**: user-defined bridges are IPv4-only by default; no change needed.
7. **Windows path handling**: `_docker_path()` already normalizes backslashes; no host-side paths are involved in the network changes.
8. **Network cleanup robustness**: `docker network rm` fails if endpoints remain; run it after `_stop_and_remove` of both containers, tolerate and warn on failure (stale networks are named per-session and harmless).
9. **`pre_sandbox_commands`** execute inside the agent container pre-netns-change... they run in the same container on the internal net with the proxy env exported — so they are now *enforced* too (previously they were advisory as well). `npm install` (HTTPS) keeps working; any command needing SSH breaks — call this out in migration notes.

## 6. Suggested PR sequencing

1. PR 1: Phase 1 (topology + env var rename + `block_global=false`) + unit tests — this alone closes the main hole.
2. PR 2: Phase 2 (DNS) + e2e negative tests.
3. PR 3: Phase 5 docs + `egress: legacy` deprecation path removal.

## 7. Residual risks (accepted, documented)

### 7a. Proxy-as-open-tunnel (FIXED)

An audit found that "SSH is blocked" / "domain rules fail closed" were false for *tunneled* traffic: the addon only hooked `request`, while mitmproxy (rawtcp defaults to on) routes non-HTTP payloads inside a `CONNECT` tunnel to raw TCP forwarding with no hooks — so `CONNECT anyhost:22` gave uncensored, unlogged SSH (and any other non-HTTP protocol) egress. Fixed by the `http_connect` domain check + `rawtcp=false` described in Phase 3. Remaining accepted risk: a `CONNECT` to an *allowed* hostname on a non-HTTP port is established (then dies at the HTTP layer); a per-port allowlist for `CONNECT` could tighten this further if ever needed.

- **Interceptor compromise = total egress**: the dual-homed interceptor is the single choke point; a mitmproxy RCE gives an attacker the egress net. Inherent to the design; the agent cannot reach the interceptor's egress interface except through mitmproxy's own listener.
- **Same-L2 agent↔interceptor attacks**: mitigated by `--cap-drop NET_RAW`; full L2 isolation would require separate nets + NAT, which breaks the model.
- **DNS leak if Phase 2 deferred**: resolution of arbitrary names via the embedded resolver (metadata/exfil channel), no connection egress.
- **Docker engine trust**: enforcement is Docker's bridge filtering; a `--privileged` agent or a Docker daemon bug escapes it. Spens's threat model already excludes "100% secure".
