feat(egress-proxy): cutover from cred-proxy (PRD 0017 chunk 2)
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>
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
"""Install pipelock's per-bottle CA into the agent container's trust
|
||||
store (PRD 0006).
|
||||
"""Install the per-bottle MITM CA into the agent container's trust
|
||||
store.
|
||||
|
||||
By the time this provisioner runs, `pipelock_tls_init` has generated
|
||||
a fresh CA into `plan.stage_dir/pipelock-ca/` and the pipelock sidecar
|
||||
is up with `tls_interception: { enabled: true }` referencing the
|
||||
in-container CA paths. This step makes the agent trust certs signed
|
||||
by that CA so the agent's TLS handshake with the bumped CONNECT
|
||||
succeeds.
|
||||
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`
|
||||
@@ -27,6 +34,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import ssl
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ....log import info
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
@@ -35,26 +43,42 @@ 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-pipelock-ca.crt"
|
||||
AGENT_CA_PATH = "/usr/local/share/ca-certificates/claude-bottle-mitm-ca.crt"
|
||||
AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
|
||||
def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Copy pipelock's 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 = plan.proxy_plan.ca_cert_host_path
|
||||
if not cert_host_path or not cert_host_path.is_file():
|
||||
# Defensive: provision runs after launch wires CA paths
|
||||
# onto the plan via dataclasses.replace; an empty path here
|
||||
# would mean that wiring was skipped.
|
||||
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_host_path or '(empty)'}; "
|
||||
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}"],
|
||||
@@ -76,4 +100,4 @@ def provision_ca(plan: DockerBottlePlan, target: str) -> None:
|
||||
# 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"pipelock ca fingerprint: sha256:{fingerprint[:32]}...")
|
||||
info(f"{label} ca fingerprint: sha256:{fingerprint[:32]}...")
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Cred-proxy provisioning inside a running Docker bottle (PRD 0010).
|
||||
|
||||
Writes the agent-side configuration that points each tool at the
|
||||
per-bottle cred-proxy sidecar:
|
||||
|
||||
- ~/.npmrc — `registry=` pointing at /npm/
|
||||
- ~/.gitconfig (appended) — `insteadOf` rules for the
|
||||
github / gitea hosts the bottle
|
||||
declared a token for
|
||||
- ~/.config/tea/config.yml — per-gitea login pointing at
|
||||
/gitea/<host>/
|
||||
|
||||
The ANTHROPIC_BASE_URL env var is set at `docker run -e` time by the
|
||||
backend's launch step, not here — it has to be in the agent's environ
|
||||
before claude starts, and there is no point in writing it to a dotfile
|
||||
the agent would have to source. See `prepare.py` for that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ....cred_proxy import CredProxyRoute
|
||||
from ....log import info
|
||||
from .. import util as docker_mod
|
||||
from ..bottle_plan import DockerBottlePlan
|
||||
from ..cred_proxy import cred_proxy_url
|
||||
|
||||
|
||||
def provision_cred_proxy(plan: DockerBottlePlan, target: str) -> None:
|
||||
"""Drop the agent-side dotfiles for each declared cred-proxy
|
||||
route. No-op when the bottle has no routes."""
|
||||
routes = plan.cred_proxy_plan.routes
|
||||
if not routes:
|
||||
return
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
git_gate_hosts = {g.UpstreamHost for g in bottle.git}
|
||||
_provision_npmrc(plan, target, routes)
|
||||
_provision_gitconfig(plan, target, routes, git_gate_hosts)
|
||||
_provision_tea_config(plan, target, routes)
|
||||
|
||||
|
||||
# --- npm --------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_npmrc(routes: tuple[CredProxyRoute, ...]) -> str:
|
||||
"""Render `~/.npmrc` content. Driven by the `npm-registry` role:
|
||||
finds the (single) route that claims it and writes a registry=
|
||||
line at the proxy. Empty string when no such route exists, so
|
||||
callers can branch on emptiness.
|
||||
|
||||
The proxy strips inbound Authorization and injects its own — the
|
||||
npmrc deliberately carries no `_authToken`. The registry alone
|
||||
is enough. Manifest validation enforces that the role is a
|
||||
singleton, so the first match is the only match."""
|
||||
for r in routes:
|
||||
if "npm-registry" in r.roles:
|
||||
return f"registry={cred_proxy_url()}{r.path}\n"
|
||||
return ""
|
||||
|
||||
|
||||
def _provision_npmrc(
|
||||
plan: DockerBottlePlan,
|
||||
target: str,
|
||||
routes: tuple[CredProxyRoute, ...],
|
||||
) -> None:
|
||||
content = render_npmrc(routes)
|
||||
if not content:
|
||||
return
|
||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
container_npmrc = f"{container_home}/.npmrc"
|
||||
npmrc = plan.stage_dir / "agent_npmrc"
|
||||
npmrc.write_text(content)
|
||||
npmrc.chmod(0o600)
|
||||
info(f"writing {container_npmrc} (cred-proxy npm registry)")
|
||||
subprocess.run(
|
||||
["docker", "cp", str(npmrc), f"{target}:{container_npmrc}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
docker_mod.docker_exec_root(target, ["chown", "node:node", container_npmrc])
|
||||
docker_mod.docker_exec_root(target, ["chmod", "644", container_npmrc])
|
||||
|
||||
|
||||
# --- git config -------------------------------------------------------------
|
||||
|
||||
|
||||
def render_cred_proxy_gitconfig(
|
||||
routes: tuple[CredProxyRoute, ...],
|
||||
git_gate_hosts: set[str] = frozenset(), # type: ignore[assignment]
|
||||
) -> str:
|
||||
"""Render the `~/.gitconfig` fragment for cred-proxy insteadOf
|
||||
rewrites. Driven by the `git-insteadof` role: each route that
|
||||
claims it produces a `[url "<proxy><path>"] insteadOf =
|
||||
<upstream>/` block. Empty string when no such route exists.
|
||||
|
||||
The rewrite is suppressed for any route whose upstream host is
|
||||
also declared in `bottle.git`. git-gate is the canonical git
|
||||
path on those hosts — its pre-receive runs gitleaks before
|
||||
forwarding the push. A cred-proxy `https://<host>/` rewrite
|
||||
would route HTTPS git ops around the gate. cred-proxy still
|
||||
refuses smart-HTTP push at runtime (defense in depth), but
|
||||
suppressing the rewrite means `git clone https://<host>/...`
|
||||
doesn't have a tempting shortcut that just confuses on push.
|
||||
|
||||
The insteadOf left-hand side comes from `route.upstream` (with a
|
||||
trailing `/` so insteadOf matches at the directory boundary),
|
||||
so the same renderer handles github.com, gitea.dideric.is, and
|
||||
any future host the user wires up."""
|
||||
rules: list[str] = []
|
||||
for r in routes:
|
||||
if "git-insteadof" not in r.roles:
|
||||
continue
|
||||
# Strip scheme to derive the host for the git-gate overlap
|
||||
# check. urllib.parse-free parse: same shape we accept in
|
||||
# manifest validation.
|
||||
host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
||||
if host in git_gate_hosts:
|
||||
continue
|
||||
rules.append(
|
||||
f'[url "{cred_proxy_url()}{r.path}"]\n'
|
||||
f"\tinsteadOf = {r.upstream}/\n"
|
||||
)
|
||||
if not rules:
|
||||
return ""
|
||||
return (
|
||||
"# claude-bottle cred-proxy (PRD 0010): rewrite https://<host>/ to\n"
|
||||
"# the per-bottle cred-proxy sidecar, which holds the upstream\n"
|
||||
"# credential and injects the Authorization header.\n"
|
||||
+ "".join(rules)
|
||||
)
|
||||
|
||||
|
||||
def _provision_gitconfig(
|
||||
plan: DockerBottlePlan,
|
||||
target: str,
|
||||
routes: tuple[CredProxyRoute, ...],
|
||||
git_gate_hosts: set[str],
|
||||
) -> None:
|
||||
"""Append the cred-proxy insteadOf rules to ~/.gitconfig. Runs
|
||||
after `provision_git`, so any git-gate rules already live in the
|
||||
file; we append rather than overwrite. Hosts already brokered by
|
||||
git-gate are skipped — git-gate is the canonical git path there."""
|
||||
content = render_cred_proxy_gitconfig(routes, git_gate_hosts)
|
||||
if not content:
|
||||
return
|
||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
container_gitconfig = f"{container_home}/.gitconfig"
|
||||
info(f"appending cred-proxy insteadOf rules to {container_gitconfig}")
|
||||
# Use `tee -a` over stdin so the content never lands on argv and the
|
||||
# append is atomic from the agent's perspective. `tee` runs as the
|
||||
# node user (the default in the container) so ownership is preserved.
|
||||
result = subprocess.run(
|
||||
["docker", "exec", "-i", target, "tee", "-a", container_gitconfig],
|
||||
input=content,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Fall back to root-tee in case ~/.gitconfig didn't exist as the
|
||||
# node user yet (no git-gate rules were written). The chown
|
||||
# below makes ownership consistent.
|
||||
result_root = subprocess.run(
|
||||
["docker", "exec", "-i", "-u", "0", target,
|
||||
"tee", "-a", container_gitconfig],
|
||||
input=content,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
_ = result_root # silence unused
|
||||
docker_mod.docker_exec_root(target, ["chown", "node:node", container_gitconfig])
|
||||
docker_mod.docker_exec_root(target, ["chmod", "644", container_gitconfig])
|
||||
|
||||
|
||||
# --- tea --------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_tea_config(routes: tuple[CredProxyRoute, ...]) -> str:
|
||||
"""Render `~/.config/tea/config.yml`. Driven by the `tea-login`
|
||||
role: each route that claims it produces one `logins:` entry
|
||||
pointing at the cred-proxy. The proxy substitutes the real
|
||||
token at request time; the value in `token:` here is a
|
||||
placeholder. `tea` refuses to make calls without a non-empty
|
||||
token field, so the placeholder is necessary."""
|
||||
tea_routes = [r for r in routes if "tea-login" in r.roles]
|
||||
if not tea_routes:
|
||||
return ""
|
||||
lines = ["logins:"]
|
||||
for r in tea_routes:
|
||||
# Derive a stable login name from the upstream host. The
|
||||
# path may not encode the host (e.g. `/gitea/dideric/` vs
|
||||
# upstream gitea.dideric.is), so we read it off `upstream`.
|
||||
host = r.upstream.removeprefix("https://").partition("/")[0].partition(":")[0]
|
||||
lines.extend([
|
||||
f"- name: {host}",
|
||||
f" url: {cred_proxy_url()}{r.path}",
|
||||
" token: cred-proxy-placeholder",
|
||||
" default: false",
|
||||
" ssh_host: \"\"",
|
||||
" ssh_key: \"\"",
|
||||
" insecure: false",
|
||||
])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _provision_tea_config(
|
||||
plan: DockerBottlePlan,
|
||||
target: str,
|
||||
routes: tuple[CredProxyRoute, ...],
|
||||
) -> None:
|
||||
content = render_tea_config(routes)
|
||||
if not content:
|
||||
return
|
||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||
container_tea = f"{container_home}/.config/tea/config.yml"
|
||||
cfg = plan.stage_dir / "agent_tea_config.yml"
|
||||
cfg.write_text(content)
|
||||
cfg.chmod(0o600)
|
||||
info(
|
||||
f"writing {container_tea} "
|
||||
f"({len([r for r in routes if 'tea-login' in r.roles])} tea login(s))"
|
||||
)
|
||||
docker_mod.docker_exec_root(
|
||||
target, ["mkdir", "-p", str(Path(container_tea).parent)]
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "cp", str(cfg), f"{target}:{container_tea}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
docker_mod.docker_exec_root(target, [
|
||||
"chown", "-R", "node:node", str(Path(container_tea).parent),
|
||||
])
|
||||
docker_mod.docker_exec_root(target, ["chmod", "600", container_tea])
|
||||
Reference in New Issue
Block a user