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
|
||||
act on them (approve / modify / reject). PRD 0013 v1.
|
||||
|
||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. For
|
||||
0013 the approval handlers are no-ops on the supervisor side: the
|
||||
response file is written (and the sidecar returns it to the agent),
|
||||
and an audit entry is appended, but no host-side config change runs.
|
||||
PRDs 0014 (cred-proxy) and 0015 (pipelock) wire in the actual
|
||||
writes.
|
||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||
approval handlers wire to the per-tool remediation engines:
|
||||
PRD 0014 (egress-proxy, retargeted from cred-proxy in PRD 0017
|
||||
chunk 3) writes routes.yaml + SIGHUPs egress-proxy; PRD 0015
|
||||
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
|
||||
(capability) rebuilds the bottle Dockerfile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,8 +27,8 @@ from ..backend.docker.capability_apply import (
|
||||
CapabilityApplyError,
|
||||
apply_capability_change,
|
||||
)
|
||||
from ..backend.docker.cred_proxy_apply import (
|
||||
CredProxyApplyError,
|
||||
from ..backend.docker.egress_proxy_apply import (
|
||||
EgressProxyApplyError,
|
||||
apply_routes_change,
|
||||
fetch_current_routes,
|
||||
)
|
||||
@@ -50,7 +50,7 @@ from ..supervise import (
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_CRED_PROXY_BLOCK,
|
||||
TOOL_EGRESS_PROXY_BLOCK,
|
||||
TOOL_PIPELOCK_BLOCK,
|
||||
archive_proposal,
|
||||
list_pending_proposals,
|
||||
@@ -64,7 +64,7 @@ from ._common import PROG
|
||||
# Errors any remediation engine may raise. Caught by the TUI key
|
||||
# handlers and surfaced in the status line so a failed apply keeps
|
||||
# the proposal pending rather than crashing curses.
|
||||
ApplyError = (CredProxyApplyError, PipelockApplyError, CapabilityApplyError)
|
||||
ApplyError = (EgressProxyApplyError, PipelockApplyError, CapabilityApplyError)
|
||||
|
||||
|
||||
# --- Discovery -------------------------------------------------------------
|
||||
@@ -103,10 +103,10 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]:
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def discover_cred_proxy_slugs() -> list[str]:
|
||||
"""Slugs of bottles with a running cred-proxy sidecar. Used by
|
||||
def discover_egress_proxy_slugs() -> list[str]:
|
||||
"""Slugs of bottles with a running egress-proxy sidecar. Used by
|
||||
the operator-initiated `routes edit` verb."""
|
||||
return _discover_sidecar_slugs("claude-bottle-cred-proxy-")
|
||||
return _discover_sidecar_slugs("claude-bottle-egress-proxy-")
|
||||
|
||||
|
||||
def discover_pipelock_slugs() -> list[str]:
|
||||
@@ -156,16 +156,16 @@ def approve(
|
||||
entry. If `final_file` is provided the status is `modified`;
|
||||
otherwise `approved`.
|
||||
|
||||
Raises CredProxyApplyError if the cred-proxy-block apply fails
|
||||
(sidecar down, invalid JSON survived the operator's modify).
|
||||
On failure no response is written and no audit entry is
|
||||
appended — the proposal stays pending so the operator can fix
|
||||
the input and retry."""
|
||||
Raises EgressProxyApplyError if the egress-proxy-block apply
|
||||
fails (sidecar down, invalid routes content survived the
|
||||
operator's modify). On failure no response is written and no
|
||||
audit entry is appended — the proposal stays pending so the
|
||||
operator can fix the input and retry."""
|
||||
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
||||
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
||||
|
||||
diff_before, diff_after = "", ""
|
||||
if qp.proposal.tool == TOOL_CRED_PROXY_BLOCK:
|
||||
if qp.proposal.tool == TOOL_EGRESS_PROXY_BLOCK:
|
||||
diff_before, diff_after = apply_routes_change(
|
||||
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]:
|
||||
"""Apply an operator-initiated routes.json change (no agent
|
||||
"""Apply an operator-initiated routes.yaml change (no agent
|
||||
proposal). Used by the `routes edit <bottle>` TUI verb and
|
||||
available for scripted use. Returns (before, after) like
|
||||
apply_routes_change. Writes an audit entry tagged
|
||||
ACTION_OPERATOR_EDIT to distinguish from tool-call approvals.
|
||||
|
||||
Raises CredProxyApplyError on failure."""
|
||||
Raises EgressProxyApplyError on failure."""
|
||||
before, after = apply_routes_change(slug, new_content)
|
||||
write_audit_entry(AuditEntry(
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
bottle_slug=slug,
|
||||
component="cred-proxy",
|
||||
component="egress-proxy",
|
||||
operator_action=ACTION_OPERATOR_EDIT,
|
||||
operator_notes="",
|
||||
justification="",
|
||||
diff=render_diff(before, after, label="cred-proxy"),
|
||||
diff=render_diff(before, after, label="egress-proxy"),
|
||||
))
|
||||
return before, after
|
||||
|
||||
@@ -239,20 +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
|
||||
operator's read; only the host ends up in pipelock's allowlist.
|
||||
|
||||
FOLLOW-UP — path-aware filtering. Pipelock 2.3.0's api_allowlist
|
||||
is hostname-only (verified by inspecting the binary's strict
|
||||
preset; the only "path" fields in pipelock's schema are about
|
||||
local filesystem paths under sandbox / file_sentry / taint). So
|
||||
approving pipelock-block opens the entire host, not the URL's
|
||||
path. If/when per-path enforcement becomes load-bearing, the
|
||||
follow-up is most likely adding an `auth_scheme: none` mode +
|
||||
`path_allowlist` field to cred-proxy (which already does
|
||||
path-prefix routing) and rewiring pipelock-block to propose
|
||||
cred-proxy routes instead of pipelock hostnames. That's a
|
||||
multi-touch change deserving its own PRD — out of scope for the
|
||||
supervise-loop work that introduced this function. See PR
|
||||
discussion on https://gitea.dideric.is/didericis/claude-bottle/pulls/25
|
||||
for the design conversation."""
|
||||
Pipelock 2.3.0's api_allowlist is hostname-only (verified by
|
||||
inspecting the binary's strict preset; the only "path" fields in
|
||||
pipelock's schema are about local filesystem paths under sandbox
|
||||
/ file_sentry / taint). Approving pipelock-block opens the
|
||||
entire host, not the URL's path.
|
||||
|
||||
Path-level enforcement was the open question this function's
|
||||
earlier docstring flagged; PRD 0017 answered it by putting
|
||||
egress-proxy in front of pipelock. The agent's
|
||||
`egress-proxy-block` tool now proposes routes.yaml changes that
|
||||
can include a `path_allowlist`. Use that tool for path-level
|
||||
follow-ups; this one stays hostname-only because pipelock is
|
||||
still the last hostname gate before egress."""
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlsplit(failed_url.strip())
|
||||
host = parsed.hostname or ""
|
||||
@@ -296,14 +295,13 @@ def _write_audit(
|
||||
diff_before: str,
|
||||
diff_after: str,
|
||||
) -> None:
|
||||
"""Audit log for cred-proxy / pipelock tools. capability-block has
|
||||
no audit log (its changes are captured by the bottle's rebuild
|
||||
record + git history per PRD 0016).
|
||||
"""Audit log for egress-proxy / pipelock tools. capability-block
|
||||
has no audit log (its changes are captured by the bottle's
|
||||
rebuild record + git history per PRD 0016).
|
||||
|
||||
For cred-proxy-block approvals the (before, after) come from the
|
||||
apply_routes_change return — a real fetched-from-sidecar diff.
|
||||
For rejections, or for tools whose remediation hasn't landed yet
|
||||
(pipelock in 0014, capability anywhere), both are empty strings
|
||||
For egress-proxy-block + pipelock-block approvals the (before,
|
||||
after) come from the apply_*_change return — a real
|
||||
fetched-from-sidecar diff. For rejections both are empty strings
|
||||
and the audit diff renders as empty."""
|
||||
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
|
||||
if component is None:
|
||||
@@ -683,22 +681,22 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
|
||||
def _suffix_for_tool(tool: str) -> str:
|
||||
if tool == TOOL_CAPABILITY_BLOCK:
|
||||
return ".dockerfile"
|
||||
# cred-proxy-block / pipelock-block: JSON-ish + plain.
|
||||
# egress-proxy-block / pipelock-block: JSON-ish + plain.
|
||||
return ".txt"
|
||||
|
||||
|
||||
def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str:
|
||||
"""Operator-initiated routes.json edit. Discover running
|
||||
cred-proxy sidecars, pick one (single → use directly; multi →
|
||||
"""Operator-initiated routes.yaml edit. Discover running
|
||||
egress-proxy sidecars, pick one (single → use directly; multi →
|
||||
prompt), fetch the current routes, open in $EDITOR, apply on
|
||||
save. Returns a status-line message."""
|
||||
return _operator_edit_flow(
|
||||
stdscr,
|
||||
label="routes",
|
||||
discover=discover_cred_proxy_slugs,
|
||||
discover=discover_egress_proxy_slugs,
|
||||
fetch=fetch_current_routes,
|
||||
apply=operator_edit_routes,
|
||||
suffix=".json",
|
||||
suffix=".yaml",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (claude_bottle.supervise_server)
|
||||
sits on the bottle's internal network and exposes three MCP tools the
|
||||
agent calls when it hits a stuck-recovery category:
|
||||
|
||||
* cred-proxy-block — agent proposes a new routes.json
|
||||
* egress-proxy-block — agent proposes a new routes.yaml
|
||||
* pipelock-block — agent proposes a new pipelock allowlist
|
||||
* capability-block — agent proposes a new agent Dockerfile
|
||||
|
||||
@@ -49,11 +49,11 @@ from pathlib import Path
|
||||
SUPERVISE_HOSTNAME = "supervise"
|
||||
SUPERVISE_PORT = 9100
|
||||
|
||||
TOOL_CRED_PROXY_BLOCK = "cred-proxy-block"
|
||||
TOOL_EGRESS_PROXY_BLOCK = "egress-proxy-block"
|
||||
TOOL_PIPELOCK_BLOCK = "pipelock-block"
|
||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||
TOOLS: tuple[str, ...] = (
|
||||
TOOL_CRED_PROXY_BLOCK,
|
||||
TOOL_EGRESS_PROXY_BLOCK,
|
||||
TOOL_PIPELOCK_BLOCK,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
)
|
||||
@@ -63,7 +63,7 @@ TOOLS: tuple[str, ...] = (
|
||||
# here — those changes are captured by git history + the rebuild
|
||||
# record laid down in PRD 0016.
|
||||
COMPONENT_FOR_TOOL: dict[str, str] = {
|
||||
TOOL_CRED_PROXY_BLOCK: "cred-proxy",
|
||||
TOOL_EGRESS_PROXY_BLOCK: "egress-proxy",
|
||||
TOOL_PIPELOCK_BLOCK: "pipelock",
|
||||
}
|
||||
|
||||
@@ -566,7 +566,7 @@ __all__ = [
|
||||
"SupervisePlan",
|
||||
"TOOLS",
|
||||
"TOOL_CAPABILITY_BLOCK",
|
||||
"TOOL_CRED_PROXY_BLOCK",
|
||||
"TOOL_EGRESS_PROXY_BLOCK",
|
||||
"TOOL_PIPELOCK_BLOCK",
|
||||
"archive_proposal",
|
||||
"audit_dir",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Supervise sidecar HTTP server (PRD 0013).
|
||||
|
||||
Per-bottle MCP server exposing three tools — `cred-proxy-block`,
|
||||
Per-bottle MCP server exposing three tools — `egress-proxy-block`,
|
||||
`pipelock-block`, `capability-block` — that the agent calls to
|
||||
propose config changes when stuck. Each tool call:
|
||||
|
||||
@@ -128,26 +128,27 @@ def jsonrpc_error(request_id: object, code: int, message: str) -> bytes:
|
||||
|
||||
TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
{
|
||||
"name": _sv.TOOL_CRED_PROXY_BLOCK,
|
||||
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||
"description": (
|
||||
"Call when cred-proxy refused your HTTPS request — missing "
|
||||
"route, expired token, wrong scope (typically a 403 or a "
|
||||
"404 from `http://cred-proxy:<port>/<path>/`). Read the "
|
||||
"current routes.json from "
|
||||
"/etc/claude-bottle/current-config/routes.json, compose a "
|
||||
"modified version with the route you need, and pass the "
|
||||
"full new file plus a justification. The operator approves "
|
||||
"or rejects in the supervise TUI. On approval the supervisor "
|
||||
"writes the new routes.json on the host and SIGHUPs cred-proxy "
|
||||
"(wired in PRD 0014; in the v1 supervise foundation the "
|
||||
"approval is acknowledged but no config change runs)."
|
||||
"Call when egress-proxy refused your HTTPS request — host "
|
||||
"without a matching route, or a path outside the route's "
|
||||
"path_allowlist (typically a 403 from the proxy). Read "
|
||||
"the current routes.yaml from "
|
||||
"/etc/claude-bottle/current-config/routes.yaml, compose "
|
||||
"a modified version that adds or relaxes the route you "
|
||||
"need, and pass the full new file plus a justification. "
|
||||
"The operator approves or rejects in the supervise TUI. "
|
||||
"On approval the supervisor writes the new routes.yaml "
|
||||
"on the host and SIGHUPs egress-proxy (the addon's reload "
|
||||
"swaps the route table atomically without dropping "
|
||||
"in-flight connections)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"routes": {
|
||||
"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": {
|
||||
"type": "string",
|
||||
@@ -226,15 +227,15 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
# tool-specific payload (stored in Proposal.proposed_file as
|
||||
# free-form text the apply path interprets per tool).
|
||||
#
|
||||
# cred-proxy-block: full proposed routes.json
|
||||
# pipelock-block: the full failed URL (scheme + host + path) —
|
||||
# supervisor extracts the host, merges into the
|
||||
# bottle's current allowlist; the path is shown
|
||||
# to the operator for context (pipelock doesn't
|
||||
# do path-level matching).
|
||||
# capability-block: full proposed Dockerfile
|
||||
# egress-proxy-block: full proposed routes.yaml
|
||||
# pipelock-block: the full failed URL (scheme + host + path) —
|
||||
# supervisor extracts the host, merges into the
|
||||
# bottle's current allowlist; the path is shown
|
||||
# to the operator for context (pipelock doesn't
|
||||
# do path-level matching).
|
||||
# capability-block: full proposed Dockerfile
|
||||
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_CAPABILITY_BLOCK: "dockerfile",
|
||||
}
|
||||
@@ -249,18 +250,18 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
||||
enter the queue."""
|
||||
if not content.strip():
|
||||
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
||||
if tool == _sv.TOOL_CRED_PROXY_BLOCK:
|
||||
if tool == _sv.TOOL_EGRESS_PROXY_BLOCK:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.json is not valid JSON: {e}",
|
||||
f"{tool}: proposed routes.yaml is not valid JSON: {e}",
|
||||
) from e
|
||||
if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list):
|
||||
raise _RpcError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.json must be an object with a 'routes' array",
|
||||
f"{tool}: proposed routes.yaml must be an object with a 'routes' array",
|
||||
)
|
||||
elif tool == _sv.TOOL_PIPELOCK_BLOCK:
|
||||
# `content` is the full failed URL. Require scheme + host so
|
||||
@@ -505,7 +506,7 @@ def serve(
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
del argv # config is env-only, matches cred_proxy_server pattern
|
||||
del argv # config is env-only, no CLI flags
|
||||
bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||
if not bottle_slug:
|
||||
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
||||
|
||||
Reference in New Issue
Block a user