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