feat(egress-proxy): retarget remediation at egress-proxy (PRD 0017 chunk 3)
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m6s

Finishes PRD 0017. The `cred-proxy-block` MCP tool is renamed and
its remediation apply path is repointed at egress-proxy.

  - `claude_bottle/supervise.py` — `TOOL_CRED_PROXY_BLOCK` →
    `TOOL_EGRESS_PROXY_BLOCK`; `COMPONENT_FOR_TOOL` maps the new
    tool ID to `egress-proxy` for audit-log routing.

  - `claude_bottle/supervise_server.py` — tool definition renamed
    + description rewritten: "Call when egress-proxy refused your
    HTTPS request ... Read the current routes.yaml from /etc/
    claude-bottle/current-config/routes.yaml, compose a modified
    version, pass the full new file plus a justification." The
    syntactic validator dispatches on the new tool ID.

  - `claude_bottle/backend/docker/egress_proxy_apply.py` — renamed
    from `cred_proxy_apply.py`. Reads routes.yaml from
    /etc/egress-proxy/routes.yaml via `docker exec cat`; validates
    via `egress_proxy_addon_core.load_routes` (so both sides use
    the same parser); writes via `docker cp`; SIGHUPs egress-proxy
    with `docker kill --signal HUP`. `EgressProxyApplyError`
    replaces `CredProxyApplyError`.

  - `claude_bottle/cli/dashboard.py` — wires the new apply +
    `discover_egress_proxy_slugs` helper; the operator-initiated
    `routes edit <bottle>` verb now writes to egress-proxy with
    `.yaml` suffix. Stale follow-up comment about path-aware
    filtering removed — PRD 0017 settled that question.

  - `tests/integration/test_supervise_sidecar.py` — restores the
    approval round-trip test (chunk 2 had switched it to a reject
    path because no cred-proxy existed). Approval stubs
    `apply_routes_change` so the test focuses on the supervise
    queue/response plumbing rather than docker-exec into a real
    egress-proxy sidecar (that's covered separately).

  - `tests/unit/test_egress_proxy_apply.py` — rewritten against
    the new validator; covers JSON shape, missing routes key,
    partial-auth-pair rejection (the addon-core parser catches
    these before SIGHUP).

  - PRDs 0010 + 0014 — status headers updated to
    Superseded / Retargeted with a callout block pointing at PRD
    0017's migration section. Historical text preserved.

384 unit + integration tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:13:44 -04:00
parent a135415dfe
commit 9cd583fbbb
14 changed files with 361 additions and 333 deletions
@@ -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",
]
@@ -0,0 +1,116 @@
"""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.
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 os
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
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 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. Write to a temp file, `docker cp` into the sidecar.
4. `docker kill --signal HUP` so the addon reloads.
Returns (before, after) where `after` == `new_content`. Raises
EgressProxyApplyError 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 = egress_proxy_container_name(slug)
before = fetch_current_routes(slug)
validate_routes_content(new_content)
fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".yaml")
try:
with os.fdopen(fd, "w") as f:
f.write(new_content)
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
__all__ = [
"EgressProxyApplyError",
"apply_routes_change",
"fetch_current_routes",
"validate_routes_content",
]
+47 -49
View File
@@ -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,8 @@ 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,
apply_routes_change, apply_routes_change,
fetch_current_routes, fetch_current_routes,
) )
@@ -50,7 +50,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 +64,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 +103,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,16 +156,16 @@ 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( diff_before, diff_after = apply_routes_change(
qp.proposal.bottle_slug, file_to_apply, qp.proposal.bottle_slug, file_to_apply,
) )
@@ -212,22 +212,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 +239,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 +295,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 +681,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",
) )
+5 -5
View File
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (claude_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the 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,11 +49,11 @@ 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"
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,
) )
@@ -63,7 +63,7 @@ TOOLS: tuple[str, ...] = (
# 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",
} }
@@ -566,7 +566,7 @@ __all__ = [
"SupervisePlan", "SupervisePlan",
"TOOLS", "TOOLS",
"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_dir", "audit_dir",
+27 -26
View File
@@ -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:
@@ -128,26 +128,27 @@ 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). Read "
"current routes.json from " "the current routes.yaml from "
"/etc/claude-bottle/current-config/routes.json, compose a " "/etc/claude-bottle/current-config/routes.yaml, compose "
"modified version with the route you need, and pass the " "a modified version that adds or relaxes the route you "
"full new file plus a justification. The operator approves " "need, and pass the full new file plus a justification. "
"or rejects in the supervise TUI. On approval the supervisor " "The operator approves or rejects in the supervise TUI. "
"writes the new routes.json on the host and SIGHUPs cred-proxy " "On approval the supervisor writes the new routes.yaml "
"(wired in PRD 0014; in the v1 supervise foundation the " "on the host and SIGHUPs egress-proxy (the addon's reload "
"approval is acknowledged but no config change runs)." "swaps the route table atomically without dropping "
"in-flight connections)."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"routes": { "routes": {
"type": "string", "type": "string",
"description": "Full proposed routes.json file content (JSON text).", "description": "Full proposed routes.yaml file content (JSON text — every JSON document is valid YAML).",
}, },
"justification": { "justification": {
"type": "string", "type": "string",
@@ -226,15 +227,15 @@ 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: full proposed routes.yaml
# pipelock-block: the full failed URL (scheme + host + path) — # pipelock-block: the full failed URL (scheme + host + path) —
# supervisor extracts the host, merges into the # supervisor extracts the host, merges into the
# bottle's current allowlist; the path is shown # bottle's current allowlist; the path is shown
# to the operator for context (pipelock doesn't # to the operator for context (pipelock doesn't
# do path-level matching). # do path-level matching).
# capability-block: full proposed Dockerfile # capability-block: full proposed Dockerfile
PROPOSED_FILE_FIELD: dict[str, str] = { PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_CRED_PROXY_BLOCK: "routes", _sv.TOOL_EGRESS_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",
} }
@@ -249,18 +250,18 @@ def validate_proposed_file(tool: str, content: str) -> None:
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_EGRESS_PROXY_BLOCK:
try: try:
parsed = json.loads(content) parsed = json.loads(content)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise _RpcError( raise _RpcError(
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
f"{tool}: proposed routes.json is not valid JSON: {e}", f"{tool}: proposed routes.yaml is not valid JSON: {e}",
) from e ) from e
if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list): if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list):
raise _RpcError( raise _RpcError(
ERR_INVALID_PARAMS, ERR_INVALID_PARAMS,
f"{tool}: proposed routes.json must be an object with a 'routes' array", f"{tool}: proposed routes.yaml must be an object with a 'routes' array",
) )
elif tool == _sv.TOOL_PIPELOCK_BLOCK: 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
@@ -505,7 +506,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")
+9 -1
View File
@@ -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
+13 -1
View File
@@ -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.
+32 -23
View File
@@ -196,7 +196,7 @@ 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,
}, },
@@ -204,28 +204,38 @@ class TestSuperviseSidecar(unittest.TestCase):
) )
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
# apply_routes_change 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.apply_routes_change
_dash.apply_routes_change = (
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/"}]}', "routes": '{"routes": [{"host": "api.example.com"}]}',
"justification": "integration test", "justification": "integration test",
}, },
}, },
@@ -249,16 +259,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.apply_routes_change = original_apply
t.join(timeout=20) t.join(timeout=20)
response = captured.get("response") response = captured.get("response")
@@ -267,12 +278,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
-39
View File
@@ -1,39 +0,0 @@
"""Unit: validate_routes_json (PRD 0014 Phase 2). docker exec / cp /
kill paths are covered by the integration test."""
import unittest
from claude_bottle.backend.docker.cred_proxy_apply import (
CredProxyApplyError,
validate_routes_json,
)
class TestValidateRoutesJson(unittest.TestCase):
def test_accepts_routes_array(self):
validate_routes_json('{"routes": []}')
validate_routes_json(
'{"routes": [{"path": "/a/", "upstream": "https://example.com",'
' "auth_scheme": "Bearer", "token_env": "T0"}]}'
)
def test_rejects_bad_json(self):
with self.assertRaises(CredProxyApplyError) as cm:
validate_routes_json("{not json")
self.assertIn("not valid JSON", str(cm.exception))
def test_rejects_non_object_top_level(self):
with self.assertRaises(CredProxyApplyError):
validate_routes_json("[]")
def test_rejects_missing_routes_key(self):
with self.assertRaises(CredProxyApplyError):
validate_routes_json('{"other": []}')
def test_rejects_non_list_routes(self):
with self.assertRaises(CredProxyApplyError):
validate_routes_json('{"routes": "not a list"}')
if __name__ == "__main__":
unittest.main()
+35 -35
View File
@@ -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.json, 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),
@@ -151,7 +151,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
dashboard.apply_capability_change = self._original_apply_capability dashboard.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_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,18 +193,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
dashboard.approve(qp) dashboard.approve(qp)
# No audit log for capability-block (per PRD 0013 / 0016). # No audit log for capability-block (per PRD 0013 / 0016).
# cred-proxy and pipelock logs both empty. # cred-proxy and pipelock logs both empty.
self.assertEqual([], read_audit_entries("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 0014 Phase 3: approve() on a egress-proxy-block proposal
must call apply_routes_change with the right args and surface must call apply_routes_change with the right args and surface
its failures.""" its failures."""
@@ -216,9 +216,9 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.apply_routes_change = self._original_apply dashboard.apply_routes_change = self._original_apply
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue_cred_proxy(self, proposed: str = '{"routes": []}\n'): def _enqueue_egress_proxy(self, proposed: str = '{"routes": []}\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,12 +229,12 @@ 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_apply_with_proposed_file(self):
calls = [] calls = []
dashboard.apply_routes_change = lambda slug, content: ( dashboard.apply_routes_change = lambda slug, content: (
calls.append((slug, content)) or ("before", content) calls.append((slug, content)) or ("before", content)
) )
qp = self._enqueue_cred_proxy(proposed='{"routes": [{"path": "/new/"}]}\n') qp = self._enqueue_egress_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]
@@ -246,16 +246,16 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
dashboard.apply_routes_change = lambda slug, content: ( dashboard.apply_routes_change = lambda slug, content: (
calls.append(content) or ("before", content) calls.append(content) or ("before", content)
) )
qp = self._enqueue_cred_proxy() qp = self._enqueue_egress_proxy()
dashboard.approve(qp, final_file='{"routes": [{"path": "/edited/"}]}\n', notes="tweaked") dashboard.approve(qp, final_file='{"routes": [{"path": "/edited/"}]}\n', notes="tweaked")
self.assertEqual(['{"routes": [{"path": "/edited/"}]}\n'], calls) self.assertEqual(['{"routes": [{"path": "/edited/"}]}\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.apply_routes_change = 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,16 +263,16 @@ 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.apply_routes_change = lambda slug, content: (
'{"routes": []}\n', # before '{"routes": []}\n', # before
'{"routes": [{"path": "/new/"}]}\n', # after '{"routes": [{"path": "/new/"}]}\n', # after
) )
qp = self._enqueue_cred_proxy(proposed='{"routes": [{"path": "/new/"}]}\n') qp = self._enqueue_egress_proxy(proposed='{"routes": [{"path": "/new/"}]}\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": [{"path": "/new/"}]}', entries[0].diff)
self.assertIn('-{"routes": []}', entries[0].diff) self.assertIn('-{"routes": []}', entries[0].diff)
@@ -282,13 +282,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 +432,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 +464,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 +472,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 +491,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
+3 -3
View File
@@ -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])
+56
View File
@@ -0,0 +1,56 @@
"""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
from claude_bottle.backend.docker.egress_proxy_apply import (
EgressProxyApplyError,
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"}]}'
)
if __name__ == "__main__":
unittest.main()
+7 -7
View File
@@ -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),
@@ -314,12 +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_CRED_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK), (TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK),
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)
+11 -11
View File
@@ -45,19 +45,19 @@ from claude_bottle.supervise_server import (
class TestValidation(unittest.TestCase): class TestValidation(unittest.TestCase):
def test_cred_proxy_block_requires_valid_json(self): def test_egress_proxy_block_requires_valid_json(self):
with self.assertRaises(_RpcError) as cm: with self.assertRaises(_RpcError) as cm:
validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, "{not json") validate_proposed_file(_sv.TOOL_EGRESS_PROXY_BLOCK, "{not json")
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("not valid JSON", cm.exception.message) self.assertIn("not valid JSON", cm.exception.message)
def test_cred_proxy_block_requires_routes_array(self): def test_egress_proxy_block_requires_routes_array(self):
with self.assertRaises(_RpcError): with self.assertRaises(_RpcError):
validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, '{"other": []}') validate_proposed_file(_sv.TOOL_EGRESS_PROXY_BLOCK, '{"other": []}')
def test_cred_proxy_block_accepts_valid_routes(self): def test_egress_proxy_block_accepts_valid_routes(self):
validate_proposed_file( validate_proposed_file(
_sv.TOOL_CRED_PROXY_BLOCK, _sv.TOOL_EGRESS_PROXY_BLOCK,
'{"routes": [{"path": "/x/", "upstream": "https://example.com"}]}', '{"routes": [{"path": "/x/", "upstream": "https://example.com"}]}',
) )
@@ -175,7 +175,7 @@ class TestHandleToolsList(unittest.TestCase):
names = [t["name"] for t in result["tools"]] # type: ignore[index] names = [t["name"] for t in result["tools"]] # type: ignore[index]
self.assertEqual( self.assertEqual(
sorted([ sorted([
_sv.TOOL_CRED_PROXY_BLOCK, _sv.TOOL_EGRESS_PROXY_BLOCK,
_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_PIPELOCK_BLOCK,
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,
]), ]),
@@ -225,7 +225,7 @@ 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": []}', "routes": '{"routes": []}',
"justification": "need a route", "justification": "need a route",
@@ -269,7 +269,7 @@ 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": {"routes": '{"routes": []}'},
}, },
self.config, self.config,
@@ -280,7 +280,7 @@ 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": []}', "routes": '{"routes": []}',
"justification": "x", "justification": "x",
@@ -367,7 +367,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(