PRD 0005: mitmproxy TLS interception for pipelock content scanning #8

Closed
didericis wants to merge 6 commits from mitmproxy-tls-interception into main
Showing only changes of commit c2eacac49f - Show all commits
+299 -233
View File
@@ -1,20 +1,30 @@
# PRD 0005: mitmproxy TLS interception for pipelock content scanning # PRD 0005: mitmproxy TLS interception for pipelock content scanning
- **Status:** Draft - **Status:** Draft (updated 2026-05-12 after open-question walkthrough)
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-12 - **Created:** 2026-05-12
## Summary ## Summary
Add a per-bottle **mitmproxy** sidecar in front of pipelock on the Add a per-bottle **mitmproxy** sidecar in front of pipelock on the
egress path so pipelock's DLP, subdomain-entropy, and MCP scanners egress path. mitmproxy bumps the agent's TLS CONNECT, decrypts the
fire on the plaintext bodies of HTTPS requests instead of only the inner HTTP, and hands each request to a vendored Python addon. The
opaque ciphertext that follows a `CONNECT`. mitmproxy terminates the addon forwards the decrypted request to pipelock as a plain HTTP
agent's TLS, hands plaintext HTTP to pipelock as an upstream forward-proxy call so pipelock's DLP, URL-scan, and header-scan
forward proxy, and re-establishes TLS to the real destination. A layers fire on real bodies. On the verdict, the addon either
fresh ephemeral CA is minted per bottle; the CA private key never short-circuits the flow with a 403 (block) or lets mitmproxy
leaves the sidecar, and the public cert is wired into the agent proceed to the real upstream (allow). mitmproxy itself generates
container's trust store at launch. the ephemeral per-bottle CA on startup; the public cert is copied
into the agent's trust store and the private key dies with the
sidecar on teardown.
This is Topology A' from `docs/research/tls-mitm-for-pipelock.md`
a variant of the research note's Topology A after a spike showed
mitmproxy's `upstream` mode re-wraps decrypted flows in a new
CONNECT to the upstream proxy (which would defeat the entire
point). The addon recovers the design by emitting plain HTTP to
pipelock explicitly instead of relying on mitmproxy's `upstream`
chaining.
## Problem ## Problem
@@ -45,7 +55,8 @@ slips past the scanner.
`pipelock-assessment.md` §Scope gaps names this as a known `pipelock-assessment.md` §Scope gaps names this as a known
limitation of the proxy-without-TLS-inspection shape. Closing it is limitation of the proxy-without-TLS-inspection shape. Closing it is
the explicit motivation for `tls-mitm-for-pipelock.md`, whose the explicit motivation for `tls-mitm-for-pipelock.md`, whose
recommendation this PRD implements. recommendation this PRD implements (with the addon adjustment
forced by the upstream-mode spike).
## Goals / Success Criteria ## Goals / Success Criteria
@@ -53,306 +64,361 @@ The feature works when all of the following are observable:
- A Node request from inside a launched bottle to a CONNECT-bumped - A Node request from inside a launched bottle to a CONNECT-bumped
HTTPS host (e.g. `https://api.anthropic.com/dlp-probe`) carrying a HTTPS host (e.g. `https://api.anthropic.com/dlp-probe`) carrying a
pipelock-recognized credential pattern in the body returns 403 from pipelock-recognized credential pattern in the body returns 403
the proxy, not a response from the upstream. The existing from the bottle's egress chain — not a response from the upstream.
`test_pipelock_blocks_secret_post` test path becomes the HTTPS The existing `test_pipelock_blocks_secret_post` test path becomes
variant of this assertion. the HTTPS variant of this assertion.
- A plain HTTPS GET from inside the bottle to an allowlisted host
with no credential pattern (e.g. `GET https://raw.githubusercontent.com/...`)
returns the real upstream response — the addon doesn't break
clean traffic.
- Claude Code itself reaches `api.anthropic.com` end-to-end through - Claude Code itself reaches `api.anthropic.com` end-to-end through
the bottle and completes a chat round-trip. No TLS-trust errors the bottle and completes a chat round-trip. No TLS-trust errors
in the agent process. in the agent process.
- mitmproxy's TLS-handshake log lines and pipelock's `body_dlp` - mitmproxy's flow log and pipelock's `body_dlp` / `header_dlp` /
event lines both appear for the same outbound request, confirming `core_dlp` event lines both appear for the same outbound request,
the two-stage path is active. confirming the two-stage path is active.
The feature is **done** when all of the following ship: The feature is **done** when all of the following ship:
- A new `MitmproxyProxy` class with the same `prepare` / `start` / - A new `MitmproxyProxy` class with the same `prepare` / `start` /
`stop` lifecycle shape as `PipelockProxy`, wired into the Docker `stop` lifecycle shape as `PipelockProxy`, wired into the Docker
backend's launch step. backend's launch step.
- The bottle launch step generates a per-bottle ephemeral CA in - A vendored Python addon at `claude_bottle/mitmproxy/addon.py`
`stage_dir`, starts the mitmproxy sidecar with that CA on the that mitmproxy loads on startup via `mitmdump -s ...`. The sidecar
per-bottle internal network, copies the CA public cert into the runs in `regular` mode (default), not `upstream` mode.
agent container's trust store, and points the agent's - The bottle launch step starts the mitmproxy sidecar, waits for
`HTTPS_PROXY` / `HTTP_PROXY` at mitmproxy. the sidecar-internal CA to be generated, copies the CA public
- mitmproxy's upstream is the existing pipelock sidecar; pipelock cert into the agent at `/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
sees plaintext HTTP from mitmproxy for every previously-HTTPS runs `update-ca-certificates` inside the agent, and threads the
request. `NODE_EXTRA_CA_CERTS` / `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`
env trio onto the agent container's runtime env.
- The agent's `HTTPS_PROXY` / `HTTP_PROXY` point at the mitmproxy
sidecar (where they pointed at pipelock under PRD 0001).
- pipelock is otherwise unchanged. It continues to load the YAML
PRD 0001 generates and runs its existing scanning pipeline; the
addon talks to it via the same forward-proxy interface today's
`test_pipelock_blocks_secret_post` uses.
- On bottle teardown the mitmproxy sidecar is removed and the - On bottle teardown the mitmproxy sidecar is removed and the
ephemeral CA private key is gone with it. ephemeral CA private key is gone with it.
- An integration test (variant of `test_pipelock_blocks_secret_post`) - An HTTPS variant of `test_pipelock_blocks_secret_post` proves
proves pipelock now blocks a credential POST that goes out over pipelock now blocks a credential POST over HTTPS rather than
HTTPS rather than plain HTTP. plain HTTP.
- An integration test proves a non-credential HTTPS request to an - An integration test proves a non-credential HTTPS GET through
allowlisted host (e.g. CONNECT-then-GET on `raw.githubusercontent.com`) the chain returns the upstream's real response.
succeeds end-to-end with mitmproxy in the path (no TLS-trust
errors, response body received).
- The dry-run preflight (`start --dry-run`) shows the mitmproxy - The dry-run preflight (`start --dry-run`) shows the mitmproxy
sidecar in both the text and `--format=json` output alongside the sidecar in both text and `--format=json` output. The JSON
existing pipelock entry. contract gains a reserved `egress.mitm: { "enabled": true, "ca_fingerprint": null }`
block; fingerprint is always null at dry-run because the CA
doesn't exist yet. Real launches emit a one-line stderr log:
`claude-bottle: mitm ca fingerprint: <sha256-first-16>...`.
## Non-goals ## Non-goals
- **Topology C** — extending pipelock itself to terminate TLS. That - **Topology C** — extending pipelock itself to terminate TLS. The
is the cleanest long-term shape per the research note's research note's recommended long-term shape, but substantial Go
recommendation but is substantial Go work and hits the work plus the Apache-2.0-vs-ELv2 question. Deferred.
Apache-2.0-vs-ELv2 question. Deferred. - **Topology D as canonical** — mitmproxy with a pipelock `/scan`
- **Topology D** — driving mitmproxy with a pipelock `/scan` HTTP HTTP endpoint. The addon in this PRD talks to pipelock via its
endpoint. Requires a pipelock surface that doesn't exist today. existing forward-proxy interface; no upstream pipelock change
Deferred. needed.
- **Persistent or shared CA across bottles.** Each bottle gets a - **Persistent or shared CA across bottles.** Each bottle gets a
fresh CA generated at start and destroyed at teardown. No CA fresh CA generated by its own mitmproxy at startup.
storage on the host, no cross-bottle reuse.
- **Selective bumping ("ignore_hosts") as a v1 manifest field.** - **Selective bumping ("ignore_hosts") as a v1 manifest field.**
v1 bumps every CONNECT. If a future allowlisted host turns out to v1 bumps every CONNECT. If a future allowlisted host turns out
pin (Mobile / Chromium-style cert pinning), a follow-up PRD adds to pin (Mobile / Chromium-style cert pinning), a follow-up PRD
the per-host opt-out — likely a `bottle.egress.tls_bump_ignore` adds the per-host opt-out via `bottle.egress.tls_bump_ignore`.
field. See Open questions. Strictly additive.
- **HTTP/3 / QUIC.** mitmproxy's HTTP/3 support is experimental. - **HTTP/3 / QUIC.** mitmproxy's HTTP/3 support is experimental.
v1 relies on the v1-egress iptables layer (separate PRD) blocking v1 relies on the v1-egress iptables layer blocking UDP/443 to
UDP/443 to force clients onto HTTP/2 over TCP, which mitmproxy force clients onto HTTP/2 over TCP, which mitmproxy 12 inspects
inspects normally. natively (verified by spike).
- **Raw TCP / non-HTTP TLS interception.** mitmproxy supports it - **Raw TCP / non-HTTP TLS interception.** mitmproxy supports it
via `--mode reverse:`, not in CONNECT-bump mode. SSH and any via `--mode reverse:`, not in CONNECT-bump mode. SSH and any
future raw-TCP egress route around mitmproxy entirely. future raw-TCP egress route around mitmproxy entirely.
- **Trust-store rewiring for non-Debian agent base images.** The - **Trust-store rewiring for non-Debian agent images.** The
current `Dockerfile` is `node:22-slim` (Debian). If a future base current `Dockerfile` is `node:22-slim` (Debian). If a future base
switches to Red-Hat-family, the `update-ca-certificates` step switches to Red-Hat-family, the `update-ca-certificates` step
becomes `update-ca-trust`. Out of scope until the base changes. becomes `update-ca-trust`. Out of scope until the base changes.
- **Response-body scanning.** Pipelock supports it; we don't wire
it in v1 because the addon would need to ferry the upstream
response back through pipelock's scanner, which the forward-
proxy interface doesn't support cleanly. v2 candidate.
- **MCP scanning on the bumped path.** Only fires on MCP-formatted
JSON-RPC payloads inside tool calls. Not relevant to plain HTTPS
agent traffic and out of v1 scope.
- **Domain-fronting verification.** Once the addon sees the inner
`Host` / `:authority`, comparing it to the outer CONNECT target
catches domain fronting. Worth ~10 lines in the addon, but
defer until the rest of v1 is settled.
- **Host-side openssl / `cryptography` for CA generation.** The
research note's open question on this is resolved by letting
mitmproxy itself generate the CA (it does so on first launch).
No new host-side crypto.
## Scope ## Scope
### In scope ### In scope
- New `claude_bottle/mitmproxy.py` mirroring `claude_bottle/pipelock.py`: - New `claude_bottle/mitmproxy/` package:
config helpers (no backend-specific Docker calls), the - `__init__.py` — backend-agnostic. Constants (sidecar port,
`MitmproxyProxy` abstract class, and the per-bottle CA generation image-pin digest, the in-container addon path), the abstract
helpers. `MitmproxyProxy` class with `prepare` / `start` / `stop` shape
- New `claude_bottle/backend/docker/mitmproxy.py` mirroring mirroring `PipelockProxy`, and the small helper that reads the
`claude_bottle/backend/docker/pipelock.py`: `DockerMitmproxyProxy` CA fingerprint from a PEM file via `openssl x509 -fingerprint`
with the Docker-specific `start` / `stop` lifecycle, the sidecar shelled out.
container name scheme, and the image pin. - `addon.py` — the Python addon mitmproxy loads. ~80150 lines.
- New provisioner: `claude_bottle/backend/docker/provision/ca.py`, For each `request` event: forward the decrypted request to
installing the CA public cert into the agent container at pipelock at `http://claude-bottle-pipelock-<slug>:8888` as a
`/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, running plain HTTP forward-proxy call (absolute-URI form). Inspect
`update-ca-certificates`, and exporting `NODE_EXTRA_CA_CERTS` / pipelock's response. If status is 403 *and* the body matches
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env vars to the agent pipelock's known block-event shape, set the flow's response to
process. The provisioner runs from `BottleBackend.provision` in a 403 with pipelock's body and short-circuit. Otherwise,
the same orchestration as `prompt`, `skills`, `ssh`, `git`. discard pipelock's response (and any wasted upstream-leg
- Per-agent network reshuffle in `DockerBottleBackend.launch`: response from pipelock's forwarder) and let mitmproxy proceed
- internal network is unchanged (mitmproxy + pipelock + agent) to the real upstream.
- agent's `HTTPS_PROXY` / `HTTP_PROXY` change from pointing at the - New `claude_bottle/backend/docker/mitmproxy.py`
pipelock service name to the mitmproxy service name `DockerMitmproxyProxy(MitmproxyProxy)` with the Docker-specific
- mitmproxy's `upstream_proxy` config points at the pipelock start/stop lifecycle. `start(plan)` does `docker create` /
service name on the internal network `docker cp addon.py …` / `docker network connect` / `docker start`,
- `DockerBottlePlan` grows a `mitmproxy_plan` field analogous to the analogous to the existing `DockerPipelockProxy.start`. Injects
existing `proxy_plan` (the pipelock one) so prepare-time state `CLAUDE_BOTTLE_PIPELOCK_URL` into the sidecar env so the addon
rides on the plan. knows where pipelock lives.
- Dry-run preflight (`start --dry-run` text + JSON) renders the - New provisioner `claude_bottle/backend/docker/provision/ca.py`.
mitmproxy line and surfaces the CA fingerprint shown in the Polls mitmproxy for the cert file, copies it through a host
bottle's trust store, so the operator can verify what's been stage dir into the agent, runs `update-ca-certificates` inside
installed. the agent, computes the SHA-256 fingerprint, and prints the
one-line stderr log.
- `BottleBackend.provision_ca(plan, target)` joins the four
existing provisioner methods on the abstract base. Default impl
is no-op so other backends don't break when they don't yet
implement TLS interception.
- `DockerBottlePlan` grows a `mitmproxy_plan` field mirroring the
existing `proxy_plan`.
- Agent container `docker run` invocation:
- `HTTPS_PROXY` / `HTTP_PROXY` change from the pipelock service
name to the mitmproxy service name.
- Three `-e` flags set the CA env trio so they're inherited by
the eventual `docker exec claude` (Docker propagates run-time
env into exec by default; fallback in Q1 below).
- Dry-run preflight rendering of the mitmproxy entry (text + JSON).
JSON gains `egress.mitm: { "enabled": true, "ca_fingerprint": null }`.
- One stderr log line at launch with the CA fingerprint.
- Two new integration tests under `tests/integration/`: - Two new integration tests under `tests/integration/`:
- `test_mitmproxy_blocks_secret_https_post.py` the HTTPS - `test_mitmproxy_blocks_secret_https_post.py` — HTTPS variant
variant of the existing `_blocks_secret_post` test. of the existing block-secret test. Asserts pipelock's body
DLP fires on a credential POST tunneled through CONNECT.
- `test_mitmproxy_allows_normal_https.py` — confirms a plain - `test_mitmproxy_allows_normal_https.py` — confirms a plain
HTTPS GET to a non-credential-bearing path through mitmproxy + HTTPS GET on an allowlisted host returns the upstream response,
pipelock returns the upstream response, asserting no trust / isolating the addon's pass-through path from the block path.
handshake breakage. - Unit tests for the addon's verdict logic (block vs allow on
- Unit tests for the new config builder (mirroring the pipelock status + body shape, edge cases) using mitmproxy's `mitmproxy.test`
YAML unit tests) and for the CA generation helper. flow fixtures. Unit tests for the proxy config builder
(mirroring `tests/unit/test_pipelock_yaml.py`).
### Out of scope ### Out of scope
- The v1 iptables + dnsmasq layer (separate PRD; see - The v1 iptables + dnsmasq layer (separate PRD; see
`network-egress-guard.md`). mitmproxy covers HTTP/HTTPS only. `network-egress-guard.md`). mitmproxy covers HTTP/HTTPS only;
Raw TCP, UDP, ICMP, and direct DNS still need the IP-level layer. raw TCP, UDP, ICMP, and direct DNS still need the IP-level layer.
- Pipelock config changes. Pipelock continues to load the YAML PRD - Pipelock config changes. Pipelock continues to load the YAML
0001 already generates. mitmproxy is opaque to it; pipelock just PRD 0001 generates; the addon talks to it via the existing
sees plain HTTP from a forward-proxy client. forward-proxy interface.
- A bottle-level toggle to skip mitmproxy entirely. v1 always wires - A bottle-level toggle to skip mitmproxy entirely. v1 always
it in. If a use case appears for an unintercepted bottle wires it in.
(e.g. testing pipelock's CONNECT-mode behavior in isolation),
that's a follow-up.
- Pinning-host detection automation. The cost of finding out (per - Pinning-host detection automation. The cost of finding out (per
the research note) is a single 5-minute test before adding a research) is a single 5-minute test before adding a host; it
host; it stays a manual step. stays a manual step.
- Pipelock upstream contributions for an `X-Pipelock-Verdict` header.
Possible follow-up. Until then the addon distinguishes blocks
from passes via status + body fingerprint.
## Proposed Design ## Proposed Design
### Topology ### Topology
``` ```
agent --HTTPS_PROXY--> mitmproxy --HTTP_PROXY--> pipelock --> internet agent --HTTPS_PROXY--> mitmproxy --addon--> pipelock (scan)
(bump TLS) (scan plain) (real TLS) (bump TLS) |
^ | (verdict via status code)
| v
+-- on allow ----- real upstream
(mitmproxy as client)
``` ```
All three containers live on the same per-bottle internal Docker All three containers live on the same per-bottle internal Docker
network. mitmproxy and pipelock are both attached to the per-bottle network. mitmproxy and pipelock are both attached to the per-bottle
egress bridge so they can reach the host network; the agent has no egress bridge for real-internet reach; the agent has no default
default route, exactly as today. route.
Concretely: Concretely:
- `agent` sets `HTTPS_PROXY=http://claude-bottle-mitm-<slug>:<port>`. - Agent sets `HTTPS_PROXY=http://claude-bottle-mitm-<slug>:<port>`.
Currently this points at `claude-bottle-pipelock-<slug>`. The PRD 0001 had this pointing at pipelock; the hostname swap is the
hostname swap is the only agent-side env change. only agent-side env change.
- `mitmproxy` runs with `--mode upstream:http://claude-bottle-pipelock-<slug>:<pipelock-port>` - mitmproxy runs in **`regular`** mode (default; no `--mode` flag).
so its decrypted plaintext is forwarded to pipelock as a regular It bumps every CONNECT, generates fake leaf certs signed by its
upstream forward-proxy request. (Research open question #1 calls own CA, and presents them to the agent.
this out: mitmproxy 10+ documentation says `upstream` mode forwards - The addon, loaded via `mitmdump -s /addon/addon.py`, intercepts
the original request shape; verify against the pinned version at each decrypted `request` event. It forwards the request to
implementation time. If forwarding wraps a new CONNECT, fall back pipelock at `http://claude-bottle-pipelock-<slug>:8888` as a
to `regular` mode with a chained proxy declared in mitmproxy's plain HTTP forward-proxy call (absolute-URI form), so pipelock
config and route plain HTTP to pipelock by hand.) sees the full URL, headers, and body.
- `pipelock` continues to listen on its existing port and receives - The addon inspects pipelock's response. If status is 403 *and*
plain HTTP from mitmproxy. No pipelock config change. the response body matches pipelock's known block-event shape,
the addon sets the mitmproxy flow's response to a 403 with
pipelock's body and short-circuits. Otherwise — including the
case where pipelock's forwarder attempted the upstream and got
a 4xx — the addon discards pipelock's response and lets
mitmproxy proceed to the real upstream.
- mitmproxy completes the outbound TLS to the real destination
using its built-in trust store, just like any other forward
proxy. Pipelock is only involved as a scanner.
The trade-off: pipelock makes a wasted upstream forward attempt
for every allowed request (it tries to forward over plain HTTP to
a real HTTPS-only host, which fails with the upstream's 4xx). This
is benign — the scan completes before forwarding, the verdict
reaches the addon, the upstream-side request happens to die in
pipelock's forwarder rather than reach the agent. Acceptable cost
for the visibility win. A pipelock-side improvement (skip the
forward when the addon only needs the scan verdict) is a future
optimization.
### New components ### New components
Two new modules, matching PRD 0001's split between - `claude_bottle/mitmproxy/__init__.py` — backend-agnostic
backend-agnostic config and backend-specific lifecycle: abstract base, constants, the `openssl x509 -fingerprint` helper.
- `claude_bottle/mitmproxy/addon.py` — the scanning addon.
- **`claude_bottle/mitmproxy.py`** — backend-agnostic. The config Reads pipelock's URL from `CLAUDE_BOTTLE_PIPELOCK_URL` (injected
builder (mitmproxy YAML / TOML — confirm format), the abstract into the sidecar env by the proxy's `start`). For each
`MitmproxyProxy` class with `prepare(...)` writing the config and `request` flow: synchronously POST to pipelock; inspect status
the ephemeral CA into `stage_dir`, the CA generation helper + body; either short-circuit with 403 or fall through.
(RSA-2048 or ECDSA-P256 — pick at impl time, research suggests - `claude_bottle/backend/docker/mitmproxy.py`
ECDSA for cert-gen speed), and constants for the sidecar's `DockerMitmproxyProxy(MitmproxyProxy)` with start/stop, the
internal-network port and image pin. `docker cp` of the addon into the sidecar before `docker start`,
- **`claude_bottle/backend/docker/mitmproxy.py`** — Docker and the `CLAUDE_BOTTLE_PIPELOCK_URL` wiring.
implementation. `DockerMitmproxyProxy(MitmproxyProxy)` with
`start(plan)` doing `docker create` / `docker cp` / `docker
network connect` / `docker start` analogous to
`DockerPipelockProxy.start`. `stop(target)` removes the sidecar
idempotently.
The provisioner that installs the CA cert into the agent's trust
store lives at `claude_bottle/backend/docker/provision/ca.py` and
plugs into the existing `BottleBackend.provision` orchestration. The
abstract `BottleBackend.provision_ca` method joins
`provision_prompt` / `provision_skills` / `provision_ssh` /
`provision_git` on the base class (PRD 0004's pattern), with a
default no-op implementation so other backends don't break when
they don't yet implement it.
### CA lifecycle ### CA lifecycle
Per `tls-mitm-for-pipelock.md` §CA lifecycle: Simplified by letting mitmproxy own the generation:
- **Generation.** Host-side in `MitmproxyProxy.prepare`, written to - **Generation.** mitmproxy generates a fresh CA on startup
`stage_dir/mitm-ca.key` (mode 600) and `stage_dir/mitm-ca.crt` inside its container at `/home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem`
(mode 644). The `.key` is copied into the mitmproxy container at (public) + `mitmproxy-ca.pem` (private). No host-side openssl
start; nothing else touches it. for *generation*; no host-side Python `cryptography` dep.
- **Bottle injection.** `provision_ca` copies only the public - **Volume strategy.** Container-internal only. No host bind
`.crt` into the agent container at mount means the CA dies with the container.
`/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, runs - **Extraction.** `provision_ca` polls (~1s) for the cert file
`update-ca-certificates` as root inside the container, and sets via `docker exec`, then `docker cp` to host stage dir, then
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, `docker cp` into the agent. Host stage dir gets cleaned up by
`SSL_CERT_FILE`, and `REQUESTS_CA_BUNDLE` for the agent process. the existing `start.py` `finally` block.
Belt-and-suspenders because some libraries honor only env vars. - **Bottle install.**
- **Teardown.** The mitmproxy sidecar container is destroyed; the 1. `docker cp <host stage>/mitm-ca.crt agent-<slug>:/usr/local/share/ca-certificates/claude-bottle-mitm.crt`
CA key vanishes with it. Nothing persists on the host outside 2. `docker exec -u 0 agent-<slug> chmod 644 …`
`stage_dir`, which the start command already deletes in its 3. `docker exec -u 0 agent-<slug> update-ca-certificates`
finally block. 4. Three `-e` flags on `docker run` set the env trio
- **Cost.** ECDSA-P256 CA + per-host leaf generation runs in (`NODE_EXTRA_CA_CERTS=…/claude-bottle-mitm.crt`,
milliseconds; the per-bottle Docker pull and network plumbing `SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
dominate startup time. `REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`) so
`docker exec claude` inherits them.
- **Teardown.** Sidecar container removed; CA private key gone.
- **Fingerprint.** Computed post-extraction via shelled-out
`openssl x509 -fingerprint -sha256 -noout`. Logged once to
stderr at launch; never the private key.
### Data model changes ### Data model changes
None in v1. The manifest schema is unchanged. mitmproxy is always None to the manifest schema. The dry-run JSON contract gains a
on for every bottle once this PRD ships. reserved `egress.mitm: { "enabled": true, "ca_fingerprint": null }`
block. Fingerprint is always null at dry-run (CA doesn't exist
yet) but the field is reserved so future schema additions stay
non-breaking.
A future selective-bump knob (per `tls-mitm-for-pipelock.md` open A future selective-bump knob would add
question #5) would land on `bottle.egress.tls_bump_ignore` as a `bottle.egress.tls_bump_ignore: [host, ...]` per the research
list of hostnames. The shape mirrors `egress.allowlist`. Adding it note. Strictly additive when it lands.
later is a strictly additive change.
### Existing code touched ### Existing code touched
- **`claude_bottle/backend/docker/launch.py`** — bring up the - **`claude_bottle/backend/docker/launch.py`** — bring up the
mitmproxy sidecar after the pipelock sidecar but before the agent mitmproxy sidecar between pipelock and the agent. Repoint the
container, repoint the agent's `HTTPS_PROXY` / `HTTP_PROXY` env agent's `HTTPS_PROXY` / `HTTP_PROXY` env flags to mitmproxy.
flags, register an `ExitStack` callback to stop mitmproxy on Register an `ExitStack` callback for mitmproxy teardown. Print
teardown. the CA fingerprint once the sidecar reports ready.
- **`claude_bottle/backend/docker/prepare.py`** — call into - **`claude_bottle/backend/docker/prepare.py`** — call into
`MitmproxyProxy.prepare(...)` alongside the existing `MitmproxyProxy.prepare(...)` alongside `PipelockProxy.prepare(...)`,
`PipelockProxy.prepare(...)`, populate populate `DockerBottlePlan.mitmproxy_plan`.
`DockerBottlePlan.mitmproxy_plan`.
- **`claude_bottle/backend/docker/backend.py`** — add the - **`claude_bottle/backend/docker/backend.py`** — add the
`DockerMitmproxyProxy` instance attribute (`self._mitm`) and `DockerMitmproxyProxy` instance attribute (`self._mitm`) and
thread it through `launch` + cleanup, mirroring the existing thread it through `launch` + cleanup, mirroring `self._proxy`.
`self._proxy` pattern.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new - **`claude_bottle/backend/docker/bottle_plan.py`** — new
`mitmproxy_plan: MitmproxyProxyPlan` field on `mitmproxy_plan` field. `print()` and `to_dict()` learn to
`DockerBottlePlan`. `print()` and `to_dict()` learn to render it. render the mitmproxy entry and the `egress.mitm` JSON block.
- **`claude_bottle/backend/__init__.py`** — abstract - **`claude_bottle/backend/__init__.py`** — abstract
`BottleBackend.provision_ca(plan, target)` joins the other four `BottleBackend.provision_ca` joins the four existing
provisioners. Default impl is a no-op (so a future fly backend provisioners; default no-op.
isn't forced to implement TLS interception in v1).
- **`tests/integration/`** — two new tests as described above. - **`tests/integration/`** — two new tests as described above.
- **`tests/unit/`** — config-builder unit tests; CA-helper unit - **`tests/unit/`** — addon-verdict tests, mitmproxy-config
tests; updated dry-run-plan test pinning the mitmproxy entry. builder tests, dry-run-plan test updated for the new
`egress.mitm` block.
### External dependencies ### External dependencies
- **mitmproxy Docker image** pulled from - **mitmproxy Docker image** pinned by digest on the `12.x` line.
`mitmproxy/mitmproxy@sha256:<digest>`. The digest is pinned in Bumped deliberately, mirroring the pipelock pin. Verified by
`claude_bottle/mitmproxy.py` and bumped deliberately, mirroring spike to speak h2 on both halves.
the pipelock pin. Tag line `mitmproxy/mitmproxy:11.x` per - No new host-side runtimes. mitmproxy generates the CA;
research §Image pin for mitmproxy. fingerprint via the `openssl` already present on Debian / macOS
- No new host-side runtimes. CA generation uses Python's `cryptography` / ubuntu-latest runners.
if it's already a transitive dep; otherwise use `openssl` shelled
out from the host-side prepare step. Decide at impl time after
confirming what's available on the runner without adding deps.
## Open questions ## Open questions
- **mitmproxy upstream-proxy mode mechanics.** Whether `upstream` (rewritten — most of the original v1 questions are now closed by
mode forwards decrypted plaintext to pipelock or re-wraps it in a the walkthrough spikes; what remains is addon-implementation
CONNECT. Documented behavior changed between mitmproxy 8 and 10. specifics worth pinning during the first impl turn.)
Needs verification against the pinned version at impl time. If
`upstream` re-wraps, fall back to `regular` mode plus a chained - **Pipelock's 403-body fingerprint.** The addon needs to
proxy directive routing plain HTTP to pipelock. distinguish a pipelock block (DLP / host) from a real-upstream
- **Pipelock plain-HTTP scanning coverage.** Pipelock's 4xx that pipelock's forwarder relayed back. Most likely shape:
`forward_proxy.enabled: true` accepts both `GET http://…` and pipelock's 403 response carries a JSON body with `event` /
`CONNECT host:443`. Confirm by reading `scanner` fields, whereas a real-upstream 4xx carries whatever
`github.com/luckyPipewrench/pipelock/blob/main/docs/configuration.md` the upstream sent. Pin the exact fingerprint by inspecting
that the full DLP / MCP / subdomain-entropy pipeline runs on the pipelock's actual 403 body bytes at impl time. Long-term
HTTP path; some pipelock layers may be gated on CONNECT only. cleanup: file an upstream feature request for an
- **CA installation in the Anthropic-provided Claude Code image.** `X-Pipelock-Verdict: block` response header so the addon can
The base image determines whether `update-ca-certificates` read a structured signal instead of pattern-matching the body.
(Debian) or `update-ca-trust` (Red Hat) applies. Confirm against - **Docker run env-var inheritance through docker exec.** Plan
the `Dockerfile` before writing the provisioner; v1 assumes assumes `docker run -e VAR=value` propagates to subsequent
Debian (`node:22-slim`). `docker exec` invocations. The Docker docs say so; not yet
- **HTTP/2 ALPN end-to-end.** Node's HTTP client negotiates `h2` empirically pinned on this project's runner setup. Verify in
via ALPN. Confirm the pinned mitmproxy version speaks `h2` to the first impl turn. Trivial fallback: thread the three `-e`
both halves without silently downgrading to `http/1.1`, which flags onto every `DockerBottle.exec*` call.
would be a noticeable performance regression on bulk transfers. - **Addon synchronous-call latency.** The addon makes a sync HTTP
- **Selective-bump policy surface.** Where does the call to pipelock per outbound flow. Pipelock is on the same
"tunnel this hostname blindly" decision live when (not if) a internal Docker network; expected per-call latency is well
pinning host appears? Recommended shape per research: under 10ms. Confirm under the parallel-request load Claude Code
`bottle.egress.tls_bump_ignore: ["example.com"]`, a list of generates (most likely a non-issue — Claude is single-stream
hostnames mitmproxy passes through via `ignore_hosts`. Defer request-wise).
until needed; record the shape so the follow-up is mechanical. - **Addon test fixtures.** mitmproxy ships `mitmproxy.test` with
- **CA generation: Python `cryptography` vs. shelled-out flow fixtures; addons can be unit-tested without a running
`openssl`.** Adding `cryptography` brings a substantial transitive proxy. Confirm the import path and recommended fixture shape at
graph; shelling to `openssl` keeps the host-side prepare step impl time; structure the addon so the verdict-decision is a
dep-light. Decide at impl time based on what's already on the pure function that's trivially testable in isolation from any
runner. Either way, the CA is per-bottle and ephemeral. HTTP I/O.
- **Domain-fronting verification.** Once pipelock sees the inner - **Pipelock allowing the addon's forwarded request through.**
`Host` / `:authority`, comparing it to the outer `CONNECT` target pipelock will see the addon's request as coming from the
catches domain fronting. Whether pipelock has a rule for this or mitmproxy sidecar's IP on the internal network. Confirm
we need to add one is a follow-up; out of scope here. pipelock has no client-IP allowlist that would reject these.
- **Dry-run preflight rendering of the CA.** Show the fingerprint Likely fine — pipelock's `client_ip` is informational in the
but never the private key. Confirm the exact dry-run JSON shape scan event, not a gate.
during implementation; the field set is part of the CLI's user-
facing contract (per PRD 0003 §to_dict notes).
## References ## References
- `docs/research/tls-mitm-for-pipelock.md` — primary source; this - `docs/research/tls-mitm-for-pipelock.md` — primary source. This
PRD implements the recommendation in §Recommendation (Topology A). PRD implements a variant of §Recommendation (Topology A) after
the spike documented under "Open questions" §1 falsified the
`upstream` mode assumption.
- `docs/research/pipelock-assessment.md` §Scope gaps — names the - `docs/research/pipelock-assessment.md` §Scope gaps — names the
TLS-inspection gap closed here. TLS-inspection gap closed here.
- `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` - `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md`
@@ -363,9 +429,9 @@ later is a strictly additive change.
module pattern reused for the new CA provisioner. module pattern reused for the new CA provisioner.
- mitmproxy: <https://mitmproxy.org>, - mitmproxy: <https://mitmproxy.org>,
<https://github.com/mitmproxy/mitmproxy> <https://github.com/mitmproxy/mitmproxy>
- mitmproxy `upstream_proxy` mode: - mitmproxy modes: <https://docs.mitmproxy.org/stable/concepts/modes/>
<https://docs.mitmproxy.org/stable/concepts/modes/#upstream-proxy>
- mitmproxy CA cert installation: - mitmproxy CA cert installation:
<https://docs.mitmproxy.org/stable/concepts/certificates/> <https://docs.mitmproxy.org/stable/concepts/certificates/>
- mitmproxy addon API: <https://docs.mitmproxy.org/stable/addons-overview/>
- Node `NODE_EXTRA_CA_CERTS`: - Node `NODE_EXTRA_CA_CERTS`:
<https://nodejs.org/api/cli.html#node_extra_ca_certsfile> <https://nodejs.org/api/cli.html#node_extra_ca_certsfile>