From 909029085eaafe417b2904409f6d859a65225425 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 04:49:22 -0400 Subject: [PATCH] feat(sidecars): egress binds 127.0.0.1 when EGRESS_LISTEN_HOST is set (PRD 0023 chunk 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Egress's bind address is now env-driven via EGRESS_LISTEN_HOST. Unset → mitmdump's default (all interfaces) — the docker backend's behavior, unchanged. Set to `127.0.0.1` → mitmdump binds localhost only. The smolmachines launch sets EGRESS_LISTEN_HOST=127.0.0.1 in the bundle's env unconditionally. TSI's allowlist is `/32` (IP-only, not port-granular), which would otherwise let the agent dial `:9099` and bypass pipelock's DLP by talking to egress directly. Binding egress to localhost inside the bundle closes that gap at the socket level — the agent still reaches the IP (TSI permits it) but egress refuses the connect because it's not listening on the docker bridge interface. The docker backend doesn't set the env var because its agent dials egress directly via the docker network alias — egress MUST be reachable from outside the bundle there. The asymmetry is documented in the entrypoint script's comment. Changes: - egress_entrypoint.sh: read EGRESS_LISTEN_HOST, conditionally pass `--listen-host ` to mitmdump. - smolmachines/launch.py: BundleLaunchSpec.environment now includes `EGRESS_LISTEN_HOST=127.0.0.1`. - New unit tests (5): the entrypoint script's argv shape under various env combinations, verified via a fake mitmdump shim that prints its argv. 545 unit + 3 integration tests passing. The egress-port-bypass probe from chunk 2d still passes (chunk 2d ran with daemons_csv="" so no egress was up; chunk 3 makes the probe preserve its property once egress IS up in chunk 4). Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/smolmachines/launch.py | 9 ++ claude_bottle/egress_entrypoint.sh | 14 ++- tests/unit/test_egress_entrypoint.py | 98 ++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_egress_entrypoint.py diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index f62f794..8a2845d 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -48,6 +48,15 @@ def launch( # bringup with inner-Plan-driven env + volumes lands # in chunk 4 alongside provisioning. daemons_csv="", + # PRD 0023 chunk 3: pin egress to localhost INSIDE the + # bundle so the agent's TSI-permitted `:*` + # connect to :9099 refuses at the socket level. Always + # set in smolmachines mode — agent dials pipelock, not + # egress, so egress is bundle-internal regardless of + # whether routes are declared. The docker backend + # doesn't set this env (egress on 0.0.0.0 by default) + # since the docker agent goes via the egress alias. + environment=("EGRESS_LISTEN_HOST=127.0.0.1",), ) _bundle.start_bundle(bundle_spec) stack.callback(_bundle.stop_bundle, plan.slug) diff --git a/claude_bottle/egress_entrypoint.sh b/claude_bottle/egress_entrypoint.sh index fddf17b..10556f0 100644 --- a/claude_bottle/egress_entrypoint.sh +++ b/claude_bottle/egress_entrypoint.sh @@ -36,6 +36,18 @@ if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then MODE="--mode upstream:$EGRESS_UPSTREAM_PROXY --listen-port 9099" fi +# Bind address. Docker backend wants `0.0.0.0` (agent dials egress +# directly via the docker network alias). Smolmachines backend +# wants `127.0.0.1` because the agent dials pipelock — not egress +# — and egress is pipelock's localhost-only upstream inside the +# bundle. TSI's IP-only allowlist would otherwise let the agent +# reach `:9099` and bypass pipelock's DLP; binding +# 127.0.0.1 inside the bundle closes that gap (PRD 0023 chunk 3). +LISTEN_HOST_FLAG="" +if [ -n "$EGRESS_LISTEN_HOST" ]; then + LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST" +fi + TRUST_FLAG="" if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then COMBINED=$CONFDIR/combined-trust.pem @@ -57,4 +69,4 @@ if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then export NO_PROXY="localhost,127.0.0.1" fi -exec mitmdump $CONFDIR_FLAG $MODE $TRUST_FLAG -s /app/egress_addon.py +exec mitmdump $CONFDIR_FLAG $MODE $LISTEN_HOST_FLAG $TRUST_FLAG -s /app/egress_addon.py diff --git a/tests/unit/test_egress_entrypoint.py b/tests/unit/test_egress_entrypoint.py new file mode 100644 index 0000000..3e88c17 --- /dev/null +++ b/tests/unit/test_egress_entrypoint.py @@ -0,0 +1,98 @@ +"""Unit: egress_entrypoint.sh argv construction (PRD 0023 chunk 3). + +The egress entrypoint is a small POSIX-sh script that builds +the mitmdump argv from env vars. The smolmachines backend +controls egress's bind address via EGRESS_LISTEN_HOST; the +docker backend leaves it unset and gets mitmdump's default +(all interfaces). + +We can't easily unit-test a shell script as Python, but we can +run it under `sh -x` with mitmdump stubbed to print its argv, +which is exactly what these tests do.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +_SCRIPT = ( + Path(__file__).resolve().parent.parent.parent + / "claude_bottle" / "egress_entrypoint.sh" +) + + +def _run_entrypoint(env: dict[str, str]) -> str: + """Run egress_entrypoint.sh with a stubbed mitmdump that + prints its argv. Returns the argv as a single string. + + The script uses `exec mitmdump ...`. We shim by prepending a + fake `mitmdump` to PATH; the shim writes its argv to stdout + and exits 0.""" + with tempfile.TemporaryDirectory() as tmp: + shim_dir = Path(tmp) + shim = shim_dir / "mitmdump" + shim.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$@"\n' + ) + shim.chmod(0o755) + run_env = { + "PATH": f"{shim_dir}:{os.environ['PATH']}", + # cat needs to find ca-certificates.crt for the + # trust-bundle branch; we don't test that path here. + **env, + } + result = subprocess.run( + ["sh", str(_SCRIPT)], + capture_output=True, text=True, env=run_env, + check=True, + ) + return result.stdout + + +class TestEgressEntrypointArgv(unittest.TestCase): + def test_default_mode_regular_no_listen_host(self): + # No env set: --mode regular@9099 + no --listen-host. + argv = _run_entrypoint({}) + self.assertIn("--mode\nregular@9099", argv) + self.assertNotIn("--listen-host", argv) + # Confdir always present. + self.assertIn( + "--set\nconfdir=/home/mitmproxy/.mitmproxy", + argv, + ) + + def test_listen_host_127_0_0_1_emits_flag(self): + # smolmachines backend sets EGRESS_LISTEN_HOST=127.0.0.1 + # to scope egress to localhost inside the bundle. + argv = _run_entrypoint({"EGRESS_LISTEN_HOST": "127.0.0.1"}) + self.assertIn("--listen-host\n127.0.0.1", argv) + + def test_listen_host_unset_emits_no_flag(self): + # Docker backend leaves it unset and gets mitmdump's + # default bind address (all interfaces). + argv = _run_entrypoint({"EGRESS_LISTEN_HOST": ""}) + self.assertNotIn("--listen-host", argv) + + def test_upstream_mode_combined_with_listen_host(self): + # smolmachines mode also sets EGRESS_UPSTREAM_PROXY so + # both flags should compose correctly. + argv = _run_entrypoint({ + "EGRESS_UPSTREAM_PROXY": "http://192.168.50.2:8888", + "EGRESS_LISTEN_HOST": "127.0.0.1", + }) + self.assertIn("--mode\nupstream:http://192.168.50.2:8888", argv) + self.assertIn("--listen-port\n9099", argv) + self.assertIn("--listen-host\n127.0.0.1", argv) + + def test_addon_always_loaded(self): + argv = _run_entrypoint({}) + self.assertIn("-s\n/app/egress_addon.py", argv) + + +if __name__ == "__main__": + unittest.main()