feat(egress-proxy-block): single-route input + merge-on-apply
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m14s

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:
2026-05-25 18:45:17 -04:00
parent 3be70eb07a
commit 1542ee0b93
7 changed files with 419 additions and 96 deletions
+36 -25
View File
@@ -127,14 +127,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
self._original_apply_routes = dashboard.apply_routes_change
self._original_add_route = dashboard.add_route
self._original_apply_allowlist = dashboard.apply_allowlist_change
self._original_fetch_allowlist = dashboard.fetch_current_allowlist
self._original_apply_capability = dashboard.apply_capability_change
# Default stubs: succeed with deterministic before/after so the
# audit log shows a non-empty diff.
dashboard.apply_routes_change = lambda slug, content: (
'{"routes": []}\n', content,
dashboard.add_route = lambda slug, content: (
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n',
)
dashboard.apply_allowlist_change = lambda slug, content: (
"old.example\n", content,
@@ -145,7 +145,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
)
def tearDown(self):
dashboard.apply_routes_change = self._original_apply_routes
dashboard.add_route = self._original_add_route
dashboard.apply_allowlist_change = self._original_apply_allowlist
dashboard.fetch_current_allowlist = self._original_fetch_allowlist
dashboard.apply_capability_change = self._original_apply_capability
@@ -204,19 +204,19 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0014 Phase 3: approve() on a egress-proxy-block proposal
must call apply_routes_change with the right args and surface
its failures."""
"""PRD 0017 chunk 3: approve() on an egress-proxy-block proposal
must call add_route (single-route merge) with the right args
and surface its failures."""
def setUp(self):
self._setup_fake_home()
self._original_apply = dashboard.apply_routes_change
self._original_add_route = dashboard.add_route
def tearDown(self):
dashboard.apply_routes_change = self._original_apply
dashboard.add_route = self._original_add_route
self._teardown_fake_home()
def _enqueue_egress_proxy(self, proposed: str = '{"routes": []}\n'):
def _enqueue_egress_proxy(self, proposed: str = '{"host": "x.example"}\n'):
p = Proposal.new(
bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK,
proposed_file=proposed,
@@ -229,29 +229,40 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
supervise.write_proposal(qdir, p)
return dashboard.QueuedProposal(proposal=p, queue_dir=qdir)
def test_egress_proxy_block_calls_apply_with_proposed_file(self):
def test_egress_proxy_block_calls_add_route_with_proposed_json(self):
calls = []
dashboard.apply_routes_change = lambda slug, content: (
calls.append((slug, content)) or ("before", content)
dashboard.add_route = lambda slug, content: (
calls.append((slug, content)) or ("before", "after")
)
qp = self._enqueue_egress_proxy(
proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n'
)
qp = self._enqueue_egress_proxy(proposed='{"routes": [{"path": "/new/"}]}\n')
dashboard.approve(qp)
self.assertEqual(1, len(calls))
slug, content = calls[0]
self.assertEqual("dev", slug)
self.assertEqual('{"routes": [{"path": "/new/"}]}\n', content)
# The single-route JSON the agent proposed reaches add_route
# unchanged — add_route fetches current state + merges.
self.assertEqual(
'{"host": "new.example", "path_allowlist": ["/x/"]}\n',
content,
)
def test_modify_passes_final_file_to_apply(self):
def test_modify_passes_final_file_to_add_route(self):
calls = []
dashboard.apply_routes_change = lambda slug, content: (
calls.append(content) or ("before", content)
dashboard.add_route = lambda slug, content: (
calls.append(content) or ("before", "after")
)
qp = self._enqueue_egress_proxy()
dashboard.approve(qp, final_file='{"routes": [{"path": "/edited/"}]}\n', notes="tweaked")
self.assertEqual(['{"routes": [{"path": "/edited/"}]}\n'], calls)
dashboard.approve(
qp,
final_file='{"host": "edited.example"}\n',
notes="tweaked",
)
self.assertEqual(['{"host": "edited.example"}\n'], calls)
def test_apply_failure_blocks_response_and_audit(self):
dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw(
dashboard.add_route = lambda slug, content: (_ for _ in ()).throw(
EgressProxyApplyError("docker exec failed")
)
qp = self._enqueue_egress_proxy()
@@ -266,15 +277,15 @@ class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("egress-proxy", "dev"))
def test_real_diff_lands_in_audit(self):
dashboard.apply_routes_change = lambda slug, content: (
dashboard.add_route = lambda slug, content: (
'{"routes": []}\n', # before
'{"routes": [{"path": "/new/"}]}\n', # after
'{"routes": [{"host": "new.example"}]}\n', # after
)
qp = self._enqueue_egress_proxy(proposed='{"routes": [{"path": "/new/"}]}\n')
qp = self._enqueue_egress_proxy(proposed='{"host": "new.example"}\n')
dashboard.approve(qp)
entries = read_audit_entries("egress-proxy", "dev")
self.assertEqual(1, len(entries))
self.assertIn('+{"routes": [{"path": "/new/"}]}', entries[0].diff)
self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff)
self.assertIn('-{"routes": []}', entries[0].diff)
def test_reject_does_not_call_apply(self):
+106
View File
@@ -4,9 +4,12 @@ integration test."""
import unittest
import json
from claude_bottle.backend.docker.egress_proxy_apply import (
EgressProxyApplyError,
_hosts_in_routes,
_merge_single_route,
validate_routes_content,
)
@@ -83,5 +86,108 @@ class TestHostsInRoutes(unittest.TestCase):
_hosts_in_routes('{"routes": [{"path": "/no-host/"}]}')
class TestMergeSingleRoute(unittest.TestCase):
BASE = '{"routes": [{"host": "api.anthropic.com"}]}'
def test_appends_route_when_host_absent(self):
merged = _merge_single_route(self.BASE, {"host": "github.com"})
routes = json.loads(merged)["routes"]
hosts = [r["host"] for r in routes]
self.assertEqual(["api.anthropic.com", "github.com"], hosts)
def test_appends_path_allowlist(self):
merged = _merge_single_route(
self.BASE,
{"host": "github.com", "path_allowlist": ["/repos/x/"]},
)
new_route = json.loads(merged)["routes"][-1]
self.assertEqual(["/repos/x/"], new_route["path_allowlist"])
def test_appends_auth_with_token_env_slot(self):
merged = _merge_single_route(
self.BASE,
{
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"},
},
)
new_route = json.loads(merged)["routes"][-1]
self.assertEqual("Bearer", new_route["auth_scheme"])
# First auth slot when no prior auth routes exist.
self.assertEqual("EGRESS_PROXY_TOKEN_0", new_route["token_env"])
def test_auth_slot_increments_past_existing(self):
base = json.dumps({"routes": [
{"host": "api.anthropic.com",
"auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0"},
]})
merged = _merge_single_route(base, {
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH"},
})
new_route = json.loads(merged)["routes"][-1]
self.assertEqual("EGRESS_PROXY_TOKEN_1", new_route["token_env"])
def test_existing_host_merges_path_allowlist_as_union(self):
base = json.dumps({"routes": [
{"host": "github.com", "path_allowlist": ["/a/"]},
]})
merged = _merge_single_route(base, {
"host": "github.com",
"path_allowlist": ["/b/"],
})
routes = json.loads(merged)["routes"]
self.assertEqual(1, len(routes)) # not duplicated
self.assertEqual(["/a/", "/b/"], routes[0]["path_allowlist"])
def test_existing_host_dedup_path_allowlist(self):
base = json.dumps({"routes": [
{"host": "github.com", "path_allowlist": ["/a/"]},
]})
merged = _merge_single_route(base, {
"host": "github.com",
"path_allowlist": ["/a/", "/b/"],
})
self.assertEqual(
["/a/", "/b/"],
json.loads(merged)["routes"][0]["path_allowlist"],
)
def test_existing_host_preserves_existing_auth_ignores_proposed(self):
# Tool docs: auth on an existing host is operator-controlled,
# not agent-controlled. The merge must not overwrite.
base = json.dumps({"routes": [
{"host": "api.github.com",
"auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0"},
]})
merged = _merge_single_route(base, {
"host": "api.github.com",
"auth": {"scheme": "token", "token_ref": "DIFFERENT"},
})
route = json.loads(merged)["routes"][0]
self.assertEqual("Bearer", route["auth_scheme"])
self.assertEqual("EGRESS_PROXY_TOKEN_0", route["token_env"])
def test_host_match_is_case_insensitive(self):
base = json.dumps({"routes": [{"host": "GitHub.com"}]})
merged = _merge_single_route(base, {
"host": "github.com",
"path_allowlist": ["/x/"],
})
routes = json.loads(merged)["routes"]
self.assertEqual(1, len(routes))
self.assertEqual(["/x/"], routes[0]["path_allowlist"])
def test_missing_host_raises(self):
with self.assertRaises(EgressProxyApplyError):
_merge_single_route(self.BASE, {})
def test_invalid_current_yaml_raises(self):
with self.assertRaises(EgressProxyApplyError):
_merge_single_route("{not json", {"host": "x.example"})
if __name__ == "__main__":
unittest.main()
+9 -21
View File
@@ -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",
},
},