diff --git a/AGENTS.md b/AGENTS.md index 15f7c02..a0b74be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,15 @@ ## What this is -bot-bottle spins up an isolated container for running AI coding agents with a -curated set of skills and env vars. The point is to run agents with broad -permissions inside a sandbox, so a misbehaving agent cannot reach the host. -A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates -the container lifecycle and the copying of skills and env vars into it. +bot-bottle spins up an isolated backend runtime for running AI coding agents +with a curated set of skills and env vars. The point is to run agents with +broad permissions inside a sandbox, so a misbehaving agent cannot reach the +host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates +the runtime lifecycle and the copying of skills and env vars into it. +The default backend is smolmachines on macOS: agents run in a libkrun +micro-VM, while the sidecar bundle still uses Docker. The legacy Docker +backend remains available with `BOT_BOTTLE_BACKEND=docker` or +`--backend=docker`. ## Goals @@ -17,7 +21,7 @@ the container lifecycle and the copying of skills and env vars into it. ## Non-goals - Communicating between agents directly -- Self hosted VMs (v1 uses local Docker containers, not VMs) +- Removing the Docker backend - Advanced agent auditing (lean on git history for auditing) ## Repository layout diff --git a/README.md b/README.md index 10b8b8b..3460598 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,19 @@ - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host. - **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top. -- **Parallel, isolated bottles** — each bottle is its own per-agent Docker `--internal` network; bottles don't share state or talk to each other. +- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. -- **Smolmachines backend (macOS)** — opt-in `BOT_BOTTLE_BACKEND=smolmachines` runs the agent in a libkrun micro-VM with the sidecar bundle still in Docker. +- **Smolmachines backend (macOS default)** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. +- **Legacy Docker backend** — still available for examples, CI, and hosts without smolvm via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. ## Architecture -A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles egress + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box. +On the default smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS. + +On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. + +The Docker topology looks like this: ``` host ( ./cli.py ) @@ -62,7 +67,9 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi ## Quickstart -Requires Docker on the host and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. +Requires Docker on the host for the sidecar bundle, smolvm on macOS for the default backend, and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. + +Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` on hosts where smolvm is not installed. ```sh ./cli.py start # builds the image on first run, drops you into claude diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 28dbc7f..501379d 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -25,7 +25,7 @@ backend exposes five methods: agents pane) to render a row. Selection is driven by `--backend` on `start` or -BOT_BOTTLE_BACKEND (env var; default "docker"). Per PRD 0003 the +BOT_BOTTLE_BACKEND (env var; default "smolmachines"). Per PRD 0003 the manifest does not carry a backend field; the host picks. """ @@ -550,11 +550,11 @@ def get_bottle_backend( `name` precedence: 1. explicit arg (CLI `--backend=` passes through here) 2. BOT_BOTTLE_BACKEND env var - 3. default `docker` + 3. default `smolmachines` Dies with a pointer at the known backends if the chosen name isn't implemented.""" - resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "docker" + resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines" if resolved not in _BACKENDS: known = ", ".join(sorted(_BACKENDS)) die(f"unknown backend {resolved!r}; known backends: {known}") diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 2ebcf42..23c7d1e 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -40,7 +40,7 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND - (default).""" + when set to `docker`; retained as a legacy/example backend.""" name = "docker" diff --git a/bot_bottle/backend/smolmachines/util.py b/bot_bottle/backend/smolmachines/util.py index b28f451..cadb8a7 100644 --- a/bot_bottle/backend/smolmachines/util.py +++ b/bot_bottle/backend/smolmachines/util.py @@ -21,7 +21,9 @@ def smolmachines_preflight() -> None: die( "BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on " "PATH. Install with: " - "curl -sSL https://smolmachines.com/install.sh | sh" + "curl -sSL https://smolmachines.com/install.sh | sh. " + "To use the legacy Docker backend instead, set " + "BOT_BOTTLE_BACKEND=docker or pass --backend=docker." ) diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 13f91cd..36b4371 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -47,7 +47,7 @@ def cmd_start(argv: list[str]) -> int: default=None, help=( "backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND " - "or 'docker'). Overrides the env var when set." + "or 'smolmachines'). Overrides the env var when set." ), ) parser.add_argument( @@ -114,7 +114,7 @@ def prepare_with_preflight( injected callable, prompt y/N via the injected callable. `backend_name` selects which backend prepares the plan - (`None` → `$BOT_BOTTLE_BACKEND` → `docker`). The CLI passes + (`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes whatever `--backend` resolved to. Returns `(plan, identity)`. `plan` is None on dry-run or diff --git a/docs/prds/prd-new-smolmachines-default.md b/docs/prds/prd-new-smolmachines-default.md index c4bb3c6..be9372b 100644 --- a/docs/prds/prd-new-smolmachines-default.md +++ b/docs/prds/prd-new-smolmachines-default.md @@ -66,10 +66,10 @@ Update `examples/bottles/dev.md` and `examples/bottles/claude.md` to remove Dock Tests that exercise the Docker backend explicitly should set `BOT_BOTTLE_BACKEND=docker` rather than relying on the default. Tests that are backend-agnostic continue to use whatever `BOT_BOTTLE_BACKEND` is set to (defaulting to smolmachines in the test environment if available). -## Open questions +## Resolved questions -- **TSI + pipelock (127.0.0.1 passthrough).** The smolmachines research note (`docs/research/smolmachines-as-vm-backend.md`) flags that TSI passthrough to `127.0.0.1` for a host-side pipelock proxy is unverified. This must be smoke-tested before the default switch lands: `curl` from inside the guest → pipelock on host should succeed; `curl` to a non-allowlisted host should be blocked. If TSI blocks loopback traffic, `--outbound-localhost-only` plus `HTTPS_PROXY` in the Smolfile may be the fix. -- **smolmachines availability check.** `is_available()` on the smolmachines backend returns false if the `smolvm` binary is not on PATH. Should the CLI warn clearly and suggest `BOT_BOTTLE_BACKEND=docker` when smolmachines is unavailable, rather than hard-failing? +- **TSI + egress proxy loopback.** The implementation uses a per-bottle loopback alias rather than broad `127.0.0.1` passthrough. The smolmachines launch integration test now asserts that the guest receives proxy env vars on a `127.x` alias, can reach an allowlisted host through the proxy, cannot reach the same host directly with proxy vars unset, and cannot reach a non-allowlisted host through the proxy. +- **smolmachines availability check.** The smolmachines preflight error points operators at the smolvm installer and explicitly suggests `BOT_BOTTLE_BACKEND=docker` / `--backend=docker` for legacy Docker-backed runs. ## References diff --git a/examples/bottles/claude.md b/examples/bottles/claude.md index a47037a..b7e5d8f 100644 --- a/examples/bottles/claude.md +++ b/examples/bottles/claude.md @@ -13,4 +13,6 @@ egress: Common Claude provider boundary. Drop this file into `~/.bot-bottle/bottles/claude.md`, then extend it from task-specific -bottles. +bottles. The default smolmachines backend keeps DNS resolution under +the VM-layer egress policy; use `BOT_BOTTLE_BACKEND=docker` only for +legacy Docker-backed runs. diff --git a/examples/bottles/dev.md b/examples/bottles/dev.md index 8e9b8aa..93b9d9c 100644 --- a/examples/bottles/dev.md +++ b/examples/bottles/dev.md @@ -10,4 +10,5 @@ The `dev` bottle — backs a generic development workflow. Inherits the Claude provider boundary from `claude`. Drop this file into `~/.bot-bottle/bottles/dev.md` and any agent referencing -`bottle: dev` will launch against this infrastructure. +`bottle: dev` will launch against this infrastructure. By default, +bot-bottle runs this bottle on the smolmachines backend. diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index b547539..1614203 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -11,8 +11,9 @@ asserts each one is blocked: 5. Secret exfil via README link pushed through git-gate The suite is backend-agnostic — it goes through `get_bottle_backend()` -so a future smolmachines backend can be tested by setting -`BOT_BOTTLE_BACKEND=smolmachines` without touching this file. +so smolmachines can be tested by setting `BOT_BOTTLE_BACKEND=smolmachines`. +When unset, this integration test pins Docker explicitly to preserve +the Docker-backed CI path. PRD 0022 chunk 1 (this commit): fixture + setUpClass + tearDownClass + preflight tool check. Attack tests land in @@ -146,7 +147,7 @@ class TestSandboxEscape(unittest.TestCase): cls._stage_dir = Path(tempfile.mkdtemp(prefix="sandbox-escape-stage.")) try: - backend = get_bottle_backend() + backend = get_bottle_backend(backend_name) plan = backend.prepare(spec, stage_dir=cls._stage_dir) cls._identity = plan.slug diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py index 2051ea3..4d6b9cd 100644 --- a/tests/integration/test_sidecar_bundle_compose.py +++ b/tests/integration/test_sidecar_bundle_compose.py @@ -56,7 +56,7 @@ class TestSidecarBundleCompose(unittest.TestCase): stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke.")) try: with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}): - backend = get_bottle_backend() + backend = get_bottle_backend("docker") spec = BottleSpec( manifest=_manifest(), agent_name="demo", diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index 3df9003..61aeb89 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -32,10 +32,10 @@ class TestGetBottleBackend(unittest.TestCase): b = get_bottle_backend() self.assertEqual("smolmachines", b.name) - def test_default_docker(self): + def test_default_smolmachines(self): with patch.dict(os.environ, {}, clear=True): b = get_bottle_backend() - self.assertEqual("docker", b.name) + self.assertEqual("smolmachines", b.name) def test_unknown_dies(self): with patch.object(backend_mod, "die", side_effect=SystemExit("die")): diff --git a/tests/unit/test_cli_start_backend_flag.py b/tests/unit/test_cli_start_backend_flag.py index b3d3529..24933d4 100644 --- a/tests/unit/test_cli_start_backend_flag.py +++ b/tests/unit/test_cli_start_backend_flag.py @@ -39,7 +39,7 @@ class TestStartBackendFlag(unittest.TestCase): self.assertEqual("smolmachines", args.backend) self.assertEqual("researcher", args.name) - def test_flag_default_none_means_env_or_docker(self): + def test_flag_default_none_means_env_or_default_backend(self): args = self._build_parser().parse_args(["researcher"]) self.assertIsNone(args.backend) diff --git a/tests/unit/test_smolmachines_util.py b/tests/unit/test_smolmachines_util.py index 89f31a0..436cce6 100644 --- a/tests/unit/test_smolmachines_util.py +++ b/tests/unit/test_smolmachines_util.py @@ -85,6 +85,7 @@ class TestPreflight(unittest.TestCase): msg = captured.getvalue() self.assertIn("smolvm", msg) self.assertIn("smolmachines.com/install.sh", msg) + self.assertIn("BOT_BOTTLE_BACKEND=docker", msg) if __name__ == "__main__":