Files
bot-bottle/claude_bottle/backend/docker/egress_proxy.py
T
didericis f807ed1149
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m5s
fix(egress-proxy): force traffic through pipelock + block unallowlisted hosts
Two issues stopping the bottle's egress allowlist from being
enforced:

1. mitmproxy was bypassing pipelock. We set HTTPS_PROXY=pipelock
   in the egress-proxy container's env, but mitmproxy is a proxy
   *server* — it does NOT honor HTTP(S)_PROXY env vars on its
   outbound side the way HTTP-client libraries do. All
   post-MITM traffic was going direct to the upstream, never
   touching pipelock's hostname allowlist or DLP scanner.

   Fix: use mitmproxy's `--mode upstream:URL` flag. The Dockerfile
   entrypoint now reads a new `EGRESS_PROXY_UPSTREAM_PROXY` env
   (set by `DockerEgressProxy.start` to the pipelock URL when
   pipelock is in the topology) and switches mitmdump to
   upstream-proxy mode. Standalone runs of the image without the
   env still get `--mode regular@9099` direct-to-upstream — useful
   for unit-test boots. Confirmed in the boot log: "HTTP(S) proxy
   (upstream mode) listening at *:9099."

2. egress-proxy was forwarding unrecognized hosts. The addon's
   `decide()` returned `Decision(action="forward")` whenever no
   route matched the request host, deferring to pipelock to gate.
   With #1 broken pipelock wasn't gating either; even with #1
   fixed, defense-in-depth wants both layers enforcing.

   Fix: no-route-match → 403 with a "host not in allowlist"
   reason. The egress allowlist is now strictly the set of hosts
   declared in `bottle.egress_proxy.routes`; bare-pass routes
   (host with no auth, no path_allowlist) cover the passthrough
   case for hosts that just need reach. path_allowlist enforcement
   on matched routes is unchanged.

Test updated: `test_no_matching_route_forwards` →
`test_no_matching_route_blocks`. 364 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:38:18 -04:00

370 lines
15 KiB
Python

