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
|
EXPOSE 9099
|
||||||
|
|
||||||
# Entrypoint:
|
# Entrypoint:
|
||||||
# --mode regular@9099 standard HTTP/HTTPS forward proxy on :9099.
|
# - Upstream proxy: when EGRESS_PROXY_UPSTREAM_PROXY is set,
|
||||||
# --set ssl_verify_upstream_trusted_ca=... only when
|
# use mitmproxy's `--mode upstream:URL` to forward all
|
||||||
# EGRESS_PROXY_UPSTREAM_CA env is set (the backend's start step
|
# post-MITM traffic through pipelock. (mitmproxy does NOT
|
||||||
# sets it to the in-container pipelock-CA path when pipelock is
|
# honor HTTPS_PROXY env vars on its outbound side — it's a
|
||||||
# present, so the upstream leg trusts pipelock's MITM). The
|
# proxy server, not a client.) Standalone runs without
|
||||||
# ${VAR:+expansion} form omits the flag when the var is unset
|
# EGRESS_PROXY_UPSTREAM_PROXY fall back to `regular@9099`
|
||||||
# or empty — useful for standalone runs of the image (e.g. unit
|
# direct-to-upstream — useful for unit tests of the image.
|
||||||
# tests) where no upstream CA is mounted.
|
# - Upstream trust: when EGRESS_PROXY_UPSTREAM_CA is set, build
|
||||||
# -s /app/egress_proxy_addon.py loads our addon, which reads the
|
# a combined trust bundle (system roots + pipelock CA) and
|
||||||
# route table from /etc/egress-proxy/routes.yaml.
|
# point mitmproxy at it via
|
||||||
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"]
|
# `--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"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
_TRANSCRIPT_SUBDIR = "transcript"
|
||||||
_METADATA_NAME = "metadata.json"
|
_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
|
# 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's session-end cleanup knows to preserve the state dir for
|
||||||
# `cli.py resume <identity>`. Absent = clean up.
|
# `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"
|
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:
|
def transcript_snapshot_dir(identity: str) -> Path:
|
||||||
"""Where capability_apply stashes the agent's transcript before
|
"""Where capability_apply stashes the agent's transcript before
|
||||||
teardown, so the next `cli.py start <agent>` can offer to
|
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]:
|
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
"""Mint the per-bottle egress-proxy MITM CA. Reuses the pipelock
|
"""Mint the per-bottle egress-proxy MITM CA via host `openssl req`.
|
||||||
binary's `tls init` subcommand — a known-good RSA CA minter we
|
|
||||||
already pin and run on this host.
|
|
||||||
|
|
||||||
Returns `(mitmproxy_pem, cert_only_pem)`:
|
Returns `(mitmproxy_pem, cert_only_pem)`:
|
||||||
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
||||||
@@ -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
|
trust store by `provision_ca` so the agent trusts the bumped
|
||||||
CONNECT cert egress-proxy presents.
|
CONNECT cert egress-proxy presents.
|
||||||
|
|
||||||
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 600).
|
Why openssl req (not the pipelock binary's `tls init`):
|
||||||
Private keys never leave the host stage dir until
|
pipelock's CA generator stamps a non-standard `Subject Key
|
||||||
`DockerEgressProxy.start` docker-cps the concat file into the
|
Identifier` on the CA (random rather than SHA-1 of the pubkey).
|
||||||
sidecar; start.py's outer finally `shutil.rmtree`s the stage dir
|
mitmproxy computes the `Authority Key Identifier` on each leaf
|
||||||
after teardown.
|
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
|
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 644 —
|
||||||
pipelock-land don't need to know about us."""
|
`docker cp` preserves the mode into the container, where the
|
||||||
# Local import keeps the module-import graph free of a hard
|
mitmproxy user (uid 1000) reads them; the host stage_dir is
|
||||||
# pipelock-image dependency at top of file (we don't actually
|
mode 700 so the private key isn't world-exposed)."""
|
||||||
# need pipelock's *runtime* here, just its tls-init subcommand).
|
|
||||||
from .pipelock import PIPELOCK_IMAGE
|
|
||||||
work = stage_dir / "egress-proxy-ca"
|
work = stage_dir / "egress-proxy-ca"
|
||||||
work.mkdir(exist_ok=True)
|
work.mkdir(exist_ok=True)
|
||||||
result = subprocess.run(
|
key_path = work / "ca-key.pem"
|
||||||
["docker", "run", "--rm",
|
cert_path = work / "ca.pem"
|
||||||
"-v", f"{work}:/h",
|
cnf_path = work / "ca.cnf"
|
||||||
"-e", "PIPELOCK_HOME=/h",
|
|
||||||
PIPELOCK_IMAGE, "tls", "init"],
|
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
|
||||||
capture_output=True,
|
# config matches RSA CAs without surprise), and openssl req's
|
||||||
text=True,
|
# default behavior here is exactly what we want.
|
||||||
check=False,
|
keygen = subprocess.run(
|
||||||
|
["openssl", "genrsa", "-out", str(key_path), "2048"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if keygen.returncode != 0:
|
||||||
die(f"egress-proxy tls init failed: {result.stderr.strip()}")
|
die(f"egress-proxy ca keygen failed: {keygen.stderr.strip()}")
|
||||||
cert = work / "ca.pem"
|
|
||||||
key = work / "ca-key.pem"
|
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
|
||||||
if not cert.is_file() or not key.is_file():
|
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
|
||||||
die(f"egress-proxy tls init did not produce ca files in {work}")
|
# leaves it later mints. Without this, chain validation breaks
|
||||||
cert.chmod(0o600)
|
# 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.
|
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||||
mitm = work / "mitmproxy-ca.pem"
|
mitm = work / "mitmproxy-ca.pem"
|
||||||
mitm.write_bytes(cert.read_bytes() + key.read_bytes())
|
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||||
mitm.chmod(0o600)
|
mitm.chmod(0o644)
|
||||||
return (mitm, cert)
|
return (mitm, cert_path)
|
||||||
|
|
||||||
|
|
||||||
class DockerEgressProxy(EgressProxy):
|
class DockerEgressProxy(EgressProxy):
|
||||||
@@ -197,14 +229,25 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
"--network-alias", EGRESS_PROXY_HOSTNAME,
|
||||||
]
|
]
|
||||||
if route_via_pipelock:
|
if route_via_pipelock:
|
||||||
# Route egress-proxy's outbound HTTPS through pipelock so
|
# Route egress-proxy's outbound traffic through pipelock
|
||||||
# the egress allowlist + DLP body scanner apply to its
|
# so the egress allowlist + DLP body scanner apply to
|
||||||
# traffic on the egress-proxy → upstream leg. Pipelock
|
# the egress-proxy → upstream leg. Pipelock MITMs each
|
||||||
# MITMs each handshake with its per-bottle CA, which is
|
# handshake with its per-bottle CA, which is docker-cp'd
|
||||||
# docker-cp'd in below and pointed to via the
|
# in below and pointed to via the EGRESS_PROXY_UPSTREAM_CA
|
||||||
# EGRESS_PROXY_UPSTREAM_CA env (entrypoint conditionally
|
# env (entrypoint conditionally adds the matching --set
|
||||||
# adds the matching --set flag).
|
# 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([
|
create_args.extend([
|
||||||
|
"-e", f"EGRESS_PROXY_UPSTREAM_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
"-e", f"HTTPS_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
"-e", f"HTTP_PROXY={plan.pipelock_proxy_url}",
|
||||||
"-e", "NO_PROXY=localhost,127.0.0.1",
|
"-e", "NO_PROXY=localhost,127.0.0.1",
|
||||||
@@ -232,6 +275,17 @@ class DockerEgressProxy(EgressProxy):
|
|||||||
f"{create_result.stderr.strip()}"
|
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]] = [
|
cps: list[tuple[Path, str, str]] = [
|
||||||
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
|
(plan.routes_path, EGRESS_PROXY_ROUTES_IN_CONTAINER, "routes.yaml"),
|
||||||
(plan.mitmproxy_ca_host_path, EGRESS_PROXY_CA_IN_CONTAINER, "mitmproxy CA"),
|
(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
|
conflict races by incrementing the suffix (unless the name was
|
||||||
user-pinned). Returns the resolved container name."""
|
user-pinned). Returns the resolved container name."""
|
||||||
proxy_url = _agent_proxy_url(plan)
|
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] = [
|
docker_args: list[str] = [
|
||||||
"--rm", "-d",
|
"--rm", "-d",
|
||||||
"--name", plan.container_name,
|
"--name", plan.container_name,
|
||||||
"--network", internal_network,
|
"--network", internal_network,
|
||||||
"-e", f"HTTPS_PROXY={proxy_url}",
|
"-e", f"HTTPS_PROXY={proxy_url}",
|
||||||
"-e", f"HTTP_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
|
# CA trust trio for the agent process. Docker propagates
|
||||||
# run-time env into `docker exec`, so `claude` sees these
|
# run-time env into `docker exec`, so `claude` sees these
|
||||||
# without per-exec threading. NODE_EXTRA_CA_CERTS points at
|
# 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])
|
docker_args.extend(["-e", name])
|
||||||
|
|
||||||
# PRD 0013: read-only current-config mount so the agent can read
|
# 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
|
# supervise tool-call proposal. Mounted from the per-bottle
|
||||||
# stage_dir/current-config/ populated at prepare time.
|
# stage_dir/current-config/ populated at prepare time.
|
||||||
if plan.supervise_plan is not None:
|
if plan.supervise_plan is not None:
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ... import pipelock
|
from ... import pipelock
|
||||||
from ...egress_proxy import egress_proxy_render_routes
|
|
||||||
from ...env import ResolvedEnv, resolve_env
|
from ...env import ResolvedEnv, resolve_env
|
||||||
from ...log import die
|
from ...log import die
|
||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
@@ -153,21 +152,18 @@ def resolve_plan(
|
|||||||
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
|
egress_proxy_plan = egress_proxy.prepare(bottle, slug, stage_dir)
|
||||||
supervise_plan = None
|
supervise_plan = None
|
||||||
if bottle.supervise:
|
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
|
# Current Dockerfile for the agent image. Read from the repo
|
||||||
# root; for `--cwd` derived images the base Dockerfile is what
|
# root; for `--cwd` derived images the base Dockerfile is what
|
||||||
# the agent should propose changes against (the derived layer
|
# the agent should propose changes against (the derived layer
|
||||||
# is just a workspace copy).
|
# 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_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
||||||
supervise_plan = supervise.prepare(
|
supervise_plan = supervise.prepare(
|
||||||
slug, stage_dir,
|
slug, stage_dir,
|
||||||
routes_content=routes_content,
|
|
||||||
allowlist_content=allowlist_content,
|
|
||||||
dockerfile_content=dockerfile_content,
|
dockerfile_content=dockerfile_content,
|
||||||
)
|
)
|
||||||
resolved = resolve_env(manifest, spec.agent_name)
|
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
|
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||||
# mutates the host os.environ.
|
# mutates the host os.environ.
|
||||||
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
forwarded_env: dict[str, str] = dict(resolved.forwarded)
|
||||||
# When the bottle declares an egress-proxy route for the Anthropic
|
# When the bottle declares an egress-proxy route with the
|
||||||
# OAuth flow, claude-code's outbound Authorization gets stripped +
|
# `claude_code_oauth` role marker, claude-code's outbound
|
||||||
# re-injected by egress-proxy. The agent's environ still needs
|
# Authorization gets stripped + re-injected by egress-proxy. The
|
||||||
# *something* claude-code recognises as a credential or it refuses
|
# agent's environ still needs *something* claude-code recognises
|
||||||
# to start; ship a non-secret placeholder. The placeholder is not
|
# as a credential or it refuses to start; ship a non-secret
|
||||||
# any real `auth.token_ref` value, so leaking it would tell an
|
# placeholder. The placeholder isn't any real token value, so
|
||||||
# attacker only that egress-proxy is in front.
|
# leaking it would tell an attacker only that egress-proxy is in
|
||||||
|
# front. Manifest validation enforces singleton on this role.
|
||||||
has_anthropic_auth = any(
|
has_anthropic_auth = any(
|
||||||
r.token_ref == "CLAUDE_CODE_OAUTH_TOKEN"
|
"claude_code_oauth" in r.roles
|
||||||
for r in egress_proxy_plan.routes
|
for r in egress_proxy_plan.routes
|
||||||
)
|
)
|
||||||
if has_anthropic_auth:
|
if has_anthropic_auth:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""dashboard: list pending supervise proposals across all bottles and
|
"""dashboard: list pending supervise proposals across all bottles and
|
||||||
act on them (approve / modify / reject). PRD 0013 v1.
|
act on them (approve / modify / reject). PRD 0013 v1.
|
||||||
|
|
||||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. For
|
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||||
0013 the approval handlers are no-ops on the supervisor side: the
|
approval handlers wire to the per-tool remediation engines:
|
||||||
response file is written (and the sidecar returns it to the agent),
|
PRD 0014 (egress-proxy, retargeted from cred-proxy in PRD 0017
|
||||||
and an audit entry is appended, but no host-side config change runs.
|
chunk 3) writes routes.yaml + SIGHUPs egress-proxy; PRD 0015
|
||||||
PRDs 0014 (cred-proxy) and 0015 (pipelock) wire in the actual
|
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
|
||||||
writes.
|
(capability) rebuilds the bottle Dockerfile.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,8 +27,9 @@ from ..backend.docker.capability_apply import (
|
|||||||
CapabilityApplyError,
|
CapabilityApplyError,
|
||||||
apply_capability_change,
|
apply_capability_change,
|
||||||
)
|
)
|
||||||
from ..backend.docker.cred_proxy_apply import (
|
from ..backend.docker.egress_proxy_apply import (
|
||||||
CredProxyApplyError,
|
EgressProxyApplyError,
|
||||||
|
add_route,
|
||||||
apply_routes_change,
|
apply_routes_change,
|
||||||
fetch_current_routes,
|
fetch_current_routes,
|
||||||
)
|
)
|
||||||
@@ -50,7 +51,7 @@ from ..supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_CRED_PROXY_BLOCK,
|
TOOL_EGRESS_PROXY_BLOCK,
|
||||||
TOOL_PIPELOCK_BLOCK,
|
TOOL_PIPELOCK_BLOCK,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
@@ -64,7 +65,7 @@ from ._common import PROG
|
|||||||
# Errors any remediation engine may raise. Caught by the TUI key
|
# Errors any remediation engine may raise. Caught by the TUI key
|
||||||
# handlers and surfaced in the status line so a failed apply keeps
|
# handlers and surfaced in the status line so a failed apply keeps
|
||||||
# the proposal pending rather than crashing curses.
|
# the proposal pending rather than crashing curses.
|
||||||
ApplyError = (CredProxyApplyError, PipelockApplyError, CapabilityApplyError)
|
ApplyError = (EgressProxyApplyError, PipelockApplyError, CapabilityApplyError)
|
||||||
|
|
||||||
|
|
||||||
# --- Discovery -------------------------------------------------------------
|
# --- Discovery -------------------------------------------------------------
|
||||||
@@ -103,10 +104,10 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]:
|
|||||||
return sorted(out)
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
def discover_cred_proxy_slugs() -> list[str]:
|
def discover_egress_proxy_slugs() -> list[str]:
|
||||||
"""Slugs of bottles with a running cred-proxy sidecar. Used by
|
"""Slugs of bottles with a running egress-proxy sidecar. Used by
|
||||||
the operator-initiated `routes edit` verb."""
|
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]:
|
def discover_pipelock_slugs() -> list[str]:
|
||||||
@@ -156,17 +157,23 @@ def approve(
|
|||||||
entry. If `final_file` is provided the status is `modified`;
|
entry. If `final_file` is provided the status is `modified`;
|
||||||
otherwise `approved`.
|
otherwise `approved`.
|
||||||
|
|
||||||
Raises CredProxyApplyError if the cred-proxy-block apply fails
|
Raises EgressProxyApplyError if the egress-proxy-block apply
|
||||||
(sidecar down, invalid JSON survived the operator's modify).
|
fails (sidecar down, invalid routes content survived the
|
||||||
On failure no response is written and no audit entry is
|
operator's modify). On failure no response is written and no
|
||||||
appended — the proposal stays pending so the operator can fix
|
audit entry is appended — the proposal stays pending so the
|
||||||
the input and retry."""
|
operator can fix the input and retry."""
|
||||||
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
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
|
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
||||||
|
|
||||||
diff_before, diff_after = "", ""
|
diff_before, diff_after = "", ""
|
||||||
if qp.proposal.tool == TOOL_CRED_PROXY_BLOCK:
|
if qp.proposal.tool == TOOL_EGRESS_PROXY_BLOCK:
|
||||||
diff_before, diff_after = apply_routes_change(
|
# 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,
|
qp.proposal.bottle_slug, file_to_apply,
|
||||||
)
|
)
|
||||||
elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK:
|
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]:
|
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
|
proposal). Used by the `routes edit <bottle>` TUI verb and
|
||||||
available for scripted use. Returns (before, after) like
|
available for scripted use. Returns (before, after) like
|
||||||
apply_routes_change. Writes an audit entry tagged
|
apply_routes_change. Writes an audit entry tagged
|
||||||
ACTION_OPERATOR_EDIT to distinguish from tool-call approvals.
|
ACTION_OPERATOR_EDIT to distinguish from tool-call approvals.
|
||||||
|
|
||||||
Raises CredProxyApplyError on failure."""
|
Raises EgressProxyApplyError on failure."""
|
||||||
before, after = apply_routes_change(slug, new_content)
|
before, after = apply_routes_change(slug, new_content)
|
||||||
write_audit_entry(AuditEntry(
|
write_audit_entry(AuditEntry(
|
||||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
bottle_slug=slug,
|
bottle_slug=slug,
|
||||||
component="cred-proxy",
|
component="egress-proxy",
|
||||||
operator_action=ACTION_OPERATOR_EDIT,
|
operator_action=ACTION_OPERATOR_EDIT,
|
||||||
operator_notes="",
|
operator_notes="",
|
||||||
justification="",
|
justification="",
|
||||||
diff=render_diff(before, after, label="cred-proxy"),
|
diff=render_diff(before, after, label="egress-proxy"),
|
||||||
))
|
))
|
||||||
return before, after
|
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
|
The full URL (with path) is preserved on the proposal for the
|
||||||
operator's read; only the host ends up in pipelock's allowlist.
|
operator's read; only the host ends up in pipelock's allowlist.
|
||||||
|
|
||||||
FOLLOW-UP — path-aware filtering. Pipelock 2.3.0's api_allowlist
|
Pipelock 2.3.0's api_allowlist is hostname-only (verified by
|
||||||
is hostname-only (verified by inspecting the binary's strict
|
inspecting the binary's strict preset; the only "path" fields in
|
||||||
preset; the only "path" fields in pipelock's schema are about
|
pipelock's schema are about local filesystem paths under sandbox
|
||||||
local filesystem paths under sandbox / file_sentry / taint). So
|
/ file_sentry / taint). Approving pipelock-block opens the
|
||||||
approving pipelock-block opens the entire host, not the URL's
|
entire host, not the URL's path.
|
||||||
path. If/when per-path enforcement becomes load-bearing, the
|
|
||||||
follow-up is most likely adding an `auth_scheme: none` mode +
|
Path-level enforcement was the open question this function's
|
||||||
`path_allowlist` field to cred-proxy (which already does
|
earlier docstring flagged; PRD 0017 answered it by putting
|
||||||
path-prefix routing) and rewiring pipelock-block to propose
|
egress-proxy in front of pipelock. The agent's
|
||||||
cred-proxy routes instead of pipelock hostnames. That's a
|
`egress-proxy-block` tool now proposes routes.yaml changes that
|
||||||
multi-touch change deserving its own PRD — out of scope for the
|
can include a `path_allowlist`. Use that tool for path-level
|
||||||
supervise-loop work that introduced this function. See PR
|
follow-ups; this one stays hostname-only because pipelock is
|
||||||
discussion on https://gitea.dideric.is/didericis/claude-bottle/pulls/25
|
still the last hostname gate before egress."""
|
||||||
for the design conversation."""
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
parsed = urllib.parse.urlsplit(failed_url.strip())
|
parsed = urllib.parse.urlsplit(failed_url.strip())
|
||||||
host = parsed.hostname or ""
|
host = parsed.hostname or ""
|
||||||
@@ -296,14 +302,13 @@ def _write_audit(
|
|||||||
diff_before: str,
|
diff_before: str,
|
||||||
diff_after: str,
|
diff_after: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Audit log for cred-proxy / pipelock tools. capability-block has
|
"""Audit log for egress-proxy / pipelock tools. capability-block
|
||||||
no audit log (its changes are captured by the bottle's rebuild
|
has no audit log (its changes are captured by the bottle's
|
||||||
record + git history per PRD 0016).
|
rebuild record + git history per PRD 0016).
|
||||||
|
|
||||||
For cred-proxy-block approvals the (before, after) come from the
|
For egress-proxy-block + pipelock-block approvals the (before,
|
||||||
apply_routes_change return — a real fetched-from-sidecar diff.
|
after) come from the apply_*_change return — a real
|
||||||
For rejections, or for tools whose remediation hasn't landed yet
|
fetched-from-sidecar diff. For rejections both are empty strings
|
||||||
(pipelock in 0014, capability anywhere), both are empty strings
|
|
||||||
and the audit diff renders as empty."""
|
and the audit diff renders as empty."""
|
||||||
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
|
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
|
||||||
if component is None:
|
if component is None:
|
||||||
@@ -683,22 +688,22 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
|
|||||||
def _suffix_for_tool(tool: str) -> str:
|
def _suffix_for_tool(tool: str) -> str:
|
||||||
if tool == TOOL_CAPABILITY_BLOCK:
|
if tool == TOOL_CAPABILITY_BLOCK:
|
||||||
return ".dockerfile"
|
return ".dockerfile"
|
||||||
# cred-proxy-block / pipelock-block: JSON-ish + plain.
|
# egress-proxy-block / pipelock-block: JSON-ish + plain.
|
||||||
return ".txt"
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str:
|
def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str:
|
||||||
"""Operator-initiated routes.json edit. Discover running
|
"""Operator-initiated routes.yaml edit. Discover running
|
||||||
cred-proxy sidecars, pick one (single → use directly; multi →
|
egress-proxy sidecars, pick one (single → use directly; multi →
|
||||||
prompt), fetch the current routes, open in $EDITOR, apply on
|
prompt), fetch the current routes, open in $EDITOR, apply on
|
||||||
save. Returns a status-line message."""
|
save. Returns a status-line message."""
|
||||||
return _operator_edit_flow(
|
return _operator_edit_flow(
|
||||||
stdscr,
|
stdscr,
|
||||||
label="routes",
|
label="routes",
|
||||||
discover=discover_cred_proxy_slugs,
|
discover=discover_egress_proxy_slugs,
|
||||||
fetch=fetch_current_routes,
|
fetch=fetch_current_routes,
|
||||||
apply=operator_edit_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
|
(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
|
the CLI reads at launch and forwards into the container's environ
|
||||||
under `token_env`. Routes that share a `token_ref` coalesce to
|
under `token_env`. Routes that share a `token_ref` coalesce to
|
||||||
one `token_env` slot."""
|
one `token_env` slot.
|
||||||
|
|
||||||
|
`roles` carries the manifest route's optional role markers (see
|
||||||
|
`manifest.EGRESS_PROXY_ROLES`). The launch step reads these for
|
||||||
|
side effects like the claude-code OAuth placeholder env."""
|
||||||
|
|
||||||
host: str
|
host: str
|
||||||
path_allowlist: tuple[str, ...] = ()
|
path_allowlist: tuple[str, ...] = ()
|
||||||
auth_scheme: str = ""
|
auth_scheme: str = ""
|
||||||
token_env: str = ""
|
token_env: str = ""
|
||||||
token_ref: str = ""
|
token_ref: str = ""
|
||||||
|
roles: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -122,7 +127,24 @@ class EgressProxyPlan:
|
|||||||
pipelock_proxy_url: str = ""
|
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,
|
bottle: Bottle,
|
||||||
) -> tuple[EgressProxyRoute, ...]:
|
) -> tuple[EgressProxyRoute, ...]:
|
||||||
"""Lift each `bottle.egress_proxy.routes[]` manifest entry into a
|
"""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
|
authenticated route with `token_ref` "GH_PAT" gets
|
||||||
`EGRESS_PROXY_TOKEN_0`; a second route with the same `token_ref`
|
`EGRESS_PROXY_TOKEN_0`; a second route with the same `token_ref`
|
||||||
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
|
shares slot 0. Unauthenticated routes (`auth` omitted) contribute
|
||||||
no slot."""
|
no slot.
|
||||||
|
|
||||||
|
Does NOT include the folded-in DEFAULT_ALLOWLIST /
|
||||||
|
bottle.egress.allowlist bare-pass entries — see
|
||||||
|
`egress_proxy_routes_for_bottle` for the effective set the
|
||||||
|
addon enforces."""
|
||||||
out: list[EgressProxyRoute] = []
|
out: list[EgressProxyRoute] = []
|
||||||
slot_for_token: dict[str, str] = {}
|
slot_for_token: dict[str, str] = {}
|
||||||
for r in bottle.egress_proxy.routes:
|
for r in bottle.egress_proxy.routes:
|
||||||
@@ -148,15 +175,41 @@ def egress_proxy_routes_for_bottle(
|
|||||||
auth_scheme=r.AuthScheme,
|
auth_scheme=r.AuthScheme,
|
||||||
token_env=token_env,
|
token_env=token_env,
|
||||||
token_ref=r.TokenRef,
|
token_ref=r.TokenRef,
|
||||||
|
roles=r.Role,
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
out.append(EgressProxyRoute(
|
out.append(EgressProxyRoute(
|
||||||
host=r.Host,
|
host=r.Host,
|
||||||
path_allowlist=r.PathAllowlist,
|
path_allowlist=r.PathAllowlist,
|
||||||
|
roles=r.Role,
|
||||||
))
|
))
|
||||||
return tuple(out)
|
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(
|
def egress_proxy_token_env_map(
|
||||||
routes: tuple[EgressProxyRoute, ...],
|
routes: tuple[EgressProxyRoute, ...],
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -279,11 +332,13 @@ class EgressProxy(ABC):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DEFAULT_ALLOWLIST",
|
||||||
"EGRESS_PROXY_HOSTNAME",
|
"EGRESS_PROXY_HOSTNAME",
|
||||||
"EGRESS_PROXY_ROUTES_IN_CONTAINER",
|
"EGRESS_PROXY_ROUTES_IN_CONTAINER",
|
||||||
"EgressProxy",
|
"EgressProxy",
|
||||||
"EgressProxyPlan",
|
"EgressProxyPlan",
|
||||||
"EgressProxyRoute",
|
"EgressProxyRoute",
|
||||||
|
"egress_proxy_manifest_routes",
|
||||||
"egress_proxy_render_routes",
|
"egress_proxy_render_routes",
|
||||||
"egress_proxy_resolve_token_values",
|
"egress_proxy_resolve_token_values",
|
||||||
"egress_proxy_routes_for_bottle",
|
"egress_proxy_routes_for_bottle",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ build input — not a module the host imports."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
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"
|
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:
|
class EgressProxyAddon:
|
||||||
"""The mitmproxy addon. One instance per `mitmdump` process; the
|
"""The mitmproxy addon. One instance per `mitmdump` process; the
|
||||||
@@ -84,17 +96,49 @@ class EgressProxyAddon:
|
|||||||
|
|
||||||
signal.signal(signal.SIGHUP, handler)
|
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
|
# mitmproxy's addon API: this method name + signature is how
|
||||||
# mitmdump discovers the request hook.
|
# mitmdump discovers the request hook.
|
||||||
def request(self, flow: http.HTTPFlow) -> None:
|
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
|
# Inbound Authorization is always stripped — the agent cannot
|
||||||
# smuggle a stolen token through the proxy. If the matched
|
# smuggle a stolen token through the proxy. If the matched
|
||||||
# route declares an auth pair, a fresh header is injected
|
# route declares an auth pair, a fresh header is injected
|
||||||
# below.
|
# below.
|
||||||
flow.request.headers.pop("authorization", None)
|
flow.request.headers.pop("authorization", None)
|
||||||
|
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
|
||||||
|
|
||||||
# Universal HTTPS git-push block. Defense-in-depth: git-gate
|
# Universal HTTPS git-push block. Defense-in-depth: git-gate
|
||||||
# (PRD 0008) is the only sanctioned outbound path for git
|
# (PRD 0008) is the only sanctioned outbound path for git
|
||||||
# writes — its pre-receive runs gitleaks. Letting HTTPS push
|
# writes — its pre-receive runs gitleaks. Letting HTTPS push
|
||||||
|
|||||||
@@ -169,11 +169,14 @@ def match_route(
|
|||||||
routes: typing.Sequence[Route],
|
routes: typing.Sequence[Route],
|
||||||
request_host: str,
|
request_host: str,
|
||||||
) -> Route | None:
|
) -> 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
|
Wildcard hosts (`*.foo.com`) are NOT supported — they caused
|
||||||
0017 open questions). Hostname comparison is case-insensitive
|
too many edge cases (apex match? cert validation? pipelock
|
||||||
because DNS names are case-insensitive."""
|
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()
|
target = request_host.lower()
|
||||||
for r in routes:
|
for r in routes:
|
||||||
if r.host.lower() == target:
|
if r.host.lower() == target:
|
||||||
@@ -190,20 +193,30 @@ def decide(
|
|||||||
"""Pure decision: given a route table + request host + path + env,
|
"""Pure decision: given a route table + request host + path + env,
|
||||||
return what the addon should do with the request.
|
return what the addon should do with the request.
|
||||||
|
|
||||||
- No matching route → forward unchanged. Pipelock will
|
- No matching route → BLOCK. The route table is the bottle's
|
||||||
hostname-gate it downstream; egress-proxy does not need to
|
egress allowlist; defense-in-depth complements pipelock's
|
||||||
decide on hosts it doesn't recognise.
|
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
|
- Matching route with `path_allowlist` set, request path doesn't
|
||||||
start with any of the allowed prefixes → block with a clear
|
start with any of the allowed prefixes → block with a clear
|
||||||
reason.
|
reason.
|
||||||
- Matching route with an auth pair → forward + inject
|
- Matching route with an auth pair → forward + inject
|
||||||
Authorization. Token comes from `environ[route.token_env]`;
|
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).
|
isn't here — operator misconfig).
|
||||||
"""
|
"""
|
||||||
route = match_route(routes, request_host)
|
route = match_route(routes, request_host)
|
||||||
if route is None:
|
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 route.path_allowlist:
|
||||||
if not any(request_path.startswith(p) for p in 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).
|
# token-not-Bearer quirk (go-gitea/gitea#16734).
|
||||||
EGRESS_PROXY_AUTH_SCHEMES = ("Bearer", "token")
|
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)
|
@dataclass(frozen=True)
|
||||||
class EgressProxyRoute:
|
class EgressProxyRoute:
|
||||||
@@ -143,6 +171,11 @@ class EgressProxyRoute:
|
|||||||
manifest's `auth` block is omitted both fields are empty strings —
|
manifest's `auth` block is omitted both fields are empty strings —
|
||||||
no Authorization is written, no token forwarded.
|
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`):
|
Validation rules (enforced in `from_dict`):
|
||||||
- `host` required, non-empty.
|
- `host` required, non-empty.
|
||||||
- `path_allowlist` optional, list of absolute path prefixes.
|
- `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
|
`token_ref` as non-empty strings; an empty `auth: {}` is an
|
||||||
error rather than a synonym for "no auth" (omit `auth` for
|
error rather than a synonym for "no auth" (omit `auth` for
|
||||||
that case).
|
that case).
|
||||||
|
- `role` optional. String or list of strings drawn from
|
||||||
|
EGRESS_PROXY_ROLES. Singleton roles (see
|
||||||
|
EGRESS_PROXY_SINGLETON_ROLES) may appear on at most one
|
||||||
|
route per bottle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Host: str
|
Host: str
|
||||||
PathAllowlist: tuple[str, ...] = ()
|
PathAllowlist: tuple[str, ...] = ()
|
||||||
AuthScheme: str = ""
|
AuthScheme: str = ""
|
||||||
TokenRef: str = ""
|
TokenRef: str = ""
|
||||||
|
Role: tuple[str, ...] = ()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute":
|
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressProxyRoute":
|
||||||
@@ -226,11 +264,37 @@ class EgressProxyRoute:
|
|||||||
auth_scheme = auth_scheme_raw
|
auth_scheme = auth_scheme_raw
|
||||||
token_ref = token_ref_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:
|
for k in d:
|
||||||
if k not in ("host", "path_allowlist", "auth"):
|
if k not in ("host", "path_allowlist", "auth", "role"):
|
||||||
die(
|
die(
|
||||||
f"{label} has unknown key {k!r}; accepted keys are "
|
f"{label} has unknown key {k!r}; accepted keys are "
|
||||||
f"'host', 'path_allowlist', 'auth'"
|
f"'host', 'path_allowlist', 'auth', 'role'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@@ -238,6 +302,7 @@ class EgressProxyRoute:
|
|||||||
PathAllowlist=prefixes,
|
PathAllowlist=prefixes,
|
||||||
AuthScheme=auth_scheme,
|
AuthScheme=auth_scheme,
|
||||||
TokenRef=token_ref,
|
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
|
- Hosts must be unique within the bottle. The proxy matches by
|
||||||
exact-host (v1, prefix matching is on path_allowlist only);
|
exact-host (v1, prefix matching is on path_allowlist only);
|
||||||
duplicate hosts leave the route choice ambiguous.
|
duplicate hosts leave the route choice ambiguous.
|
||||||
|
- Singleton roles (see EGRESS_PROXY_SINGLETON_ROLES) may appear
|
||||||
|
on at most one route per bottle.
|
||||||
|
|
||||||
No cross-validation against `bottle.git` is performed. git-gate
|
No cross-validation against `bottle.git` is performed. git-gate
|
||||||
(SSH push/fetch) and egress-proxy (HTTPS) broker different
|
(SSH push/fetch) and egress-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."
|
f"{r.Host!r}; each host must be unique on the proxy."
|
||||||
)
|
)
|
||||||
seen_hosts[key] = None
|
seen_hosts[key] = None
|
||||||
|
for role in EGRESS_PROXY_SINGLETON_ROLES:
|
||||||
|
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:
|
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 pathlib import Path
|
||||||
from typing import cast
|
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 .supervise import SUPERVISE_HOSTNAME
|
||||||
from .manifest import Bottle
|
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
|
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
|
||||||
# enabled. The Claude API endpoint is an LLM provider — its request
|
# enabled. The Claude API endpoint is an LLM provider — its request
|
||||||
# bodies are user-authored conversation text that legitimately can
|
# 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]:
|
def pipelock_route_hosts(bottle: Bottle) -> list[str]:
|
||||||
"""Hostnames declared in `bottle.egress_proxy.routes`. Returned
|
"""Hostnames declared in `bottle.egress_proxy.routes`. Returned
|
||||||
sorted + deduped.
|
sorted + deduped. Used by the no-egress-proxy fallback path
|
||||||
|
below; bottles that DO use egress-proxy include the same hosts
|
||||||
Post-cutover topology (PRD 0017): the agent's HTTPS_PROXY points
|
via `egress_proxy_routes_for_bottle`."""
|
||||||
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."""
|
|
||||||
hosts = {r.Host for r in bottle.egress_proxy.routes if r.Host}
|
hosts = {r.Host for r in bottle.egress_proxy.routes if r.Host}
|
||||||
return sorted(hosts)
|
return sorted(hosts)
|
||||||
|
|
||||||
|
|
||||||
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
|
||||||
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
|
"""Hostnames pipelock allows. Sorted for stability.
|
||||||
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.
|
|
||||||
|
|
||||||
The egress-proxy + supervise hostnames are auto-added because the
|
Two paths, depending on whether the bottle uses egress-proxy:
|
||||||
sidecars sit on the bottle's internal network alongside the agent;
|
|
||||||
requests that pass through pipelock for `egress-proxy:9099` or
|
- Bottle declares `egress_proxy.routes[]` → agent's HTTPS_PROXY
|
||||||
`supervise:9100` (e.g. when egress-proxy uses HTTPS_PROXY=pipelock
|
points at egress-proxy. Egress-proxy is the bottle's primary
|
||||||
on its upstream leg) would otherwise be 403'd by pipelock's
|
allowlist gate (DEFAULT_ALLOWLIST + bottle.egress.allowlist +
|
||||||
hostname gate."""
|
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] = {}
|
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:
|
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:
|
if bottle.supervise:
|
||||||
seen.setdefault(SUPERVISE_HOSTNAME, None)
|
seen.setdefault(SUPERVISE_HOSTNAME, None)
|
||||||
return sorted(seen.keys())
|
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
|
sits on the bottle's internal network and exposes three MCP tools the
|
||||||
agent calls when it hits a stuck-recovery category:
|
agent calls when it hits a stuck-recovery category:
|
||||||
|
|
||||||
* 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
|
* pipelock-block — agent proposes a new pipelock allowlist
|
||||||
* capability-block — agent proposes a new agent Dockerfile
|
* capability-block — agent proposes a new agent Dockerfile
|
||||||
|
|
||||||
@@ -49,21 +49,33 @@ from pathlib import Path
|
|||||||
SUPERVISE_HOSTNAME = "supervise"
|
SUPERVISE_HOSTNAME = "supervise"
|
||||||
SUPERVISE_PORT = 9100
|
SUPERVISE_PORT = 9100
|
||||||
|
|
||||||
TOOL_CRED_PROXY_BLOCK = "cred-proxy-block"
|
TOOL_EGRESS_PROXY_BLOCK = "egress-proxy-block"
|
||||||
TOOL_PIPELOCK_BLOCK = "pipelock-block"
|
TOOL_PIPELOCK_BLOCK = "pipelock-block"
|
||||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||||
|
TOOL_LIST_EGRESS_PROXY_ROUTES = "list-egress-proxy-routes"
|
||||||
TOOLS: tuple[str, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_CRED_PROXY_BLOCK,
|
TOOL_EGRESS_PROXY_BLOCK,
|
||||||
TOOL_PIPELOCK_BLOCK,
|
TOOL_PIPELOCK_BLOCK,
|
||||||
TOOL_CAPABILITY_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
|
# capability-block has no on-disk config the operator edits in place
|
||||||
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
||||||
# here — those changes are captured by git history + the rebuild
|
# here — those changes are captured by git history + the rebuild
|
||||||
# record laid down in PRD 0016.
|
# record laid down in PRD 0016.
|
||||||
COMPONENT_FOR_TOOL: dict[str, str] = {
|
COMPONENT_FOR_TOOL: dict[str, str] = {
|
||||||
TOOL_CRED_PROXY_BLOCK: "cred-proxy",
|
TOOL_EGRESS_PROXY_BLOCK: "egress-proxy",
|
||||||
TOOL_PIPELOCK_BLOCK: "pipelock",
|
TOOL_PIPELOCK_BLOCK: "pipelock",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,11 +434,15 @@ def sha256_hex(content: str) -> str:
|
|||||||
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# Filenames inside the per-bottle current-config dir. The agent reads
|
# Filename of the staged Dockerfile inside the agent's read-only
|
||||||
# these (read-only) from CURRENT_CONFIG_DIR_IN_AGENT and proposes
|
# current-config mount. The capability-block tool's description
|
||||||
# modified versions back via the three MCP tools.
|
# points the agent at this exact path so it can read the current
|
||||||
CURRENT_CONFIG_ROUTES = "routes.json"
|
# Dockerfile and propose modifications.
|
||||||
CURRENT_CONFIG_ALLOWLIST = "allowlist"
|
#
|
||||||
|
# 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"
|
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
|
||||||
|
|
||||||
|
|
||||||
@@ -436,12 +452,12 @@ class SupervisePlan:
|
|||||||
|
|
||||||
`queue_dir` is the host directory bind-mounted into the sidecar
|
`queue_dir` is the host directory bind-mounted into the sidecar
|
||||||
at /run/supervise/queue. `current_config_dir` is the host
|
at /run/supervise/queue. `current_config_dir` is the host
|
||||||
directory bind-mounted (read-only) into the *agent* container at
|
directory bind-mounted (read-only) into the *agent* container
|
||||||
/etc/claude-bottle/current-config, holding routes.json + allowlist
|
at /etc/claude-bottle/current-config — currently holds only the
|
||||||
+ Dockerfile so the agent can read them before composing a
|
Dockerfile snapshot (routes.yaml + allowlist moved to the
|
||||||
proposal. `internal_network` is empty at prepare time; the
|
`list-egress-proxy-routes` MCP tool). `internal_network` is
|
||||||
backend's launch step fills it via dataclasses.replace before
|
empty at prepare time; the backend's launch step fills it via
|
||||||
calling .start."""
|
dataclasses.replace before calling .start."""
|
||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
queue_dir: Path
|
queue_dir: Path
|
||||||
@@ -459,8 +475,6 @@ class Supervise(ABC):
|
|||||||
slug: str,
|
slug: str,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
*,
|
*,
|
||||||
routes_content: str = "",
|
|
||||||
allowlist_content: str = "",
|
|
||||||
dockerfile_content: str = "",
|
dockerfile_content: str = "",
|
||||||
) -> SupervisePlan:
|
) -> SupervisePlan:
|
||||||
"""Stage the per-bottle queue dir on the host and the
|
"""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)
|
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||||
current_config_dir = stage_dir / "current-config"
|
current_config_dir = stage_dir / "current-config"
|
||||||
current_config_dir.mkdir(parents=True, exist_ok=True)
|
current_config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(current_config_dir / CURRENT_CONFIG_ROUTES).write_text(
|
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
|
||||||
routes_content or '{"routes": []}\n'
|
dockerfile_path.write_text(dockerfile_content)
|
||||||
)
|
dockerfile_path.chmod(0o644)
|
||||||
(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)
|
|
||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
queue_dir=queue_dir,
|
queue_dir=queue_dir,
|
||||||
@@ -548,10 +554,8 @@ __all__ = [
|
|||||||
"ACTION_OPERATOR_EDIT",
|
"ACTION_OPERATOR_EDIT",
|
||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"COMPONENT_FOR_TOOL",
|
"COMPONENT_FOR_TOOL",
|
||||||
"CURRENT_CONFIG_ALLOWLIST",
|
|
||||||
"CURRENT_CONFIG_DIR_IN_AGENT",
|
"CURRENT_CONFIG_DIR_IN_AGENT",
|
||||||
"CURRENT_CONFIG_DOCKERFILE",
|
"CURRENT_CONFIG_DOCKERFILE",
|
||||||
"CURRENT_CONFIG_ROUTES",
|
|
||||||
"DEFAULT_POLL_INTERVAL_SEC",
|
"DEFAULT_POLL_INTERVAL_SEC",
|
||||||
"Proposal",
|
"Proposal",
|
||||||
"QUEUE_DIR_IN_CONTAINER",
|
"QUEUE_DIR_IN_CONTAINER",
|
||||||
@@ -565,8 +569,11 @@ __all__ = [
|
|||||||
"Supervise",
|
"Supervise",
|
||||||
"SupervisePlan",
|
"SupervisePlan",
|
||||||
"TOOLS",
|
"TOOLS",
|
||||||
|
"EGRESS_PROXY_FORWARD_PROXY",
|
||||||
|
"EGRESS_PROXY_INTROSPECT_URL",
|
||||||
"TOOL_CAPABILITY_BLOCK",
|
"TOOL_CAPABILITY_BLOCK",
|
||||||
"TOOL_CRED_PROXY_BLOCK",
|
"TOOL_EGRESS_PROXY_BLOCK",
|
||||||
|
"TOOL_LIST_EGRESS_PROXY_ROUTES",
|
||||||
"TOOL_PIPELOCK_BLOCK",
|
"TOOL_PIPELOCK_BLOCK",
|
||||||
"archive_proposal",
|
"archive_proposal",
|
||||||
"audit_dir",
|
"audit_dir",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Supervise sidecar HTTP server (PRD 0013).
|
"""Supervise sidecar HTTP server (PRD 0013).
|
||||||
|
|
||||||
Per-bottle MCP server exposing three tools — `cred-proxy-block`,
|
Per-bottle MCP server exposing three tools — `egress-proxy-block`,
|
||||||
`pipelock-block`, `capability-block` — that the agent calls to
|
`pipelock-block`, `capability-block` — that the agent calls to
|
||||||
propose config changes when stuck. Each tool call:
|
propose config changes when stuck. Each tool call:
|
||||||
|
|
||||||
@@ -36,7 +36,9 @@ import os
|
|||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
import typing
|
import typing
|
||||||
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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]] = [
|
TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CRED_PROXY_BLOCK,
|
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
"description": (
|
"description": (
|
||||||
"Call when cred-proxy refused your HTTPS request — missing "
|
"Call when egress-proxy refused your HTTPS request — host "
|
||||||
"route, expired token, wrong scope (typically a 403 or a "
|
"without a matching route, or a path outside the route's "
|
||||||
"404 from `http://cred-proxy:<port>/<path>/`). Read the "
|
"path_allowlist (typically a 403 from the proxy). Propose "
|
||||||
"current routes.json from "
|
"a SINGLE route to add: the host you need + (optionally) "
|
||||||
"/etc/claude-bottle/current-config/routes.json, compose a "
|
"a path_allowlist + (optionally) an auth block. The "
|
||||||
"modified version with the route you need, and pass the "
|
"supervisor merges the route into the live table at "
|
||||||
"full new file plus a justification. The operator approves "
|
"approval time — you do NOT need to see or reproduce the "
|
||||||
"or rejects in the supervise TUI. On approval the supervisor "
|
"existing routes, and you do not pass a full routes file. "
|
||||||
"writes the new routes.json on the host and SIGHUPs cred-proxy "
|
"If the host already has a route, the proposed "
|
||||||
"(wired in PRD 0014; in the v1 supervise foundation the "
|
"path_allowlist entries are unioned with the existing "
|
||||||
"approval is acknowledged but no config change runs)."
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"routes": {
|
"host": {
|
||||||
"type": "string",
|
"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": {
|
"justification": {
|
||||||
"type": "string",
|
"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,
|
"name": _sv.TOOL_PIPELOCK_BLOCK,
|
||||||
"description": (
|
"description": (
|
||||||
"Call when pipelock refused your outbound request — host "
|
"Call when pipelock refused your outbound request and "
|
||||||
"not in the allowlist, connection refused at the egress "
|
"the failing host is genuinely missing from the bottle's "
|
||||||
"layer. Pass the full URL you tried to hit (scheme + "
|
"allowlist (vs. blocked for DLP reasons — those need a "
|
||||||
"host + path) plus a justification. The supervisor "
|
"different remediation). In practice pipelock's allowlist "
|
||||||
"extracts the hostname and merges it into the bottle's "
|
"is now a mirror of the egress-proxy routes set by "
|
||||||
"current pipelock allowlist; the path is captured as "
|
"`egress-proxy-block`, so prefer that tool when you want "
|
||||||
"context for the operator to review (pipelock's allowlist "
|
"to add a host. This tool stays available for the rare "
|
||||||
"is hostname-only — it can't enforce path-level rules). "
|
"case where pipelock and egress-proxy have diverged. "
|
||||||
"On approval the supervisor restarts pipelock with the "
|
"Pass the full URL you tried to hit (scheme + host + "
|
||||||
"merged allowlist."
|
"path); the supervisor extracts the hostname and merges "
|
||||||
|
"it into pipelock's allowlist. On approval the "
|
||||||
|
"supervisor restarts pipelock."
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -226,15 +282,22 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
# tool-specific payload (stored in Proposal.proposed_file as
|
# tool-specific payload (stored in Proposal.proposed_file as
|
||||||
# free-form text the apply path interprets per tool).
|
# free-form text the apply path interprets per tool).
|
||||||
#
|
#
|
||||||
# cred-proxy-block: full proposed routes.json
|
# egress-proxy-block: JSON object describing a SINGLE route to
|
||||||
# pipelock-block: the full failed URL (scheme + host + path) —
|
# add — `{host, path_allowlist?, auth?}`. The
|
||||||
# supervisor extracts the host, merges into the
|
# supervisor merges this into the live routes
|
||||||
# bottle's current allowlist; the path is shown
|
# file at approval time.
|
||||||
# to the operator for context (pipelock doesn't
|
# pipelock-block: the full failed URL (scheme + host + path) —
|
||||||
# do path-level matching).
|
# supervisor extracts the host, merges into the
|
||||||
# capability-block: full proposed Dockerfile
|
# 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] = {
|
PROPOSED_FILE_FIELD: dict[str, str] = {
|
||||||
_sv.TOOL_CRED_PROXY_BLOCK: "routes",
|
|
||||||
_sv.TOOL_PIPELOCK_BLOCK: "failed_url",
|
_sv.TOOL_PIPELOCK_BLOCK: "failed_url",
|
||||||
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
||||||
}
|
}
|
||||||
@@ -243,26 +306,18 @@ PROPOSED_FILE_FIELD: dict[str, str] = {
|
|||||||
# --- Validation ------------------------------------------------------------
|
# --- 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:
|
def validate_proposed_file(tool: str, content: str) -> None:
|
||||||
"""Syntactic validation. The operator is the real gate; this just
|
"""Syntactic validation. The operator is the real gate; this just
|
||||||
catches obvious paste-errors / wrong-tool selections before they
|
catches obvious paste-errors / wrong-tool selections before they
|
||||||
enter the queue."""
|
enter the queue."""
|
||||||
if not content.strip():
|
if not content.strip():
|
||||||
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
||||||
if tool == _sv.TOOL_CRED_PROXY_BLOCK:
|
if tool == _sv.TOOL_PIPELOCK_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:
|
|
||||||
# `content` is the full failed URL. Require scheme + host so
|
# `content` is the full failed URL. Require scheme + host so
|
||||||
# the supervisor can extract a hostname for the allowlist
|
# the supervisor can extract a hostname for the allowlist
|
||||||
# merge; the path is preserved for operator context.
|
# 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}")
|
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 ----------------------------------------------------------
|
# --- MCP handlers ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -307,36 +426,86 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
|
|||||||
return {"tools": TOOL_DEFINITIONS}
|
return {"tools": TOOL_DEFINITIONS}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_list_egress_proxy_routes(
|
||||||
|
_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(
|
def handle_tools_call(
|
||||||
params: dict[str, object],
|
params: dict[str, object],
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Validates the proposal, writes it to the queue, blocks waiting
|
"""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")
|
name = params.get("name")
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
||||||
if name not in PROPOSED_FILE_FIELD:
|
if name == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES:
|
||||||
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
|
return handle_list_egress_proxy_routes(params.get("arguments", {}), config)
|
||||||
|
|
||||||
args_raw = params.get("arguments", {})
|
args_raw = params.get("arguments", {})
|
||||||
if not isinstance(args_raw, dict):
|
if not isinstance(args_raw, dict):
|
||||||
raise _RpcError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
|
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")
|
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():
|
if not isinstance(justification, str) or not justification.strip():
|
||||||
raise _RpcError(
|
raise _RpcError(
|
||||||
ERR_INVALID_PARAMS,
|
ERR_INVALID_PARAMS,
|
||||||
f"{name}: 'justification' is required and must be a non-empty string",
|
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(
|
proposal = _sv.Proposal.new(
|
||||||
bottle_slug=config.bottle_slug,
|
bottle_slug=config.bottle_slug,
|
||||||
@@ -505,7 +674,7 @@ def serve(
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
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", "")
|
bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||||
if not bottle_slug:
|
if not bottle_slug:
|
||||||
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
# PRD 0010: Credential proxy for agent-bound API tokens
|
# PRD 0010: Credential proxy for agent-bound API tokens
|
||||||
|
|
||||||
- **Status:** Draft
|
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-13
|
- **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
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
# PRD 0014: cred-proxy block remediation
|
# PRD 0014: cred-proxy block remediation
|
||||||
|
|
||||||
- **Status:** Draft
|
- **Status:** Retargeted by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-25
|
- **Created:** 2026-05-25
|
||||||
|
- **Retargeted:** 2026-05-25
|
||||||
- **Parent:** PRD 0012
|
- **Parent:** PRD 0012
|
||||||
- **Depends on:** PRD 0013
|
- **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
|
## 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.
|
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"]}
|
names = {t["name"] for t in result["result"]["tools"]}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
_sv.TOOL_CRED_PROXY_BLOCK,
|
_sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
_sv.TOOL_PIPELOCK_BLOCK,
|
_sv.TOOL_PIPELOCK_BLOCK,
|
||||||
_sv.TOOL_CAPABILITY_BLOCK,
|
_sv.TOOL_CAPABILITY_BLOCK,
|
||||||
|
_sv.TOOL_LIST_EGRESS_PROXY_ROUTES,
|
||||||
},
|
},
|
||||||
names,
|
names,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tools_call_round_trips_through_queue(self):
|
def test_tools_call_round_trips_through_queue(self):
|
||||||
"""End-to-end: agent in the bottle calls cred-proxy-block;
|
"""End-to-end: agent in the bottle calls egress-proxy-block;
|
||||||
the call blocks on the queue; the host rejects via the
|
the call blocks on the queue; the host approves via the
|
||||||
dashboard helpers; the agent receives the rejection.
|
dashboard helpers; the agent receives the approval.
|
||||||
|
|
||||||
PRD 0017 chunk 2 deleted the cred-proxy sidecar, so the
|
This test focuses on the supervise sidecar's queue + response
|
||||||
approval-apply path on cred-proxy-block is broken in this
|
plumbing, not the egress-proxy apply path itself. The apply
|
||||||
intermediate state (chunk 3 retargets it at egress-proxy and
|
function is stubbed so we don't need to bring up a real
|
||||||
restores the round-trip approval test). For now this verifies
|
egress-proxy sidecar (its docker lifecycle has its own
|
||||||
only the queue + response leg by exercising the reject path
|
integration coverage)."""
|
||||||
— no docker-exec into a sidecar needed."""
|
|
||||||
self._require_bind_mount_sharing()
|
self._require_bind_mount_sharing()
|
||||||
self._bring_up_sidecar()
|
self._bring_up_sidecar()
|
||||||
|
|
||||||
|
# Stub the apply step. The dashboard's approve() calls
|
||||||
|
# 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] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def caller() -> None:
|
def caller() -> None:
|
||||||
captured["response"] = self._curl_jsonrpc({
|
captured["response"] = self._curl_jsonrpc({
|
||||||
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
|
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
|
||||||
"params": {
|
"params": {
|
||||||
"name": _sv.TOOL_CRED_PROXY_BLOCK,
|
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"routes": '{"routes": [{"path": "/x/"}]}',
|
"host": "api.example.com",
|
||||||
"justification": "integration test",
|
"justification": "integration test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -249,16 +260,17 @@ class TestSuperviseSidecar(unittest.TestCase):
|
|||||||
self.assertIsNotNone(qp, "proposal never appeared in queue")
|
self.assertIsNotNone(qp, "proposal never appeared in queue")
|
||||||
assert qp is not None # type-narrowing
|
assert qp is not None # type-narrowing
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
_sv.TOOL_CRED_PROXY_BLOCK, qp.proposal.tool,
|
_sv.TOOL_EGRESS_PROXY_BLOCK, qp.proposal.tool,
|
||||||
)
|
)
|
||||||
self.assertEqual("integration test", qp.proposal.justification)
|
self.assertEqual("integration test", qp.proposal.justification)
|
||||||
|
|
||||||
# Reject via the dashboard helper. The reject path skips
|
# Approve via the dashboard helper. The apply step (now
|
||||||
# the sidecar-apply step, so it works without a real
|
# stubbed) would docker-exec into the egress-proxy sidecar
|
||||||
# cred-proxy sidecar (which doesn't exist in chunk 2's
|
# and SIGHUP it. The supervise sidecar sees the response
|
||||||
# transitional state).
|
# file and returns to the curl caller.
|
||||||
dashboard.reject(qp, reason="no real cred-proxy in chunk 2")
|
dashboard.approve(qp, notes="lgtm from integration test")
|
||||||
finally:
|
finally:
|
||||||
|
_dash.add_route = original_apply
|
||||||
t.join(timeout=20)
|
t.join(timeout=20)
|
||||||
|
|
||||||
response = captured.get("response")
|
response = captured.get("response")
|
||||||
@@ -267,12 +279,10 @@ class TestSuperviseSidecar(unittest.TestCase):
|
|||||||
self.assertEqual(7, response["id"])
|
self.assertEqual(7, response["id"])
|
||||||
result = response["result"]
|
result = response["result"]
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
# Rejected tool calls surface as MCP errors so the agent
|
self.assertFalse(result.get("isError"))
|
||||||
# treats them as failures (not silent successes).
|
|
||||||
self.assertTrue(result.get("isError"))
|
|
||||||
text = result["content"][0]["text"]
|
text = result["content"][0]["text"]
|
||||||
self.assertIn("rejected", text)
|
self.assertIn("status: approved", text)
|
||||||
self.assertIn("no real cred-proxy", text)
|
self.assertIn("notes: lgtm from integration test", text)
|
||||||
|
|
||||||
def test_orphan_sidecar_name_collision_recovered(self):
|
def test_orphan_sidecar_name_collision_recovered(self):
|
||||||
"""An orphan supervise sidecar from a previous run blocks
|
"""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 import supervise
|
||||||
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
|
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
|
||||||
from claude_bottle.backend.docker.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.backend.docker.pipelock_apply import PipelockApplyError
|
||||||
from claude_bottle.cli import dashboard
|
from claude_bottle.cli import dashboard
|
||||||
from claude_bottle.supervise import (
|
from claude_bottle.supervise import (
|
||||||
@@ -26,7 +26,7 @@ from claude_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_CRED_PROXY_BLOCK,
|
TOOL_EGRESS_PROXY_BLOCK,
|
||||||
TOOL_PIPELOCK_BLOCK,
|
TOOL_PIPELOCK_BLOCK,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
read_response,
|
read_response,
|
||||||
@@ -37,13 +37,13 @@ from claude_bottle.supervise import (
|
|||||||
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _proposal(slug: str = "dev", tool: str = TOOL_CRED_PROXY_BLOCK) -> Proposal:
|
def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_PROXY_BLOCK) -> Proposal:
|
||||||
# Per-tool payload shape: cred-proxy gets routes.json, pipelock
|
# Per-tool payload shape: cred-proxy gets routes.yaml, pipelock
|
||||||
# gets a failed URL (PR #25 follow-up), capability gets a
|
# gets a failed URL (PR #25 follow-up), capability gets a
|
||||||
# Dockerfile-ish blob. Match the production dispatch in
|
# Dockerfile-ish blob. Match the production dispatch in
|
||||||
# PROPOSED_FILE_FIELD.
|
# PROPOSED_FILE_FIELD.
|
||||||
payloads = {
|
payloads = {
|
||||||
TOOL_CRED_PROXY_BLOCK: '{"routes": []}\n',
|
TOOL_EGRESS_PROXY_BLOCK: '{"routes": []}\n',
|
||||||
TOOL_PIPELOCK_BLOCK: "https://example.com/path",
|
TOOL_PIPELOCK_BLOCK: "https://example.com/path",
|
||||||
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
||||||
}
|
}
|
||||||
@@ -95,13 +95,13 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
|
|||||||
|
|
||||||
def test_sorted_by_arrival_across_bottles(self):
|
def test_sorted_by_arrival_across_bottles(self):
|
||||||
early = Proposal.new(
|
early = Proposal.new(
|
||||||
bottle_slug="api", tool=TOOL_CRED_PROXY_BLOCK,
|
bottle_slug="api", tool=TOOL_EGRESS_PROXY_BLOCK,
|
||||||
proposed_file="{}", justification="early",
|
proposed_file="{}", justification="early",
|
||||||
current_file_hash="h",
|
current_file_hash="h",
|
||||||
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
late = Proposal.new(
|
late = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
|
||||||
proposed_file="{}", justification="late",
|
proposed_file="{}", justification="late",
|
||||||
current_file_hash="h",
|
current_file_hash="h",
|
||||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||||
@@ -127,14 +127,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
|
|||||||
class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
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_apply_allowlist = dashboard.apply_allowlist_change
|
||||||
self._original_fetch_allowlist = dashboard.fetch_current_allowlist
|
self._original_fetch_allowlist = dashboard.fetch_current_allowlist
|
||||||
self._original_apply_capability = dashboard.apply_capability_change
|
self._original_apply_capability = dashboard.apply_capability_change
|
||||||
# Default stubs: succeed with deterministic before/after so the
|
# Default stubs: succeed with deterministic before/after so the
|
||||||
# audit log shows a non-empty diff.
|
# audit log shows a non-empty diff.
|
||||||
dashboard.apply_routes_change = lambda slug, content: (
|
dashboard.add_route = lambda slug, content: (
|
||||||
'{"routes": []}\n', content,
|
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n',
|
||||||
)
|
)
|
||||||
dashboard.apply_allowlist_change = lambda slug, content: (
|
dashboard.apply_allowlist_change = lambda slug, content: (
|
||||||
"old.example\n", content,
|
"old.example\n", content,
|
||||||
@@ -145,13 +145,13 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
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.apply_allowlist_change = self._original_apply_allowlist
|
||||||
dashboard.fetch_current_allowlist = self._original_fetch_allowlist
|
dashboard.fetch_current_allowlist = self._original_fetch_allowlist
|
||||||
dashboard.apply_capability_change = self._original_apply_capability
|
dashboard.apply_capability_change = self._original_apply_capability
|
||||||
self._teardown_fake_home()
|
self._teardown_fake_home()
|
||||||
|
|
||||||
def _enqueue(self, tool: str = TOOL_CRED_PROXY_BLOCK):
|
def _enqueue(self, tool: str = TOOL_EGRESS_PROXY_BLOCK):
|
||||||
p = _proposal(tool=tool)
|
p = _proposal(tool=tool)
|
||||||
qdir = supervise.queue_dir_for_slug("dev")
|
qdir = supervise.queue_dir_for_slug("dev")
|
||||||
qdir.mkdir(parents=True, exist_ok=True)
|
qdir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -164,7 +164,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_APPROVED, resp.status)
|
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||||
self.assertIsNone(resp.final_file)
|
self.assertIsNone(resp.final_file)
|
||||||
entries = read_audit_entries("cred-proxy", "dev")
|
entries = read_audit_entries("egress-proxy", "dev")
|
||||||
self.assertEqual(1, len(entries))
|
self.assertEqual(1, len(entries))
|
||||||
self.assertEqual("approved", entries[0].operator_action)
|
self.assertEqual("approved", entries[0].operator_action)
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual(STATUS_MODIFIED, resp.status)
|
self.assertEqual(STATUS_MODIFIED, resp.status)
|
||||||
self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file)
|
self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file)
|
||||||
self.assertEqual("tweaked", resp.notes)
|
self.assertEqual("tweaked", resp.notes)
|
||||||
entries = read_audit_entries("cred-proxy", "dev")
|
entries = read_audit_entries("egress-proxy", "dev")
|
||||||
self.assertEqual("modified", entries[0].operator_action)
|
self.assertEqual("modified", entries[0].operator_action)
|
||||||
|
|
||||||
def test_reject_writes_rejection(self):
|
def test_reject_writes_rejection(self):
|
||||||
@@ -184,7 +184,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_REJECTED, resp.status)
|
self.assertEqual(STATUS_REJECTED, resp.status)
|
||||||
self.assertEqual("nope", resp.notes)
|
self.assertEqual("nope", resp.notes)
|
||||||
entries = read_audit_entries("cred-proxy", "dev")
|
entries = read_audit_entries("egress-proxy", "dev")
|
||||||
self.assertEqual("rejected", entries[0].operator_action)
|
self.assertEqual("rejected", entries[0].operator_action)
|
||||||
self.assertEqual("nope", entries[0].operator_notes)
|
self.assertEqual("nope", entries[0].operator_notes)
|
||||||
|
|
||||||
@@ -193,32 +193,32 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
dashboard.approve(qp)
|
dashboard.approve(qp)
|
||||||
# No audit log for capability-block (per PRD 0013 / 0016).
|
# No audit log for capability-block (per PRD 0013 / 0016).
|
||||||
# cred-proxy and pipelock logs both empty.
|
# cred-proxy and pipelock logs both empty.
|
||||||
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
|
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
|
||||||
self.assertEqual([], read_audit_entries("pipelock", "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)
|
qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK)
|
||||||
dashboard.approve(qp)
|
dashboard.approve(qp)
|
||||||
self.assertEqual(1, len(read_audit_entries("pipelock", "dev")))
|
self.assertEqual(1, len(read_audit_entries("pipelock", "dev")))
|
||||||
self.assertEqual(0, len(read_audit_entries("cred-proxy", "dev")))
|
self.assertEqual(0, len(read_audit_entries("egress-proxy", "dev")))
|
||||||
|
|
||||||
|
|
||||||
class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""PRD 0014 Phase 3: approve() on a cred-proxy-block proposal
|
"""PRD 0017 chunk 3: approve() on an egress-proxy-block proposal
|
||||||
must call apply_routes_change with the right args and surface
|
must call add_route (single-route merge) with the right args
|
||||||
its failures."""
|
and surface its failures."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
self._setup_fake_home()
|
||||||
self._original_apply = dashboard.apply_routes_change
|
self._original_add_route = dashboard.add_route
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
dashboard.apply_routes_change = self._original_apply
|
dashboard.add_route = self._original_add_route
|
||||||
self._teardown_fake_home()
|
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(
|
p = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
|
||||||
proposed_file=proposed,
|
proposed_file=proposed,
|
||||||
justification="need a route",
|
justification="need a route",
|
||||||
current_file_hash=sha256_hex(proposed),
|
current_file_hash=sha256_hex(proposed),
|
||||||
@@ -229,33 +229,44 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
|||||||
supervise.write_proposal(qdir, p)
|
supervise.write_proposal(qdir, p)
|
||||||
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
|
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
|
||||||
|
|
||||||
def test_cred_proxy_block_calls_apply_with_proposed_file(self):
|
def test_egress_proxy_block_calls_add_route_with_proposed_json(self):
|
||||||
calls = []
|
calls = []
|
||||||
dashboard.apply_routes_change = lambda slug, content: (
|
dashboard.add_route = lambda slug, content: (
|
||||||
calls.append((slug, content)) or ("before", 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)
|
dashboard.approve(qp)
|
||||||
self.assertEqual(1, len(calls))
|
self.assertEqual(1, len(calls))
|
||||||
slug, content = calls[0]
|
slug, content = calls[0]
|
||||||
self.assertEqual("dev", slug)
|
self.assertEqual("dev", slug)
|
||||||
self.assertEqual('{"routes": [{"path": "/new/"}]}\n', content)
|
# The single-route JSON the agent proposed reaches add_route
|
||||||
|
# unchanged — add_route fetches current state + merges.
|
||||||
def test_modify_passes_final_file_to_apply(self):
|
self.assertEqual(
|
||||||
calls = []
|
'{"host": "new.example", "path_allowlist": ["/x/"]}\n',
|
||||||
dashboard.apply_routes_change = lambda slug, content: (
|
content,
|
||||||
calls.append(content) or ("before", content)
|
|
||||||
)
|
)
|
||||||
qp = self._enqueue_cred_proxy()
|
|
||||||
dashboard.approve(qp, final_file='{"routes": [{"path": "/edited/"}]}\n', notes="tweaked")
|
def test_modify_passes_final_file_to_add_route(self):
|
||||||
self.assertEqual(['{"routes": [{"path": "/edited/"}]}\n'], calls)
|
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):
|
def test_apply_failure_blocks_response_and_audit(self):
|
||||||
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
|
dashboard.add_route = lambda slug, content: (_ for _ in ()).throw(
|
||||||
CredProxyApplyError("docker exec failed")
|
EgressProxyApplyError("docker exec failed")
|
||||||
)
|
)
|
||||||
qp = self._enqueue_cred_proxy()
|
qp = self._enqueue_egress_proxy()
|
||||||
with self.assertRaises(CredProxyApplyError):
|
with self.assertRaises(EgressProxyApplyError):
|
||||||
dashboard.approve(qp)
|
dashboard.approve(qp)
|
||||||
# No response file (proposal stays pending).
|
# No response file (proposal stays pending).
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -263,18 +274,18 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
|||||||
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
|
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
|
||||||
)
|
)
|
||||||
# No audit entry.
|
# No audit entry.
|
||||||
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
|
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
|
||||||
|
|
||||||
def test_real_diff_lands_in_audit(self):
|
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": []}\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)
|
dashboard.approve(qp)
|
||||||
entries = read_audit_entries("cred-proxy", "dev")
|
entries = read_audit_entries("egress-proxy", "dev")
|
||||||
self.assertEqual(1, len(entries))
|
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)
|
self.assertIn('-{"routes": []}', entries[0].diff)
|
||||||
|
|
||||||
def test_reject_does_not_call_apply(self):
|
def test_reject_does_not_call_apply(self):
|
||||||
@@ -282,13 +293,13 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
|||||||
dashboard.apply_routes_change = lambda slug, content: (
|
dashboard.apply_routes_change = lambda slug, content: (
|
||||||
called.append(True) or ("", content)
|
called.append(True) or ("", content)
|
||||||
)
|
)
|
||||||
qp = self._enqueue_cred_proxy()
|
qp = self._enqueue_egress_proxy()
|
||||||
dashboard.reject(qp, reason="no thanks")
|
dashboard.reject(qp, reason="no thanks")
|
||||||
self.assertEqual([], called)
|
self.assertEqual([], called)
|
||||||
# Reject still writes a response + audit entry with empty diff.
|
# Reject still writes a response + audit entry with empty diff.
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_REJECTED, resp.status)
|
self.assertEqual(STATUS_REJECTED, resp.status)
|
||||||
entries = read_audit_entries("cred-proxy", "dev")
|
entries = read_audit_entries("egress-proxy", "dev")
|
||||||
self.assertEqual(1, len(entries))
|
self.assertEqual(1, len(entries))
|
||||||
self.assertEqual("", entries[0].diff)
|
self.assertEqual("", entries[0].diff)
|
||||||
|
|
||||||
@@ -432,7 +443,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
|||||||
dashboard.approve(qp)
|
dashboard.approve(qp)
|
||||||
# capability-block has no audit log per PRD 0013 — its record
|
# capability-block has no audit log per PRD 0013 — its record
|
||||||
# lives in the per-bottle Dockerfile + transcript state.
|
# lives in the per-bottle Dockerfile + transcript state.
|
||||||
self.assertEqual([], read_audit_entries("cred-proxy", "dev"))
|
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
|
||||||
self.assertEqual([], read_audit_entries("pipelock", "dev"))
|
self.assertEqual([], read_audit_entries("pipelock", "dev"))
|
||||||
|
|
||||||
def test_proposal_archived_after_apply(self):
|
def test_proposal_archived_after_apply(self):
|
||||||
@@ -464,7 +475,7 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
|
|||||||
'{"routes": []}\n', content,
|
'{"routes": []}\n', content,
|
||||||
)
|
)
|
||||||
dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n')
|
dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n')
|
||||||
entries = read_audit_entries("cred-proxy", "dev")
|
entries = read_audit_entries("egress-proxy", "dev")
|
||||||
self.assertEqual(1, len(entries))
|
self.assertEqual(1, len(entries))
|
||||||
self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action)
|
self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action)
|
||||||
self.assertEqual("", entries[0].justification)
|
self.assertEqual("", entries[0].justification)
|
||||||
@@ -472,14 +483,14 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase):
|
|||||||
|
|
||||||
def test_failure_does_not_write_audit(self):
|
def test_failure_does_not_write_audit(self):
|
||||||
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
|
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
|
||||||
CredProxyApplyError("nope")
|
EgressProxyApplyError("nope")
|
||||||
)
|
)
|
||||||
with self.assertRaises(CredProxyApplyError):
|
with self.assertRaises(EgressProxyApplyError):
|
||||||
dashboard.operator_edit_routes("dev", '{"routes": []}\n')
|
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
|
"""Slug-extraction parsing — exercises only the parsing path; the
|
||||||
docker ps invocation itself is environment-dependent (and tested
|
docker ps invocation itself is environment-dependent (and tested
|
||||||
implicitly by the integration test)."""
|
implicitly by the integration test)."""
|
||||||
@@ -491,7 +502,7 @@ class TestDiscoverCredProxySlugs(unittest.TestCase):
|
|||||||
original = os.environ.get("PATH", "")
|
original = os.environ.get("PATH", "")
|
||||||
os.environ["PATH"] = "/nonexistent-no-docker-here"
|
os.environ["PATH"] = "/nonexistent-no-docker-here"
|
||||||
try:
|
try:
|
||||||
self.assertEqual([], dashboard.discover_cred_proxy_slugs())
|
self.assertEqual([], dashboard.discover_egress_proxy_slugs())
|
||||||
self.assertEqual([], dashboard.discover_pipelock_slugs())
|
self.assertEqual([], dashboard.discover_pipelock_slugs())
|
||||||
finally:
|
finally:
|
||||||
os.environ["PATH"] = original
|
os.environ["PATH"] = original
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from claude_bottle.cli import dashboard
|
|||||||
from claude_bottle.supervise import (
|
from claude_bottle.supervise import (
|
||||||
Proposal,
|
Proposal,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_CRED_PROXY_BLOCK,
|
TOOL_EGRESS_PROXY_BLOCK,
|
||||||
TOOL_PIPELOCK_BLOCK,
|
TOOL_PIPELOCK_BLOCK,
|
||||||
sha256_hex,
|
sha256_hex,
|
||||||
)
|
)
|
||||||
@@ -46,9 +46,9 @@ class TestPipelockHostHighlight(unittest.TestCase):
|
|||||||
green_lines = [text for text, attr in lines if attr == self.GREEN]
|
green_lines = [text for text, attr in lines if attr == self.GREEN]
|
||||||
self.assertEqual(["api.github.com"], green_lines)
|
self.assertEqual(["api.github.com"], green_lines)
|
||||||
|
|
||||||
def test_no_green_lines_for_cred_proxy_block(self):
|
def test_no_green_lines_for_egress_proxy_block(self):
|
||||||
lines = dashboard._detail_lines(
|
lines = dashboard._detail_lines(
|
||||||
_qp(TOOL_CRED_PROXY_BLOCK, '{"routes": []}'),
|
_qp(TOOL_EGRESS_PROXY_BLOCK, '{"routes": []}'),
|
||||||
green_attr=self.GREEN,
|
green_attr=self.GREEN,
|
||||||
)
|
)
|
||||||
self.assertEqual([], [t for t, a in lines if a == self.GREEN])
|
self.assertEqual([], [t for t, a in lines if a == self.GREEN])
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import json
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from claude_bottle.egress_proxy import (
|
from claude_bottle.egress_proxy import (
|
||||||
|
DEFAULT_ALLOWLIST,
|
||||||
|
egress_proxy_manifest_routes,
|
||||||
egress_proxy_render_routes,
|
egress_proxy_render_routes,
|
||||||
egress_proxy_resolve_token_values,
|
egress_proxy_resolve_token_values,
|
||||||
egress_proxy_routes_for_bottle,
|
egress_proxy_routes_for_bottle,
|
||||||
@@ -27,7 +29,7 @@ class TestRoutesForBottle(unittest.TestCase):
|
|||||||
"host": "api.github.com",
|
"host": "api.github.com",
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
|
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
|
||||||
}])
|
}])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
self.assertEqual(1, len(routes))
|
self.assertEqual(1, len(routes))
|
||||||
r = routes[0]
|
r = routes[0]
|
||||||
self.assertEqual("api.github.com", r.host)
|
self.assertEqual("api.github.com", r.host)
|
||||||
@@ -38,7 +40,7 @@ class TestRoutesForBottle(unittest.TestCase):
|
|||||||
|
|
||||||
def test_unauthenticated_route_has_empty_auth_fields(self):
|
def test_unauthenticated_route_has_empty_auth_fields(self):
|
||||||
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
|
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
r = routes[0]
|
r = routes[0]
|
||||||
self.assertEqual("", r.auth_scheme)
|
self.assertEqual("", r.auth_scheme)
|
||||||
self.assertEqual("", r.token_env)
|
self.assertEqual("", r.token_env)
|
||||||
@@ -52,7 +54,7 @@ class TestRoutesForBottle(unittest.TestCase):
|
|||||||
{"host": "github.com",
|
{"host": "github.com",
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}},
|
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}},
|
||||||
])
|
])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
slots = {r.token_env for r in routes}
|
slots = {r.token_env for r in routes}
|
||||||
self.assertEqual({"EGRESS_PROXY_TOKEN_0"}, slots)
|
self.assertEqual({"EGRESS_PROXY_TOKEN_0"}, slots)
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ class TestRoutesForBottle(unittest.TestCase):
|
|||||||
{"host": "b.example",
|
{"host": "b.example",
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
|
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
|
||||||
])
|
])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
slots = [r.token_env for r in routes]
|
slots = [r.token_env for r in routes]
|
||||||
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], slots)
|
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], slots)
|
||||||
|
|
||||||
@@ -77,12 +79,56 @@ class TestRoutesForBottle(unittest.TestCase):
|
|||||||
{"host": "b.example",
|
{"host": "b.example",
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
|
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
|
||||||
])
|
])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
authed = [r.token_env for r in routes if r.token_env]
|
authed = [r.token_env for r in routes if r.token_env]
|
||||||
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], authed)
|
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], authed)
|
||||||
self.assertEqual("", routes[1].token_env)
|
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):
|
class TestTokenEnvMap(unittest.TestCase):
|
||||||
def test_only_authenticated_routes_contribute(self):
|
def test_only_authenticated_routes_contribute(self):
|
||||||
b = _bottle([
|
b = _bottle([
|
||||||
@@ -90,7 +136,7 @@ class TestTokenEnvMap(unittest.TestCase):
|
|||||||
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
|
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
|
||||||
{"host": "passthrough.example"},
|
{"host": "passthrough.example"},
|
||||||
])
|
])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
m = egress_proxy_token_env_map(routes)
|
m = egress_proxy_token_env_map(routes)
|
||||||
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "T1"}, m)
|
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "T1"}, m)
|
||||||
|
|
||||||
@@ -105,7 +151,7 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
|
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
|
||||||
"path_allowlist": ["/repos/x/"],
|
"path_allowlist": ["/repos/x/"],
|
||||||
}])
|
}])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
payload = json.loads(egress_proxy_render_routes(routes))
|
payload = json.loads(egress_proxy_render_routes(routes))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[{
|
[{
|
||||||
@@ -123,7 +169,7 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
# enforces both-or-neither, so emitting empty strings would
|
# enforces both-or-neither, so emitting empty strings would
|
||||||
# round-trip as a partial pair and crash.
|
# round-trip as a partial pair and crash.
|
||||||
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
|
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
payload = json.loads(egress_proxy_render_routes(routes))
|
payload = json.loads(egress_proxy_render_routes(routes))
|
||||||
entry = payload["routes"][0]
|
entry = payload["routes"][0]
|
||||||
self.assertNotIn("auth_scheme", entry)
|
self.assertNotIn("auth_scheme", entry)
|
||||||
@@ -134,7 +180,7 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
"host": "api.anthropic.com",
|
"host": "api.anthropic.com",
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "CL"},
|
"auth": {"scheme": "Bearer", "token_ref": "CL"},
|
||||||
}])
|
}])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
payload = json.loads(egress_proxy_render_routes(routes))
|
payload = json.loads(egress_proxy_render_routes(routes))
|
||||||
self.assertNotIn("path_allowlist", payload["routes"][0])
|
self.assertNotIn("path_allowlist", payload["routes"][0])
|
||||||
|
|
||||||
@@ -149,7 +195,7 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
{"host": "github.com", "path_allowlist": ["/x/"]},
|
{"host": "github.com", "path_allowlist": ["/x/"]},
|
||||||
{"host": "api.anthropic.com"},
|
{"host": "api.anthropic.com"},
|
||||||
])
|
])
|
||||||
routes = egress_proxy_routes_for_bottle(b)
|
routes = egress_proxy_manifest_routes(b)
|
||||||
addon_routes = load_routes(egress_proxy_render_routes(routes))
|
addon_routes = load_routes(egress_proxy_render_routes(routes))
|
||||||
self.assertEqual(3, len(addon_routes))
|
self.assertEqual(3, len(addon_routes))
|
||||||
self.assertEqual("Bearer", addon_routes[0].auth_scheme)
|
self.assertEqual("Bearer", addon_routes[0].auth_scheme)
|
||||||
|
|||||||
@@ -147,18 +147,28 @@ class TestMatchRoute(unittest.TestCase):
|
|||||||
# other-host shouldn't be matched via a "ends with" check.
|
# other-host shouldn't be matched via a "ends with" check.
|
||||||
self.assertIsNone(match_route(self.ROUTES, "evil.api.github.com"))
|
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 --------------------------------------------------------------
|
# --- decide --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestDecide(unittest.TestCase):
|
class TestDecide(unittest.TestCase):
|
||||||
def test_no_matching_route_forwards(self):
|
def test_no_matching_route_blocks(self):
|
||||||
# Hostnames the operator didn't declare are not the
|
# Defense-in-depth: egress-proxy gates the bottle's allowlist
|
||||||
# egress-proxy's concern; pipelock's hostname allowlist gates
|
# too, not just pipelock. Any host the operator didn't declare
|
||||||
# them downstream.
|
# in egress_proxy.routes is 403'd at egress-proxy before it
|
||||||
|
# ever reaches pipelock.
|
||||||
d = decide((), "elsewhere.example", "/anything", {})
|
d = decide((), "elsewhere.example", "/anything", {})
|
||||||
self.assertEqual("forward", d.action)
|
self.assertEqual("block", d.action)
|
||||||
self.assertIsNone(d.inject_authorization)
|
self.assertIn("allowlist", d.reason)
|
||||||
|
self.assertIn("'elsewhere.example'", d.reason)
|
||||||
|
|
||||||
def test_path_allowlist_match_forwards(self):
|
def test_path_allowlist_match_forwards(self):
|
||||||
d = decide(
|
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):
|
class TestRouteValidation(unittest.TestCase):
|
||||||
def test_duplicate_hosts_rejected(self):
|
def test_duplicate_hosts_rejected(self):
|
||||||
# Routes match by exact host; duplicates leave the choice
|
# 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("registry.npmjs.org", eff)
|
||||||
self.assertIn("api.github.com", eff)
|
self.assertIn("api.github.com", eff)
|
||||||
|
|
||||||
def test_egress_proxy_hostname_auto_added_when_routes_exist(self):
|
def test_egress_proxy_hostname_NOT_in_pipelock_allowlist(self):
|
||||||
# Egress-proxy's outbound leg uses HTTPS_PROXY=pipelock, so
|
# The agent never dials egress-proxy via the proxy mechanism
|
||||||
# any request that flows through egress-proxy → pipelock
|
# — it IS the proxy. Pipelock receives upstream hostnames
|
||||||
# would otherwise be rejected by pipelock's hostname gate.
|
# from egress-proxy's CONNECT requests, not the
|
||||||
|
# `egress-proxy` hostname itself.
|
||||||
eff = pipelock_effective_allowlist(_bottle(_routes([
|
eff = pipelock_effective_allowlist(_bottle(_routes([
|
||||||
{"host": "x.example",
|
{"host": "x.example",
|
||||||
"auth": {"scheme": "Bearer", "token_ref": "T"}},
|
"auth": {"scheme": "Bearer", "token_ref": "T"}},
|
||||||
])))
|
])))
|
||||||
self.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)
|
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):
|
def test_supervise_hostname_auto_added_when_supervise_enabled(self):
|
||||||
# The agent's MCP client opens long-polled requests to
|
# The agent's MCP client opens long-polled requests to
|
||||||
# http://supervise:9100/. They bypass the agent's HTTP_PROXY
|
# http://supervise:9100/. They bypass the agent's HTTP_PROXY
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from claude_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_CRED_PROXY_BLOCK,
|
TOOL_EGRESS_PROXY_BLOCK,
|
||||||
TOOL_PIPELOCK_BLOCK,
|
TOOL_PIPELOCK_BLOCK,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
audit_log_path,
|
audit_log_path,
|
||||||
@@ -37,7 +37,7 @@ from claude_bottle.supervise import (
|
|||||||
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _proposal(tool: str = TOOL_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(
|
return Proposal.new(
|
||||||
bottle_slug="dev",
|
bottle_slug="dev",
|
||||||
tool=tool,
|
tool=tool,
|
||||||
@@ -54,7 +54,7 @@ class TestProposalRoundtrip(unittest.TestCase):
|
|||||||
self.assertTrue(p.id)
|
self.assertTrue(p.id)
|
||||||
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
|
self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp)
|
||||||
self.assertEqual("dev", p.bottle_slug)
|
self.assertEqual("dev", p.bottle_slug)
|
||||||
self.assertEqual(TOOL_CRED_PROXY_BLOCK, p.tool)
|
self.assertEqual(TOOL_EGRESS_PROXY_BLOCK, p.tool)
|
||||||
|
|
||||||
def test_to_from_dict_roundtrip(self):
|
def test_to_from_dict_roundtrip(self):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
@@ -139,13 +139,13 @@ class TestQueueIO(unittest.TestCase):
|
|||||||
def test_list_pending_sorted_by_arrival(self):
|
def test_list_pending_sorted_by_arrival(self):
|
||||||
# Fabricate two with explicit timestamps.
|
# Fabricate two with explicit timestamps.
|
||||||
a = Proposal.new(
|
a = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
|
||||||
proposed_file="{}", justification="early",
|
proposed_file="{}", justification="early",
|
||||||
current_file_hash="x",
|
current_file_hash="x",
|
||||||
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
b = Proposal.new(
|
b = Proposal.new(
|
||||||
bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK,
|
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
|
||||||
proposed_file="{}", justification="late",
|
proposed_file="{}", justification="late",
|
||||||
current_file_hash="x",
|
current_file_hash="x",
|
||||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||||
@@ -297,9 +297,9 @@ class TestDiffAndHash(unittest.TestCase):
|
|||||||
self.assertEqual("", render_diff("a\nb\n", "a\nb\n"))
|
self.assertEqual("", render_diff("a\nb\n", "a\nb\n"))
|
||||||
|
|
||||||
def test_render_diff_shows_changes(self):
|
def test_render_diff_shows_changes(self):
|
||||||
diff = render_diff("a\nb\nc\n", "a\nB\nc\n", label="routes.json")
|
diff = render_diff("a\nb\nc\n", "a\nB\nc\n", label="routes.yaml")
|
||||||
self.assertIn("routes.json (current)", diff)
|
self.assertIn("routes.yaml (current)", diff)
|
||||||
self.assertIn("routes.json (proposed)", diff)
|
self.assertIn("routes.yaml (proposed)", diff)
|
||||||
self.assertIn("-b", diff)
|
self.assertIn("-b", diff)
|
||||||
self.assertIn("+B", diff)
|
self.assertIn("+B", diff)
|
||||||
|
|
||||||
@@ -314,12 +314,17 @@ class TestDiffAndHash(unittest.TestCase):
|
|||||||
class TestToolConstants(unittest.TestCase):
|
class TestToolConstants(unittest.TestCase):
|
||||||
def test_tools_tuple_matches_individual_constants(self):
|
def test_tools_tuple_matches_individual_constants(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(TOOL_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,
|
supervise.TOOLS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_component_map_covers_two_remediation_tools_only(self):
|
def test_component_map_covers_two_remediation_tools_only(self):
|
||||||
self.assertIn(TOOL_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.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL)
|
||||||
self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL)
|
self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL)
|
||||||
|
|
||||||
@@ -357,20 +362,10 @@ class TestSupervisePrepare(unittest.TestCase):
|
|||||||
def test_prepare_creates_queue_and_current_config(self):
|
def test_prepare_creates_queue_and_current_config(self):
|
||||||
plan = _StubSupervise().prepare(
|
plan = _StubSupervise().prepare(
|
||||||
"dev", self.stage_dir,
|
"dev", self.stage_dir,
|
||||||
routes_content='{"routes": [{"path": "/x/"}]}\n',
|
|
||||||
allowlist_content="example.com\n",
|
|
||||||
dockerfile_content="FROM python:3.13\n",
|
dockerfile_content="FROM python:3.13\n",
|
||||||
)
|
)
|
||||||
self.assertTrue(plan.queue_dir.is_dir())
|
self.assertTrue(plan.queue_dir.is_dir())
|
||||||
self.assertTrue(plan.current_config_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(
|
self.assertEqual(
|
||||||
"FROM python:3.13\n",
|
"FROM python:3.13\n",
|
||||||
(plan.current_config_dir / "Dockerfile").read_text(),
|
(plan.current_config_dir / "Dockerfile").read_text(),
|
||||||
@@ -378,12 +373,12 @@ class TestSupervisePrepare(unittest.TestCase):
|
|||||||
self.assertEqual("dev", plan.slug)
|
self.assertEqual("dev", plan.slug)
|
||||||
self.assertEqual("", plan.internal_network)
|
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)
|
plan = _StubSupervise().prepare("dev", self.stage_dir)
|
||||||
self.assertEqual(
|
files = sorted(p.name for p in plan.current_config_dir.iterdir())
|
||||||
'{"routes": []}\n',
|
self.assertEqual(["Dockerfile"], files)
|
||||||
(plan.current_config_dir / "routes.json").read_text(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -45,22 +45,6 @@ from claude_bottle.supervise_server import (
|
|||||||
|
|
||||||
|
|
||||||
class TestValidation(unittest.TestCase):
|
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):
|
def test_pipelock_block_accepts_https_url(self):
|
||||||
validate_proposed_file(
|
validate_proposed_file(
|
||||||
_sv.TOOL_PIPELOCK_BLOCK,
|
_sv.TOOL_PIPELOCK_BLOCK,
|
||||||
@@ -89,8 +73,12 @@ class TestValidation(unittest.TestCase):
|
|||||||
"FROM python:3.13\nRUN apk add git\n",
|
"FROM python:3.13\nRUN apk add git\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_proposed_file_rejected_for_all_tools(self):
|
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
|
||||||
for tool in _sv.TOOLS:
|
# 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.subTest(tool=tool):
|
||||||
with self.assertRaises(_RpcError):
|
with self.assertRaises(_RpcError):
|
||||||
validate_proposed_file(tool, " \n\t")
|
validate_proposed_file(tool, " \n\t")
|
||||||
@@ -170,27 +158,43 @@ class TestHandleInitialize(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestHandleToolsList(unittest.TestCase):
|
class TestHandleToolsList(unittest.TestCase):
|
||||||
def test_returns_three_tools(self):
|
def test_returns_all_tools(self):
|
||||||
result = handle_tools_list({})
|
result = handle_tools_list({})
|
||||||
names = [t["name"] for t in result["tools"]] # type: ignore[index]
|
names = [t["name"] for t in result["tools"]] # type: ignore[index]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted([
|
sorted([
|
||||||
_sv.TOOL_CRED_PROXY_BLOCK,
|
_sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
_sv.TOOL_PIPELOCK_BLOCK,
|
_sv.TOOL_PIPELOCK_BLOCK,
|
||||||
_sv.TOOL_CAPABILITY_BLOCK,
|
_sv.TOOL_CAPABILITY_BLOCK,
|
||||||
|
_sv.TOOL_LIST_EGRESS_PROXY_ROUTES,
|
||||||
]),
|
]),
|
||||||
sorted(names),
|
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:
|
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"]
|
schema = tool["inputSchema"]
|
||||||
self.assertEqual("object", schema["type"]) # type: ignore[index]
|
self.assertEqual("object", schema["type"]) # type: ignore[index]
|
||||||
required = schema["required"] # type: ignore[index]
|
required = schema["required"] # type: ignore[index]
|
||||||
self.assertEqual(2, len(required))
|
self.assertEqual(2, len(required))
|
||||||
self.assertIn("justification", 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):
|
class TestHandleToolsCall(unittest.TestCase):
|
||||||
@@ -225,9 +229,9 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
result = handle_tools_call(
|
result = handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CRED_PROXY_BLOCK,
|
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"routes": '{"routes": []}',
|
"host": "example.com",
|
||||||
"justification": "need a route",
|
"justification": "need a route",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -269,8 +273,8 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
with self.assertRaises(_RpcError):
|
with self.assertRaises(_RpcError):
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CRED_PROXY_BLOCK,
|
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
"arguments": {"routes": '{"routes": []}'},
|
"arguments": {"host": "example.com"},
|
||||||
},
|
},
|
||||||
self.config,
|
self.config,
|
||||||
)
|
)
|
||||||
@@ -280,9 +284,9 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CRED_PROXY_BLOCK,
|
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"routes": '{"routes": []}',
|
"host": "example.com",
|
||||||
"justification": "x",
|
"justification": "x",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -367,7 +371,7 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
self.assertEqual("2.0", result["jsonrpc"])
|
self.assertEqual("2.0", result["jsonrpc"])
|
||||||
self.assertEqual(1, result["id"])
|
self.assertEqual(1, result["id"])
|
||||||
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
|
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
|
||||||
self.assertIn(_sv.TOOL_CRED_PROXY_BLOCK, names)
|
self.assertIn(_sv.TOOL_EGRESS_PROXY_BLOCK, names)
|
||||||
|
|
||||||
def test_unknown_method_returns_jsonrpc_error(self):
|
def test_unknown_method_returns_jsonrpc_error(self):
|
||||||
result = self._post_jsonrpc(
|
result = self._post_jsonrpc(
|
||||||
|
|||||||
Reference in New Issue
Block a user