feat(supervise): list-egress-proxy-routes MCP tool, defaults on egress-proxy
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m7s

Reshape the allowlist topology so the egress-proxy is the bottle's
single allowlist surface, and replace the agent-side
routes/allowlist file mounts with a live MCP tool.

Policy change (move defaults to egress-proxy):

  - `egress_proxy_routes_for_bottle(bottle)` now folds in
    DEFAULT_ALLOWLIST (the claude-code defaults) and
    `bottle.egress.allowlist` (user adds) as bare-pass routes (no
    auth, no path filter), on top of the bottle's
    `egress_proxy.routes`. Manifest routes win on host collision.
  - `pipelock_effective_allowlist(bottle)` mirrors egress-proxy's
    effective host set when egress-proxy is in use. Pipelock is
    no longer the bottle's primary allowlist authority; it
    enforces a downstream copy as defense-in-depth + does DLP body
    scanning.
  - Split out `egress_proxy_manifest_routes(bottle)` for callers
    that want just the manifest entries (tests, internal use).
  - DEFAULT_ALLOWLIST moves from `pipelock.py` to `egress_proxy.py`
    (pipelock re-imports for the no-egress-proxy fallback path).
  - Dropped the `egress-proxy` auto-allow on pipelock's allowlist
    — the agent never dials egress-proxy via the proxy mechanism;
    pipelock only sees upstream hostnames from egress-proxy's
    CONNECTs.

Introspection endpoint (existing mitmproxy feature):

  - Egress-proxy addon recognises requests to the magic host
    `_egress-proxy.local` and synthesizes responses via
    `flow.response = http.Response.make(...)` — no upstream
    connection, no allowlist enforcement on the magic host.
  - `GET /allowlist` returns the in-memory route table as JSON
    (host + path_allowlist + auth_scheme + token_env per route;
    no token VALUES).
  - Smoke-tested end-to-end against a real egress-proxy container.

MCP tool (existing supervise plumbing):

  - New `list-egress-proxy-routes` tool (no inputs, no operator
    approval). Handler fetches via egress-proxy's introspection
    endpoint using urllib's ProxyHandler against
    `EGRESS_PROXY_FORWARD_PROXY`. Returns the JSON payload as the
    tool's text content; `isError: true` if the proxy is
    unreachable.
  - `egress-proxy-block` description now points the agent at
    `list-egress-proxy-routes` instead of a staged file path.
  - `pipelock-block` description acknowledges the mirror — agents
    should prefer `egress-proxy-block` to add hosts; pipelock-block
    stays for the rare divergence case.

Drop agent-side file mounts:

  - Supervise's `current-config` dir staging no longer writes
    routes.yaml / allowlist. Only `Dockerfile` remains
    (capability-block still reads it from
    `/etc/claude-bottle/current-config/Dockerfile`).
  - `prepare.py` stops passing `routes_content` /
    `allowlist_content` to `supervise.prepare`.
  - `Supervise.prepare` signature simplified to one
    `dockerfile_content` kwarg.

Tests: 400 unit + integration pass. Added coverage for
defaults-folding (`TestRoutesForBottleFoldsDefaults`), the new
tool definition + handler, and the updated supervise.prepare
shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 18:23:01 -04:00
parent 1cec0d9aa6
commit 3be70eb07a
12 changed files with 410 additions and 144 deletions
@@ -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
+4 -8
View File
@@ -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)
+50 -2
View File
@@ -127,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
@@ -138,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:
@@ -164,6 +186,30 @@ def egress_proxy_routes_for_bottle(
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]:
@@ -286,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",
+46 -2
View File
@@ -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
+42 -41
View File
@@ -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())
+33 -32
View File
@@ -52,12 +52,24 @@ SUPERVISE_PORT = 9100
TOOL_EGRESS_PROXY_BLOCK = "egress-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_EGRESS_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
@@ -422,17 +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
# Filename of the staged egress-proxy routes file inside the agent's # Dockerfile and propose modifications.
# read-only current-config mount. JSON content under a `.yaml` #
# extension to match the live file the egress-proxy sidecar reads # routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
# (`/etc/egress-proxy/routes.yaml`) — the egress-proxy-block tool # moved them behind the `list-egress-proxy-routes` MCP tool (live
# description points at this exact path, and the apply step writes # state from egress-proxy's introspection endpoint) so the agent
# the new content to the matching live path. # always sees current data rather than a launch-time snapshot.
CURRENT_CONFIG_ROUTES = "routes.yaml"
CURRENT_CONFIG_ALLOWLIST = "allowlist"
CURRENT_CONFIG_DOCKERFILE = "Dockerfile" CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
@@ -442,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.yaml + 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
@@ -465,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
@@ -477,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,
@@ -554,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",
@@ -571,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_EGRESS_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",
+88 -21
View File
@@ -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
@@ -132,16 +134,17 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"description": ( "description": (
"Call when egress-proxy refused your HTTPS request — host " "Call when egress-proxy refused your HTTPS request — host "
"without a matching route, or a path outside the route's " "without a matching route, or a path outside the route's "
"path_allowlist (typically a 403 from the proxy). Read " "path_allowlist (typically a 403 from the proxy). First "
"the current routes.yaml from " "call `list-egress-proxy-routes` to see the current route "
"/etc/claude-bottle/current-config/routes.yaml, compose " "table; compose a modified version that adds or relaxes "
"a modified version that adds or relaxes the route you " "the route you need, and pass the full new file plus a "
"need, and pass the full new file plus a justification. " "justification. The operator approves or rejects in the "
"The operator approves or rejects in the supervise TUI. " "supervise TUI. On approval the supervisor writes the "
"On approval the supervisor writes the new routes.yaml " "new routes.yaml on the host, SIGHUPs egress-proxy (the "
"on the host and SIGHUPs egress-proxy (the addon's reload " "addon's reload swaps the route table atomically without "
"swaps the route table atomically without dropping " "dropping in-flight connections), and mirrors the route "
"in-flight connections)." "hosts onto pipelock's allowlist so the downstream gate "
"lets them through too."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -158,19 +161,41 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"required": ["routes", "justification"], "required": ["routes", "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",
@@ -308,15 +333,57 @@ 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 == _sv.TOOL_LIST_EGRESS_PROXY_ROUTES:
return handle_list_egress_proxy_routes(params.get("arguments", {}), config)
if name not in PROPOSED_FILE_FIELD: if name not in PROPOSED_FILE_FIELD:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}") raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
args_raw = params.get("arguments", {}) args_raw = params.get("arguments", {})
@@ -199,6 +199,7 @@ class TestSuperviseSidecar(unittest.TestCase):
_sv.TOOL_EGRESS_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,
) )
+56 -10
View File
@@ -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)
+17 -8
View File
@@ -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
+11 -16
View File
@@ -314,7 +314,12 @@ 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_EGRESS_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,
) )
@@ -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.yaml").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.yaml").read_text(),
)
if __name__ == "__main__": if __name__ == "__main__":
+20 -4
View File
@@ -170,7 +170,7 @@ 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(
@@ -178,19 +178,35 @@ class TestHandleToolsList(unittest.TestCase):
_sv.TOOL_EGRESS_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):