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:
@@ -23,11 +23,10 @@ from . import prepare as _prepare
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_cleanup_plan import DockerBottleCleanupPlan
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .cred_proxy import DockerCredProxy
|
||||
from .egress_proxy import DockerEgressProxy
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import DockerPipelockProxy
|
||||
from .provision import ca as _ca
|
||||
from .provision import cred_proxy as _cred_proxy
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import skills as _skills
|
||||
@@ -44,7 +43,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def __init__(self) -> None:
|
||||
self._proxy = DockerPipelockProxy()
|
||||
self._git_gate = DockerGitGate()
|
||||
self._cred_proxy = DockerCredProxy()
|
||||
self._egress_proxy = DockerEgressProxy()
|
||||
self._supervise = DockerSupervise()
|
||||
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||
@@ -53,7 +52,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
stage_dir=stage_dir,
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
cred_proxy=self._cred_proxy,
|
||||
egress_proxy=self._egress_proxy,
|
||||
supervise=self._supervise,
|
||||
)
|
||||
|
||||
@@ -63,7 +62,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
plan,
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
cred_proxy=self._cred_proxy,
|
||||
egress_proxy=self._egress_proxy,
|
||||
supervise=self._supervise,
|
||||
provision=self.provision,
|
||||
) as bottle:
|
||||
@@ -81,9 +80,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
def provision_git(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_git.provision_git(plan, target)
|
||||
|
||||
def provision_cred_proxy(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_cred_proxy.provision_cred_proxy(plan, target)
|
||||
|
||||
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
|
||||
_supervise_prov.provision_supervise(plan, target)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ...cred_proxy import CredProxyPlan
|
||||
from ...egress_proxy import EgressProxyPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...manifest import Agent, Bottle
|
||||
@@ -58,7 +58,7 @@ class DockerBottlePlan(BottlePlan):
|
||||
prompt_file: Path
|
||||
proxy_plan: PipelockProxyPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
cred_proxy_plan: CredProxyPlan
|
||||
egress_proxy_plan: EgressProxyPlan
|
||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
||||
# is opt-in via the manifest's bottle.supervise field.
|
||||
supervise_plan: SupervisePlan | None
|
||||
@@ -72,10 +72,11 @@ class DockerBottlePlan(BottlePlan):
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
# The agent sees the union of literal env names (rendered into
|
||||
# --env-file) and forwarded env names (`-e NAME` with the value
|
||||
# arriving via subprocess env). The forwarded set already
|
||||
# reflects PRD 0010's switch — when cred-proxy holds the
|
||||
# anthropic token, CLAUDE_CODE_OAUTH_TOKEN is absent and
|
||||
# ANTHROPIC_BASE_URL is present.
|
||||
# arriving via subprocess env). The forwarded set holds the
|
||||
# OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
|
||||
# interpolations from the manifest; egress-proxy holds upstream
|
||||
# tokens in its own environ, so no token forwarding from the
|
||||
# agent to the proxy is needed.
|
||||
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
|
||||
return _PlanView(
|
||||
agent=agent,
|
||||
@@ -120,16 +121,25 @@ class DockerBottlePlan(BottlePlan):
|
||||
info(f" git gate : {'; '.join(git_lines)}")
|
||||
else:
|
||||
info(" git remotes : (none)")
|
||||
if self.cred_proxy_plan.routes:
|
||||
lines = [f"{r.path}→{r.upstream}" for r in self.cred_proxy_plan.routes]
|
||||
refs = sorted({r.token_ref for r in self.cred_proxy_plan.routes})
|
||||
info(f" cred-proxy : {len(lines)} route(s); tokens: {', '.join(refs)}")
|
||||
if self.egress_proxy_plan.routes:
|
||||
lines = []
|
||||
for r in self.egress_proxy_plan.routes:
|
||||
paths = (
|
||||
" " + ",".join(r.path_allowlist) if r.path_allowlist else ""
|
||||
)
|
||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||
lines.append(f"{r.host}{auth}{paths}")
|
||||
refs = sorted({r.token_ref for r in self.egress_proxy_plan.routes if r.token_ref})
|
||||
tokens_part = (
|
||||
f"; tokens: {', '.join(refs)}" if refs else ""
|
||||
)
|
||||
info(f" egress-proxy : {len(lines)} route(s){tokens_part}")
|
||||
for line in lines:
|
||||
info(f" {line}")
|
||||
else:
|
||||
info(" cred-proxy : (none)")
|
||||
info(" egress-proxy : (none)")
|
||||
info(f" egress : {self.allowlist_summary}")
|
||||
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
||||
info(" tls intercept : egress-proxy (per-bottle ephemeral CA, generated at launch)")
|
||||
if self.supervise_plan is not None:
|
||||
info(
|
||||
f" supervise : enabled; queue at {self.supervise_plan.queue_dir}"
|
||||
@@ -166,23 +176,22 @@ class DockerBottlePlan(BottlePlan):
|
||||
}
|
||||
for u in self.git_gate_plan.upstreams
|
||||
],
|
||||
"cred_proxy": [
|
||||
"egress_proxy": [
|
||||
{
|
||||
"path": r.path,
|
||||
"upstream": r.upstream,
|
||||
"host": r.host,
|
||||
"path_allowlist": list(r.path_allowlist),
|
||||
"auth_scheme": r.auth_scheme,
|
||||
"token_ref": r.token_ref,
|
||||
"roles": list(r.roles),
|
||||
}
|
||||
for r in self.cred_proxy_plan.routes
|
||||
for r in self.egress_proxy_plan.routes
|
||||
],
|
||||
"egress": {
|
||||
"host_count": len(hosts),
|
||||
"hosts": hosts,
|
||||
# PRD 0006: pipelock's `tls_interception` block is on
|
||||
# for every launched bottle. ca_fingerprint is always
|
||||
# null at dry-run because the CA doesn't exist yet —
|
||||
# real launches print the fingerprint to stderr from
|
||||
# PRD 0017: TLS interception moved from pipelock to
|
||||
# egress-proxy. ca_fingerprint is always null at
|
||||
# dry-run because the CA doesn't exist yet — real
|
||||
# launches print the fingerprint to stderr from
|
||||
# provision_ca. Reserved field for forward-compat.
|
||||
"tls_interception": {
|
||||
"enabled": True,
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
"""DockerCredProxy — the Docker-specific lifecycle for the per-bottle
|
||||
cred-proxy sidecar (PRD 0010). Inherits the platform-agnostic prepare
|
||||
step (route lift + routes.json render + token-env-map derivation)
|
||||
from `CredProxy`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...cred_proxy import (
|
||||
CRED_PROXY_HOSTNAME,
|
||||
CredProxy,
|
||||
CredProxyPlan,
|
||||
cred_proxy_resolve_token_values,
|
||||
)
|
||||
from ...log import die, info, warn
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
CRED_PROXY_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_CRED_PROXY_IMAGE",
|
||||
"claude-bottle-cred-proxy:latest",
|
||||
)
|
||||
|
||||
CRED_PROXY_DOCKERFILE = "Dockerfile.cred-proxy"
|
||||
|
||||
# Listening port inside the sidecar. The agent dials cred-proxy on
|
||||
# this port; surfaced as a constant so the provisioner and tests can
|
||||
# both reference it.
|
||||
CRED_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_CRED_PROXY_PORT", "9099"))
|
||||
|
||||
# In-container path the proxy server reads its route table from.
|
||||
# Pre-created in Dockerfile.cred-proxy so `docker cp` can drop the
|
||||
# file directly.
|
||||
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
|
||||
|
||||
# In-container path for the per-bottle pipelock CA. Alpine's
|
||||
# update-ca-certificates picks anything ending in `.crt` under
|
||||
# /usr/local/share/ca-certificates/ and folds it into the system
|
||||
# trust store at boot — so cred-proxy's HTTPS client trusts
|
||||
# pipelock's bumped certs when pipelock MITMs the outbound leg.
|
||||
CRED_PROXY_PIPELOCK_CA_IN_CONTAINER = "/usr/local/share/ca-certificates/pipelock.crt"
|
||||
|
||||
# Repo root, for `docker build` context. Resolved from this file's
|
||||
# location: claude_bottle/backend/docker/cred_proxy.py → repo root.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def cred_proxy_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-cred-proxy-{slug}"
|
||||
|
||||
|
||||
def cred_proxy_url() -> str:
|
||||
"""Base URL the agent dials. Stable across bottles because the
|
||||
sidecar attaches `--network-alias cred-proxy` on the internal
|
||||
network; the container name (which carries the slug) is not
|
||||
referenced by agent-side config."""
|
||||
return f"http://{CRED_PROXY_HOSTNAME}:{CRED_PROXY_PORT}"
|
||||
|
||||
|
||||
def build_cred_proxy_image() -> None:
|
||||
"""Build the cred-proxy image from `Dockerfile.cred-proxy`.
|
||||
Called by `DockerCredProxy.start`; exposed at module level so
|
||||
integration tests can build it without running the full launch
|
||||
pipeline."""
|
||||
docker_mod.build_image(CRED_PROXY_IMAGE, _REPO_DIR, dockerfile=CRED_PROXY_DOCKERFILE)
|
||||
|
||||
|
||||
class DockerCredProxy(CredProxy):
|
||||
"""Brings the cred-proxy sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: CredProxyPlan) -> str:
|
||||
"""Boot the cred-proxy sidecar:
|
||||
1. Resolve every host TokenRef env var into a concrete
|
||||
value. Fails early if any are unset.
|
||||
2. Build the cred-proxy image (no-op when cache is hot).
|
||||
3. `docker create` on the internal network with
|
||||
`--network-alias cred-proxy` and one `-e CRED_PROXY_TOKEN_N`
|
||||
flag per route. The values arrive via subprocess env, so
|
||||
they never land on argv.
|
||||
4. `docker cp` the routes.json into the container.
|
||||
5. Attach to the per-agent egress network so the proxy can
|
||||
reach the real upstream over HTTPS.
|
||||
6. `docker start`.
|
||||
Returns the container name (the target passed to `.stop`)."""
|
||||
if not plan.routes:
|
||||
die("DockerCredProxy.start called with no routes; caller should skip")
|
||||
if not plan.internal_network or not plan.egress_network:
|
||||
die(
|
||||
"DockerCredProxy.start: internal_network / egress_network must be "
|
||||
"populated on the plan before start"
|
||||
)
|
||||
if not plan.routes_path.is_file():
|
||||
die(
|
||||
f"cred-proxy routes file missing at {plan.routes_path}; "
|
||||
f"CredProxy.prepare must run first"
|
||||
)
|
||||
# pipelock fields are populated by launch.py in production; both
|
||||
# must be present (URL + CA) or both absent. Mixing is a wiring
|
||||
# bug. Both-absent is supported only as a test escape hatch:
|
||||
# the integration tests in tests/integration/ exercise header
|
||||
# injection in isolation and do not 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(
|
||||
"DockerCredProxy.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"DockerCredProxy.start: pipelock CA missing at "
|
||||
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
|
||||
)
|
||||
|
||||
# Resolve host env vars into concrete values. This 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 = cred_proxy_resolve_token_values(plan.token_env_map, dict(os.environ))
|
||||
|
||||
build_cred_proxy_image()
|
||||
|
||||
name = cred_proxy_container_name(plan.slug)
|
||||
info(f"starting cred-proxy sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
"--network-alias", CRED_PROXY_HOSTNAME,
|
||||
]
|
||||
if route_via_pipelock:
|
||||
# Route cred-proxy's outbound HTTPS through pipelock so
|
||||
# the egress allowlist + DLP body scanner apply to its
|
||||
# traffic. Pipelock MITMs each handshake with the
|
||||
# per-bottle CA we docker cp in below.
|
||||
create_args.extend([
|
||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||
])
|
||||
# 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 cred_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(CRED_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 cred-proxy sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
cps: list[tuple[str, str, str]] = [
|
||||
(str(plan.routes_path), CRED_PROXY_ROUTES_IN_CONTAINER, "routes.json"),
|
||||
]
|
||||
if route_via_pipelock:
|
||||
# CA must land BEFORE `docker start` so the entrypoint's
|
||||
# update-ca-certificates picks it up. Docker cp's the
|
||||
# file in even on the stopped container — that's the
|
||||
# whole reason this works without a custom build step.
|
||||
cps.append((
|
||||
str(plan.pipelock_ca_host_path),
|
||||
CRED_PROXY_PIPELOCK_CA_IN_CONTAINER,
|
||||
"pipelock CA",
|
||||
))
|
||||
for src, dst, label in cps:
|
||||
cp_result = subprocess.run(
|
||||
["docker", "cp", 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 cred-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 cred-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 cred-proxy sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
@@ -22,15 +22,25 @@ import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .cred_proxy import (
|
||||
CRED_PROXY_ROUTES_IN_CONTAINER,
|
||||
cred_proxy_container_name,
|
||||
)
|
||||
# Constants inlined from the deleted `claude_bottle.backend.docker.
|
||||
# cred_proxy` module (PRD 0017 chunk 2 cutover). Chunk 3 retargets
|
||||
# this file at egress-proxy and gets rid of these.
|
||||
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
|
||||
|
||||
|
||||
def _cred_proxy_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-cred-proxy-{slug}"
|
||||
|
||||
|
||||
class CredProxyApplyError(RuntimeError):
|
||||
"""Raised when fetch / apply fails. Caller renders to the
|
||||
operator; does not crash the dashboard."""
|
||||
operator; does not crash the dashboard.
|
||||
|
||||
PRD 0017 chunk 2 deletes the cred-proxy sidecar; this module's
|
||||
docker-exec calls now hit a non-existent container and raise
|
||||
CredProxyApplyError with a "container not running" message,
|
||||
which the dashboard surfaces to the operator. Chunk 3 retargets
|
||||
everything at egress-proxy."""
|
||||
|
||||
|
||||
def fetch_current_routes(slug: str) -> str:
|
||||
@@ -38,7 +48,7 @@ def fetch_current_routes(slug: str) -> str:
|
||||
for `slug`. Returns the file content as a string. Raises
|
||||
CredProxyApplyError if the sidecar isn't reachable or the read
|
||||
fails."""
|
||||
container = cred_proxy_container_name(slug)
|
||||
container = _cred_proxy_container_name(slug)
|
||||
r = subprocess.run(
|
||||
["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER],
|
||||
capture_output=True, text=True, check=False,
|
||||
@@ -80,7 +90,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
|
||||
sidecar are unchanged if the failure is before docker cp, and
|
||||
are reverted in spirit if SIGHUP fails (cp landed but reload
|
||||
didn't fire — caller's next attempt will SIGHUP again)."""
|
||||
container = cred_proxy_container_name(slug)
|
||||
container = _cred_proxy_container_name(slug)
|
||||
before = fetch_current_routes(slug)
|
||||
validate_routes_json(new_content)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform-
|
||||
agnostic prepare step (route lift + routes.yaml render + token-env
|
||||
map derivation) from `EgressProxy`.
|
||||
|
||||
Chunk 1 of the PRD: the lifecycle is implemented but not yet called
|
||||
from `launch.py`. Tests build the image and exercise start/stop
|
||||
directly. Chunk 2 wires this in alongside the cred-proxy removal."""
|
||||
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
|
||||
|
||||
@@ -24,6 +24,8 @@ 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",
|
||||
@@ -32,9 +34,19 @@ EGRESS_PROXY_IMAGE = os.environ.get(
|
||||
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy"
|
||||
|
||||
# Listening port inside the sidecar. The agent's HTTP_PROXY env var
|
||||
# (chunk 2) will resolve to `http://egress-proxy:<port>`.
|
||||
# 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)
|
||||
@@ -62,6 +74,55 @@ def build_egress_proxy_image() -> None:
|
||||
)
|
||||
|
||||
|
||||
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||
"""Mint the per-bottle egress-proxy MITM CA. Reuses the pipelock
|
||||
binary's `tls init` subcommand — a known-good RSA CA minter we
|
||||
already pin and run on this host.
|
||||
|
||||
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.
|
||||
|
||||
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 600).
|
||||
Private keys never leave the host stage dir until
|
||||
`DockerEgressProxy.start` docker-cps the concat file into the
|
||||
sidecar; start.py's outer finally `shutil.rmtree`s the stage dir
|
||||
after teardown.
|
||||
|
||||
Imported lazily inside the function so test patchers in
|
||||
pipelock-land don't need to know about us."""
|
||||
# Local import keeps the module-import graph free of a hard
|
||||
# pipelock-image dependency at top of file (we don't actually
|
||||
# need pipelock's *runtime* here, just its tls-init subcommand).
|
||||
from .pipelock import PIPELOCK_IMAGE
|
||||
work = stage_dir / "egress-proxy-ca"
|
||||
work.mkdir(exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{work}:/h",
|
||||
"-e", "PIPELOCK_HOME=/h",
|
||||
PIPELOCK_IMAGE, "tls", "init"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"egress-proxy tls init failed: {result.stderr.strip()}")
|
||||
cert = work / "ca.pem"
|
||||
key = work / "ca-key.pem"
|
||||
if not cert.is_file() or not key.is_file():
|
||||
die(f"egress-proxy tls init did not produce ca files in {work}")
|
||||
cert.chmod(0o600)
|
||||
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||
mitm = work / "mitmproxy-ca.pem"
|
||||
mitm.write_bytes(cert.read_bytes() + key.read_bytes())
|
||||
mitm.chmod(0o600)
|
||||
return (mitm, cert)
|
||||
|
||||
|
||||
class DockerEgressProxy(EgressProxy):
|
||||
"""Brings the egress-proxy sidecar up and down via Docker."""
|
||||
|
||||
@@ -71,13 +132,16 @@ class DockerEgressProxy(EgressProxy):
|
||||
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` and one `-e EGRESS_PROXY_TOKEN_N`
|
||||
flag per token slot. The values arrive via subprocess env, so
|
||||
they never land on argv.
|
||||
4. `docker cp` the routes.yaml into the container.
|
||||
`--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 (chunk 2 turns this into the pipelock leg
|
||||
via HTTPS_PROXY).
|
||||
reach pipelock.
|
||||
6. `docker start`.
|
||||
Returns the container name (the target passed to `.stop`)."""
|
||||
if not plan.routes:
|
||||
@@ -92,6 +156,27 @@ class DockerEgressProxy(EgressProxy):
|
||||
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
|
||||
@@ -111,15 +196,19 @@ class DockerEgressProxy(EgressProxy):
|
||||
"--network", plan.internal_network,
|
||||
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
||||
]
|
||||
if plan.pipelock_proxy_url:
|
||||
if route_via_pipelock:
|
||||
# Route egress-proxy's outbound HTTPS through pipelock so
|
||||
# the egress allowlist + DLP body scanner apply to its
|
||||
# traffic on the egress-proxy → upstream leg. Wiring lands
|
||||
# in chunk 2.
|
||||
# traffic on 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).
|
||||
create_args.extend([
|
||||
"-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
|
||||
@@ -143,24 +232,34 @@ class DockerEgressProxy(EgressProxy):
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
cp_result = subprocess.run(
|
||||
["docker", "cp", str(plan.routes_path),
|
||||
f"{name}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if cp_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
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,
|
||||
)
|
||||
die(
|
||||
f"failed to copy routes.yaml into {name}: "
|
||||
f"{cp_result.stderr.strip()}"
|
||||
)
|
||||
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],
|
||||
|
||||
@@ -24,7 +24,11 @@ from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .cred_proxy import DockerCredProxy
|
||||
from .egress_proxy import (
|
||||
DockerEgressProxy,
|
||||
egress_proxy_tls_init,
|
||||
egress_proxy_url,
|
||||
)
|
||||
from .git_gate import DockerGitGate
|
||||
from .pipelock import (
|
||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||
@@ -47,7 +51,7 @@ def launch(
|
||||
*,
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
cred_proxy: DockerCredProxy,
|
||||
egress_proxy: DockerEgressProxy,
|
||||
supervise: DockerSupervise,
|
||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||
) -> Generator[DockerBottle, None, None]:
|
||||
@@ -83,19 +87,28 @@ def launch(
|
||||
|
||||
# Docker assigns a CIDR to the new internal network. Pipelock's
|
||||
# SSRF guard otherwise rejects any destination resolving into
|
||||
# RFC1918 space — which includes the cred-proxy / git-gate /
|
||||
# pipelock sidecars themselves. Allowlist the bottle's own
|
||||
# internal subnet so the agent can reach its sidecars via
|
||||
# pipelock; api_allowlist + body-scanning still apply.
|
||||
# RFC1918 space — which includes the sibling sidecars
|
||||
# (egress-proxy → pipelock on the upstream leg, etc.).
|
||||
# Allowlist the bottle's own internal subnet so internal
|
||||
# traffic passes through pipelock; api_allowlist + body-scanning
|
||||
# still apply.
|
||||
internal_cidr = network_mod.network_inspect_cidr(internal_network)
|
||||
|
||||
# Per-bottle ephemeral CA for pipelock's TLS interception
|
||||
# (PRD 0006). One-shot pipelock container writes ca.pem +
|
||||
# ca-key.pem under plan.stage_dir; .start docker-cp's them
|
||||
# into the sidecar. The private key never leaves the host
|
||||
# stage dir, which start.py's outer finally `shutil.rmtree`s
|
||||
# after the sidecar is torn down.
|
||||
# Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two
|
||||
# separate CAs:
|
||||
# - pipelock CA: signs MITM certs pipelock presents on the
|
||||
# egress-proxy → upstream leg.
|
||||
# - egress-proxy CA: signs MITM certs egress-proxy presents
|
||||
# to the agent on the agent → egress-proxy leg.
|
||||
# Both are minted by one-shot pipelock containers (pipelock's
|
||||
# `tls init` is a known-good RSA CA minter) under stage_dir;
|
||||
# the .start steps docker-cp the files in. Private keys never
|
||||
# leave the host stage dir, which start.py's outer finally
|
||||
# `shutil.rmtree`s after the sidecars are torn down.
|
||||
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir)
|
||||
egress_proxy_ca_host, egress_proxy_ca_cert_only = egress_proxy_tls_init(
|
||||
plan.stage_dir,
|
||||
)
|
||||
|
||||
# Re-render the pipelock yaml with the SSRF allowlist now that
|
||||
# we know the internal CIDR. Prepare wrote the yaml without
|
||||
@@ -141,26 +154,28 @@ def launch(
|
||||
git_gate_name = git_gate.start(plan.git_gate_plan)
|
||||
stack.callback(git_gate.stop, git_gate_name)
|
||||
|
||||
# Cred-proxy (PRD 0010). One sidecar per bottle when
|
||||
# bottle.cred_proxy.routes is non-empty. Must come up AFTER pipelock
|
||||
# — cred-proxy routes its outbound HTTPS through pipelock
|
||||
# (HTTPS_PROXY in environ + the per-bottle CA in its trust
|
||||
# store) so the egress allowlist + body scanner sit in the
|
||||
# cred-proxy path too. Must come up BEFORE the agent so DNS
|
||||
# resolution for `cred-proxy` succeeds on the agent's first
|
||||
# call; tokens flow from the host env into the sidecar's
|
||||
# environ, not the agent's.
|
||||
if plan.cred_proxy_plan.routes:
|
||||
cred_proxy_plan = dataclasses.replace(
|
||||
plan.cred_proxy_plan,
|
||||
# Egress-proxy (PRD 0017). One sidecar per bottle when
|
||||
# bottle.egress_proxy.routes is non-empty. Must come up AFTER
|
||||
# pipelock — egress-proxy routes its outbound HTTPS through
|
||||
# pipelock (HTTPS_PROXY in environ + the pipelock CA in its
|
||||
# trust store) so the egress allowlist + body scanner sit on
|
||||
# the egress-proxy → upstream leg. Must come up BEFORE the
|
||||
# agent so DNS resolution for `egress-proxy` succeeds on the
|
||||
# agent's first call; tokens flow from the host env into the
|
||||
# sidecar's environ, not the agent's.
|
||||
if plan.egress_proxy_plan.routes:
|
||||
egress_proxy_plan = dataclasses.replace(
|
||||
plan.egress_proxy_plan,
|
||||
internal_network=internal_network,
|
||||
egress_network=egress_network,
|
||||
mitmproxy_ca_host_path=egress_proxy_ca_host,
|
||||
mitmproxy_ca_cert_only_host_path=egress_proxy_ca_cert_only,
|
||||
pipelock_ca_host_path=ca_cert_host,
|
||||
pipelock_proxy_url=pipelock_proxy_url(plan.slug),
|
||||
)
|
||||
plan = dataclasses.replace(plan, cred_proxy_plan=cred_proxy_plan)
|
||||
cred_proxy_name = cred_proxy.start(plan.cred_proxy_plan)
|
||||
stack.callback(cred_proxy.stop, cred_proxy_name)
|
||||
plan = dataclasses.replace(plan, egress_proxy_plan=egress_proxy_plan)
|
||||
egress_proxy_name = egress_proxy.start(plan.egress_proxy_plan)
|
||||
stack.callback(egress_proxy.stop, egress_proxy_name)
|
||||
|
||||
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
|
||||
# Internal-network only — the sidecar makes no outbound calls.
|
||||
@@ -208,11 +223,23 @@ def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||
return ",".join(hosts)
|
||||
|
||||
|
||||
def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
||||
"""Pick the proxy URL the agent's HTTP_PROXY env points at. PRD
|
||||
0017: when an egress-proxy is declared, the agent goes through
|
||||
egress-proxy (which in turn uses HTTPS_PROXY=pipelock on its
|
||||
outbound leg). Otherwise the agent talks straight to pipelock —
|
||||
keeps the network surface minimal for bottles that don't need
|
||||
path filtering or credential injection."""
|
||||
if plan.egress_proxy_plan.routes:
|
||||
return egress_proxy_url()
|
||||
return pipelock_proxy_url(plan.slug)
|
||||
|
||||
|
||||
def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
||||
"""Build the `docker run` argv and execute it, handling name-
|
||||
conflict races by incrementing the suffix (unless the name was
|
||||
user-pinned). Returns the resolved container name."""
|
||||
proxy_url = pipelock_proxy_url(plan.slug)
|
||||
proxy_url = _agent_proxy_url(plan)
|
||||
docker_args: list[str] = [
|
||||
"--rm", "-d",
|
||||
"--name", plan.container_name,
|
||||
|
||||
@@ -15,17 +15,13 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ... import pipelock
|
||||
from ...cred_proxy import cred_proxy_render_routes
|
||||
from ...egress_proxy import egress_proxy_render_routes
|
||||
from ...env import ResolvedEnv, resolve_env
|
||||
from ...log import die
|
||||
from .. import BottleSpec
|
||||
from . import util as docker_mod
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .cred_proxy import (
|
||||
DockerCredProxy,
|
||||
cred_proxy_container_name,
|
||||
cred_proxy_url,
|
||||
)
|
||||
from .egress_proxy import DockerEgressProxy, egress_proxy_container_name
|
||||
from .git_gate import DockerGitGate, git_gate_container_name
|
||||
from .bottle_state import (
|
||||
BottleMetadata,
|
||||
@@ -46,7 +42,7 @@ def resolve_plan(
|
||||
stage_dir: Path,
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
cred_proxy: DockerCredProxy,
|
||||
egress_proxy: DockerEgressProxy,
|
||||
supervise: DockerSupervise,
|
||||
) -> DockerBottlePlan:
|
||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||
@@ -127,15 +123,15 @@ def resolve_plan(
|
||||
# surface as a docker-create conflict deep inside launch() with no
|
||||
# actionable hint. Fail fast here with a cleanup pointer instead.
|
||||
# Only probe sidecars this launch will actually try to create:
|
||||
# pipelock always; git-gate when bottle.git is non-empty; cred-proxy
|
||||
# when bottle.cred_proxy.routes is non-empty.
|
||||
# pipelock always; git-gate when bottle.git is non-empty;
|
||||
# egress-proxy when bottle.egress_proxy.routes is non-empty.
|
||||
sidecar_probes: list[tuple[str, str]] = [
|
||||
("pipelock", pipelock_container_name(slug)),
|
||||
]
|
||||
if bottle.git:
|
||||
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
||||
if bottle.cred_proxy.routes:
|
||||
sidecar_probes.append(("cred-proxy", cred_proxy_container_name(slug)))
|
||||
if bottle.egress_proxy.routes:
|
||||
sidecar_probes.append(("egress-proxy", egress_proxy_container_name(slug)))
|
||||
if bottle.supervise:
|
||||
sidecar_probes.append(("supervise", supervise_container_name(slug)))
|
||||
for label, sidecar_name in sidecar_probes:
|
||||
@@ -154,10 +150,13 @@ def resolve_plan(
|
||||
|
||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||
cred_proxy_plan = cred_proxy.prepare(bottle, slug, stage_dir)
|
||||
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
routes_content = cred_proxy_render_routes(cred_proxy_plan.routes) if cred_proxy_plan.routes else ""
|
||||
routes_content = (
|
||||
egress_proxy_render_routes(egress_proxy_plan.routes)
|
||||
if egress_proxy_plan.routes else ""
|
||||
)
|
||||
allowlist_content = "\n".join(pipelock.pipelock_effective_allowlist(bottle)) + "\n"
|
||||
# Current Dockerfile for the agent image. Read from the repo
|
||||
# root; for `--cwd` derived images the base Dockerfile is what
|
||||
@@ -176,36 +175,21 @@ def resolve_plan(
|
||||
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||
# mutates the host os.environ.
|
||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||
# Find the (at most one) cred-proxy route claiming the
|
||||
# anthropic-base-url role. Manifest validation enforces the
|
||||
# singleton constraint. cred-proxy is the only path the Anthropic
|
||||
# OAuth token reaches the bottle — there is no fallback that
|
||||
# forwards it into the agent's environ directly. Bottles that
|
||||
# need claude-code to authenticate must declare an
|
||||
# anthropic-base-url route.
|
||||
anthropic_route = next(
|
||||
(r for r in cred_proxy_plan.routes if "anthropic-base-url" in r.roles),
|
||||
None,
|
||||
# When the bottle declares an egress-proxy route for the Anthropic
|
||||
# OAuth flow, claude-code's outbound Authorization gets stripped +
|
||||
# re-injected by egress-proxy. The agent's environ still needs
|
||||
# *something* claude-code recognises as a credential or it refuses
|
||||
# to start; ship a non-secret placeholder. The placeholder is not
|
||||
# any real `auth.token_ref` value, so leaking it would tell an
|
||||
# attacker only that egress-proxy is in front.
|
||||
has_anthropic_auth = any(
|
||||
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
|
||||
for r in egress_proxy_plan.routes
|
||||
)
|
||||
if anthropic_route is not None:
|
||||
# Point claude-code at the cred-proxy. The sidecar holds the
|
||||
# OAuth token; the agent's environ does not. Strip the
|
||||
# trailing slash so claude-code's path-join produces e.g.
|
||||
# http://cred-proxy:9099/anthropic/v1/messages.
|
||||
forwarded_env["ANTHROPIC_BASE_URL"] = (
|
||||
f"{cred_proxy_url()}{anthropic_route.path}".rstrip("/")
|
||||
)
|
||||
# claude-code refuses to start without *some* credential in
|
||||
# its env. The proxy strips inbound Authorization on every
|
||||
# request and injects the real one — so a non-secret
|
||||
# placeholder is sufficient and the SC1 test still holds
|
||||
# (the placeholder is not a `cred_proxy.routes[].TokenRef`
|
||||
# value). The agent cannot exfiltrate this string because
|
||||
# it carries no meaning to api.anthropic.com.
|
||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "cred-proxy-placeholder"
|
||||
# Belt-and-braces: turn off telemetry endpoints that don't
|
||||
# route through ANTHROPIC_BASE_URL (statsig, error reporting).
|
||||
# PRD 0010 open question default.
|
||||
if has_anthropic_auth:
|
||||
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder"
|
||||
# Belt-and-braces: turn off telemetry endpoints (statsig,
|
||||
# error reporting) that egress-proxy can't gate by auth.
|
||||
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
|
||||
_write_env_file(resolved, env_file)
|
||||
@@ -229,7 +213,7 @@ def resolve_plan(
|
||||
prompt_file=prompt_file,
|
||||
proxy_plan=proxy_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
cred_proxy_plan=cred_proxy_plan,
|
||||
egress_proxy_plan=egress_proxy_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
allowlist_summary=allowlist_summary,
|
||||
use_runsc=use_runsc,
|
||||
|
||||
@@ -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