diff --git a/claude_bottle/sidecar_init.py b/claude_bottle/sidecar_init.py index 88a3a40..2ccfe86 100644 --- a/claude_bottle/sidecar_init.py +++ b/claude_bottle/sidecar_init.py @@ -54,6 +54,33 @@ class _DaemonSpec: argv: Sequence[str] +# Env-var name prefixes that carry egress-only credentials. +# `egress_apply.py` assigns `EGRESS_TOKEN_` slots that egress +# reads to inject `Authorization` headers on configured routes; +# every other daemon in the bundle (especially pipelock with +# `scan_env: true`) MUST NOT see these values or it'll match the +# injected token in the request egress just sent and 403-block +# the legitimate traffic (issue #84). The agent itself runs in a +# different machine and never has access to these slots in the +# first place, so stripping them from non-egress daemons loses no +# DLP coverage — pipelock can't catch the exfil of a value the +# agent doesn't have. +_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) + + +def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]: + """Egress sees the full bundle env. Everyone else gets a copy + with `EGRESS_TOKEN_*` (and any other future egress-only + credential slots) stripped. Returns a fresh dict — callers + can mutate without affecting `base_env`.""" + if name == "egress": + return dict(base_env) + return { + k: v for k, v in base_env.items() + if not any(k.startswith(p) for p in _EGRESS_ONLY_ENV_PREFIXES) + } + + # Order matters only for first-launch race-window reasons: egress # starts first so pipelock's upstream connect succeeds during # pipelock's own startup. git-gate and supervise are independent. @@ -116,6 +143,7 @@ def _spawn(spec: _DaemonSpec) -> subprocess.Popen: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, + env=_env_for_daemon(spec.name, dict(os.environ)), ) threading.Thread( target=_pump, args=(spec.name, proc.stdout), daemon=True diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index b750605..a2b87db 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -20,10 +20,56 @@ from unittest.mock import patch from claude_bottle.sidecar_init import ( _DaemonSpec, _Supervisor, + _env_for_daemon, _selected_daemons, ) +class TestEnvForDaemon(unittest.TestCase): + """Scope egress-only credential env vars to the egress daemon. + + Regression for issue #84: pipelock's `scan_env: true` matched + `EGRESS_TOKEN_*` against egress's just-injected Authorization + header and 403-blocked the legitimate request. The agent + never has access to these slots, so stripping them from + non-egress daemons loses no DLP coverage.""" + + _BASE = { + "PATH": "/usr/bin", + "EGRESS_TOKEN_0": "tok-anthropic", + "EGRESS_TOKEN_1": "tok-gitea", + "EGRESS_UPSTREAM_PROXY": "http://127.0.0.1:8888", + "SUPERVISE_PORT": "9100", + } + + def test_egress_sees_full_env(self): + env = _env_for_daemon("egress", self._BASE) + self.assertEqual(self._BASE, env) + + def test_pipelock_loses_egress_tokens(self): + env = _env_for_daemon("pipelock", self._BASE) + self.assertNotIn("EGRESS_TOKEN_0", env) + self.assertNotIn("EGRESS_TOKEN_1", env) + # Non-token bundle env stays — supervise / git-gate / the + # upstream proxy URL are all load-bearing for other + # daemons. + self.assertEqual("/usr/bin", env["PATH"]) + self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) + self.assertEqual("9100", env["SUPERVISE_PORT"]) + + def test_git_gate_and_supervise_also_lose_egress_tokens(self): + for name in ("git-gate", "supervise"): + env = _env_for_daemon(name, self._BASE) + self.assertNotIn("EGRESS_TOKEN_0", env) + self.assertNotIn("EGRESS_TOKEN_1", env) + + def test_returns_independent_dict(self): + # Caller mutation mustn't affect the original. + env = _env_for_daemon("pipelock", self._BASE) + env["X"] = "y" + self.assertNotIn("X", self._BASE) + + class TestSelectedDaemons(unittest.TestCase): """Env-var subset filtering. The compose renderer is the source of truth for which daemons are wired; the supervisor just