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()