Flip Status: Draft -> Active for the 23 PRDs whose work has shipped to main (including 0027, now that PR #95 has merged). Leaves the terminal-status PRDs unchanged: 0007 and 0010 (Superseded) and 0014 (Retargeted) were replaced, not shipped as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
PRD 0006: pipelock native TLS interception
- Status: Active
- Author: didericis
- Created: 2026-05-12
Summary
Turn on pipelock's built-in tls_interception so its DLP / URL /
header / MCP scanners fire on the plaintext of HTTPS requests
instead of only the outer CONNECT hostname. Pipelock generates a
per-bottle ephemeral CA at launch (pipelock tls init); the
public cert is installed into the agent container's trust store
and the private key dies with the sidecar on teardown. The
existing per-agent sidecar topology from PRD 0001 is otherwise
unchanged — one container, no addon, no second proxy.
This supersedes the closed PR #8 / branch mitmproxy-tls-interception,
which built a mitmproxy + addon chain on the (falsified) premise
that pipelock could not MITM. Empirical proof from the impl-time
spike: with tls_interception: { enabled: true, ca_cert, ca_key }
in the pipelock config, pipelock answered a credential POST over
HTTPS with STATUS=403 / body: blocked: request body contains secret: GitHub Token and emitted both
scanner:"tls_intercept" and scanner:"body_dlp" events.
Problem
PRD 0001 wired pipelock onto every bottle's egress, but pipelock
ran with its default tls_interception.enabled: false. The agent
container's only egress route is pipelock, but pipelock only saw
CONNECT hostnames and the encrypted bytes inside the tunnel.
Pipelock's headline scanners — request body DLP (48 credential
patterns), header DLP, URL DLP, subdomain entropy, MCP scanning,
response-body scanning — all need plaintext to fire. Against the
HTTPS-only hosts in DEFAULT_ALLOWLIST (api.anthropic.com,
raw.githubusercontent.com, etc.) they are effectively disabled.
The existing tests/integration/test_pipelock_blocks_secret_post
test only fires because it forces the agent to send plain HTTP
through pipelock's forward-proxy mode. Real Claude Code traffic
uses HTTPS via CONNECT and slips past the scanner.
Goals / Success Criteria
The feature works when all of the following are observable:
- A Node / curl request from inside a launched bottle to a
CONNECT-bumped HTTPS host (e.g.
https://api.anthropic.com/dlp-probe) carrying a pipelock-recognized credential pattern in the body returns 403 from pipelock with the documentedblocked: request body contains secret: …body. Pipelock'sbody_dlpevent fires on the decrypted request. - A clean HTTPS GET from inside the bottle to an allowlisted host
(e.g.
https://raw.githubusercontent.com/...) returns the real upstream response — TLS interception doesn't break legitimate traffic. - The agent's TLS library trusts pipelock's bumped leaf certs (per the bottle's installed CA); no TLS-trust errors.
- Claude Code reaches
api.anthropic.comend-to-end through the bottle and completes a chat round-trip.
The feature is done when all of the following ship:
pipelock_build_config/pipelock_render_yamlemit atls_interceptionblock withenabled: trueand the per-bottle CA cert/key paths. The defaults (cert_ttl: 24h,cert_cache_size: 10000,passthrough_domains: []) are kept; onlyenabledand the cert paths are populated.- The prepare step generates a per-bottle CA via
pipelock tls initin a one-shot container, writesca.pemandca-key.pemtostage_dir. Paths land on theDockerBottlePlan. DockerPipelockProxy.startmounts the stage dir into the sidecar (read-only) so the running pipelock can read its CA.BottleBackend.provision_ca(new) copies the CA public cert into the agent at/usr/local/share/ca-certificates/bot-bottle-mitm.crt, runsupdate-ca-certificates, and sets theNODE_EXTRA_CA_CERTS/SSL_CERT_FILE/REQUESTS_CA_BUNDLEenv trio on the agent container's runtime env. Default no-op on the abstract base so other backends aren't forced to implement.- The launch step prints a one-line stderr log with the SHA-256
fingerprint of the public CA cert (computed via stdlib
ssl.PEM_cert_to_DER_cert+hashlib.sha256). - On bottle teardown the sidecar is removed and the CA private key is gone with it.
- Two new integration tests under
tests/integration/:- HTTPS variant of the credential-post block test (proves the
tls_intercept+body_dlpchain fires end-to-end). - Clean HTTPS GET test (proves the allow path doesn't break TLS trust and returns real upstream content).
- HTTPS variant of the credential-post block test (proves the
- The dry-run preflight (
start --dry-run) renders the new TLS layer. Text: one line under the egress summary. JSON: a reservedegress.tls_interception: { enabled: true, ca_fingerprint: null }block — fingerprint is null at dry-run because the CA only exists after launch.
Non-goals
- A second proxy in the chain. Pipelock does the bumping natively; the mitmproxy approach was based on a wrong premise (closed PR #8).
- Per-bottle override to disable interception. v1 always enables
tls_interception. The pipelock-sidepassthrough_domainslist is the right knob if a future allowlisted host turns out to pin certs — exposing it through the manifest is a follow-up. - A long-lived / shared CA across bottles. Each bottle gets a
fresh CA generated by
pipelock tls initand destroyed with the sidecar. - Tuning
cert_ttl,cert_cache_size,max_response_bytes,cross_request_detection, or other pipelock advanced features. Defaults frompipelock generate config --preset strictare fine for v1. - Trust-store paths for non-Debian agent images.
node:22-slimis Debian;update-ca-certificatesis the right command. A Red-Hat-family base would needupdate-ca-trust. - HTTP/3 / QUIC. Pipelock's interception is HTTP/HTTPS-over-TLS; UDP/443 still needs an iptables layer (separate PRD).
Scope
In scope
bot_bottle/pipelock.pychanges:- Extend
pipelock_build_configto includetls_interception: { enabled: true, ca_cert: <path>, ca_key: <path> }. Paths are populated from the plan; the function's signature grows acert_path/key_pathpair or reads them offBottleonce they're stored. - Extend
pipelock_render_yamlto emit the new block.
- Extend
bot_bottle/backend/docker/pipelock.pychanges:- New helper
pipelock_tls_init(stage_dir)runs the upstream image as a one-shot:docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init, leavingca.pemandca-key.pemunderstage_dir. The host file owner is whatever the upstream image's user is; the sidecar mount is read-only so this is fine. DockerPipelockProxy.startdocker cps the CA cert + key into the sidecar at/etc/pipelock/ca.pemand/etc/pipelock/ca-key.pembetweendocker createanddocker start, mirroring the existing pattern for the YAML config. If pipelock's image runs as non-root, adocker exec -u 0 chown pipelock:pipelock /etc/pipelock/ca*.pemlands between thecpand thestart.
- New helper
bot_bottle/backend/__init__.py: new abstract methodprovision_ca(plan, target)onBottleBackend, default no-op.BottleBackend.provisionorchestratesca → prompt → skills → ssh → git.bot_bottle/backend/docker/provision/ca.py(new):- Reads the cert from
stage_dir(already written by prepare). docker cpinto the agent.docker exec -u 0 ... chmod 644 ...+update-ca-certificates.- Computes the SHA-256 fingerprint with stdlib (
ssl+hashlib), emits one stderr log line.
- Reads the cert from
bot_bottle/backend/docker/launch.py:- Three new
-eflags on the agent'sdocker run:NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm.crt,SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt,REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. HTTPS_PROXY/HTTP_PROXYcontinue to point at pipelock (unchanged from PRD 0001 — the mitmproxy detour in PR #8 is abandoned).
- Three new
bot_bottle/backend/docker/bottle_plan.py:- One new
info(...)line inprint()noting TLS interception is on. to_dict()gains anegress.tls_interception: { enabled: true, ca_fingerprint: null }block. Reserved for future population.
- One new
bot_bottle/backend/docker/prepare.py: callpipelock_tls_init(stage_dir)and write the resolved cert/key paths onto the plan (either on the existingproxy_planfield or on the parentDockerBottlePlan).- Tests:
tests/integration/test_pipelock_blocks_secret_https_post.py(new) — HTTPS variant of the existing block test.tests/integration/test_pipelock_allows_normal_https.py(new) — clean HTTPS GET succeeds.tests/unit/test_pipelock_yaml.pyupdated to assert the newtls_interceptionblock in the rendered config.tests/integration/test_dry_run_plan.pyupdated to assert the newegress.tls_interceptionJSON block.
Out of scope
- Modifying pipelock itself. We're using existing config knobs.
- A manifest field to disable / customize interception per bottle. Doable but premature.
- Wiring
passthrough_domains. The default[]is correct for v1; add the manifest field when a pinning host shows up. The shape is pre-recorded so the follow-up is mechanical:bottle.egress.tls_passthrough_domains: [host, ...], mirroring the existingegress.allowlist. cross_request_detection,entropy_budget,fragment_reassembly,reverse_proxy,scan_api— features pipelock exposes but we don't need for the body-DLP gap.
Proposed Design
Topology
agent --HTTPS_PROXY--> pipelock --[bumps TLS]--> internet
(sees plaintext: URL, headers, body)
Same single-sidecar shape as PRD 0001. The only addition is
tls_interception in pipelock's config plus the per-bottle CA
generated at prepare time.
CA lifecycle
- Generation. Host-side, at prepare time, via a one-shot
docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init. Output:<stage>/ca.pem+<stage>/ca-key.pem, mode 600. - Sidecar install.
DockerPipelockProxy.startdocker cps the CA cert + key into the sidecar at/etc/pipelock/ca.pemand/etc/pipelock/ca-key.pembetweendocker createanddocker start. Same pattern the proxy already uses for the YAML config — no bind-mount, no UID/permission concern from the one-shot generation step. The rendered YAML references the in-container paths. - Bottle install.
provision_ca(Docker impl) doesdocker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/bot-bottle-mitm.crt, thenupdate-ca-certificates. The CA env trio is set atdocker run -etime (Docker propagates run-time env intodocker exec). - Per-bottle ephemerality. Enforced by regenerating per
launch, not by validity windows. Pipelock's defaults
(
cert_ttl: 24hfor leaves,--validity 87600hfor the CA) are fine — the CA lives only as long as the sidecar, which is the bottle's lifetime. - Teardown. Sidecar removed via
ExitStackcallback, then the launch context manager's outerfinallyshutil.rmtreesstage_dir. CA dies with both, in that order, so the sidecar is never reading a deleted mount on shutdown. - Fingerprint. Computed via stdlib in
provision_caand logged once to stderr (bot-bottle: mitm ca fingerprint: sha256:<hex>…). The private key never appears in any log.
Data model changes
None to the manifest schema. The dry-run JSON contract grows a
reserved egress.tls_interception block; the fingerprint is
always null at dry-run because the CA doesn't exist yet.
Existing code touched
Surgical, all on the existing pipelock path:
bot_bottle/pipelock.py— config builder + YAML renderer.bot_bottle/backend/__init__.py— abstractprovision_ca.bot_bottle/backend/docker/pipelock.py—tls inithelper, sidecar volume mount.bot_bottle/backend/docker/prepare.py— CA paths on plan.bot_bottle/backend/docker/launch.py— CA env trio on agent.bot_bottle/backend/docker/backend.py—provision_cadispatch + threadself._proxythrough prepare/launch unchanged shape.bot_bottle/backend/docker/bottle_plan.py— preflight rendering.bot_bottle/backend/docker/provision/ca.py(new).
Net diff is meaningfully smaller than PR #8 because pipelock already does the work — no addon, no second sidecar, no second backend module.
External dependencies
- Pipelock image — unchanged pin from PRD 0001
(
ghcr.io/luckypipewrench/pipelock@sha256:3b1a3941…, matching pipelock v2.3.0). No new image dependency. - No host-side crypto deps. CA generation uses the pipelock
image's own
tls initcommand in a one-shot container. Fingerprint uses Python stdlibssl+hashlib.
References
docs/research/pipelock-assessment.md(now corrected) — pipelock capability assessment including thetls_interceptionblock.docs/prds/0001-per-agent-egress-proxy-via-pipelock.md— egress-proxy baseline this PRD extends.docs/prds/0003-bottle-backend-abstraction.md— backend ABC contract this PRD adds aprovision_camethod to.docs/prds/0004-split-out-provisioners.md— per-provisioner module pattern reused for the new CA provisioner.- Pipelock
tlsCLI (in-image help):pipelock tls init / install-ca / show-ca. - Closed PR #8 — earlier mitmproxy-based design built on the falsified "pipelock can't MITM" premise; archived for context.