"""DockerEgressProxy — the Docker-specific lifecycle for the
per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform-
agnostic prepare step (route lift + routes.yaml render + token-env
map derivation) from `EgressProxy`.
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
block remediation flow (PRD 0014)."""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from ...egress_proxy import (
EGRESS_PROXY_HOSTNAME,
EGRESS_PROXY_ROUTES_IN_CONTAINER,
EgressProxy,
EgressProxyPlan,
egress_proxy_resolve_token_values,
)
from ...log import die, info, warn
from . import util as docker_mod
EGRESS_PROXY_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE",
"claude-bottle-egress-proxy:latest",
)
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy"
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
# resolves to `http://egress-proxy:<port>`.
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099"))
# In-container path for mitmproxy's CA. The format is a single PEM
# file holding BOTH the cert and the private key, concatenated. The
# upstream-trust CA (pipelock's, so egress-proxy trusts the upstream
# leg) is a separate file because pipelock keeps a different CA on
# its end.
EGRESS_PROXY_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER = (
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
)
# Repo root, for `docker build` context. Resolved from this file's
# location: claude_bottle/backend/docker/egress_proxy.py → repo root.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def egress_proxy_container_name(slug: str) -> str:
return f"claude-bottle-egress-proxy-{slug}"
def egress_proxy_url() -> str:
"""Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable
across bottles because the sidecar attaches `--network-alias
egress-proxy` on the internal network; the container name (which
carries the slug) is not referenced by agent-side config."""
return f"http://{EGRESS_PROXY_HOSTNAME}:{EGRESS_PROXY_PORT}"
def build_egress_proxy_image() -> None:
"""Build the egress-proxy image from `Dockerfile.egress-proxy`.
Called by `DockerEgressProxy.start`; exposed at module level so
integration tests can build it without running the full launch
pipeline."""
docker_mod.build_image(
EGRESS_PROXY_IMAGE, _REPO_DIR, dockerfile=EGRESS_PROXY_DOCKERFILE,
)
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Mint the per-bottle egress-proxy MITM CA via host `openssl req`.
Returns `(mitmproxy_pem, cert_only_pem)`:
- `mitmproxy_pem` is the single-PEM concat (cert + key)
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
- `cert_only_pem` is the cert alone — installed into the agent's
trust store by `provision_ca` so the agent trusts the bumped
CONNECT cert egress-proxy presents.
Why openssl req (not the pipelock binary's `tls init`):
pipelock's CA generator stamps a non-standard `Subject Key
Identifier` on the CA (random rather than SHA-1 of the pubkey).
mitmproxy computes the `Authority Key Identifier` on each leaf
it mints as SHA-1(issuer's pubkey). openssl's chain validator
uses the leaf's AKI to find the issuer cert by SKI; pipelock's
SKI doesn't match → openssl reports "unable to get local issuer
certificate" even though the CA is right there in the trust
store. openssl req's `subjectKeyIdentifier=hash` extension uses
SHA-1(pubkey), matching mitmproxy's computation.
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 644 —
`docker cp` preserves the mode into the container, where the
mitmproxy user (uid 1000) reads them; the host stage_dir is
mode 700 so the private key isn't world-exposed)."""
work = stage_dir / "egress-proxy-ca"
work.mkdir(exist_ok=True)
key_path = work / "ca-key.pem"
cert_path = work / "ca.pem"
cnf_path = work / "ca.cnf"
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
# config matches RSA CAs without surprise), and openssl req's
# default behavior here is exactly what we want.
keygen = subprocess.run(
["openssl", "genrsa", "-out", str(key_path), "2048"],
capture_output=True, text=True, check=False,
)
if keygen.returncode != 0:
die(f"egress-proxy ca keygen failed: {keygen.stderr.strip()}")
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
# leaves it later mints. Without this, chain validation breaks
# despite the CA being present in the trust store.
cnf_path.write_text(
"[req]\n"
"distinguished_name = req_dn\n"
"prompt = no\n"
"x509_extensions = v3_ca\n"
"\n"
"[req_dn]\n"
"O = claude-bottle\n"
"CN = claude-bottle egress-proxy CA\n"
"\n"
"[v3_ca]\n"
"basicConstraints = critical, CA:TRUE\n"
"keyUsage = critical, keyCertSign, cRLSign\n"
"subjectKeyIdentifier = hash\n"
)
cnf_path.chmod(0o644)
req = subprocess.run(
["openssl", "req", "-x509", "-new", "-nodes",
"-key", str(key_path),
"-sha256", "-days", "365",
"-config", str(cnf_path),
"-out", str(cert_path)],
capture_output=True, text=True, check=False,
)
if req.returncode != 0:
die(f"egress-proxy ca cert generation failed: {req.stderr.strip()}")
cert_path.chmod(0o644)
# mitmproxy reads cert + key from a single concatenated PEM file.
mitm = work / "mitmproxy-ca.pem"
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
mitm.chmod(0o644)
return (mitm, cert_path)
class DockerEgressProxy(EgressProxy):
"""Brings the egress-proxy sidecar up and down via Docker."""
def start(self, plan: EgressProxyPlan) -> str:
"""Boot the egress-proxy sidecar:
1. Resolve every host TokenRef env var into a concrete
value. Fails early if any are unset.
2. Build the egress-proxy image (no-op when cache is hot).
3. `docker create` on the internal network with
`--network-alias egress-proxy`, the `HTTPS_PROXY=pipelock`
env (so the upstream leg traverses pipelock), the
`EGRESS_PROXY_UPSTREAM_CA` env pointing at the in-container
pipelock-CA path (so mitmproxy trusts pipelock's MITM),
and one `-e EGRESS_PROXY_TOKEN_N` flag per token slot.
Secret values arrive via subprocess env, never argv.
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
concat), and pipelock CA (cert only) into the container.
5. Attach to the per-agent egress network so the proxy can
reach pipelock.
6. `docker start`.
Returns the container name (the target passed to `.stop`)."""
if not plan.routes:
die("DockerEgressProxy.start called with no routes; caller should skip")
if not plan.internal_network or not plan.egress_network:
die(
"DockerEgressProxy.start: internal_network / egress_network must be "
"populated on the plan before start"
)
if not plan.routes_path.is_file():
die(
f"egress-proxy routes file missing at {plan.routes_path}; "
f"EgressProxy.prepare must run first"
)
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
die(
f"DockerEgressProxy.start: mitmproxy CA missing at "
f"{plan.mitmproxy_ca_host_path}; egress_proxy_tls_init must run first"
)
# pipelock CA + upstream proxy URL: both must be present (we
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
# upstream leg) or both absent (egress-proxy goes direct, for
# standalone integration tests that don't bring pipelock up).
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
if route_via_pipelock:
if not plan.pipelock_proxy_url:
die(
"DockerEgressProxy.start: pipelock_ca_host_path is set but "
"pipelock_proxy_url is empty; populate both or neither."
)
if not plan.pipelock_ca_host_path.is_file():
die(
f"DockerEgressProxy.start: pipelock CA missing at "
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
)
# Resolve host env vars into concrete values. Must happen at
# start time (not prepare) — the values flow into the sidecar's
# environ via subprocess env. The plan never holds them.
token_values = egress_proxy_resolve_token_values(
plan.token_env_map, dict(os.environ),
)
build_egress_proxy_image()
name = egress_proxy_container_name(plan.slug)
info(f"starting egress-proxy sidecar {name} on network {plan.internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", plan.internal_network,
"--network-alias", EGRESS_PROXY_HOSTNAME,
]
if route_via_pipelock:
# Route egress-proxy's outbound traffic through pipelock
# so the egress allowlist + DLP body scanner apply to
# the egress-proxy → upstream leg. Pipelock MITMs each
# handshake with its per-bottle CA, which is docker-cp'd
# in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA
# env (entrypoint conditionally adds the matching --set
# flag).
#
# EGRESS_PROXY_UPSTREAM_PROXY is the mechanism: mitmproxy
# does NOT honor HTTPS_PROXY env vars on its outbound
# side (it's a proxy server, not a client). The
# entrypoint reads this env and switches mitmdump to
# `--mode upstream:<URL>` so all post-MITM traffic
# CONNECTs to pipelock instead of going direct. The
# HTTPS/HTTP_PROXY env vars below are kept for any
# bundled client libraries (mitmproxy plugin requests,
# etc.) that might honor them — harmless if ignored.
create_args.extend([
"-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1",
"-e", f"EGRESS_PROXY_UPSTREAM_CA={EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER}",
])
# One -e flag per token slot; values arrive via subprocess env.
# docker create with `-e NAME` (no =VALUE) reads NAME from the
# current process env at create time. We pass `env=child_env`
# to subprocess.run so the value comes from token_values, not
# the host's os.environ directly — keeps the resolver in one
# place and lets egress_proxy_resolve_token_values surface
# missing-env errors with a clear hint.
for token_env in sorted(plan.token_env_map.keys()):
create_args.extend(["-e", token_env])
create_args.append(EGRESS_PROXY_IMAGE)
child_env: dict[str, str] = {**os.environ, **token_values}
create_result = subprocess.run(
create_args, capture_output=True, text=True, env=child_env, check=False,
)
if create_result.returncode != 0:
die(
f"failed to create egress-proxy sidecar {name}: "
f"{create_result.stderr.strip()}"
)
# routes.yaml also lands inside the container; bump to 644
# for the same reason as the CAs — mitmproxy user (uid 1000)
# has to read it. Host stage_dir is mode 700 so the file
# isn't actually exposed to other host users.
plan.routes_path.chmod(0o644)
# Pipelock CA: pipelock itself runs as root so its in-pipelock
# copy doesn't care about mode, but egress-proxy's mitmproxy
# user does. Bump on the host so docker cp into egress-proxy
# carries world-readable.
if route_via_pipelock:
plan.pipelock_ca_host_path.chmod(0o644)
cps: list[tuple[Path, str, str]] = [
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"),
]
if route_via_pipelock:
cps.append((
plan.pipelock_ca_host_path,
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER,
"pipelock CA",
))
for src, dst, label in cps:
cp_result = subprocess.run(
["docker", "cp", str(src), f"{name}:{dst}"],
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 {label} into {name}: "
f"{cp_result.stderr.strip()}"
)
connect_result = subprocess.run(
["docker", "network", "connect", plan.egress_network, name],
capture_output=True, text=True, check=False,
)
if connect_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to attach egress-proxy sidecar {name} to egress network "
f"{plan.egress_network}: {connect_result.stderr.strip()}"
)
start_result = subprocess.run(
["docker", "start", name], capture_output=True, text=True, check=False,
)
if start_result.returncode != 0:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
die(
f"failed to start egress-proxy sidecar {name}: "
f"{start_result.stderr.strip()}"
)
return name
def stop(self, target: str) -> None:
"""Idempotent: missing container is success. `target` is the
container name returned by `.start`."""
if subprocess.run(
["docker", "inspect", target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0:
if subprocess.run(
["docker", "rm", "-f", target],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode != 0:
warn(
f"failed to remove egress-proxy sidecar {target}; "
f"clean up with 'docker rm -f {target}'"
)