diff --git a/Dockerfile.egress-proxy b/Dockerfile.egress similarity index 66% rename from Dockerfile.egress-proxy rename to Dockerfile.egress index c948077..9a51728 100644 --- a/Dockerfile.egress-proxy +++ b/Dockerfile.egress @@ -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 / # 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 # the mitmproxy hook wrapper. Both land flat in /app/ so mitmdump's # 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_proxy_addon.py /app/egress_proxy_addon.py +COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py +COPY claude_bottle/egress_addon.py /app/egress_addon.py # Pre-create the runtime directories the backend's start step will # `docker cp` into. docker cp does not create intermediate dirs, so # 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 # pipelock CA (cert only, for upstream # trust on the HTTPS_PROXY=pipelock leg) # Ownership lets the unprivileged mitmproxy user read the files. -RUN mkdir -p /etc/egress-proxy /home/mitmproxy/.mitmproxy \ - && chown -R mitmproxy:mitmproxy /etc/egress-proxy /home/mitmproxy/.mitmproxy /app +RUN mkdir -p /etc/egress /home/mitmproxy/.mitmproxy \ + && chown -R mitmproxy:mitmproxy /etc/egress /home/mitmproxy/.mitmproxy /app 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 # for the internal network to route to it. EXPOSE 9099 # 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 # post-MITM traffic through pipelock. (mitmproxy does NOT # honor HTTPS_PROXY env vars on its outbound side — it's a # 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. -# - 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 # point mitmproxy at it via # `--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 # bundle covers both pipelock-MITM'd and pipelock-passthrough # hosts. -# - -s /app/egress_proxy_addon.py → loads our addon, reads -# /etc/egress-proxy/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"] +# - -s /app/egress_addon.py → loads our addon, reads +# /etc/egress/routes.yaml. +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"] diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 589f775..6c60cc4 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -231,7 +231,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): def provision_ca(self, plan: PlanT, target: str) -> None: """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 backends that don't yet support TLS interception (every backend except Docker today) aren't forced to implement it. The Docker diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index b46986a..5832dc2 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -23,7 +23,7 @@ from . import prepare as _prepare from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan -from .egress_proxy import DockerEgressProxy +from .egress import DockerEgress from .git_gate import DockerGitGate from .pipelock import DockerPipelockProxy from .provision import ca as _ca @@ -43,7 +43,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def __init__(self) -> None: self._proxy = DockerPipelockProxy() self._git_gate = DockerGitGate() - self._egress_proxy = DockerEgressProxy() + self._egress = DockerEgress() self._supervise = DockerSupervise() def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: @@ -52,7 +52,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup stage_dir=stage_dir, proxy=self._proxy, git_gate=self._git_gate, - egress_proxy=self._egress_proxy, + egress=self._egress, supervise=self._supervise, ) @@ -62,7 +62,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup plan, proxy=self._proxy, git_gate=self._git_gate, - egress_proxy=self._egress_proxy, + egress=self._egress, supervise=self._supervise, provision=self.provision, ) as bottle: diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index 68b157b..1805dd6 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -11,7 +11,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path -from ...egress_proxy import EgressProxyPlan +from ...egress import EgressPlan from ...git_gate import GitGatePlan from ...log import info from ...pipelock import PipelockProxyPlan @@ -45,7 +45,7 @@ class DockerBottlePlan(BottlePlan): prompt_file: Path proxy_plan: PipelockProxyPlan git_gate_plan: GitGatePlan - egress_proxy_plan: EgressProxyPlan + egress_plan: EgressPlan # None when bottle.supervise is False. PRD 0013 supervise sidecar # is opt-in via the manifest's bottle.supervise field. supervise_plan: SupervisePlan | None @@ -65,14 +65,14 @@ class DockerBottlePlan(BottlePlan): # --env-file) and forwarded env names (`-e NAME` with the # value arriving via subprocess env). The forwarded set holds # the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env - # interpolations from the manifest; egress-proxy holds + # interpolations from the manifest; egress holds # upstream tokens in its own environ, so no token forwarding # from the agent to the proxy is needed. env_names = sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())) def _multi(label: str, values: list[str]) -> None: """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.""" if not values: info(f"{label}: (none)") @@ -95,11 +95,11 @@ class DockerBottlePlan(BottlePlan): if git_lines: _multi(" git gate ", git_lines) - if self.egress_proxy_plan.routes: + if self.egress_plan.routes: 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 "" egress_lines.append(f"{r.host}{auth}") - _multi(" egress-proxy ", egress_lines) + _multi(" egress ", egress_lines) print(file=sys.stderr) diff --git a/claude_bottle/backend/docker/bottle_state.py b/claude_bottle/backend/docker/bottle_state.py index b1ec2f6..854192b 100644 --- a/claude_bottle/backend/docker/bottle_state.py +++ b/claude_bottle/backend/docker/bottle_state.py @@ -47,7 +47,7 @@ _TRANSCRIPT_SUBDIR = "transcript" _METADATA_NAME = "metadata.json" # Live-config dir bind-mounted into the supervise sidecar (read-only). # 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. _LIVE_CONFIG_SUBDIR = "live-config" LIVE_CONFIG_ROUTES_NAME = "routes.yaml" diff --git a/claude_bottle/backend/docker/egress_proxy.py b/claude_bottle/backend/docker/egress.py similarity index 71% rename from claude_bottle/backend/docker/egress_proxy.py rename to claude_bottle/backend/docker/egress.py index 819b6b0..9d1f5f1 100644 --- a/claude_bottle/backend/docker/egress_proxy.py +++ b/claude_bottle/backend/docker/egress.py @@ -1,7 +1,7 @@ -"""DockerEgressProxy — the Docker-specific lifecycle for the -per-bottle egress-proxy sidecar (PRD 0017). Inherits the platform- +"""DockerEgress — the Docker-specific lifecycle for the +per-bottle egress sidecar (PRD 0017). Inherits the platform- 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 launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy- @@ -13,12 +13,12 @@ import os import subprocess from pathlib import Path -from ...egress_proxy import ( - EGRESS_PROXY_HOSTNAME, - EGRESS_PROXY_ROUTES_IN_CONTAINER, - EgressProxy, - EgressProxyPlan, - egress_proxy_resolve_token_values, +from ...egress import ( + EGRESS_HOSTNAME, + EGRESS_ROUTES_IN_CONTAINER, + Egress, + EgressPlan, + egress_resolve_token_values, ) from ...log import die, info, warn from . import util as docker_mod @@ -26,63 +26,63 @@ from . import util as docker_mod -EGRESS_PROXY_IMAGE = os.environ.get( - "CLAUDE_BOTTLE_EGRESS_PROXY_IMAGE", - "claude-bottle-egress-proxy:latest", +EGRESS_IMAGE = os.environ.get( + "CLAUDE_BOTTLE_EGRESS_IMAGE", + "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 -# resolves to `http://egress-proxy:`. -EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099")) +# resolves to `http://egress:`. +EGRESS_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PORT", "9099")) # In-container path for mitmproxy's CA. The format is a single PEM # file holding BOTH the cert and the private key, concatenated. The -# upstream-trust CA (pipelock's, so egress-proxy trusts the upstream +# upstream-trust CA (pipelock's, so egress trusts the upstream # leg) is a separate file because pipelock keeps a different CA on # its end. -EGRESS_PROXY_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem" -EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER = ( +EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem" +EGRESS_PIPELOCK_CA_IN_CONTAINER = ( "/home/mitmproxy/.mitmproxy/pipelock-ca.pem" ) # Repo root, for `docker build` context. Resolved from this file's -# location: claude_bottle/backend/docker/egress_proxy.py → repo root. +# location: claude_bottle/backend/docker/egress.py → repo root. _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -def egress_proxy_container_name(slug: str) -> str: - return f"claude-bottle-egress-proxy-{slug}" +def egress_container_name(slug: str) -> str: + 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 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.""" - return f"http://{EGRESS_PROXY_HOSTNAME}:{EGRESS_PROXY_PORT}" + return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}" -def build_egress_proxy_image() -> None: - """Build the egress-proxy image from `Dockerfile.egress-proxy`. - Called by `DockerEgressProxy.start`; exposed at module level so +def build_egress_image() -> None: + """Build the egress image from `Dockerfile.egress`. + Called by `DockerEgress.start`; exposed at module level so integration tests can build it without running the full launch pipeline.""" docker_mod.build_image( - EGRESS_PROXY_IMAGE, _REPO_DIR, dockerfile=EGRESS_PROXY_DOCKERFILE, + EGRESS_IMAGE, _REPO_DIR, dockerfile=EGRESS_DOCKERFILE, ) -def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]: - """Mint the per-bottle egress-proxy MITM CA via host `openssl req`. +def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: + """Mint the per-bottle egress MITM CA via host `openssl req`. Returns `(mitmproxy_pem, cert_only_pem)`: - `mitmproxy_pem` is the single-PEM concat (cert + key) mitmproxy reads from `~/.mitmproxy/mitmproxy-ca.pem`. - `cert_only_pem` is the cert alone — installed into the agent's trust store by `provision_ca` so the agent trusts the bumped - CONNECT cert egress-proxy presents. + CONNECT cert egress presents. Why openssl req (not the pipelock binary's `tls init`): 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 SHA-1(pubkey), matching mitmproxy's computation. - Both files live under `/egress-proxy-ca/` (mode 644 — + Both files live under `/egress-ca/` (mode 644 — `docker cp` preserves the mode into the container, where the mitmproxy user (uid 1000) reads them; the host stage_dir is mode 700 so the private key isn't world-exposed).""" - work = stage_dir / "egress-proxy-ca" + work = stage_dir / "egress-ca" work.mkdir(exist_ok=True) key_path = work / "ca-key.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, ) 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 # 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" "[req_dn]\n" "O = claude-bottle\n" - "CN = claude-bottle egress-proxy CA\n" + "CN = claude-bottle egress CA\n" "\n" "[v3_ca]\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, ) 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) # 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) -class DockerEgressProxy(EgressProxy): - """Brings the egress-proxy sidecar up and down via Docker.""" +class DockerEgress(Egress): + """Brings the egress sidecar up and down via Docker.""" - def start(self, plan: EgressProxyPlan) -> str: - """Boot the egress-proxy sidecar: + def start(self, plan: EgressPlan) -> str: + """Boot the egress sidecar: 1. Resolve every host TokenRef env var into a concrete value. Fails early if any are unset. - 2. Build the egress-proxy image (no-op when cache is hot). + 2. Build the egress image (no-op when cache is hot). 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 - `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), - 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. 4. `docker cp` the routes.yaml, mitmproxy CA (cert+key concat), and pipelock CA (cert only) into the container. @@ -177,67 +177,67 @@ class DockerEgressProxy(EgressProxy): 6. `docker start`. Returns the container name (the target passed to `.stop`).""" if not plan.routes: - die("DockerEgressProxy.start called with no routes; caller should skip") + die("DockerEgress.start called with no routes; caller should skip") if not plan.internal_network or not plan.egress_network: die( - "DockerEgressProxy.start: internal_network / egress_network must be " + "DockerEgress.start: internal_network / egress_network must be " "populated on the plan before start" ) if not plan.routes_path.is_file(): die( - f"egress-proxy routes file missing at {plan.routes_path}; " - f"EgressProxy.prepare must run first" + f"egress routes file missing at {plan.routes_path}; " + f"Egress.prepare must run first" ) if plan.mitmproxy_ca_host_path == Path() or not plan.mitmproxy_ca_host_path.is_file(): die( - f"DockerEgressProxy.start: mitmproxy CA missing at " - f"{plan.mitmproxy_ca_host_path}; egress_proxy_tls_init must run first" + f"DockerEgress.start: mitmproxy CA missing at " + f"{plan.mitmproxy_ca_host_path}; egress_tls_init must run first" ) # pipelock CA + upstream proxy URL: both must be present (we # use HTTPS_PROXY=pipelock with pipelock's own MITM CA on the - # upstream leg) or both absent (egress-proxy goes direct, for + # upstream leg) or both absent (egress goes direct, for # standalone integration tests that don't bring pipelock up). route_via_pipelock = bool(plan.pipelock_proxy_url) or plan.pipelock_ca_host_path != Path() if route_via_pipelock: if not plan.pipelock_proxy_url: die( - "DockerEgressProxy.start: pipelock_ca_host_path is set but " + "DockerEgress.start: pipelock_ca_host_path is set but " "pipelock_proxy_url is empty; populate both or neither." ) if not plan.pipelock_ca_host_path.is_file(): die( - f"DockerEgressProxy.start: pipelock CA missing at " + f"DockerEgress.start: pipelock CA missing at " f"{plan.pipelock_ca_host_path}; pipelock_tls_init must run first" ) # Resolve host env vars into concrete values. Must happen at # start time (not prepare) — the values flow into the sidecar's # environ via subprocess env. The plan never holds them. - token_values = egress_proxy_resolve_token_values( + token_values = egress_resolve_token_values( plan.token_env_map, dict(os.environ), ) - build_egress_proxy_image() + build_egress_image() - name = egress_proxy_container_name(plan.slug) - info(f"starting egress-proxy sidecar {name} on network {plan.internal_network}") + name = egress_container_name(plan.slug) + info(f"starting egress sidecar {name} on network {plan.internal_network}") create_args = [ "docker", "create", "--name", name, "--network", plan.internal_network, - "--network-alias", EGRESS_PROXY_HOSTNAME, + "--network-alias", EGRESS_HOSTNAME, ] 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 - # 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 - # 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 # 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 # side (it's a proxy server, not a client). The # entrypoint reads this env and switches mitmdump to @@ -247,22 +247,22 @@ class DockerEgressProxy(EgressProxy): # bundled client libraries (mitmproxy plugin requests, # etc.) that might honor them — harmless if ignored. create_args.extend([ - "-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}", + "-e", f"EGRESS_UPSTREAM_PROXY={plan.pipelock_proxy_url}", "-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}", "-e", f"HTTP_PROXY={plan.pipelock_proxy_url}", "-e", "NO_PROXY=localhost,127.0.0.1", - "-e", f"EGRESS_PROXY_UPSTREAM_CA={EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER}", + "-e", f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}", ]) # One -e flag per token slot; values arrive via subprocess env. # docker create with `-e NAME` (no =VALUE) reads NAME from the # current process env at create time. We pass `env=child_env` # to subprocess.run so the value comes from token_values, not # the host's os.environ directly — keeps the resolver in one - # place and lets egress_proxy_resolve_token_values surface + # place and lets egress_resolve_token_values surface # missing-env errors with a clear hint. for token_env in sorted(plan.token_env_map.keys()): create_args.extend(["-e", token_env]) - create_args.append(EGRESS_PROXY_IMAGE) + create_args.append(EGRESS_IMAGE) child_env: dict[str, str] = {**os.environ, **token_values} @@ -271,7 +271,7 @@ class DockerEgressProxy(EgressProxy): ) if create_result.returncode != 0: die( - f"failed to create egress-proxy sidecar {name}: " + f"failed to create egress sidecar {name}: " f"{create_result.stderr.strip()}" ) @@ -281,19 +281,19 @@ class DockerEgressProxy(EgressProxy): # isn't actually exposed to other host users. plan.routes_path.chmod(0o644) # Pipelock CA: pipelock itself runs as root so its in-pipelock - # copy doesn't care about mode, but egress-proxy's mitmproxy - # user does. Bump on the host so docker cp into egress-proxy + # copy doesn't care about mode, but egress's mitmproxy + # user does. Bump on the host so docker cp into egress # carries world-readable. if route_via_pipelock: plan.pipelock_ca_host_path.chmod(0o644) cps: list[tuple[Path, str, str]] = [ - (plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"), - (plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"), + (plan.routes_path, EGRESS_ROUTES_IN_CONTAINER, "routes.yaml"), + (plan.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER, "mitmproxy CA"), ] if route_via_pipelock: cps.append(( plan.pipelock_ca_host_path, - EGRESS_PROXY_PIPELOCK_CA_IN_CONTAINER, + EGRESS_PIPELOCK_CA_IN_CONTAINER, "pipelock CA", )) for src, dst, label in cps: @@ -327,7 +327,7 @@ class DockerEgressProxy(EgressProxy): check=False, ) 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()}" ) @@ -342,7 +342,7 @@ class DockerEgressProxy(EgressProxy): check=False, ) die( - f"failed to start egress-proxy sidecar {name}: " + f"failed to start egress sidecar {name}: " f"{start_result.stderr.strip()}" ) @@ -364,6 +364,6 @@ class DockerEgressProxy(EgressProxy): check=False, ).returncode != 0: warn( - f"failed to remove egress-proxy sidecar {target}; " + f"failed to remove egress sidecar {target}; " f"clean up with 'docker rm -f {target}'" ) diff --git a/claude_bottle/backend/docker/egress_proxy_apply.py b/claude_bottle/backend/docker/egress_apply.py similarity index 82% rename from claude_bottle/backend/docker/egress_proxy_apply.py rename to claude_bottle/backend/docker/egress_apply.py index e73c620..63e633a 100644 --- a/claude_bottle/backend/docker/egress_proxy_apply.py +++ b/claude_bottle/backend/docker/egress_apply.py @@ -1,21 +1,21 @@ """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 -egress-proxy-block proposal (or runs the operator-initiated +egress-block proposal (or runs the operator-initiated `routes edit ` verb). Fetches the current routes.yaml via `docker exec cat`, validates the new content, writes it into the sidecar via `docker cp`, then `docker kill --signal HUP` to make the addon reload without dropping connections. 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 hostname allowlist + DLP body scan on the upstream leg, and a host added to one must be in the other or the request 403s 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 operator can retry. """ @@ -29,9 +29,9 @@ import subprocess import tempfile from pathlib import Path -from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER -from ...egress_proxy_addon_core import load_routes -from .egress_proxy import egress_proxy_container_name +from ...egress import EGRESS_ROUTES_IN_CONTAINER +from ...egress_addon_core import load_routes +from .egress import egress_container_name from .pipelock_apply import ( PipelockApplyError, 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 operator; does not crash the dashboard.""" 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 - EgressProxyApplyError if the sidecar isn't reachable or the read + EgressApplyError if the sidecar isn't reachable or the read fails.""" - container = egress_proxy_container_name(slug) + container = egress_container_name(slug) 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, ) if r.returncode != 0: - raise EgressProxyApplyError( + raise EgressApplyError( f"could not read routes.yaml from {container}: " f"{(r.stderr or '').strip() or 'container not running?'}" ) @@ -71,7 +71,7 @@ def validate_routes_content(content: str) -> None: try: load_routes(content) except ValueError as e: - raise EgressProxyApplyError( + raise EgressApplyError( f"proposed routes.yaml is not valid: {e}" ) from e @@ -83,7 +83,7 @@ def _hosts_in_routes(content: str) -> list[str]: try: routes = load_routes(content) except ValueError as e: - raise EgressProxyApplyError( + raise EgressApplyError( f"proposed routes.yaml is not valid: {e}" ) from e 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, # stray characters) is silently dropped from the mirror so the # 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 # 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_.-]+$") @@ -110,10 +110,10 @@ def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None: """Ensure every pipelock-compatible `hosts` entry is on pipelock's allowlist. Fetches pipelock's current allowlist, 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 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.""" safe_hosts = _pipelock_safe_hosts(hosts) try: @@ -124,42 +124,42 @@ def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None: return # nothing to add apply_allowlist_change(slug, render_allowlist_content(merged)) 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 # pipelock-side problem so the operator looks in the right # place; their `pipelock edit` flow can repair manually. - raise EgressProxyApplyError( - f"pipelock allowlist mirror failed (egress-proxy NOT " + raise EgressApplyError( + f"pipelock allowlist mirror failed (egress NOT " f"updated): {e}. Fix pipelock's allowlist manually with " f"`pipelock edit ` then retry the proposal." ) from e 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). 2. Validate the new content via the addon's own parser. 3. Mirror the route hosts onto pipelock's allowlist (so the 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. 5. `docker kill --signal HUP` so the addon reloads. - Order matters: pipelock first, then egress-proxy. If the - pipelock step fails, egress-proxy hasn't been touched and the - old routes stay live. If the egress-proxy step fails after + Order matters: pipelock first, then egress. If the + pipelock step fails, egress hasn't been touched and the + old routes stay live. If the egress step fails after pipelock succeeded, pipelock has the host in its allowlist but - egress-proxy doesn't enforce it yet — harmless extra-permissive - state at pipelock, and a re-approval will land the egress-proxy + egress doesn't enforce it yet — harmless extra-permissive + state at pipelock, and a re-approval will land the egress side. Returns (before, after) where `after` == `new_content`. Raises - EgressProxyApplyError on any step.""" - container = egress_proxy_container_name(slug) + EgressApplyError on any step.""" + container = egress_container_name(slug) before = fetch_current_routes(slug) 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. _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) cp = subprocess.run( ["docker", "cp", tmp_path, - f"{container}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"], + f"{container}:{EGRESS_ROUTES_IN_CONTAINER}"], capture_output=True, text=True, check=False, ) if cp.returncode != 0: - raise EgressProxyApplyError( + raise EgressApplyError( f"failed to copy routes.yaml into {container}: " 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, ) if sig.returncode != 0: - raise EgressProxyApplyError( + raise EgressApplyError( f"failed to SIGHUP {container}: " f"{(sig.stderr or '').strip()}" ) @@ -228,18 +228,18 @@ def _merge_single_route( try: cfg = json.loads(current_yaml) except json.JSONDecodeError as e: - raise EgressProxyApplyError( + raise EgressApplyError( f"current routes.yaml is not valid JSON: {e}" ) from e routes = cfg.get("routes") if not isinstance(routes, list): - raise EgressProxyApplyError( + raise EgressApplyError( "current routes.yaml: 'routes' is not a list" ) new_host = str(new_route.get("host", "")).lower() if not new_host: - raise EgressProxyApplyError( + raise EgressApplyError( "proposed route is missing 'host'" ) @@ -280,7 +280,7 @@ def _merge_single_route( }) next_idx = len(existing_slots) 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 # environ keyed by token_env. A newly-added auth route at # 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]: - """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, and applies via `apply_routes_change`. Returns (before, after) full-file content for the audit log.""" try: proposed = json.loads(proposed_route_json) except json.JSONDecodeError as e: - raise EgressProxyApplyError( + raise EgressApplyError( f"proposed route is not valid JSON: {e}" ) from e if not isinstance(proposed, dict): - raise EgressProxyApplyError( + raise EgressApplyError( "proposed route must be a JSON object" ) current = fetch_current_routes(slug) @@ -315,7 +315,7 @@ def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]: __all__ = [ - "EgressProxyApplyError", + "EgressApplyError", "add_route", "apply_routes_change", "fetch_current_routes", diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 070cc81..09db597 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -24,10 +24,10 @@ from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan -from .egress_proxy import ( - DockerEgressProxy, - egress_proxy_tls_init, - egress_proxy_url, +from .egress import ( + DockerEgress, + egress_tls_init, + egress_url, ) from .git_gate import DockerGitGate from .pipelock import ( @@ -51,7 +51,7 @@ def launch( *, proxy: DockerPipelockProxy, git_gate: DockerGitGate, - egress_proxy: DockerEgressProxy, + egress: DockerEgress, supervise: DockerSupervise, provision: Callable[[DockerBottlePlan, str], str | None], ) -> Generator[DockerBottle, None, None]: @@ -88,7 +88,7 @@ def launch( # Docker assigns a CIDR to the new internal network. Pipelock's # SSRF guard otherwise rejects any destination resolving into # RFC1918 space — which includes the 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 # traffic passes through pipelock; api_allowlist + body-scanning # still apply. @@ -97,16 +97,16 @@ def launch( # Per-bottle ephemeral CAs (PRD 0006 + PRD 0017). Two # separate CAs: # - pipelock CA: signs MITM certs pipelock presents on the - # egress-proxy → upstream leg. - # - egress-proxy CA: signs MITM certs egress-proxy presents - # to the agent on the agent → egress-proxy leg. + # egress → upstream leg. + # - egress CA: signs MITM certs egress presents + # to the agent on the agent → egress leg. # Both are minted by one-shot pipelock containers (pipelock's # `tls init` is a known-good RSA CA minter) under stage_dir; # the .start steps docker-cp the files in. Private keys never # leave the host stage dir, which start.py's outer finally # `shutil.rmtree`s after the sidecars are torn down. ca_cert_host, ca_key_host = pipelock_tls_init(plan.stage_dir) - egress_proxy_ca_host, egress_proxy_ca_cert_only = egress_proxy_tls_init( + egress_ca_host, egress_ca_cert_only = egress_tls_init( plan.stage_dir, ) @@ -156,26 +156,26 @@ def launch( # Egress-proxy (PRD 0017). One sidecar per bottle when # 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 # trust store) so the egress allowlist + body scanner sit on - # the egress-proxy → upstream leg. Must come up BEFORE the - # agent so DNS resolution for `egress-proxy` succeeds on the + # the egress → upstream leg. Must come up BEFORE the + # agent so DNS resolution for `egress` succeeds on the # agent's first call; tokens flow from the host env into the # sidecar's environ, not the agent's. - if plan.egress_proxy_plan.routes: - egress_proxy_plan = dataclasses.replace( - plan.egress_proxy_plan, + if plan.egress_plan.routes: + egress_plan = dataclasses.replace( + plan.egress_plan, internal_network=internal_network, egress_network=egress_network, - mitmproxy_ca_host_path=egress_proxy_ca_host, - mitmproxy_ca_cert_only_host_path=egress_proxy_ca_cert_only, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, pipelock_ca_host_path=ca_cert_host, pipelock_proxy_url=pipelock_proxy_url(plan.slug), ) - plan = dataclasses.replace(plan, egress_proxy_plan=egress_proxy_plan) - egress_proxy_name = egress_proxy.start(plan.egress_proxy_plan) - stack.callback(egress_proxy.stop, egress_proxy_name) + plan = dataclasses.replace(plan, egress_plan=egress_plan) + egress_name = egress.start(plan.egress_plan) + stack.callback(egress.stop, egress_name) # Supervise sidecar (PRD 0013). Opt-in via bottle.supervise. # 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: """Pick the proxy URL the agent's HTTP_PROXY env points at. PRD - 0017: when an egress-proxy is declared, the agent goes through - egress-proxy (which in turn uses HTTPS_PROXY=pipelock on its + 0017: when an egress is declared, the agent goes through + egress (which in turn uses HTTPS_PROXY=pipelock on its outbound leg). Otherwise the agent talks straight to pipelock — keeps the network surface minimal for bottles that don't need path filtering or credential injection.""" - if plan.egress_proxy_plan.routes: - return egress_proxy_url() + if plan.egress_plan.routes: + return egress_url() 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 # `http://` URLs and only honor lowercase `http_proxy`. Without # 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 # --internal). Lowercase HTTPS_PROXY isn't strictly needed but # we set it for symmetry — some tools check one or the other. diff --git a/claude_bottle/backend/docker/network.py b/claude_bottle/backend/docker/network.py index 9cc0981..408f75f 100644 --- a/claude_bottle/backend/docker/network.py +++ b/claude_bottle/backend/docker/network.py @@ -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 gateway). Pipelock straddles that network and a per-agent user-defined diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index a816df4..b0cd69f 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -19,7 +19,7 @@ from ...log import die from .. import BottleSpec from . import util as docker_mod 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 .bottle_state import ( BottleMetadata, @@ -40,7 +40,7 @@ def resolve_plan( stage_dir: Path, proxy: DockerPipelockProxy, git_gate: DockerGitGate, - egress_proxy: DockerEgressProxy, + egress: DockerEgress, supervise: DockerSupervise, ) -> DockerBottlePlan: """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. # Only probe sidecars this launch will actually try to create: # 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]] = [ ("pipelock", pipelock_container_name(slug)), ] if bottle.git: sidecar_probes.append(("git-gate", git_gate_container_name(slug))) 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: sidecar_probes.append(("supervise", supervise_container_name(slug))) for label, sidecar_name in sidecar_probes: @@ -148,7 +148,7 @@ def resolve_plan( proxy_plan = proxy.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 if bottle.supervise: # Current Dockerfile for the agent image. Read from the repo @@ -157,7 +157,7 @@ def resolve_plan( # is just a workspace copy). # (routes.yaml + pipelock allowlist used to land here too but # 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.) dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" 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 # mutates the host os.environ. 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 - # 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 # as a credential or it refuses to start; ship a non-secret # 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. has_anthropic_auth = any( "claude_code_oauth" in r.roles - for r in egress_proxy_plan.routes + for r in egress_plan.routes ) 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, - # 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("DISABLE_ERROR_REPORTING", "1") _write_env_file(resolved, env_file) @@ -208,7 +208,7 @@ def resolve_plan( prompt_file=prompt_file, proxy_plan=proxy_plan, git_gate_plan=git_gate_plan, - egress_proxy_plan=egress_proxy_plan, + egress_plan=egress_plan, supervise_plan=supervise_plan, use_runsc=use_runsc, ) diff --git a/claude_bottle/backend/docker/provision/ca.py b/claude_bottle/backend/docker/provision/ca.py index 0b3d2c9..453fc11 100644 --- a/claude_bottle/backend/docker/provision/ca.py +++ b/claude_bottle/backend/docker/provision/ca.py @@ -3,16 +3,16 @@ store. Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target: - - Bottle declares `egress_proxy.routes[]` → agent's HTTP_PROXY - points at egress-proxy; the cert the agent must trust is the - one egress-proxy mints leaf certs with (the egress-proxy CA). - - No egress_proxy routes → agent's HTTP_PROXY points straight at + - Bottle declares `egress.routes[]` → agent's HTTP_PROXY + points at egress; the cert the agent must trust is the + one egress mints leaf certs with (the egress CA). + - No egress routes → agent's HTTP_PROXY points straight at pipelock; the cert the agent must trust is pipelock's CA (the pre-cutover behavior). By the time this provisioner runs, the corresponding `tls_init` helper has generated the chosen CA under `plan.stage_dir`, and the -sidecar (pipelock or egress-proxy) is up referencing the +sidecar (pipelock or egress) is up referencing the in-container CA paths. 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 wins when the bottle declares any routes (it sits in front of pipelock); else pipelock.""" - if plan.egress_proxy_plan.routes: - cert = plan.egress_proxy_plan.mitmproxy_ca_cert_only_host_path + if plan.egress_plan.routes: + cert = plan.egress_plan.mitmproxy_ca_cert_only_host_path if cert == Path() or not cert.is_file(): from ....log import die die( - f"egress-proxy CA cert missing at {cert or '(empty)'}; " - f"launch must have called egress_proxy_tls_init and " + f"egress CA cert missing at {cert or '(empty)'}; " + f"launch must have called egress_tls_init and " f"re-bound the plan before provision" ) - return cert, "egress-proxy" + return cert, "egress" cert = plan.proxy_plan.ca_cert_host_path if not cert or not cert.is_file(): from ....log import die diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index f94b0b7..029165b 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -3,8 +3,8 @@ act on them (approve / modify / reject). PRD 0013 v1. Curses-based TUI; modify-then-approve shells out to $EDITOR. The approval handlers wire to the per-tool remediation engines: -PRD 0014 (egress-proxy, retargeted from cred-proxy in PRD 0017 -chunk 3) writes routes.yaml + SIGHUPs egress-proxy; PRD 0015 +PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 +chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 (pipelock) writes the allowlist + restarts pipelock; PRD 0016 (capability) rebuilds the bottle Dockerfile. """ @@ -27,8 +27,8 @@ from ..backend.docker.capability_apply import ( CapabilityApplyError, apply_capability_change, ) -from ..backend.docker.egress_proxy_apply import ( - EgressProxyApplyError, +from ..backend.docker.egress_apply import ( + EgressApplyError, add_route, apply_routes_change, fetch_current_routes, @@ -51,7 +51,7 @@ from ..supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_PROXY_BLOCK, + TOOL_EGRESS_BLOCK, TOOL_PIPELOCK_BLOCK, archive_proposal, list_pending_proposals, @@ -65,7 +65,7 @@ from ._common import PROG # Errors any remediation engine may raise. Caught by the TUI key # handlers and surfaced in the status line so a failed apply keeps # the proposal pending rather than crashing curses. -ApplyError = (EgressProxyApplyError, PipelockApplyError, CapabilityApplyError) +ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) # --- Discovery ------------------------------------------------------------- @@ -104,10 +104,10 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]: return sorted(out) -def discover_egress_proxy_slugs() -> list[str]: - """Slugs of bottles with a running egress-proxy sidecar. Used by +def discover_egress_slugs() -> list[str]: + """Slugs of bottles with a running egress sidecar. Used by 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]: @@ -157,7 +157,7 @@ def approve( entry. If `final_file` is provided the status is `modified`; 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 operator's modify). On failure no response is written and no 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 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 - # 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 # audit log gets the BEFORE/AFTER of the full file so the # 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 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) write_audit_entry(AuditEntry( timestamp=datetime.now(timezone.utc).isoformat(), bottle_slug=slug, - component="egress-proxy", + component="egress", operator_action=ACTION_OPERATOR_EDIT, operator_notes="", justification="", - diff=render_diff(before, after, label="egress-proxy"), + diff=render_diff(before, after, label="egress"), )) 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 earlier docstring flagged; PRD 0017 answered it by putting - egress-proxy in front of pipelock. The agent's - `egress-proxy-block` tool now proposes routes.yaml changes that + egress in front of pipelock. The agent's + `egress-block` tool now proposes routes.yaml changes that can include a `path_allowlist`. Use that tool for path-level follow-ups; this one stays hostname-only because pipelock is still the last hostname gate before egress.""" @@ -302,11 +302,11 @@ def _write_audit( diff_before: str, diff_after: str, ) -> 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 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 fetched-from-sidecar diff. For rejections both are empty strings 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: if tool == TOOL_CAPABILITY_BLOCK: return ".dockerfile" - # egress-proxy-block / pipelock-block: JSON-ish + plain. + # egress-block / pipelock-block: JSON-ish + plain. return ".txt" def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str: """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 save. Returns a status-line message.""" return _operator_edit_flow( stdscr, label="routes", - discover=discover_egress_proxy_slugs, + discover=discover_egress_slugs, fetch=fetch_current_routes, apply=operator_edit_routes, suffix=".yaml", diff --git a/claude_bottle/egress_proxy.py b/claude_bottle/egress.py similarity index 72% rename from claude_bottle/egress_proxy.py rename to claude_bottle/egress.py index b32d526..aa96acc 100644 --- a/claude_bottle/egress_proxy.py +++ b/claude_bottle/egress.py @@ -10,16 +10,16 @@ owns three jobs: 3. Inject `Authorization` headers for routes that declare an `auth` block, the same way cred-proxy does today. -This module defines the abstract proxy (`EgressProxy`), its plan -dataclass (`EgressProxyPlan`), and the resolved per-route shape -(`EgressProxyRoute`). The sidecar's start/stop lifecycle is backend- +This module defines the abstract proxy (`Egress`), its plan +dataclass (`EgressPlan`), and the resolved per-route shape +(`EgressRoute`). The sidecar's start/stop lifecycle is backend- 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 lifecycle are wired into the agent's `HTTP_PROXY` path; cred-proxy 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 @@ -33,23 +33,23 @@ from .log import die 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, # others later) attaches this name to its sidecar on the bottle's # internal network. The agent's `HTTP_PROXY` env var resolves to -# `http://egress-proxy:` once chunk 2 cuts over. -EGRESS_PROXY_HOSTNAME = "egress-proxy" +# `http://egress:` once chunk 2 cuts over. +EGRESS_HOSTNAME = "egress" # 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 # 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) -class EgressProxyRoute: - """One resolved route on the egress-proxy sidecar. +class EgressRoute: + """One resolved route on the egress sidecar. `host` matches the request's hostname (case-insensitive). The 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` block was omitted). - `token_env` is the env-var slot inside the egress-proxy container - (e.g. `EGRESS_PROXY_TOKEN_0`); `token_ref` is the host env var + `token_env` is the env-var slot inside the egress container + (e.g. `EGRESS_TOKEN_0`); `token_ref` is the host env var the CLI reads at launch and forwards into the container's environ under `token_env`. Routes that share a `token_ref` coalesce to one `token_env` slot. `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.""" host: str @@ -77,8 +77,8 @@ class EgressProxyRoute: @dataclass(frozen=True) -class EgressProxyPlan: - """Output of EgressProxy.prepare; consumed by .start. +class EgressPlan: + """Output of Egress.prepare; consumed by .start. The slug + routes_path + routes + token_env_map fields are filled at prepare time (host-side, side-effect-free on docker). @@ -89,13 +89,13 @@ class EgressProxyPlan: `token_env_map` is `{: }`. 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 — secrets never land in a dataclass that might be logged. `mitmproxy_ca_host_path` is the host path of the per-bottle - egress-proxy CA (single PEM with cert+key concatenated) minted - by `egress_proxy_tls_init`. `.start` docker-cps it into the + egress CA (single PEM with cert+key concatenated) minted + by `egress_tls_init`. `.start` docker-cps it into the sidecar at `~/.mitmproxy/mitmproxy-ca.pem` — mitmproxy reads 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 (cert only). `.start` docker-cps it into the sidecar so 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 pipelock's hostname allowlist + DLP body scanner on the - egress-proxy → upstream leg. + egress → upstream leg. """ slug: str routes_path: Path - routes: tuple[EgressProxyRoute, ...] + routes: tuple[EgressRoute, ...] token_env_map: dict[str, str] internal_network: str = "" egress_network: str = "" @@ -128,11 +128,11 @@ class EgressProxyPlan: # 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 # 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 -# now and pipelock's allowlist is mirrored from egress-proxy. +# moves it to egress because egress is the primary gate +# now and pipelock's allowlist is mirrored from egress. DEFAULT_ALLOWLIST: tuple[str, ...] = ( "api.anthropic.com", "statsig.anthropic.com", @@ -144,32 +144,32 @@ DEFAULT_ALLOWLIST: tuple[str, ...] = ( ) -def egress_proxy_manifest_routes( +def egress_manifest_routes( bottle: Bottle, -) -> tuple[EgressProxyRoute, ...]: +) -> tuple[EgressRoute, ...]: """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. Token-env slots are assigned per distinct `token_ref`: the first 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 no slot. Does NOT include the folded-in DEFAULT_ALLOWLIST / 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.""" - out: list[EgressProxyRoute] = [] + out: list[EgressRoute] = [] slot_for_token: dict[str, str] = {} for r in bottle.egress.routes: if r.AuthScheme and r.TokenRef: token_env = slot_for_token.get(r.TokenRef) 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 - out.append(EgressProxyRoute( + out.append(EgressRoute( host=r.Host, path_allowlist=r.PathAllowlist, auth_scheme=r.AuthScheme, @@ -178,7 +178,7 @@ def egress_proxy_manifest_routes( roles=r.Role, )) else: - out.append(EgressProxyRoute( + out.append(EgressRoute( host=r.Host, path_allowlist=r.PathAllowlist, roles=r.Role, @@ -186,10 +186,10 @@ def egress_proxy_manifest_routes( return tuple(out) -def egress_proxy_routes_for_bottle( +def egress_routes_for_bottle( bottle: Bottle, -) -> tuple[EgressProxyRoute, ...]: - """Effective egress-proxy routes: manifest routes followed by +) -> tuple[EgressRoute, ...]: + """Effective egress routes: manifest routes followed by bare-pass entries for DEFAULT_ALLOWLIST hosts. This is what 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 `bottle.egress.routes` as a bare-pass entry (`- host: `). The legacy `bottle.egress.allowlist` - folding is gone — egress_proxy is the single allowlist surface.""" - out: list[EgressProxyRoute] = list(egress_proxy_manifest_routes(bottle)) + folding is gone — egress is the single allowlist surface.""" + out: list[EgressRoute] = list(egress_manifest_routes(bottle)) claimed: set[str] = {r.host.lower() for r in out} for host in DEFAULT_ALLOWLIST: if host.lower() not in claimed: - out.append(EgressProxyRoute(host=host)) + out.append(EgressRoute(host=host)) claimed.add(host.lower()) return tuple(out) -def egress_proxy_token_env_map( - routes: tuple[EgressProxyRoute, ...], +def egress_token_env_map( + routes: tuple[EgressRoute, ...], ) -> dict[str, str]: """Collapse the route list into `{token_env: token_ref}` for the authenticated routes. Routes without `auth` contribute no entry. Conflict detection: two routes that share a `token_env` slot but 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.""" out: dict[str, str] = {} for r in routes: @@ -228,7 +228,7 @@ def egress_proxy_token_env_map( existing = out.get(r.token_env) if existing is not None and existing != r.token_ref: 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"token slot must reference the same host env var." ) @@ -236,8 +236,8 @@ def egress_proxy_token_env_map( return out -def egress_proxy_render_routes( - routes: tuple[EgressProxyRoute, ...], +def egress_render_routes( + routes: tuple[EgressRoute, ...], ) -> str: """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" -def egress_proxy_resolve_token_values( +def egress_resolve_token_values( token_env_map: dict[str, str], host_env: dict[str, str], ) -> dict[str, str]: @@ -277,27 +277,27 @@ def egress_proxy_resolve_token_values( value = host_env.get(token_ref) if value is None: 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"from bottle.egress.routes." ) if not value: die( - f"egress-proxy: host env var '{token_ref}' is empty. The " - f"egress-proxy will not inject an empty token; set it to " + f"egress: host env var '{token_ref}' is empty. The " + f"egress will not inject an empty token; set it to " f"the real value or remove the route's auth block." ) out[token_env] = value return out -class EgressProxy(ABC): +class Egress(ABC): """The per-bottle egress proxy. Encapsulates the host-side prepare (route lift + routes.yaml render + token-env-map derivation); the sidecar's start/stop lifecycle is backend-specific and lives on 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, render the routes file (mode 600) under `stage_dir`, and 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 `internal_network` / `egress_network` / `pipelock_proxy_url` via `dataclasses.replace` before passing it to `.start`.""" - routes = egress_proxy_routes_for_bottle(bottle) - routes_path = stage_dir / "egress_proxy_routes.yaml" - routes_path.write_text(egress_proxy_render_routes(routes)) + routes = egress_routes_for_bottle(bottle) + routes_path = stage_dir / "egress_routes.yaml" + routes_path.write_text(egress_render_routes(routes)) routes_path.chmod(0o600) - return EgressProxyPlan( + return EgressPlan( slug=slug, routes_path=routes_path, routes=routes, - token_env_map=egress_proxy_token_env_map(routes), + token_env_map=egress_token_env_map(routes), ) @abstractmethod - def start(self, plan: EgressProxyPlan) -> str: - """Bring up the egress-proxy sidecar according to `plan`. + def start(self, plan: EgressPlan) -> str: + """Bring up the egress sidecar according to `plan`. Returns the target string identifying the running instance — the same value to pass to `.stop`. Backend-specific.""" @abstractmethod 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 is success. Backend-specific.""" __all__ = [ "DEFAULT_ALLOWLIST", - "EGRESS_PROXY_HOSTNAME", - "EGRESS_PROXY_ROUTES_IN_CONTAINER", - "EgressProxy", - "EgressProxyPlan", - "EgressProxyRoute", - "egress_proxy_manifest_routes", - "egress_proxy_render_routes", - "egress_proxy_resolve_token_values", - "egress_proxy_routes_for_bottle", - "egress_proxy_token_env_map", + "EGRESS_HOSTNAME", + "EGRESS_ROUTES_IN_CONTAINER", + "Egress", + "EgressPlan", + "EgressRoute", + "egress_manifest_routes", + "egress_render_routes", + "egress_resolve_token_values", + "egress_routes_for_bottle", + "egress_token_env_map", ] diff --git a/claude_bottle/egress_proxy_addon.py b/claude_bottle/egress_addon.py similarity index 77% rename from claude_bottle/egress_proxy_addon.py rename to claude_bottle/egress_addon.py index dace611..bb8e65c 100644 --- a/claude_bottle/egress_proxy_addon.py +++ b/claude_bottle/egress_addon.py @@ -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 -egress-proxy container. Wraps the pure logic from -`egress_proxy_addon_core` with mitmproxy's HTTPFlow API: +Loaded by `mitmdump -s /app/egress_addon.py` inside the +egress container. Wraps the pure logic from +`egress_addon_core` with mitmproxy's HTTPFlow API: - - At startup, read `EGRESS_PROXY_ROUTES` (default - `/etc/egress-proxy/routes.yaml`, JSON content) → routes table. + - At startup, read `EGRESS_ROUTES` (default + `/etc/egress/routes.yaml`, JSON content) → routes table. - SIGHUP re-reads the file and atomically swaps the in-memory table. A parse error keeps the old table in place — better to 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 — 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 -`egress_proxy_addon_core.py` flat into `/app/`; the absolute import +Dockerfile.egress copies both this file and +`egress_addon_core.py` flat into `/app/`; the absolute import below works because mitmdump runs with `/app` on its sys.path. The parallel file in the package source tree (claude_bottle/) is the 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] -# 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 # 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. -# Requests through the proxy for `_egress-proxy.local/` are +# Requests through the proxy for `_egress.local/` are # intercepted and answered with synthetic responses (the addon's # `request` hook sets `flow.response` before any upstream connection). # The hostname is not in DNS — only clients dialing through this -# specific egress-proxy can reach it, and only via HTTP (no TLS). -# Used by the supervise sidecar's `list-egress-proxy-routes` MCP +# specific egress can reach it, and only via HTTP (no TLS). +# Used by the supervise sidecar's `list-egress-routes` MCP # 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 request hook is invoked on every CONNECT-decapsulated HTTP/HTTPS request the agent makes.""" 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._reload(initial=True) self._install_sighup() @@ -72,7 +72,7 @@ class EgressProxyAddon: except (OSError, ValueError) as e: tag = "boot" if initial else "SIGHUP" sys.stderr.write( - f"egress-proxy: {tag} load failed: {e}\n" + f"egress: {tag} load failed: {e}\n" ) if initial: # No baseline to fall back on; serve nothing rather @@ -82,7 +82,7 @@ class EgressProxyAddon: return self.routes = new_routes 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" ) @@ -97,7 +97,7 @@ class EgressProxyAddon: signal.signal(signal.SIGHUP, handler) 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 route table as JSON (host, path_allowlist, auth_scheme, token_env per route — no token VALUES, those live in the @@ -114,7 +114,7 @@ class EgressProxyAddon: return flow.response = http.Response.make( 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"}, ) @@ -123,7 +123,7 @@ class EgressProxyAddon: def request(self, flow: http.HTTPFlow) -> None: 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 # before the strip-auth + route logic — these requests aren't # 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 # (PRD 0008) is the only sanctioned outbound path for git # 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. if is_git_push_request(request_path, query): flow.response = http.Response.make( 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"git-gate's pre-receive hook)." ), @@ -175,4 +175,4 @@ class EgressProxyAddon: flow.request.headers["authorization"] = decision.inject_authorization -addons = [EgressProxyAddon()] +addons = [EgressAddon()] diff --git a/claude_bottle/egress_proxy_addon_core.py b/claude_bottle/egress_addon_core.py similarity index 91% rename from claude_bottle/egress_proxy_addon_core.py rename to claude_bottle/egress_addon_core.py index 5e4c854..396eeee 100644 --- a/claude_bottle/egress_proxy_addon_core.py +++ b/claude_bottle/egress_addon_core.py @@ -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 `mitmproxy` package. The companion module wraps these with the `mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar 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. """ @@ -19,7 +19,7 @@ from dataclasses import dataclass @dataclass(frozen=True) 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 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", "path_allowlist": ["/repos/x/", "/users/x"], # 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 are unaffected. Egress-proxy refuses HTTPS push because git-gate's 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. 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 pipelock + upstream untouched. """ @@ -212,8 +212,8 @@ def decide( return Decision( action="block", reason=( - f"egress-proxy: host {request_host!r} is not in the " - f"bottle's egress_proxy.routes allowlist. Declare a " + f"egress: host {request_host!r} is not in the " + f"bottle's egress.routes allowlist. Declare a " f"route for it or remove the request." ), ) @@ -223,7 +223,7 @@ def decide( return Decision( action="block", 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}" ), ) @@ -234,7 +234,7 @@ def decide( return Decision( action="block", 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" ), ) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 54dda7f..4ccb9a0 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -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 # 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 # 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 # 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 # special happens on the agent side. -EGRESS_PROXY_ROLES = frozenset({ +EGRESS_ROLES = frozenset({ "claude_code_oauth", }) @@ -152,14 +152,14 @@ EGRESS_PROXY_ROLES = frozenset({ # claude_code_oauth drives a single placeholder env var; two routes # claiming it would leave "which one is the canonical OAuth route?" # ambiguous for any future role-aware logic. -EGRESS_PROXY_SINGLETON_ROLES = frozenset({ +EGRESS_SINGLETON_ROLES = frozenset({ "claude_code_oauth", }) @dataclass(frozen=True) -class EgressProxyRoute: - """One route on the per-bottle egress-proxy sidecar (PRD 0017). +class EgressRoute: + """One route on the per-bottle egress sidecar (PRD 0017). `Host` matches the request's hostname (case-insensitive). The optional `PathAllowlist` constrains the URL path to a set of @@ -171,7 +171,7 @@ class EgressProxyRoute: no Authorization is written, no token forwarded. `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 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 that case). - `role` optional. String or list of strings drawn from - EGRESS_PROXY_ROLES. Singleton roles (see - EGRESS_PROXY_SINGLETON_ROLES) may appear on at most one + EGRESS_ROLES. Singleton roles (see + EGRESS_SINGLETON_ROLES) may appear on at most one route per bottle. """ @@ -195,7 +195,7 @@ class EgressProxyRoute: Role: tuple[str, ...] = () @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}]" d = _as_json_object(raw, label) host = d.get("host") @@ -243,10 +243,10 @@ class EgressProxyRoute: f"{label} auth.scheme is required when 'auth' is set " f"(non-empty string)" ) - if auth_scheme_raw not in EGRESS_PROXY_AUTH_SCHEMES: + if auth_scheme_raw not in EGRESS_AUTH_SCHEMES: die( 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") if not isinstance(token_ref_raw, str) or not token_ref_raw: @@ -283,10 +283,10 @@ class EgressProxyRoute: f"(was {type(role_raw).__name__})" ) for r in roles: - if r not in EGRESS_PROXY_ROLES: + if r not in EGRESS_ROLES: die( f"{label} role {r!r} is not one of " - f"{', '.join(sorted(EGRESS_PROXY_ROLES))}" + f"{', '.join(sorted(EGRESS_ROLES))}" ) for k in d: @@ -306,19 +306,19 @@ class EgressProxyRoute: @dataclass(frozen=True) -class EgressProxyConfig: - """Per-bottle egress-proxy configuration. Today this is just the +class EgressConfig: + """Per-bottle egress configuration. Today this is just the route table; the nesting under `egress:` leaves room for per-bottle proxy settings (port override, log level, etc.) in follow-ups.""" - routes: tuple[EgressProxyRoute, ...] = () + routes: tuple[EgressRoute, ...] = () @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") routes_raw = d.get("routes") - routes: tuple[EgressProxyRoute, ...] = () + routes: tuple[EgressRoute, ...] = () if routes_raw is not None: if not isinstance(routes_raw, list): die( @@ -327,10 +327,10 @@ class EgressProxyConfig: ) routes_list = cast(list[object], routes_raw) routes = tuple( - EgressProxyRoute.from_dict(bottle_name, i, entry) + EgressRoute.from_dict(bottle_name, i, entry) for i, entry in enumerate(routes_list) ) - _validate_egress_proxy_routes(bottle_name, routes) + _validate_egress_routes(bottle_name, routes) for k in d: if k != "routes": die( @@ -344,12 +344,12 @@ class EgressProxyConfig: class Bottle: env: Mapping[str, str] = field(default_factory=_empty_str_dict) 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, # the launch step brings up a supervise sidecar that exposes three # MCP tools to the agent (cred-proxy-block, pipelock-block, # 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. False (the default) skips the sidecar and mount. supervise: bool = False @@ -403,7 +403,7 @@ class Bottle: die( f"bottle '{name}' has a 'tokens' field. The shape was reworked: " 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: @@ -414,18 +414,18 @@ class Bottle: f" → 'host' (just the upstream hostname)\n" f" - 'auth_scheme' + 'token_ref' (flat)\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"longer needed.\n" f" - 'path_allowlist' (new): optional URL prefix gate for the " f"host.\n" - f"See docs/prds/0017-egress-proxy-via-mitmproxy.md." + f"See docs/prds/0017-egress-via-mitmproxy.md." ) egress = ( - EgressProxyConfig.from_dict(name, d["egress"]) + EgressConfig.from_dict(name, d["egress"]) if "egress" in d - else EgressProxyConfig() + else EgressConfig() ) 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) -def _validate_egress_proxy_routes( +def _validate_egress_routes( bottle_name: str, - routes: tuple[EgressProxyRoute, ...], + routes: tuple[EgressRoute, ...], ) -> None: """Cross-validation for `bottle.egress.routes`: - Hosts must be unique within the bottle. The proxy matches by exact-host (v1, prefix matching is on path_allowlist only); 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. 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 dev setup.""" 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." ) 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] if len(with_role) > 1: hosts = ", ".join(r.Host for r in with_role) diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index ffb4bb5..ef21e91 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -5,10 +5,10 @@ forward proxy with hostname allowlisting + DLP scanning + URL-entropy checks. One sidecar per agent, attached to the agent's --internal network and a per-agent user-defined egress bridge. -Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress-proxy -(not pipelock); egress-proxy sets `HTTPS_PROXY=pipelock` on its +Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress +(not pipelock); egress sets `HTTPS_PROXY=pipelock` on its 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 upstream. @@ -22,10 +22,10 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from .egress_proxy import ( +from .egress import ( DEFAULT_ALLOWLIST, - EGRESS_PROXY_HOSTNAME, - egress_proxy_routes_for_bottle, + EGRESS_HOSTNAME, + egress_routes_for_bottle, ) from .supervise import SUPERVISE_HOSTNAME from .manifest import Bottle @@ -53,12 +53,12 @@ DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = ( def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: """Hostnames pipelock allows. Sorted for stability. - Always mirrors `egress_proxy_routes_for_bottle(bottle)` — the - egress-proxy is the single allowlist surface; pipelock's + Always mirrors `egress_routes_for_bottle(bottle)` — the + egress is the single allowlist surface; pipelock's 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 - 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 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 through git-gate (PRD 0008), not pipelock.""" seen: dict[str, None] = {} - for r in egress_proxy_routes_for_bottle(bottle): + for r in egress_routes_for_bottle(bottle): if r.host: seen.setdefault(r.host, None) 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 — 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 - 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 threat model (the agent has no access to a user's crypto wallet 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 its body scanner sees the cleartext. - egress-proxy route hosts (github, gitea, npm) are deliberately - NOT auto-added here. egress-proxy's HTTPS client trusts pipelock's + egress route hosts (github, gitea, npm) are deliberately + NOT auto-added here. egress's HTTPS client trusts pipelock's 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 the PRD 0017 cutover. @@ -159,7 +159,7 @@ def pipelock_build_config( pipelock's SSRF guard. Pipelock blocks RFC1918-resolved destinations by default, which would catch sibling-sidecar 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 pass through pipelock while api_allowlist + body-scanning still apply. Empty by default; omitted from the rendered yaml when @@ -272,7 +272,7 @@ class PipelockProxyPlan: that they are populated. `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 api_allowlist and body-scanning still apply.""" diff --git a/claude_bottle/supervise.py b/claude_bottle/supervise.py index 2237e81..25701f7 100644 --- a/claude_bottle/supervise.py +++ b/claude_bottle/supervise.py @@ -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 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 * capability-block — agent proposes a new agent Dockerfile @@ -49,33 +49,33 @@ from pathlib import Path SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 -TOOL_EGRESS_PROXY_BLOCK = "egress-proxy-block" +TOOL_EGRESS_BLOCK = "egress-block" TOOL_PIPELOCK_BLOCK = "pipelock-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, ...] = ( - TOOL_EGRESS_PROXY_BLOCK, + TOOL_EGRESS_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK, - TOOL_LIST_EGRESS_PROXY_ROUTES, + TOOL_LIST_EGRESS_ROUTES, ) -# The supervise sidecar uses these to query egress-proxy's -# introspection endpoint for the `list-egress-proxy-routes` MCP -# tool. The hostname + port match egress-proxy's docker network -# alias + listen port (see claude_bottle.egress_proxy.EGRESS_PROXY_HOSTNAME -# and backend.docker.egress_proxy.EGRESS_PROXY_PORT — the values +# The supervise sidecar uses these to query egress's +# introspection endpoint for the `list-egress-routes` MCP +# tool. The hostname + port match egress's docker network +# alias + listen port (see claude_bottle.egress.EGRESS_HOSTNAME +# and backend.docker.egress.EGRESS_PORT — the values # are inlined here so the in-container supervise_server doesn't -# need to import the egress-proxy package). -EGRESS_PROXY_FORWARD_PROXY = "http://egress-proxy:9099" -EGRESS_PROXY_INTROSPECT_URL = "http://_egress-proxy.local/allowlist" +# need to import the egress package). +EGRESS_FORWARD_PROXY = "http://egress:9099" +EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" # capability-block has no on-disk config the operator edits in place # (the Dockerfile is rebuilt, not patched), so it has no audit log # here — those changes are captured by git history + the rebuild # record laid down in PRD 0016. COMPONENT_FOR_TOOL: dict[str, str] = { - TOOL_EGRESS_PROXY_BLOCK: "egress-proxy", + TOOL_EGRESS_BLOCK: "egress", TOOL_PIPELOCK_BLOCK: "pipelock", } @@ -440,8 +440,8 @@ def sha256_hex(content: str) -> str: # Dockerfile and propose modifications. # # routes.yaml + allowlist used to live here too; PRD 0017 chunk 3 -# moved them behind the `list-egress-proxy-routes` MCP tool (live -# state from egress-proxy's introspection endpoint) so the agent +# moved them behind the `list-egress-routes` MCP tool (live +# state from egress's introspection endpoint) so the agent # always sees current data rather than a launch-time snapshot. CURRENT_CONFIG_DOCKERFILE = "Dockerfile" @@ -455,7 +455,7 @@ class SupervisePlan: directory bind-mounted (read-only) into the *agent* container at /etc/claude-bottle/current-config — currently holds only 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 dataclasses.replace before calling .start.""" @@ -569,11 +569,11 @@ __all__ = [ "Supervise", "SupervisePlan", "TOOLS", - "EGRESS_PROXY_FORWARD_PROXY", - "EGRESS_PROXY_INTROSPECT_URL", + "EGRESS_FORWARD_PROXY", + "EGRESS_INTROSPECT_URL", "TOOL_CAPABILITY_BLOCK", - "TOOL_EGRESS_PROXY_BLOCK", - "TOOL_LIST_EGRESS_PROXY_ROUTES", + "TOOL_EGRESS_BLOCK", + "TOOL_LIST_EGRESS_ROUTES", "TOOL_PIPELOCK_BLOCK", "archive_proposal", "audit_dir", diff --git a/claude_bottle/supervise_server.py b/claude_bottle/supervise_server.py index dd455e9..5bc4c25 100644 --- a/claude_bottle/supervise_server.py +++ b/claude_bottle/supervise_server.py @@ -1,6 +1,6 @@ """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 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]] = [ { - "name": _sv.TOOL_EGRESS_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_BLOCK, "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 " "path_allowlist (typically a 403 from the proxy). Propose " "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 " "or rejects in the supervise TUI. On approval the " "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 " "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": ( - "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 " "per allowed host, each carrying its path_allowlist (if " "any) and whether the proxy injects Authorization for " "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. " "Pipelock's allowlist is a mirror of this set — every " "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 " "allowlist (vs. blocked for DLP reasons — those need a " "different remediation). In practice pipelock's allowlist " - "is now a mirror of the egress-proxy routes set by " - "`egress-proxy-block`, so prefer that tool when you want " + "is now a mirror of the egress routes set by " + "`egress-block`, so prefer that tool when you want " "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 + " "path); the supervisor extracts the hostname and merges " "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 # 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 # supervisor merges this into the live routes # 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 # 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. PROPOSED_FILE_FIELD: dict[str, str] = { _sv.TOOL_PIPELOCK_BLOCK: "failed_url", @@ -306,8 +306,8 @@ PROPOSED_FILE_FIELD: dict[str, str] = { # --- Validation ------------------------------------------------------------ -# Auth schemes accepted on egress-proxy-block proposals — match the -# manifest-side EGRESS_PROXY_AUTH_SCHEMES. +# Auth schemes accepted on egress-block proposals — match the +# manifest-side EGRESS_AUTH_SCHEMES. _AUTH_SCHEMES = ("Bearer", "token") @@ -344,10 +344,10 @@ def validate_proposed_file(tool: str, content: str) -> None: def _validate_and_bundle_egress_route( args: dict[str, object], ) -> 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 _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") if not isinstance(host, str) or not host.strip(): raise _RpcError( @@ -426,32 +426,32 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]: return {"tools": TOOL_DEFINITIONS} -def handle_list_egress_proxy_routes( +def handle_list_egress_routes( _params: dict[str, object], _config: ServerConfig, ) -> dict[str, object]: - """Fetch the live egress-proxy route table via its - `_egress-proxy.local/allowlist` introspection endpoint. The - request goes through egress-proxy as a forward proxy; the + """Fetch the live egress route table via its + `_egress.local/allowlist` introspection endpoint. The + request goes through egress as a forward proxy; the addon recognises the magic host and synthesizes a response — no real upstream connection, no allowlist enforcement against the magic host. Returns the JSON payload as the tool's text content.""" proxy_handler = urllib.request.ProxyHandler({ - "http": _sv.EGRESS_PROXY_FORWARD_PROXY, + "http": _sv.EGRESS_FORWARD_PROXY, }) opener = urllib.request.build_opener(proxy_handler) 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") except (urllib.error.URLError, OSError) as e: return { "content": [{ "type": "text", "text": ( - f"list-egress-proxy-routes: could not reach " - f"{_sv.EGRESS_PROXY_INTROSPECT_URL!r} via " - f"{_sv.EGRESS_PROXY_FORWARD_PROXY!r}: {e}" + f"list-egress-routes: could not reach " + f"{_sv.EGRESS_INTROSPECT_URL!r} via " + f"{_sv.EGRESS_FORWARD_PROXY!r}: {e}" ), }], "isError": True, @@ -475,8 +475,8 @@ def handle_tools_call( name = params.get("name") if not isinstance(name, str): raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'") - if name == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES: - return handle_list_egress_proxy_routes(params.get("arguments", {}), config) + if name == _sv.TOOL_LIST_EGRESS_ROUTES: + return handle_list_egress_routes(params.get("arguments", {}), config) args_raw = params.get("arguments", {}) 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", ) - if name == _sv.TOOL_EGRESS_PROXY_BLOCK: + if name == _sv.TOOL_EGRESS_BLOCK: # 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 # the new one, and writes the merged file. proposed_file = _validate_and_bundle_egress_route(args_raw) diff --git a/tests/integration/test_supervise_sidecar.py b/tests/integration/test_supervise_sidecar.py index eb33e5e..1bf0e37 100644 --- a/tests/integration/test_supervise_sidecar.py +++ b/tests/integration/test_supervise_sidecar.py @@ -196,29 +196,29 @@ class TestSuperviseSidecar(unittest.TestCase): names = {t["name"] for t in result["result"]["tools"]} self.assertEqual( { - _sv.TOOL_EGRESS_PROXY_BLOCK, + _sv.TOOL_EGRESS_BLOCK, _sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK, - _sv.TOOL_LIST_EGRESS_PROXY_ROUTES, + _sv.TOOL_LIST_EGRESS_ROUTES, }, names, ) 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 dashboard helpers; the agent receives the approval. 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 - egress-proxy sidecar (its docker lifecycle has its own + egress sidecar (its docker lifecycle has its own integration coverage).""" self._require_bind_mount_sharing() self._bring_up_sidecar() # 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 # to a no-op that returns plausible before/after strings # the audit-log writer can render. @@ -234,7 +234,7 @@ class TestSuperviseSidecar(unittest.TestCase): captured["response"] = self._curl_jsonrpc({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { - "name": _sv.TOOL_EGRESS_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_BLOCK, "arguments": { "host": "api.example.com", "justification": "integration test", @@ -260,12 +260,12 @@ class TestSuperviseSidecar(unittest.TestCase): self.assertIsNotNone(qp, "proposal never appeared in queue") assert qp is not None # type-narrowing self.assertEqual( - _sv.TOOL_EGRESS_PROXY_BLOCK, qp.proposal.tool, + _sv.TOOL_EGRESS_BLOCK, qp.proposal.tool, ) self.assertEqual("integration test", qp.proposal.justification) # 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 # file and returns to the curl caller. dashboard.approve(qp, notes="lgtm from integration test") diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 834d9ce..27feb7a 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -17,7 +17,7 @@ from pathlib import Path from claude_bottle import supervise 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.cli import dashboard from claude_bottle.supervise import ( @@ -26,7 +26,7 @@ from claude_bottle.supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_PROXY_BLOCK, + TOOL_EGRESS_BLOCK, TOOL_PIPELOCK_BLOCK, read_audit_entries, read_response, @@ -37,13 +37,13 @@ from claude_bottle.supervise import ( 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 # gets a failed URL (PR #25 follow-up), capability gets a # Dockerfile-ish blob. Match the production dispatch in # PROPOSED_FILE_FIELD. payloads = { - TOOL_EGRESS_PROXY_BLOCK: '{"routes": []}\n', + TOOL_EGRESS_BLOCK: '{"routes": []}\n', TOOL_PIPELOCK_BLOCK: "https://example.com/path", 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): early = Proposal.new( - bottle_slug="api", tool=TOOL_EGRESS_PROXY_BLOCK, + bottle_slug="api", tool=TOOL_EGRESS_BLOCK, proposed_file="{}", justification="early", current_file_hash="h", now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), ) late = Proposal.new( - bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_BLOCK, proposed_file="{}", justification="late", current_file_hash="h", 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 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) qdir = supervise.queue_dir_for_slug("dev") 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) self.assertEqual(STATUS_APPROVED, resp.status) 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("approved", entries[0].operator_action) @@ -175,7 +175,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file) 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) def test_reject_writes_rejection(self): @@ -184,7 +184,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_REJECTED, resp.status) 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("nope", entries[0].operator_notes) @@ -193,18 +193,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): dashboard.approve(qp) # No audit log for capability-block (per PRD 0013 / 0016). # 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")) - def test_pipelock_audit_distinct_from_egress_proxy(self): + def test_pipelock_audit_distinct_from_egress(self): qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) dashboard.approve(qp) 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): - """PRD 0017 chunk 3: approve() on an egress-proxy-block proposal +class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): + """PRD 0017 chunk 3: approve() on an egress-block proposal must call add_route (single-route merge) with the right args and surface its failures.""" @@ -216,9 +216,9 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.add_route = self._original_add_route 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( - bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_BLOCK, proposed_file=proposed, justification="need a route", current_file_hash=sha256_hex(proposed), @@ -229,12 +229,12 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): supervise.write_proposal(qdir, p) 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 = [] dashboard.add_route = lambda slug, content: ( calls.append((slug, content)) or ("before", "after") ) - qp = self._enqueue_egress_proxy( + qp = self._enqueue_egress( proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n' ) dashboard.approve(qp) @@ -253,7 +253,7 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.add_route = lambda slug, content: ( calls.append(content) or ("before", "after") ) - qp = self._enqueue_egress_proxy() + qp = self._enqueue_egress() dashboard.approve( qp, final_file='{"host": "edited.example"}\n', @@ -263,10 +263,10 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): def test_apply_failure_blocks_response_and_audit(self): dashboard.add_route = lambda slug, content: (_ for _ in ()).throw( - EgressProxyApplyError("docker exec failed") + EgressApplyError("docker exec failed") ) - qp = self._enqueue_egress_proxy() - with self.assertRaises(EgressProxyApplyError): + qp = self._enqueue_egress() + with self.assertRaises(EgressApplyError): dashboard.approve(qp) # No response file (proposal stays pending). self.assertEqual( @@ -274,16 +274,16 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], ) # 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): dashboard.add_route = lambda slug, content: ( '{"routes": []}\n', # before '{"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) - entries = read_audit_entries("egress-proxy", "dev") + entries = read_audit_entries("egress", "dev") self.assertEqual(1, len(entries)) self.assertIn('+{"routes": [{"host": "new.example"}]}', 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: ( called.append(True) or ("", content) ) - qp = self._enqueue_egress_proxy() + qp = self._enqueue_egress() dashboard.reject(qp, reason="no thanks") self.assertEqual([], called) # Reject still writes a response + audit entry with empty diff. resp = read_response(qp.queue_dir, qp.proposal.id) 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("", entries[0].diff) @@ -443,7 +443,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.approve(qp) # capability-block has no audit log per PRD 0013 — its record # 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")) def test_proposal_archived_after_apply(self): @@ -475,7 +475,7 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase): '{"routes": []}\n', content, ) 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(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action) self.assertEqual("", entries[0].justification) @@ -483,14 +483,14 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase): def test_failure_does_not_write_audit(self): 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') - 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 docker ps invocation itself is environment-dependent (and tested implicitly by the integration test).""" @@ -502,7 +502,7 @@ class TestDiscoverEgressProxySlugs(unittest.TestCase): original = os.environ.get("PATH", "") os.environ["PATH"] = "/nonexistent-no-docker-here" try: - self.assertEqual([], dashboard.discover_egress_proxy_slugs()) + self.assertEqual([], dashboard.discover_egress_slugs()) self.assertEqual([], dashboard.discover_pipelock_slugs()) finally: os.environ["PATH"] = original diff --git a/tests/unit/test_dashboard_detail_lines.py b/tests/unit/test_dashboard_detail_lines.py index 096cfbe..732430c 100644 --- a/tests/unit/test_dashboard_detail_lines.py +++ b/tests/unit/test_dashboard_detail_lines.py @@ -12,7 +12,7 @@ from claude_bottle.cli import dashboard from claude_bottle.supervise import ( Proposal, TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_PROXY_BLOCK, + TOOL_EGRESS_BLOCK, TOOL_PIPELOCK_BLOCK, sha256_hex, ) @@ -46,9 +46,9 @@ class TestPipelockHostHighlight(unittest.TestCase): green_lines = [text for text, attr in lines if attr == self.GREEN] 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( - _qp(TOOL_EGRESS_PROXY_BLOCK, '{"routes": []}'), + _qp(TOOL_EGRESS_BLOCK, '{"routes": []}'), green_attr=self.GREEN, ) self.assertEqual([], [t for t, a in lines if a == self.GREEN]) diff --git a/tests/unit/test_egress_proxy.py b/tests/unit/test_egress.py similarity index 73% rename from tests/unit/test_egress_proxy.py rename to tests/unit/test_egress.py index 40090b0..6dcd4f3 100644 --- a/tests/unit/test_egress_proxy.py +++ b/tests/unit/test_egress.py @@ -1,16 +1,16 @@ -"""Unit: EgressProxy route lift + routes.yaml render + token +"""Unit: Egress route lift + routes.yaml render + token resolution (PRD 0017).""" import json import unittest -from claude_bottle.egress_proxy import ( +from claude_bottle.egress import ( DEFAULT_ALLOWLIST, - egress_proxy_manifest_routes, - egress_proxy_render_routes, - egress_proxy_resolve_token_values, - egress_proxy_routes_for_bottle, - egress_proxy_token_env_map, + egress_manifest_routes, + egress_render_routes, + egress_resolve_token_values, + egress_routes_for_bottle, + egress_token_env_map, ) from claude_bottle.log import Die from claude_bottle.manifest import Manifest @@ -29,18 +29,18 @@ class TestRoutesForBottle(unittest.TestCase): "host": "api.github.com", "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, }]) - routes = egress_proxy_manifest_routes(b) + routes = egress_manifest_routes(b) self.assertEqual(1, len(routes)) r = routes[0] self.assertEqual("api.github.com", r.host) 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((), r.path_allowlist) def test_unauthenticated_route_has_empty_auth_fields(self): b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}]) - routes = egress_proxy_manifest_routes(b) + routes = egress_manifest_routes(b) r = routes[0] self.assertEqual("", r.auth_scheme) self.assertEqual("", r.token_env) @@ -54,9 +54,9 @@ class TestRoutesForBottle(unittest.TestCase): {"host": "github.com", "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} - self.assertEqual({"EGRESS_PROXY_TOKEN_0"}, slots) + self.assertEqual({"EGRESS_TOKEN_0"}, slots) def test_distinct_token_refs_get_distinct_slots(self): b = _bottle([ @@ -65,9 +65,9 @@ class TestRoutesForBottle(unittest.TestCase): {"host": "b.example", "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] - 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): # A bare-pass route between two authenticated routes mustn't @@ -79,9 +79,9 @@ class TestRoutesForBottle(unittest.TestCase): {"host": "b.example", "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] - 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) @@ -92,7 +92,7 @@ class TestRoutesForBottleFoldsDefaults(unittest.TestCase): def test_defaults_present_when_no_manifest_routes(self): 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: self.assertIn(default, hosts) @@ -104,17 +104,17 @@ class TestRoutesForBottleFoldsDefaults(unittest.TestCase): "host": "api.anthropic.com", "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"] self.assertEqual(1, len(anthropic)) self.assertEqual("Bearer", anthropic[0].auth_scheme) 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 # _routes_for_bottle wrapper. 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) @@ -125,12 +125,12 @@ class TestTokenEnvMap(unittest.TestCase): "auth": {"scheme": "Bearer", "token_ref": "T1"}}, {"host": "passthrough.example"}, ]) - routes = egress_proxy_manifest_routes(b) - m = egress_proxy_token_env_map(routes) - self.assertEqual({"EGRESS_PROXY_TOKEN_0": "T1"}, m) + routes = egress_manifest_routes(b) + m = egress_token_env_map(routes) + self.assertEqual({"EGRESS_TOKEN_0": "T1"}, m) def test_no_routes_empty(self): - self.assertEqual({}, egress_proxy_token_env_map(())) + self.assertEqual({}, egress_token_env_map(())) class TestRenderRoutes(unittest.TestCase): @@ -140,14 +140,14 @@ class TestRenderRoutes(unittest.TestCase): "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, "path_allowlist": ["/repos/x/"], }]) - routes = egress_proxy_manifest_routes(b) - payload = json.loads(egress_proxy_render_routes(routes)) + routes = egress_manifest_routes(b) + payload = json.loads(egress_render_routes(routes)) self.assertEqual( [{ "host": "api.github.com", "path_allowlist": ["/repos/x/"], "auth_scheme": "Bearer", - "token_env": "EGRESS_PROXY_TOKEN_0", + "token_env": "EGRESS_TOKEN_0", }], payload["routes"], ) @@ -158,8 +158,8 @@ class TestRenderRoutes(unittest.TestCase): # enforces both-or-neither, so emitting empty strings would # round-trip as a partial pair and crash. b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}]) - routes = egress_proxy_manifest_routes(b) - payload = json.loads(egress_proxy_render_routes(routes)) + routes = egress_manifest_routes(b) + payload = json.loads(egress_render_routes(routes)) entry = payload["routes"][0] self.assertNotIn("auth_scheme", entry) self.assertNotIn("token_env", entry) @@ -169,14 +169,14 @@ class TestRenderRoutes(unittest.TestCase): "host": "api.anthropic.com", "auth": {"scheme": "Bearer", "token_ref": "CL"}, }]) - routes = egress_proxy_manifest_routes(b) - payload = json.loads(egress_proxy_render_routes(routes)) + routes = egress_manifest_routes(b) + payload = json.loads(egress_render_routes(routes)) self.assertNotIn("path_allowlist", payload["routes"][0]) def test_round_trip_through_addon_core(self): # Render here → parse in the addon must succeed for every # 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([ {"host": "api.github.com", "auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}, @@ -184,34 +184,34 @@ class TestRenderRoutes(unittest.TestCase): {"host": "github.com", "path_allowlist": ["/x/"]}, {"host": "api.anthropic.com"}, ]) - routes = egress_proxy_manifest_routes(b) - addon_routes = load_routes(egress_proxy_render_routes(routes)) + routes = egress_manifest_routes(b) + addon_routes = load_routes(egress_render_routes(routes)) self.assertEqual(3, len(addon_routes)) 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[2].auth_scheme) class TestResolveTokenValues(unittest.TestCase): def test_reads_host_env(self): - out = egress_proxy_resolve_token_values( - {"EGRESS_PROXY_TOKEN_0": "GH_PAT"}, + out = egress_resolve_token_values( + {"EGRESS_TOKEN_0": "GH_PAT"}, {"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): with self.assertRaises(Die): - egress_proxy_resolve_token_values( - {"EGRESS_PROXY_TOKEN_0": "GH_PAT"}, + egress_resolve_token_values( + {"EGRESS_TOKEN_0": "GH_PAT"}, {}, ) def test_empty_token_ref_dies(self): with self.assertRaises(Die): - egress_proxy_resolve_token_values( - {"EGRESS_PROXY_TOKEN_0": "GH_PAT"}, + egress_resolve_token_values( + {"EGRESS_TOKEN_0": "GH_PAT"}, {"GH_PAT": ""}, ) diff --git a/tests/unit/test_egress_proxy_addon_core.py b/tests/unit/test_egress_addon_core.py similarity index 90% rename from tests/unit/test_egress_proxy_addon_core.py rename to tests/unit/test_egress_addon_core.py index 833feac..4212f16 100644 --- a/tests/unit/test_egress_proxy_addon_core.py +++ b/tests/unit/test_egress_addon_core.py @@ -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 -`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 -from claude_bottle.egress_proxy_addon_core import ( +from claude_bottle.egress_addon_core import ( Decision, Route, decide, @@ -34,12 +34,12 @@ class TestParseRoutes(unittest.TestCase): "host": "api.github.com", "path_allowlist": ["/repos/x/", "/users/x"], "auth_scheme": "Bearer", - "token_env": "EGRESS_PROXY_TOKEN_0", + "token_env": "EGRESS_TOKEN_0", }]}) r = routes[0] self.assertEqual(("/repos/x/", "/users/x"), r.path_allowlist) 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): # 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: parse_routes({"routes": [{ "host": "x.example", - "token_env": "EGRESS_PROXY_TOKEN_0", + "token_env": "EGRESS_TOKEN_0", }]}) self.assertIn("both set or both empty", str(cm.exception)) @@ -161,9 +161,9 @@ class TestMatchRoute(unittest.TestCase): class TestDecide(unittest.TestCase): 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 - # 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. d = decide((), "elsewhere.example", "/anything", {}) self.assertEqual("block", d.action) @@ -197,8 +197,8 @@ class TestDecide(unittest.TestCase): def test_auth_injection_uses_environ_value(self): d = decide( (Route(host="api.github.com", auth_scheme="Bearer", - token_env="EGRESS_PROXY_TOKEN_0"),), - "api.github.com", "/repos/x", {"EGRESS_PROXY_TOKEN_0": "the-token"}, + token_env="EGRESS_TOKEN_0"),), + "api.github.com", "/repos/x", {"EGRESS_TOKEN_0": "the-token"}, ) self.assertEqual("forward", d.action) self.assertEqual("Bearer the-token", d.inject_authorization) @@ -210,11 +210,11 @@ class TestDecide(unittest.TestCase): # request the upstream would reject. d = decide( (Route(host="api.github.com", auth_scheme="Bearer", - token_env="EGRESS_PROXY_TOKEN_0"),), + token_env="EGRESS_TOKEN_0"),), "api.github.com", "/repos/x", {}, ) 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): # 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. d = decide( (Route(host="api.github.com", auth_scheme="Bearer", - token_env="EGRESS_PROXY_TOKEN_0"),), - "api.github.com", "/repos/x", {"EGRESS_PROXY_TOKEN_0": ""}, + token_env="EGRESS_TOKEN_0"),), + "api.github.com", "/repos/x", {"EGRESS_TOKEN_0": ""}, ) self.assertEqual("block", d.action) @@ -240,8 +240,8 @@ class TestDecide(unittest.TestCase): # go-gitea/gitea#16734). The addon is scheme-agnostic. d = decide( (Route(host="git.example", auth_scheme="token", - token_env="EGRESS_PROXY_TOKEN_0"),), - "git.example", "/api/v1/repos", {"EGRESS_PROXY_TOKEN_0": "abc"}, + token_env="EGRESS_TOKEN_0"),), + "git.example", "/api/v1/repos", {"EGRESS_TOKEN_0": "abc"}, ) self.assertEqual("token abc", d.inject_authorization) diff --git a/tests/unit/test_egress_proxy_apply.py b/tests/unit/test_egress_apply.py similarity index 88% rename from tests/unit/test_egress_proxy_apply.py rename to tests/unit/test_egress_apply.py index 8e2f5a4..ad40941 100644 --- a/tests/unit/test_egress_proxy_apply.py +++ b/tests/unit/test_egress_apply.py @@ -6,8 +6,8 @@ import unittest import json -from claude_bottle.backend.docker.egress_proxy_apply import ( - EgressProxyApplyError, +from claude_bottle.backend.docker.egress_apply import ( + EgressApplyError, _hosts_in_routes, _merge_single_route, _pipelock_safe_hosts, @@ -27,30 +27,30 @@ class TestValidateRoutesContent(unittest.TestCase): '{"routes": [{"host": "api.github.com",' ' "path_allowlist": ["/repos/x/"],' ' "auth_scheme": "Bearer",' - ' "token_env": "EGRESS_PROXY_TOKEN_0"}]}' + ' "token_env": "EGRESS_TOKEN_0"}]}' ) def test_rejects_bad_json(self): - with self.assertRaises(EgressProxyApplyError) as cm: + with self.assertRaises(EgressApplyError) as cm: validate_routes_content("{not json") self.assertIn("not valid", str(cm.exception)) def test_rejects_non_object_top_level(self): - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): validate_routes_content("[]") def test_rejects_missing_routes_key(self): - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): validate_routes_content('{"other": []}') def test_rejects_non_list_routes(self): - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): validate_routes_content('{"routes": "not a list"}') def test_rejects_partial_auth_pair(self): # The addon-core parser enforces both-or-neither — the apply # path picks this up before SIGHUP'ing the sidecar. - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): validate_routes_content( '{"routes": [{"host": "x.example",' ' "auth_scheme": "Bearer"}]}' @@ -83,7 +83,7 @@ class TestHostsInRoutes(unittest.TestCase): def test_invalid_routes_raises(self): # The mirror helper relies on parsing succeeding; bad input # should error before pipelock is touched. - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): _hosts_in_routes('{"routes": [{"path": "/no-host/"}]}') @@ -115,20 +115,20 @@ class TestMergeSingleRoute(unittest.TestCase): new_route = json.loads(merged)["routes"][-1] self.assertEqual("Bearer", new_route["auth_scheme"]) # 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): base = json.dumps({"routes": [ {"host": "api.anthropic.com", "auth_scheme": "Bearer", - "token_env": "EGRESS_PROXY_TOKEN_0"}, + "token_env": "EGRESS_TOKEN_0"}, ]}) merged = _merge_single_route(base, { "host": "api.github.com", "auth": {"scheme": "Bearer", "token_ref": "GH"}, }) 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): base = json.dumps({"routes": [ @@ -161,7 +161,7 @@ class TestMergeSingleRoute(unittest.TestCase): base = json.dumps({"routes": [ {"host": "api.github.com", "auth_scheme": "Bearer", - "token_env": "EGRESS_PROXY_TOKEN_0"}, + "token_env": "EGRESS_TOKEN_0"}, ]}) merged = _merge_single_route(base, { "host": "api.github.com", @@ -169,7 +169,7 @@ class TestMergeSingleRoute(unittest.TestCase): }) route = json.loads(merged)["routes"][0] 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): base = json.dumps({"routes": [{"host": "GitHub.com"}]}) @@ -182,11 +182,11 @@ class TestMergeSingleRoute(unittest.TestCase): self.assertEqual(["/x/"], routes[0]["path_allowlist"]) def test_missing_host_raises(self): - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): _merge_single_route(self.BASE, {}) def test_invalid_current_yaml_raises(self): - with self.assertRaises(EgressProxyApplyError): + with self.assertRaises(EgressApplyError): _merge_single_route("{not json", {"host": "x.example"}) @@ -198,7 +198,7 @@ class TestPipelockSafeHosts(unittest.TestCase): ) 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 # entirely from the pipelock mirror so the apply doesn't # fail parse. diff --git a/tests/unit/test_manifest_egress_proxy.py b/tests/unit/test_manifest_egress.py similarity index 96% rename from tests/unit/test_manifest_egress_proxy.py rename to tests/unit/test_manifest_egress.py index 94936dc..1a15cf2 100644 --- a/tests/unit/test_manifest_egress_proxy.py +++ b/tests/unit/test_manifest_egress.py @@ -8,7 +8,7 @@ auth omission means unauthenticated.""" import unittest from claude_bottle.log import Die -from claude_bottle.manifest import EgressProxyRoute, Manifest +from claude_bottle.manifest import EgressRoute, Manifest def _bottle(routes): @@ -201,8 +201,8 @@ class TestRouteValidation(unittest.TestCase): b = _bottle([]) self.assertEqual((), b.egress.routes) - def test_no_egress_proxy_block_means_empty(self): - # The bottle dataclass defaults to an empty EgressProxyConfig. + def test_no_egress_block_means_empty(self): + # The bottle dataclass defaults to an empty EgressConfig. b = Manifest.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -211,7 +211,7 @@ class TestRouteValidation(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): Manifest.from_json_obj({ "bottles": {"dev": {"egress": {"wat": []}}}, diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index fb841c8..cf72317 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -31,7 +31,7 @@ _BOTTLE_DEV = """ - host: example.com --- - The dev bottle. Anthropic OAuth via egress-proxy. + The dev bottle. Anthropic OAuth via egress. """ _AGENT_IMPL = """ diff --git a/tests/unit/test_pipelock_allowlist.py b/tests/unit/test_pipelock_allowlist.py index 61506f2..892d02d 100644 --- a/tests/unit/test_pipelock_allowlist.py +++ b/tests/unit/test_pipelock_allowlist.py @@ -1,5 +1,5 @@ """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 contribute; they flow through the per-agent git-gate (PRD 0008).""" @@ -25,9 +25,9 @@ def _routes(routes): class TestEffectiveAllowlist(unittest.TestCase): 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 - # egress_proxy_routes_for_bottle). + # egress_routes_for_bottle). eff = pipelock_effective_allowlist(_bottle({})) self.assertIn("api.anthropic.com", eff) self.assertIn("sentry.io", eff) @@ -62,16 +62,16 @@ class TestAllowlistWithRoutes(unittest.TestCase): self.assertIn(default, eff) self.assertIn("x.example", eff) - def test_egress_proxy_hostname_NOT_in_pipelock_allowlist(self): - # The agent never dials egress-proxy via the proxy mechanism + def test_egress_hostname_NOT_in_pipelock_allowlist(self): + # The agent never dials egress via the proxy mechanism # — it IS the proxy. Pipelock receives upstream hostnames - # from egress-proxy's CONNECT requests, not the - # `egress-proxy` hostname itself. + # from egress's CONNECT requests, not the + # `egress` hostname itself. eff = pipelock_effective_allowlist(_bottle(_routes([ {"host": "x.example", "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): eff = pipelock_effective_allowlist(_bottle({"supervise": True})) @@ -84,9 +84,9 @@ class TestAllowlistWithRoutes(unittest.TestCase): self.assertNotIn("supervise", eff_explicit) 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 - # has already passed (or 403'd) at egress-proxy. + # has already passed (or 403'd) at egress. eff = pipelock_effective_allowlist(_bottle(_routes([ {"host": "github.com", "path_allowlist": ["/x/", "/y/"]}, ]))) diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 75d6f71..d344098 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -17,7 +17,7 @@ from claude_bottle.supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_PROXY_BLOCK, + TOOL_EGRESS_BLOCK, TOOL_PIPELOCK_BLOCK, archive_proposal, audit_log_path, @@ -37,7 +37,7 @@ from claude_bottle.supervise import ( 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( bottle_slug="dev", tool=tool, @@ -54,7 +54,7 @@ class TestProposalRoundtrip(unittest.TestCase): self.assertTrue(p.id) self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp) 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): p = _proposal() @@ -139,13 +139,13 @@ class TestQueueIO(unittest.TestCase): def test_list_pending_sorted_by_arrival(self): # Fabricate two with explicit timestamps. a = Proposal.new( - bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_BLOCK, proposed_file="{}", justification="early", current_file_hash="x", now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), ) b = Proposal.new( - bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_BLOCK, proposed_file="{}", justification="late", current_file_hash="x", 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): self.assertEqual( ( - TOOL_EGRESS_PROXY_BLOCK, + TOOL_EGRESS_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK, - supervise.TOOL_LIST_EGRESS_PROXY_ROUTES, + supervise.TOOL_LIST_EGRESS_ROUTES, ), supervise.TOOLS, ) 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.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): # 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) files = sorted(p.name for p in plan.current_config_dir.iterdir()) self.assertEqual(["Dockerfile"], files) diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index eb7c296..1c376b0 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -74,9 +74,9 @@ class TestValidation(unittest.TestCase): ) 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 - # 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`. for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK): with self.subTest(tool=tool): @@ -163,10 +163,10 @@ class TestHandleToolsList(unittest.TestCase): names = [t["name"] for t in result["tools"]] # type: ignore[index] self.assertEqual( sorted([ - _sv.TOOL_EGRESS_PROXY_BLOCK, + _sv.TOOL_EGRESS_BLOCK, _sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK, - _sv.TOOL_LIST_EGRESS_PROXY_ROUTES, + _sv.TOOL_LIST_EGRESS_ROUTES, ]), sorted(names), ) @@ -186,10 +186,10 @@ class TestHandleToolsList(unittest.TestCase): self.assertIn("justification", required) 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( 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"] self.assertEqual({}, schema.get("properties")) # type: ignore[union-attr] @@ -229,7 +229,7 @@ class TestHandleToolsCall(unittest.TestCase): try: result = handle_tools_call( { - "name": _sv.TOOL_EGRESS_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_BLOCK, "arguments": { "host": "example.com", "justification": "need a route", @@ -273,7 +273,7 @@ class TestHandleToolsCall(unittest.TestCase): with self.assertRaises(_RpcError): handle_tools_call( { - "name": _sv.TOOL_EGRESS_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_BLOCK, "arguments": {"host": "example.com"}, }, self.config, @@ -284,7 +284,7 @@ class TestHandleToolsCall(unittest.TestCase): try: handle_tools_call( { - "name": _sv.TOOL_EGRESS_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_BLOCK, "arguments": { "host": "example.com", "justification": "x", @@ -371,7 +371,7 @@ class TestHttpEndToEnd(unittest.TestCase): self.assertEqual("2.0", result["jsonrpc"]) self.assertEqual(1, result["id"]) 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): result = self._post_jsonrpc(