feat(egress): add global log option for full request/response logging
Adds a top-level `log: true` option to the egress config that logs the full request (method, path, headers, body) and response (status, headers, body) for every forwarded connection as JSON lines on stderr. Wire format: `log: true` at the root of routes.yaml, parsed into the new `Config` dataclass alongside `routes`. The sidecar addon switches from `self.routes` to `self.config` and writes `_log_request` / `_log_response` JSON lines when `self.config.log` is set. Manifest: `egress.log: true` in bottle YAML flows through `EgressConfig.Log` → `Egress.prepare()` → `egress_render_routes(..., log=)` → routes.yaml. `EgressPlan` also carries the flag for introspection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -62,6 +62,7 @@ class EgressPlan:
|
|||||||
egress_network: str = ""
|
egress_network: str = ""
|
||||||
mitmproxy_ca_host_path: Path = Path()
|
mitmproxy_ca_host_path: Path = Path()
|
||||||
mitmproxy_ca_cert_only_host_path: Path = Path()
|
mitmproxy_ca_cert_only_host_path: Path = Path()
|
||||||
|
log: bool = False
|
||||||
|
|
||||||
|
|
||||||
def egress_manifest_routes(
|
def egress_manifest_routes(
|
||||||
@@ -190,10 +191,15 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
|||||||
|
|
||||||
def egress_render_routes(
|
def egress_render_routes(
|
||||||
routes: tuple[EgressRoute, ...],
|
routes: tuple[EgressRoute, ...],
|
||||||
|
*,
|
||||||
|
log: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
lines: list[str] = ["routes:"]
|
lines: list[str] = []
|
||||||
|
if log:
|
||||||
|
lines.append("log: true")
|
||||||
|
lines.append("routes:")
|
||||||
if not routes:
|
if not routes:
|
||||||
lines[0] = "routes: []"
|
lines[-1] = "routes: []"
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
for r in routes:
|
for r in routes:
|
||||||
f = _route_to_yaml_fields(r)
|
f = _route_to_yaml_fields(r)
|
||||||
@@ -279,14 +285,16 @@ class Egress(ABC):
|
|||||||
provider_routes: tuple[EgressRoute, ...] = (),
|
provider_routes: tuple[EgressRoute, ...] = (),
|
||||||
) -> EgressPlan:
|
) -> EgressPlan:
|
||||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||||
|
log = bottle.egress.Log
|
||||||
routes_path = stage_dir / "egress_routes.yaml"
|
routes_path = stage_dir / "egress_routes.yaml"
|
||||||
routes_path.write_text(egress_render_routes(routes))
|
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||||
routes_path.chmod(0o600)
|
routes_path.chmod(0o600)
|
||||||
return EgressPlan(
|
return EgressPlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
routes_path=routes_path,
|
routes_path=routes_path,
|
||||||
routes=routes,
|
routes=routes,
|
||||||
token_env_map=egress_token_env_map(routes),
|
token_env_map=egress_token_env_map(routes),
|
||||||
|
log=log,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
+43
-11
@@ -15,10 +15,11 @@ from pathlib import Path
|
|||||||
from mitmproxy import http # type: ignore[import-not-found]
|
from mitmproxy import http # type: ignore[import-not-found]
|
||||||
|
|
||||||
from egress_addon_core import ( # type: ignore[import-not-found]
|
from egress_addon_core import ( # type: ignore[import-not-found]
|
||||||
|
Config,
|
||||||
Route,
|
Route,
|
||||||
decide,
|
decide,
|
||||||
is_git_push_request,
|
is_git_push_request,
|
||||||
load_routes,
|
load_config,
|
||||||
match_route,
|
match_route,
|
||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
@@ -33,26 +34,27 @@ INTROSPECT_HOST = "_egress.local"
|
|||||||
class EgressAddon:
|
class EgressAddon:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
||||||
self.routes: tuple[Route, ...] = ()
|
self.config: Config = Config(routes=())
|
||||||
self._reload(initial=True)
|
self._reload(initial=True)
|
||||||
self._install_sighup()
|
self._install_sighup()
|
||||||
|
|
||||||
def _reload(self, *, initial: bool = False) -> None:
|
def _reload(self, *, initial: bool = False) -> None:
|
||||||
try:
|
try:
|
||||||
text = Path(self.routes_path).read_text(encoding="utf-8")
|
text = Path(self.routes_path).read_text(encoding="utf-8")
|
||||||
new_routes = load_routes(text)
|
new_config = load_config(text)
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
tag = "boot" if initial else "SIGHUP"
|
tag = "boot" if initial else "SIGHUP"
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"egress: {tag} load failed: {e}\n"
|
f"egress: {tag} load failed: {e}\n"
|
||||||
)
|
)
|
||||||
if initial:
|
if initial:
|
||||||
self.routes = ()
|
self.config = Config(routes=())
|
||||||
return
|
return
|
||||||
self.routes = new_routes
|
self.config = new_config
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"egress: loaded {len(self.routes)} route(s): "
|
f"egress: loaded {len(self.config.routes)} route(s): "
|
||||||
f"{', '.join(r.host for r in self.routes)}\n"
|
f"{', '.join(r.host for r in self.config.routes)}"
|
||||||
|
f"{' [log=on]' if self.config.log else ''}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _install_sighup(self) -> None:
|
def _install_sighup(self) -> None:
|
||||||
@@ -68,7 +70,7 @@ class EgressAddon:
|
|||||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
||||||
if path == "/allowlist":
|
if path == "/allowlist":
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"routes": [dataclasses.asdict(r) for r in self.routes]},
|
{"routes": [dataclasses.asdict(r) for r in self.config.routes]},
|
||||||
indent=2,
|
indent=2,
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
flow.response = http.Response.make(
|
flow.response = http.Response.make(
|
||||||
@@ -90,6 +92,31 @@ class EgressAddon:
|
|||||||
{"Content-Type": "text/plain; charset=utf-8"},
|
{"Content-Type": "text/plain; charset=utf-8"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _log_request(self, flow: http.HTTPFlow) -> None:
|
||||||
|
sys.stderr.write(
|
||||||
|
json.dumps({
|
||||||
|
"event": "egress_request",
|
||||||
|
"host": flow.request.pretty_host,
|
||||||
|
"method": flow.request.method,
|
||||||
|
"path": flow.request.path,
|
||||||
|
"headers": dict(flow.request.headers),
|
||||||
|
"body": flow.request.get_text(strict=False) or "",
|
||||||
|
})
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_response(self, flow: http.HTTPFlow) -> None:
|
||||||
|
sys.stderr.write(
|
||||||
|
json.dumps({
|
||||||
|
"event": "egress_response",
|
||||||
|
"host": flow.request.pretty_host,
|
||||||
|
"status": flow.response.status_code,
|
||||||
|
"headers": dict(flow.response.headers),
|
||||||
|
"body": flow.response.get_text(strict=False) or "",
|
||||||
|
})
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
def request(self, flow: http.HTTPFlow) -> None:
|
def request(self, flow: http.HTTPFlow) -> None:
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
@@ -99,7 +126,7 @@ class EgressAddon:
|
|||||||
|
|
||||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
||||||
# agent tried to smuggle in the Authorization header.
|
# agent tried to smuggle in the Authorization header.
|
||||||
route = match_route(self.routes, flow.request.pretty_host)
|
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||||
if route is not None:
|
if route is not None:
|
||||||
body = flow.request.get_text(strict=False) or ""
|
body = flow.request.get_text(strict=False) or ""
|
||||||
auth_header = flow.request.headers.get("authorization", "")
|
auth_header = flow.request.headers.get("authorization", "")
|
||||||
@@ -127,7 +154,7 @@ class EgressAddon:
|
|||||||
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
||||||
|
|
||||||
decision = decide(
|
decision = decide(
|
||||||
self.routes,
|
self.config.routes,
|
||||||
flow.request.pretty_host,
|
flow.request.pretty_host,
|
||||||
request_path,
|
request_path,
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -142,13 +169,18 @@ class EgressAddon:
|
|||||||
if decision.inject_authorization is not None:
|
if decision.inject_authorization is not None:
|
||||||
flow.request.headers["authorization"] = decision.inject_authorization
|
flow.request.headers["authorization"] = decision.inject_authorization
|
||||||
|
|
||||||
|
if self.config.log:
|
||||||
|
self._log_request(flow)
|
||||||
|
|
||||||
def response(self, flow: http.HTTPFlow) -> None:
|
def response(self, flow: http.HTTPFlow) -> None:
|
||||||
"""DLP inbound scan on response bodies (PRD 0053)."""
|
"""DLP inbound scan on response bodies (PRD 0053)."""
|
||||||
route = match_route(self.routes, flow.request.pretty_host)
|
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||||
if route is None:
|
if route is None:
|
||||||
return
|
return
|
||||||
if flow.response is None:
|
if flow.response is None:
|
||||||
return
|
return
|
||||||
|
if self.config.log:
|
||||||
|
self._log_response(flow)
|
||||||
body = flow.response.get_text(strict=False) or ""
|
body = flow.response.get_text(strict=False) or ""
|
||||||
if not body:
|
if not body:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ class Route:
|
|||||||
inbound_detectors: tuple[str, ...] | None = None
|
inbound_detectors: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
routes: tuple[Route, ...]
|
||||||
|
log: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Decision:
|
class Decision:
|
||||||
action: str # "forward" or "block"
|
action: str # "forward" or "block"
|
||||||
@@ -334,6 +340,29 @@ def load_routes(text: str) -> tuple[Route, ...]:
|
|||||||
return parse_routes(payload)
|
return parse_routes(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_config(payload: object) -> "Config":
|
||||||
|
"""Parse a full egress config payload (top-level log flag + routes)."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("routes payload: top-level must be an object")
|
||||||
|
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
||||||
|
|
||||||
|
log_raw: object = payload_dict.get("log", False)
|
||||||
|
if not isinstance(log_raw, bool):
|
||||||
|
raise ValueError("routes payload: 'log' must be true or false")
|
||||||
|
|
||||||
|
routes = parse_routes(payload)
|
||||||
|
return Config(routes=routes, log=log_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str) -> "Config":
|
||||||
|
"""Parse YAML text → Config (routes + log flag)."""
|
||||||
|
try:
|
||||||
|
payload = parse_yaml_subset(text)
|
||||||
|
except YamlSubsetError as e:
|
||||||
|
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
||||||
|
return parse_config(payload)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Match evaluation
|
# Match evaluation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -535,6 +564,7 @@ def scan_inbound(
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Config",
|
||||||
"Decision",
|
"Decision",
|
||||||
"HeaderMatch",
|
"HeaderMatch",
|
||||||
"MatchEntry",
|
"MatchEntry",
|
||||||
@@ -544,8 +574,10 @@ __all__ = [
|
|||||||
"decide",
|
"decide",
|
||||||
"evaluate_matches",
|
"evaluate_matches",
|
||||||
"is_git_push_request",
|
"is_git_push_request",
|
||||||
|
"load_config",
|
||||||
"load_routes",
|
"load_routes",
|
||||||
"match_route",
|
"match_route",
|
||||||
|
"parse_config",
|
||||||
"parse_routes",
|
"parse_routes",
|
||||||
"scan_inbound",
|
"scan_inbound",
|
||||||
"scan_outbound",
|
"scan_outbound",
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ def _parse_dlp_block(
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EgressConfig:
|
class EgressConfig:
|
||||||
routes: tuple[EgressRoute, ...] = ()
|
routes: tuple[EgressRoute, ...] = ()
|
||||||
|
Log: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
|
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
|
||||||
@@ -367,10 +368,15 @@ class EgressConfig:
|
|||||||
for i, entry in enumerate(routes_list)
|
for i, entry in enumerate(routes_list)
|
||||||
)
|
)
|
||||||
validate_egress_routes(bottle_name, routes)
|
validate_egress_routes(bottle_name, routes)
|
||||||
|
log_raw = d.get("log", False)
|
||||||
|
if not isinstance(log_raw, bool):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' egress.log must be true or false"
|
||||||
|
)
|
||||||
for k in d:
|
for k in d:
|
||||||
if k != "routes":
|
if k not in ("routes", "log"):
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' egress has unknown key {k!r}; "
|
f"bottle '{bottle_name}' egress has unknown key {k!r}; "
|
||||||
f"only 'routes' is accepted"
|
f"accepted keys are 'routes', 'log'"
|
||||||
)
|
)
|
||||||
return cls(routes=routes)
|
return cls(routes=routes, Log=log_raw)
|
||||||
|
|||||||
@@ -324,6 +324,44 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
self.assertEqual(("token_patterns",), addon_routes[0].outbound_detectors)
|
self.assertEqual(("token_patterns",), addon_routes[0].outbound_detectors)
|
||||||
self.assertEqual((), addon_routes[0].inbound_detectors)
|
self.assertEqual((), addon_routes[0].inbound_detectors)
|
||||||
|
|
||||||
|
def test_log_false_omitted_from_render(self):
|
||||||
|
b = _bottle([{"host": "x.example"}])
|
||||||
|
routes = egress_routes_for_bottle(b)
|
||||||
|
rendered = egress_render_routes(routes, log=False)
|
||||||
|
self.assertNotIn("log:", rendered)
|
||||||
|
|
||||||
|
def test_log_true_emitted_at_top_level(self):
|
||||||
|
b = _bottle([{"host": "x.example"}])
|
||||||
|
routes = egress_routes_for_bottle(b)
|
||||||
|
rendered = egress_render_routes(routes, log=True)
|
||||||
|
self.assertTrue(rendered.startswith("log: true\n"))
|
||||||
|
|
||||||
|
def test_log_true_round_trips_to_addon_core(self):
|
||||||
|
from bot_bottle.egress_addon_core import load_config
|
||||||
|
b = _bottle([{"host": "x.example"}])
|
||||||
|
routes = egress_routes_for_bottle(b)
|
||||||
|
rendered = egress_render_routes(routes, log=True)
|
||||||
|
cfg = load_config(rendered)
|
||||||
|
self.assertTrue(cfg.log)
|
||||||
|
self.assertEqual("x.example", cfg.routes[0].host)
|
||||||
|
|
||||||
|
def test_log_via_manifest_flows_to_render(self):
|
||||||
|
from bot_bottle.manifest import Manifest
|
||||||
|
from bot_bottle.egress_addon_core import load_config
|
||||||
|
m = Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {"egress": {
|
||||||
|
"log": True,
|
||||||
|
"routes": [{"host": "x.example"}],
|
||||||
|
}}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
})
|
||||||
|
bottle = m.bottles["dev"]
|
||||||
|
self.assertTrue(bottle.egress.Log)
|
||||||
|
routes = egress_routes_for_bottle(bottle)
|
||||||
|
rendered = egress_render_routes(routes, log=bottle.egress.Log)
|
||||||
|
cfg = load_config(rendered)
|
||||||
|
self.assertTrue(cfg.log)
|
||||||
|
|
||||||
|
|
||||||
class TestResolveTokenValues(unittest.TestCase):
|
class TestResolveTokenValues(unittest.TestCase):
|
||||||
def test_reads_host_env(self):
|
def test_reads_host_env(self):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from bot_bottle.egress_addon_core import (
|
from bot_bottle.egress_addon_core import (
|
||||||
|
Config,
|
||||||
Decision,
|
Decision,
|
||||||
HeaderMatch,
|
HeaderMatch,
|
||||||
MatchEntry,
|
MatchEntry,
|
||||||
@@ -21,8 +22,10 @@ from bot_bottle.egress_addon_core import (
|
|||||||
decide,
|
decide,
|
||||||
evaluate_matches,
|
evaluate_matches,
|
||||||
is_git_push_request,
|
is_git_push_request,
|
||||||
|
load_config,
|
||||||
load_routes,
|
load_routes,
|
||||||
match_route,
|
match_route,
|
||||||
|
parse_config,
|
||||||
parse_routes,
|
parse_routes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,6 +274,34 @@ class TestLoadRoutes(unittest.TestCase):
|
|||||||
load_routes("routes:\n\t- host: x\n")
|
load_routes("routes:\n\t- host: x\n")
|
||||||
|
|
||||||
|
|
||||||
|
# --- load_config / parse_config ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig(unittest.TestCase):
|
||||||
|
def test_log_defaults_to_false(self):
|
||||||
|
cfg = load_config('routes:\n - host: "api.example"\n')
|
||||||
|
self.assertFalse(cfg.log)
|
||||||
|
self.assertEqual(1, len(cfg.routes))
|
||||||
|
|
||||||
|
def test_log_true_parsed(self):
|
||||||
|
cfg = load_config('log: true\nroutes:\n - host: "api.example"\n')
|
||||||
|
self.assertTrue(cfg.log)
|
||||||
|
self.assertEqual(1, len(cfg.routes))
|
||||||
|
|
||||||
|
def test_log_false_explicit(self):
|
||||||
|
cfg = load_config('log: false\nroutes:\n - host: "api.example"\n')
|
||||||
|
self.assertFalse(cfg.log)
|
||||||
|
|
||||||
|
def test_log_non_bool_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
load_config('log: "yes"\nroutes: []\n')
|
||||||
|
|
||||||
|
def test_routes_accessible_via_config(self):
|
||||||
|
cfg = load_config('routes:\n - host: "x.example"\n')
|
||||||
|
self.assertIsInstance(cfg, Config)
|
||||||
|
self.assertEqual("x.example", cfg.routes[0].host)
|
||||||
|
|
||||||
|
|
||||||
# --- evaluate_matches ---------------------------------------------------
|
# --- evaluate_matches ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,25 @@ class TestConfigShape(unittest.TestCase):
|
|||||||
"bottle": "dev"}},
|
"bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def test_log_defaults_false(self):
|
||||||
|
b = _bottle([])
|
||||||
|
self.assertFalse(b.egress.Log)
|
||||||
|
|
||||||
|
def test_log_true_accepted(self):
|
||||||
|
b = Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {"egress": {"log": True, "routes": []}}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}).bottles["dev"]
|
||||||
|
self.assertTrue(b.egress.Log)
|
||||||
|
|
||||||
|
def test_log_non_bool_rejected(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
Manifest.from_json_obj({
|
||||||
|
"bottles": {"dev": {"egress": {"log": "yes"}}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "",
|
||||||
|
"bottle": "dev"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user