feat(egress-proxy-apply): mirror new route hosts into pipelock allowlist
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m7s

When the operator approves an egress-proxy-block proposal that
adds a host to egress-proxy's routes, the request would still 403
downstream at pipelock — pipelock's hostname allowlist is set at
bottle launch and doesn't learn about routes added later. The
agent saw "Approved" but the very next retry still failed.

Fix: `apply_routes_change` now mirrors every host in the proposed
routes onto pipelock's allowlist before flipping egress-proxy.
Order matters — pipelock first so a pipelock failure doesn't
leave egress-proxy in a half-state:

  1. Validate the new routes content.
  2. Extract the hosts.
  3. Merge them onto pipelock's current allowlist
     (`apply_allowlist_change` — restarts pipelock with the merged
     yaml). No-op when every host is already present.
  4. docker cp the new routes.yaml into egress-proxy + SIGHUP.

If pipelock's restart fails, egress-proxy is untouched and the
operator gets a clear error pointing at the pipelock half-state.
If egress-proxy's update fails after pipelock succeeded, pipelock
just has the host pre-allowlisted — harmless extra-permissive
until the operator retries.

Adds `_hosts_in_routes` helper using the addon's own parser
(so the mirrored host set matches exactly what the addon will
match on). 4 new unit tests; 368 total pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:34:10 -04:00
parent d75d5f3e48
commit 1cec0d9aa6
2 changed files with 98 additions and 6 deletions
@@ -8,6 +8,13 @@ egress-proxy-block proposal (or runs the operator-initiated
sidecar via `docker cp`, then `docker kill --signal HUP` to make sidecar via `docker cp`, then `docker kill --signal HUP` to make
the addon reload without dropping connections. the addon reload without dropping connections.
Also mirrors the new route hosts into pipelock's hostname allowlist
so the downstream leg lets them through — egress-proxy enforces
the path-aware allowlist on the agent leg, pipelock enforces the
hostname allowlist + DLP body scan on the upstream leg, and a
host added to one must be in the other or the request 403s
somewhere along the chain.
Raises EgressProxyApplyError on any failure — the dashboard Raises EgressProxyApplyError on any failure — the dashboard
surfaces the message and keeps the proposal pending so the surfaces the message and keeps the proposal pending so the
operator can retry. operator can retry.
@@ -23,6 +30,13 @@ from pathlib import Path
from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER
from ...egress_proxy_addon_core import load_routes from ...egress_proxy_addon_core import load_routes
from .egress_proxy import egress_proxy_container_name from .egress_proxy import egress_proxy_container_name
from .pipelock_apply import (
PipelockApplyError,
apply_allowlist_change,
fetch_current_allowlist,
parse_allowlist_content,
render_allowlist_content,
)
class EgressProxyApplyError(RuntimeError): class EgressProxyApplyError(RuntimeError):
@@ -60,22 +74,69 @@ def validate_routes_content(content: str) -> None:
) from e ) from e
def _hosts_in_routes(content: str) -> list[str]:
"""Extract the host list from a routes.yaml content string.
Uses the addon's own parser so any host the addon will match on
also lands in pipelock's allowlist. Returns sorted+deduped."""
try:
routes = load_routes(content)
except ValueError as e:
raise EgressProxyApplyError(
f"proposed routes.yaml is not valid: {e}"
) from e
return sorted({r.host for r in routes if r.host})
def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None:
"""Ensure every `hosts` entry is on pipelock's allowlist. Fetches
pipelock's current allowlist, merges, re-applies. No-op if every
host is already present (apply still restarts pipelock if any
host is new). Raises EgressProxyApplyError on pipelock failures
so the caller's diff/audit reflects the half-state."""
try:
current = fetch_current_allowlist(slug)
existing = parse_allowlist_content(current)
merged = sorted(set(existing) | set(hosts))
if merged == sorted(existing):
return # nothing to add
apply_allowlist_change(slug, render_allowlist_content(merged))
except PipelockApplyError as e:
raise EgressProxyApplyError(
f"egress-proxy routes updated but pipelock allowlist "
f"mirror failed: {e}. The request will 403 at pipelock "
f"until pipelock's allowlist is refreshed; retry the "
f"proposal or edit pipelock's allowlist by hand."
) from e
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
"""Apply `new_content` to the egress-proxy sidecar for `slug`: """Apply `new_content` to the egress-proxy sidecar for `slug`:
1. Fetch current routes.yaml (for the before-diff). 1. Fetch current routes.yaml (for the before-diff).
2. Validate the new content via the addon's own parser. 2. Validate the new content via the addon's own parser.
3. Write to a temp file, `docker cp` into the sidecar. 3. Mirror the route hosts onto pipelock's allowlist (so the
4. `docker kill --signal HUP` so the addon reloads. downstream hostname gate lets them through).
4. Write to a temp file, `docker cp` into the egress-proxy
sidecar.
5. `docker kill --signal HUP` so the addon reloads.
Order matters: pipelock first, then egress-proxy. If the
pipelock step fails, egress-proxy hasn't been touched and the
old routes stay live. If the egress-proxy step fails after
pipelock succeeded, pipelock has the host in its allowlist but
egress-proxy doesn't enforce it yet — harmless extra-permissive
state at pipelock, and a re-approval will land the egress-proxy
side.
Returns (before, after) where `after` == `new_content`. Raises Returns (before, after) where `after` == `new_content`. Raises
EgressProxyApplyError on any step; the existing routes in the EgressProxyApplyError on any step."""
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) container = egress_proxy_container_name(slug)
before = fetch_current_routes(slug) before = fetch_current_routes(slug)
validate_routes_content(new_content) validate_routes_content(new_content)
# Pipelock mirror first — if it fails, egress-proxy stays intact
# and the operator gets a clear error about the half-state.
_mirror_hosts_to_pipelock(slug, _hosts_in_routes(new_content))
fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".yaml") fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".yaml")
try: try:
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
+31
View File
@@ -6,6 +6,7 @@ import unittest
from claude_bottle.backend.docker.egress_proxy_apply import ( from claude_bottle.backend.docker.egress_proxy_apply import (
EgressProxyApplyError, EgressProxyApplyError,
_hosts_in_routes,
validate_routes_content, validate_routes_content,
) )
@@ -52,5 +53,35 @@ class TestValidateRoutesContent(unittest.TestCase):
) )
class TestHostsInRoutes(unittest.TestCase):
def test_extracts_each_unique_host(self):
hosts = _hosts_in_routes(
'{"routes": [{"host": "api.github.com"},'
' {"host": "github.com"},'
' {"host": "api.anthropic.com"}]}'
)
# Sorted+deduped.
self.assertEqual(
["api.anthropic.com", "api.github.com", "github.com"],
hosts,
)
def test_dedupes_same_host(self):
hosts = _hosts_in_routes(
'{"routes": [{"host": "x.example", "path_allowlist": ["/a/"]},'
' {"host": "x.example", "path_allowlist": ["/b/"]}]}'
)
self.assertEqual(["x.example"], hosts)
def test_empty_routes_returns_empty(self):
self.assertEqual([], _hosts_in_routes('{"routes": []}'))
def test_invalid_routes_raises(self):
# The mirror helper relies on parsing succeeding; bad input
# should error before pipelock is touched.
with self.assertRaises(EgressProxyApplyError):
_hosts_in_routes('{"routes": [{"path": "/no-host/"}]}')
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()