70f773ac61
Hard cutover. cred-proxy is deleted; egress-proxy is now the agent's
HTTP_PROXY (when routes are declared) with pipelock on its outbound
leg. Two per-bottle CAs are minted: egress-proxy's (agent trust
store) and pipelock's (egress-proxy's outbound trust store).
Manifest:
- `bottle.cred_proxy` → hard error with a migration recipe.
- `bottle.egress_proxy` is the new shape (PRD 0017 chunk 1).
- CredProxy* types + role validators removed.
Wiring:
- launch.py: `egress_proxy_tls_init` mints the egress-proxy CA
(cert+key concat for mitmproxy + cert-only for agent trust);
`DockerEgressProxy.start` docker-cps both CAs in, sets
`HTTPS_PROXY=pipelock` + `EGRESS_PROXY_UPSTREAM_CA` so mitmdump
trusts pipelock's MITM. Agent's HTTP_PROXY points at
egress-proxy when routes exist, else falls back to pipelock
(no-routes bottles unchanged).
- prepare.py / backend.py: `cred_proxy` arg → `egress_proxy`;
sidecar-orphan probe + plan field + dashboard view all
renamed.
- provision_ca: selects the egress-proxy CA when present, else
pipelock's (filename renamed to claude-bottle-mitm-ca.crt).
- bottle.provision: cred-proxy dotfile rewrites (~/.npmrc,
~/.gitconfig insteadOf, tea config) are gone — HTTP_PROXY
catches everything respecting it.
Pipelock helpers:
- `pipelock_token_hosts` → `pipelock_route_hosts` (now reading
egress_proxy.routes).
- cred-proxy hostname auto-allow → egress-proxy hostname
auto-allow.
- Anthropic seed-phrase workaround now triggers when an
egress_proxy route targets api.anthropic.com (was based on the
cred-proxy `anthropic-base-url` role).
Dockerfile.egress-proxy:
- Entrypoint conditionally passes
`--set ssl_verify_upstream_trusted_ca=$EGRESS_PROXY_UPSTREAM_CA`
(via the `${VAR:+...}` shell expansion) so standalone runs without
a mounted pipelock CA still boot.
- mkdirs `/home/mitmproxy/.mitmproxy` ahead of `docker cp`.
Deleted: claude_bottle/{cred_proxy,cred_proxy_server}.py,
backend/docker/{cred_proxy,provision/cred_proxy}.py,
Dockerfile.cred-proxy, plus the corresponding unit + integration
tests. backend/docker/cred_proxy_apply.py stays as a stub for
chunk 3 to rewrite (its container-name + routes-path constants
are inlined so it survives without the deleted module).
Test changes:
- test_pipelock_allowlist rewritten against egress-proxy routes
+ the new `pipelock_route_hosts`.
- test_manifest_md_load + test_pipelock_yaml + test_yaml_subset
fixtures migrated to the `egress_proxy: { routes: [...] }`
shape.
- test_supervise_sidecar's round-trip test switched from
`dashboard.approve` to `dashboard.reject`: the approval-apply
path on cred-proxy-block proposals hits a deleted sidecar in
chunk 2's transitional state. Chunk 3 restores the approval
test once the remediation flow is retargeted at egress-proxy.
376 tests pass (was 427; net delta is removed cred-proxy tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
"""Install the per-bottle MITM CA into the agent container's trust
|
|
store.
|
|
|
|
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target:
|
|
|
|
- Bottle declares `egress_proxy.routes[]` → agent's HTTP_PROXY
|
|
points at egress-proxy; the cert the agent must trust is the
|
|
one egress-proxy mints leaf certs with (the egress-proxy CA).
|
|
- No egress_proxy routes → agent's HTTP_PROXY points straight at
|
|
pipelock; the cert the agent must trust is pipelock's CA (the
|
|
pre-cutover behavior).
|
|
|
|
By the time this provisioner runs, the corresponding `tls_init`
|
|
helper has generated the chosen CA under `plan.stage_dir`, and the
|
|
sidecar (pipelock or egress-proxy) is up referencing the
|
|
in-container CA paths.
|
|
|
|
Cert lands on Debian's standard source path
|
|
(`/usr/local/share/ca-certificates/`); `update-ca-certificates`
|
|
rebuilds `/etc/ssl/certs/ca-certificates.crt`, which is what curl,
|
|
Python `ssl`, and OpenSSL-based tools all read by default. The env
|
|
trio set on the agent's `docker run` covers Node
|
|
(`NODE_EXTRA_CA_CERTS`) and Python `requests` /
|
|
`SSL_CERT_FILE`-honoring libraries that don't load the system
|
|
bundle.
|
|
|
|
The fingerprint is computed via stdlib (`ssl.PEM_cert_to_DER_cert`
|
|
+ `hashlib.sha256`) and logged once to stderr. The private key
|
|
stays on the host (under `stage_dir`) until teardown wipes the
|
|
stage dir; nothing in the agent ever sees it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import ssl
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from ....log import info
|
|
from ..bottle_plan import DockerBottlePlan
|
|
|
|
|
|
# Debian-family path for sources that `update-ca-certificates` reads.
|
|
# Bundle path is what the command rebuilds and what every standard
|
|
# TLS consumer in the image reads.
|
|
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt"
|
|
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
|
|
|
|
|
def _select_ca_cert(plan: DockerBottlePlan) -> tuple[Path, str]:
|
|
"""Pick the CA cert (and a short label for the log line) that
|
|
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
|
|
wins when the bottle declares any routes (it sits in front of
|
|
pipelock); else pipelock."""
|
|
if plan.egress_proxy_plan.routes:
|
|
cert = plan.egress_proxy_plan.mitmproxy_ca_cert_only_host_path
|
|
if cert == Path() or not cert.is_file():
|
|
from ....log import die
|
|
die(
|
|
f"egress-proxy CA cert missing at {cert or '(empty)'}; "
|
|
f"launch must have called egress_proxy_tls_init and "
|
|
f"re-bound the plan before provision"
|
|
)
|
|
return cert, "egress-proxy"
|
|
cert = plan.proxy_plan.ca_cert_host_path
|
|
if not cert or not cert.is_file():
|
|
from ....log import die
|
|
die(
|
|
f"pipelock CA cert missing at {cert or '(empty)'}; "
|
|
f"launch must have called pipelock_tls_init and re-bound "
|
|
f"the plan before provision"
|
|
)
|
|
return cert, "pipelock"
|
|
|
|
|
|
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
|
"""Copy the agent-facing CA cert into the agent, rebuild the
|
|
trust bundle, emit a one-line fingerprint log. Called from
|
|
`BottleBackend.provision` after the agent container is up."""
|
|
container = target
|
|
cert_host_path, label = _select_ca_cert(plan)
|
|
|
|
subprocess.run(
|
|
["docker", "cp", str(cert_host_path), f"{container}:{AGENT_CA_PATH}"],
|
|
stdout=subprocess.DEVNULL,
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
["docker", "exec", "-u", "0", container, "chmod", "644", AGENT_CA_PATH],
|
|
stdout=subprocess.DEVNULL,
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
["docker", "exec", "-u", "0", container, "update-ca-certificates"],
|
|
stdout=subprocess.DEVNULL,
|
|
check=True,
|
|
)
|
|
|
|
# Stdlib SHA-256 of the cert's DER bytes — the standard
|
|
# fingerprint form. Never the private key.
|
|
der = ssl.PEM_cert_to_DER_cert(cert_host_path.read_text())
|
|
fingerprint = hashlib.sha256(der).hexdigest()
|
|
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
|