Merge pull request 'feat(egress-proxy): retarget remediation flow (PRD 0017 chunk 3)' (#30) from egress-proxy-block-remediation into main
test / unit (push) Successful in 18s
test / integration (push) Successful in 1m4s

This commit was merged in pull request #30.
This commit is contained in:
2026-05-25 20:34:23 -04:00
28 changed files with 1624 additions and 604 deletions
+20 -11
View File
@@ -44,14 +44,23 @@ USER mitmproxy
EXPOSE 9099
# Entrypoint:
# --mode regular@9099 standard HTTP/HTTPS forward proxy on :9099.
# --set ssl_verify_upstream_trusted_ca=... only when
# EGRESS_PROXY_UPSTREAM_CA env is set (the backend's start step
# sets it to the in-container pipelock-CA path when pipelock is
# present, so the upstream leg trusts pipelock's MITM). The
# ${VAR:+expansion} form omits the flag when the var is unset
# or empty — useful for standalone runs of the image (e.g. unit
# tests) where no upstream CA is mounted.
# -s /app/egress_proxy_addon.py loads our addon, which reads the
# route table from /etc/egress-proxy/routes.yaml.
ENTRYPOINT ["sh", "-c", "exec mitmdump --mode regular@9099 ${EGRESS_PROXY_UPSTREAM_CA:+--set ssl_verify_upstream_trusted_ca=$EGRESS_PROXY_UPSTREAM_CA} -s /app/egress_proxy_addon.py"]
# - Upstream proxy: when EGRESS_PROXY_UPSTREAM_PROXY is set,
# use mitmproxy's `--mode upstream:URL` to forward all
# post-MITM traffic through pipelock. (mitmproxy does NOT
# honor HTTPS_PROXY env vars on its outbound side — it's a
# proxy server, not a client.) Standalone runs without
# EGRESS_PROXY_UPSTREAM_PROXY fall back to `regular@9099`
# direct-to-upstream — useful for unit tests of the image.
# - Upstream trust: when EGRESS_PROXY_UPSTREAM_CA is set, build
# a combined trust bundle (system roots + pipelock CA) and
# point mitmproxy at it via
# `--set ssl_verify_upstream_trusted_ca`. This option REPLACES
# mitmproxy's default trust store with the file we point it
# at — passing just pipelock's CA would break pipelock-
# passthrough hosts (api.anthropic.com etc.) where mitmproxy
# sees real upstream certs signed by public CAs. The combined
# bundle covers both pipelock-MITM'd and pipelock-passthrough
# hosts.
# - -s /app/egress_proxy_addon.py → loads our addon, reads
# /etc/egress-proxy/routes.yaml.
ENTRYPOINT ["sh", "-c", "MODE=\"--mode regular@9099\"; if [ -n \"$EGRESS_PROXY_UPSTREAM_PROXY\" ]; then MODE=\"--mode upstream:$EGRESS_PROXY_UPSTREAM_PROXY --listen-port 9099\"; fi; TRUST_FLAG=\"\"; if [ -n \"$EGRESS_PROXY_UPSTREAM_CA\" ] && [ -f \"$EGRESS_PROXY_UPSTREAM_CA\" ]; then COMBINED=/home/mitmproxy/.mitmproxy/combined-trust.pem; cat /etc/ssl/certs/ca-certificates.crt \"$EGRESS_PROXY_UPSTREAM_CA\" > \"$COMBINED\"; TRUST_FLAG=\"--set ssl_verify_upstream_trusted_ca=$COMBINED\"; fi; exec mitmdump $MODE $TRUST_FLAG -s /app/egress_proxy_addon.py"]
@@ -45,6 +45,13 @@ _STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_TRANSCRIPT_SUBDIR = "transcript"
_METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's
# `list-pipelock-allowlist` / `list-egress-proxy-routes` MCP tools
# return the current state — not a snapshot from launch time.
_LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
# Empty marker file. capability_apply writes it before teardown so
# cli.py's session-end cleanup knows to preserve the state dir for
# `cli.py resume <identity>`. Absent = clean up.
@@ -152,6 +159,41 @@ def per_bottle_image_tag(identity: str) -> str:
return f"claude-bottle-rebuilt-{identity}:latest"
def live_config_dir(identity: str) -> Path:
"""Per-bottle live-config dir. Bind-mounted read-only into the
supervise sidecar; the host's apply paths refresh the files on
every operator approval so the agent's `list-*` MCP tools always
return current state."""
return bottle_state_dir(identity) / _LIVE_CONFIG_SUBDIR
def live_routes_path(identity: str) -> Path:
return live_config_dir(identity) / LIVE_CONFIG_ROUTES_NAME
def live_allowlist_path(identity: str) -> Path:
return live_config_dir(identity) / LIVE_CONFIG_ALLOWLIST_NAME
def write_live_config(
identity: str, *, routes: str = "", allowlist: str = "",
) -> Path:
"""Initialise (or refresh) the live-config dir. Empty-string args
leave the existing file alone (caller passes only what it knows).
Returns the live-config dir path."""
d = live_config_dir(identity)
d.mkdir(parents=True, exist_ok=True)
if routes:
p = live_routes_path(identity)
p.write_text(routes)
p.chmod(0o644)
if allowlist:
p = live_allowlist_path(identity)
p.write_text(allowlist)
p.chmod(0o644)
return d
def transcript_snapshot_dir(identity: str) -> Path:
"""Where capability_apply stashes the agent's transcript before
teardown, so the next `cli.py start <agent>` can offer to
@@ -1,133 +0,0 @@
"""Host-side helper to apply a routes.json change to a running
cred-proxy sidecar (PRD 0014).
Used by the supervise dashboard when the operator approves a
cred-proxy-block proposal (or runs the operator-initiated `routes
edit <bottle>` verb). Fetches the current routes.json via `docker
exec cat`, validates the new JSON, writes it into the sidecar via
`docker cp`, then `docker kill --signal HUP` to make the in-sidecar
SIGHUP handler (PRD 0014 Phase 1) reload without dropping
connections.
Raises CredProxyApplyError on any failure — the dashboard surfaces
the message and keeps the proposal pending so the operator can
retry.
"""
from __future__ import annotations
import json
import os
import subprocess
import tempfile
from pathlib import Path
# Constants inlined from the deleted `claude_bottle.backend.docker.
# cred_proxy` module (PRD 0017 chunk 2 cutover). Chunk 3 retargets
# this file at egress-proxy and gets rid of these.
CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json"
def _cred_proxy_container_name(slug: str) -> str:
return f"claude-bottle-cred-proxy-{slug}"
class CredProxyApplyError(RuntimeError):
"""Raised when fetch / apply fails. Caller renders to the
operator; does not crash the dashboard.
PRD 0017 chunk 2 deletes the cred-proxy sidecar; this module's
docker-exec calls now hit a non-existent container and raise
CredProxyApplyError with a "container not running" message,
which the dashboard surfaces to the operator. Chunk 3 retargets
everything at egress-proxy."""
def fetch_current_routes(slug: str) -> str:
"""Read the live routes.json from the running cred-proxy sidecar
for `slug`. Returns the file content as a string. Raises
CredProxyApplyError if the sidecar isn't reachable or the read
fails."""
container = _cred_proxy_container_name(slug)
r = subprocess.run(
["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
raise CredProxyApplyError(
f"could not read routes.json from {container}: "
f"{(r.stderr or '').strip() or 'container not running?'}"
)
return r.stdout
def validate_routes_json(content: str) -> None:
"""Syntactic check before SIGHUP — the sidecar's reload also
validates, but failing here keeps the old routes live and gives
the operator a clearer error than 'reload failed' in the
sidecar logs."""
try:
parsed = json.loads(content)
except json.JSONDecodeError as e:
raise CredProxyApplyError(
f"proposed routes.json is not valid JSON: {e}"
) from e
if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list):
raise CredProxyApplyError(
"proposed routes.json must be an object with a 'routes' array"
)
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
"""Apply `new_content` to the cred-proxy sidecar for `slug`:
1. Fetch current routes.json (for the before-diff).
2. Validate the new JSON.
3. Write to a temp file, `docker cp` into the sidecar.
4. `docker kill --signal HUP` so cred-proxy reloads.
Returns (before, after) where `after` == `new_content`. Raises
CredProxyApplyError on any step; the existing routes in the
sidecar are unchanged if the failure is before docker cp, and
are reverted in spirit if SIGHUP fails (cp landed but reload
didn't fire — caller's next attempt will SIGHUP again)."""
container = _cred_proxy_container_name(slug)
before = fetch_current_routes(slug)
validate_routes_json(new_content)
fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
f.write(new_content)
cp = subprocess.run(
["docker", "cp", tmp_path, f"{container}:{CRED_PROXY_ROUTES_IN_CONTAINER}"],
capture_output=True, text=True, check=False,
)
if cp.returncode != 0:
raise CredProxyApplyError(
f"failed to copy routes.json into {container}: "
f"{(cp.stderr or '').strip()}"
)
sig = subprocess.run(
["docker", "kill", "--signal", "HUP", container],
capture_output=True, text=True, check=False,
)
if sig.returncode != 0:
raise CredProxyApplyError(
f"failed to SIGHUP {container}: "
f"{(sig.stderr or '').strip()}"
)
finally:
try:
Path(tmp_path).unlink()
except OSError:
pass
return before, new_content
__all__ = [
"CredProxyApplyError",
"apply_routes_change",
"fetch_current_routes",
"validate_routes_json",
]
+93 -39
View File
@@ -75,9 +75,7 @@ def build_egress_proxy_image() -> None:
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
"""Mint the per-bottle egress-proxy MITM CA. Reuses the pipelock
binary's `tls init` subcommand — a known-good RSA CA minter we
already pin and run on this host.
"""Mint the per-bottle egress-proxy MITM CA via host `openssl req`.
Returns `(mitmproxy_pem, cert_only_pem)`:
- `mitmproxy_pem` is the single-PEM concat (cert + key)
@@ -86,41 +84,75 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
trust store by `provision_ca` so the agent trusts the bumped
CONNECT cert egress-proxy presents.
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 600).
Private keys never leave the host stage dir until
`DockerEgressProxy.start` docker-cps the concat file into the
sidecar; start.py's outer finally `shutil.rmtree`s the stage dir
after teardown.
Why openssl req (not the pipelock binary's `tls init`):
pipelock's CA generator stamps a non-standard `Subject Key
Identifier` on the CA (random rather than SHA-1 of the pubkey).
mitmproxy computes the `Authority Key Identifier` on each leaf
it mints as SHA-1(issuer's pubkey). openssl's chain validator
uses the leaf's AKI to find the issuer cert by SKI; pipelock's
SKI doesn't match → openssl reports "unable to get local issuer
certificate" even though the CA is right there in the trust
store. openssl req's `subjectKeyIdentifier=hash` extension uses
SHA-1(pubkey), matching mitmproxy's computation.
Imported lazily inside the function so test patchers in
pipelock-land don't need to know about us."""
# Local import keeps the module-import graph free of a hard
# pipelock-image dependency at top of file (we don't actually
# need pipelock's *runtime* here, just its tls-init subcommand).
from .pipelock import PIPELOCK_IMAGE
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 644 —
`docker cp` preserves the mode into the container, where the
mitmproxy user (uid 1000) reads them; the host stage_dir is
mode 700 so the private key isn't world-exposed)."""
work = stage_dir / "egress-proxy-ca"
work.mkdir(exist_ok=True)
result = subprocess.run(
["docker", "run", "--rm",
"-v", f"{work}:/h",
"-e", "PIPELOCK_HOME=/h",
PIPELOCK_IMAGE, "tls", "init"],
capture_output=True,
text=True,
check=False,
key_path = work / "ca-key.pem"
cert_path = work / "ca.pem"
cnf_path = work / "ca.cnf"
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
# config matches RSA CAs without surprise), and openssl req's
# default behavior here is exactly what we want.
keygen = subprocess.run(
["openssl", "genrsa", "-out", str(key_path), "2048"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(f"egress-proxy tls init failed: {result.stderr.strip()}")
cert = work / "ca.pem"
key = work / "ca-key.pem"
if not cert.is_file() or not key.is_file():
die(f"egress-proxy tls init did not produce ca files in {work}")
cert.chmod(0o600)
if keygen.returncode != 0:
die(f"egress-proxy ca keygen failed: {keygen.stderr.strip()}")
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
# leaves it later mints. Without this, chain validation breaks
# despite the CA being present in the trust store.
cnf_path.write_text(
"[req]\n"
"distinguished_name = req_dn\n"
"prompt = no\n"
"x509_extensions = v3_ca\n"
"\n"
"[req_dn]\n"
"O = claude-bottle\n"
"CN = claude-bottle egress-proxy CA\n"
"\n"
"[v3_ca]\n"
"basicConstraints = critical, CA:TRUE\n"
"keyUsage = critical, keyCertSign, cRLSign\n"
"subjectKeyIdentifier = hash\n"
)
cnf_path.chmod(0o644)
req = subprocess.run(
["openssl", "req", "-x509", "-new", "-nodes",
"-key", str(key_path),
"-sha256", "-days", "365",
"-config", str(cnf_path),
"-out", str(cert_path)],
capture_output=True, text=True, check=False,
)
if req.returncode != 0:
die(f"egress-proxy ca cert generation failed: {req.stderr.strip()}")
cert_path.chmod(0o644)
# mitmproxy reads cert + key from a single concatenated PEM file.
mitm = work / "mitmproxy-ca.pem"
mitm.write_bytes(cert.read_bytes() + key.read_bytes())
mitm.chmod(0o600)
return (mitm, cert)
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
mitm.chmod(0o644)
return (mitm, cert_path)
class DockerEgressProxy(EgressProxy):
@@ -197,14 +229,25 @@ class DockerEgressProxy(EgressProxy):
"--network-alias", EGRESS_PROXY_HOSTNAME,
]
if route_via_pipelock:
# Route egress-proxy's outbound HTTPS through pipelock so
# the egress allowlist + DLP body scanner apply to its
# traffic on the egress-proxy → upstream leg. Pipelock
# MITMs each handshake with its per-bottle CA, which is
# docker-cp'd in below and pointed to via the
# EGRESS_PROXY_UPSTREAM_CA env (entrypoint conditionally
# adds the matching --set flag).
# Route egress-proxy's outbound traffic through pipelock
# so the egress allowlist + DLP body scanner apply to
# the egress-proxy → upstream leg. Pipelock MITMs each
# handshake with its per-bottle CA, which is docker-cp'd
# in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA
# env (entrypoint conditionally adds the matching --set
# flag).
#
# EGRESS_PROXY_UPSTREAM_PROXY is the mechanism: mitmproxy
# does NOT honor HTTPS_PROXY env vars on its outbound
# side (it's a proxy server, not a client). The
# entrypoint reads this env and switches mitmdump to
# `--mode upstream:<URL>` so all post-MITM traffic
# CONNECTs to pipelock instead of going direct. The
# HTTPS/HTTP_PROXY env vars below are kept for any
# bundled client libraries (mitmproxy plugin requests,
# etc.) that might honor them — harmless if ignored.
create_args.extend([
"-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1",
@@ -232,6 +275,17 @@ class DockerEgressProxy(EgressProxy):
f"{create_result.stderr.strip()}"
)
# routes.yaml also lands inside the container; bump to 644
# for the same reason as the CAs — mitmproxy user (uid 1000)
# has to read it. Host stage_dir is mode 700 so the file
# isn't actually exposed to other host users.
plan.routes_path.chmod(0o644)
# Pipelock CA: pipelock itself runs as root so its in-pipelock
# copy doesn't care about mode, but egress-proxy's mitmproxy
# user does. Bump on the host so docker cp into egress-proxy
# carries world-readable.
if route_via_pipelock:
plan.pipelock_ca_host_path.chmod(0o644)
cps: list[tuple[Path, str, str]] = [
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"),
@@ -0,0 +1,323 @@
"""Host-side helper to apply a routes.yaml change to a running
egress-proxy sidecar (PRD 0014 retargeted by PRD 0017 chunk 3).
Used by the supervise dashboard when the operator approves an
egress-proxy-block proposal (or runs the operator-initiated
`routes edit <bottle>` verb). Fetches the current routes.yaml via
`docker exec cat`, validates the new content, writes it into the
sidecar via `docker cp`, then `docker kill --signal HUP` to make
the addon reload without dropping connections.
Also mirrors the new route hosts into pipelock's hostname allowlist
so the downstream leg lets them through — egress-proxy enforces
the path-aware allowlist on the agent leg, pipelock enforces the
hostname allowlist + DLP body scan on the upstream leg, and a
host added to one must be in the other or the request 403s
somewhere along the chain.
Raises EgressProxyApplyError on any failure — the dashboard
surfaces the message and keeps the proposal pending so the
operator can retry.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
from pathlib import Path
from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER
from ...egress_proxy_addon_core import load_routes
from .egress_proxy import egress_proxy_container_name
from .pipelock_apply import (
PipelockApplyError,
apply_allowlist_change,
fetch_current_allowlist,
parse_allowlist_content,
render_allowlist_content,
)
class EgressProxyApplyError(RuntimeError):
"""Raised when fetch / apply fails. Caller renders to the
operator; does not crash the dashboard."""
def fetch_current_routes(slug: str) -> str:
"""Read the live routes.yaml from the running egress-proxy sidecar
for `slug`. Returns the file content as a string. Raises
EgressProxyApplyError if the sidecar isn't reachable or the read
fails."""
container = egress_proxy_container_name(slug)
r = subprocess.run(
["docker", "exec", container, "cat", EGRESS_PROXY_ROUTES_IN_CONTAINER],
capture_output=True, text=True, check=False,
)
if r.returncode != 0:
raise EgressProxyApplyError(
f"could not read routes.yaml from {container}: "
f"{(r.stderr or '').strip() or 'container not running?'}"
)
return r.stdout
def validate_routes_content(content: str) -> None:
"""Syntactic check before SIGHUP — the addon's reload also
validates, but failing here keeps the old routes live and gives
the operator a clearer error than the addon's stderr line."""
try:
load_routes(content)
except ValueError as e:
raise EgressProxyApplyError(
f"proposed routes.yaml is not valid: {e}"
) from e
def _hosts_in_routes(content: str) -> list[str]:
"""Extract the host list from a routes.yaml content string.
Uses the addon's own parser so any host the addon will match on
also lands in pipelock's allowlist. Returns sorted+deduped."""
try:
routes = load_routes(content)
except ValueError as e:
raise EgressProxyApplyError(
f"proposed routes.yaml is not valid: {e}"
) from e
return sorted({r.host for r in routes if r.host})
# Pipelock's allowlist parser accepts only literal hostnames:
# `[A-Za-z0-9_.-]+`. Anything else (wildcards, IPv6 literals,
# stray characters) is silently dropped from the mirror so the
# pipelock apply doesn't fail parse before the new yaml is even
# written. The dropped hosts stay on egress-proxy's route table —
# but the addon does exact-host match only, so they'll never
# match anything either. (Wildcard host matching was removed —
# see `match_route` in egress_proxy_addon_core for the rationale.)
_PIPELOCK_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
def _pipelock_safe_hosts(hosts: list[str]) -> list[str]:
"""Drop any host pipelock's allowlist parser would reject.
Order preserved."""
return [h for h in hosts if _PIPELOCK_HOST_RE.match(h)]
def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None:
"""Ensure every pipelock-compatible `hosts` entry is on
pipelock's allowlist. Fetches pipelock's current allowlist,
merges, re-applies. Hosts pipelock can't represent (wildcards,
etc.) are silently skipped — they stay live on egress-proxy
but aren't enforced at pipelock. No-op if every host is already
present (apply still restarts pipelock if any host is new).
Raises EgressProxyApplyError on pipelock failures so the
caller's diff/audit reflects the half-state."""
safe_hosts = _pipelock_safe_hosts(hosts)
try:
current = fetch_current_allowlist(slug)
existing = parse_allowlist_content(current)
merged = sorted(set(existing) | set(safe_hosts))
if merged == sorted(existing):
return # nothing to add
apply_allowlist_change(slug, render_allowlist_content(merged))
except PipelockApplyError as e:
# Mirror runs BEFORE the egress-proxy write, so egress-proxy
# is unchanged on this failure path. Report it as a
# pipelock-side problem so the operator looks in the right
# place; their `pipelock edit` flow can repair manually.
raise EgressProxyApplyError(
f"pipelock allowlist mirror failed (egress-proxy NOT "
f"updated): {e}. Fix pipelock's allowlist manually with "
f"`pipelock edit <bottle>` then retry the proposal."
) from e
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
"""Apply `new_content` to the egress-proxy sidecar for `slug`:
1. Fetch current routes.yaml (for the before-diff).
2. Validate the new content via the addon's own parser.
3. Mirror the route hosts onto pipelock's allowlist (so the
downstream hostname gate lets them through).
4. Write to a temp file, `docker cp` into the egress-proxy
sidecar.
5. `docker kill --signal HUP` so the addon reloads.
Order matters: pipelock first, then egress-proxy. If the
pipelock step fails, egress-proxy hasn't been touched and the
old routes stay live. If the egress-proxy step fails after
pipelock succeeded, pipelock has the host in its allowlist but
egress-proxy doesn't enforce it yet — harmless extra-permissive
state at pipelock, and a re-approval will land the egress-proxy
side.
Returns (before, after) where `after` == `new_content`. Raises
EgressProxyApplyError on any step."""
container = egress_proxy_container_name(slug)
before = fetch_current_routes(slug)
validate_routes_content(new_content)
# Pipelock mirror first — if it fails, egress-proxy stays intact
# and the operator gets a clear error about the half-state.
_mirror_hosts_to_pipelock(slug, _hosts_in_routes(new_content))
fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".yaml")
try:
with os.fdopen(fd, "w") as f:
f.write(new_content)
# mkstemp creates the file with mode 0600. `docker cp`
# preserves mode + host uid into the container, so without
# chmod the file lands as 0600 owned by the host user's uid,
# which inside the container is not mitmproxy (uid 1000) —
# the addon's reload then fails with PermissionError on the
# SIGHUP-triggered re-read and the old routes table stays in
# memory. Bump to 0644 so mitmproxy can read it post-cp;
# the host stage_dir doesn't apply to this tmp file but the
# content isn't secret (no tokens — those live in the
# container's environ), so 0644 in /tmp is fine.
os.chmod(tmp_path, 0o644)
cp = subprocess.run(
["docker", "cp", tmp_path,
f"{container}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"],
capture_output=True, text=True, check=False,
)
if cp.returncode != 0:
raise EgressProxyApplyError(
f"failed to copy routes.yaml into {container}: "
f"{(cp.stderr or '').strip()}"
)
sig = subprocess.run(
["docker", "kill", "--signal", "HUP", container],
capture_output=True, text=True, check=False,
)
if sig.returncode != 0:
raise EgressProxyApplyError(
f"failed to SIGHUP {container}: "
f"{(sig.stderr or '').strip()}"
)
finally:
try:
Path(tmp_path).unlink()
except OSError:
pass
return before, new_content
def _merge_single_route(
current_yaml: str, new_route: dict[str, object],
) -> str:
"""Merge a single proposed route into the current routes.yaml
content, returning the merged JSON-as-yaml string.
Behavior:
- If `new_route['host']` is NOT in the current routes →
append the route.
- If the host IS already present → union the path_allowlist
entries (proposed existing). The existing `auth_scheme`
and `token_env` are preserved — agent-proposed auth changes
on an existing host are ignored, matching the tool's
documented semantics.
The supervisor renders the merged routes.yaml with the same
JSON layout the addon expects (host + path_allowlist +
auth_scheme + token_env). Token VALUES never appear here; the
routes file carries only env-var slot NAMES."""
try:
cfg = json.loads(current_yaml)
except json.JSONDecodeError as e:
raise EgressProxyApplyError(
f"current routes.yaml is not valid JSON: {e}"
) from e
routes = cfg.get("routes")
if not isinstance(routes, list):
raise EgressProxyApplyError(
"current routes.yaml: 'routes' is not a list"
)
new_host = str(new_route.get("host", "")).lower()
if not new_host:
raise EgressProxyApplyError(
"proposed route is missing 'host'"
)
proposed_paths = list(new_route.get("path_allowlist") or [])
# Look for an existing entry with the same host (case-insensitive).
for entry in routes:
if not isinstance(entry, dict):
continue
if str(entry.get("host", "")).lower() == new_host:
# Merge path_allowlist: union proposed + existing, ordered
# by first-seen so existing paths stay in original order.
existing_paths: list[str] = list(entry.get("path_allowlist") or [])
seen = {p: None for p in existing_paths}
for p in proposed_paths:
seen.setdefault(p, None)
merged_paths = list(seen.keys())
if merged_paths:
entry["path_allowlist"] = merged_paths
# Preserve existing auth — tool description says agent-
# proposed auth on an existing host is ignored.
break
else:
# Host not present; build a new route entry from the
# proposed fields. Need to assign a token_env slot if
# `auth` was proposed (otherwise the addon's parser rejects
# a half-set auth pair). Slots: count existing slots, pick
# the next free index.
entry = {"host": new_route["host"]}
if proposed_paths:
entry["path_allowlist"] = proposed_paths
auth = new_route.get("auth")
if isinstance(auth, dict) and auth.get("scheme") and auth.get("token_ref"):
existing_slots = sorted({
str(r.get("token_env"))
for r in routes
if isinstance(r, dict) and r.get("token_env")
})
next_idx = len(existing_slots)
entry["auth_scheme"] = str(auth["scheme"])
entry["token_env"] = f"EGRESS_PROXY_TOKEN_{next_idx}"
# NOTE: the addon reads token VALUES from its container's
# environ keyed by token_env. A newly-added auth route at
# runtime points at a slot that has no env value → the
# addon will 403 with "token env unset" until the operator
# arranges for the value to land in the container's env.
# Recording this here so the operator-facing diff carries
# the slot name they'll need to provision.
routes.append(entry)
cfg["routes"] = routes
return json.dumps(cfg, indent=2) + "\n"
def add_route(slug: str, proposed_route_json: str) -> tuple[str, str]:
"""Apply a single-route addition to the egress-proxy. Parses the
agent's proposed route, fetches the current routes file, merges,
and applies via `apply_routes_change`. Returns (before, after)
full-file content for the audit log."""
try:
proposed = json.loads(proposed_route_json)
except json.JSONDecodeError as e:
raise EgressProxyApplyError(
f"proposed route is not valid JSON: {e}"
) from e
if not isinstance(proposed, dict):
raise EgressProxyApplyError(
"proposed route must be a JSON object"
)
current = fetch_current_routes(slug)
merged = _merge_single_route(current, proposed)
return apply_routes_change(slug, merged)
__all__ = [
"EgressProxyApplyError",
"add_route",
"apply_routes_change",
"fetch_current_routes",
"validate_routes_content",
]
+14 -2
View File
@@ -240,13 +240,25 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
conflict races by incrementing the suffix (unless the name was
user-pinned). Returns the resolved container name."""
proxy_url = _agent_proxy_url(plan)
no_proxy = _agent_no_proxy(plan)
# Set BOTH cases of every *_PROXY var. libcurl's CVE-2016-5388
# httpoxy mitigation makes it ignore uppercase `HTTP_PROXY` for
# `http://` URLs and only honor lowercase `http_proxy`. Without
# the lowercase var, plain-HTTP requests from the agent bypass
# egress-proxy entirely (going direct, then failing with
# "network unreachable" because the agent's bridge is
# --internal). Lowercase HTTPS_PROXY isn't strictly needed but
# we set it for symmetry — some tools check one or the other.
docker_args: list[str] = [
"--rm", "-d",
"--name", plan.container_name,
"--network", internal_network,
"-e", f"HTTPS_PROXY={proxy_url}",
"-e", f"HTTP_PROXY={proxy_url}",
"-e", f"NO_PROXY={_agent_no_proxy(plan)}",
"-e", f"https_proxy={proxy_url}",
"-e", f"http_proxy={proxy_url}",
"-e", f"NO_PROXY={no_proxy}",
"-e", f"no_proxy={no_proxy}",
# CA trust trio for the agent process. Docker propagates
# run-time env into `docker exec`, so `claude` sees these
# without per-exec threading. NODE_EXTRA_CA_CERTS points at
@@ -266,7 +278,7 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
docker_args.extend(["-e", name])
# PRD 0013: read-only current-config mount so the agent can read
# routes.json / allowlist / Dockerfile before composing a
# routes.yaml / allowlist / Dockerfile before composing a
# supervise tool-call proposal. Mounted from the per-bottle
# stage_dir/current-config/ populated at prepare time.
if plan.supervise_plan is not None:
+13 -16
View File
@@ -15,7 +15,6 @@ from datetime import datetime, timezone
from pathlib import Path
from ... import pipelock
from ...egress_proxy import egress_proxy_render_routes
from ...env import ResolvedEnv, resolve_env
from ...log import die
from .. import BottleSpec
@@ -153,21 +152,18 @@ def resolve_plan(
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
supervise_plan = None
if bottle.supervise:
routes_content = (
egress_proxy_render_routes(egress_proxy_plan.routes)
if egress_proxy_plan.routes else ""
)
allowlist_content = "\n".join(pipelock.pipelock_effective_allowlist(bottle)) + "\n"
# Current Dockerfile for the agent image. Read from the repo
# root; for `--cwd` derived images the base Dockerfile is what
# the agent should propose changes against (the derived layer
# is just a workspace copy).
# (routes.yaml + pipelock allowlist used to land here too but
# PRD 0017 chunk 3 moved them behind the
# `list-egress-proxy-routes` MCP tool so the agent gets live
# state rather than a launch-time snapshot.)
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
supervise_plan = supervise.prepare(
slug, stage_dir,
routes_content=routes_content,
allowlist_content=allowlist_content,
dockerfile_content=dockerfile_content,
)
resolved = resolve_env(manifest, spec.agent_name)
@@ -175,15 +171,16 @@ def resolve_plan(
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
# When the bottle declares an egress-proxy route for the Anthropic
# OAuth flow, claude-code's outbound Authorization gets stripped +
# re-injected by egress-proxy. The agent's environ still needs
# *something* claude-code recognises as a credential or it refuses
# to start; ship a non-secret placeholder. The placeholder is not
# any real `auth.token_ref` value, so leaking it would tell an
# attacker only that egress-proxy is in front.
# When the bottle declares an egress-proxy route with the
# `claude_code_oauth` role marker, claude-code's outbound
# Authorization gets stripped + re-injected by egress-proxy. The
# agent's environ still needs *something* claude-code recognises
# as a credential or it refuses to start; ship a non-secret
# placeholder. The placeholder isn't any real token value, so
# leaking it would tell an attacker only that egress-proxy is in
# front. Manifest validation enforces singleton on this role.
has_anthropic_auth = any(
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
"claude_code_oauth" in r.roles
for r in egress_proxy_plan.routes
)
if has_anthropic_auth:
+55 -50
View File
@@ -1,12 +1,12 @@
"""dashboard: list pending supervise proposals across all bottles and
act on them (approve / modify / reject). PRD 0013 v1.
Curses-based TUI; modify-then-approve shells out to $EDITOR. For
0013 the approval handlers are no-ops on the supervisor side: the
response file is written (and the sidecar returns it to the agent),
and an audit entry is appended, but no host-side config change runs.
PRDs 0014 (cred-proxy) and 0015 (pipelock) wire in the actual
writes.
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handlers wire to the per-tool remediation engines:
PRD 0014 (egress-proxy, retargeted from cred-proxy in PRD 0017
chunk 3) writes routes.yaml + SIGHUPs egress-proxy; PRD 0015
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
(capability) rebuilds the bottle Dockerfile.
"""
from __future__ import annotations
@@ -27,8 +27,9 @@ from ..backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
)
from ..backend.docker.cred_proxy_apply import (
CredProxyApplyError,
from ..backend.docker.egress_proxy_apply import (
EgressProxyApplyError,
add_route,
apply_routes_change,
fetch_current_routes,
)
@@ -50,7 +51,7 @@ from ..supervise import (
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK,
TOOL_CRED_PROXY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK,
TOOL_PIPELOCK_BLOCK,
archive_proposal,
list_pending_proposals,
@@ -64,7 +65,7 @@ from ._common import PROG
# Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses.
ApplyError = (CredProxyApplyError, PipelockApplyError, CapabilityApplyError)
ApplyError = (EgressProxyApplyError, PipelockApplyError, CapabilityApplyError)
# --- Discovery -------------------------------------------------------------
@@ -103,10 +104,10 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]:
return sorted(out)
def discover_cred_proxy_slugs() -> list[str]:
"""Slugs of bottles with a running cred-proxy sidecar. Used by
def discover_egress_proxy_slugs() -> list[str]:
"""Slugs of bottles with a running egress-proxy sidecar. Used by
the operator-initiated `routes edit` verb."""
return _discover_sidecar_slugs("claude-bottle-cred-proxy-")
return _discover_sidecar_slugs("claude-bottle-egress-proxy-")
def discover_pipelock_slugs() -> list[str]:
@@ -156,17 +157,23 @@ def approve(
entry. If `final_file` is provided the status is `modified`;
otherwise `approved`.
Raises CredProxyApplyError if the cred-proxy-block apply fails
(sidecar down, invalid JSON survived the operator's modify).
On failure no response is written and no audit entry is
appended the proposal stays pending so the operator can fix
the input and retry."""
Raises EgressProxyApplyError if the egress-proxy-block apply
fails (sidecar down, invalid routes content survived the
operator's modify). On failure no response is written and no
audit entry is appended the proposal stays pending so the
operator can fix the input and retry."""
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", ""
if qp.proposal.tool == TOOL_CRED_PROXY_BLOCK:
diff_before, diff_after = apply_routes_change(
if qp.proposal.tool == TOOL_EGRESS_PROXY_BLOCK:
# The proposal is a single-route JSON; add_route fetches the
# current routes from the running egress-proxy, merges the
# new route in, and applies the full merged file. The
# audit log gets the BEFORE/AFTER of the full file so the
# diff renders cleanly even though the agent only proposed
# one entry.
diff_before, diff_after = add_route(
qp.proposal.bottle_slug, file_to_apply,
)
elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK:
@@ -212,22 +219,22 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
def operator_edit_routes(slug: str, new_content: str) -> tuple[str, str]:
"""Apply an operator-initiated routes.json change (no agent
"""Apply an operator-initiated routes.yaml change (no agent
proposal). Used by the `routes edit <bottle>` TUI verb and
available for scripted use. Returns (before, after) like
apply_routes_change. Writes an audit entry tagged
ACTION_OPERATOR_EDIT to distinguish from tool-call approvals.
Raises CredProxyApplyError on failure."""
Raises EgressProxyApplyError on failure."""
before, after = apply_routes_change(slug, new_content)
write_audit_entry(AuditEntry(
timestamp=datetime.now(timezone.utc).isoformat(),
bottle_slug=slug,
component="cred-proxy",
component="egress-proxy",
operator_action=ACTION_OPERATOR_EDIT,
operator_notes="",
justification="",
diff=render_diff(before, after, label="cred-proxy"),
diff=render_diff(before, after, label="egress-proxy"),
))
return before, after
@@ -239,20 +246,19 @@ def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]:
The full URL (with path) is preserved on the proposal for the
operator's read; only the host ends up in pipelock's allowlist.
FOLLOW-UP path-aware filtering. Pipelock 2.3.0's api_allowlist
is hostname-only (verified by inspecting the binary's strict
preset; the only "path" fields in pipelock's schema are about
local filesystem paths under sandbox / file_sentry / taint). So
approving pipelock-block opens the entire host, not the URL's
path. If/when per-path enforcement becomes load-bearing, the
follow-up is most likely adding an `auth_scheme: none` mode +
`path_allowlist` field to cred-proxy (which already does
path-prefix routing) and rewiring pipelock-block to propose
cred-proxy routes instead of pipelock hostnames. That's a
multi-touch change deserving its own PRD out of scope for the
supervise-loop work that introduced this function. See PR
discussion on https://gitea.dideric.is/didericis/claude-bottle/pulls/25
for the design conversation."""
Pipelock 2.3.0's api_allowlist is hostname-only (verified by
inspecting the binary's strict preset; the only "path" fields in
pipelock's schema are about local filesystem paths under sandbox
/ file_sentry / taint). Approving pipelock-block opens the
entire host, not the URL's path.
Path-level enforcement was the open question this function's
earlier docstring flagged; PRD 0017 answered it by putting
egress-proxy in front of pipelock. The agent's
`egress-proxy-block` tool now proposes routes.yaml changes that
can include a `path_allowlist`. Use that tool for path-level
follow-ups; this one stays hostname-only because pipelock is
still the last hostname gate before egress."""
import urllib.parse
parsed = urllib.parse.urlsplit(failed_url.strip())
host = parsed.hostname or ""
@@ -296,14 +302,13 @@ def _write_audit(
diff_before: str,
diff_after: str,
) -> None:
"""Audit log for cred-proxy / pipelock tools. capability-block has
no audit log (its changes are captured by the bottle's rebuild
record + git history per PRD 0016).
"""Audit log for egress-proxy / pipelock tools. capability-block
has no audit log (its changes are captured by the bottle's
rebuild record + git history per PRD 0016).
For cred-proxy-block approvals the (before, after) come from the
apply_routes_change return a real fetched-from-sidecar diff.
For rejections, or for tools whose remediation hasn't landed yet
(pipelock in 0014, capability anywhere), both are empty strings
For egress-proxy-block + pipelock-block approvals the (before,
after) come from the apply_*_change return a real
fetched-from-sidecar diff. For rejections both are empty strings
and the audit diff renders as empty."""
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
if component is None:
@@ -683,22 +688,22 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile"
# cred-proxy-block / pipelock-block: JSON-ish + plain.
# egress-proxy-block / pipelock-block: JSON-ish + plain.
return ".txt"
def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str:
"""Operator-initiated routes.json edit. Discover running
cred-proxy sidecars, pick one (single use directly; multi
"""Operator-initiated routes.yaml edit. Discover running
egress-proxy sidecars, pick one (single use directly; multi
prompt), fetch the current routes, open in $EDITOR, apply on
save. Returns a status-line message."""
return _operator_edit_flow(
stdscr,
label="routes",
discover=discover_cred_proxy_slugs,
discover=discover_egress_proxy_slugs,
fetch=fetch_current_routes,
apply=operator_edit_routes,
suffix=".json",
suffix=".yaml",
)
+58 -3
View File
@@ -62,13 +62,18 @@ class EgressProxyRoute:
(e.g. `EGRESS_PROXY_TOKEN_0`); `token_ref` is the host env var
the CLI reads at launch and forwards into the container's environ
under `token_env`. Routes that share a `token_ref` coalesce to
one `token_env` slot."""
one `token_env` slot.
`roles` carries the manifest route's optional role markers (see
`manifest.EGRESS_PROXY_ROLES`). The launch step reads these for
side effects like the claude-code OAuth placeholder env."""
host: str
path_allowlist: tuple[str, ...] = ()
auth_scheme: str = ""
token_env: str = ""
token_ref: str = ""
roles: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -122,7 +127,24 @@ class EgressProxyPlan:
pipelock_proxy_url: str = ""
def egress_proxy_routes_for_bottle(
# Hosts the agent needs by default for claude-code itself. Folded
# into every bottle's egress-proxy routes table as bare-pass entries
# (no auth, no path filter) so the agent reaches them without each
# bottle having to opt in. Pipelock used to own this list; PRD 0017
# moves it to egress-proxy because egress-proxy is the primary gate
# now and pipelock's allowlist is mirrored from egress-proxy.
DEFAULT_ALLOWLIST: tuple[str, ...] = (
"api.anthropic.com",
"statsig.anthropic.com",
"sentry.io",
"claude.ai",
"platform.claude.com",
"downloads.claude.ai",
"raw.githubusercontent.com",
)
def egress_proxy_manifest_routes(
bottle: Bottle,
) -> tuple[EgressProxyRoute, ...]:
"""Lift each `bottle.egress_proxy.routes[]` manifest entry into a
@@ -133,7 +155,12 @@ def egress_proxy_routes_for_bottle(
authenticated route with `token_ref` "GH_PAT" gets
`EGRESS_PROXY_TOKEN_0`; a second route with the same `token_ref`
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
no slot."""
no slot.
Does NOT include the folded-in DEFAULT_ALLOWLIST /
bottle.egress.allowlist bare-pass entries see
`egress_proxy_routes_for_bottle` for the effective set the
addon enforces."""
out: list[EgressProxyRoute] = []
slot_for_token: dict[str, str] = {}
for r in bottle.egress_proxy.routes:
@@ -148,15 +175,41 @@ def egress_proxy_routes_for_bottle(
auth_scheme=r.AuthScheme,
token_env=token_env,
token_ref=r.TokenRef,
roles=r.Role,
))
else:
out.append(EgressProxyRoute(
host=r.Host,
path_allowlist=r.PathAllowlist,
roles=r.Role,
))
return tuple(out)
def egress_proxy_routes_for_bottle(
bottle: Bottle,
) -> tuple[EgressProxyRoute, ...]:
"""Effective egress-proxy routes: manifest routes followed by
bare-pass entries for DEFAULT_ALLOWLIST hosts and
`bottle.egress.allowlist` hosts. This is what gets rendered into
routes.yaml + what the addon enforces.
Manifest routes win over defaults on host collision (manifest
routes carry more specific config auth, path filter, role
markers). Hostname comparison is case-insensitive."""
out: list[EgressProxyRoute] = list(egress_proxy_manifest_routes(bottle))
claimed: set[str] = {r.host.lower() for r in out}
for host in DEFAULT_ALLOWLIST:
if host.lower() not in claimed:
out.append(EgressProxyRoute(host=host))
claimed.add(host.lower())
for host in bottle.egress.allowlist:
if host and host.lower() not in claimed:
out.append(EgressProxyRoute(host=host))
claimed.add(host.lower())
return tuple(out)
def egress_proxy_token_env_map(
routes: tuple[EgressProxyRoute, ...],
) -> dict[str, str]:
@@ -279,11 +332,13 @@ class EgressProxy(ABC):
__all__ = [
"DEFAULT_ALLOWLIST",
"EGRESS_PROXY_HOSTNAME",
"EGRESS_PROXY_ROUTES_IN_CONTAINER",
"EgressProxy",
"EgressProxyPlan",
"EgressProxyRoute",
"egress_proxy_manifest_routes",
"egress_proxy_render_routes",
"egress_proxy_resolve_token_values",
"egress_proxy_routes_for_bottle",
+46 -2
View File
@@ -26,6 +26,8 @@ build input — not a module the host imports."""
from __future__ import annotations
import dataclasses
import json
import os
import signal
import sys
@@ -41,6 +43,16 @@ from egress_proxy_addon_core import Route, decide, is_git_push_request, load_rou
DEFAULT_ROUTES_PATH = "/etc/egress-proxy/routes.yaml"
# Magic hostname the addon recognises as an introspection target.
# Requests through the proxy for `_egress-proxy.local/<path>` are
# intercepted and answered with synthetic responses (the addon's
# `request` hook sets `flow.response` before any upstream connection).
# The hostname is not in DNS — only clients dialing through this
# specific egress-proxy can reach it, and only via HTTP (no TLS).
# Used by the supervise sidecar's `list-egress-proxy-routes` MCP
# tool to surface the live route table to the agent.
INTROSPECT_HOST = "_egress-proxy.local"
class EgressProxyAddon:
"""The mitmproxy addon. One instance per `mitmdump` process; the
@@ -84,17 +96,49 @@ class EgressProxyAddon:
signal.signal(signal.SIGHUP, handler)
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
"""Synthesize a response for `_egress-proxy.local` requests.
Currently supports `/allowlist` which returns the in-memory
route table as JSON (host, path_allowlist, auth_scheme,
token_env per route no token VALUES, those live in the
container's environ)."""
if path == "/allowlist":
payload = json.dumps(
{"routes": [dataclasses.asdict(r) for r in self.routes]},
indent=2,
).encode("utf-8")
flow.response = http.Response.make(
200, payload,
{"Content-Type": "application/json"},
)
return
flow.response = http.Response.make(
404,
f"egress-proxy introspection: no such endpoint {path!r}".encode(),
{"Content-Type": "text/plain; charset=utf-8"},
)
# mitmproxy's addon API: this method name + signature is how
# mitmdump discovers the request hook.
def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?")
# Introspection: requests to the magic `_egress-proxy.local`
# host are answered locally with a synthetic response. Check
# before the strip-auth + route logic — these requests aren't
# real upstream traffic, the agent isn't injecting auth, and
# the addon's own decide() would 403 the magic host (it's
# never in the routes table).
if flow.request.pretty_host == INTROSPECT_HOST:
self._serve_introspection(flow, request_path)
return
# Inbound Authorization is always stripped — the agent cannot
# smuggle a stolen token through the proxy. If the matched
# route declares an auth pair, a fresh header is injected
# below.
flow.request.headers.pop("authorization", None)
request_path, _, query = flow.request.path.partition("?")
# Universal HTTPS git-push block. Defense-in-depth: git-gate
# (PRD 0008) is the only sanctioned outbound path for git
# writes — its pre-receive runs gitleaks. Letting HTTPS push
+22 -9
View File
@@ -169,11 +169,14 @@ def match_route(
routes: typing.Sequence[Route],
request_host: str,
) -> Route | None:
"""Return the first route whose `host` matches `request_host`.
"""Return the first route whose `host` matches `request_host`
exactly (case-insensitive). DNS names are case-insensitive.
Exact match in v1 globs / wildcards are a follow-up (per PRD
0017 open questions). Hostname comparison is case-insensitive
because DNS names are case-insensitive."""
Wildcard hosts (`*.foo.com`) are NOT supported they caused
too many edge cases (apex match? cert validation? pipelock
mirror mismatch?) for too little payoff. Operators that need
multiple subdomains declare them individually (or one common
parent host as a bare-pass route)."""
target = request_host.lower()
for r in routes:
if r.host.lower() == target:
@@ -190,20 +193,30 @@ def decide(
"""Pure decision: given a route table + request host + path + env,
return what the addon should do with the request.
- No matching route forward unchanged. Pipelock will
hostname-gate it downstream; egress-proxy does not need to
decide on hosts it doesn't recognise.
- No matching route BLOCK. The route table is the bottle's
egress allowlist; defense-in-depth complements pipelock's
hostname gate on the downstream leg. A bottle that wants a
host reachable from the agent must declare a route for it
(bare-pass route no `auth`, no `path_allowlist` is fine
for hosts that just need passthrough).
- Matching route with `path_allowlist` set, request path doesn't
start with any of the allowed prefixes block with a clear
reason.
- Matching route with an auth pair forward + inject
Authorization. Token comes from `environ[route.token_env]`;
missing/empty values 500 (route declared auth but the secret
missing/empty values block (route declared auth but the secret
isn't here — operator misconfig).
"""
route = match_route(routes, request_host)
if route is None:
return Decision(action="forward")
return Decision(
action="block",
reason=(
f"egress-proxy: host {request_host!r} is not in the "
f"bottle's egress_proxy.routes allowlist. Declare a "
f"route for it or remove the request."
),
)
if route.path_allowlist:
if not any(request_path.startswith(p) for p in route.path_allowlist):
+78 -2
View File
@@ -129,6 +129,34 @@ class GitEntry:
# token-not-Bearer quirk (go-gitea/gitea#16734).
EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token")
# Optional per-route role markers. A role signals "this route plays
# a specific named part in the bottle's auth flow"; the launch step
# acts on the marker.
#
# claude_code_oauth: this route auth-injects on the agent's
# claude-code OAuth flow. Triggers prepare.py
# to ship a placeholder CLAUDE_CODE_OAUTH_TOKEN
# to the agent (so claude-code starts) and
# disable nonessential-traffic / error-reporting
# env vars. Host doesn't matter to the placeholder
# logic — declare the role on whichever route
# injects the OAuth header.
#
# Routes without a `role` are pure proxy entries: egress-proxy
# enforces path_allowlist + injects auth on its own, but nothing
# special happens on the agent side.
EGRESS_PROXY_ROLES = frozenset({
"claude_code_oauth",
})
# Singleton roles may appear on at most one route per bottle.
# claude_code_oauth drives a single placeholder env var; two routes
# claiming it would leave "which one is the canonical OAuth route?"
# ambiguous for any future role-aware logic.
EGRESS_PROXY_SINGLETON_ROLES = frozenset({
"claude_code_oauth",
})
@dataclass(frozen=True)
class EgressProxyRoute:
@@ -143,6 +171,11 @@ class EgressProxyRoute:
manifest's `auth` block is omitted both fields are empty strings —
no Authorization is written, no token forwarded.
`Role` is an optional tuple of named markers (see
EGRESS_PROXY_ROLES). The launch step reads these and triggers
associated side effects (e.g. the `claude_code_oauth` marker
causes prepare.py to set a placeholder OAuth env on the agent).
Validation rules (enforced in `from_dict`):
- `host` required, non-empty.
- `path_allowlist` optional, list of absolute path prefixes.
@@ -150,12 +183,17 @@ class EgressProxyRoute:
`token_ref` as non-empty strings; an empty `auth: {}` is an
error rather than a synonym for "no auth" (omit `auth` for
that case).
- `role` optional. String or list of strings drawn from
EGRESS_PROXY_ROLES. Singleton roles (see
EGRESS_PROXY_SINGLETON_ROLES) may appear on at most one
route per bottle.
"""
Host: str
PathAllowlist: tuple[str, ...] = ()
AuthScheme: str = ""
TokenRef: str = ""
Role: tuple[str, ...] = ()
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute":
@@ -226,11 +264,37 @@ class EgressProxyRoute:
auth_scheme = auth_scheme_raw
token_ref = token_ref_raw
role_raw = d.get("role")
roles: tuple[str, ...] = ()
if role_raw is None:
roles = ()
elif isinstance(role_raw, str):
roles = (role_raw,)
elif isinstance(role_raw, list):
role_list = cast(list[object], role_raw)
collected_roles: list[str] = []
for r in role_list:
if not isinstance(r, str):
die(f"{label} role items must be strings (got {type(r).__name__})")
collected_roles.append(r)
roles = tuple(collected_roles)
else:
die(
f"{label} role must be a string or a list of strings "
f"(was {type(role_raw).__name__})"
)
for r in roles:
if r not in EGRESS_PROXY_ROLES:
die(
f"{label} role {r!r} is not one of "
f"{', '.join(sorted(EGRESS_PROXY_ROLES))}"
)
for k in d:
if k not in ("host", "path_allowlist", "auth"):
if k not in ("host", "path_allowlist", "auth", "role"):
die(
f"{label} has unknown key {k!r}; accepted keys are "
f"'host', 'path_allowlist', 'auth'"
f"'host', 'path_allowlist', 'auth', 'role'"
)
return cls(
@@ -238,6 +302,7 @@ class EgressProxyRoute:
PathAllowlist=prefixes,
AuthScheme=auth_scheme,
TokenRef=token_ref,
Role=roles,
)
@@ -715,6 +780,8 @@ def _validate_egress_proxy_routes(
- Hosts must be unique within the bottle. The proxy matches by
exact-host (v1, prefix matching is on path_allowlist only);
duplicate hosts leave the route choice ambiguous.
- Singleton roles (see EGRESS_PROXY_SINGLETON_ROLES) may appear
on at most one route per bottle.
No cross-validation against `bottle.git` is performed. git-gate
(SSH push/fetch) and egress-proxy (HTTPS) broker different
@@ -729,6 +796,15 @@ def _validate_egress_proxy_routes(
f"{r.Host!r}; each host must be unique on the proxy."
)
seen_hosts[key] = None
for role in EGRESS_PROXY_SINGLETON_ROLES:
with_role = [r for r in routes if role in r.Role]
if len(with_role) > 1:
hosts = ", ".join(r.Host for r in with_role)
die(
f"bottle '{bottle_name}' egress_proxy.routes has {len(with_role)} "
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
f"single launch-step side effect — pick one."
)
def _validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
+42 -41
View File
@@ -22,21 +22,14 @@ from dataclasses import dataclass
from pathlib import Path
from typing import cast
from .egress_proxy import EGRESS_PROXY_HOSTNAME
from .egress_proxy import (
DEFAULT_ALLOWLIST,
EGRESS_PROXY_HOSTNAME,
egress_proxy_routes_for_bottle,
)
from .supervise import SUPERVISE_HOSTNAME
from .manifest import Bottle
# Baked-in default allowlist for hosts Claude Code itself needs.
DEFAULT_ALLOWLIST: tuple[str, ...] = (
"api.anthropic.com",
"statsig.anthropic.com",
"sentry.io",
"claude.ai",
"platform.claude.com",
"downloads.claude.ai",
"raw.githubusercontent.com",
)
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
# enabled. The Claude API endpoint is an LLM provider — its request
# bodies are user-authored conversation text that legitimately can
@@ -64,43 +57,51 @@ def pipelock_bottle_allowlist(bottle: Bottle) -> list[str]:
def pipelock_route_hosts(bottle: Bottle) -> list[str]:
"""Hostnames declared in `bottle.egress_proxy.routes`. Returned
sorted + deduped.
Post-cutover topology (PRD 0017): the agent's HTTPS_PROXY points
at egress-proxy, not pipelock; egress-proxy's outbound leg sets
`HTTPS_PROXY=pipelock`. So pipelock no longer terminates the
agent's connections — it sees the egress-proxy → upstream leg
only. Each declared route's host still needs to be on pipelock's
allowlist so that leg can leave the egress network."""
sorted + deduped. Used by the no-egress-proxy fallback path
below; bottles that DO use egress-proxy include the same hosts
via `egress_proxy_routes_for_bottle`."""
hosts = {r.Host for r in bottle.egress_proxy.routes if r.Host}
return sorted(hosts)
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
the egress-proxy route hosts (from bottle.egress_proxy.routes), the
egress-proxy sidecar's own hostname when any route is declared, and
the supervise sidecar's hostname when bottle.supervise is enabled.
Sorted for stability. Git upstreams declared in `bottle.git` do NOT
contribute here git traffic flows through the per-agent git-gate
sidecar (PRD 0008), not pipelock.
"""Hostnames pipelock allows. Sorted for stability.
The egress-proxy + supervise hostnames are auto-added because the
sidecars sit on the bottle's internal network alongside the agent;
requests that pass through pipelock for `egress-proxy:9099` or
`supervise:9100` (e.g. when egress-proxy uses HTTPS_PROXY=pipelock
on its upstream leg) would otherwise be 403'd by pipelock's
hostname gate."""
Two paths, depending on whether the bottle uses egress-proxy:
- Bottle declares `egress_proxy.routes[]` agent's HTTPS_PROXY
points at egress-proxy. Egress-proxy is the bottle's primary
allowlist gate (DEFAULT_ALLOWLIST + bottle.egress.allowlist +
manifest routes all live there as bare-pass or full routes,
folded in by `egress_proxy_routes_for_bottle`). Pipelock's
allowlist is then a MIRROR of egress-proxy's hosts — same
set, just serving as the defense-in-depth hostname gate +
DLP scanner on the upstream leg.
- Bottle has no `egress_proxy.routes[]` agent talks straight
to pipelock. Pipelock keeps its previous behavior: bake in
DEFAULT_ALLOWLIST + bottle.egress.allowlist for claude-code
defaults.
The supervise sidecar's hostname is auto-added when supervise
is enabled (sibling-sidecar traffic that flows through pipelock
would otherwise be 403'd). Git upstreams declared in
`bottle.git` do NOT contribute here git traffic flows
through git-gate (PRD 0008), not pipelock."""
seen: dict[str, None] = {}
for h in DEFAULT_ALLOWLIST:
seen.setdefault(h, None)
for h in pipelock_bottle_allowlist(bottle):
if h:
seen.setdefault(h, None)
for h in pipelock_route_hosts(bottle):
seen.setdefault(h, None)
if bottle.egress_proxy.routes:
seen.setdefault(EGRESS_PROXY_HOSTNAME, None)
# Mirror egress-proxy's effective host set — same defaults
# and bottle.egress.allowlist entries are already folded in
# at the egress-proxy layer; we don't add them twice.
for r in egress_proxy_routes_for_bottle(bottle):
if r.host:
seen.setdefault(r.host, None)
else:
for h in DEFAULT_ALLOWLIST:
seen.setdefault(h, None)
for h in pipelock_bottle_allowlist(bottle):
if h:
seen.setdefault(h, None)
if bottle.supervise:
seen.setdefault(SUPERVISE_HOSTNAME, None)
return sorted(seen.keys())
+38 -31
View File
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (claude_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the
agent calls when it hits a stuck-recovery category:
* cred-proxy-block agent proposes a new routes.json
* egress-proxy-block agent proposes a new routes.yaml
* pipelock-block agent proposes a new pipelock allowlist
* capability-block agent proposes a new agent Dockerfile
@@ -49,21 +49,33 @@ from pathlib import Path
SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100
TOOL_CRED_PROXY_BLOCK = "cred-proxy-block"
TOOL_EGRESS_PROXY_BLOCK = "egress-proxy-block"
TOOL_PIPELOCK_BLOCK = "pipelock-block"
TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_LIST_EGRESS_PROXY_ROUTES = "list-egress-proxy-routes"
TOOLS: tuple[str, ...] = (
TOOL_CRED_PROXY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK,
TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK,
TOOL_LIST_EGRESS_PROXY_ROUTES,
)
# The supervise sidecar uses these to query egress-proxy's
# introspection endpoint for the `list-egress-proxy-routes` MCP
# tool. The hostname + port match egress-proxy's docker network
# alias + listen port (see claude_bottle.egress_proxy.EGRESS_PROXY_HOSTNAME
# and backend.docker.egress_proxy.EGRESS_PROXY_PORT — the values
# are inlined here so the in-container supervise_server doesn't
# need to import the egress-proxy package).
EGRESS_PROXY_FORWARD_PROXY = "http://egress-proxy:9099"
EGRESS_PROXY_INTROSPECT_URL = "http://_egress-proxy.local/allowlist"
# capability-block has no on-disk config the operator edits in place
# (the Dockerfile is rebuilt, not patched), so it has no audit log
# here — those changes are captured by git history + the rebuild
# record laid down in PRD 0016.
COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_CRED_PROXY_BLOCK: "cred-proxy",
TOOL_EGRESS_PROXY_BLOCK: "egress-proxy",
TOOL_PIPELOCK_BLOCK: "pipelock",
}
@@ -422,11 +434,15 @@ def sha256_hex(content: str) -> str:
# --- Sidecar plan + abstract lifecycle -------------------------------------
# Filenames inside the per-bottle current-config dir. The agent reads
# these (read-only) from CURRENT_CONFIG_DIR_IN_AGENT and proposes
# modified versions back via the three MCP tools.
CURRENT_CONFIG_ROUTES = "routes.json"
CURRENT_CONFIG_ALLOWLIST = "allowlist"
# Filename of the staged Dockerfile inside the agent's read-only
# current-config mount. The capability-block tool's description
# points the agent at this exact path so it can read the current
# Dockerfile and propose modifications.
#
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
# moved them behind the `list-egress-proxy-routes` MCP tool (live
# state from egress-proxy's introspection endpoint) so the agent
# always sees current data rather than a launch-time snapshot.
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
@@ -436,12 +452,12 @@ class SupervisePlan:
`queue_dir` is the host directory bind-mounted into the sidecar
at /run/supervise/queue. `current_config_dir` is the host
directory bind-mounted (read-only) into the *agent* container at
/etc/claude-bottle/current-config, holding routes.json + allowlist
+ Dockerfile so the agent can read them before composing a
proposal. `internal_network` is empty at prepare time; the
backend's launch step fills it via dataclasses.replace before
calling .start."""
directory bind-mounted (read-only) into the *agent* container
at /etc/claude-bottle/current-config currently holds only the
Dockerfile snapshot (routes.yaml + allowlist moved to the
`list-egress-proxy-routes` MCP tool). `internal_network` is
empty at prepare time; the backend's launch step fills it via
dataclasses.replace before calling .start."""
slug: str
queue_dir: Path
@@ -459,8 +475,6 @@ class Supervise(ABC):
slug: str,
stage_dir: Path,
*,
routes_content: str = "",
allowlist_content: str = "",
dockerfile_content: str = "",
) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the
@@ -471,17 +485,9 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True)
(current_config_dir / CURRENT_CONFIG_ROUTES).write_text(
routes_content or '{"routes": []}\n'
)
(current_config_dir / CURRENT_CONFIG_ALLOWLIST).write_text(allowlist_content)
(current_config_dir / CURRENT_CONFIG_DOCKERFILE).write_text(dockerfile_content)
for name in (
CURRENT_CONFIG_ROUTES,
CURRENT_CONFIG_ALLOWLIST,
CURRENT_CONFIG_DOCKERFILE,
):
(current_config_dir / name).chmod(0o644)
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
dockerfile_path.write_text(dockerfile_content)
dockerfile_path.chmod(0o644)
return SupervisePlan(
slug=slug,
queue_dir=queue_dir,
@@ -548,10 +554,8 @@ __all__ = [
"ACTION_OPERATOR_EDIT",
"AuditEntry",
"COMPONENT_FOR_TOOL",
"CURRENT_CONFIG_ALLOWLIST",
"CURRENT_CONFIG_DIR_IN_AGENT",
"CURRENT_CONFIG_DOCKERFILE",
"CURRENT_CONFIG_ROUTES",
"DEFAULT_POLL_INTERVAL_SEC",
"Proposal",
"QUEUE_DIR_IN_CONTAINER",
@@ -565,8 +569,11 @@ __all__ = [
"Supervise",
"SupervisePlan",
"TOOLS",
"EGRESS_PROXY_FORWARD_PROXY",
"EGRESS_PROXY_INTROSPECT_URL",
"TOOL_CAPABILITY_BLOCK",
"TOOL_CRED_PROXY_BLOCK",
"TOOL_EGRESS_PROXY_BLOCK",
"TOOL_LIST_EGRESS_PROXY_ROUTES",
"TOOL_PIPELOCK_BLOCK",
"archive_proposal",
"audit_dir",
+230 -61
View File
@@ -1,6 +1,6 @@
"""Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing three tools `cred-proxy-block`,
Per-bottle MCP server exposing three tools `egress-proxy-block`,
`pipelock-block`, `capability-block` that the agent calls to
propose config changes when stuck. Each tool call:
@@ -36,7 +36,9 @@ import os
import socketserver
import sys
import typing
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
@@ -128,48 +130,102 @@ def jsonrpc_error(request_id: object, code: int, message: str) -> bytes:
TOOL_DEFINITIONS: list[dict[str, object]] = [
{
"name": _sv.TOOL_CRED_PROXY_BLOCK,
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
"description": (
"Call when cred-proxy refused your HTTPS request — missing "
"route, expired token, wrong scope (typically a 403 or a "
"404 from `http://cred-proxy:<port>/<path>/`). Read the "
"current routes.json from "
"/etc/claude-bottle/current-config/routes.json, compose a "
"modified version with the route you need, and pass the "
"full new file plus a justification. The operator approves "
"or rejects in the supervise TUI. On approval the supervisor "
"writes the new routes.json on the host and SIGHUPs cred-proxy "
"(wired in PRD 0014; in the v1 supervise foundation the "
"approval is acknowledged but no config change runs)."
"Call when egress-proxy refused your HTTPS request — host "
"without a matching route, or a path outside the route's "
"path_allowlist (typically a 403 from the proxy). Propose "
"a SINGLE route to add: the host you need + (optionally) "
"a path_allowlist + (optionally) an auth block. The "
"supervisor merges the route into the live table at "
"approval time — you do NOT need to see or reproduce the "
"existing routes, and you do not pass a full routes file. "
"If the host already has a route, the proposed "
"path_allowlist entries are unioned with the existing "
"ones (host stays single-route). The operator approves "
"or rejects in the supervise TUI. On approval the "
"supervisor writes the merged routes.yaml, SIGHUPs "
"egress-proxy (atomic swap, no dropped connections), and "
"mirrors the host onto pipelock's allowlist for the "
"downstream gate."
),
"inputSchema": {
"type": "object",
"properties": {
"routes": {
"host": {
"type": "string",
"description": "Full proposed routes.json file content (JSON text).",
"description": "The hostname to allow (e.g. 'api.github.com'). Case-insensitive on match.",
},
"path_allowlist": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional URL path prefixes the route permits. "
"Each must start with '/'. Omit to allow all "
"paths under this host (bare-pass route)."
),
},
"auth": {
"type": "object",
"description": (
"Optional credential injection. {scheme, "
"token_ref}: scheme is 'Bearer' or 'token'; "
"token_ref names the host env var holding the "
"secret value. Omit to add a host without "
"credential injection. Ignored if the host "
"already has a route (operator decides auth "
"changes, not the agent)."
),
"properties": {
"scheme": {"type": "string"},
"token_ref": {"type": "string"},
},
"required": ["scheme", "token_ref"],
"additionalProperties": False,
},
"justification": {
"type": "string",
"description": "Why this routes change is justified.",
"description": "Why this host needs to be allowed.",
},
},
"required": ["routes", "justification"],
"required": ["host", "justification"],
},
},
{
"name": _sv.TOOL_LIST_EGRESS_PROXY_ROUTES,
"description": (
"List the current egress-proxy route table — the bottle's "
"primary egress allowlist. Returns JSON with one entry "
"per allowed host, each carrying its path_allowlist (if "
"any) and whether the proxy injects Authorization for "
"the route. Use this before composing an "
"`egress-proxy-block` proposal so the new routes file "
"extends the live one rather than replacing it. "
"Pipelock's allowlist is a mirror of this set — every "
"host listed here is also reachable through pipelock's "
"downstream hostname gate."
),
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
{
"name": _sv.TOOL_PIPELOCK_BLOCK,
"description": (
"Call when pipelock refused your outbound request — host "
"not in the allowlist, connection refused at the egress "
"layer. Pass the full URL you tried to hit (scheme + "
"host + path) plus a justification. The supervisor "
"extracts the hostname and merges it into the bottle's "
"current pipelock allowlist; the path is captured as "
"context for the operator to review (pipelock's allowlist "
"is hostname-only — it can't enforce path-level rules). "
"On approval the supervisor restarts pipelock with the "
"merged allowlist."
"Call when pipelock refused your outbound request and "
"the failing host is genuinely missing from the bottle's "
"allowlist (vs. blocked for DLP reasons — those need a "
"different remediation). In practice pipelock's allowlist "
"is now a mirror of the egress-proxy routes set by "
"`egress-proxy-block`, so prefer that tool when you want "
"to add a host. This tool stays available for the rare "
"case where pipelock and egress-proxy have diverged. "
"Pass the full URL you tried to hit (scheme + host + "
"path); the supervisor extracts the hostname and merges "
"it into pipelock's allowlist. On approval the "
"supervisor restarts pipelock."
),
"inputSchema": {
"type": "object",
@@ -226,15 +282,22 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
# tool-specific payload (stored in Proposal.proposed_file as
# free-form text the apply path interprets per tool).
#
# cred-proxy-block: full proposed routes.json
# pipelock-block: the full failed URL (scheme + host + path) —
# supervisor extracts the host, merges into the
# bottle's current allowlist; the path is shown
# to the operator for context (pipelock doesn't
# do path-level matching).
# capability-block: full proposed Dockerfile
# egress-proxy-block: JSON object describing a SINGLE route to
# add — `{host, path_allowlist?, auth?}`. The
# supervisor merges this into the live routes
# file at approval time.
# pipelock-block: the full failed URL (scheme + host + path) —
# supervisor extracts the host, merges into the
# bottle's current allowlist; the path is shown
# to the operator for context (pipelock doesn't
# do path-level matching).
# capability-block: full proposed Dockerfile
#
# Egress-proxy-block doesn't use a single "field name" → the JSON
# payload is constructed from multiple structured input fields in
# `handle_egress_proxy_block`. The mapping stays one-entry-per-tool
# so the generic dispatch keeps working for the other two.
PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_CRED_PROXY_BLOCK: "routes",
_sv.TOOL_PIPELOCK_BLOCK: "failed_url",
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
}
@@ -243,26 +306,18 @@ PROPOSED_FILE_FIELD: dict[str, str] = {
# --- Validation ------------------------------------------------------------
# Auth schemes accepted on egress-proxy-block proposals — match the
# manifest-side EGRESS_PROXY_AUTH_SCHEMES.
_AUTH_SCHEMES = ("Bearer", "token")
def validate_proposed_file(tool: str, content: str) -> None:
"""Syntactic validation. The operator is the real gate; this just
catches obvious paste-errors / wrong-tool selections before they
enter the queue."""
if not content.strip():
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
if tool == _sv.TOOL_CRED_PROXY_BLOCK:
try:
parsed = json.loads(content)
except json.JSONDecodeError as e:
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: proposed routes.json is not valid JSON: {e}",
) from e
if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: proposed routes.json must be an object with a 'routes' array",
)
elif tool == _sv.TOOL_PIPELOCK_BLOCK:
if tool == _sv.TOOL_PIPELOCK_BLOCK:
# `content` is the full failed URL. Require scheme + host so
# the supervisor can extract a hostname for the allowlist
# merge; the path is preserved for operator context.
@@ -286,6 +341,70 @@ def validate_proposed_file(tool: str, content: str) -> None:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
def _validate_and_bundle_egress_route(
args: dict[str, object],
) -> str:
"""Validate egress-proxy-block input fields and bundle them into
a JSON string that becomes the Proposal.proposed_file. Raises
_RpcError on bad input the agent retries with a fixed shape."""
tool = _sv.TOOL_EGRESS_PROXY_BLOCK
host = args.get("host")
if not isinstance(host, str) or not host.strip():
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: 'host' is required and must be a non-empty string",
)
payload: dict[str, object] = {"host": host}
path_allow_raw = args.get("path_allowlist")
if path_allow_raw is not None:
if not isinstance(path_allow_raw, list):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: 'path_allowlist' must be an array of strings",
)
prefixes: list[str] = []
for i, p in enumerate(path_allow_raw):
if not isinstance(p, str):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: path_allowlist[{i}] must be a string",
)
if not p.startswith("/"):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: path_allowlist[{i}] {p!r} must start with '/'",
)
prefixes.append(p)
if prefixes:
payload["path_allowlist"] = prefixes
auth_raw = args.get("auth")
if auth_raw is not None:
if not isinstance(auth_raw, dict):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: 'auth' must be an object with 'scheme' and 'token_ref'",
)
scheme = auth_raw.get("scheme")
token_ref = auth_raw.get("token_ref")
if not isinstance(scheme, str) or scheme not in _AUTH_SCHEMES:
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: auth.scheme must be one of "
f"{', '.join(_AUTH_SCHEMES)} (got {scheme!r})",
)
if not isinstance(token_ref, str) or not token_ref:
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: auth.token_ref must be a non-empty string "
f"naming the host env var holding the token",
)
payload["auth"] = {"scheme": scheme, "token_ref": token_ref}
return json.dumps(payload, indent=2) + "\n"
# --- MCP handlers ----------------------------------------------------------
@@ -307,36 +426,86 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
return {"tools": TOOL_DEFINITIONS}
def handle_list_egress_proxy_routes(
_params: dict[str, object],
_config: ServerConfig,
) -> dict[str, object]:
"""Fetch the live egress-proxy route table via its
`_egress-proxy.local/allowlist` introspection endpoint. The
request goes through egress-proxy as a forward proxy; the
addon recognises the magic host and synthesizes a response
no real upstream connection, no allowlist enforcement
against the magic host. Returns the JSON payload as the
tool's text content."""
proxy_handler = urllib.request.ProxyHandler({
"http": _sv.EGRESS_PROXY_FORWARD_PROXY,
})
opener = urllib.request.build_opener(proxy_handler)
try:
with opener.open(_sv.EGRESS_PROXY_INTROSPECT_URL, timeout=5) as resp:
body = resp.read().decode("utf-8")
except (urllib.error.URLError, OSError) as e:
return {
"content": [{
"type": "text",
"text": (
f"list-egress-proxy-routes: could not reach "
f"{_sv.EGRESS_PROXY_INTROSPECT_URL!r} via "
f"{_sv.EGRESS_PROXY_FORWARD_PROXY!r}: {e}"
),
}],
"isError": True,
}
return {
"content": [{"type": "text", "text": body}],
"isError": False,
}
def handle_tools_call(
params: dict[str, object],
config: ServerConfig,
) -> dict[str, object]:
"""Validates the proposal, writes it to the queue, blocks waiting
for a Response, returns the result wrapped in MCP `content`."""
for a Response, returns the result wrapped in MCP `content`.
Side-effect-free `list-*` tools short-circuit before the queue/
blocking machinery they're read-only introspection that
doesn't need operator approval."""
name = params.get("name")
if not isinstance(name, str):
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
if name not in PROPOSED_FILE_FIELD:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
if name == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES:
return handle_list_egress_proxy_routes(params.get("arguments", {}), config)
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
raise _RpcError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
file_field = PROPOSED_FILE_FIELD[name]
proposed_file = args_raw.get(file_field)
justification = args_raw.get("justification")
if not isinstance(proposed_file, str):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{name}: '{file_field}' is required and must be a string",
)
if not isinstance(justification, str) or not justification.strip():
raise _RpcError(
ERR_INVALID_PARAMS,
f"{name}: 'justification' is required and must be a non-empty string",
)
validate_proposed_file(name, proposed_file)
if name == _sv.TOOL_EGRESS_PROXY_BLOCK:
# Structured input → JSON bundle on Proposal.proposed_file.
# The dashboard's apply step (egress_proxy_apply.add_route)
# parses this JSON, fetches the current routes, merges in
# the new one, and writes the merged file.
proposed_file = _validate_and_bundle_egress_route(args_raw)
elif name in PROPOSED_FILE_FIELD:
file_field = PROPOSED_FILE_FIELD[name]
proposed_file = args_raw.get(file_field)
if not isinstance(proposed_file, str):
raise _RpcError(
ERR_INVALID_PARAMS,
f"{name}: '{file_field}' is required and must be a string",
)
validate_proposed_file(name, proposed_file)
else:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
proposal = _sv.Proposal.new(
bottle_slug=config.bottle_slug,
@@ -505,7 +674,7 @@ def serve(
def main(argv: list[str]) -> int:
del argv # config is env-only, matches cred_proxy_server pattern
del argv # config is env-only, no CLI flags
bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
if not bottle_slug:
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
+9 -1
View File
@@ -1,8 +1,16 @@
# PRD 0010: Credential proxy for agent-bound API tokens
- **Status:** Draft
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
- **Author:** didericis
- **Created:** 2026-05-13
- **Superseded:** 2026-05-25
> **Historical reference only.** The cred-proxy sidecar this PRD
> describes was replaced by the egress-proxy sidecar (PRD 0017) in
> a hard cutover. The auth-injection role moved over largely intact;
> path-prefix routing is replaced by universal MITM at the agent's
> HTTP_PROXY. See PRD 0017's "Migration — hard cutover" section for
> the field-by-field manifest rename.
## Summary
+13 -1
View File
@@ -1,11 +1,23 @@
# PRD 0014: cred-proxy block remediation
- **Status:** Draft
- **Status:** Retargeted by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
- **Author:** didericis
- **Created:** 2026-05-25
- **Retargeted:** 2026-05-25
- **Parent:** PRD 0012
- **Depends on:** PRD 0013
> **Retarget notice.** The remediation shape this PRD describes (MCP
> tool → operator approve → SIGHUP a sidecar) is intact, but the
> sidecar moved: cred-proxy is gone, replaced by egress-proxy under
> PRD 0017. The MCP tool is now named `egress-proxy-block`; the
> proposed file is `routes.yaml` (JSON content) in egress-proxy's
> route shape (host + path_allowlist + nested `auth` block); the
> apply path docker-cps + SIGHUPs egress-proxy. The audit-log
> component label changed from `cred-proxy` to `egress-proxy`.
> Operator-initiated `routes edit <bottle>` still exists with the
> same UX, now pointed at the egress-proxy sidecar.
## Summary
Wires the **cred-proxy block** path (PRD 0012 *Stuck categories*) end-to-end. cred-proxy gains SIGHUP-based hot reload of `routes.json`. The supervisor, on approval of a `cred-proxy-block` proposal, writes the new `routes.json` to the host and SIGHUPs cred-proxy — no restart, no dropped connections. The TUI gains a proactive `routes edit <bottle>` verb for operator-initiated edits unrelated to a tool call. The cred-proxy audit log (format defined in PRD 0013) is filled in with real entries on every edit.
+33 -23
View File
@@ -196,36 +196,47 @@ class TestSuperviseSidecar(unittest.TestCase):
names = {t["name"] for t in result["result"]["tools"]}
self.assertEqual(
{
_sv.TOOL_CRED_PROXY_BLOCK,
_sv.TOOL_EGRESS_PROXY_BLOCK,
_sv.TOOL_PIPELOCK_BLOCK,
_sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_LIST_EGRESS_PROXY_ROUTES,
},
names,
)
def test_tools_call_round_trips_through_queue(self):
"""End-to-end: agent in the bottle calls cred-proxy-block;
the call blocks on the queue; the host rejects via the
dashboard helpers; the agent receives the rejection.
"""End-to-end: agent in the bottle calls egress-proxy-block;
the call blocks on the queue; the host approves via the
dashboard helpers; the agent receives the approval.
PRD 0017 chunk 2 deleted the cred-proxy sidecar, so the
approval-apply path on cred-proxy-block is broken in this
intermediate state (chunk 3 retargets it at egress-proxy and
restores the round-trip approval test). For now this verifies
only the queue + response leg by exercising the reject path
no docker-exec into a sidecar needed."""
This test focuses on the supervise sidecar's queue + response
plumbing, not the egress-proxy apply path itself. The apply
function is stubbed so we don't need to bring up a real
egress-proxy sidecar (its docker lifecycle has its own
integration coverage)."""
self._require_bind_mount_sharing()
self._bring_up_sidecar()
# Stub the apply step. The dashboard's approve() calls
# add_route to docker-exec into the egress-proxy sidecar;
# this test isn't exercising the real sidecar, so patch it
# to a no-op that returns plausible before/after strings
# the audit-log writer can render.
from claude_bottle.cli import dashboard as _dash
original_apply = _dash.add_route
_dash.add_route = (
lambda slug, new: ("(stubbed before)", new)
)
captured: dict[str, object] = {}
def caller() -> None:
captured["response"] = self._curl_jsonrpc({
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {
"name": _sv.TOOL_CRED_PROXY_BLOCK,
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
"arguments": {
"routes": '{"routes": [{"path": "/x/"}]}',
"host": "api.example.com",
"justification": "integration test",
},
},
@@ -249,16 +260,17 @@ class TestSuperviseSidecar(unittest.TestCase):
self.assertIsNotNone(qp, "proposal never appeared in queue")
assert qp is not None # type-narrowing
self.assertEqual(
_sv.TOOL_CRED_PROXY_BLOCK, qp.proposal.tool,
_sv.TOOL_EGRESS_PROXY_BLOCK, qp.proposal.tool,
)
self.assertEqual("integration test", qp.proposal.justification)
# Reject via the dashboard helper. The reject path skips
# the sidecar-apply step, so it works without a real
# cred-proxy sidecar (which doesn't exist in chunk 2's
# transitional state).
dashboard.reject(qp, reason="no real cred-proxy in chunk 2")
# Approve via the dashboard helper. The apply step (now
# stubbed) would docker-exec into the egress-proxy sidecar
# and SIGHUP it. The supervise sidecar sees the response
# file and returns to the curl caller.
dashboard.approve(qp, notes="lgtm from integration test")
finally:
_dash.add_route = original_apply
t.join(timeout=20)
response = captured.get("response")
@@ -267,12 +279,10 @@ class TestSuperviseSidecar(unittest.TestCase):
self.assertEqual(7, response["id"])
result = response["result"]
assert isinstance(result, dict)
# Rejected tool calls surface as MCP errors so the agent
# treats them as failures (not silent successes).
self.assertTrue(result.get("isError"))
self.assertFalse(result.get("isError"))
text = result["content"][0]["text"]
self.assertIn("rejected", text)
self.assertIn("no real cred-proxy", text)
self.assertIn("status: approved", text)
self.assertIn("notes: lgtm from integration test", text)
def test_orphan_sidecar_name_collision_recovered(self):
"""An orphan supervise sidecar from a previous run blocks
-39
View File
@@ -1,39 +0,0 @@
"""Unit: validate_routes_json (PRD 0014 Phase 2). docker exec / cp /
kill paths are covered by the integration test."""
import unittest
from claude_bottle.backend.docker.cred_proxy_apply import (
CredProxyApplyError,
validate_routes_json,
)
class TestValidateRoutesJson(unittest.TestCase):
def test_accepts_routes_array(self):
validate_routes_json('{"routes": []}')
validate_routes_json(
'{"routes": [{"path": "/a/", "upstream": "https://example.com",'
' "auth_scheme": "Bearer", "token_env": "T0"}]}'
)
def test_rejects_bad_json(self):
with self.assertRaises(CredProxyApplyError) as cm:
validate_routes_json("{not json")
self.assertIn("not valid JSON", str(cm.exception))
def test_rejects_non_object_top_level(self):
with self.assertRaises(CredProxyApplyError):
validate_routes_json("[]")
def test_rejects_missing_routes_key(self):
with self.assertRaises(CredProxyApplyError):
validate_routes_json('{"other": []}')
def test_rejects_non_list_routes(self):
with self.assertRaises(CredProxyApplyError):
validate_routes_json('{"routes": "not a list"}')
if __name__ == "__main__":
unittest.main()
+69 -58
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from claude_bottle import supervise
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
from claude_bottle.backend.docker.cred_proxy_apply import CredProxyApplyError
from claude_bottle.backend.docker.egress_proxy_apply import EgressProxyApplyError
from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError
from claude_bottle.cli import dashboard
from claude_bottle.supervise import (
@@ -26,7 +26,7 @@ from claude_bottle.supervise import (
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK,
TOOL_CRED_PROXY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK,
TOOL_PIPELOCK_BLOCK,
read_audit_entries,
read_response,
@@ -37,13 +37,13 @@ from claude_bottle.supervise import (
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(slug: str = "dev", tool: str = TOOL_CRED_PROXY_BLOCK) -> Proposal:
# Per-tool payload shape: cred-proxy gets routes.json, pipelock
def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_PROXY_BLOCK) -> Proposal:
# Per-tool payload shape: cred-proxy gets routes.yaml, pipelock
# gets a failed URL (PR #25 follow-up), capability gets a
# Dockerfile-ish blob. Match the production dispatch in
# PROPOSED_FILE_FIELD.
payloads = {
TOOL_CRED_PROXY_BLOCK: '{"routes": []}\n',
TOOL_EGRESS_PROXY_BLOCK: '{"routes": []}\n',
TOOL_PIPELOCK_BLOCK: "https://example.com/path",
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
}
@@ -95,13 +95,13 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
def test_sorted_by_arrival_across_bottles(self):
early = Proposal.new(
bottle_slug="api", tool=TOOL_CRED_PROXY_BLOCK,
bottle_slug="api", tool=TOOL_EGRESS_PROXY_BLOCK,
proposed_file="{}", justification="early",
current_file_hash="h",
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
)
late = Proposal.new(
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
proposed_file="{}", justification="late",
current_file_hash="h",
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
@@ -127,14 +127,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
self._original_apply_routes = dashboard.apply_routes_change
self._original_add_route = dashboard.add_route
self._original_apply_allowlist = dashboard.apply_allowlist_change
self._original_fetch_allowlist = dashboard.fetch_current_allowlist
self._original_apply_capability = dashboard.apply_capability_change
# Default stubs: succeed with deterministic before/after so the
# audit log shows a non-empty diff.
dashboard.apply_routes_change = lambda slug, content: (
'{"routes": []}\n', content,
dashboard.add_route = lambda slug, content: (
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n',
)
dashboard.apply_allowlist_change = lambda slug, content: (
"old.example\n", content,
@@ -145,13 +145,13 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
)
def tearDown(self):
dashboard.apply_routes_change = self._original_apply_routes
dashboard.add_route = self._original_add_route
dashboard.apply_allowlist_change = self._original_apply_allowlist
dashboard.fetch_current_allowlist = self._original_fetch_allowlist
dashboard.apply_capability_change = self._original_apply_capability
self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_CRED_PROXY_BLOCK):
def _enqueue(self, tool: str = TOOL_EGRESS_PROXY_BLOCK):
p = _proposal(tool=tool)
qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True)
@@ -164,7 +164,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_APPROVED, resp.status)
self.assertIsNone(resp.final_file)
entries = read_audit_entries("cred-proxy", "dev")
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual(1, len(entries))
self.assertEqual("approved", entries[0].operator_action)
@@ -175,7 +175,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual(STATUS_MODIFIED, resp.status)
self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file)
self.assertEqual("tweaked", resp.notes)
entries = read_audit_entries("cred-proxy", "dev")
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual("modified", entries[0].operator_action)
def test_reject_writes_rejection(self):
@@ -184,7 +184,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status)
self.assertEqual("nope", resp.notes)
entries = read_audit_entries("cred-proxy", "dev")
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual("rejected", entries[0].operator_action)
self.assertEqual("nope", entries[0].operator_notes)
@@ -193,32 +193,32 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
dashboard.approve(qp)
# No audit log for capability-block (per PRD 0013 / 0016).
# cred-proxy and pipelock logs both empty.
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_pipelock_audit_distinct_from_cred_proxy(self):
def test_pipelock_audit_distinct_from_egress_proxy(self):
qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK)
dashboard.approve(qp)
self.assertEqual(1, len(read_audit_entries("pipelock", "dev")))
self.assertEqual(0, len(read_audit_entries("cred-proxy", "dev")))
self.assertEqual(0, len(read_audit_entries("egress-proxy", "dev")))
class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0014 Phase 3: approve() on a cred-proxy-block proposal
must call apply_routes_change with the right args and surface
its failures."""
class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0017 chunk 3: approve() on an egress-proxy-block proposal
must call add_route (single-route merge) with the right args
and surface its failures."""
def setUp(self):
self._setup_fake_home()
self._original_apply = dashboard.apply_routes_change
self._original_add_route = dashboard.add_route
def tearDown(self):
dashboard.apply_routes_change = self._original_apply
dashboard.add_route = self._original_add_route
self._teardown_fake_home()
def _enqueue_cred_proxy(self, proposed: str = '{"routes": []}\n'):
def _enqueue_egress_proxy(self, proposed: str = '{"host": "x.example"}\n'):
p = Proposal.new(
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
proposed_file=proposed,
justification="need a route",
current_file_hash=sha256_hex(proposed),
@@ -229,33 +229,44 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
def test_cred_proxy_block_calls_apply_with_proposed_file(self):
def test_egress_proxy_block_calls_add_route_with_proposed_json(self):
calls = []
dashboard.apply_routes_change = lambda slug, content: (
calls.append((slug, content)) or ("before", content)
dashboard.add_route = lambda slug, content: (
calls.append((slug, content)) or ("before", "after")
)
qp = self._enqueue_egress_proxy(
proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n'
)
qp = self._enqueue_cred_proxy(proposed='{"routes": [{"path": "/new/"}]}\n')
dashboard.approve(qp)
self.assertEqual(1, len(calls))
slug, content = calls[0]
self.assertEqual("dev", slug)
self.assertEqual('{"routes": [{"path": "/new/"}]}\n', content)
def test_modify_passes_final_file_to_apply(self):
calls = []
dashboard.apply_routes_change = lambda slug, content: (
calls.append(content) or ("before", content)
# The single-route JSON the agent proposed reaches add_route
# unchanged — add_route fetches current state + merges.
self.assertEqual(
'{"host": "new.example", "path_allowlist": ["/x/"]}\n',
content,
)
qp = self._enqueue_cred_proxy()
dashboard.approve(qp, final_file='{"routes": [{"path": "/edited/"}]}\n', notes="tweaked")
self.assertEqual(['{"routes": [{"path": "/edited/"}]}\n'], calls)
def test_modify_passes_final_file_to_add_route(self):
calls = []
dashboard.add_route = lambda slug, content: (
calls.append(content) or ("before", "after")
)
qp = self._enqueue_egress_proxy()
dashboard.approve(
qp,
final_file='{"host": "edited.example"}\n',
notes="tweaked",
)
self.assertEqual(['{"host": "edited.example"}\n'], calls)
def test_apply_failure_blocks_response_and_audit(self):
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
CredProxyApplyError("docker exec failed")
dashboard.add_route = lambda slug, content: (_ for _ in ()).throw(
EgressProxyApplyError("docker exec failed")
)
qp = self._enqueue_cred_proxy()
with self.assertRaises(CredProxyApplyError):
qp = self._enqueue_egress_proxy()
with self.assertRaises(EgressProxyApplyError):
dashboard.approve(qp)
# No response file (proposal stays pending).
self.assertEqual(
@@ -263,18 +274,18 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
)
# No audit entry.
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
def test_real_diff_lands_in_audit(self):
dashboard.apply_routes_change = lambda slug, content: (
dashboard.add_route = lambda slug, content: (
'{"routes": []}\n', # before
'{"routes": [{"path": "/new/"}]}\n', # after
'{"routes": [{"host": "new.example"}]}\n', # after
)
qp = self._enqueue_cred_proxy(proposed='{"routes": [{"path": "/new/"}]}\n')
qp = self._enqueue_egress_proxy(proposed='{"host": "new.example"}\n')
dashboard.approve(qp)
entries = read_audit_entries("cred-proxy", "dev")
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual(1, len(entries))
self.assertIn('+{"routes": [{"path": "/new/"}]}', entries[0].diff)
self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff)
self.assertIn('-{"routes": []}', entries[0].diff)
def test_reject_does_not_call_apply(self):
@@ -282,13 +293,13 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.apply_routes_change = lambda slug, content: (
called.append(True) or ("", content)
)
qp = self._enqueue_cred_proxy()
qp = self._enqueue_egress_proxy()
dashboard.reject(qp, reason="no thanks")
self.assertEqual([], called)
# Reject still writes a response + audit entry with empty diff.
resp = read_response(qp.queue_dir, qp.proposal.id)
self.assertEqual(STATUS_REJECTED, resp.status)
entries = read_audit_entries("cred-proxy", "dev")
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual(1, len(entries))
self.assertEqual("", entries[0].diff)
@@ -432,7 +443,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.approve(qp)
# capability-block has no audit log per PRD 0013 — its record
# lives in the per-bottle Dockerfile + transcript state.
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_proposal_archived_after_apply(self):
@@ -464,7 +475,7 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
'{"routes": []}\n', content,
)
dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n')
entries = read_audit_entries("cred-proxy", "dev")
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual(1, len(entries))
self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action)
self.assertEqual("", entries[0].justification)
@@ -472,14 +483,14 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
def test_failure_does_not_write_audit(self):
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
CredProxyApplyError("nope")
EgressProxyApplyError("nope")
)
with self.assertRaises(CredProxyApplyError):
with self.assertRaises(EgressProxyApplyError):
dashboard.operator_edit_routes("dev", '{"routes": []}\n')
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
class TestDiscoverCredProxySlugs(unittest.TestCase):
class TestDiscoverEgressProxySlugs(unittest.TestCase):
"""Slug-extraction parsing — exercises only the parsing path; the
docker ps invocation itself is environment-dependent (and tested
implicitly by the integration test)."""
@@ -491,7 +502,7 @@ class TestDiscoverCredProxySlugs(unittest.TestCase):
original = os.environ.get("PATH", "")
os.environ["PATH"] = "/nonexistent-no-docker-here"
try:
self.assertEqual([], dashboard.discover_cred_proxy_slugs())
self.assertEqual([], dashboard.discover_egress_proxy_slugs())
self.assertEqual([], dashboard.discover_pipelock_slugs())
finally:
os.environ["PATH"] = original
+3 -3
View File
@@ -12,7 +12,7 @@ from claude_bottle.cli import dashboard
from claude_bottle.supervise import (
Proposal,
TOOL_CAPABILITY_BLOCK,
TOOL_CRED_PROXY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK,
TOOL_PIPELOCK_BLOCK,
sha256_hex,
)
@@ -46,9 +46,9 @@ class TestPipelockHostHighlight(unittest.TestCase):
green_lines = [text for text, attr in lines if attr == self.GREEN]
self.assertEqual(["api.github.com"], green_lines)
def test_no_green_lines_for_cred_proxy_block(self):
def test_no_green_lines_for_egress_proxy_block(self):
lines = dashboard._detail_lines(
_qp(TOOL_CRED_PROXY_BLOCK, '{"routes": []}'),
_qp(TOOL_EGRESS_PROXY_BLOCK, '{"routes": []}'),
green_attr=self.GREEN,
)
self.assertEqual([], [t for t, a in lines if a == self.GREEN])
+56 -10
View File
@@ -5,6 +5,8 @@ import json
import unittest
from claude_bottle.egress_proxy import (
DEFAULT_ALLOWLIST,
egress_proxy_manifest_routes,
egress_proxy_render_routes,
egress_proxy_resolve_token_values,
egress_proxy_routes_for_bottle,
@@ -27,7 +29,7 @@ class TestRoutesForBottle(unittest.TestCase):
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
}])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
self.assertEqual(1, len(routes))
r = routes[0]
self.assertEqual("api.github.com", r.host)
@@ -38,7 +40,7 @@ class TestRoutesForBottle(unittest.TestCase):
def test_unauthenticated_route_has_empty_auth_fields(self):
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
r = routes[0]
self.assertEqual("", r.auth_scheme)
self.assertEqual("", r.token_env)
@@ -52,7 +54,7 @@ class TestRoutesForBottle(unittest.TestCase):
{"host": "github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}},
])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
slots = {r.token_env for r in routes}
self.assertEqual({"EGRESS_PROXY_TOKEN_0"}, slots)
@@ -63,7 +65,7 @@ class TestRoutesForBottle(unittest.TestCase):
{"host": "b.example",
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
slots = [r.token_env for r in routes]
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], slots)
@@ -77,12 +79,56 @@ class TestRoutesForBottle(unittest.TestCase):
{"host": "b.example",
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
authed = [r.token_env for r in routes if r.token_env]
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], authed)
self.assertEqual("", routes[1].token_env)
class TestRoutesForBottleFoldsDefaults(unittest.TestCase):
"""The effective route table includes DEFAULT_ALLOWLIST +
bottle.egress.allowlist as bare-pass entries pipelock's
allowlist is a mirror of this set."""
def test_defaults_present_when_no_manifest_routes(self):
b = _bottle([])
hosts = [r.host for r in egress_proxy_routes_for_bottle(b)]
for default in DEFAULT_ALLOWLIST:
self.assertIn(default, hosts)
def test_manifest_route_wins_over_default(self):
# api.anthropic.com is in DEFAULT_ALLOWLIST. A manifest
# route for the same host takes precedence — we want the
# auth config to apply, not a duplicate bare-pass entry.
b = _bottle([{
"host": "api.anthropic.com",
"auth": {"scheme": "Bearer", "token_ref": "T"},
}])
routes = egress_proxy_routes_for_bottle(b)
anthropic = [r for r in routes if r.host == "api.anthropic.com"]
self.assertEqual(1, len(anthropic))
self.assertEqual("Bearer", anthropic[0].auth_scheme)
def test_bottle_egress_allowlist_folded_in(self):
m = Manifest.from_json_obj({
"bottles": {"dev": {
"egress_proxy": {"routes": []},
"egress": {"allowlist": ["example.com"]},
}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
hosts = [r.host for r in egress_proxy_routes_for_bottle(m.bottles["dev"])]
self.assertIn("example.com", hosts)
def test_manifest_only_when_no_defaults_or_allowlist(self):
# Sanity: egress_proxy_manifest_routes returns just the
# manifest entries — defaults are added by the
# _routes_for_bottle wrapper.
b = _bottle([{"host": "x.example"}])
manifest = [r.host for r in egress_proxy_manifest_routes(b)]
self.assertEqual(["x.example"], manifest)
class TestTokenEnvMap(unittest.TestCase):
def test_only_authenticated_routes_contribute(self):
b = _bottle([
@@ -90,7 +136,7 @@ class TestTokenEnvMap(unittest.TestCase):
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "passthrough.example"},
])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
m = egress_proxy_token_env_map(routes)
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "T1"}, m)
@@ -105,7 +151,7 @@ class TestRenderRoutes(unittest.TestCase):
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
"path_allowlist": ["/repos/x/"],
}])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
payload = json.loads(egress_proxy_render_routes(routes))
self.assertEqual(
[{
@@ -123,7 +169,7 @@ class TestRenderRoutes(unittest.TestCase):
# enforces both-or-neither, so emitting empty strings would
# round-trip as a partial pair and crash.
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
payload = json.loads(egress_proxy_render_routes(routes))
entry = payload["routes"][0]
self.assertNotIn("auth_scheme", entry)
@@ -134,7 +180,7 @@ class TestRenderRoutes(unittest.TestCase):
"host": "api.anthropic.com",
"auth": {"scheme": "Bearer", "token_ref": "CL"},
}])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
payload = json.loads(egress_proxy_render_routes(routes))
self.assertNotIn("path_allowlist", payload["routes"][0])
@@ -149,7 +195,7 @@ class TestRenderRoutes(unittest.TestCase):
{"host": "github.com", "path_allowlist": ["/x/"]},
{"host": "api.anthropic.com"},
])
routes = egress_proxy_routes_for_bottle(b)
routes = egress_proxy_manifest_routes(b)
addon_routes = load_routes(egress_proxy_render_routes(routes))
self.assertEqual(3, len(addon_routes))
self.assertEqual("Bearer", addon_routes[0].auth_scheme)
+16 -6
View File
@@ -147,18 +147,28 @@ class TestMatchRoute(unittest.TestCase):
# other-host shouldn't be matched via a "ends with" check.
self.assertIsNone(match_route(self.ROUTES, "evil.api.github.com"))
def test_wildcard_hosts_not_supported(self):
# `*.example.com` is treated as a literal host string by
# the exact-only matcher. Removed from the design after
# the apex/RFC-6125/pipelock-mirror edge cases stacked up.
routes = (Route(host="*.example.com"),)
self.assertIsNone(match_route(routes, "foo.example.com"))
self.assertIsNone(match_route(routes, "example.com"))
# --- decide --------------------------------------------------------------
class TestDecide(unittest.TestCase):
def test_no_matching_route_forwards(self):
# Hostnames the operator didn't declare are not the
# egress-proxy's concern; pipelock's hostname allowlist gates
# them downstream.
def test_no_matching_route_blocks(self):
# Defense-in-depth: egress-proxy gates the bottle's allowlist
# too, not just pipelock. Any host the operator didn't declare
# in egress_proxy.routes is 403'd at egress-proxy before it
# ever reaches pipelock.
d = decide((), "elsewhere.example", "/anything", {})
self.assertEqual("forward", d.action)
self.assertIsNone(d.inject_authorization)
self.assertEqual("block", d.action)
self.assertIn("allowlist", d.reason)
self.assertIn("'elsewhere.example'", d.reason)
def test_path_allowlist_match_forwards(self):
d = decide(
+229
View File
@@ -0,0 +1,229 @@
"""Unit: validate_routes_content (PRD 0014 retargeted by PRD 0017
chunk 3). docker exec / cp / kill paths are covered by the
integration test."""
import unittest
import json
from claude_bottle.backend.docker.egress_proxy_apply import (
EgressProxyApplyError,
_hosts_in_routes,
_merge_single_route,
_pipelock_safe_hosts,
validate_routes_content,
)
class TestValidateRoutesContent(unittest.TestCase):
def test_accepts_minimal_route_table(self):
validate_routes_content('{"routes": []}')
validate_routes_content(
'{"routes": [{"host": "api.github.com"}]}'
)
def test_accepts_full_route(self):
validate_routes_content(
'{"routes": [{"host": "api.github.com",'
' "path_allowlist": ["/repos/x/"],'
' "auth_scheme": "Bearer",'
' "token_env": "EGRESS_PROXY_TOKEN_0"}]}'
)
def test_rejects_bad_json(self):
with self.assertRaises(EgressProxyApplyError) as cm:
validate_routes_content("{not json")
self.assertIn("not valid", str(cm.exception))
def test_rejects_non_object_top_level(self):
with self.assertRaises(EgressProxyApplyError):
validate_routes_content("[]")
def test_rejects_missing_routes_key(self):
with self.assertRaises(EgressProxyApplyError):
validate_routes_content('{"other": []}')
def test_rejects_non_list_routes(self):
with self.assertRaises(EgressProxyApplyError):
validate_routes_content('{"routes": "not a list"}')
def test_rejects_partial_auth_pair(self):
# The addon-core parser enforces both-or-neither — the apply
# path picks this up before SIGHUP'ing the sidecar.
with self.assertRaises(EgressProxyApplyError):
validate_routes_content(
'{"routes": [{"host": "x.example",'
' "auth_scheme": "Bearer"}]}'
)
class TestHostsInRoutes(unittest.TestCase):
def test_extracts_each_unique_host(self):
hosts = _hosts_in_routes(
'{"routes": [{"host": "api.github.com"},'
' {"host": "github.com"},'
' {"host": "api.anthropic.com"}]}'
)
# Sorted+deduped.
self.assertEqual(
["api.anthropic.com", "api.github.com", "github.com"],
hosts,
)
def test_dedupes_same_host(self):
hosts = _hosts_in_routes(
'{"routes": [{"host": "x.example", "path_allowlist": ["/a/"]},'
' {"host": "x.example", "path_allowlist": ["/b/"]}]}'
)
self.assertEqual(["x.example"], hosts)
def test_empty_routes_returns_empty(self):
self.assertEqual([], _hosts_in_routes('{"routes": []}'))
def test_invalid_routes_raises(self):
# The mirror helper relies on parsing succeeding; bad input
# should error before pipelock is touched.
with self.assertRaises(EgressProxyApplyError):
_hosts_in_routes('{"routes": [{"path": "/no-host/"}]}')
class TestMergeSingleRoute(unittest.TestCase):
BASE = '{"routes": [{"host": "api.anthropic.com"}]}'
def test_appends_route_when_host_absent(self):
merged = _merge_single_route(self.BASE, {"host": "github.com"})
routes = json.loads(merged)["routes"]
hosts = [r["host"] for r in routes]
self.assertEqual(["api.anthropic.com", "github.com"], hosts)
def test_appends_path_allowlist(self):
merged = _merge_single_route(
self.BASE,
{"host": "github.com", "path_allowlist": ["/repos/x/"]},
)
new_route = json.loads(merged)["routes"][-1]
self.assertEqual(["/repos/x/"], new_route["path_allowlist"])
def test_appends_auth_with_token_env_slot(self):
merged = _merge_single_route(
self.BASE,
{
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"},
},
)
new_route = json.loads(merged)["routes"][-1]
self.assertEqual("Bearer", new_route["auth_scheme"])
# First auth slot when no prior auth routes exist.
self.assertEqual("EGRESS_PROXY_TOKEN_0", new_route["token_env"])
def test_auth_slot_increments_past_existing(self):
base = json.dumps({"routes": [
{"host": "api.anthropic.com",
"auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0"},
]})
merged = _merge_single_route(base, {
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"},
})
new_route = json.loads(merged)["routes"][-1]
self.assertEqual("EGRESS_PROXY_TOKEN_1", new_route["token_env"])
def test_existing_host_merges_path_allowlist_as_union(self):
base = json.dumps({"routes": [
{"host": "github.com", "path_allowlist": ["/a/"]},
]})
merged = _merge_single_route(base, {
"host": "github.com",
"path_allowlist": ["/b/"],
})
routes = json.loads(merged)["routes"]
self.assertEqual(1, len(routes)) # not duplicated
self.assertEqual(["/a/", "/b/"], routes[0]["path_allowlist"])
def test_existing_host_dedup_path_allowlist(self):
base = json.dumps({"routes": [
{"host": "github.com", "path_allowlist": ["/a/"]},
]})
merged = _merge_single_route(base, {
"host": "github.com",
"path_allowlist": ["/a/", "/b/"],
})
self.assertEqual(
["/a/", "/b/"],
json.loads(merged)["routes"][0]["path_allowlist"],
)
def test_existing_host_preserves_existing_auth_ignores_proposed(self):
# Tool docs: auth on an existing host is operator-controlled,
# not agent-controlled. The merge must not overwrite.
base = json.dumps({"routes": [
{"host": "api.github.com",
"auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0"},
]})
merged = _merge_single_route(base, {
"host": "api.github.com",
"auth": {"scheme": "token", "token_ref": "DIFFERENT"},
})
route = json.loads(merged)["routes"][0]
self.assertEqual("Bearer", route["auth_scheme"])
self.assertEqual("EGRESS_PROXY_TOKEN_0", route["token_env"])
def test_host_match_is_case_insensitive(self):
base = json.dumps({"routes": [{"host": "GitHub.com"}]})
merged = _merge_single_route(base, {
"host": "github.com",
"path_allowlist": ["/x/"],
})
routes = json.loads(merged)["routes"]
self.assertEqual(1, len(routes))
self.assertEqual(["/x/"], routes[0]["path_allowlist"])
def test_missing_host_raises(self):
with self.assertRaises(EgressProxyApplyError):
_merge_single_route(self.BASE, {})
def test_invalid_current_yaml_raises(self):
with self.assertRaises(EgressProxyApplyError):
_merge_single_route("{not json", {"host": "x.example"})
class TestPipelockSafeHosts(unittest.TestCase):
def test_passes_normal_hostnames_through(self):
self.assertEqual(
["api.github.com", "registry.npmjs.org"],
_pipelock_safe_hosts(["api.github.com", "registry.npmjs.org"]),
)
def test_drops_wildcards(self):
# Wildcard host matching was removed from egress-proxy too,
# so a `*.foo.com` route is dead weight anyway; we drop it
# entirely from the pipelock mirror so the apply doesn't
# fail parse.
self.assertEqual(
["api.github.com"],
_pipelock_safe_hosts(["*.example.com", "api.github.com"]),
)
def test_drops_bare_wildcard(self):
self.assertEqual([], _pipelock_safe_hosts(["*"]))
def test_drops_ipv6_literals(self):
self.assertEqual(
["api.example.com"],
_pipelock_safe_hosts(["[::1]", "api.example.com"]),
)
def test_preserves_order(self):
self.assertEqual(
["a.example", "b.example", "c.example"],
_pipelock_safe_hosts([
"a.example", "*.junk", "b.example", "weird host", "c.example",
]),
)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -128,6 +128,57 @@ class TestAuth(unittest.TestCase):
}])
class TestRole(unittest.TestCase):
def test_omitted_means_no_roles(self):
b = _bottle([{"host": "x.example"}])
self.assertEqual((), b.egress_proxy.routes[0].Role)
def test_string_normalizes_to_tuple(self):
b = _bottle([{
"host": "api.anthropic.com",
"role": "claude_code_oauth",
"auth": {"scheme": "Bearer", "token_ref": "T"},
}])
self.assertEqual(("claude_code_oauth",),
b.egress_proxy.routes[0].Role)
def test_list_supported(self):
b = _bottle([{
"host": "api.anthropic.com",
"role": ["claude_code_oauth"],
"auth": {"scheme": "Bearer", "token_ref": "T"},
}])
self.assertEqual(("claude_code_oauth",),
b.egress_proxy.routes[0].Role)
def test_unknown_role_rejected(self):
# The role enum is locked down — typos shouldn't silently
# become no-op markers.
with self.assertRaises(Die):
_bottle([{"host": "x.example", "role": "totally-made-up"}])
def test_non_string_role_rejected(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "role": 42}])
def test_list_with_non_string_item_rejected(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example",
"role": ["claude_code_oauth", 42]}])
def test_singleton_claude_code_oauth_enforced(self):
# Two routes both claiming the role would make "which one
# drives the placeholder env?" ambiguous.
with self.assertRaises(Die):
_bottle([
{"host": "api.anthropic.com", "role": "claude_code_oauth",
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "api2.anthropic.example",
"role": "claude_code_oauth",
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
])
class TestRouteValidation(unittest.TestCase):
def test_duplicate_hosts_rejected(self):
# Routes match by exact host; duplicates leave the choice
+17 -8
View File
@@ -67,20 +67,29 @@ class TestAllowlistWithRoutes(unittest.TestCase):
self.assertIn("registry.npmjs.org", eff)
self.assertIn("api.github.com", eff)
def test_egress_proxy_hostname_auto_added_when_routes_exist(self):
# Egress-proxy's outbound leg uses HTTPS_PROXY=pipelock, so
# any request that flows through egress-proxy → pipelock
# would otherwise be rejected by pipelock's hostname gate.
def test_egress_proxy_hostname_NOT_in_pipelock_allowlist(self):
# The agent never dials egress-proxy via the proxy mechanism
# — it IS the proxy. Pipelock receives upstream hostnames
# from egress-proxy's CONNECT requests, not the
# `egress-proxy` hostname itself.
eff = pipelock_effective_allowlist(_bottle(_routes([
{"host": "x.example",
"auth": {"scheme": "Bearer", "token_ref": "T"}},
])))
self.assertIn("egress-proxy", eff)
def test_egress_proxy_hostname_NOT_added_when_no_routes(self):
eff = pipelock_effective_allowlist(_bottle({}))
self.assertNotIn("egress-proxy", eff)
def test_pipelock_mirrors_egress_proxy_defaults_when_routes_present(self):
# When egress_proxy is in use, pipelock's allowlist mirrors
# the egress-proxy effective routes — which fold in
# DEFAULT_ALLOWLIST + bottle.egress.allowlist.
eff = pipelock_effective_allowlist(_bottle(_routes([
{"host": "x.example",
"auth": {"scheme": "Bearer", "token_ref": "T"}},
])))
for default in ("api.anthropic.com", "sentry.io"):
self.assertIn(default, eff)
self.assertIn("x.example", eff)
def test_supervise_hostname_auto_added_when_supervise_enabled(self):
# The agent's MCP client opens long-polled requests to
# http://supervise:9100/. They bypass the agent's HTTP_PROXY
+20 -25
View File
@@ -17,7 +17,7 @@ from claude_bottle.supervise import (
STATUS_MODIFIED,
STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK,
TOOL_CRED_PROXY_BLOCK,
TOOL_EGRESS_PROXY_BLOCK,
TOOL_PIPELOCK_BLOCK,
archive_proposal,
audit_log_path,
@@ -37,7 +37,7 @@ from claude_bottle.supervise import (
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(tool: str = TOOL_CRED_PROXY_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal:
def _proposal(tool: str = TOOL_EGRESS_PROXY_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal:
return Proposal.new(
bottle_slug="dev",
tool=tool,
@@ -54,7 +54,7 @@ class TestProposalRoundtrip(unittest.TestCase):
self.assertTrue(p.id)
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
self.assertEqual("dev", p.bottle_slug)
self.assertEqual(TOOL_CRED_PROXY_BLOCK, p.tool)
self.assertEqual(TOOL_EGRESS_PROXY_BLOCK, p.tool)
def test_to_from_dict_roundtrip(self):
p = _proposal()
@@ -139,13 +139,13 @@ class TestQueueIO(unittest.TestCase):
def test_list_pending_sorted_by_arrival(self):
# Fabricate two with explicit timestamps.
a = Proposal.new(
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
proposed_file="{}", justification="early",
current_file_hash="x",
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
)
b = Proposal.new(
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
proposed_file="{}", justification="late",
current_file_hash="x",
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
@@ -297,9 +297,9 @@ class TestDiffAndHash(unittest.TestCase):
self.assertEqual("", render_diff("a\nb\n", "a\nb\n"))
def test_render_diff_shows_changes(self):
diff = render_diff("a\nb\nc\n", "a\nB\nc\n", label="routes.json")
self.assertIn("routes.json (current)", diff)
self.assertIn("routes.json (proposed)", diff)
diff = render_diff("a\nb\nc\n", "a\nB\nc\n", label="routes.yaml")
self.assertIn("routes.yaml (current)", diff)
self.assertIn("routes.yaml (proposed)", diff)
self.assertIn("-b", diff)
self.assertIn("+B", diff)
@@ -314,12 +314,17 @@ class TestDiffAndHash(unittest.TestCase):
class TestToolConstants(unittest.TestCase):
def test_tools_tuple_matches_individual_constants(self):
self.assertEqual(
(TOOL_CRED_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK),
(
TOOL_EGRESS_PROXY_BLOCK,
TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK,
supervise.TOOL_LIST_EGRESS_PROXY_ROUTES,
),
supervise.TOOLS,
)
def test_component_map_covers_two_remediation_tools_only(self):
self.assertIn(TOOL_CRED_PROXY_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertIn(TOOL_EGRESS_PROXY_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL)
@@ -357,20 +362,10 @@ class TestSupervisePrepare(unittest.TestCase):
def test_prepare_creates_queue_and_current_config(self):
plan = _StubSupervise().prepare(
"dev", self.stage_dir,
routes_content='{"routes": [{"path": "/x/"}]}\n',
allowlist_content="example.com\n",
dockerfile_content="FROM python:3.13\n",
)
self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.current_config_dir.is_dir())
self.assertEqual(
'{"routes": [{"path": "/x/"}]}\n',
(plan.current_config_dir / "routes.json").read_text(),
)
self.assertEqual(
"example.com\n",
(plan.current_config_dir / "allowlist").read_text(),
)
self.assertEqual(
"FROM python:3.13\n",
(plan.current_config_dir / "Dockerfile").read_text(),
@@ -378,12 +373,12 @@ class TestSupervisePrepare(unittest.TestCase):
self.assertEqual("dev", plan.slug)
self.assertEqual("", plan.internal_network)
def test_prepare_defaults_routes_to_empty_when_absent(self):
def test_prepare_only_writes_dockerfile_to_current_config(self):
# routes.yaml + allowlist live behind the
# `list-egress-proxy-routes` MCP tool now (PRD 0017 chunk 3).
plan = _StubSupervise().prepare("dev", self.stage_dir)
self.assertEqual(
'{"routes": []}\n',
(plan.current_config_dir / "routes.json").read_text(),
)
files = sorted(p.name for p in plan.current_config_dir.iterdir())
self.assertEqual(["Dockerfile"], files)
if __name__ == "__main__":
+34 -30
View File
@@ -45,22 +45,6 @@ from claude_bottle.supervise_server import (
class TestValidation(unittest.TestCase):
def test_cred_proxy_block_requires_valid_json(self):
with self.assertRaises(_RpcError) as cm:
validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, "{not json")
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("not valid JSON", cm.exception.message)
def test_cred_proxy_block_requires_routes_array(self):
with self.assertRaises(_RpcError):
validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, '{"other": []}')
def test_cred_proxy_block_accepts_valid_routes(self):
validate_proposed_file(
_sv.TOOL_CRED_PROXY_BLOCK,
'{"routes": [{"path": "/x/", "upstream": "https://example.com"}]}',
)
def test_pipelock_block_accepts_https_url(self):
validate_proposed_file(
_sv.TOOL_PIPELOCK_BLOCK,
@@ -89,8 +73,12 @@ class TestValidation(unittest.TestCase):
"FROM python:3.13\nRUN apk add git\n",
)
def test_empty_proposed_file_rejected_for_all_tools(self):
for tool in _sv.TOOLS:
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
# egress-proxy-block has structured input (validated in
# _validate_and_bundle_egress_route, not here) and
# list-egress-proxy-routes takes no input. Only the other
# two go through `validate_proposed_file`.
for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK):
with self.subTest(tool=tool):
with self.assertRaises(_RpcError):
validate_proposed_file(tool, " \n\t")
@@ -170,27 +158,43 @@ class TestHandleInitialize(unittest.TestCase):
class TestHandleToolsList(unittest.TestCase):
def test_returns_three_tools(self):
def test_returns_all_tools(self):
result = handle_tools_list({})
names = [t["name"] for t in result["tools"]] # type: ignore[index]
self.assertEqual(
sorted([
_sv.TOOL_CRED_PROXY_BLOCK,
_sv.TOOL_EGRESS_PROXY_BLOCK,
_sv.TOOL_PIPELOCK_BLOCK,
_sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_LIST_EGRESS_PROXY_ROUTES,
]),
sorted(names),
)
def test_each_tool_has_inputSchema_with_two_required_fields(self):
def test_remediation_tools_have_inputSchema_with_two_required_fields(self):
# Only the proposal/remediation tools have required input
# fields. The list-* introspection tools take no input.
for tool in TOOL_DEFINITIONS:
with self.subTest(name=tool["name"]):
name = tool["name"]
if name not in PROPOSED_FILE_FIELD:
continue
with self.subTest(name=name):
schema = tool["inputSchema"]
self.assertEqual("object", schema["type"]) # type: ignore[index]
required = schema["required"] # type: ignore[index]
self.assertEqual(2, len(required))
self.assertIn("justification", required)
self.assertIn(PROPOSED_FILE_FIELD[tool["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):
tool = next(
t for t in TOOL_DEFINITIONS
if t["name"] == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES
)
schema = tool["inputSchema"]
self.assertEqual({}, schema.get("properties")) # type: ignore[union-attr]
# No `required` array because no inputs are required.
self.assertNotIn("required", schema) # type: ignore[operator]
class TestHandleToolsCall(unittest.TestCase):
@@ -225,9 +229,9 @@ class TestHandleToolsCall(unittest.TestCase):
try:
result = handle_tools_call(
{
"name": _sv.TOOL_CRED_PROXY_BLOCK,
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
"arguments": {
"routes": '{"routes": []}',
"host": "example.com",
"justification": "need a route",
},
},
@@ -269,8 +273,8 @@ class TestHandleToolsCall(unittest.TestCase):
with self.assertRaises(_RpcError):
handle_tools_call(
{
"name": _sv.TOOL_CRED_PROXY_BLOCK,
"arguments": {"routes": '{"routes": []}'},
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
"arguments": {"host": "example.com"},
},
self.config,
)
@@ -280,9 +284,9 @@ class TestHandleToolsCall(unittest.TestCase):
try:
handle_tools_call(
{
"name": _sv.TOOL_CRED_PROXY_BLOCK,
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
"arguments": {
"routes": '{"routes": []}',
"host": "example.com",
"justification": "x",
},
},
@@ -367,7 +371,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.assertEqual("2.0", result["jsonrpc"])
self.assertEqual(1, result["id"])
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
self.assertIn(_sv.TOOL_CRED_PROXY_BLOCK, names)
self.assertIn(_sv.TOOL_EGRESS_PROXY_BLOCK, names)
def test_unknown_method_returns_jsonrpc_error(self):
result = self._post_jsonrpc(