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

The manifest key is `egress:` now; finish the rename so the rest of
the codebase matches. Files (Dockerfile.egress, claude_bottle/egress.py
etc.), classes (Egress, EgressConfig, EgressRoute, EgressPlan,
DockerEgress), constants (EGRESS_HOSTNAME, EGRESS_ROUTES, ...),
container name prefix (claude-bottle-egress-*), docker network alias
(egress), the introspection host (_egress.local), the MCP tool IDs
(egress-block, list-egress-routes), and the preflight label all drop
the `-proxy` suffix.
This commit is contained in:
2026-05-25 21:59:47 -04:00
parent 14c8a51c16
commit 1e5b0dcfca
30 changed files with 583 additions and 583 deletions
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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:
+7 -7
View File
@@ -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)
+1 -1
View File
@@ -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"
@@ -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:<port>`.
EGRESS_PROXY_PORT = int(os.environ.get("CLAUDE_BOTTLE_EGRESS_PROXY_PORT", "9099"))
# resolves to `http://egress:<port>`.
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 `<stage_dir>/egress-proxy-ca/` (mode 644
Both files live under `<stage_dir>/egress-ca/` (mode 644
`docker cp` preserves the mode into the container, where the
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}'"
)
@@ -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 <bottle>` 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 <bottle>` 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",
+26 -26
View File
@@ -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.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Docker network plumbing for the per-agent egress-proxy topology.
"""Docker network plumbing for the per-agent egress topology.
The agent container sits on a Docker `--internal` network (no default
gateway). Pipelock straddles that network and a per-agent user-defined
+13 -13
View File
@@ -19,7 +19,7 @@ from ...log import die
from .. import BottleSpec
from . import 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,
)
+10 -10
View File
@@ -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
+22 -22
View File
@@ -3,8 +3,8 @@ act on them (approve / modify / reject). PRD 0013 v1.
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
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",
@@ -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:<port>` once chunk 2 cuts over.
EGRESS_PROXY_HOSTNAME = "egress-proxy"
# `http://egress:<port>` 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 `{<token_env in container>: <token_ref on host>}`.
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: <name>`). 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",
]
@@ -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/<path>` are
# Requests through the proxy for `_egress.local/<path>` 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()]
@@ -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"
),
)
+34 -34
View File
@@ -123,10 +123,10 @@ class GitEntry:
)
# Auth schemes for the egress-proxy route's optional `auth` block.
# Auth schemes for the egress route's optional `auth` block.
# Same values cred-proxy accepts today; `token` sidesteps the Gitea
# 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)
+17 -17
View File
@@ -5,10 +5,10 @@ forward proxy with hostname allowlisting + DLP scanning + URL-entropy
checks. One sidecar per agent, attached to the agent's --internal
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."""
+21 -21
View File
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (claude_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the
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",
+29 -29
View File
@@ -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)