refactor: rename egress-proxy → egress everywhere
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+75
-75
@@ -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}'"
|
||||
)
|
||||
+43
-43
@@ -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",
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user