feat(egress-proxy): add mitmproxy-based sidecar core (PRD 0017 chunk 1)
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m39s

Lands the new egress-proxy artifact alongside cred-proxy. Chunk 2
wires the agent's HTTP_PROXY to it and removes cred-proxy.

  - `Dockerfile.egress-proxy` — mitmproxy 11.1.3 base, COPY addon
    files flat to /app, mkdir routes dir at /etc/egress-proxy/.
    Digest pin deferred to chunk 2.
  - `egress_proxy_addon_core.py` — pure-logic parse + decide
    (host-importable; 21 unit tests).
  - `egress_proxy_addon.py` — mitmproxy hook wrapper, container-only
    (boot + SIGHUP reload, strip-Authorization + decide + 403/inject).
  - `egress_proxy.py` — host helpers: manifest lift, routes.yaml
    render (JSON content), token-env-map, Plan + abstract class.
  - `backend/docker/egress_proxy.py` — `DockerEgressProxy` start/stop
    mirroring `DockerCredProxy`; not yet called from launch.py.
  - `manifest.py` — new `EgressProxyRoute` + `EgressProxyConfig` types
    with the nested `auth: { scheme, token_ref }` block per PRD;
    `bottle.egress_proxy` added to the bottle key set alongside
    `cred_proxy` (chunk 2 hard-fails on the latter).

