PRD 0005: mitmproxy TLS interception for pipelock content scanning #8
@@ -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. ~80–150 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user