Files
bot-bottle/claude_bottle/backend/docker/mitmproxy.py
T
didericis e579c3d4fd feat(mitmproxy): vendor the addon and Docker sidecar lifecycle
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>
2026-05-12 13:32:36 -04:00

179 lines
6.5 KiB
Python

"""DockerMitmproxyProxy — the Docker-specific lifecycle for the
mitmproxy sidecar. Inherits the addon-bundling from MitmproxyProxy.
The sidecar runs `mitmdump -s /addon/addon.py`, listens on
MITMPROXY_PORT inside the per-bottle internal network, and generates
its own ephemeral CA on first launch (extracted by provision_ca,
installed into the agent's trust store)."""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
from ...log import die, info, warn
from ...mitmproxy import MitmproxyProxy, MitmproxyProxyPlan
# mitmproxy/mitmproxy:12.2.3 (mitmproxy v12 release line). The digest
# is the multi-arch image index — pulls resolve to the right per-arch
# child digest. Bumped deliberately; see PRD 0005.
MITMPROXY_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_MITMPROXY_IMAGE",
"mitmproxy/mitmproxy@sha256:00b77b5d8804c8ad18cb6caefbf9d5849e895e8986c5ce011f4ae30f4385962f",
)
# Listening port for mitmproxy's forward proxy (agent-facing).
MITMPROXY_PORT = os.environ.get("CLAUDE_BOTTLE_MITMPROXY_PORT", "8080")
# Path inside the sidecar where the addon is dropped by docker cp.
MITMPROXY_ADDON_PATH = "/addon/addon.py"
# Path inside the sidecar where mitmproxy generates its CA.
_CA_PATH_IN_SIDECAR = "/home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem"
def mitmproxy_container_name(slug: str) -> str:
return f"claude-bottle-mitm-{slug}"
def mitmproxy_proxy_url(slug: str) -> str:
return f"http://{mitmproxy_container_name(slug)}:{MITMPROXY_PORT}"
class DockerMitmproxyProxy(MitmproxyProxy):
"""Brings the mitmproxy sidecar up and down via Docker."""
def start(self, plan: MitmproxyProxyPlan, *, pipelock_url: str) -> str:
"""Boot the mitmproxy sidecar:
1. `docker create` on the internal network with mitmdump
argv: `--listen-port <port> -s <addon path>` plus the
pipelock URL injected as an env var.
2. `docker cp` the vendored addon to the sidecar.
3. Attach to the per-agent egress network so mitmproxy
can reach real upstreams.
4. `docker start`.
Returns the container name (the proxy_target passed to .stop
and .extract_ca_cert)."""
name = mitmproxy_container_name(plan.slug)
if not plan.addon_src.is_file():
die(f"mitmproxy addon not found at {plan.addon_src}")
info(f"starting mitmproxy sidecar {name} on network {plan.internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", plan.internal_network,
"-e", f"CLAUDE_BOTTLE_PIPELOCK_URL={pipelock_url}",
MITMPROXY_IMAGE,
"mitmdump",
"--listen-port", MITMPROXY_PORT,
"-s", MITMPROXY_ADDON_PATH,
]
if subprocess.run(
create_args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
die(f"failed to create mitmproxy sidecar {name}")
cp_result = subprocess.run(
["docker", "cp", str(plan.addon_src), f"{name}:{MITMPROXY_ADDON_PATH}"],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(f"failed to copy mitmproxy addon into {name}: {cp_result.stderr.strip()}")
if subprocess.run(
["docker", "network", "connect", plan.egress_network, name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(f"failed to attach mitmproxy sidecar {name} to egress "
f"network {plan.egress_network}")
if subprocess.run(
["docker", "start", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(f"failed to start mitmproxy sidecar {name}")
return name
def stop(self, proxy_target: str) -> None:
"""Idempotent: missing container is success. Mirrors
DockerPipelockProxy.stop."""
if subprocess.run(
["docker", "inspect", proxy_target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0:
if subprocess.run(
["docker", "rm", "-f", proxy_target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
warn(
f"failed to remove mitmproxy sidecar {proxy_target}; "
f"clean up with 'docker rm -f {proxy_target}'"
)
def extract_ca_cert(self, proxy_target: str, dest_path: Path) -> None:
"""Poll the running sidecar for the CA cert (mitmproxy
generates it on first launch, typically <1s after start),
then `docker cp` the public half to `dest_path`. The private
key never leaves the container."""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
check = subprocess.run(
["docker", "exec", proxy_target, "test", "-f", _CA_PATH_IN_SIDECAR],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if check.returncode == 0:
break
time.sleep(0.5)
else:
die(f"mitmproxy CA cert did not appear at {_CA_PATH_IN_SIDECAR} "
f"after 15s — sidecar {proxy_target} may have failed to start")
cp_result = subprocess.run(
["docker", "cp", f"{proxy_target}:{_CA_PATH_IN_SIDECAR}", str(dest_path)],
capture_output=True,
text=True,
check=False,
)
if cp_result.returncode != 0:
die(f"failed to extract mitmproxy CA cert from {proxy_target}: "
f"{cp_result.stderr.strip()}")