All 427 unit tests pass. Image builds; `docker run` boots mitmdump
and the addon loads routes from a mounted routes.yaml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:58:24 -04:00
parent a2a7396a14
commit 3df54573d4
9 changed files with 1664 additions and 7 deletions
+185
View File
@@ -0,0 +1,185 @@
"""Unit: EgressProxy route lift + routes.yaml render + token
resolution (PRD 0017)."""
import json
import unittest
from claude_bottle.egress_proxy import (
egress_proxy_render_routes,
egress_proxy_resolve_token_values,
egress_proxy_routes_for_bottle,
egress_proxy_token_env_map,
)
from claude_bottle.log import Die
from claude_bottle.manifest import Manifest
def _bottle(routes):
return Manifest.from_json_obj({
"bottles": {"dev": {"egress_proxy": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
class TestRoutesForBottle(unittest.TestCase):
def test_authenticated_route_gets_slot(self):
b = _bottle([{
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
}])
routes = egress_proxy_routes_for_bottle(b)
self.assertEqual(1, len(routes))
r = routes[0]
self.assertEqual("api.github.com", r.host)
self.assertEqual("Bearer", r.auth_scheme)
self.assertEqual("EGRESS_PROXY_TOKEN_0", r.token_env)
self.assertEqual("GH_PAT", r.token_ref)
self.assertEqual((), r.path_allowlist)
def test_unauthenticated_route_has_empty_auth_fields(self):
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
routes = egress_proxy_routes_for_bottle(b)
r = routes[0]
self.assertEqual("", r.auth_scheme)
self.assertEqual("", r.token_env)
self.assertEqual("", r.token_ref)
self.assertEqual(("/x/",), r.path_allowlist)
def test_shared_token_ref_collapses_to_one_slot(self):
b = _bottle([
{"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}},
{"host": "github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"}},
])
routes = egress_proxy_routes_for_bottle(b)
slots = {r.token_env for r in routes}
self.assertEqual({"EGRESS_PROXY_TOKEN_0"}, slots)
def test_distinct_token_refs_get_distinct_slots(self):
b = _bottle([
{"host": "a.example",
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "b.example",
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
])
routes = egress_proxy_routes_for_bottle(b)
slots = [r.token_env for r in routes]
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], slots)
def test_unauthenticated_routes_dont_consume_slots(self):
# A bare-pass route between two authenticated routes mustn't
# skip a slot number — slot 0 + slot 1 stay tight.
b = _bottle([
{"host": "a.example",
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "passthrough.example"},
{"host": "b.example",
"auth": {"scheme": "Bearer", "token_ref": "T2"}},
])
routes = egress_proxy_routes_for_bottle(b)
authed = [r.token_env for r in routes if r.token_env]
self.assertEqual(["EGRESS_PROXY_TOKEN_0", "EGRESS_PROXY_TOKEN_1"], authed)
self.assertEqual("", routes[1].token_env)
class TestTokenEnvMap(unittest.TestCase):
def test_only_authenticated_routes_contribute(self):
b = _bottle([
{"host": "a.example",
"auth": {"scheme": "Bearer", "token_ref": "T1"}},
{"host": "passthrough.example"},
])
routes = egress_proxy_routes_for_bottle(b)
m = egress_proxy_token_env_map(routes)
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "T1"}, m)
def test_no_routes_empty(self):
self.assertEqual({}, egress_proxy_token_env_map(()))
class TestRenderRoutes(unittest.TestCase):
def test_authenticated_route_serialised_with_auth_fields(self):
b = _bottle([{
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
"path_allowlist": ["/repos/x/"],
}])
routes = egress_proxy_routes_for_bottle(b)
payload = json.loads(egress_proxy_render_routes(routes))
self.assertEqual(
[{
"host": "api.github.com",
"path_allowlist": ["/repos/x/"],
"auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0",
}],
payload["routes"],
)
def test_unauthenticated_route_omits_auth_fields(self):
# auth_scheme + token_env keys are absent when the route was
# declared without an `auth` block — the addon's parser
# enforces both-or-neither, so emitting empty strings would
# round-trip as a partial pair and crash.
b = _bottle([{"host": "github.com", "path_allowlist": ["/x/"]}])
routes = egress_proxy_routes_for_bottle(b)
payload = json.loads(egress_proxy_render_routes(routes))
entry = payload["routes"][0]
self.assertNotIn("auth_scheme", entry)
self.assertNotIn("token_env", entry)
def test_no_path_allowlist_omits_field(self):
b = _bottle([{
"host": "api.anthropic.com",
"auth": {"scheme": "Bearer", "token_ref": "CL"},
}])
routes = egress_proxy_routes_for_bottle(b)
payload = json.loads(egress_proxy_render_routes(routes))
self.assertNotIn("path_allowlist", payload["routes"][0])
def test_round_trip_through_addon_core(self):
# Render here → parse in the addon must succeed for every
# combination the manifest can produce.
from claude_bottle.egress_proxy_addon_core import load_routes
b = _bottle([
{"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
"path_allowlist": ["/repos/x/"]},
{"host": "github.com", "path_allowlist": ["/x/"]},
{"host": "api.anthropic.com"},
])
routes = egress_proxy_routes_for_bottle(b)
addon_routes = load_routes(egress_proxy_render_routes(routes))
self.assertEqual(3, len(addon_routes))
self.assertEqual("Bearer", addon_routes[0].auth_scheme)
self.assertEqual("EGRESS_PROXY_TOKEN_0", addon_routes[0].token_env)
self.assertEqual("", addon_routes[1].auth_scheme)
self.assertEqual("", addon_routes[2].auth_scheme)
class TestResolveTokenValues(unittest.TestCase):
def test_reads_host_env(self):
out = egress_proxy_resolve_token_values(
{"EGRESS_PROXY_TOKEN_0": "GH_PAT"},
{"GH_PAT": "the-value"},
)
self.assertEqual({"EGRESS_PROXY_TOKEN_0": "the-value"}, out)
def test_missing_token_ref_dies(self):
with self.assertRaises(Die):
egress_proxy_resolve_token_values(
{"EGRESS_PROXY_TOKEN_0": "GH_PAT"},
{},
)
def test_empty_token_ref_dies(self):
with self.assertRaises(Die):
egress_proxy_resolve_token_values(
{"EGRESS_PROXY_TOKEN_0": "GH_PAT"},
{"GH_PAT": ""},
)
if __name__ == "__main__":
unittest.main()
+249
View File
@@ -0,0 +1,249 @@
"""Unit: pure-logic core of the egress-proxy mitmproxy addon (PRD 0017).
These tests target `egress_proxy_addon_core` — the host-importable
half of the addon. The mitmproxy hook wrapper in
`egress_proxy_addon.py` is container-only and is not exercised here."""
import unittest
from claude_bottle.egress_proxy_addon_core import (
Decision,
Route,
decide,
load_routes,
match_route,
parse_routes,
)
# --- parse_routes --------------------------------------------------------
class TestParseRoutes(unittest.TestCase):
def test_minimal_route(self):
routes = parse_routes({"routes": [{"host": "api.github.com"}]})
self.assertEqual(1, len(routes))
self.assertEqual("api.github.com", routes[0].host)
self.assertEqual((), routes[0].path_allowlist)
self.assertEqual("", routes[0].auth_scheme)
self.assertEqual("", routes[0].token_env)
def test_full_route(self):
routes = parse_routes({"routes": [{
"host": "api.github.com",
"path_allowlist": ["/repos/x/", "/users/x"],
"auth_scheme": "Bearer",
"token_env": "EGRESS_PROXY_TOKEN_0",
}]})
r = routes[0]
self.assertEqual(("/repos/x/", "/users/x"), r.path_allowlist)
self.assertEqual("Bearer", r.auth_scheme)
self.assertEqual("EGRESS_PROXY_TOKEN_0", r.token_env)
def test_order_preserved(self):
# Host match is exact (not longest-prefix), but the file order
# is preserved anyway so the operator's mental model matches
# what the proxy sees.
routes = parse_routes({"routes": [
{"host": "a.example"},
{"host": "b.example"},
{"host": "c.example"},
]})
self.assertEqual(
["a.example", "b.example", "c.example"],
[r.host for r in routes],
)
def test_partial_auth_pair_rejected(self):
# auth_scheme without token_env is a renderer bug (the manifest's
# `auth: { scheme, token_ref }` block writes both at once).
with self.assertRaises(ValueError) as cm:
parse_routes({"routes": [{
"host": "x.example",
"auth_scheme": "Bearer",
}]})
self.assertIn("both set or both empty", str(cm.exception))
def test_partial_auth_other_direction_rejected(self):
with self.assertRaises(ValueError) as cm:
parse_routes({"routes": [{
"host": "x.example",
"token_env": "EGRESS_PROXY_TOKEN_0",
}]})
self.assertIn("both set or both empty", str(cm.exception))
def test_path_allowlist_must_be_absolute(self):
with self.assertRaises(ValueError) as cm:
parse_routes({"routes": [{
"host": "x.example",
"path_allowlist": ["no-leading-slash/"],
}]})
self.assertIn("absolute path prefix", str(cm.exception))
def test_path_allowlist_items_must_be_strings(self):
with self.assertRaises(ValueError):
parse_routes({"routes": [{
"host": "x.example",
"path_allowlist": [42],
}]})
def test_top_level_must_be_object(self):
with self.assertRaises(ValueError):
parse_routes(["not", "an", "object"])
def test_routes_must_be_list(self):
with self.assertRaises(ValueError):
parse_routes({"routes": "not a list"})
def test_route_must_have_host(self):
with self.assertRaises(ValueError):
parse_routes({"routes": [{}]})
# --- load_routes ---------------------------------------------------------
class TestLoadRoutes(unittest.TestCase):
def test_json_text_round_trip(self):
routes = load_routes('{"routes":[{"host":"api.example"}]}')
self.assertEqual(1, len(routes))
self.assertEqual("api.example", routes[0].host)
def test_invalid_json_raises_value_error(self):
# Both decode and schema errors land as ValueError so callers
# have a single except clause.
with self.assertRaises(ValueError):
load_routes("not json at all")
# --- match_route ---------------------------------------------------------
class TestMatchRoute(unittest.TestCase):
ROUTES = (
Route(host="api.github.com"),
Route(host="github.com", path_allowlist=("/x/",)),
)
def test_exact_match(self):
r = match_route(self.ROUTES, "api.github.com")
self.assertIsNotNone(r)
self.assertEqual("api.github.com", r.host)
def test_case_insensitive(self):
# DNS hostnames are case-insensitive per RFC 1035; mitmproxy
# surfaces the host as the agent wrote it, which may include
# uppercase. Lookup must normalise.
r = match_route(self.ROUTES, "API.GitHub.COM")
self.assertIsNotNone(r)
self.assertEqual("api.github.com", r.host)
def test_no_match_returns_none(self):
self.assertIsNone(match_route(self.ROUTES, "elsewhere.example"))
def test_no_substring_or_prefix_matching(self):
# api.github.com is in the table; github.com is too. Some
# other-host shouldn't be matched via a "ends with" check.
self.assertIsNone(match_route(self.ROUTES, "evil.api.github.com"))
# --- decide --------------------------------------------------------------
class TestDecide(unittest.TestCase):
def test_no_matching_route_forwards(self):
# Hostnames the operator didn't declare are not the
# egress-proxy's concern; pipelock's hostname allowlist gates
# them downstream.
d = decide((), "elsewhere.example", "/anything", {})
self.assertEqual("forward", d.action)
self.assertIsNone(d.inject_authorization)
def test_path_allowlist_match_forwards(self):
d = decide(
(Route(host="github.com", path_allowlist=("/didericis/",)),),
"github.com", "/didericis/repo", {},
)
self.assertEqual("forward", d.action)
def test_path_allowlist_miss_blocks(self):
d = decide(
(Route(host="github.com", path_allowlist=("/didericis/",)),),
"github.com", "/somebody-else/secret", {},
)
self.assertEqual("block", d.action)
self.assertIn("path_allowlist", d.reason)
self.assertIn("'github.com'", d.reason)
def test_empty_path_allowlist_means_no_constraint(self):
# Bare-pass route: declared but no path filtering.
d = decide(
(Route(host="api.anthropic.com"),),
"api.anthropic.com", "/v1/messages", {},
)
self.assertEqual("forward", d.action)
def test_auth_injection_uses_environ_value(self):
d = decide(
(Route(host="api.github.com", auth_scheme="Bearer",
token_env="EGRESS_PROXY_TOKEN_0"),),
"api.github.com", "/repos/x", {"EGRESS_PROXY_TOKEN_0": "the-token"},
)
self.assertEqual("forward", d.action)
self.assertEqual("Bearer the-token", d.inject_authorization)
def test_auth_with_missing_token_env_blocks(self):
# The route declared auth but the secret isn't in the
# container's env — operator misconfig at start-time, blocked
# with a clear reason rather than forwarding an unauthenticated
# request the upstream would reject.
d = decide(
(Route(host="api.github.com", auth_scheme="Bearer",
token_env="EGRESS_PROXY_TOKEN_0"),),
"api.github.com", "/repos/x", {},
)
self.assertEqual("block", d.action)
self.assertIn("EGRESS_PROXY_TOKEN_0", d.reason)
def test_auth_with_empty_token_env_blocks(self):
# Empty env var is treated the same as unset — we don't inject
# a literal "Bearer " (blank token) which would burn the
# upstream rate limit with a 401.
d = decide(
(Route(host="api.github.com", auth_scheme="Bearer",
token_env="EGRESS_PROXY_TOKEN_0"),),
"api.github.com", "/repos/x", {"EGRESS_PROXY_TOKEN_0": ""},
)
self.assertEqual("block", d.action)
def test_unauthenticated_route_skips_injection(self):
d = decide(
(Route(host="github.com", path_allowlist=("/x/",)),),
"github.com", "/x/repo", {"GH_PAT": "should-not-appear"},
)
self.assertEqual("forward", d.action)
self.assertIsNone(d.inject_authorization)
def test_token_token_scheme(self):
# Gitea uses `Authorization: token <pat>` (sidesteps
# go-gitea/gitea#16734). The addon is scheme-agnostic.
d = decide(
(Route(host="git.example", auth_scheme="token",
token_env="EGRESS_PROXY_TOKEN_0"),),
"git.example", "/api/v1/repos", {"EGRESS_PROXY_TOKEN_0": "abc"},
)
self.assertEqual("token abc", d.inject_authorization)
# --- Decision dataclass --------------------------------------------------
class TestDecisionDefaults(unittest.TestCase):
def test_forward_default_has_no_reason_or_inject(self):
d = Decision(action="forward")
self.assertEqual("", d.reason)
self.assertIsNone(d.inject_authorization)
if __name__ == "__main__":
unittest.main()
+173
View File
@@ -0,0 +1,173 @@
"""Unit: manifest parsing for `bottle.egress_proxy.routes[]` (PRD 0017).
The route shape is new: `host` (required), optional `path_allowlist`,
optional nested `auth: { scheme, token_ref }`. Validation rules per
the PRD: empty `auth: {}` is an error, partial `auth` is an error,
auth omission means unauthenticated."""
import unittest
from claude_bottle.log import Die
from claude_bottle.manifest import EgressProxyRoute, Manifest
def _bottle(routes):
return Manifest.from_json_obj({
"bottles": {"dev": {"egress_proxy": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
class TestMinimalRoute(unittest.TestCase):
def test_host_only(self):
b = _bottle([{"host": "api.example.com"}])
self.assertEqual(1, len(b.egress_proxy.routes))
r = b.egress_proxy.routes[0]
self.assertEqual("api.example.com", r.Host)
self.assertEqual((), r.PathAllowlist)
self.assertEqual("", r.AuthScheme)
self.assertEqual("", r.TokenRef)
def test_host_required(self):
with self.assertRaises(Die):
_bottle([{}])
def test_host_must_be_non_empty(self):
with self.assertRaises(Die):
_bottle([{"host": ""}])
def test_unknown_top_level_key_dies(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "wat": "yes"}])
class TestPathAllowlist(unittest.TestCase):
def test_optional(self):
b = _bottle([{"host": "x.example"}])
self.assertEqual((), b.egress_proxy.routes[0].PathAllowlist)
def test_must_be_array(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "path_allowlist": "/x/"}])
def test_items_must_be_strings(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "path_allowlist": [42]}])
def test_items_must_be_absolute_paths(self):
with self.assertRaises(Die):
_bottle([{"host": "x.example", "path_allowlist": ["nope/"]}])
def test_full_list(self):
b = _bottle([{
"host": "github.com",
"path_allowlist": ["/didericis/", "/users/didericis"],
}])
self.assertEqual(
("/didericis/", "/users/didericis"),
b.egress_proxy.routes[0].PathAllowlist,
)
class TestAuth(unittest.TestCase):
def test_omitted_means_no_auth(self):
b = _bottle([{"host": "github.com"}])
r = b.egress_proxy.routes[0]
self.assertEqual("", r.AuthScheme)
self.assertEqual("", r.TokenRef)
def test_full_auth(self):
b = _bottle([{
"host": "api.github.com",
"auth": {"scheme": "Bearer", "token_ref": "GH_PAT"},
}])
r = b.egress_proxy.routes[0]
self.assertEqual("Bearer", r.AuthScheme)
self.assertEqual("GH_PAT", r.TokenRef)
def test_empty_auth_block_rejected(self):
# Per PRD 0017: `auth: {}` is an error, not a synonym for
# "no auth" — that's what omission is for.
with self.assertRaises(Die):
_bottle([{"host": "x.example", "auth": {}}])
def test_missing_scheme_rejected(self):
with self.assertRaises(Die):
_bottle([{
"host": "x.example",
"auth": {"token_ref": "T"},
}])
def test_missing_token_ref_rejected(self):
with self.assertRaises(Die):
_bottle([{
"host": "x.example",
"auth": {"scheme": "Bearer"},
}])
def test_unknown_scheme_rejected(self):
with self.assertRaises(Die):
_bottle([{
"host": "x.example",
"auth": {"scheme": "Basic", "token_ref": "T"},
}])
def test_token_scheme_allowed(self):
# Gitea quirk: `Authorization: token <pat>` (not Bearer).
b = _bottle([{
"host": "git.example",
"auth": {"scheme": "token", "token_ref": "GITEA_PAT"},
}])
self.assertEqual("token", b.egress_proxy.routes[0].AuthScheme)
def test_unknown_auth_key_rejected(self):
with self.assertRaises(Die):
_bottle([{
"host": "x.example",
"auth": {"scheme": "Bearer", "token_ref": "T", "extra": "no"},
}])
class TestRouteValidation(unittest.TestCase):
def test_duplicate_hosts_rejected(self):
# Routes match by exact host; duplicates leave the choice
# ambiguous, so we reject them up front rather than picking
# the first/last silently.
with self.assertRaises(Die):
_bottle([
{"host": "github.com"},
{"host": "github.com", "path_allowlist": ["/x/"]},
])
def test_duplicate_host_case_insensitive(self):
with self.assertRaises(Die):
_bottle([
{"host": "GitHub.com"},
{"host": "github.com"},
])
def test_empty_routes_allowed(self):
b = _bottle([])
self.assertEqual((), b.egress_proxy.routes)
def test_no_egress_proxy_block_means_empty(self):
# The bottle dataclass defaults to an empty EgressProxyConfig.
b = Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
self.assertEqual((), b.egress_proxy.routes)
class TestConfigShape(unittest.TestCase):
def test_unknown_egress_proxy_key_rejected(self):
with self.assertRaises(Die):
Manifest.from_json_obj({
"bottles": {"dev": {"egress_proxy": {"wat": []}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
})
if __name__ == "__main__":
unittest.main()