e579c3d4fd
First step of PRD 0005. Three new files for the mitmproxy-in-front-of-pipelock topology — wiring into the bottle launch comes in the next commit. - claude_bottle/mitmproxy/__init__.py: abstract MitmproxyProxy base + MitmproxyProxyPlan. Mirrors the PipelockProxy shape (prepare / start / stop) and adds extract_ca_cert for the CA cert hand-off into the agent. - claude_bottle/mitmproxy/addon.py: the vendored Python addon mitmproxy loads inside the sidecar. Forwards each decrypted request to pipelock as a plain HTTP forward-proxy call, inspects the response, and short-circuits the flow with 403 on a pipelock block (status=403 + body starts with `blocked: `, pinned empirically against pipelock 2.3.0 in the impl spike). Self-contained — no claude_bottle imports — so it loads in a sidecar that doesn't have claude_bottle on its path. - claude_bottle/backend/docker/mitmproxy.py: DockerMitmproxyProxy with create / cp / network connect / start lifecycle. Pinned to mitmproxy/mitmproxy@sha256:00b77b5d… (multi-arch manifest for v12.2.3). - tests/unit/test_mitmproxy_verdict.py: pins the verdict fingerprint so a pipelock-side body shape change breaks loudly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
82 lines
3.3 KiB
Python
82 lines
3.3 KiB
Python
"""mitmproxy TLS-interception sidecar for the per-bottle egress
|
|
topology (PRD 0005).
|
|
|
|
Sits in front of pipelock on the bottle's egress path so pipelock's
|
|
body / header / URL DLP scanners see plaintext for HTTPS targets.
|
|
The sidecar runs in mitmproxy's `regular` mode and loads the
|
|
vendored addon at `addon.py`; the addon forwards each decrypted
|
|
request to pipelock as a plain HTTP forward-proxy call and gates
|
|
the mitmproxy flow on pipelock's verdict.
|
|
|
|
This module is platform-agnostic: it owns the abstract proxy
|
|
lifecycle (prepare / start / stop / extract_ca_cert). The
|
|
Docker-specific lifecycle lives in
|
|
`claude_bottle/backend/docker/mitmproxy.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MitmproxyProxyPlan:
|
|
"""Output of MitmproxyProxy.prepare; consumed by .start when the
|
|
sidecar needs to be brought up.
|
|
|
|
`addon_src` is the host-side path to the vendored addon.py,
|
|
resolved at prepare time. `slug` is the per-agent identifier
|
|
used as the suffix in every per-bottle resource name. The
|
|
network fields default to empty and are populated by the
|
|
backend's launch step (via dataclasses.replace) once those
|
|
networks have actually been created — same pattern as
|
|
PipelockProxyPlan."""
|
|
|
|
addon_src: Path
|
|
slug: str
|
|
internal_network: str = ""
|
|
egress_network: str = ""
|
|
|
|
|
|
class MitmproxyProxy(ABC):
|
|
"""The mitmproxy TLS-interception sidecar. The proxy-config + addon
|
|
bundling are platform-agnostic; the sidecar's start/stop lifecycle
|
|
and the CA extraction step are backend-specific and live on
|
|
concrete subclasses."""
|
|
|
|
def prepare(self, slug: str) -> MitmproxyProxyPlan:
|
|
"""Locate the vendored addon source and return the start
|
|
plan. The addon is checked into the project and identical
|
|
across bottles; per-bottle wiring (pipelock URL) is injected
|
|
via env vars at start time, not via a generated config."""
|
|
addon_src = Path(__file__).resolve().parent / "addon.py"
|
|
if not addon_src.is_file():
|
|
raise FileNotFoundError(
|
|
f"mitmproxy addon not found at {addon_src}; the "
|
|
f"package was installed incompletely"
|
|
)
|
|
return MitmproxyProxyPlan(addon_src=addon_src, slug=slug)
|
|
|
|
@abstractmethod
|
|
def start(self, plan: MitmproxyProxyPlan, *, pipelock_url: str) -> str:
|
|
"""Bring up the mitmproxy sidecar according to `plan`.
|
|
`pipelock_url` is injected into the sidecar's env (as
|
|
CLAUDE_BOTTLE_PIPELOCK_URL) so the addon knows where to
|
|
scan. Returns the proxy_target string identifying the
|
|
running sidecar — the same value to pass to `.stop` and
|
|
`.extract_ca_cert`."""
|
|
|
|
@abstractmethod
|
|
def stop(self, proxy_target: str) -> None:
|
|
"""Tear down the sidecar identified by `proxy_target`.
|
|
Idempotent: a missing target is success."""
|
|
|
|
@abstractmethod
|
|
def extract_ca_cert(self, proxy_target: str, dest_path: Path) -> None:
|
|
"""Copy the public CA cert from the running sidecar to
|
|
`dest_path` on the host. Polls the sidecar for the cert
|
|
file to appear (mitmproxy generates the CA on first launch).
|
|
The private key never leaves the sidecar."""
|