refactor: rename egress-proxy → egress everywhere
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m10s

The manifest key is `egress:` now; finish the rename so the rest of
the codebase matches. Files (Dockerfile.egress, claude_bottle/egress.py
etc.), classes (Egress, EgressConfig, EgressRoute, EgressPlan,
DockerEgress), constants (EGRESS_HOSTNAME, EGRESS_ROUTES, ...),
container name prefix (claude-bottle-egress-*), docker network alias
(egress), the introspection host (_egress.local), the MCP tool IDs
(egress-block, list-egress-routes), and the preflight label all drop
the `-proxy` suffix.
This commit is contained in:
2026-05-25 21:59:47 -04:00
parent 14c8a51c16
commit 1e5b0dcfca
30 changed files with 583 additions and 583 deletions
+13 -13
View File
@@ -1,4 +1,4 @@
# Per-bottle egress-proxy sidecar image (PRD 0017). # Per-bottle egress sidecar image (PRD 0017).
# #
# Replaces cred-proxy (PRD 0010). Sits on the agent's HTTP_PROXY / # Replaces cred-proxy (PRD 0010). Sits on the agent's HTTP_PROXY /
# HTTPS_PROXY path (wiring lands in chunk 2) and owns three jobs: # HTTPS_PROXY path (wiring lands in chunk 2) and owns three jobs:
@@ -22,36 +22,36 @@ USER root
# both inside the container and from the host's tests; `_addon.py` is # both inside the container and from the host's tests; `_addon.py` is
# the mitmproxy hook wrapper. Both land flat in /app/ so mitmdump's # the mitmproxy hook wrapper. Both land flat in /app/ so mitmdump's
# loader finds them as top-level sibling modules. # loader finds them as top-level sibling modules.
COPY claude_bottle/egress_proxy_addon_core.py /app/egress_proxy_addon_core.py COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY claude_bottle/egress_proxy_addon.py /app/egress_proxy_addon.py COPY claude_bottle/egress_addon.py /app/egress_addon.py
# Pre-create the runtime directories the backend's start step will # Pre-create the runtime directories the backend's start step will
# `docker cp` into. docker cp does not create intermediate dirs, so # `docker cp` into. docker cp does not create intermediate dirs, so
# the mkdir must be baked into the image. # the mkdir must be baked into the image.
# /etc/egress-proxy routes.yaml lands here # /etc/egress routes.yaml lands here
# ~/.mitmproxy mitmproxy CA (cert+key concat) + the # ~/.mitmproxy mitmproxy CA (cert+key concat) + the
# pipelock CA (cert only, for upstream # pipelock CA (cert only, for upstream
# trust on the HTTPS_PROXY=pipelock leg) # trust on the HTTPS_PROXY=pipelock leg)
# Ownership lets the unprivileged mitmproxy user read the files. # Ownership lets the unprivileged mitmproxy user read the files.
RUN mkdir -p /etc/egress-proxy /home/mitmproxy/.mitmproxy \ RUN mkdir -p /etc/egress /home/mitmproxy/.mitmproxy \
&& chown -R mitmproxy:mitmproxy /etc/egress-proxy /home/mitmproxy/.mitmproxy /app && chown -R mitmproxy:mitmproxy /etc/egress /home/mitmproxy/.mitmproxy /app
USER mitmproxy USER mitmproxy
# Listening port. Agents dial egress-proxy on this port via their # Listening port. Agents dial egress on this port via their
# HTTP_PROXY env. Surfaced as EXPOSE for documentation; not required # HTTP_PROXY env. Surfaced as EXPOSE for documentation; not required
# for the internal network to route to it. # for the internal network to route to it.
EXPOSE 9099 EXPOSE 9099
# Entrypoint: # Entrypoint:
# - Upstream proxy: when EGRESS_PROXY_UPSTREAM_PROXY is set, # - Upstream proxy: when EGRESS_UPSTREAM_PROXY is set,
# use mitmproxy's `--mode upstream:URL` to forward all # use mitmproxy's `--mode upstream:URL` to forward all
# post-MITM traffic through pipelock. (mitmproxy does NOT # post-MITM traffic through pipelock. (mitmproxy does NOT
# honor HTTPS_PROXY env vars on its outbound side — it's a # honor HTTPS_PROXY env vars on its outbound side — it's a
# proxy server, not a client.) Standalone runs without # proxy server, not a client.) Standalone runs without
# EGRESS_PROXY_UPSTREAM_PROXY fall back to `regular@9099` # EGRESS_UPSTREAM_PROXY fall back to `regular@9099`
# direct-to-upstream — useful for unit tests of the image. # direct-to-upstream — useful for unit tests of the image.
# - Upstream trust: when EGRESS_PROXY_UPSTREAM_CA is set, build # - Upstream trust: when EGRESS_UPSTREAM_CA is set, build
# a combined trust bundle (system roots + pipelock CA) and # a combined trust bundle (system roots + pipelock CA) and
# point mitmproxy at it via # point mitmproxy at it via
# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES # `--set ssl_verify_upstream_trusted_ca`. This option REPLACES
@@ -61,6 +61,6 @@ EXPOSE 9099
# sees real upstream certs signed by public CAs. The combined # sees real upstream certs signed by public CAs. The combined
# bundle covers both pipelock-MITM'd and pipelock-passthrough # bundle covers both pipelock-MITM'd and pipelock-passthrough
# hosts. # hosts.
# - -s /app/egress_proxy_addon.py → loads our addon, reads # - -s /app/egress_addon.py → loads our addon, reads
# /etc/egress-proxy/routes.yaml. # /etc/egress/routes.yaml.
ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_PROXY_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_PROXY_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_PROXY_UPSTREAM_CA\" ] && [ -f \"$EGRESS_PROXY_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_PROXY_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_proxy_addon.py"] ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_UPSTREAM_CA\" ] && [ -f \"$EGRESS_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_addon.py"]
+1 -1
View File
@@ -231,7 +231,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def provision_ca(self, plan: PlanT, target: str) -> None: def provision_ca(self, plan: PlanT, target: str) -> None:
"""Install the per-bottle CA into the agent's trust store so """Install the per-bottle CA into the agent's trust store so
the agent trusts the bumped CONNECT cert egress-proxy (was the agent trusts the bumped CONNECT cert egress (was
pipelock, pre-PRD-0017) presents. Default impl is a no-op so pipelock, pre-PRD-0017) presents. Default impl is a no-op so
backends that don't yet support TLS interception (every backend backends that don't yet support TLS interception (every backend
except Docker today) aren't forced to implement it. The Docker except Docker today) aren't forced to implement it. The Docker
+4 -4
View File
@@ -23,7 +23,7 @@ from . import prepare as _prepare
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .egress_proxy import DockerEgressProxy from .egress import DockerEgress
from .git_gate import DockerGitGate from .git_gate import DockerGitGate
from .pipelock import DockerPipelockProxy from .pipelock import DockerPipelockProxy
from .provision import ca as _ca from .provision import ca as _ca
@@ -43,7 +43,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def __init__(self) -> None: def __init__(self) -> None:
self._proxy = DockerPipelockProxy() self._proxy = DockerPipelockProxy()
self._git_gate = DockerGitGate() self._git_gate = DockerGitGate()
self._egress_proxy = DockerEgressProxy() self._egress = DockerEgress()
self._supervise = DockerSupervise() self._supervise = DockerSupervise()
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
@@ -52,7 +52,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
stage_dir=stage_dir, stage_dir=stage_dir,
proxy=self._proxy, proxy=self._proxy,
git_gate=self._git_gate, git_gate=self._git_gate,
egress_proxy=self._egress_proxy, egress=self._egress,
supervise=self._supervise, supervise=self._supervise,
) )
@@ -62,7 +62,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
plan, plan,
proxy=self._proxy, proxy=self._proxy,
git_gate=self._git_gate, git_gate=self._git_gate,
egress_proxy=self._egress_proxy, egress=self._egress,
supervise=self._supervise, supervise=self._supervise,
provision=self.provision, provision=self.provision,
) as bottle: ) as bottle:
+7 -7
View File
@@ -11,7 +11,7 @@ import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from ...egress_proxy import EgressProxyPlan from ...egress import EgressPlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...pipelock import PipelockProxyPlan from ...pipelock import PipelockProxyPlan
@@ -45,7 +45,7 @@ class DockerBottlePlan(BottlePlan):
prompt_file: Path prompt_file: Path
proxy_plan: PipelockProxyPlan proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan git_gate_plan: GitGatePlan
egress_proxy_plan: EgressProxyPlan egress_plan: EgressPlan
# None when bottle.supervise is False. PRD 0013 supervise sidecar # None when bottle.supervise is False. PRD 0013 supervise sidecar
# is opt-in via the manifest's bottle.supervise field. # is opt-in via the manifest's bottle.supervise field.
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
@@ -65,14 +65,14 @@ class DockerBottlePlan(BottlePlan):
# --env-file) and forwarded env names (`-e NAME` with the # --env-file) and forwarded env names (`-e NAME` with the
# value arriving via subprocess env). The forwarded set holds # value arriving via subprocess env). The forwarded set holds
# the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env # the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
# interpolations from the manifest; egress-proxy holds # interpolations from the manifest; egress holds
# upstream tokens in its own environ, so no token forwarding # upstream tokens in its own environ, so no token forwarding
# from the agent to the proxy is needed. # from the agent to the proxy is needed.
env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())) env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys()))
def _multi(label: str, values: list[str]) -> None: def _multi(label: str, values: list[str]) -> None:
"""Print a label with N continuation-indented values. Used """Print a label with N continuation-indented values. Used
for env / skills / git-gate / egress-proxy where one item for env / skills / git-gate / egress where one item
per line keeps the summary scannable.""" per line keeps the summary scannable."""
if not values: if not values:
info(f"{label}: (none)") info(f"{label}: (none)")
@@ -95,11 +95,11 @@ class DockerBottlePlan(BottlePlan):
if git_lines: if git_lines:
_multi(" git gate ", git_lines) _multi(" git gate ", git_lines)
if self.egress_proxy_plan.routes: if self.egress_plan.routes:
egress_lines = [] egress_lines = []
for r in self.egress_proxy_plan.routes: for r in self.egress_plan.routes:
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else "" auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
egress_lines.append(f"{r.host}{auth}") egress_lines.append(f"{r.host}{auth}")
_multi(" egress-proxy ", egress_lines) _multi(" egress ", egress_lines)
print(file=sys.stderr) print(file=sys.stderr)
+1 -1
View File
@@ -47,7 +47,7 @@ _TRANSCRIPT_SUBDIR = "transcript"
_METADATA_NAME = "metadata.json" _METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise sidecar (read-only). # Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's # Host's apply paths keep these files fresh so supervise's
# `list-pipelock-allowlist` / `list-egress-proxy-routes` MCP tools # `list-pipelock-allowlist` / `list-egress-routes` MCP tools
# return the current state — not a snapshot from launch time. # return the current state — not a snapshot from launch time.
_LIVE_CONFIG_SUBDIR = "live-config" _LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml" LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
@@ -1,7 +1,7 @@
"""DockerEgressProxy — the Docker-specific lifecycle for the """DockerEgress — the Docker-specific lifecycle for the
per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform- per-bottle egress sidecar (PRD 0017). Inherits the platform-
agnostic prepare step (route lift + routes.yaml render + token-env agnostic prepare step (route lift + routes.yaml render + token-env
map derivation) from `EgressProxy`. map derivation) from `Egress`.
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into 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- launch.py cred-proxy is gone. Chunk 3 retargets the cred-proxy-
@@ -13,12 +13,12 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from ...egress_proxy import ( from ...egress import (
EGRESS_PROXY_HOSTNAME, EGRESS_HOSTNAME,
EGRESS_PROXY_ROUTES_IN_CONTAINER, EGRESS_ROUTES_IN_CONTAINER,
EgressProxy, Egress,
EgressProxyPlan, EgressPlan,
egress_proxy_resolve_token_values, egress_resolve_token_values,
) )
from ...log import die, info, warn from ...log import die, info, warn
from . import util as docker_mod from . import util as docker_mod
@@ -26,63 +26,63 @@ from . import util as docker_mod
EGRESS_PROXY_IMAGE = os.environ.get( EGRESS_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE", "CLAUDE_BOTTLE_EGRESS_IMAGE",
"claude-bottle-egress-proxy:latest", "claude-bottle-egress:latest",
) )
EGRESS_PROXY_DOCKERFILE = "Dockerfile.egress-proxy" EGRESS_DOCKERFILE = "Dockerfile.egress"
# Listening port inside the sidecar. The agent's HTTP_PROXY env var # Listening port inside the sidecar. The agent's HTTP_PROXY env var
# resolves to `http://egress-proxy:<port>`. # resolves to `http://egress:<port>`.
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099")) EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099"))
# In-container path for mitmproxy's CA. The format is a single PEM # In-container path for mitmproxy's CA. The format is a single PEM
# file holding BOTH the cert and the private key, concatenated. The # file holding BOTH the cert and the private key, concatenated. The
# upstream-trust CA (pipelock's, so egress-proxy trusts the upstream # upstream-trust CA (pipelock's, so egress trusts the upstream
# leg) is a separate file because pipelock keeps a different CA on # leg) is a separate file because pipelock keeps a different CA on
# its end. # its end.
EGRESS_PROXY_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem" EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem"
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER = ( EGRESS_PIPELOCK_CA_IN_CONTAINER = (
"/home/mitmproxy/.mitmproxy/pipelock-ca.pem" "/home/mitmproxy/.mitmproxy/pipelock-ca.pem"
) )
# Repo root, for `docker build` context. Resolved from this file's # Repo root, for `docker build` context. Resolved from this file's
# location: claude_bottle/backend/docker/egress_proxy.py → repo root. # location: claude_bottle/backend/docker/egress.py → repo root.
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
def egress_proxy_container_name(slug: str) -> str: def egress_container_name(slug: str) -> str:
return f"claude-bottle-egress-proxy-{slug}" return f"claude-bottle-egress-{slug}"
def egress_proxy_url() -> str: def egress_url() -> str:
"""Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable """Base URL the agent will dial via HTTP_PROXY (chunk 2). Stable
across bottles because the sidecar attaches `--network-alias across bottles because the sidecar attaches `--network-alias
egress-proxy` on the internal network; the container name (which egress` on the internal network; the container name (which
carries the slug) is not referenced by agent-side config.""" carries the slug) is not referenced by agent-side config."""
return f"http://{EGRESS_PROXY_HOSTNAME}:{EGRESS_PROXY_PORT}" return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
def build_egress_proxy_image() -> None: def build_egress_image() -> None:
"""Build the egress-proxy image from `Dockerfile.egress-proxy`. """Build the egress image from `Dockerfile.egress`.
Called by `DockerEgressProxy.start`; exposed at module level so Called by `DockerEgress.start`; exposed at module level so
integration tests can build it without running the full launch integration tests can build it without running the full launch
pipeline.""" pipeline."""
docker_mod.build_image( docker_mod.build_image(
EGRESS_PROXY_IMAGE, _REPO_DIR, dockerfile=EGRESS_PROXY_DOCKERFILE, EGRESS_IMAGE, _REPO_DIR, dockerfile=EGRESS_DOCKERFILE,
) )
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]: def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Mint the per-bottle egress-proxy MITM CA via host `openssl req`. """Mint the per-bottle egress MITM CA via host `openssl req`.
Returns `(mitmproxy_pem, cert_only_pem)`: Returns `(mitmproxy_pem, cert_only_pem)`:
- `mitmproxy_pem` is the single-PEM concat (cert + key) - `mitmproxy_pem` is the single-PEM concat (cert + key)
mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`. mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`.
- `cert_only_pem` is the cert alone installed into the agent's - `cert_only_pem` is the cert alone installed into the agent's
trust store by `provision_ca` so the agent trusts the bumped trust store by `provision_ca` so the agent trusts the bumped
CONNECT cert egress-proxy presents. CONNECT cert egress presents.
Why openssl req (not the pipelock binary's `tls init`): Why openssl req (not the pipelock binary's `tls init`):
pipelock's CA generator stamps a non-standard `Subject Key pipelock's CA generator stamps a non-standard `Subject Key
@@ -95,11 +95,11 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
store. openssl req's `subjectKeyIdentifier=hash` extension uses store. openssl req's `subjectKeyIdentifier=hash` extension uses
SHA-1(pubkey), matching mitmproxy's computation. SHA-1(pubkey), matching mitmproxy's computation.
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 644 Both files live under `<stage_dir>/egress-ca/` (mode 644
`docker cp` preserves the mode into the container, where the `docker cp` preserves the mode into the container, where the
mitmproxy user (uid 1000) reads them; the host stage_dir is mitmproxy user (uid 1000) reads them; the host stage_dir is
mode 700 so the private key isn't world-exposed).""" mode 700 so the private key isn't world-exposed)."""
work = stage_dir / "egress-proxy-ca" work = stage_dir / "egress-ca"
work.mkdir(exist_ok=True) work.mkdir(exist_ok=True)
key_path = work / "ca-key.pem" key_path = work / "ca-key.pem"
cert_path = work / "ca.pem" cert_path = work / "ca.pem"
@@ -113,7 +113,7 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if keygen.returncode != 0: if keygen.returncode != 0:
die(f"egress-proxy ca keygen failed: {keygen.stderr.strip()}") die(f"egress ca keygen failed: {keygen.stderr.strip()}")
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as # `subjectKeyIdentifier=hash` makes openssl compute the SKI as
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the # SHA-1(pubkey), matching how mitmproxy computes the AKI on the
@@ -127,7 +127,7 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"\n" "\n"
"[req_dn]\n" "[req_dn]\n"
"O = claude-bottle\n" "O = claude-bottle\n"
"CN = claude-bottle egress-proxy CA\n" "CN = claude-bottle egress CA\n"
"\n" "\n"
"[v3_ca]\n" "[v3_ca]\n"
"basicConstraints = critical, CA:TRUE\n" "basicConstraints = critical, CA:TRUE\n"
@@ -145,7 +145,7 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if req.returncode != 0: if req.returncode != 0:
die(f"egress-proxy ca cert generation failed: {req.stderr.strip()}") die(f"egress ca cert generation failed: {req.stderr.strip()}")
cert_path.chmod(0o644) cert_path.chmod(0o644)
# mitmproxy reads cert + key from a single concatenated PEM file. # mitmproxy reads cert + key from a single concatenated PEM file.
@@ -155,20 +155,20 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
return (mitm, cert_path) return (mitm, cert_path)
class DockerEgressProxy(EgressProxy): class DockerEgress(Egress):
"""Brings the egress-proxy sidecar up and down via Docker.""" """Brings the egress sidecar up and down via Docker."""
def start(self, plan: EgressProxyPlan) -> str: def start(self, plan: EgressPlan) -> str:
"""Boot the egress-proxy sidecar: """Boot the egress sidecar:
1. Resolve every host TokenRef env var into a concrete 1. Resolve every host TokenRef env var into a concrete
value. Fails early if any are unset. value. Fails early if any are unset.
2. Build the egress-proxy image (no-op when cache is hot). 2. Build the egress image (no-op when cache is hot).
3. `docker create` on the internal network with 3. `docker create` on the internal network with
`--network-alias egress-proxy`, the `HTTPS_PROXY=pipelock` `--network-alias egress`, the `HTTPS_PROXY=pipelock`
env (so the upstream leg traverses pipelock), the env (so the upstream leg traverses pipelock), the
`EGRESS_PROXY_UPSTREAM_CA` env pointing at the in-container `EGRESS_UPSTREAM_CA` env pointing at the in-container
pipelock-CA path (so mitmproxy trusts pipelock's MITM), pipelock-CA path (so mitmproxy trusts pipelock's MITM),
and one `-e EGRESS_PROXY_TOKEN_N` flag per token slot. and one `-e EGRESS_TOKEN_N` flag per token slot.
Secret values arrive via subprocess env, never argv. Secret values arrive via subprocess env, never argv.
4. `docker cp` the routes.yaml, mitmproxy CA (cert+key 4. `docker cp` the routes.yaml, mitmproxy CA (cert+key
concat), and pipelock CA (cert only) into the container. concat), and pipelock CA (cert only) into the container.
@@ -177,67 +177,67 @@ class DockerEgressProxy(EgressProxy):
6. `docker start`. 6. `docker start`.
Returns the container name (the target passed to `.stop`).""" Returns the container name (the target passed to `.stop`)."""
if not plan.routes: if not plan.routes:
die("DockerEgressProxy.start called with no routes; caller should skip") die("DockerEgress.start called with no routes; caller should skip")
if not plan.internal_network or not plan.egress_network: if not plan.internal_network or not plan.egress_network:
die( die(
"DockerEgressProxy.start: internal_network / egress_network must be " "DockerEgress.start: internal_network / egress_network must be "
"populated on the plan before start" "populated on the plan before start"
) )
if not plan.routes_path.is_file(): if not plan.routes_path.is_file():
die( die(
f"egress-proxy routes file missing at {plan.routes_path}; " f"egress routes file missing at {plan.routes_path}; "
f"EgressProxy.prepare must run first" f"Egress.prepare must run first"
) )
if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file(): if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file():
die( die(
f"DockerEgressProxy.start: mitmproxy CA missing at " f"DockerEgress.start: mitmproxy CA missing at "
f"{plan.mitmproxy_ca_host_path}; egress_proxy_tls_init must run first" f"{plan.mitmproxy_ca_host_path}; egress_tls_init must run first"
) )
# pipelock CA + upstream proxy URL: both must be present (we # pipelock CA + upstream proxy URL: both must be present (we
# use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the # use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the
# upstream leg) or both absent (egress-proxy goes direct, for # upstream leg) or both absent (egress goes direct, for
# standalone integration tests that don't bring pipelock up). # standalone integration tests that don't bring pipelock up).
route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path() route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path()
if route_via_pipelock: if route_via_pipelock:
if not plan.pipelock_proxy_url: if not plan.pipelock_proxy_url:
die( die(
"DockerEgressProxy.start: pipelock_ca_host_path is set but " "DockerEgress.start: pipelock_ca_host_path is set but "
"pipelock_proxy_url is empty; populate both or neither." "pipelock_proxy_url is empty; populate both or neither."
) )
if not plan.pipelock_ca_host_path.is_file(): if not plan.pipelock_ca_host_path.is_file():
die( die(
f"DockerEgressProxy.start: pipelock CA missing at " f"DockerEgress.start: pipelock CA missing at "
f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first" f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first"
) )
# Resolve host env vars into concrete values. Must happen at # Resolve host env vars into concrete values. Must happen at
# start time (not prepare) — the values flow into the sidecar's # start time (not prepare) — the values flow into the sidecar's
# environ via subprocess env. The plan never holds them. # environ via subprocess env. The plan never holds them.
token_values = egress_proxy_resolve_token_values( token_values = egress_resolve_token_values(
plan.token_env_map, dict(os.environ), plan.token_env_map, dict(os.environ),
) )
build_egress_proxy_image() build_egress_image()
name = egress_proxy_container_name(plan.slug) name = egress_container_name(plan.slug)
info(f"starting egress-proxy sidecar {name} on network {plan.internal_network}") info(f"starting egress sidecar {name} on network {plan.internal_network}")
create_args = [ create_args = [
"docker", "create", "docker", "create",
"--name", name, "--name", name,
"--network", plan.internal_network, "--network", plan.internal_network,
"--network-alias", EGRESS_PROXY_HOSTNAME, "--network-alias", EGRESS_HOSTNAME,
] ]
if route_via_pipelock: if route_via_pipelock:
# Route egress-proxy's outbound traffic through pipelock # Route egress's outbound traffic through pipelock
# so the egress allowlist + DLP body scanner apply to # so the egress allowlist + DLP body scanner apply to
# the egress-proxy → upstream leg. Pipelock MITMs each # the egress → upstream leg. Pipelock MITMs each
# handshake with its per-bottle CA, which is docker-cp'd # handshake with its per-bottle CA, which is docker-cp'd
# in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA # in below and pointed to via the EGRESS_UPSTREAM_CA
# env (entrypoint conditionally adds the matching --set # env (entrypoint conditionally adds the matching --set
# flag). # flag).
# #
# EGRESS_PROXY_UPSTREAM_PROXY is the mechanism: mitmproxy # EGRESS_UPSTREAM_PROXY is the mechanism: mitmproxy
# does NOT honor HTTPS_PROXY env vars on its outbound # does NOT honor HTTPS_PROXY env vars on its outbound
# side (it's a proxy server, not a client). The # side (it's a proxy server, not a client). The
# entrypoint reads this env and switches mitmdump to # entrypoint reads this env and switches mitmdump to
@@ -247,22 +247,22 @@ class DockerEgressProxy(EgressProxy):
# bundled client libraries (mitmproxy plugin requests, # bundled client libraries (mitmproxy plugin requests,
# etc.) that might honor them — harmless if ignored. # etc.) that might honor them — harmless if ignored.
create_args.extend([ create_args.extend([
"-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}", "-e", f"EGRESS_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}", "-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}", "-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1", "-e", "NO_PROXY=localhost,127.0.0.1",
"-e", f"EGRESS_PROXY_UPSTREAM_CA={EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER}", "-e", f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}",
]) ])
# One -e flag per token slot; values arrive via subprocess env. # One -e flag per token slot; values arrive via subprocess env.
# docker create with `-e NAME` (no =VALUE) reads NAME from the # docker create with `-e NAME` (no =VALUE) reads NAME from the
# current process env at create time. We pass `env=child_env` # current process env at create time. We pass `env=child_env`
# to subprocess.run so the value comes from token_values, not # to subprocess.run so the value comes from token_values, not
# the host's os.environ directly — keeps the resolver in one # the host's os.environ directly — keeps the resolver in one
# place and lets egress_proxy_resolve_token_values surface # place and lets egress_resolve_token_values surface
# missing-env errors with a clear hint. # missing-env errors with a clear hint.
for token_env in sorted(plan.token_env_map.keys()): for token_env in sorted(plan.token_env_map.keys()):
create_args.extend(["-e", token_env]) create_args.extend(["-e", token_env])
create_args.append(EGRESS_PROXY_IMAGE) create_args.append(EGRESS_IMAGE)
child_env: dict[str, str] = {**os.environ, **token_values} child_env: dict[str, str] = {**os.environ, **token_values}
@@ -271,7 +271,7 @@ class DockerEgressProxy(EgressProxy):
) )
if create_result.returncode != 0: if create_result.returncode != 0:
die( die(
f"failed to create egress-proxy sidecar {name}: " f"failed to create egress sidecar {name}: "
f"{create_result.stderr.strip()}" f"{create_result.stderr.strip()}"
) )
@@ -281,19 +281,19 @@ class DockerEgressProxy(EgressProxy):
# isn't actually exposed to other host users. # isn't actually exposed to other host users.
plan.routes_path.chmod(0o644) plan.routes_path.chmod(0o644)
# Pipelock CA: pipelock itself runs as root so its in-pipelock # Pipelock CA: pipelock itself runs as root so its in-pipelock
# copy doesn't care about mode, but egress-proxy's mitmproxy # copy doesn't care about mode, but egress's mitmproxy
# user does. Bump on the host so docker cp into egress-proxy # user does. Bump on the host so docker cp into egress
# carries world-readable. # carries world-readable.
if route_via_pipelock: if route_via_pipelock:
plan.pipelock_ca_host_path.chmod(0o644) plan.pipelock_ca_host_path.chmod(0o644)
cps: list[tuple[Path, str, str]] = [ cps: list[tuple[Path, str, str]] = [
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"), (plan.routes_path, EGRESS_ROUTES_IN_CONTAINER, "routes.yaml"),
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"), (plan.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER, "mitmproxy CA"),
] ]
if route_via_pipelock: if route_via_pipelock:
cps.append(( cps.append((
plan.pipelock_ca_host_path, plan.pipelock_ca_host_path,
EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER, EGRESS_PIPELOCK_CA_IN_CONTAINER,
"pipelock CA", "pipelock CA",
)) ))
for src, dst, label in cps: for src, dst, label in cps:
@@ -327,7 +327,7 @@ class DockerEgressProxy(EgressProxy):
check=False, check=False,
) )
die( die(
f"failed to attach egress-proxy sidecar {name} to egress network " f"failed to attach egress sidecar {name} to egress network "
f"{plan.egress_network}: {connect_result.stderr.strip()}" f"{plan.egress_network}: {connect_result.stderr.strip()}"
) )
@@ -342,7 +342,7 @@ class DockerEgressProxy(EgressProxy):
check=False, check=False,
) )
die( die(
f"failed to start egress-proxy sidecar {name}: " f"failed to start egress sidecar {name}: "
f"{start_result.stderr.strip()}" f"{start_result.stderr.strip()}"
) )
@@ -364,6 +364,6 @@ class DockerEgressProxy(EgressProxy):
check=False, check=False,
).returncode != 0: ).returncode != 0:
warn( warn(
f"failed to remove egress-proxy sidecar {target}; " f"failed to remove egress sidecar {target}; "
f"clean up with 'docker rm -f {target}'" f"clean up with 'docker rm -f {target}'"
) )
@@ -1,21 +1,21 @@
"""Host-side helper to apply a routes.yaml change to a running """Host-side helper to apply a routes.yaml change to a running
egress-proxy sidecar (PRD 0014 retargeted by PRD 0017 chunk 3). egress sidecar (PRD 0014 retargeted by PRD 0017 chunk 3).
Used by the supervise dashboard when the operator approves an Used by the supervise dashboard when the operator approves an
egress-proxy-block proposal (or runs the operator-initiated egress-block proposal (or runs the operator-initiated
`routes edit <bottle>` verb). Fetches the current routes.yaml via `routes edit <bottle>` verb). Fetches the current routes.yaml via
`docker exec cat`, validates the new content, writes it into the `docker exec cat`, validates the new content, writes it into the
sidecar via `docker cp`, then `docker kill --signal HUP` to make sidecar via `docker cp`, then `docker kill --signal HUP` to make
the addon reload without dropping connections. the addon reload without dropping connections.
Also mirrors the new route hosts into pipelock's hostname allowlist Also mirrors the new route hosts into pipelock's hostname allowlist
so the downstream leg lets them through egress-proxy enforces so the downstream leg lets them through egress enforces
the path-aware allowlist on the agent leg, pipelock enforces the the path-aware allowlist on the agent leg, pipelock enforces the
hostname allowlist + DLP body scan on the upstream leg, and a hostname allowlist + DLP body scan on the upstream leg, and a
host added to one must be in the other or the request 403s host added to one must be in the other or the request 403s
somewhere along the chain. somewhere along the chain.
Raises EgressProxyApplyError on any failure the dashboard Raises EgressApplyError on any failure the dashboard
surfaces the message and keeps the proposal pending so the surfaces the message and keeps the proposal pending so the
operator can retry. operator can retry.
""" """
@@ -29,9 +29,9 @@ import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER from ...egress import EGRESS_ROUTES_IN_CONTAINER
from ...egress_proxy_addon_core import load_routes from ...egress_addon_core import load_routes
from .egress_proxy import egress_proxy_container_name from .egress import egress_container_name
from .pipelock_apply import ( from .pipelock_apply import (
PipelockApplyError, PipelockApplyError,
apply_allowlist_change, apply_allowlist_change,
@@ -41,23 +41,23 @@ from .pipelock_apply import (
) )
class EgressProxyApplyError(RuntimeError): class EgressApplyError(RuntimeError):
"""Raised when fetch / apply fails. Caller renders to the """Raised when fetch / apply fails. Caller renders to the
operator; does not crash the dashboard.""" operator; does not crash the dashboard."""
def fetch_current_routes(slug: str) -> str: def fetch_current_routes(slug: str) -> str:
"""Read the live routes.yaml from the running egress-proxy sidecar """Read the live routes.yaml from the running egress sidecar
for `slug`. Returns the file content as a string. Raises for `slug`. Returns the file content as a string. Raises
EgressProxyApplyError if the sidecar isn't reachable or the read EgressApplyError if the sidecar isn't reachable or the read
fails.""" fails."""
container = egress_proxy_container_name(slug) container = egress_container_name(slug)
r = subprocess.run( r = subprocess.run(
["docker", "exec", container, "cat", EGRESS_PROXY_ROUTES_IN_CONTAINER], ["docker", "exec", container, "cat", EGRESS_ROUTES_IN_CONTAINER],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if r.returncode != 0: if r.returncode != 0:
raise EgressProxyApplyError( raise EgressApplyError(
f"could not read routes.yaml from {container}: " f"could not read routes.yaml from {container}: "
f"{(r.stderr or '').strip() or 'container not running?'}" f"{(r.stderr or '').strip() or 'container not running?'}"
) )
@@ -71,7 +71,7 @@ def validate_routes_content(content: str) -> None:
try: try:
load_routes(content) load_routes(content)
except ValueError as e: except ValueError as e:
raise EgressProxyApplyError( raise EgressApplyError(
f"proposed routes.yaml is not valid: {e}" f"proposed routes.yaml is not valid: {e}"
) from e ) from e
@@ -83,7 +83,7 @@ def _hosts_in_routes(content: str) -> list[str]:
try: try:
routes = load_routes(content) routes = load_routes(content)
except ValueError as e: except ValueError as e:
raise EgressProxyApplyError( raise EgressApplyError(
f"proposed routes.yaml is not valid: {e}" f"proposed routes.yaml is not valid: {e}"
) from e ) from e
return sorted({r.host for r in routes if r.host}) return sorted({r.host for r in routes if r.host})
@@ -93,10 +93,10 @@ def _hosts_in_routes(content: str) -> list[str]:
# `[A-Za-z0-9_.-]+`. Anything else (wildcards, IPv6 literals, # `[A-Za-z0-9_.-]+`. Anything else (wildcards, IPv6 literals,
# stray characters) is silently dropped from the mirror so the # stray characters) is silently dropped from the mirror so the
# pipelock apply doesn't fail parse before the new yaml is even # pipelock apply doesn't fail parse before the new yaml is even
# written. The dropped hosts stay on egress-proxy's route table — # written. The dropped hosts stay on egress's route table —
# but the addon does exact-host match only, so they'll never # but the addon does exact-host match only, so they'll never
# match anything either. (Wildcard host matching was removed — # match anything either. (Wildcard host matching was removed —
# see `match_route` in egress_proxy_addon_core for the rationale.) # see `match_route` in egress_addon_core for the rationale.)
_PIPELOCK_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _PIPELOCK_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
@@ -110,10 +110,10 @@ def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None:
"""Ensure every pipelock-compatible `hosts` entry is on """Ensure every pipelock-compatible `hosts` entry is on
pipelock's allowlist. Fetches pipelock's current allowlist, pipelock's allowlist. Fetches pipelock's current allowlist,
merges, re-applies. Hosts pipelock can't represent (wildcards, merges, re-applies. Hosts pipelock can't represent (wildcards,
etc.) are silently skipped they stay live on egress-proxy etc.) are silently skipped they stay live on egress
but aren't enforced at pipelock. No-op if every host is already but aren't enforced at pipelock. No-op if every host is already
present (apply still restarts pipelock if any host is new). present (apply still restarts pipelock if any host is new).
Raises EgressProxyApplyError on pipelock failures so the Raises EgressApplyError on pipelock failures so the
caller's diff/audit reflects the half-state.""" caller's diff/audit reflects the half-state."""
safe_hosts = _pipelock_safe_hosts(hosts) safe_hosts = _pipelock_safe_hosts(hosts)
try: try:
@@ -124,42 +124,42 @@ def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None:
return # nothing to add return # nothing to add
apply_allowlist_change(slug, render_allowlist_content(merged)) apply_allowlist_change(slug, render_allowlist_content(merged))
except PipelockApplyError as e: except PipelockApplyError as e:
# Mirror runs BEFORE the egress-proxy write, so egress-proxy # Mirror runs BEFORE the egress write, so egress
# is unchanged on this failure path. Report it as a # is unchanged on this failure path. Report it as a
# pipelock-side problem so the operator looks in the right # pipelock-side problem so the operator looks in the right
# place; their `pipelock edit` flow can repair manually. # place; their `pipelock edit` flow can repair manually.
raise EgressProxyApplyError( raise EgressApplyError(
f"pipelock allowlist mirror failed (egress-proxy NOT " f"pipelock allowlist mirror failed (egress NOT "
f"updated): {e}. Fix pipelock's allowlist manually with " f"updated): {e}. Fix pipelock's allowlist manually with "
f"`pipelock edit <bottle>` then retry the proposal." f"`pipelock edit <bottle>` then retry the proposal."
) from e ) from e
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
"""Apply `new_content` to the egress-proxy sidecar for `slug`: """Apply `new_content` to the egress sidecar for `slug`:
1. Fetch current routes.yaml (for the before-diff). 1. Fetch current routes.yaml (for the before-diff).
2. Validate the new content via the addon's own parser. 2. Validate the new content via the addon's own parser.
3. Mirror the route hosts onto pipelock's allowlist (so the 3. Mirror the route hosts onto pipelock's allowlist (so the
downstream hostname gate lets them through). downstream hostname gate lets them through).
4. Write to a temp file, `docker cp` into the egress-proxy 4. Write to a temp file, `docker cp` into the egress
sidecar. sidecar.
5. `docker kill --signal HUP` so the addon reloads. 5. `docker kill --signal HUP` so the addon reloads.
Order matters: pipelock first, then egress-proxy. If the Order matters: pipelock first, then egress. If the
pipelock step fails, egress-proxy hasn't been touched and the pipelock step fails, egress hasn't been touched and the
old routes stay live. If the egress-proxy step fails after old routes stay live. If the egress step fails after
pipelock succeeded, pipelock has the host in its allowlist but pipelock succeeded, pipelock has the host in its allowlist but
egress-proxy doesn't enforce it yet — harmless extra-permissive egress doesn't enforce it yet — harmless extra-permissive
state at pipelock, and a re-approval will land the egress-proxy state at pipelock, and a re-approval will land the egress
side. side.
Returns (before, after) where `after` == `new_content`. Raises Returns (before, after) where `after` == `new_content`. Raises
EgressProxyApplyError on any step.""" EgressApplyError on any step."""
container = egress_proxy_container_name(slug) container = egress_container_name(slug)
before = fetch_current_routes(slug) before = fetch_current_routes(slug)
validate_routes_content(new_content) validate_routes_content(new_content)
# Pipelock mirror first — if it fails, egress-proxy stays intact # Pipelock mirror first — if it fails, egress stays intact
# and the operator gets a clear error about the half-state. # and the operator gets a clear error about the half-state.
_mirror_hosts_to_pipelock(slug, _hosts_in_routes(new_content)) _mirror_hosts_to_pipelock(slug, _hosts_in_routes(new_content))
@@ -180,11 +180,11 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
os.chmod(tmp_path, 0o644) os.chmod(tmp_path, 0o644)
cp = subprocess.run( cp = subprocess.run(
["docker", "cp", tmp_path, ["docker", "cp", tmp_path,
f"{container}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"], f"{container}:{EGRESS_ROUTES_IN_CONTAINER}"],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if cp.returncode != 0: if cp.returncode != 0:
raise EgressProxyApplyError( raise EgressApplyError(
f"failed to copy routes.yaml into {container}: " f"failed to copy routes.yaml into {container}: "
f"{(cp.stderr or '').strip()}" f"{(cp.stderr or '').strip()}"
) )
@@ -193,7 +193,7 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
if sig.returncode != 0: if sig.returncode != 0:
raise EgressProxyApplyError( raise EgressApplyError(
f"failed to SIGHUP {container}: " f"failed to SIGHUP {container}: "
f"{(sig.stderr or '').strip()}" f"{(sig.stderr or '').strip()}"
) )
@@ -228,18 +228,18 @@ def _merge_single_route(
try: try:
cfg = json.loads(current_yaml) cfg = json.loads(current_yaml)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise EgressProxyApplyError( raise EgressApplyError(
f"current routes.yaml is not valid JSON: {e}" f"current routes.yaml is not valid JSON: {e}"
) from e ) from e
routes = cfg.get("routes") routes = cfg.get("routes")
if not isinstance(routes, list): if not isinstance(routes, list):
raise EgressProxyApplyError( raise EgressApplyError(
"current routes.yaml: 'routes' is not a list" "current routes.yaml: 'routes' is not a list"
) )
new_host = str(new_route.get("host", "")).lower() new_host = str(new_route.get("host", "")).lower()
if not new_host: if not new_host:
raise EgressProxyApplyError( raise EgressApplyError(
"proposed route is missing 'host'" "proposed route is missing 'host'"
) )
@@ -280,7 +280,7 @@ def _merge_single_route(
}) })
next_idx = len(existing_slots) next_idx = len(existing_slots)
entry["auth_scheme"] = str(auth["scheme"]) entry["auth_scheme"] = str(auth["scheme"])
entry["token_env"] = f"EGRESS_PROXY_TOKEN_{next_idx}" entry["token_env"] = f"EGRESS_TOKEN_{next_idx}"
# NOTE: the addon reads token VALUES from its container's # NOTE: the addon reads token VALUES from its container's
# environ keyed by token_env. A newly-added auth route at # environ keyed by token_env. A newly-added auth route at
# runtime points at a slot that has no env value → the # runtime points at a slot that has no env value → the
@@ -295,18 +295,18 @@ def _merge_single_route(
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]: def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
"""Apply a single-route addition to the egress-proxy. Parses the """Apply a single-route addition to the egress. Parses the
agent's proposed route, fetches the current routes file, merges, agent's proposed route, fetches the current routes file, merges,
and applies via `apply_routes_change`. Returns (before, after) and applies via `apply_routes_change`. Returns (before, after)
full-file content for the audit log.""" full-file content for the audit log."""
try: try:
proposed = json.loads(proposed_route_json) proposed = json.loads(proposed_route_json)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise EgressProxyApplyError( raise EgressApplyError(
f"proposed route is not valid JSON: {e}" f"proposed route is not valid JSON: {e}"
) from e ) from e
if not isinstance(proposed, dict): if not isinstance(proposed, dict):
raise EgressProxyApplyError( raise EgressApplyError(
"proposed route must be a JSON object" "proposed route must be a JSON object"
) )
current = fetch_current_routes(slug) current = fetch_current_routes(slug)
@@ -315,7 +315,7 @@ def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
__all__ = [ __all__ = [
"EgressProxyApplyError", "EgressApplyError",
"add_route", "add_route",
"apply_routes_change", "apply_routes_change",
"fetch_current_routes", "fetch_current_routes",
+26 -26
View File
@@ -24,10 +24,10 @@ from . import network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .egress_proxy import ( from .egress import (
DockerEgressProxy, DockerEgress,
egress_proxy_tls_init, egress_tls_init,
egress_proxy_url, egress_url,
) )
from .git_gate import DockerGitGate from .git_gate import DockerGitGate
from .pipelock import ( from .pipelock import (
@@ -51,7 +51,7 @@ def launch(
*, *,
proxy: DockerPipelockProxy, proxy: DockerPipelockProxy,
git_gate: DockerGitGate, git_gate: DockerGitGate,
egress_proxy: DockerEgressProxy, egress: DockerEgress,
supervise: DockerSupervise, supervise: DockerSupervise,
provision: Callable[[DockerBottlePlan, str], str | None], provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]: ) -> Generator[DockerBottle, None, None]:
@@ -88,7 +88,7 @@ def launch(
# Docker assigns a CIDR to the new internal network. Pipelock's # Docker assigns a CIDR to the new internal network. Pipelock's
# SSRF guard otherwise rejects any destination resolving into # SSRF guard otherwise rejects any destination resolving into
# RFC1918 space — which includes the sibling sidecars # RFC1918 space — which includes the sibling sidecars
# (egress-proxy → pipelock on the upstream leg, etc.). # (egress → pipelock on the upstream leg, etc.).
# Allowlist the bottle's own internal subnet so internal # Allowlist the bottle's own internal subnet so internal
# traffic passes through pipelock; api_allowlist + body-scanning # traffic passes through pipelock; api_allowlist + body-scanning
# still apply. # still apply.
@@ -97,16 +97,16 @@ def launch(
# Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two # Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two
# separate CAs: # separate CAs:
# - pipelock CA: signs MITM certs pipelock presents on the # - pipelock CA: signs MITM certs pipelock presents on the
# egress-proxy → upstream leg. # egress → upstream leg.
# - egress-proxy CA: signs MITM certs egress-proxy presents # - egress CA: signs MITM certs egress presents
# to the agent on the agent → egress-proxy leg. # to the agent on the agent → egress leg.
# Both are minted by one-shot pipelock containers (pipelock's # Both are minted by one-shot pipelock containers (pipelock's
# `tls init` is a known-good RSA CA minter) under stage_dir; # `tls init` is a known-good RSA CA minter) under stage_dir;
# the .start steps docker-cp the files in. Private keys never # the .start steps docker-cp the files in. Private keys never
# leave the host stage dir, which start.py's outer finally # leave the host stage dir, which start.py's outer finally
# `shutil.rmtree`s after the sidecars are torn down. # `shutil.rmtree`s after the sidecars are torn down.
ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir) 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( egress_ca_host, egress_ca_cert_only = egress_tls_init(
plan.stage_dir, plan.stage_dir,
) )
@@ -156,26 +156,26 @@ def launch(
# Egress-proxy (PRD 0017). One sidecar per bottle when # Egress-proxy (PRD 0017). One sidecar per bottle when
# bottle.egress.routes is non-empty. Must come up AFTER # bottle.egress.routes is non-empty. Must come up AFTER
# pipelock — egress-proxy routes its outbound HTTPS through # pipelock — egress routes its outbound HTTPS through
# pipelock (HTTPS_PROXY in environ + the pipelock CA in its # pipelock (HTTPS_PROXY in environ + the pipelock CA in its
# trust store) so the egress allowlist + body scanner sit on # trust store) so the egress allowlist + body scanner sit on
# the egress-proxy → upstream leg. Must come up BEFORE the # the egress → upstream leg. Must come up BEFORE the
# agent so DNS resolution for `egress-proxy` succeeds on the # agent so DNS resolution for `egress` succeeds on the
# agent's first call; tokens flow from the host env into the # agent's first call; tokens flow from the host env into the
# sidecar's environ, not the agent's. # sidecar's environ, not the agent's.
if plan.egress_proxy_plan.routes: if plan.egress_plan.routes:
egress_proxy_plan = dataclasses.replace( egress_plan = dataclasses.replace(
plan.egress_proxy_plan, plan.egress_plan,
internal_network=internal_network, internal_network=internal_network,
egress_network=egress_network, egress_network=egress_network,
mitmproxy_ca_host_path=egress_proxy_ca_host, mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_proxy_ca_cert_only, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
pipelock_ca_host_path=ca_cert_host, pipelock_ca_host_path=ca_cert_host,
pipelock_proxy_url=pipelock_proxy_url(plan.slug), pipelock_proxy_url=pipelock_proxy_url(plan.slug),
) )
plan = dataclasses.replace(plan, egress_proxy_plan=egress_proxy_plan) plan = dataclasses.replace(plan, egress_plan=egress_plan)
egress_proxy_name = egress_proxy.start(plan.egress_proxy_plan) egress_name = egress.start(plan.egress_plan)
stack.callback(egress_proxy.stop, egress_proxy_name) stack.callback(egress.stop, egress_name)
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise. # Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
# Internal-network only — the sidecar makes no outbound calls. # Internal-network only — the sidecar makes no outbound calls.
@@ -225,13 +225,13 @@ def _agent_no_proxy(plan: DockerBottlePlan) -> str:
def _agent_proxy_url(plan: DockerBottlePlan) -> str: def _agent_proxy_url(plan: DockerBottlePlan) -> str:
"""Pick the proxy URL the agent's HTTP_PROXY env points at. PRD """Pick the proxy URL the agent's HTTP_PROXY env points at. PRD
0017: when an egress-proxy is declared, the agent goes through 0017: when an egress is declared, the agent goes through
egress-proxy (which in turn uses HTTPS_PROXY=pipelock on its egress (which in turn uses HTTPS_PROXY=pipelock on its
outbound leg). Otherwise the agent talks straight to pipelock — outbound leg). Otherwise the agent talks straight to pipelock —
keeps the network surface minimal for bottles that don't need keeps the network surface minimal for bottles that don't need
path filtering or credential injection.""" path filtering or credential injection."""
if plan.egress_proxy_plan.routes: if plan.egress_plan.routes:
return egress_proxy_url() return egress_url()
return pipelock_proxy_url(plan.slug) return pipelock_proxy_url(plan.slug)
@@ -245,7 +245,7 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
# httpoxy mitigation makes it ignore uppercase `HTTP_PROXY` for # httpoxy mitigation makes it ignore uppercase `HTTP_PROXY` for
# `http://` URLs and only honor lowercase `http_proxy`. Without # `http://` URLs and only honor lowercase `http_proxy`. Without
# the lowercase var, plain-HTTP requests from the agent bypass # the lowercase var, plain-HTTP requests from the agent bypass
# egress-proxy entirely (going direct, then failing with # egress entirely (going direct, then failing with
# "network unreachable" because the agent's bridge is # "network unreachable" because the agent's bridge is
# --internal). Lowercase HTTPS_PROXY isn't strictly needed but # --internal). Lowercase HTTPS_PROXY isn't strictly needed but
# we set it for symmetry — some tools check one or the other. # we set it for symmetry — some tools check one or the other.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Docker network plumbing for the per-agent egress-proxy topology. """Docker network plumbing for the per-agent egress topology.
The agent container sits on a Docker `--internal` network (no default The agent container sits on a Docker `--internal` network (no default
gateway). Pipelock straddles that network and a per-agent user-defined gateway). Pipelock straddles that network and a per-agent user-defined
+13 -13
View File
@@ -19,7 +19,7 @@ from ...log import die
from .. import BottleSpec from .. import BottleSpec
from . import util as docker_mod from . import util as docker_mod
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .egress_proxy import DockerEgressProxy, egress_proxy_container_name from .egress import DockerEgress, egress_container_name
from .git_gate import DockerGitGate, git_gate_container_name from .git_gate import DockerGitGate, git_gate_container_name
from .bottle_state import ( from .bottle_state import (
BottleMetadata, BottleMetadata,
@@ -40,7 +40,7 @@ def resolve_plan(
stage_dir: Path, stage_dir: Path,
proxy: DockerPipelockProxy, proxy: DockerPipelockProxy,
git_gate: DockerGitGate, git_gate: DockerGitGate,
egress_proxy: DockerEgressProxy, egress: DockerEgress,
supervise: DockerSupervise, supervise: DockerSupervise,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts """Resolve Docker-specific names and write scratch files. Trusts
@@ -122,14 +122,14 @@ def resolve_plan(
# actionable hint. Fail fast here with a cleanup pointer instead. # actionable hint. Fail fast here with a cleanup pointer instead.
# Only probe sidecars this launch will actually try to create: # Only probe sidecars this launch will actually try to create:
# pipelock always; git-gate when bottle.git is non-empty; # pipelock always; git-gate when bottle.git is non-empty;
# egress-proxy when bottle.egress.routes is non-empty. # egress when bottle.egress.routes is non-empty.
sidecar_probes: list[tuple[str, str]] = [ sidecar_probes: list[tuple[str, str]] = [
("pipelock", pipelock_container_name(slug)), ("pipelock", pipelock_container_name(slug)),
] ]
if bottle.git: if bottle.git:
sidecar_probes.append(("git-gate", git_gate_container_name(slug))) sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
if bottle.egress.routes: if bottle.egress.routes:
sidecar_probes.append(("egress-proxy", egress_proxy_container_name(slug))) sidecar_probes.append(("egress", egress_container_name(slug)))
if bottle.supervise: if bottle.supervise:
sidecar_probes.append(("supervise", supervise_container_name(slug))) sidecar_probes.append(("supervise", supervise_container_name(slug)))
for label, sidecar_name in sidecar_probes: for label, sidecar_name in sidecar_probes:
@@ -148,7 +148,7 @@ def resolve_plan(
proxy_plan = proxy.prepare(bottle, slug, stage_dir) proxy_plan = proxy.prepare(bottle, slug, stage_dir)
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir) git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir) egress_plan = egress.prepare(bottle, slug, stage_dir)
supervise_plan = None supervise_plan = None
if bottle.supervise: if bottle.supervise:
# Current Dockerfile for the agent image. Read from the repo # Current Dockerfile for the agent image. Read from the repo
@@ -157,7 +157,7 @@ def resolve_plan(
# is just a workspace copy). # is just a workspace copy).
# (routes.yaml + pipelock allowlist used to land here too but # (routes.yaml + pipelock allowlist used to land here too but
# PRD 0017 chunk 3 moved them behind the # PRD 0017 chunk 3 moved them behind the
# `list-egress-proxy-routes` MCP tool so the agent gets live # `list-egress-routes` MCP tool so the agent gets live
# state rather than a launch-time snapshot.) # state rather than a launch-time snapshot.)
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else "" dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
@@ -170,22 +170,22 @@ def resolve_plan(
# never lands on argv or in env_file) goes into one dict. Nothing # never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ. # mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded) forwarded_env: dict[str, str] = dict(resolved.forwarded)
# When the bottle declares an egress-proxy route with the # When the bottle declares an egress route with the
# `claude_code_oauth` role marker, claude-code's outbound # `claude_code_oauth` role marker, claude-code's outbound
# Authorization gets stripped + re-injected by egress-proxy. The # Authorization gets stripped + re-injected by egress. The
# agent's environ still needs *something* claude-code recognises # agent's environ still needs *something* claude-code recognises
# as a credential or it refuses to start; ship a non-secret # as a credential or it refuses to start; ship a non-secret
# placeholder. The placeholder isn't any real token value, so # placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress-proxy is in # leaking it would tell an attacker only that egress is in
# front. Manifest validation enforces singleton on this role. # front. Manifest validation enforces singleton on this role.
has_anthropic_auth = any( has_anthropic_auth = any(
"claude_code_oauth" in r.roles "claude_code_oauth" in r.roles
for r in egress_proxy_plan.routes for r in egress_plan.routes
) )
if has_anthropic_auth: if has_anthropic_auth:
forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-proxy-placeholder" forwarded_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
# Belt-and-braces: turn off telemetry endpoints (statsig, # Belt-and-braces: turn off telemetry endpoints (statsig,
# error reporting) that egress-proxy can't gate by auth. # error reporting) that egress can't gate by auth.
forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1") forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1")
_write_env_file(resolved, env_file) _write_env_file(resolved, env_file)
@@ -208,7 +208,7 @@ def resolve_plan(
prompt_file=prompt_file, prompt_file=prompt_file,
proxy_plan=proxy_plan, proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_proxy_plan=egress_proxy_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
use_runsc=use_runsc, use_runsc=use_runsc,
) )
+10 -10
View File
@@ -3,16 +3,16 @@ store.
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target: Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target:
- Bottle declares `egress_proxy.routes[]` → agent's HTTP_PROXY - Bottle declares `egress.routes[]` → agent's HTTP_PROXY
points at egress-proxy; the cert the agent must trust is the points at egress; the cert the agent must trust is the
one egress-proxy mints leaf certs with (the egress-proxy CA). one egress mints leaf certs with (the egress CA).
- No egress_proxy routes → agent's HTTP_PROXY points straight at - No egress routes → agent's HTTP_PROXY points straight at
pipelock; the cert the agent must trust is pipelock's CA (the pipelock; the cert the agent must trust is pipelock's CA (the
pre-cutover behavior). pre-cutover behavior).
By the time this provisioner runs, the corresponding `tls_init` By the time this provisioner runs, the corresponding `tls_init`
helper has generated the chosen CA under `plan.stage_dir`, and the helper has generated the chosen CA under `plan.stage_dir`, and the
sidecar (pipelock or egress-proxy) is up referencing the sidecar (pipelock or egress) is up referencing the
in-container CA paths. in-container CA paths.
Cert lands on Debian's standard source path Cert lands on Debian's standard source path
@@ -52,16 +52,16 @@ def _select_ca_cert(plan: DockerBottlePlan) -> tuple[Path, str]:
matches the proxy the agent's HTTP_PROXY points at. Egress-proxy matches the proxy the agent's HTTP_PROXY points at. Egress-proxy
wins when the bottle declares any routes (it sits in front of wins when the bottle declares any routes (it sits in front of
pipelock); else pipelock.""" pipelock); else pipelock."""
if plan.egress_proxy_plan.routes: if plan.egress_plan.routes:
cert = plan.egress_proxy_plan.mitmproxy_ca_cert_only_host_path cert = plan.egress_plan.mitmproxy_ca_cert_only_host_path
if cert == Path() or not cert.is_file(): if cert == Path() or not cert.is_file():
from ....log import die from ....log import die
die( die(
f"egress-proxy CA cert missing at {cert or '(empty)'}; " f"egress CA cert missing at {cert or '(empty)'}; "
f"launch must have called egress_proxy_tls_init and " f"launch must have called egress_tls_init and "
f"re-bound the plan before provision" f"re-bound the plan before provision"
) )
return cert, "egress-proxy" return cert, "egress"
cert = plan.proxy_plan.ca_cert_host_path cert = plan.proxy_plan.ca_cert_host_path
if not cert or not cert.is_file(): if not cert or not cert.is_file():
from ....log import die from ....log import die
+22 -22
View File
@@ -3,8 +3,8 @@ act on them (approve / modify / reject). PRD 0013 v1.
Curses-based TUI; modify-then-approve shells out to $EDITOR. The Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handlers wire to the per-tool remediation engines: approval handlers wire to the per-tool remediation engines:
PRD 0014 (egress-proxy, retargeted from cred-proxy in PRD 0017 PRD 0014 (egress, retargeted from cred-proxy in PRD 0017
chunk 3) writes routes.yaml + SIGHUPs egress-proxy; PRD 0015 chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015
(pipelock) writes the allowlist + restarts pipelock; PRD 0016 (pipelock) writes the allowlist + restarts pipelock; PRD 0016
(capability) rebuilds the bottle Dockerfile. (capability) rebuilds the bottle Dockerfile.
""" """
@@ -27,8 +27,8 @@ from ..backend.docker.capability_apply import (
CapabilityApplyError, CapabilityApplyError,
apply_capability_change, apply_capability_change,
) )
from ..backend.docker.egress_proxy_apply import ( from ..backend.docker.egress_apply import (
EgressProxyApplyError, EgressApplyError,
add_route, add_route,
apply_routes_change, apply_routes_change,
fetch_current_routes, fetch_current_routes,
@@ -51,7 +51,7 @@ from ..supervise import (
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK, TOOL_PIPELOCK_BLOCK,
archive_proposal, archive_proposal,
list_pending_proposals, list_pending_proposals,
@@ -65,7 +65,7 @@ from ._common import PROG
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps # handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses. # the proposal pending rather than crashing curses.
ApplyError = (EgressProxyApplyError, PipelockApplyError, CapabilityApplyError) ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError)
# --- Discovery ------------------------------------------------------------- # --- Discovery -------------------------------------------------------------
@@ -104,10 +104,10 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]:
return sorted(out) return sorted(out)
def discover_egress_proxy_slugs() -> list[str]: def discover_egress_slugs() -> list[str]:
"""Slugs of bottles with a running egress-proxy sidecar. Used by """Slugs of bottles with a running egress sidecar. Used by
the operator-initiated `routes edit` verb.""" the operator-initiated `routes edit` verb."""
return _discover_sidecar_slugs("claude-bottle-egress-proxy-") return _discover_sidecar_slugs("claude-bottle-egress-")
def discover_pipelock_slugs() -> list[str]: def discover_pipelock_slugs() -> list[str]:
@@ -157,7 +157,7 @@ def approve(
entry. If `final_file` is provided the status is `modified`; entry. If `final_file` is provided the status is `modified`;
otherwise `approved`. otherwise `approved`.
Raises EgressProxyApplyError if the egress-proxy-block apply Raises EgressApplyError if the egress-block apply
fails (sidecar down, invalid routes content survived the fails (sidecar down, invalid routes content survived the
operator's modify). On failure no response is written and no operator's modify). On failure no response is written and no
audit entry is appended — the proposal stays pending so the audit entry is appended — the proposal stays pending so the
@@ -166,9 +166,9 @@ def approve(
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", "" diff_before, diff_after = "", ""
if qp.proposal.tool == TOOL_EGRESS_PROXY_BLOCK: if qp.proposal.tool == TOOL_EGRESS_BLOCK:
# The proposal is a single-route JSON; add_route fetches the # The proposal is a single-route JSON; add_route fetches the
# current routes from the running egress-proxy, merges the # current routes from the running egress, merges the
# new route in, and applies the full merged file. The # new route in, and applies the full merged file. The
# audit log gets the BEFORE/AFTER of the full file so the # audit log gets the BEFORE/AFTER of the full file so the
# diff renders cleanly even though the agent only proposed # diff renders cleanly even though the agent only proposed
@@ -225,16 +225,16 @@ def operator_edit_routes(slug: str, new_content: str) -> tuple[str, str]:
apply_routes_change. Writes an audit entry tagged apply_routes_change. Writes an audit entry tagged
ACTION_OPERATOR_EDIT to distinguish from tool-call approvals. ACTION_OPERATOR_EDIT to distinguish from tool-call approvals.
Raises EgressProxyApplyError on failure.""" Raises EgressApplyError on failure."""
before, after = apply_routes_change(slug, new_content) before, after = apply_routes_change(slug, new_content)
write_audit_entry(AuditEntry( write_audit_entry(AuditEntry(
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(timezone.utc).isoformat(),
bottle_slug=slug, bottle_slug=slug,
component="egress-proxy", component="egress",
operator_action=ACTION_OPERATOR_EDIT, operator_action=ACTION_OPERATOR_EDIT,
operator_notes="", operator_notes="",
justification="", justification="",
diff=render_diff(before, after, label="egress-proxy"), diff=render_diff(before, after, label="egress"),
)) ))
return before, after return before, after
@@ -254,8 +254,8 @@ def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]:
Path-level enforcement was the open question this function's Path-level enforcement was the open question this function's
earlier docstring flagged; PRD 0017 answered it by putting earlier docstring flagged; PRD 0017 answered it by putting
egress-proxy in front of pipelock. The agent's egress in front of pipelock. The agent's
`egress-proxy-block` tool now proposes routes.yaml changes that `egress-block` tool now proposes routes.yaml changes that
can include a `path_allowlist`. Use that tool for path-level can include a `path_allowlist`. Use that tool for path-level
follow-ups; this one stays hostname-only because pipelock is follow-ups; this one stays hostname-only because pipelock is
still the last hostname gate before egress.""" still the last hostname gate before egress."""
@@ -302,11 +302,11 @@ def _write_audit(
diff_before: str, diff_before: str,
diff_after: str, diff_after: str,
) -> None: ) -> None:
"""Audit log for egress-proxy / pipelock tools. capability-block """Audit log for egress / pipelock tools. capability-block
has no audit log (its changes are captured by the bottle's has no audit log (its changes are captured by the bottle's
rebuild record + git history per PRD 0016). rebuild record + git history per PRD 0016).
For egress-proxy-block + pipelock-block approvals the (before, For egress-block + pipelock-block approvals the (before,
after) come from the apply_*_change return — a real after) come from the apply_*_change return — a real
fetched-from-sidecar diff. For rejections both are empty strings fetched-from-sidecar diff. For rejections both are empty strings
and the audit diff renders as empty.""" and the audit diff renders as empty."""
@@ -688,19 +688,19 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
def _suffix_for_tool(tool: str) -> str: def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK: if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile" return ".dockerfile"
# egress-proxy-block / pipelock-block: JSON-ish + plain. # egress-block / pipelock-block: JSON-ish + plain.
return ".txt" return ".txt"
def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str: def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str:
"""Operator-initiated routes.yaml edit. Discover running """Operator-initiated routes.yaml edit. Discover running
egress-proxy sidecars, pick one (single → use directly; multi → egress sidecars, pick one (single → use directly; multi →
prompt), fetch the current routes, open in $EDITOR, apply on prompt), fetch the current routes, open in $EDITOR, apply on
save. Returns a status-line message.""" save. Returns a status-line message."""
return _operator_edit_flow( return _operator_edit_flow(
stdscr, stdscr,
label="routes", label="routes",
discover=discover_egress_proxy_slugs, discover=discover_egress_slugs,
fetch=fetch_current_routes, fetch=fetch_current_routes,
apply=operator_edit_routes, apply=operator_edit_routes,
suffix=".yaml", suffix=".yaml",
@@ -10,16 +10,16 @@ owns three jobs:
3. Inject `Authorization` headers for routes that declare an 3. Inject `Authorization` headers for routes that declare an
`auth` block, the same way cred-proxy does today. `auth` block, the same way cred-proxy does today.
This module defines the abstract proxy (`EgressProxy`), its plan This module defines the abstract proxy (`Egress`), its plan
dataclass (`EgressProxyPlan`), and the resolved per-route shape dataclass (`EgressPlan`), and the resolved per-route shape
(`EgressProxyRoute`). The sidecar's start/stop lifecycle is backend- (`EgressRoute`). The sidecar's start/stop lifecycle is backend-
specific and lives on concrete subclasses (see specific and lives on concrete subclasses (see
`claude_bottle/backend/docker/egress_proxy.py`). `claude_bottle/backend/docker/egress.py`).
Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker Chunks 1+2 of the PRD: this module + the mitmproxy addon + the Docker
lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy
has been removed. Chunk 3 retargets the cred-proxy-block remediation has been removed. Chunk 3 retargets the cred-proxy-block remediation
flow (PRD 0014) at egress-proxy and renames the MCP tool. flow (PRD 0014) at egress and renames the MCP tool.
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,23 +33,23 @@ from .log import die
from .manifest import Bottle from .manifest import Bottle
# DNS name agents will dial for the per-bottle egress-proxy sidecar. # DNS name agents will dial for the per-bottle egress sidecar.
# Backend-agnostic by contract: every concrete backend (Docker today, # Backend-agnostic by contract: every concrete backend (Docker today,
# others later) attaches this name to its sidecar on the bottle's # others later) attaches this name to its sidecar on the bottle's
# internal network. The agent's `HTTP_PROXY` env var resolves to # internal network. The agent's `HTTP_PROXY` env var resolves to
# `http://egress-proxy:<port>` once chunk 2 cuts over. # `http://egress:<port>` once chunk 2 cuts over.
EGRESS_PROXY_HOSTNAME = "egress-proxy" EGRESS_HOSTNAME = "egress"
# In-container path the addon reads. Pre-created in # In-container path the addon reads. Pre-created in
# `Dockerfile.egress-proxy` so `docker cp` can drop the file directly. # `Dockerfile.egress` so `docker cp` can drop the file directly.
# `.yaml` extension per PRD 0017 — content is JSON (valid YAML) so # `.yaml` extension per PRD 0017 — content is JSON (valid YAML) so
# both sides can use stdlib `json`. # both sides can use stdlib `json`.
EGRESS_PROXY_ROUTES_IN_CONTAINER = "/etc/egress-proxy/routes.yaml" EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressProxyRoute: class EgressRoute:
"""One resolved route on the egress-proxy sidecar. """One resolved route on the egress sidecar.
`host` matches the request's hostname (case-insensitive). The `host` matches the request's hostname (case-insensitive). The
optional `path_allowlist` constrains the URL path; empty tuple optional `path_allowlist` constrains the URL path; empty tuple
@@ -58,14 +58,14 @@ class EgressProxyRoute:
strings mean "no auth injection" (the manifest's nested `auth` strings mean "no auth injection" (the manifest's nested `auth`
block was omitted). block was omitted).
`token_env` is the env-var slot inside the egress-proxy container `token_env` is the env-var slot inside the egress container
(e.g. `EGRESS_PROXY_TOKEN_0`); `token_ref` is the host env var (e.g. `EGRESS_TOKEN_0`); `token_ref` is the host env var
the CLI reads at launch and forwards into the container's environ the CLI reads at launch and forwards into the container's environ
under `token_env`. Routes that share a `token_ref` coalesce to under `token_env`. Routes that share a `token_ref` coalesce to
one `token_env` slot. one `token_env` slot.
`roles` carries the manifest route's optional role markers (see `roles` carries the manifest route's optional role markers (see
`manifest.EGRESS_PROXY_ROLES`). The launch step reads these for `manifest.EGRESS_ROLES`). The launch step reads these for
side effects like the claude-code OAuth placeholder env.""" side effects like the claude-code OAuth placeholder env."""
host: str host: str
@@ -77,8 +77,8 @@ class EgressProxyRoute:
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressProxyPlan: class EgressPlan:
"""Output of EgressProxy.prepare; consumed by .start. """Output of Egress.prepare; consumed by .start.
The slug + routes_path + routes + token_env_map fields are The slug + routes_path + routes + token_env_map fields are
filled at prepare time (host-side, side-effect-free on docker). filled at prepare time (host-side, side-effect-free on docker).
@@ -89,13 +89,13 @@ class EgressProxyPlan:
`token_env_map` is `{<token_env in container>: <token_ref on host>}`. `token_env_map` is `{<token_env in container>: <token_ref on host>}`.
The backend's start step reads `os.environ[token_ref]` and The backend's start step reads `os.environ[token_ref]` and
forwards the value into the egress-proxy container's environ forwards the value into the egress container's environ
under `token_env`. The plan itself never holds token values under `token_env`. The plan itself never holds token values
secrets never land in a dataclass that might be logged. secrets never land in a dataclass that might be logged.
`mitmproxy_ca_host_path` is the host path of the per-bottle `mitmproxy_ca_host_path` is the host path of the per-bottle
egress-proxy CA (single PEM with cert+key concatenated) minted egress CA (single PEM with cert+key concatenated) minted
by `egress_proxy_tls_init`. `.start` docker-cps it into the by `egress_tls_init`. `.start` docker-cps it into the
sidecar at `~/.mitmproxy/mitmproxy-ca.pem` mitmproxy reads sidecar at `~/.mitmproxy/mitmproxy-ca.pem` mitmproxy reads
that file at boot to mint per-host leaf certs. that file at boot to mint per-host leaf certs.
@@ -107,17 +107,17 @@ class EgressProxyPlan:
`pipelock_ca_host_path` is the host path of the pipelock CA `pipelock_ca_host_path` is the host path of the pipelock CA
(cert only). `.start` docker-cps it into the sidecar so the (cert only). `.start` docker-cps it into the sidecar so the
proxy's outbound HTTPS client trusts pipelock's MITM on the proxy's outbound HTTPS client trusts pipelock's MITM on the
egress-proxy upstream leg. egress upstream leg.
`pipelock_proxy_url` is the URL egress-proxy sets as `HTTPS_PROXY` `pipelock_proxy_url` is the URL egress sets as `HTTPS_PROXY`
in its environ so outbound HTTPS traverses pipelock keeping in its environ so outbound HTTPS traverses pipelock keeping
pipelock's hostname allowlist + DLP body scanner on the pipelock's hostname allowlist + DLP body scanner on the
egress-proxy upstream leg. egress upstream leg.
""" """
slug: str slug: str
routes_path: Path routes_path: Path
routes: tuple[EgressProxyRoute, ...] routes: tuple[EgressRoute, ...]
token_env_map: dict[str, str] token_env_map: dict[str, str]
internal_network: str = "" internal_network: str = ""
egress_network: str = "" egress_network: str = ""
@@ -128,11 +128,11 @@ class EgressProxyPlan:
# Hosts the agent needs by default for claude-code itself. Folded # Hosts the agent needs by default for claude-code itself. Folded
# into every bottle's egress-proxy routes table as bare-pass entries # into every bottle's egress routes table as bare-pass entries
# (no auth, no path filter) so the agent reaches them without each # (no auth, no path filter) so the agent reaches them without each
# bottle having to opt in. Pipelock used to own this list; PRD 0017 # bottle having to opt in. Pipelock used to own this list; PRD 0017
# moves it to egress-proxy because egress-proxy is the primary gate # moves it to egress because egress is the primary gate
# now and pipelock's allowlist is mirrored from egress-proxy. # now and pipelock's allowlist is mirrored from egress.
DEFAULT_ALLOWLIST: tuple[str, ...] = ( DEFAULT_ALLOWLIST: tuple[str, ...] = (
"api.anthropic.com", "api.anthropic.com",
"statsig.anthropic.com", "statsig.anthropic.com",
@@ -144,32 +144,32 @@ DEFAULT_ALLOWLIST: tuple[str, ...] = (
) )
def egress_proxy_manifest_routes( def egress_manifest_routes(
bottle: Bottle, bottle: Bottle,
) -> tuple[EgressProxyRoute, ...]: ) -> tuple[EgressRoute, ...]:
"""Lift each `bottle.egress.routes[]` manifest entry into a """Lift each `bottle.egress.routes[]` manifest entry into a
resolved EgressProxyRoute. Order is preserved so route lookup at resolved EgressRoute. Order is preserved so route lookup at
the proxy is stable. the proxy is stable.
Token-env slots are assigned per distinct `token_ref`: the first Token-env slots are assigned per distinct `token_ref`: the first
authenticated route with `token_ref` "GH_PAT" gets authenticated route with `token_ref` "GH_PAT" gets
`EGRESS_PROXY_TOKEN_0`; a second route with the same `token_ref` `EGRESS_TOKEN_0`; a second route with the same `token_ref`
shares slot 0. Unauthenticated routes (`auth` omitted) contribute shares slot 0. Unauthenticated routes (`auth` omitted) contribute
no slot. no slot.
Does NOT include the folded-in DEFAULT_ALLOWLIST / Does NOT include the folded-in DEFAULT_ALLOWLIST /
bottle.egress.allowlist bare-pass entries see bottle.egress.allowlist bare-pass entries see
`egress_proxy_routes_for_bottle` for the effective set the `egress_routes_for_bottle` for the effective set the
addon enforces.""" addon enforces."""
out: list[EgressProxyRoute] = [] out: list[EgressRoute] = []
slot_for_token: dict[str, str] = {} slot_for_token: dict[str, str] = {}
for r in bottle.egress.routes: for r in bottle.egress.routes:
if r.AuthScheme and r.TokenRef: if r.AuthScheme and r.TokenRef:
token_env = slot_for_token.get(r.TokenRef) token_env = slot_for_token.get(r.TokenRef)
if token_env is None: if token_env is None:
token_env = f"EGRESS_PROXY_TOKEN_{len(slot_for_token)}" token_env = f"EGRESS_TOKEN_{len(slot_for_token)}"
slot_for_token[r.TokenRef] = token_env slot_for_token[r.TokenRef] = token_env
out.append(EgressProxyRoute( out.append(EgressRoute(
host=r.Host, host=r.Host,
path_allowlist=r.PathAllowlist, path_allowlist=r.PathAllowlist,
auth_scheme=r.AuthScheme, auth_scheme=r.AuthScheme,
@@ -178,7 +178,7 @@ def egress_proxy_manifest_routes(
roles=r.Role, roles=r.Role,
)) ))
else: else:
out.append(EgressProxyRoute( out.append(EgressRoute(
host=r.Host, host=r.Host,
path_allowlist=r.PathAllowlist, path_allowlist=r.PathAllowlist,
roles=r.Role, roles=r.Role,
@@ -186,10 +186,10 @@ def egress_proxy_manifest_routes(
return tuple(out) return tuple(out)
def egress_proxy_routes_for_bottle( def egress_routes_for_bottle(
bottle: Bottle, bottle: Bottle,
) -> tuple[EgressProxyRoute, ...]: ) -> tuple[EgressRoute, ...]:
"""Effective egress-proxy routes: manifest routes followed by """Effective egress routes: manifest routes followed by
bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what
gets rendered into routes.yaml + what the addon enforces. gets rendered into routes.yaml + what the addon enforces.
@@ -201,25 +201,25 @@ def egress_proxy_routes_for_bottle(
DEFAULT_ALLOWLIST declare it directly in DEFAULT_ALLOWLIST declare it directly in
`bottle.egress.routes` as a bare-pass entry `bottle.egress.routes` as a bare-pass entry
(`- host: <name>`). The legacy `bottle.egress.allowlist` (`- host: <name>`). The legacy `bottle.egress.allowlist`
folding is gone egress_proxy is the single allowlist surface.""" folding is gone egress is the single allowlist surface."""
out: list[EgressProxyRoute] = list(egress_proxy_manifest_routes(bottle)) out: list[EgressRoute] = list(egress_manifest_routes(bottle))
claimed: set[str] = {r.host.lower() for r in out} claimed: set[str] = {r.host.lower() for r in out}
for host in DEFAULT_ALLOWLIST: for host in DEFAULT_ALLOWLIST:
if host.lower() not in claimed: if host.lower() not in claimed:
out.append(EgressProxyRoute(host=host)) out.append(EgressRoute(host=host))
claimed.add(host.lower()) claimed.add(host.lower())
return tuple(out) return tuple(out)
def egress_proxy_token_env_map( def egress_token_env_map(
routes: tuple[EgressProxyRoute, ...], routes: tuple[EgressRoute, ...],
) -> dict[str, str]: ) -> dict[str, str]:
"""Collapse the route list into `{token_env: token_ref}` for the """Collapse the route list into `{token_env: token_ref}` for the
authenticated routes. Routes without `auth` contribute no entry. authenticated routes. Routes without `auth` contribute no entry.
Conflict detection: two routes that share a `token_env` slot but Conflict detection: two routes that share a `token_env` slot but
name different `token_ref` host vars is a programming error in name different `token_ref` host vars is a programming error in
`egress_proxy_routes_for_bottle`; surface it as a die rather than `egress_routes_for_bottle`; surface it as a die rather than
silently picking one.""" silently picking one."""
out: dict[str, str] = {} out: dict[str, str] = {}
for r in routes: for r in routes:
@@ -228,7 +228,7 @@ def egress_proxy_token_env_map(
existing = out.get(r.token_env) existing = out.get(r.token_env)
if existing is not None and existing != r.token_ref: if existing is not None and existing != r.token_ref:
die( die(
f"egress-proxy plan conflict: {r.token_env} maps to both " f"egress plan conflict: {r.token_env} maps to both "
f"{existing!r} and {r.token_ref!r}. Two routes sharing a " f"{existing!r} and {r.token_ref!r}. Two routes sharing a "
f"token slot must reference the same host env var." f"token slot must reference the same host env var."
) )
@@ -236,8 +236,8 @@ def egress_proxy_token_env_map(
return out return out
def egress_proxy_render_routes( def egress_render_routes(
routes: tuple[EgressProxyRoute, ...], routes: tuple[EgressRoute, ...],
) -> str: ) -> str:
"""Serialize the route table for the addon to read. """Serialize the route table for the addon to read.
@@ -262,7 +262,7 @@ def egress_proxy_render_routes(
return json.dumps(payload, indent=2, sort_keys=False) + "\n" return json.dumps(payload, indent=2, sort_keys=False) + "\n"
def egress_proxy_resolve_token_values( def egress_resolve_token_values(
token_env_map: dict[str, str], token_env_map: dict[str, str],
host_env: dict[str, str], host_env: dict[str, str],
) -> dict[str, str]: ) -> dict[str, str]:
@@ -277,27 +277,27 @@ def egress_proxy_resolve_token_values(
value = host_env.get(token_ref) value = host_env.get(token_ref)
if value is None: if value is None:
die( die(
f"egress-proxy: host env var '{token_ref}' is unset. Set it " f"egress: host env var '{token_ref}' is unset. Set it "
f"before launching, or remove the corresponding auth block " f"before launching, or remove the corresponding auth block "
f"from bottle.egress.routes." f"from bottle.egress.routes."
) )
if not value: if not value:
die( die(
f"egress-proxy: host env var '{token_ref}' is empty. The " f"egress: host env var '{token_ref}' is empty. The "
f"egress-proxy will not inject an empty token; set it to " f"egress will not inject an empty token; set it to "
f"the real value or remove the route's auth block." f"the real value or remove the route's auth block."
) )
out[token_env] = value out[token_env] = value
return out return out
class EgressProxy(ABC): class Egress(ABC):
"""The per-bottle egress proxy. Encapsulates the host-side prepare """The per-bottle egress proxy. Encapsulates the host-side prepare
(route lift + routes.yaml render + token-env-map derivation); the (route lift + routes.yaml render + token-env-map derivation); the
sidecar's start/stop lifecycle is backend-specific and lives on sidecar's start/stop lifecycle is backend-specific and lives on
concrete subclasses.""" concrete subclasses."""
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> EgressProxyPlan: def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> EgressPlan:
"""Lift `bottle.egress.routes` into resolved routes, """Lift `bottle.egress.routes` into resolved routes,
render the routes file (mode 600) under `stage_dir`, and render the routes file (mode 600) under `stage_dir`, and
return the plan. Pure host-side, no docker subprocess. The return the plan. Pure host-side, no docker subprocess. The
@@ -308,40 +308,40 @@ class EgressProxy(ABC):
Returned plan is incomplete: the launch step must fill Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` / `pipelock_proxy_url` `internal_network` / `egress_network` / `pipelock_proxy_url`
via `dataclasses.replace` before passing it to `.start`.""" via `dataclasses.replace` before passing it to `.start`."""
routes = egress_proxy_routes_for_bottle(bottle) routes = egress_routes_for_bottle(bottle)
routes_path = stage_dir / "egress_proxy_routes.yaml" routes_path = stage_dir / "egress_routes.yaml"
routes_path.write_text(egress_proxy_render_routes(routes)) routes_path.write_text(egress_render_routes(routes))
routes_path.chmod(0o600) routes_path.chmod(0o600)
return EgressProxyPlan( return EgressPlan(
slug=slug, slug=slug,
routes_path=routes_path, routes_path=routes_path,
routes=routes, routes=routes,
token_env_map=egress_proxy_token_env_map(routes), token_env_map=egress_token_env_map(routes),
) )
@abstractmethod @abstractmethod
def start(self, plan: EgressProxyPlan) -> str: def start(self, plan: EgressPlan) -> str:
"""Bring up the egress-proxy sidecar according to `plan`. """Bring up the egress sidecar according to `plan`.
Returns the target string identifying the running instance Returns the target string identifying the running instance
the same value to pass to `.stop`. Backend-specific.""" the same value to pass to `.stop`. Backend-specific."""
@abstractmethod @abstractmethod
def stop(self, target: str) -> None: def stop(self, target: str) -> None:
"""Tear down the egress-proxy sidecar identified by `target` """Tear down the egress sidecar identified by `target`
(the value `.start` returned). Idempotent: a missing target (the value `.start` returned). Idempotent: a missing target
is success. Backend-specific.""" is success. Backend-specific."""
__all__ = [ __all__ = [
"DEFAULT_ALLOWLIST", "DEFAULT_ALLOWLIST",
"EGRESS_PROXY_HOSTNAME", "EGRESS_HOSTNAME",
"EGRESS_PROXY_ROUTES_IN_CONTAINER", "EGRESS_ROUTES_IN_CONTAINER",
"EgressProxy", "Egress",
"EgressProxyPlan", "EgressPlan",
"EgressProxyRoute", "EgressRoute",
"egress_proxy_manifest_routes", "egress_manifest_routes",
"egress_proxy_render_routes", "egress_render_routes",
"egress_proxy_resolve_token_values", "egress_resolve_token_values",
"egress_proxy_routes_for_bottle", "egress_routes_for_bottle",
"egress_proxy_token_env_map", "egress_token_env_map",
] ]
@@ -1,11 +1,11 @@
"""mitmproxy addon entrypoint for the egress-proxy sidecar (PRD 0017). """mitmproxy addon entrypoint for the egress sidecar (PRD 0017).
Loaded by `mitmdump -s /app/egress_proxy_addon.py` inside the Loaded by `mitmdump -s /app/egress_addon.py` inside the
egress-proxy container. Wraps the pure logic from egress container. Wraps the pure logic from
`egress_proxy_addon_core` with mitmproxy's HTTPFlow API: `egress_addon_core` with mitmproxy's HTTPFlow API:
- At startup, read `EGRESS_PROXY_ROUTES` (default - At startup, read `EGRESS_ROUTES` (default
`/etc/egress-proxy/routes.yaml`, JSON content) routes table. `/etc/egress/routes.yaml`, JSON content) routes table.
- SIGHUP re-reads the file and atomically swaps the in-memory - SIGHUP re-reads the file and atomically swaps the in-memory
table. A parse error keeps the old table in place better to table. A parse error keeps the old table in place better to
keep serving the old config than to leave the proxy with no keep serving the old config than to leave the proxy with no
@@ -16,10 +16,10 @@ egress-proxy container. Wraps the pure logic from
This file imports `mitmproxy` and is never imported on the host This file imports `mitmproxy` and is never imported on the host
mitmproxy is a container-only dependency. The host's tests target mitmproxy is a container-only dependency. The host's tests target
`egress_proxy_addon_core`. `egress_addon_core`.
Dockerfile.egress-proxy copies both this file and Dockerfile.egress copies both this file and
`egress_proxy_addon_core.py` flat into `/app/`; the absolute import `egress_addon_core.py` flat into `/app/`; the absolute import
below works because mitmdump runs with `/app` on its sys.path. The below works because mitmdump runs with `/app` on its sys.path. The
parallel file in the package source tree (claude_bottle/) is the parallel file in the package source tree (claude_bottle/) is the
build input not a module the host imports.""" build input not a module the host imports."""
@@ -35,32 +35,32 @@ from pathlib import Path
from mitmproxy import http # type: ignore[import-not-found] from mitmproxy import http # type: ignore[import-not-found]
# Absolute import (NOT `from .egress_proxy_addon_core`) — the # Absolute import (NOT `from .egress_addon_core`) — the
# container drops both files flat into /app/ so they are sibling # container drops both files flat into /app/ so they are sibling
# top-level modules to mitmdump's loader, not a package. # top-level modules to mitmdump's loader, not a package.
from egress_proxy_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found] from egress_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found]
DEFAULT_ROUTES_PATH = "/etc/egress-proxy/routes.yaml" DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
# Magic hostname the addon recognises as an introspection target. # Magic hostname the addon recognises as an introspection target.
# Requests through the proxy for `_egress-proxy.local/<path>` are # Requests through the proxy for `_egress.local/<path>` are
# intercepted and answered with synthetic responses (the addon's # intercepted and answered with synthetic responses (the addon's
# `request` hook sets `flow.response` before any upstream connection). # `request` hook sets `flow.response` before any upstream connection).
# The hostname is not in DNS — only clients dialing through this # The hostname is not in DNS — only clients dialing through this
# specific egress-proxy can reach it, and only via HTTP (no TLS). # specific egress can reach it, and only via HTTP (no TLS).
# Used by the supervise sidecar's `list-egress-proxy-routes` MCP # Used by the supervise sidecar's `list-egress-routes` MCP
# tool to surface the live route table to the agent. # tool to surface the live route table to the agent.
INTROSPECT_HOST = "_egress-proxy.local" INTROSPECT_HOST = "_egress.local"
class EgressProxyAddon: class EgressAddon:
"""The mitmproxy addon. One instance per `mitmdump` process; the """The mitmproxy addon. One instance per `mitmdump` process; the
request hook is invoked on every CONNECT-decapsulated HTTP/HTTPS request hook is invoked on every CONNECT-decapsulated HTTP/HTTPS
request the agent makes.""" request the agent makes."""
def __init__(self) -> None: def __init__(self) -> None:
self.routes_path = os.environ.get("EGRESS_PROXY_ROUTES", DEFAULT_ROUTES_PATH) self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
self.routes: tuple[Route, ...] = () self.routes: tuple[Route, ...] = ()
self._reload(initial=True) self._reload(initial=True)
self._install_sighup() self._install_sighup()
@@ -72,7 +72,7 @@ class EgressProxyAddon:
except (OSError, ValueError) as e: except (OSError, ValueError) as e:
tag = "boot" if initial else "SIGHUP" tag = "boot" if initial else "SIGHUP"
sys.stderr.write( sys.stderr.write(
f"egress-proxy: {tag} load failed: {e}\n" f"egress: {tag} load failed: {e}\n"
) )
if initial: if initial:
# No baseline to fall back on; serve nothing rather # No baseline to fall back on; serve nothing rather
@@ -82,7 +82,7 @@ class EgressProxyAddon:
return return
self.routes = new_routes self.routes = new_routes
sys.stderr.write( sys.stderr.write(
f"egress-proxy: loaded {len(self.routes)} route(s): " f"egress: loaded {len(self.routes)} route(s): "
f"{', '.join(r.host for r in self.routes)}\n" f"{', '.join(r.host for r in self.routes)}\n"
) )
@@ -97,7 +97,7 @@ class EgressProxyAddon:
signal.signal(signal.SIGHUP, handler) signal.signal(signal.SIGHUP, handler)
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None: def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
"""Synthesize a response for `_egress-proxy.local` requests. """Synthesize a response for `_egress.local` requests.
Currently supports `/allowlist` which returns the in-memory Currently supports `/allowlist` which returns the in-memory
route table as JSON (host, path_allowlist, auth_scheme, route table as JSON (host, path_allowlist, auth_scheme,
token_env per route no token VALUES, those live in the token_env per route no token VALUES, those live in the
@@ -114,7 +114,7 @@ class EgressProxyAddon:
return return
flow.response = http.Response.make( flow.response = http.Response.make(
404, 404,
f"egress-proxy introspection: no such endpoint {path!r}".encode(), f"egress introspection: no such endpoint {path!r}".encode(),
{"Content-Type": "text/plain; charset=utf-8"}, {"Content-Type": "text/plain; charset=utf-8"},
) )
@@ -123,7 +123,7 @@ class EgressProxyAddon:
def request(self, flow: http.HTTPFlow) -> None: def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?") request_path, _, query = flow.request.path.partition("?")
# Introspection: requests to the magic `_egress-proxy.local` # Introspection: requests to the magic `_egress.local`
# host are answered locally with a synthetic response. Check # host are answered locally with a synthetic response. Check
# before the strip-auth + route logic — these requests aren't # before the strip-auth + route logic — these requests aren't
# real upstream traffic, the agent isn't injecting auth, and # real upstream traffic, the agent isn't injecting auth, and
@@ -142,13 +142,13 @@ class EgressProxyAddon:
# Universal HTTPS git-push block. Defense-in-depth: git-gate # Universal HTTPS git-push block. Defense-in-depth: git-gate
# (PRD 0008) is the only sanctioned outbound path for git # (PRD 0008) is the only sanctioned outbound path for git
# writes — its pre-receive runs gitleaks. Letting HTTPS push # writes — its pre-receive runs gitleaks. Letting HTTPS push
# through egress-proxy + auth injection would route around # through egress + auth injection would route around
# that scan, so we 403 before any route logic. # that scan, so we 403 before any route logic.
if is_git_push_request(request_path, query): if is_git_push_request(request_path, query):
flow.response = http.Response.make( flow.response = http.Response.make(
403, 403,
( (
b"egress-proxy: git push over HTTPS is not supported; " b"egress: git push over HTTPS is not supported; "
b"use the bottle.git SSH path (gitleaks-scanned by " b"use the bottle.git SSH path (gitleaks-scanned by "
b"git-gate's pre-receive hook)." b"git-gate's pre-receive hook)."
), ),
@@ -175,4 +175,4 @@ class EgressProxyAddon:
flow.request.headers["authorization"] = decision.inject_authorization flow.request.headers["authorization"] = decision.inject_authorization
addons = [EgressProxyAddon()] addons = [EgressAddon()]
@@ -1,12 +1,12 @@
"""Pure logic for the egress-proxy mitmproxy addon (PRD 0017). """Pure logic for the egress mitmproxy addon (PRD 0017).
Split out of `egress_proxy_addon.py` so the host's unit tests can Split out of `egress_addon.py` so the host's unit tests can
exercise the parse + decision functions without depending on the exercise the parse + decision functions without depending on the
`mitmproxy` package. The companion module wraps these with the `mitmproxy` package. The companion module wraps these with the
`mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar `mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar
container. container.
Stdlib only: this file ships into the egress-proxy image, where the Stdlib only: this file ships into the egress image, where the
container's Python is whatever mitmproxy itself runs on. container's Python is whatever mitmproxy itself runs on.
""" """
@@ -19,7 +19,7 @@ from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class Route: class Route:
"""One row of the egress-proxy route table. """One row of the egress route table.
`host` is the request's `Host` header (or SNI hostname) to match `host` is the request's `Host` header (or SNI hostname) to match
against. `path_allowlist` is an optional tuple of absolute path against. `path_allowlist` is an optional tuple of absolute path
@@ -60,7 +60,7 @@ def parse_routes(payload: object) -> tuple[Route, ...]:
"host": "api.github.com", "host": "api.github.com",
"path_allowlist": ["/repos/x/", "/users/x"], # optional "path_allowlist": ["/repos/x/", "/users/x"], # optional
"auth_scheme": "Bearer", # optional "auth_scheme": "Bearer", # optional
"token_env": "EGRESS_PROXY_TOKEN_0" # optional "token_env": "EGRESS_TOKEN_0" # optional
}, },
... ...
] ]
@@ -145,11 +145,11 @@ def is_git_push_request(path: str, query: str) -> bool:
Fetches use `service=git-upload-pack` / `/git-upload-pack` and Fetches use `service=git-upload-pack` / `/git-upload-pack` and
are unaffected. Egress-proxy refuses HTTPS push because git-gate's are unaffected. Egress-proxy refuses HTTPS push because git-gate's
pre-receive gitleaks scan is the gate for outbound git data; pre-receive gitleaks scan is the gate for outbound git data;
routing push through egress-proxy would bypass that. Use the routing push through egress would bypass that. Use the
bottle.git SSH path if you need to push. bottle.git SSH path if you need to push.
Universal across routes the block fires even when no Universal across routes the block fires even when no
egress_proxy route matches the host. A bare-pass route (host with egress route matches the host. A bare-pass route (host with
no auth, no path_allowlist) would otherwise let push through to no auth, no path_allowlist) would otherwise let push through to
pipelock + upstream untouched. pipelock + upstream untouched.
""" """
@@ -212,8 +212,8 @@ def decide(
return Decision( return Decision(
action="block", action="block",
reason=( reason=(
f"egress-proxy: host {request_host!r} is not in the " f"egress: host {request_host!r} is not in the "
f"bottle's egress_proxy.routes allowlist. Declare a " f"bottle's egress.routes allowlist. Declare a "
f"route for it or remove the request." f"route for it or remove the request."
), ),
) )
@@ -223,7 +223,7 @@ def decide(
return Decision( return Decision(
action="block", action="block",
reason=( reason=(
f"egress-proxy: path {request_path!r} not in " f"egress: path {request_path!r} not in "
f"path_allowlist for {route.host!r}" f"path_allowlist for {route.host!r}"
), ),
) )
@@ -234,7 +234,7 @@ def decide(
return Decision( return Decision(
action="block", action="block",
reason=( reason=(
f"egress-proxy: route for {route.host!r} declared auth " f"egress: route for {route.host!r} declared auth "
f"but env var {route.token_env!r} is unset" f"but env var {route.token_env!r} is unset"
), ),
) )
+34 -34
View File
@@ -123,10 +123,10 @@ class GitEntry:
) )
# Auth schemes for the egress-proxy route's optional `auth` block. # Auth schemes for the egress route's optional `auth` block.
# Same values cred-proxy accepts today; `token` sidesteps the Gitea # Same values cred-proxy accepts today; `token` sidesteps the Gitea
# token-not-Bearer quirk (go-gitea/gitea#16734). # token-not-Bearer quirk (go-gitea/gitea#16734).
EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token") EGRESS_AUTH_SCHEMES = ("Bearer", "token")
# Optional per-route role markers. A role signals "this route plays # Optional per-route role markers. A role signals "this route plays
# a specific named part in the bottle's auth flow"; the launch step # a specific named part in the bottle's auth flow"; the launch step
@@ -141,10 +141,10 @@ EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token")
# logic — declare the role on whichever route # logic — declare the role on whichever route
# injects the OAuth header. # injects the OAuth header.
# #
# Routes without a `role` are pure proxy entries: egress-proxy # Routes without a `role` are pure proxy entries: egress
# enforces path_allowlist + injects auth on its own, but nothing # enforces path_allowlist + injects auth on its own, but nothing
# special happens on the agent side. # special happens on the agent side.
EGRESS_PROXY_ROLES = frozenset({ EGRESS_ROLES = frozenset({
"claude_code_oauth", "claude_code_oauth",
}) })
@@ -152,14 +152,14 @@ EGRESS_PROXY_ROLES = frozenset({
# claude_code_oauth drives a single placeholder env var; two routes # claude_code_oauth drives a single placeholder env var; two routes
# claiming it would leave "which one is the canonical OAuth route?" # claiming it would leave "which one is the canonical OAuth route?"
# ambiguous for any future role-aware logic. # ambiguous for any future role-aware logic.
EGRESS_PROXY_SINGLETON_ROLES = frozenset({ EGRESS_SINGLETON_ROLES = frozenset({
"claude_code_oauth", "claude_code_oauth",
}) })
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressProxyRoute: class EgressRoute:
"""One route on the per-bottle egress-proxy sidecar (PRD 0017). """One route on the per-bottle egress sidecar (PRD 0017).
`Host` matches the request's hostname (case-insensitive). The `Host` matches the request's hostname (case-insensitive). The
optional `PathAllowlist` constrains the URL path to a set of optional `PathAllowlist` constrains the URL path to a set of
@@ -171,7 +171,7 @@ class EgressProxyRoute:
no Authorization is written, no token forwarded. no Authorization is written, no token forwarded.
`Role` is an optional tuple of named markers (see `Role` is an optional tuple of named markers (see
EGRESS_PROXY_ROLES). The launch step reads these and triggers EGRESS_ROLES). The launch step reads these and triggers
associated side effects (e.g. the `claude_code_oauth` marker associated side effects (e.g. the `claude_code_oauth` marker
causes prepare.py to set a placeholder OAuth env on the agent). causes prepare.py to set a placeholder OAuth env on the agent).
@@ -183,8 +183,8 @@ class EgressProxyRoute:
error rather than a synonym for "no auth" (omit `auth` for error rather than a synonym for "no auth" (omit `auth` for
that case). that case).
- `role` optional. String or list of strings drawn from - `role` optional. String or list of strings drawn from
EGRESS_PROXY_ROLES. Singleton roles (see EGRESS_ROLES. Singleton roles (see
EGRESS_PROXY_SINGLETON_ROLES) may appear on at most one EGRESS_SINGLETON_ROLES) may appear on at most one
route per bottle. route per bottle.
""" """
@@ -195,7 +195,7 @@ class EgressProxyRoute:
Role: tuple[str, ...] = () Role: tuple[str, ...] = ()
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute": def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
label = f"bottle '{bottle_name}' egress.routes[{idx}]" label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = _as_json_object(raw, label) d = _as_json_object(raw, label)
host = d.get("host") host = d.get("host")
@@ -243,10 +243,10 @@ class EgressProxyRoute:
f"{label} auth.scheme is required when 'auth' is set " f"{label} auth.scheme is required when 'auth' is set "
f"(non-empty string)" f"(non-empty string)"
) )
if auth_scheme_raw not in EGRESS_PROXY_AUTH_SCHEMES: if auth_scheme_raw not in EGRESS_AUTH_SCHEMES:
die( die(
f"{label} auth.scheme {auth_scheme_raw!r} is not one of " f"{label} auth.scheme {auth_scheme_raw!r} is not one of "
f"{', '.join(EGRESS_PROXY_AUTH_SCHEMES)}" f"{', '.join(EGRESS_AUTH_SCHEMES)}"
) )
token_ref_raw = auth_d.get("token_ref") token_ref_raw = auth_d.get("token_ref")
if not isinstance(token_ref_raw, str) or not token_ref_raw: if not isinstance(token_ref_raw, str) or not token_ref_raw:
@@ -283,10 +283,10 @@ class EgressProxyRoute:
f"(was {type(role_raw).__name__})" f"(was {type(role_raw).__name__})"
) )
for r in roles: for r in roles:
if r not in EGRESS_PROXY_ROLES: if r not in EGRESS_ROLES:
die( die(
f"{label} role {r!r} is not one of " f"{label} role {r!r} is not one of "
f"{', '.join(sorted(EGRESS_PROXY_ROLES))}" f"{', '.join(sorted(EGRESS_ROLES))}"
) )
for k in d: for k in d:
@@ -306,19 +306,19 @@ class EgressProxyRoute:
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressProxyConfig: class EgressConfig:
"""Per-bottle egress-proxy configuration. Today this is just the """Per-bottle egress configuration. Today this is just the
route table; the nesting under `egress:` leaves room for route table; the nesting under `egress:` leaves room for
per-bottle proxy settings (port override, log level, etc.) in per-bottle proxy settings (port override, log level, etc.) in
follow-ups.""" follow-ups."""
routes: tuple[EgressProxyRoute, ...] = () routes: tuple[EgressRoute, ...] = ()
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressProxyConfig": def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
d = _as_json_object(raw, f"bottle '{bottle_name}' egress") d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes") routes_raw = d.get("routes")
routes: tuple[EgressProxyRoute, ...] = () routes: tuple[EgressRoute, ...] = ()
if routes_raw is not None: if routes_raw is not None:
if not isinstance(routes_raw, list): if not isinstance(routes_raw, list):
die( die(
@@ -327,10 +327,10 @@ class EgressProxyConfig:
) )
routes_list = cast(list[object], routes_raw) routes_list = cast(list[object], routes_raw)
routes = tuple( routes = tuple(
EgressProxyRoute.from_dict(bottle_name, i, entry) EgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list) for i, entry in enumerate(routes_list)
) )
_validate_egress_proxy_routes(bottle_name, routes) _validate_egress_routes(bottle_name, routes)
for k in d: for k in d:
if k != "routes": if k != "routes":
die( die(
@@ -344,12 +344,12 @@ class EgressProxyConfig:
class Bottle: class Bottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
egress: EgressProxyConfig = field(default_factory=EgressProxyConfig) egress: EgressConfig = field(default_factory=EgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes three # the launch step brings up a supervise sidecar that exposes three
# MCP tools to the agent (cred-proxy-block, pipelock-block, # MCP tools to the agent (cred-proxy-block, pipelock-block,
# capability-block; the cred-proxy-block tool is renamed and # capability-block; the cred-proxy-block tool is renamed and
# retargeted at egress-proxy in PRD 0017 chunk 3) plus mounts the # retargeted at egress in PRD 0017 chunk 3) plus mounts the
# current-config dir read-only into the agent at /etc/claude-bottle/ # current-config dir read-only into the agent at /etc/claude-bottle/
# current-config. False (the default) skips the sidecar and mount. # current-config. False (the default) skips the sidecar and mount.
supervise: bool = False supervise: bool = False
@@ -403,7 +403,7 @@ class Bottle:
die( die(
f"bottle '{name}' has a 'tokens' field. The shape was reworked: " f"bottle '{name}' has a 'tokens' field. The shape was reworked: "
f"each route now lives under 'egress.routes' with explicit " f"each route now lives under 'egress.routes' with explicit "
f"host / path_allowlist / auth. See docs/prds/0017-egress-proxy-via-mitmproxy.md." f"host / path_allowlist / auth. See docs/prds/0017-egress-via-mitmproxy.md."
) )
if "cred_proxy" in d: if "cred_proxy" in d:
@@ -414,18 +414,18 @@ class Bottle:
f"'host' (just the upstream hostname)\n" f"'host' (just the upstream hostname)\n"
f" - 'auth_scheme' + 'token_ref' (flat)\n" f" - 'auth_scheme' + 'token_ref' (flat)\n"
f"'auth: {{ scheme, token_ref }}' (nested, optional)\n" f"'auth: {{ scheme, token_ref }}' (nested, optional)\n"
f" - 'role' (provisioner dotfile rewrites): drop — egress-proxy " f" - 'role' (provisioner dotfile rewrites): drop — egress "
f"is on the agent's HTTP_PROXY path, so dotfile rewrites are no " f"is on the agent's HTTP_PROXY path, so dotfile rewrites are no "
f"longer needed.\n" f"longer needed.\n"
f" - 'path_allowlist' (new): optional URL prefix gate for the " f" - 'path_allowlist' (new): optional URL prefix gate for the "
f"host.\n" f"host.\n"
f"See docs/prds/0017-egress-proxy-via-mitmproxy.md." f"See docs/prds/0017-egress-via-mitmproxy.md."
) )
egress = ( egress = (
EgressProxyConfig.from_dict(name, d["egress"]) EgressConfig.from_dict(name, d["egress"])
if "egress" in d if "egress" in d
else EgressProxyConfig() else EgressConfig()
) )
supervise_raw = d.get("supervise", False) supervise_raw = d.get("supervise", False)
@@ -711,20 +711,20 @@ def _parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
return (user, host, port, path) return (user, host, port, path)
def _validate_egress_proxy_routes( def _validate_egress_routes(
bottle_name: str, bottle_name: str,
routes: tuple[EgressProxyRoute, ...], routes: tuple[EgressRoute, ...],
) -> None: ) -> None:
"""Cross-validation for `bottle.egress.routes`: """Cross-validation for `bottle.egress.routes`:
- Hosts must be unique within the bottle. The proxy matches by - Hosts must be unique within the bottle. The proxy matches by
exact-host (v1, prefix matching is on path_allowlist only); exact-host (v1, prefix matching is on path_allowlist only);
duplicate hosts leave the route choice ambiguous. duplicate hosts leave the route choice ambiguous.
- Singleton roles (see EGRESS_PROXY_SINGLETON_ROLES) may appear - Singleton roles (see EGRESS_SINGLETON_ROLES) may appear
on at most one route per bottle. on at most one route per bottle.
No cross-validation against `bottle.git` is performed. git-gate No cross-validation against `bottle.git` is performed. git-gate
(SSH push/fetch) and egress-proxy (HTTPS) broker different (SSH push/fetch) and egress (HTTPS) broker different
protocols; declaring both for the same host is a legitimate protocols; declaring both for the same host is a legitimate
dev setup.""" dev setup."""
seen_hosts: dict[str, None] = {} seen_hosts: dict[str, None] = {}
@@ -736,7 +736,7 @@ def _validate_egress_proxy_routes(
f"{r.Host!r}; each host must be unique on the proxy." f"{r.Host!r}; each host must be unique on the proxy."
) )
seen_hosts[key] = None seen_hosts[key] = None
for role in EGRESS_PROXY_SINGLETON_ROLES: for role in EGRESS_SINGLETON_ROLES:
with_role = [r for r in routes if role in r.Role] with_role = [r for r in routes if role in r.Role]
if len(with_role) > 1: if len(with_role) > 1:
hosts = ", ".join(r.Host for r in with_role) hosts = ", ".join(r.Host for r in with_role)
+17 -17
View File
@@ -5,10 +5,10 @@ forward proxy with hostname allowlisting + DLP scanning + URL-entropy
checks. One sidecar per agent, attached to the agent's --internal checks. One sidecar per agent, attached to the agent's --internal
network and a per-agent user-defined egress bridge. network and a per-agent user-defined egress bridge.
Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress-proxy Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress
(not pipelock); egress-proxy sets `HTTPS_PROXY=pipelock` on its (not pipelock); egress sets `HTTPS_PROXY=pipelock` on its
outbound leg. So pipelock no longer sees the agent's connections outbound leg. So pipelock no longer sees the agent's connections
directly it sees the egress-proxy upstream leg, applies the directly it sees the egress upstream leg, applies the
hostname allowlist + DLP body scan there, and forwards to the real hostname allowlist + DLP body scan there, and forwards to the real
upstream. upstream.
@@ -22,10 +22,10 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
from .egress_proxy import ( from .egress import (
DEFAULT_ALLOWLIST, DEFAULT_ALLOWLIST,
EGRESS_PROXY_HOSTNAME, EGRESS_HOSTNAME,
egress_proxy_routes_for_bottle, egress_routes_for_bottle,
) )
from .supervise import SUPERVISE_HOSTNAME from .supervise import SUPERVISE_HOSTNAME
from .manifest import Bottle from .manifest import Bottle
@@ -53,12 +53,12 @@ DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = (
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
"""Hostnames pipelock allows. Sorted for stability. """Hostnames pipelock allows. Sorted for stability.
Always mirrors `egress_proxy_routes_for_bottle(bottle)` the Always mirrors `egress_routes_for_bottle(bottle)` the
egress-proxy is the single allowlist surface; pipelock's egress is the single allowlist surface; pipelock's
allowlist is the downstream copy for defense-in-depth + DLP allowlist is the downstream copy for defense-in-depth + DLP
body scanning. For bottles without any `egress_proxy.routes[]` body scanning. For bottles without any `egress.routes[]`
declared, this is just the baked DEFAULT_ALLOWLIST that declared, this is just the baked DEFAULT_ALLOWLIST that
egress_proxy_routes_for_bottle always folds in. egress_routes_for_bottle always folds in.
The supervise sidecar's hostname is auto-added when supervise The supervise sidecar's hostname is auto-added when supervise
is enabled (sibling-sidecar traffic that flows through pipelock is enabled (sibling-sidecar traffic that flows through pipelock
@@ -66,7 +66,7 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
`bottle.git` do NOT contribute here git traffic flows `bottle.git` do NOT contribute here git traffic flows
through git-gate (PRD 0008), not pipelock.""" through git-gate (PRD 0008), not pipelock."""
seen: dict[str, None] = {} seen: dict[str, None] = {}
for r in egress_proxy_routes_for_bottle(bottle): for r in egress_routes_for_bottle(bottle):
if r.host: if r.host:
seen.setdefault(r.host, None) seen.setdefault(r.host, None)
if bottle.supervise: if bottle.supervise:
@@ -95,7 +95,7 @@ def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
body through three pipelock instances). It is a global toggle body through three pipelock instances). It is a global toggle
no per-path / per-host knob in pipelock 2.3.0 so we turn the no per-path / per-host knob in pipelock 2.3.0 so we turn the
detector off for the entire bottle when the bottle declares an detector off for the entire bottle when the bottle declares an
egress-proxy route to `api.anthropic.com`. The trade-off is egress route to `api.anthropic.com`. The trade-off is
accepted: BIP-39 detection has little value in claude-bottle's accepted: BIP-39 detection has little value in claude-bottle's
threat model (the agent has no access to a user's crypto wallet threat model (the agent has no access to a user's crypto wallet
seeds; the patterns that matter gh*_, sk-ant-, AKIA, etc. seeds; the patterns that matter gh*_, sk-ant-, AKIA, etc.
@@ -113,10 +113,10 @@ def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
other allowlisted host is MITM'd by pipelock's per-bottle CA so other allowlisted host is MITM'd by pipelock's per-bottle CA so
its body scanner sees the cleartext. its body scanner sees the cleartext.
egress-proxy route hosts (github, gitea, npm) are deliberately egress route hosts (github, gitea, npm) are deliberately
NOT auto-added here. egress-proxy's HTTPS client trusts pipelock's NOT auto-added here. egress's HTTPS client trusts pipelock's
CA at runtime (folded into its trust store via docker cp), so CA at runtime (folded into its trust store via docker cp), so
pipelock MITMs and body-scans the egress-proxy upstream leg the pipelock MITMs and body-scans the egress upstream leg the
same way it body-scanned the agent's direct HTTPS traffic before same way it body-scanned the agent's direct HTTPS traffic before
the PRD 0017 cutover. the PRD 0017 cutover.
@@ -159,7 +159,7 @@ def pipelock_build_config(
pipelock's SSRF guard. Pipelock blocks RFC1918-resolved pipelock's SSRF guard. Pipelock blocks RFC1918-resolved
destinations by default, which would catch sibling-sidecar destinations by default, which would catch sibling-sidecar
traffic on the bottle's internal Docker network in 172.x space traffic on the bottle's internal Docker network in 172.x space
(e.g. egress-proxy pipelock on the upstream leg). Pass the (e.g. egress pipelock on the upstream leg). Pass the
bottle's internal network CIDR here so internal-network requests bottle's internal network CIDR here so internal-network requests
pass through pipelock while api_allowlist + body-scanning still pass through pipelock while api_allowlist + body-scanning still
apply. Empty by default; omitted from the rendered yaml when apply. Empty by default; omitted from the rendered yaml when
@@ -272,7 +272,7 @@ class PipelockProxyPlan:
that they are populated. that they are populated.
`internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist` `internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist`
so traffic from sibling sidecars (egress-proxy pipelock on the so traffic from sibling sidecars (egress pipelock on the
upstream leg, etc.) bypasses pipelock's RFC1918 SSRF guard while upstream leg, etc.) bypasses pipelock's RFC1918 SSRF guard while
api_allowlist and body-scanning still apply.""" api_allowlist and body-scanning still apply."""
+21 -21
View File
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (claude_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the sits on the bottle's internal network and exposes three MCP tools the
agent calls when it hits a stuck-recovery category: agent calls when it hits a stuck-recovery category:
* egress-proxy-block agent proposes a new routes.yaml * egress-block agent proposes a new routes.yaml
* pipelock-block agent proposes a new pipelock allowlist * pipelock-block agent proposes a new pipelock allowlist
* capability-block agent proposes a new agent Dockerfile * capability-block agent proposes a new agent Dockerfile
@@ -49,33 +49,33 @@ from pathlib import Path
SUPERVISE_HOSTNAME = "supervise" SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
TOOL_EGRESS_PROXY_BLOCK = "egress-proxy-block" TOOL_EGRESS_BLOCK = "egress-block"
TOOL_PIPELOCK_BLOCK = "pipelock-block" TOOL_PIPELOCK_BLOCK = "pipelock-block"
TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_LIST_EGRESS_PROXY_ROUTES = "list-egress-proxy-routes" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = ( TOOLS: tuple[str, ...] = (
TOOL_EGRESS_PROXY_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK, TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_LIST_EGRESS_PROXY_ROUTES, TOOL_LIST_EGRESS_ROUTES,
) )
# The supervise sidecar uses these to query egress-proxy's # The supervise sidecar uses these to query egress's
# introspection endpoint for the `list-egress-proxy-routes` MCP # introspection endpoint for the `list-egress-routes` MCP
# tool. The hostname + port match egress-proxy's docker network # tool. The hostname + port match egress's docker network
# alias + listen port (see claude_bottle.egress_proxy.EGRESS_PROXY_HOSTNAME # alias + listen port (see claude_bottle.egress.EGRESS_HOSTNAME
# and backend.docker.egress_proxy.EGRESS_PROXY_PORT — the values # and backend.docker.egress.EGRESS_PORT — the values
# are inlined here so the in-container supervise_server doesn't # are inlined here so the in-container supervise_server doesn't
# need to import the egress-proxy package). # need to import the egress package).
EGRESS_PROXY_FORWARD_PROXY = "http://egress-proxy:9099" EGRESS_FORWARD_PROXY = "http://egress:9099"
EGRESS_PROXY_INTROSPECT_URL = "http://_egress-proxy.local/allowlist" EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
# capability-block has no on-disk config the operator edits in place # capability-block has no on-disk config the operator edits in place
# (the Dockerfile is rebuilt, not patched), so it has no audit log # (the Dockerfile is rebuilt, not patched), so it has no audit log
# here — those changes are captured by git history + the rebuild # here — those changes are captured by git history + the rebuild
# record laid down in PRD 0016. # record laid down in PRD 0016.
COMPONENT_FOR_TOOL: dict[str, str] = { COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_EGRESS_PROXY_BLOCK: "egress-proxy", TOOL_EGRESS_BLOCK: "egress",
TOOL_PIPELOCK_BLOCK: "pipelock", TOOL_PIPELOCK_BLOCK: "pipelock",
} }
@@ -440,8 +440,8 @@ def sha256_hex(content: str) -> str:
# Dockerfile and propose modifications. # Dockerfile and propose modifications.
# #
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3 # routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
# moved them behind the `list-egress-proxy-routes` MCP tool (live # moved them behind the `list-egress-routes` MCP tool (live
# state from egress-proxy's introspection endpoint) so the agent # state from egress's introspection endpoint) so the agent
# always sees current data rather than a launch-time snapshot. # always sees current data rather than a launch-time snapshot.
CURRENT_CONFIG_DOCKERFILE = "Dockerfile" CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
@@ -455,7 +455,7 @@ class SupervisePlan:
directory bind-mounted (read-only) into the *agent* container directory bind-mounted (read-only) into the *agent* container
at /etc/claude-bottle/current-config currently holds only the at /etc/claude-bottle/current-config currently holds only the
Dockerfile snapshot (routes.yaml + allowlist moved to the Dockerfile snapshot (routes.yaml + allowlist moved to the
`list-egress-proxy-routes` MCP tool). `internal_network` is `list-egress-routes` MCP tool). `internal_network` is
empty at prepare time; the backend's launch step fills it via empty at prepare time; the backend's launch step fills it via
dataclasses.replace before calling .start.""" dataclasses.replace before calling .start."""
@@ -569,11 +569,11 @@ __all__ = [
"Supervise", "Supervise",
"SupervisePlan", "SupervisePlan",
"TOOLS", "TOOLS",
"EGRESS_PROXY_FORWARD_PROXY", "EGRESS_FORWARD_PROXY",
"EGRESS_PROXY_INTROSPECT_URL", "EGRESS_INTROSPECT_URL",
"TOOL_CAPABILITY_BLOCK", "TOOL_CAPABILITY_BLOCK",
"TOOL_EGRESS_PROXY_BLOCK", "TOOL_EGRESS_BLOCK",
"TOOL_LIST_EGRESS_PROXY_ROUTES", "TOOL_LIST_EGRESS_ROUTES",
"TOOL_PIPELOCK_BLOCK", "TOOL_PIPELOCK_BLOCK",
"archive_proposal", "archive_proposal",
"audit_dir", "audit_dir",
+29 -29
View File
@@ -1,6 +1,6 @@
"""Supervise sidecar HTTP server (PRD 0013). """Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing three tools `egress-proxy-block`, Per-bottle MCP server exposing three tools `egress-block`,
`pipelock-block`, `capability-block` that the agent calls to `pipelock-block`, `capability-block` that the agent calls to
propose config changes when stuck. Each tool call: propose config changes when stuck. Each tool call:
@@ -130,9 +130,9 @@ def jsonrpc_error(request_id: object, code: int, message: str) -> bytes:
TOOL_DEFINITIONS: list[dict[str, object]] = [ TOOL_DEFINITIONS: list[dict[str, object]] = [
{ {
"name": _sv.TOOL_EGRESS_PROXY_BLOCK, "name": _sv.TOOL_EGRESS_BLOCK,
"description": ( "description": (
"Call when egress-proxy refused your HTTPS request — host " "Call when egress refused your HTTPS request — host "
"without a matching route, or a path outside the route's " "without a matching route, or a path outside the route's "
"path_allowlist (typically a 403 from the proxy). Propose " "path_allowlist (typically a 403 from the proxy). Propose "
"a SINGLE route to add: the host you need + (optionally) " "a SINGLE route to add: the host you need + (optionally) "
@@ -145,7 +145,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"ones (host stays single-route). The operator approves " "ones (host stays single-route). The operator approves "
"or rejects in the supervise TUI. On approval the " "or rejects in the supervise TUI. On approval the "
"supervisor writes the merged routes.yaml, SIGHUPs " "supervisor writes the merged routes.yaml, SIGHUPs "
"egress-proxy (atomic swap, no dropped connections), and " "egress (atomic swap, no dropped connections), and "
"mirrors the host onto pipelock's allowlist for the " "mirrors the host onto pipelock's allowlist for the "
"downstream gate." "downstream gate."
), ),
@@ -192,14 +192,14 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
}, },
}, },
{ {
"name": _sv.TOOL_LIST_EGRESS_PROXY_ROUTES, "name": _sv.TOOL_LIST_EGRESS_ROUTES,
"description": ( "description": (
"List the current egress-proxy route table — the bottle's " "List the current egress route table — the bottle's "
"primary egress allowlist. Returns JSON with one entry " "primary egress allowlist. Returns JSON with one entry "
"per allowed host, each carrying its path_allowlist (if " "per allowed host, each carrying its path_allowlist (if "
"any) and whether the proxy injects Authorization for " "any) and whether the proxy injects Authorization for "
"the route. Use this before composing an " "the route. Use this before composing an "
"`egress-proxy-block` proposal so the new routes file " "`egress-block` proposal so the new routes file "
"extends the live one rather than replacing it. " "extends the live one rather than replacing it. "
"Pipelock's allowlist is a mirror of this set — every " "Pipelock's allowlist is a mirror of this set — every "
"host listed here is also reachable through pipelock's " "host listed here is also reachable through pipelock's "
@@ -218,10 +218,10 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"the failing host is genuinely missing from the bottle's " "the failing host is genuinely missing from the bottle's "
"allowlist (vs. blocked for DLP reasons — those need a " "allowlist (vs. blocked for DLP reasons — those need a "
"different remediation). In practice pipelock's allowlist " "different remediation). In practice pipelock's allowlist "
"is now a mirror of the egress-proxy routes set by " "is now a mirror of the egress routes set by "
"`egress-proxy-block`, so prefer that tool when you want " "`egress-block`, so prefer that tool when you want "
"to add a host. This tool stays available for the rare " "to add a host. This tool stays available for the rare "
"case where pipelock and egress-proxy have diverged. " "case where pipelock and egress have diverged. "
"Pass the full URL you tried to hit (scheme + host + " "Pass the full URL you tried to hit (scheme + host + "
"path); the supervisor extracts the hostname and merges " "path); the supervisor extracts the hostname and merges "
"it into pipelock's allowlist. On approval the " "it into pipelock's allowlist. On approval the "
@@ -282,7 +282,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
# tool-specific payload (stored in Proposal.proposed_file as # tool-specific payload (stored in Proposal.proposed_file as
# free-form text the apply path interprets per tool). # free-form text the apply path interprets per tool).
# #
# egress-proxy-block: JSON object describing a SINGLE route to # egress-block: JSON object describing a SINGLE route to
# add — `{host, path_allowlist?, auth?}`. The # add — `{host, path_allowlist?, auth?}`. The
# supervisor merges this into the live routes # supervisor merges this into the live routes
# file at approval time. # file at approval time.
@@ -295,7 +295,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
# #
# Egress-proxy-block doesn't use a single "field name" → the JSON # Egress-proxy-block doesn't use a single "field name" → the JSON
# payload is constructed from multiple structured input fields in # payload is constructed from multiple structured input fields in
# `handle_egress_proxy_block`. The mapping stays one-entry-per-tool # `handle_egress_block`. The mapping stays one-entry-per-tool
# so the generic dispatch keeps working for the other two. # so the generic dispatch keeps working for the other two.
PROPOSED_FILE_FIELD: dict[str, str] = { PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_PIPELOCK_BLOCK: "failed_url", _sv.TOOL_PIPELOCK_BLOCK: "failed_url",
@@ -306,8 +306,8 @@ PROPOSED_FILE_FIELD: dict[str, str] = {
# --- Validation ------------------------------------------------------------ # --- Validation ------------------------------------------------------------
# Auth schemes accepted on egress-proxy-block proposals — match the # Auth schemes accepted on egress-block proposals — match the
# manifest-side EGRESS_PROXY_AUTH_SCHEMES. # manifest-side EGRESS_AUTH_SCHEMES.
_AUTH_SCHEMES = ("Bearer", "token") _AUTH_SCHEMES = ("Bearer", "token")
@@ -344,10 +344,10 @@ def validate_proposed_file(tool: str, content: str) -> None:
def _validate_and_bundle_egress_route( def _validate_and_bundle_egress_route(
args: dict[str, object], args: dict[str, object],
) -> str: ) -> str:
"""Validate egress-proxy-block input fields and bundle them into """Validate egress-block input fields and bundle them into
a JSON string that becomes the Proposal.proposed_file. Raises a JSON string that becomes the Proposal.proposed_file. Raises
_RpcError on bad input the agent retries with a fixed shape.""" _RpcError on bad input the agent retries with a fixed shape."""
tool = _sv.TOOL_EGRESS_PROXY_BLOCK tool = _sv.TOOL_EGRESS_BLOCK
host = args.get("host") host = args.get("host")
if not isinstance(host, str) or not host.strip(): if not isinstance(host, str) or not host.strip():
raise _RpcError( raise _RpcError(
@@ -426,32 +426,32 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
return {"tools": TOOL_DEFINITIONS} return {"tools": TOOL_DEFINITIONS}
def handle_list_egress_proxy_routes( def handle_list_egress_routes(
_params: dict[str, object], _params: dict[str, object],
_config: ServerConfig, _config: ServerConfig,
) -> dict[str, object]: ) -> dict[str, object]:
"""Fetch the live egress-proxy route table via its """Fetch the live egress route table via its
`_egress-proxy.local/allowlist` introspection endpoint. The `_egress.local/allowlist` introspection endpoint. The
request goes through egress-proxy as a forward proxy; the request goes through egress as a forward proxy; the
addon recognises the magic host and synthesizes a response addon recognises the magic host and synthesizes a response
no real upstream connection, no allowlist enforcement no real upstream connection, no allowlist enforcement
against the magic host. Returns the JSON payload as the against the magic host. Returns the JSON payload as the
tool's text content.""" tool's text content."""
proxy_handler = urllib.request.ProxyHandler({ proxy_handler = urllib.request.ProxyHandler({
"http": _sv.EGRESS_PROXY_FORWARD_PROXY, "http": _sv.EGRESS_FORWARD_PROXY,
}) })
opener = urllib.request.build_opener(proxy_handler) opener = urllib.request.build_opener(proxy_handler)
try: try:
with opener.open(_sv.EGRESS_PROXY_INTROSPECT_URL, timeout=5) as resp: with opener.open(_sv.EGRESS_INTROSPECT_URL, timeout=5) as resp:
body = resp.read().decode("utf-8") body = resp.read().decode("utf-8")
except (urllib.error.URLError, OSError) as e: except (urllib.error.URLError, OSError) as e:
return { return {
"content": [{ "content": [{
"type": "text", "type": "text",
"text": ( "text": (
f"list-egress-proxy-routes: could not reach " f"list-egress-routes: could not reach "
f"{_sv.EGRESS_PROXY_INTROSPECT_URL!r} via " f"{_sv.EGRESS_INTROSPECT_URL!r} via "
f"{_sv.EGRESS_PROXY_FORWARD_PROXY!r}: {e}" f"{_sv.EGRESS_FORWARD_PROXY!r}: {e}"
), ),
}], }],
"isError": True, "isError": True,
@@ -475,8 +475,8 @@ def handle_tools_call(
name = params.get("name") name = params.get("name")
if not isinstance(name, str): if not isinstance(name, str):
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'") raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
if name == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES: if name == _sv.TOOL_LIST_EGRESS_ROUTES:
return handle_list_egress_proxy_routes(params.get("arguments", {}), config) return handle_list_egress_routes(params.get("arguments", {}), config)
args_raw = params.get("arguments", {}) args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict): if not isinstance(args_raw, dict):
@@ -489,9 +489,9 @@ def handle_tools_call(
f"{name}: 'justification' is required and must be a non-empty string", f"{name}: 'justification' is required and must be a non-empty string",
) )
if name == _sv.TOOL_EGRESS_PROXY_BLOCK: if name == _sv.TOOL_EGRESS_BLOCK:
# Structured input → JSON bundle on Proposal.proposed_file. # Structured input → JSON bundle on Proposal.proposed_file.
# The dashboard's apply step (egress_proxy_apply.add_route) # The dashboard's apply step (egress_apply.add_route)
# parses this JSON, fetches the current routes, merges in # parses this JSON, fetches the current routes, merges in
# the new one, and writes the merged file. # the new one, and writes the merged file.
proposed_file = _validate_and_bundle_egress_route(args_raw) proposed_file = _validate_and_bundle_egress_route(args_raw)
+9 -9
View File
@@ -196,29 +196,29 @@ class TestSuperviseSidecar(unittest.TestCase):
names = {t["name"] for t in result["result"]["tools"]} names = {t["name"] for t in result["result"]["tools"]}
self.assertEqual( self.assertEqual(
{ {
_sv.TOOL_EGRESS_PROXY_BLOCK, _sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_PIPELOCK_BLOCK,
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_LIST_EGRESS_PROXY_ROUTES, _sv.TOOL_LIST_EGRESS_ROUTES,
}, },
names, names,
) )
def test_tools_call_round_trips_through_queue(self): def test_tools_call_round_trips_through_queue(self):
"""End-to-end: agent in the bottle calls egress-proxy-block; """End-to-end: agent in the bottle calls egress-block;
the call blocks on the queue; the host approves via the the call blocks on the queue; the host approves via the
dashboard helpers; the agent receives the approval. dashboard helpers; the agent receives the approval.
This test focuses on the supervise sidecar's queue + response This test focuses on the supervise sidecar's queue + response
plumbing, not the egress-proxy apply path itself. The apply plumbing, not the egress apply path itself. The apply
function is stubbed so we don't need to bring up a real function is stubbed so we don't need to bring up a real
egress-proxy sidecar (its docker lifecycle has its own egress sidecar (its docker lifecycle has its own
integration coverage).""" integration coverage)."""
self._require_bind_mount_sharing() self._require_bind_mount_sharing()
self._bring_up_sidecar() self._bring_up_sidecar()
# Stub the apply step. The dashboard's approve() calls # Stub the apply step. The dashboard's approve() calls
# add_route to docker-exec into the egress-proxy sidecar; # add_route to docker-exec into the egress sidecar;
# this test isn't exercising the real sidecar, so patch it # this test isn't exercising the real sidecar, so patch it
# to a no-op that returns plausible before/after strings # to a no-op that returns plausible before/after strings
# the audit-log writer can render. # the audit-log writer can render.
@@ -234,7 +234,7 @@ class TestSuperviseSidecar(unittest.TestCase):
captured["response"] = self._curl_jsonrpc({ captured["response"] = self._curl_jsonrpc({
"jsonrpc": "2.0", "id": 7, "method": "tools/call", "jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": { "params": {
"name": _sv.TOOL_EGRESS_PROXY_BLOCK, "name": _sv.TOOL_EGRESS_BLOCK,
"arguments": { "arguments": {
"host": "api.example.com", "host": "api.example.com",
"justification": "integration test", "justification": "integration test",
@@ -260,12 +260,12 @@ class TestSuperviseSidecar(unittest.TestCase):
self.assertIsNotNone(qp, "proposal never appeared in queue") self.assertIsNotNone(qp, "proposal never appeared in queue")
assert qp is not None # type-narrowing assert qp is not None # type-narrowing
self.assertEqual( self.assertEqual(
_sv.TOOL_EGRESS_PROXY_BLOCK, qp.proposal.tool, _sv.TOOL_EGRESS_BLOCK, qp.proposal.tool,
) )
self.assertEqual("integration test", qp.proposal.justification) self.assertEqual("integration test", qp.proposal.justification)
# Approve via the dashboard helper. The apply step (now # Approve via the dashboard helper. The apply step (now
# stubbed) would docker-exec into the egress-proxy sidecar # stubbed) would docker-exec into the egress sidecar
# and SIGHUP it. The supervise sidecar sees the response # and SIGHUP it. The supervise sidecar sees the response
# file and returns to the curl caller. # file and returns to the curl caller.
dashboard.approve(qp, notes="lgtm from integration test") dashboard.approve(qp, notes="lgtm from integration test")
+35 -35
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from claude_bottle import supervise from claude_bottle import supervise
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
from claude_bottle.backend.docker.egress_proxy_apply import EgressProxyApplyError from claude_bottle.backend.docker.egress_apply import EgressApplyError
from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError
from claude_bottle.cli import dashboard from claude_bottle.cli import dashboard
from claude_bottle.supervise import ( from claude_bottle.supervise import (
@@ -26,7 +26,7 @@ from claude_bottle.supervise import (
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK, TOOL_PIPELOCK_BLOCK,
read_audit_entries, read_audit_entries,
read_response, read_response,
@@ -37,13 +37,13 @@ from claude_bottle.supervise import (
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_PROXY_BLOCK) -> Proposal: def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_BLOCK) -> Proposal:
# Per-tool payload shape: cred-proxy gets routes.yaml, pipelock # Per-tool payload shape: cred-proxy gets routes.yaml, pipelock
# gets a failed URL (PR #25 follow-up), capability gets a # gets a failed URL (PR #25 follow-up), capability gets a
# Dockerfile-ish blob. Match the production dispatch in # Dockerfile-ish blob. Match the production dispatch in
# PROPOSED_FILE_FIELD. # PROPOSED_FILE_FIELD.
payloads = { payloads = {
TOOL_EGRESS_PROXY_BLOCK: '{"routes": []}\n', TOOL_EGRESS_BLOCK: '{"routes": []}\n',
TOOL_PIPELOCK_BLOCK: "https://example.com/path", TOOL_PIPELOCK_BLOCK: "https://example.com/path",
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
} }
@@ -95,13 +95,13 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
def test_sorted_by_arrival_across_bottles(self): def test_sorted_by_arrival_across_bottles(self):
early = Proposal.new( early = Proposal.new(
bottle_slug="api", tool=TOOL_EGRESS_PROXY_BLOCK, bottle_slug="api", tool=TOOL_EGRESS_BLOCK,
proposed_file="{}", justification="early", proposed_file="{}", justification="early",
current_file_hash="h", current_file_hash="h",
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
) )
late = Proposal.new( late = Proposal.new(
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_BLOCK,
proposed_file="{}", justification="late", proposed_file="{}", justification="late",
current_file_hash="h", current_file_hash="h",
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
@@ -151,7 +151,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
dashboard.apply_capability_change = self._original_apply_capability dashboard.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_EGRESS_PROXY_BLOCK): def _enqueue(self, tool: str = TOOL_EGRESS_BLOCK):
p = _proposal(tool=tool) p = _proposal(tool=tool)
qdir = supervise.queue_dir_for_slug("dev") qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True) qdir.mkdir(parents=True, exist_ok=True)
@@ -164,7 +164,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status) self.assertEqual(STATUS_APPROVED, resp.status)
self.assertIsNone(resp.final_file) self.assertIsNone(resp.final_file)
entries = read_audit_entries("egress-proxy", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries)) self.assertEqual(1, len(entries))
self.assertEqual("approved", entries[0].operator_action) self.assertEqual("approved", entries[0].operator_action)
@@ -175,7 +175,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual(STATUS_MODIFIED, resp.status)
self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file) self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file)
self.assertEqual("tweaked", resp.notes) self.assertEqual("tweaked", resp.notes)
entries = read_audit_entries("egress-proxy", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual("modified", entries[0].operator_action) self.assertEqual("modified", entries[0].operator_action)
def test_reject_writes_rejection(self): def test_reject_writes_rejection(self):
@@ -184,7 +184,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual(STATUS_REJECTED, resp.status)
self.assertEqual("nope", resp.notes) self.assertEqual("nope", resp.notes)
entries = read_audit_entries("egress-proxy", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual("rejected", entries[0].operator_action) self.assertEqual("rejected", entries[0].operator_action)
self.assertEqual("nope", entries[0].operator_notes) self.assertEqual("nope", entries[0].operator_notes)
@@ -193,18 +193,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
dashboard.approve(qp) dashboard.approve(qp)
# No audit log for capability-block (per PRD 0013 / 0016). # No audit log for capability-block (per PRD 0013 / 0016).
# cred-proxy and pipelock logs both empty. # cred-proxy and pipelock logs both empty.
self.assertEqual([], read_audit_entries("egress-proxy", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_pipelock_audit_distinct_from_egress_proxy(self): def test_pipelock_audit_distinct_from_egress(self):
qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK)
dashboard.approve(qp) dashboard.approve(qp)
self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) self.assertEqual(1, len(read_audit_entries("pipelock", "dev")))
self.assertEqual(0, len(read_audit_entries("egress-proxy", "dev"))) self.assertEqual(0, len(read_audit_entries("egress", "dev")))
class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0017 chunk 3: approve() on an egress-proxy-block proposal """PRD 0017 chunk 3: approve() on an egress-block proposal
must call add_route (single-route merge) with the right args must call add_route (single-route merge) with the right args
and surface its failures.""" and surface its failures."""
@@ -216,9 +216,9 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.add_route = self._original_add_route dashboard.add_route = self._original_add_route
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue_egress_proxy(self, proposed: str = '{"host": "x.example"}\n'): def _enqueue_egress(self, proposed: str = '{"host": "x.example"}\n'):
p = Proposal.new( p = Proposal.new(
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_BLOCK,
proposed_file=proposed, proposed_file=proposed,
justification="need a route", justification="need a route",
current_file_hash=sha256_hex(proposed), current_file_hash=sha256_hex(proposed),
@@ -229,12 +229,12 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
supervise.write_proposal(qdir, p) supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
def test_egress_proxy_block_calls_add_route_with_proposed_json(self): def test_egress_block_calls_add_route_with_proposed_json(self):
calls = [] calls = []
dashboard.add_route = lambda slug, content: ( dashboard.add_route = lambda slug, content: (
calls.append((slug, content)) or ("before", "after") calls.append((slug, content)) or ("before", "after")
) )
qp = self._enqueue_egress_proxy( qp = self._enqueue_egress(
proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n' proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n'
) )
dashboard.approve(qp) dashboard.approve(qp)
@@ -253,7 +253,7 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.add_route = lambda slug, content: ( dashboard.add_route = lambda slug, content: (
calls.append(content) or ("before", "after") calls.append(content) or ("before", "after")
) )
qp = self._enqueue_egress_proxy() qp = self._enqueue_egress()
dashboard.approve( dashboard.approve(
qp, qp,
final_file='{"host": "edited.example"}\n', final_file='{"host": "edited.example"}\n',
@@ -263,10 +263,10 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
def test_apply_failure_blocks_response_and_audit(self): def test_apply_failure_blocks_response_and_audit(self):
dashboard.add_route = lambda slug, content: (_ for _ in ()).throw( dashboard.add_route = lambda slug, content: (_ for _ in ()).throw(
EgressProxyApplyError("docker exec failed") EgressApplyError("docker exec failed")
) )
qp = self._enqueue_egress_proxy() qp = self._enqueue_egress()
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
dashboard.approve(qp) dashboard.approve(qp)
# No response file (proposal stays pending). # No response file (proposal stays pending).
self.assertEqual( self.assertEqual(
@@ -274,16 +274,16 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)], [p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
) )
# No audit entry. # No audit entry.
self.assertEqual([], read_audit_entries("egress-proxy", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
def test_real_diff_lands_in_audit(self): def test_real_diff_lands_in_audit(self):
dashboard.add_route = lambda slug, content: ( dashboard.add_route = lambda slug, content: (
'{"routes": []}\n', # before '{"routes": []}\n', # before
'{"routes": [{"host": "new.example"}]}\n', # after '{"routes": [{"host": "new.example"}]}\n', # after
) )
qp = self._enqueue_egress_proxy(proposed='{"host": "new.example"}\n') qp = self._enqueue_egress(proposed='{"host": "new.example"}\n')
dashboard.approve(qp) dashboard.approve(qp)
entries = read_audit_entries("egress-proxy", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries)) self.assertEqual(1, len(entries))
self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff) self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff)
self.assertIn('-{"routes": []}', entries[0].diff) self.assertIn('-{"routes": []}', entries[0].diff)
@@ -293,13 +293,13 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.apply_routes_change = lambda slug, content: ( dashboard.apply_routes_change = lambda slug, content: (
called.append(True) or ("", content) called.append(True) or ("", content)
) )
qp = self._enqueue_egress_proxy() qp = self._enqueue_egress()
dashboard.reject(qp, reason="no thanks") dashboard.reject(qp, reason="no thanks")
self.assertEqual([], called) self.assertEqual([], called)
# Reject still writes a response + audit entry with empty diff. # Reject still writes a response + audit entry with empty diff.
resp = read_response(qp.queue_dir, qp.proposal.id) resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual(STATUS_REJECTED, resp.status)
entries = read_audit_entries("egress-proxy", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries)) self.assertEqual(1, len(entries))
self.assertEqual("", entries[0].diff) self.assertEqual("", entries[0].diff)
@@ -443,7 +443,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.approve(qp) dashboard.approve(qp)
# capability-block has no audit log per PRD 0013 — its record # capability-block has no audit log per PRD 0013 — its record
# lives in the per-bottle Dockerfile + transcript state. # lives in the per-bottle Dockerfile + transcript state.
self.assertEqual([], read_audit_entries("egress-proxy", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_proposal_archived_after_apply(self): def test_proposal_archived_after_apply(self):
@@ -475,7 +475,7 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
'{"routes": []}\n', content, '{"routes": []}\n', content,
) )
dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n') dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n')
entries = read_audit_entries("egress-proxy", "dev") entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries)) self.assertEqual(1, len(entries))
self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action) self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action)
self.assertEqual("", entries[0].justification) self.assertEqual("", entries[0].justification)
@@ -483,14 +483,14 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
def test_failure_does_not_write_audit(self): def test_failure_does_not_write_audit(self):
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw( dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
EgressProxyApplyError("nope") EgressApplyError("nope")
) )
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
dashboard.operator_edit_routes("dev", '{"routes": []}\n') dashboard.operator_edit_routes("dev", '{"routes": []}\n')
self.assertEqual([], read_audit_entries("egress-proxy", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
class TestDiscoverEgressProxySlugs(unittest.TestCase): class TestDiscoverEgressSlugs(unittest.TestCase):
"""Slug-extraction parsing — exercises only the parsing path; the """Slug-extraction parsing — exercises only the parsing path; the
docker ps invocation itself is environment-dependent (and tested docker ps invocation itself is environment-dependent (and tested
implicitly by the integration test).""" implicitly by the integration test)."""
@@ -502,7 +502,7 @@ class TestDiscoverEgressProxySlugs(unittest.TestCase):
original = os.environ.get("PATH", "") original = os.environ.get("PATH", "")
os.environ["PATH"] = "/nonexistent-no-docker-here" os.environ["PATH"] = "/nonexistent-no-docker-here"
try: try:
self.assertEqual([], dashboard.discover_egress_proxy_slugs()) self.assertEqual([], dashboard.discover_egress_slugs())
self.assertEqual([], dashboard.discover_pipelock_slugs()) self.assertEqual([], dashboard.discover_pipelock_slugs())
finally: finally:
os.environ["PATH"] = original os.environ["PATH"] = original
+3 -3
View File
@@ -12,7 +12,7 @@ from claude_bottle.cli import dashboard
from claude_bottle.supervise import ( from claude_bottle.supervise import (
Proposal, Proposal,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK, TOOL_PIPELOCK_BLOCK,
sha256_hex, sha256_hex,
) )
@@ -46,9 +46,9 @@ class TestPipelockHostHighlight(unittest.TestCase):
green_lines = [text for text, attr in lines if attr == self.GREEN] green_lines = [text for text, attr in lines if attr == self.GREEN]
self.assertEqual(["api.github.com"], green_lines) self.assertEqual(["api.github.com"], green_lines)
def test_no_green_lines_for_egress_proxy_block(self): def test_no_green_lines_for_egress_block(self):
lines = dashboard._detail_lines( lines = dashboard._detail_lines(
_qp(TOOL_EGRESS_PROXY_BLOCK, '{"routes": []}'), _qp(TOOL_EGRESS_BLOCK, '{"routes": []}'),
green_attr=self.GREEN, green_attr=self.GREEN,
) )
self.assertEqual([], [t for t, a in lines if a == self.GREEN]) self.assertEqual([], [t for t, a in lines if a == self.GREEN])
@@ -1,16 +1,16 @@
"""Unit: EgressProxy route lift + routes.yaml render + token """Unit: Egress route lift + routes.yaml render + token
resolution (PRD 0017).""" resolution (PRD 0017)."""
import json import json
import unittest import unittest
from claude_bottle.egress_proxy import ( from claude_bottle.egress import (
DEFAULT_ALLOWLIST, DEFAULT_ALLOWLIST,
egress_proxy_manifest_routes, egress_manifest_routes,
egress_proxy_render_routes, egress_render_routes,
egress_proxy_resolve_token_values, egress_resolve_token_values,
egress_proxy_routes_for_bottle, egress_routes_for_bottle,
egress_proxy_token_env_map, egress_token_env_map,
) )
from claude_bottle.log import Die from claude_bottle.log import Die
from claude_bottle.manifest import Manifest from claude_bottle.manifest import Manifest
@@ -29,18 +29,18 @@ class TestRoutesForBottle(unittest.TestCase):
"host": "api.github.com", "host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
}]) }])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
self.assertEqual(1, len(routes)) self.assertEqual(1, len(routes))
r = routes[0] r = routes[0]
self.assertEqual("api.github.com", r.host) self.assertEqual("api.github.com", r.host)
self.assertEqual("Bearer", r.auth_scheme) self.assertEqual("Bearer", r.auth_scheme)
self.assertEqual("EGRESS_PROXY_TOKEN_0", r.token_env) self.assertEqual("EGRESS_TOKEN_0", r.token_env)
self.assertEqual("GH_PAT", r.token_ref) self.assertEqual("GH_PAT", r.token_ref)
self.assertEqual((), r.path_allowlist) self.assertEqual((), r.path_allowlist)
def test_unauthenticated_route_has_empty_auth_fields(self): def test_unauthenticated_route_has_empty_auth_fields(self):
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}]) b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
r = routes[0] r = routes[0]
self.assertEqual("", r.auth_scheme) self.assertEqual("", r.auth_scheme)
self.assertEqual("", r.token_env) self.assertEqual("", r.token_env)
@@ -54,9 +54,9 @@ class TestRoutesForBottle(unittest.TestCase):
{"host": "github.com", {"host": "github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}}, "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}},
]) ])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
slots = {r.token_env for r in routes} slots = {r.token_env for r in routes}
self.assertEqual({"EGRESS_PROXY_TOKEN_0"}, slots) self.assertEqual({"EGRESS_TOKEN_0"}, slots)
def test_distinct_token_refs_get_distinct_slots(self): def test_distinct_token_refs_get_distinct_slots(self):
b = _bottle([ b = _bottle([
@@ -65,9 +65,9 @@ class TestRoutesForBottle(unittest.TestCase):
{"host": "b.example", {"host": "b.example",
"auth": {"scheme": "Bearer", "token_ref": "T2"}}, "auth": {"scheme": "Bearer", "token_ref": "T2"}},
]) ])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
slots = [r.token_env for r in routes] slots = [r.token_env for r in routes]
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], slots) self.assertEqual(["EGRESS_TOKEN_0", "EGRESS_TOKEN_1"], slots)
def test_unauthenticated_routes_dont_consume_slots(self): def test_unauthenticated_routes_dont_consume_slots(self):
# A bare-pass route between two authenticated routes mustn't # A bare-pass route between two authenticated routes mustn't
@@ -79,9 +79,9 @@ class TestRoutesForBottle(unittest.TestCase):
{"host": "b.example", {"host": "b.example",
"auth": {"scheme": "Bearer", "token_ref": "T2"}}, "auth": {"scheme": "Bearer", "token_ref": "T2"}},
]) ])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
authed = [r.token_env for r in routes if r.token_env] authed = [r.token_env for r in routes if r.token_env]
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], authed) self.assertEqual(["EGRESS_TOKEN_0", "EGRESS_TOKEN_1"], authed)
self.assertEqual("", routes[1].token_env) self.assertEqual("", routes[1].token_env)
@@ -92,7 +92,7 @@ class TestRoutesForBottleFoldsDefaults(unittest.TestCase):
def test_defaults_present_when_no_manifest_routes(self): def test_defaults_present_when_no_manifest_routes(self):
b = _bottle([]) b = _bottle([])
hosts = [r.host for r in egress_proxy_routes_for_bottle(b)] hosts = [r.host for r in egress_routes_for_bottle(b)]
for default in DEFAULT_ALLOWLIST: for default in DEFAULT_ALLOWLIST:
self.assertIn(default, hosts) self.assertIn(default, hosts)
@@ -104,17 +104,17 @@ class TestRoutesForBottleFoldsDefaults(unittest.TestCase):
"host": "api.anthropic.com", "host": "api.anthropic.com",
"auth": {"scheme": "Bearer", "token_ref": "T"}, "auth": {"scheme": "Bearer", "token_ref": "T"},
}]) }])
routes = egress_proxy_routes_for_bottle(b) routes = egress_routes_for_bottle(b)
anthropic = [r for r in routes if r.host == "api.anthropic.com"] anthropic = [r for r in routes if r.host == "api.anthropic.com"]
self.assertEqual(1, len(anthropic)) self.assertEqual(1, len(anthropic))
self.assertEqual("Bearer", anthropic[0].auth_scheme) self.assertEqual("Bearer", anthropic[0].auth_scheme)
def test_manifest_only_when_no_defaults_or_allowlist(self): def test_manifest_only_when_no_defaults_or_allowlist(self):
# Sanity: egress_proxy_manifest_routes returns just the # Sanity: egress_manifest_routes returns just the
# manifest entries — defaults are added by the # manifest entries — defaults are added by the
# _routes_for_bottle wrapper. # _routes_for_bottle wrapper.
b = _bottle([{"host": "x.example"}]) b = _bottle([{"host": "x.example"}])
manifest = [r.host for r in egress_proxy_manifest_routes(b)] manifest = [r.host for r in egress_manifest_routes(b)]
self.assertEqual(["x.example"], manifest) self.assertEqual(["x.example"], manifest)
@@ -125,12 +125,12 @@ class TestTokenEnvMap(unittest.TestCase):
"auth": {"scheme": "Bearer", "token_ref": "T1"}}, "auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "passthrough.example"}, {"host": "passthrough.example"},
]) ])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
m = egress_proxy_token_env_map(routes) m = egress_token_env_map(routes)
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "T1"}, m) self.assertEqual({"EGRESS_TOKEN_0": "T1"}, m)
def test_no_routes_empty(self): def test_no_routes_empty(self):
self.assertEqual({}, egress_proxy_token_env_map(())) self.assertEqual({}, egress_token_env_map(()))
class TestRenderRoutes(unittest.TestCase): class TestRenderRoutes(unittest.TestCase):
@@ -140,14 +140,14 @@ class TestRenderRoutes(unittest.TestCase):
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
"path_allowlist": ["/repos/x/"], "path_allowlist": ["/repos/x/"],
}]) }])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
payload = json.loads(egress_proxy_render_routes(routes)) payload = json.loads(egress_render_routes(routes))
self.assertEqual( self.assertEqual(
[{ [{
"host": "api.github.com", "host": "api.github.com",
"path_allowlist": ["/repos/x/"], "path_allowlist": ["/repos/x/"],
"auth_scheme": "Bearer", "auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0", "token_env": "EGRESS_TOKEN_0",
}], }],
payload["routes"], payload["routes"],
) )
@@ -158,8 +158,8 @@ class TestRenderRoutes(unittest.TestCase):
# enforces both-or-neither, so emitting empty strings would # enforces both-or-neither, so emitting empty strings would
# round-trip as a partial pair and crash. # round-trip as a partial pair and crash.
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}]) b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
payload = json.loads(egress_proxy_render_routes(routes)) payload = json.loads(egress_render_routes(routes))
entry = payload["routes"][0] entry = payload["routes"][0]
self.assertNotIn("auth_scheme", entry) self.assertNotIn("auth_scheme", entry)
self.assertNotIn("token_env", entry) self.assertNotIn("token_env", entry)
@@ -169,14 +169,14 @@ class TestRenderRoutes(unittest.TestCase):
"host": "api.anthropic.com", "host": "api.anthropic.com",
"auth": {"scheme": "Bearer", "token_ref": "CL"}, "auth": {"scheme": "Bearer", "token_ref": "CL"},
}]) }])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
payload = json.loads(egress_proxy_render_routes(routes)) payload = json.loads(egress_render_routes(routes))
self.assertNotIn("path_allowlist", payload["routes"][0]) self.assertNotIn("path_allowlist", payload["routes"][0])
def test_round_trip_through_addon_core(self): def test_round_trip_through_addon_core(self):
# Render here → parse in the addon must succeed for every # Render here → parse in the addon must succeed for every
# combination the manifest can produce. # combination the manifest can produce.
from claude_bottle.egress_proxy_addon_core import load_routes from claude_bottle.egress_addon_core import load_routes
b = _bottle([ b = _bottle([
{"host": "api.github.com", {"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
@@ -184,34 +184,34 @@ class TestRenderRoutes(unittest.TestCase):
{"host": "github.com", "path_allowlist": ["/x/"]}, {"host": "github.com", "path_allowlist": ["/x/"]},
{"host": "api.anthropic.com"}, {"host": "api.anthropic.com"},
]) ])
routes = egress_proxy_manifest_routes(b) routes = egress_manifest_routes(b)
addon_routes = load_routes(egress_proxy_render_routes(routes)) addon_routes = load_routes(egress_render_routes(routes))
self.assertEqual(3, len(addon_routes)) self.assertEqual(3, len(addon_routes))
self.assertEqual("Bearer", addon_routes[0].auth_scheme) self.assertEqual("Bearer", addon_routes[0].auth_scheme)
self.assertEqual("EGRESS_PROXY_TOKEN_0", addon_routes[0].token_env) self.assertEqual("EGRESS_TOKEN_0", addon_routes[0].token_env)
self.assertEqual("", addon_routes[1].auth_scheme) self.assertEqual("", addon_routes[1].auth_scheme)
self.assertEqual("", addon_routes[2].auth_scheme) self.assertEqual("", addon_routes[2].auth_scheme)
class TestResolveTokenValues(unittest.TestCase): class TestResolveTokenValues(unittest.TestCase):
def test_reads_host_env(self): def test_reads_host_env(self):
out = egress_proxy_resolve_token_values( out = egress_resolve_token_values(
{"EGRESS_PROXY_TOKEN_0": "GH_PAT"}, {"EGRESS_TOKEN_0": "GH_PAT"},
{"GH_PAT": "the-value"}, {"GH_PAT": "the-value"},
) )
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "the-value"}, out) self.assertEqual({"EGRESS_TOKEN_0": "the-value"}, out)
def test_missing_token_ref_dies(self): def test_missing_token_ref_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
egress_proxy_resolve_token_values( egress_resolve_token_values(
{"EGRESS_PROXY_TOKEN_0": "GH_PAT"}, {"EGRESS_TOKEN_0": "GH_PAT"},
{}, {},
) )
def test_empty_token_ref_dies(self): def test_empty_token_ref_dies(self):
with self.assertRaises(Die): with self.assertRaises(Die):
egress_proxy_resolve_token_values( egress_resolve_token_values(
{"EGRESS_PROXY_TOKEN_0": "GH_PAT"}, {"EGRESS_TOKEN_0": "GH_PAT"},
{"GH_PAT": ""}, {"GH_PAT": ""},
) )
@@ -1,12 +1,12 @@
"""Unit: pure-logic core of the egress-proxy mitmproxy addon (PRD 0017). """Unit: pure-logic core of the egress mitmproxy addon (PRD 0017).
These tests target `egress_proxy_addon_core` the host-importable These tests target `egress_addon_core` the host-importable
half of the addon. The mitmproxy hook wrapper in half of the addon. The mitmproxy hook wrapper in
`egress_proxy_addon.py` is container-only and is not exercised here.""" `egress_addon.py` is container-only and is not exercised here."""
import unittest import unittest
from claude_bottle.egress_proxy_addon_core import ( from claude_bottle.egress_addon_core import (
Decision, Decision,
Route, Route,
decide, decide,
@@ -34,12 +34,12 @@ class TestParseRoutes(unittest.TestCase):
"host": "api.github.com", "host": "api.github.com",
"path_allowlist": ["/repos/x/", "/users/x"], "path_allowlist": ["/repos/x/", "/users/x"],
"auth_scheme": "Bearer", "auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0", "token_env": "EGRESS_TOKEN_0",
}]}) }]})
r = routes[0] r = routes[0]
self.assertEqual(("/repos/x/", "/users/x"), r.path_allowlist) self.assertEqual(("/repos/x/", "/users/x"), r.path_allowlist)
self.assertEqual("Bearer", r.auth_scheme) self.assertEqual("Bearer", r.auth_scheme)
self.assertEqual("EGRESS_PROXY_TOKEN_0", r.token_env) self.assertEqual("EGRESS_TOKEN_0", r.token_env)
def test_order_preserved(self): def test_order_preserved(self):
# Host match is exact (not longest-prefix), but the file order # Host match is exact (not longest-prefix), but the file order
@@ -69,7 +69,7 @@ class TestParseRoutes(unittest.TestCase):
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm:
parse_routes({"routes": [{ parse_routes({"routes": [{
"host": "x.example", "host": "x.example",
"token_env": "EGRESS_PROXY_TOKEN_0", "token_env": "EGRESS_TOKEN_0",
}]}) }]})
self.assertIn("both set or both empty", str(cm.exception)) self.assertIn("both set or both empty", str(cm.exception))
@@ -161,9 +161,9 @@ class TestMatchRoute(unittest.TestCase):
class TestDecide(unittest.TestCase): class TestDecide(unittest.TestCase):
def test_no_matching_route_blocks(self): def test_no_matching_route_blocks(self):
# Defense-in-depth: egress-proxy gates the bottle's allowlist # Defense-in-depth: egress gates the bottle's allowlist
# too, not just pipelock. Any host the operator didn't declare # too, not just pipelock. Any host the operator didn't declare
# in egress_proxy.routes is 403'd at egress-proxy before it # in egress.routes is 403'd at egress before it
# ever reaches pipelock. # ever reaches pipelock.
d = decide((), "elsewhere.example", "/anything", {}) d = decide((), "elsewhere.example", "/anything", {})
self.assertEqual("block", d.action) self.assertEqual("block", d.action)
@@ -197,8 +197,8 @@ class TestDecide(unittest.TestCase):
def test_auth_injection_uses_environ_value(self): def test_auth_injection_uses_environ_value(self):
d = decide( d = decide(
(Route(host="api.github.com", auth_scheme="Bearer", (Route(host="api.github.com", auth_scheme="Bearer",
token_env="EGRESS_PROXY_TOKEN_0"),), token_env="EGRESS_TOKEN_0"),),
"api.github.com", "/repos/x", {"EGRESS_PROXY_TOKEN_0": "the-token"}, "api.github.com", "/repos/x", {"EGRESS_TOKEN_0": "the-token"},
) )
self.assertEqual("forward", d.action) self.assertEqual("forward", d.action)
self.assertEqual("Bearer the-token", d.inject_authorization) self.assertEqual("Bearer the-token", d.inject_authorization)
@@ -210,11 +210,11 @@ class TestDecide(unittest.TestCase):
# request the upstream would reject. # request the upstream would reject.
d = decide( d = decide(
(Route(host="api.github.com", auth_scheme="Bearer", (Route(host="api.github.com", auth_scheme="Bearer",
token_env="EGRESS_PROXY_TOKEN_0"),), token_env="EGRESS_TOKEN_0"),),
"api.github.com", "/repos/x", {}, "api.github.com", "/repos/x", {},
) )
self.assertEqual("block", d.action) self.assertEqual("block", d.action)
self.assertIn("EGRESS_PROXY_TOKEN_0", d.reason) self.assertIn("EGRESS_TOKEN_0", d.reason)
def test_auth_with_empty_token_env_blocks(self): def test_auth_with_empty_token_env_blocks(self):
# Empty env var is treated the same as unset — we don't inject # Empty env var is treated the same as unset — we don't inject
@@ -222,8 +222,8 @@ class TestDecide(unittest.TestCase):
# upstream rate limit with a 401. # upstream rate limit with a 401.
d = decide( d = decide(
(Route(host="api.github.com", auth_scheme="Bearer", (Route(host="api.github.com", auth_scheme="Bearer",
token_env="EGRESS_PROXY_TOKEN_0"),), token_env="EGRESS_TOKEN_0"),),
"api.github.com", "/repos/x", {"EGRESS_PROXY_TOKEN_0": ""}, "api.github.com", "/repos/x", {"EGRESS_TOKEN_0": ""},
) )
self.assertEqual("block", d.action) self.assertEqual("block", d.action)
@@ -240,8 +240,8 @@ class TestDecide(unittest.TestCase):
# go-gitea/gitea#16734). The addon is scheme-agnostic. # go-gitea/gitea#16734). The addon is scheme-agnostic.
d = decide( d = decide(
(Route(host="git.example", auth_scheme="token", (Route(host="git.example", auth_scheme="token",
token_env="EGRESS_PROXY_TOKEN_0"),), token_env="EGRESS_TOKEN_0"),),
"git.example", "/api/v1/repos", {"EGRESS_PROXY_TOKEN_0": "abc"}, "git.example", "/api/v1/repos", {"EGRESS_TOKEN_0": "abc"},
) )
self.assertEqual("token abc", d.inject_authorization) self.assertEqual("token abc", d.inject_authorization)
@@ -6,8 +6,8 @@ import unittest
import json import json
from claude_bottle.backend.docker.egress_proxy_apply import ( from claude_bottle.backend.docker.egress_apply import (
EgressProxyApplyError, EgressApplyError,
_hosts_in_routes, _hosts_in_routes,
_merge_single_route, _merge_single_route,
_pipelock_safe_hosts, _pipelock_safe_hosts,
@@ -27,30 +27,30 @@ class TestValidateRoutesContent(unittest.TestCase):
'{"routes": [{"host": "api.github.com",' '{"routes": [{"host": "api.github.com",'
' "path_allowlist": ["/repos/x/"],' ' "path_allowlist": ["/repos/x/"],'
' "auth_scheme": "Bearer",' ' "auth_scheme": "Bearer",'
' "token_env": "EGRESS_PROXY_TOKEN_0"}]}' ' "token_env": "EGRESS_TOKEN_0"}]}'
) )
def test_rejects_bad_json(self): def test_rejects_bad_json(self):
with self.assertRaises(EgressProxyApplyError) as cm: with self.assertRaises(EgressApplyError) as cm:
validate_routes_content("{not json") validate_routes_content("{not json")
self.assertIn("not valid", str(cm.exception)) self.assertIn("not valid", str(cm.exception))
def test_rejects_non_object_top_level(self): def test_rejects_non_object_top_level(self):
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
validate_routes_content("[]") validate_routes_content("[]")
def test_rejects_missing_routes_key(self): def test_rejects_missing_routes_key(self):
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
validate_routes_content('{"other": []}') validate_routes_content('{"other": []}')
def test_rejects_non_list_routes(self): def test_rejects_non_list_routes(self):
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
validate_routes_content('{"routes": "not a list"}') validate_routes_content('{"routes": "not a list"}')
def test_rejects_partial_auth_pair(self): def test_rejects_partial_auth_pair(self):
# The addon-core parser enforces both-or-neither — the apply # The addon-core parser enforces both-or-neither — the apply
# path picks this up before SIGHUP'ing the sidecar. # path picks this up before SIGHUP'ing the sidecar.
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
validate_routes_content( validate_routes_content(
'{"routes": [{"host": "x.example",' '{"routes": [{"host": "x.example",'
' "auth_scheme": "Bearer"}]}' ' "auth_scheme": "Bearer"}]}'
@@ -83,7 +83,7 @@ class TestHostsInRoutes(unittest.TestCase):
def test_invalid_routes_raises(self): def test_invalid_routes_raises(self):
# The mirror helper relies on parsing succeeding; bad input # The mirror helper relies on parsing succeeding; bad input
# should error before pipelock is touched. # should error before pipelock is touched.
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
_hosts_in_routes('{"routes": [{"path": "/no-host/"}]}') _hosts_in_routes('{"routes": [{"path": "/no-host/"}]}')
@@ -115,20 +115,20 @@ class TestMergeSingleRoute(unittest.TestCase):
new_route = json.loads(merged)["routes"][-1] new_route = json.loads(merged)["routes"][-1]
self.assertEqual("Bearer", new_route["auth_scheme"]) self.assertEqual("Bearer", new_route["auth_scheme"])
# First auth slot when no prior auth routes exist. # First auth slot when no prior auth routes exist.
self.assertEqual("EGRESS_PROXY_TOKEN_0", new_route["token_env"]) self.assertEqual("EGRESS_TOKEN_0", new_route["token_env"])
def test_auth_slot_increments_past_existing(self): def test_auth_slot_increments_past_existing(self):
base = json.dumps({"routes": [ base = json.dumps({"routes": [
{"host": "api.anthropic.com", {"host": "api.anthropic.com",
"auth_scheme": "Bearer", "auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0"}, "token_env": "EGRESS_TOKEN_0"},
]}) ]})
merged = _merge_single_route(base, { merged = _merge_single_route(base, {
"host": "api.github.com", "host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"}, "auth": {"scheme": "Bearer", "token_ref": "GH"},
}) })
new_route = json.loads(merged)["routes"][-1] new_route = json.loads(merged)["routes"][-1]
self.assertEqual("EGRESS_PROXY_TOKEN_1", new_route["token_env"]) self.assertEqual("EGRESS_TOKEN_1", new_route["token_env"])
def test_existing_host_merges_path_allowlist_as_union(self): def test_existing_host_merges_path_allowlist_as_union(self):
base = json.dumps({"routes": [ base = json.dumps({"routes": [
@@ -161,7 +161,7 @@ class TestMergeSingleRoute(unittest.TestCase):
base = json.dumps({"routes": [ base = json.dumps({"routes": [
{"host": "api.github.com", {"host": "api.github.com",
"auth_scheme": "Bearer", "auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0"}, "token_env": "EGRESS_TOKEN_0"},
]}) ]})
merged = _merge_single_route(base, { merged = _merge_single_route(base, {
"host": "api.github.com", "host": "api.github.com",
@@ -169,7 +169,7 @@ class TestMergeSingleRoute(unittest.TestCase):
}) })
route = json.loads(merged)["routes"][0] route = json.loads(merged)["routes"][0]
self.assertEqual("Bearer", route["auth_scheme"]) self.assertEqual("Bearer", route["auth_scheme"])
self.assertEqual("EGRESS_PROXY_TOKEN_0", route["token_env"]) self.assertEqual("EGRESS_TOKEN_0", route["token_env"])
def test_host_match_is_case_insensitive(self): def test_host_match_is_case_insensitive(self):
base = json.dumps({"routes": [{"host": "GitHub.com"}]}) base = json.dumps({"routes": [{"host": "GitHub.com"}]})
@@ -182,11 +182,11 @@ class TestMergeSingleRoute(unittest.TestCase):
self.assertEqual(["/x/"], routes[0]["path_allowlist"]) self.assertEqual(["/x/"], routes[0]["path_allowlist"])
def test_missing_host_raises(self): def test_missing_host_raises(self):
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
_merge_single_route(self.BASE, {}) _merge_single_route(self.BASE, {})
def test_invalid_current_yaml_raises(self): def test_invalid_current_yaml_raises(self):
with self.assertRaises(EgressProxyApplyError): with self.assertRaises(EgressApplyError):
_merge_single_route("{not json", {"host": "x.example"}) _merge_single_route("{not json", {"host": "x.example"})
@@ -198,7 +198,7 @@ class TestPipelockSafeHosts(unittest.TestCase):
) )
def test_drops_wildcards(self): def test_drops_wildcards(self):
# Wildcard host matching was removed from egress-proxy too, # Wildcard host matching was removed from egress too,
# so a `*.foo.com` route is dead weight anyway; we drop it # so a `*.foo.com` route is dead weight anyway; we drop it
# entirely from the pipelock mirror so the apply doesn't # entirely from the pipelock mirror so the apply doesn't
# fail parse. # fail parse.
@@ -8,7 +8,7 @@ auth omission means unauthenticated."""
import unittest import unittest
from claude_bottle.log import Die from claude_bottle.log import Die
from claude_bottle.manifest import EgressProxyRoute, Manifest from claude_bottle.manifest import EgressRoute, Manifest
def _bottle(routes): def _bottle(routes):
@@ -201,8 +201,8 @@ class TestRouteValidation(unittest.TestCase):
b = _bottle([]) b = _bottle([])
self.assertEqual((), b.egress.routes) self.assertEqual((), b.egress.routes)
def test_no_egress_proxy_block_means_empty(self): def test_no_egress_block_means_empty(self):
# The bottle dataclass defaults to an empty EgressProxyConfig. # The bottle dataclass defaults to an empty EgressConfig.
b = Manifest.from_json_obj({ b = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
@@ -211,7 +211,7 @@ class TestRouteValidation(unittest.TestCase):
class TestConfigShape(unittest.TestCase): class TestConfigShape(unittest.TestCase):
def test_unknown_egress_proxy_key_rejected(self): def test_unknown_egress_key_rejected(self):
with self.assertRaises(Die): with self.assertRaises(Die):
Manifest.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"wat": []}}}, "bottles": {"dev": {"egress": {"wat": []}}},
+1 -1
View File
@@ -31,7 +31,7 @@ _BOTTLE_DEV = """
- host: example.com - host: example.com
--- ---
The dev bottle. Anthropic OAuth via egress-proxy. The dev bottle. Anthropic OAuth via egress.
""" """
_AGENT_IMPL = """ _AGENT_IMPL = """
+10 -10
View File
@@ -1,5 +1,5 @@
"""Unit: pipelock_effective_allowlist — pipelock's allowlist """Unit: pipelock_effective_allowlist — pipelock's allowlist
mirrors `egress_proxy_routes_for_bottle` (which folds in mirrors `egress_routes_for_bottle` (which folds in
DEFAULT_ALLOWLIST). Git upstreams declared in `bottle.git` don't DEFAULT_ALLOWLIST). Git upstreams declared in `bottle.git` don't
contribute; they flow through the per-agent git-gate (PRD 0008).""" contribute; they flow through the per-agent git-gate (PRD 0008)."""
@@ -25,9 +25,9 @@ def _routes(routes):
class TestEffectiveAllowlist(unittest.TestCase): class TestEffectiveAllowlist(unittest.TestCase):
def test_default_allowlist_present_without_any_manifest_routes(self): def test_default_allowlist_present_without_any_manifest_routes(self):
# No egress_proxy routes declared → pipelock allowlist is # No egress routes declared → pipelock allowlist is
# just the baked DEFAULT_ALLOWLIST (folded in by # just the baked DEFAULT_ALLOWLIST (folded in by
# egress_proxy_routes_for_bottle). # egress_routes_for_bottle).
eff = pipelock_effective_allowlist(_bottle({})) eff = pipelock_effective_allowlist(_bottle({}))
self.assertIn("api.anthropic.com", eff) self.assertIn("api.anthropic.com", eff)
self.assertIn("sentry.io", eff) self.assertIn("sentry.io", eff)
@@ -62,16 +62,16 @@ class TestAllowlistWithRoutes(unittest.TestCase):
self.assertIn(default, eff) self.assertIn(default, eff)
self.assertIn("x.example", eff) self.assertIn("x.example", eff)
def test_egress_proxy_hostname_NOT_in_pipelock_allowlist(self): def test_egress_hostname_NOT_in_pipelock_allowlist(self):
# The agent never dials egress-proxy via the proxy mechanism # The agent never dials egress via the proxy mechanism
# — it IS the proxy. Pipelock receives upstream hostnames # — it IS the proxy. Pipelock receives upstream hostnames
# from egress-proxy's CONNECT requests, not the # from egress's CONNECT requests, not the
# `egress-proxy` hostname itself. # `egress` hostname itself.
eff = pipelock_effective_allowlist(_bottle(_routes([ eff = pipelock_effective_allowlist(_bottle(_routes([
{"host": "x.example", {"host": "x.example",
"auth": {"scheme": "Bearer", "token_ref": "T"}}, "auth": {"scheme": "Bearer", "token_ref": "T"}},
]))) ])))
self.assertNotIn("egress-proxy", eff) self.assertNotIn("egress", eff)
def test_supervise_hostname_auto_added_when_supervise_enabled(self): def test_supervise_hostname_auto_added_when_supervise_enabled(self):
eff = pipelock_effective_allowlist(_bottle({"supervise": True})) eff = pipelock_effective_allowlist(_bottle({"supervise": True}))
@@ -84,9 +84,9 @@ class TestAllowlistWithRoutes(unittest.TestCase):
self.assertNotIn("supervise", eff_explicit) self.assertNotIn("supervise", eff_explicit)
def test_path_allowlist_does_not_affect_pipelock_allowlist(self): def test_path_allowlist_does_not_affect_pipelock_allowlist(self):
# path_allowlist is enforced by egress-proxy, not pipelock. # path_allowlist is enforced by egress, not pipelock.
# Pipelock only sees the upstream hostname; the path filter # Pipelock only sees the upstream hostname; the path filter
# has already passed (or 403'd) at egress-proxy. # has already passed (or 403'd) at egress.
eff = pipelock_effective_allowlist(_bottle(_routes([ eff = pipelock_effective_allowlist(_bottle(_routes([
{"host": "github.com", "path_allowlist": ["/x/", "/y/"]}, {"host": "github.com", "path_allowlist": ["/x/", "/y/"]},
]))) ])))
+9 -9
View File
@@ -17,7 +17,7 @@ from claude_bottle.supervise import (
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK, TOOL_PIPELOCK_BLOCK,
archive_proposal, archive_proposal,
audit_log_path, audit_log_path,
@@ -37,7 +37,7 @@ from claude_bottle.supervise import (
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(tool: str = TOOL_EGRESS_PROXY_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal: def _proposal(tool: str = TOOL_EGRESS_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal:
return Proposal.new( return Proposal.new(
bottle_slug="dev", bottle_slug="dev",
tool=tool, tool=tool,
@@ -54,7 +54,7 @@ class TestProposalRoundtrip(unittest.TestCase):
self.assertTrue(p.id) self.assertTrue(p.id)
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp) self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
self.assertEqual("dev", p.bottle_slug) self.assertEqual("dev", p.bottle_slug)
self.assertEqual(TOOL_EGRESS_PROXY_BLOCK, p.tool) self.assertEqual(TOOL_EGRESS_BLOCK, p.tool)
def test_to_from_dict_roundtrip(self): def test_to_from_dict_roundtrip(self):
p = _proposal() p = _proposal()
@@ -139,13 +139,13 @@ class TestQueueIO(unittest.TestCase):
def test_list_pending_sorted_by_arrival(self): def test_list_pending_sorted_by_arrival(self):
# Fabricate two with explicit timestamps. # Fabricate two with explicit timestamps.
a = Proposal.new( a = Proposal.new(
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_BLOCK,
proposed_file="{}", justification="early", proposed_file="{}", justification="early",
current_file_hash="x", current_file_hash="x",
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
) )
b = Proposal.new( b = Proposal.new(
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, bottle_slug="dev", tool=TOOL_EGRESS_BLOCK,
proposed_file="{}", justification="late", proposed_file="{}", justification="late",
current_file_hash="x", current_file_hash="x",
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
@@ -315,16 +315,16 @@ class TestToolConstants(unittest.TestCase):
def test_tools_tuple_matches_individual_constants(self): def test_tools_tuple_matches_individual_constants(self):
self.assertEqual( self.assertEqual(
( (
TOOL_EGRESS_PROXY_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK, TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
supervise.TOOL_LIST_EGRESS_PROXY_ROUTES, supervise.TOOL_LIST_EGRESS_ROUTES,
), ),
supervise.TOOLS, supervise.TOOLS,
) )
def test_component_map_covers_two_remediation_tools_only(self): def test_component_map_covers_two_remediation_tools_only(self):
self.assertIn(TOOL_EGRESS_PROXY_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertIn(TOOL_EGRESS_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL)
@@ -375,7 +375,7 @@ class TestSupervisePrepare(unittest.TestCase):
def test_prepare_only_writes_dockerfile_to_current_config(self): def test_prepare_only_writes_dockerfile_to_current_config(self):
# routes.yaml + allowlist live behind the # routes.yaml + allowlist live behind the
# `list-egress-proxy-routes` MCP tool now (PRD 0017 chunk 3). # `list-egress-routes` MCP tool now (PRD 0017 chunk 3).
plan = _StubSupervise().prepare("dev", self.stage_dir) plan = _StubSupervise().prepare("dev", self.stage_dir)
files = sorted(p.name for p in plan.current_config_dir.iterdir()) files = sorted(p.name for p in plan.current_config_dir.iterdir())
self.assertEqual(["Dockerfile"], files) self.assertEqual(["Dockerfile"], files)
+10 -10
View File
@@ -74,9 +74,9 @@ class TestValidation(unittest.TestCase):
) )
def test_empty_proposed_file_rejected_for_tools_with_file_field(self): def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
# egress-proxy-block has structured input (validated in # egress-block has structured input (validated in
# _validate_and_bundle_egress_route, not here) and # _validate_and_bundle_egress_route, not here) and
# list-egress-proxy-routes takes no input. Only the other # list-egress-routes takes no input. Only the other
# two go through `validate_proposed_file`. # two go through `validate_proposed_file`.
for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK): for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK):
with self.subTest(tool=tool): with self.subTest(tool=tool):
@@ -163,10 +163,10 @@ class TestHandleToolsList(unittest.TestCase):
names = [t["name"] for t in result["tools"]] # type: ignore[index] names = [t["name"] for t in result["tools"]] # type: ignore[index]
self.assertEqual( self.assertEqual(
sorted([ sorted([
_sv.TOOL_EGRESS_PROXY_BLOCK, _sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_PIPELOCK_BLOCK,
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_LIST_EGRESS_PROXY_ROUTES, _sv.TOOL_LIST_EGRESS_ROUTES,
]), ]),
sorted(names), sorted(names),
) )
@@ -186,10 +186,10 @@ class TestHandleToolsList(unittest.TestCase):
self.assertIn("justification", required) self.assertIn("justification", required)
self.assertIn(PROPOSED_FILE_FIELD[name], required) # type: ignore[index] self.assertIn(PROPOSED_FILE_FIELD[name], required) # type: ignore[index]
def test_list_egress_proxy_routes_takes_no_input(self): def test_list_egress_routes_takes_no_input(self):
tool = next( tool = next(
t for t in TOOL_DEFINITIONS t for t in TOOL_DEFINITIONS
if t["name"] == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES if t["name"] == _sv.TOOL_LIST_EGRESS_ROUTES
) )
schema = tool["inputSchema"] schema = tool["inputSchema"]
self.assertEqual({}, schema.get("properties")) # type: ignore[union-attr] self.assertEqual({}, schema.get("properties")) # type: ignore[union-attr]
@@ -229,7 +229,7 @@ class TestHandleToolsCall(unittest.TestCase):
try: try:
result = handle_tools_call( result = handle_tools_call(
{ {
"name": _sv.TOOL_EGRESS_PROXY_BLOCK, "name": _sv.TOOL_EGRESS_BLOCK,
"arguments": { "arguments": {
"host": "example.com", "host": "example.com",
"justification": "need a route", "justification": "need a route",
@@ -273,7 +273,7 @@ class TestHandleToolsCall(unittest.TestCase):
with self.assertRaises(_RpcError): with self.assertRaises(_RpcError):
handle_tools_call( handle_tools_call(
{ {
"name": _sv.TOOL_EGRESS_PROXY_BLOCK, "name": _sv.TOOL_EGRESS_BLOCK,
"arguments": {"host": "example.com"}, "arguments": {"host": "example.com"},
}, },
self.config, self.config,
@@ -284,7 +284,7 @@ class TestHandleToolsCall(unittest.TestCase):
try: try:
handle_tools_call( handle_tools_call(
{ {
"name": _sv.TOOL_EGRESS_PROXY_BLOCK, "name": _sv.TOOL_EGRESS_BLOCK,
"arguments": { "arguments": {
"host": "example.com", "host": "example.com",
"justification": "x", "justification": "x",
@@ -371,7 +371,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.assertEqual("2.0", result["jsonrpc"]) self.assertEqual("2.0", result["jsonrpc"])
self.assertEqual(1, result["id"]) self.assertEqual(1, result["id"])
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index] names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
self.assertIn(_sv.TOOL_EGRESS_PROXY_BLOCK, names) self.assertIn(_sv.TOOL_EGRESS_BLOCK, names)
def test_unknown_method_returns_jsonrpc_error(self): def test_unknown_method_returns_jsonrpc_error(self):
result = self._post_jsonrpc( result = self._post_jsonrpc(