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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user