feat(egress-proxy-block): single-route input + merge-on-apply
Instead of asking the agent to compose and submit a full routes
file, the tool now takes ONE proposed route — host + optional
path_allowlist + optional auth — and the supervisor merges it
into the live routes table at approval time. The agent no longer
needs to fetch / reproduce / extend the existing allowlist; it
just describes the host it wants reachable.
Tool input (new):
- `host` (required)
- `path_allowlist` (optional, array of absolute path prefixes)
- `auth` (optional, {scheme, token_ref})
- `justification` (required)
Merge semantics (in `egress_proxy_apply._merge_single_route`):
- Host NOT in current routes → append the proposed route as a
new entry. If `auth` is set, assign the next EGRESS_PROXY_TOKEN_N
slot.
- Host already present → union the proposed `path_allowlist`
with the existing one (proposed entries appended after
existing, deduped). Existing `auth_scheme` / `token_env`
preserved; proposed `auth` ignored (operator-controlled, not
agent-controlled).
- Hostname comparison is case-insensitive.
Dashboard wiring: `approve()` on an egress-proxy-block proposal
now calls `add_route(slug, proposed_route_json)` instead of
`apply_routes_change(slug, full_file)`. add_route fetches the
current routes from the running egress-proxy, merges, and calls
apply_routes_change with the merged content — so the
pipelock-mirror + SIGHUP plumbing from chunk 3 still runs
end-to-end. Audit diff still captures the full-file before/after.
Tool description rewritten to make the new shape obvious and to
stop pointing the agent at the routes file. The
`list-egress-proxy-routes` tool stays available for agents that
want to see what's currently allowed.
Tests: 9 new `_merge_single_route` cases (host absent/present,
path-allowlist union+dedup, auth-slot indexing, case-insensitive
match, existing-auth preservation, missing-host rejection,
malformed-current rejection). 407 unit + integration pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -45,22 +45,6 @@ from claude_bottle.supervise_server import (
|
||||
|
||||
|
||||
class TestValidation(unittest.TestCase):
|
||||
def test_egress_proxy_block_requires_valid_json(self):
|
||||
with self.assertRaises(_RpcError) as cm:
|
||||
validate_proposed_file(_sv.TOOL_EGRESS_PROXY_BLOCK, "{not json")
|
||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||
self.assertIn("not valid JSON", cm.exception.message)
|
||||
|
||||
def test_egress_proxy_block_requires_routes_array(self):
|
||||
with self.assertRaises(_RpcError):
|
||||
validate_proposed_file(_sv.TOOL_EGRESS_PROXY_BLOCK, '{"other": []}')
|
||||
|
||||
def test_egress_proxy_block_accepts_valid_routes(self):
|
||||
validate_proposed_file(
|
||||
_sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||
'{"routes": [{"path": "/x/", "upstream": "https://example.com"}]}',
|
||||
)
|
||||
|
||||
def test_pipelock_block_accepts_https_url(self):
|
||||
validate_proposed_file(
|
||||
_sv.TOOL_PIPELOCK_BLOCK,
|
||||
@@ -89,8 +73,12 @@ class TestValidation(unittest.TestCase):
|
||||
"FROM python:3.13\nRUN apk add git\n",
|
||||
)
|
||||
|
||||
def test_empty_proposed_file_rejected_for_all_tools(self):
|
||||
for tool in _sv.TOOLS:
|
||||
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
|
||||
# egress-proxy-block has structured input (validated in
|
||||
# _validate_and_bundle_egress_route, not here) and
|
||||
# list-egress-proxy-routes takes no input. Only the other
|
||||
# two go through `validate_proposed_file`.
|
||||
for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK):
|
||||
with self.subTest(tool=tool):
|
||||
with self.assertRaises(_RpcError):
|
||||
validate_proposed_file(tool, " \n\t")
|
||||
@@ -243,7 +231,7 @@ class TestHandleToolsCall(unittest.TestCase):
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||
"arguments": {
|
||||
"routes": '{"routes": []}',
|
||||
"host": "example.com",
|
||||
"justification": "need a route",
|
||||
},
|
||||
},
|
||||
@@ -286,7 +274,7 @@ class TestHandleToolsCall(unittest.TestCase):
|
||||
handle_tools_call(
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||
"arguments": {"routes": '{"routes": []}'},
|
||||
"arguments": {"host": "example.com"},
|
||||
},
|
||||
self.config,
|
||||
)
|
||||
@@ -298,7 +286,7 @@ class TestHandleToolsCall(unittest.TestCase):
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_PROXY_BLOCK,
|
||||
"arguments": {
|
||||
"routes": '{"routes": []}',
|
||||
"host": "example.com",
|
||||
"justification": "x",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user