Merge pull request 'feat(egress-proxy): retarget remediation flow (PRD 0017 chunk 3)' (#30) from egress-proxy-block-remediation into main
This commit was merged in pull request #30.
This commit is contained in:
+20
-11
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user