fix(sidecar_init): scope EGRESS_TOKEN_* to egress daemon only (issue #84) #85
@@ -54,6 +54,33 @@ class _DaemonSpec:
|
|||||||
argv: Sequence[str]
|
argv: Sequence[str]
|
||||||
|
|
||||||
|
|
||||||
|
# Env-var name prefixes that carry egress-only credentials.
|
||||||
|
# `egress_apply.py` assigns `EGRESS_TOKEN_<n>` 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
|
# Order matters only for first-launch race-window reasons: egress
|
||||||
# starts first so pipelock's upstream connect succeeds during
|
# starts first so pipelock's upstream connect succeeds during
|
||||||
# pipelock's own startup. git-gate and supervise are independent.
|
# pipelock's own startup. git-gate and supervise are independent.
|
||||||
@@ -116,6 +143,7 @@ def _spawn(spec: _DaemonSpec) -> subprocess.Popen:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
|
env=_env_for_daemon(spec.name, dict(os.environ)),
|
||||||
)
|
)
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=_pump, args=(spec.name, proc.stdout), daemon=True
|
target=_pump, args=(spec.name, proc.stdout), daemon=True
|
||||||
|
|||||||
@@ -20,10 +20,56 @@ from unittest.mock import patch
|
|||||||
from claude_bottle.sidecar_init import (
|
from claude_bottle.sidecar_init import (
|
||||||
_DaemonSpec,
|
_DaemonSpec,
|
||||||
_Supervisor,
|
_Supervisor,
|
||||||
|
_env_for_daemon,
|
||||||
_selected_daemons,
|
_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):
|
class TestSelectedDaemons(unittest.TestCase):
|
||||||
"""Env-var subset filtering. The compose renderer is the source
|
"""Env-var subset filtering. The compose renderer is the source
|
||||||
of truth for which daemons are wired; the supervisor just
|
of truth for which daemons are wired; the supervisor just
|
||||||
|
|||||||
Reference in New Issue
Block a user