feat(egress-proxy): retarget remediation at egress-proxy (PRD 0017 chunk 3)
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:
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
# PRD 0010: Credential proxy for agent-bound API tokens
|
# PRD 0010: Credential proxy for agent-bound API tokens
|
||||||
|
|
||||||
- **Status:** Draft
|
- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-13
|
- **Created:** 2026-05-13
|
||||||
|
- **Superseded:** 2026-05-25
|
||||||
|
|
||||||
|
> **Historical reference only.** The cred-proxy sidecar this PRD
|
||||||
|
> describes was replaced by the egress-proxy sidecar (PRD 0017) in
|
||||||
|
> a hard cutover. The auth-injection role moved over largely intact;
|
||||||
|
> path-prefix routing is replaced by universal MITM at the agent's
|
||||||
|
> HTTP_PROXY. See PRD 0017's "Migration — hard cutover" section for
|
||||||
|
> the field-by-field manifest rename.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
# PRD 0014: cred-proxy block remediation
|
# PRD 0014: cred-proxy block remediation
|
||||||
|
|
||||||
- **Status:** Draft
|
- **Status:** Retargeted by [PRD 0017](0017-egress-proxy-via-mitmproxy.md)
|
||||||
- **Author:** didericis
|
- **Author:** didericis
|
||||||
- **Created:** 2026-05-25
|
- **Created:** 2026-05-25
|
||||||
|
- **Retargeted:** 2026-05-25
|
||||||
- **Parent:** PRD 0012
|
- **Parent:** PRD 0012
|
||||||
- **Depends on:** PRD 0013
|
- **Depends on:** PRD 0013
|
||||||
|
|
||||||
|
> **Retarget notice.** The remediation shape this PRD describes (MCP
|
||||||
|
> tool → operator approve → SIGHUP a sidecar) is intact, but the
|
||||||
|
> sidecar moved: cred-proxy is gone, replaced by egress-proxy under
|
||||||
|
> PRD 0017. The MCP tool is now named `egress-proxy-block`; the
|
||||||
|
> proposed file is `routes.yaml` (JSON content) in egress-proxy's
|
||||||
|
> route shape (host + path_allowlist + nested `auth` block); the
|
||||||
|
> apply path docker-cps + SIGHUPs egress-proxy. The audit-log
|
||||||
|
> component label changed from `cred-proxy` to `egress-proxy`.
|
||||||
|
> Operator-initiated `routes edit <bottle>` still exists with the
|
||||||
|
> same UX, now pointed at the egress-proxy sidecar.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Wires the **cred-proxy block** path (PRD 0012 *Stuck categories*) end-to-end. cred-proxy gains SIGHUP-based hot reload of `routes.json`. The supervisor, on approval of a `cred-proxy-block` proposal, writes the new `routes.json` to the host and SIGHUPs cred-proxy — no restart, no dropped connections. The TUI gains a proactive `routes edit <bottle>` verb for operator-initiated edits unrelated to a tool call. The cred-proxy audit log (format defined in PRD 0013) is filled in with real entries on every edit.
|
Wires the **cred-proxy block** path (PRD 0012 *Stuck categories*) end-to-end. cred-proxy gains SIGHUP-based hot reload of `routes.json`. The supervisor, on approval of a `cred-proxy-block` proposal, writes the new `routes.json` to the host and SIGHUPs cred-proxy — no restart, no dropped connections. The TUI gains a proactive `routes edit <bottle>` verb for operator-initiated edits unrelated to a tool call. The cred-proxy audit log (format defined in PRD 0013) is filled in with real entries on every edit.
|
||||||
|
|||||||
@@ -196,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
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Unit: validate_routes_json (PRD 0014 Phase 2). docker exec / cp /
|
|
||||||
kill paths are covered by the integration test."""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from claude_bottle.backend.docker.cred_proxy_apply import (
|
|
||||||
CredProxyApplyError,
|
|
||||||
validate_routes_json,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidateRoutesJson(unittest.TestCase):
|
|
||||||
def test_accepts_routes_array(self):
|
|
||||||
validate_routes_json('{"routes": []}')
|
|
||||||
validate_routes_json(
|
|
||||||
'{"routes": [{"path": "/a/", "upstream": "https://example.com",'
|
|
||||||
' "auth_scheme": "Bearer", "token_env": "T0"}]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_bad_json(self):
|
|
||||||
with self.assertRaises(CredProxyApplyError) as cm:
|
|
||||||
validate_routes_json("{not json")
|
|
||||||
self.assertIn("not valid JSON", str(cm.exception))
|
|
||||||
|
|
||||||
def test_rejects_non_object_top_level(self):
|
|
||||||
with self.assertRaises(CredProxyApplyError):
|
|
||||||
validate_routes_json("[]")
|
|
||||||
|
|
||||||
def test_rejects_missing_routes_key(self):
|
|
||||||
with self.assertRaises(CredProxyApplyError):
|
|
||||||
validate_routes_json('{"other": []}')
|
|
||||||
|
|
||||||
def test_rejects_non_list_routes(self):
|
|
||||||
with self.assertRaises(CredProxyApplyError):
|
|
||||||
validate_routes_json('{"routes": "not a list"}')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from claude_bottle import supervise
|
from claude_bottle import supervise
|
||||||
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
|
from claude_bottle.backend.docker.capability_apply import CapabilityApplyError
|
||||||
from claude_bottle.backend.docker.cred_proxy_apply import CredProxyApplyError
|
from claude_bottle.backend.docker.egress_proxy_apply import EgressProxyApplyError
|
||||||
from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError
|
from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError
|
||||||
from claude_bottle.cli import dashboard
|
from claude_bottle.cli import dashboard
|
||||||
from claude_bottle.supervise import (
|
from claude_bottle.supervise import (
|
||||||
@@ -26,7 +26,7 @@ from claude_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_CRED_PROXY_BLOCK,
|
TOOL_EGRESS_PROXY_BLOCK,
|
||||||
TOOL_PIPELOCK_BLOCK,
|
TOOL_PIPELOCK_BLOCK,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
read_response,
|
read_response,
|
||||||
@@ -37,13 +37,13 @@ from claude_bottle.supervise import (
|
|||||||
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _proposal(slug: str = "dev", tool: str = TOOL_CRED_PROXY_BLOCK) -> Proposal:
|
def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_PROXY_BLOCK) -> Proposal:
|
||||||
# Per-tool payload shape: cred-proxy gets routes.json, pipelock
|
# Per-tool payload shape: cred-proxy gets routes.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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user