diff --git a/README.md b/README.md index b413ac7..293d685 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,29 @@ more. Any one agent only has the access it needs to do its job. The container is the boundary against an uncoordinated agent reaching the host: a misbehaving Claude Code session can't read files outside the bottle, can't reach the host's network without going through -pipelock, and can't see other bottles. It is not a hardened boundary -against a determined attacker with kernel-level escape capability — -that's a v2 question (see -`docs/research/stronger-isolation-alternatives.md`). The egress proxy -and OAuth-token handling below are the load-bearing pieces of v1. +pipelock, and can't see other bottles. By default it is not a hardened +boundary against a determined attacker with kernel-level escape +capability — see `docs/research/stronger-isolation-alternatives.md` +for the broader v2 discussion. + +Linux hosts can opt into [gVisor](https://gvisor.dev/) per bottle for +a userspace syscall barrier between the agent and the host kernel: + +```jsonc +{ + "bottles": { + "default": { "runtime": "runsc" } + } +} +``` + +When `runtime` is set to `"runsc"`, claude-bottle verifies the runtime +is registered with Docker before launch and passes `--runtime=runsc` +to the agent container. Default is `"runc"` (Docker's default). gVisor +is not available on macOS. + +The egress proxy and OAuth-token handling below are the load-bearing +pieces of v1. ## Quickstart diff --git a/claude-bottle.example.json b/claude-bottle.example.json index dbfd93c..20431dd 100644 --- a/claude-bottle.example.json +++ b/claude-bottle.example.json @@ -13,6 +13,7 @@ }, "gitea-dev": { + "runtime": "runsc", "env": { "GITEA_TOKEN": "?paste your Gitea API token", "GITHUB_TOKEN": "${GH_PAT}", diff --git a/claude_bottle/cli/start.py b/claude_bottle/cli/start.py index e3fe772..5cca959 100644 --- a/claude_bottle/cli/start.py +++ b/claude_bottle/cli/start.py @@ -21,6 +21,7 @@ from ..env_resolve import env_resolve from ..log import die, info from ..manifest import ( manifest_agent_bottle, + manifest_bottle_runtime, manifest_env_names, manifest_prompt, manifest_require_agent, @@ -101,6 +102,10 @@ def cmd_start(argv: list[str]) -> int: ) manifest_require_bottle(manifest, bottle_name) + runtime = manifest_bottle_runtime(manifest, bottle_name) + if runtime == "runsc": + docker_mod.require_runsc() + ssh_entries = manifest_ssh(manifest, name) if ssh_entries: ssh_mod.ssh_validate_entries(ssh_entries) @@ -166,6 +171,7 @@ def cmd_start(argv: list[str]) -> int: ) info("skills : " + (" ".join(skill_names) if skill_names else "(none)")) info(f"bottle : {bottle_name}") + info(f" runtime : {runtime}{' (gVisor)' if runtime == 'runsc' else ''}") if ssh_entries: ssh_names = ", ".join(e.get("Host", "") for e in ssh_entries) info(f" ssh hosts : {ssh_names}") @@ -216,6 +222,8 @@ def cmd_start(argv: list[str]) -> int: "-e", f"HTTP_PROXY={proxy_url}", "-e", "NO_PROXY=localhost,127.0.0.1", ] + if runtime != "runc": + docker_args.extend(["--runtime", runtime]) if env_file.stat().st_size > 0: docker_args.extend(["--env-file", str(env_file)]) diff --git a/claude_bottle/docker.py b/claude_bottle/docker.py index 80304ec..0cd7b5f 100644 --- a/claude_bottle/docker.py +++ b/claude_bottle/docker.py @@ -19,6 +19,22 @@ def require_docker() -> None: die("docker not found") +def require_runsc() -> None: + """Fail with an install pointer if the `runsc` (gVisor) runtime is + not registered with the local Docker daemon. Called when a bottle + sets `runtime: "runsc"`.""" + result = subprocess.run( + ["docker", "info", "--format", "{{json .Runtimes}}"], + capture_output=True, + text=True, + ) + if result.returncode != 0 or "runsc" not in result.stdout: + info("This bottle requested runtime 'runsc' but the gVisor runtime is not registered with Docker.") + info("Install gVisor and register it with the daemon: https://gvisor.dev/docs/user_guide/install/") + info("On macOS, gVisor is not available natively; remove 'runtime' from the bottle or run on Linux.") + die("runsc runtime not available") + + def image_exists(ref: str) -> bool: return _silent_run(["docker", "image", "inspect", ref]) == 0 diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index 8e1ff65..91eaa39 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -154,6 +154,30 @@ def manifest_bottle_ssh(manifest: Manifest, bottle_name: str) -> list[dict[str, return list(bottle.get("ssh") or []) +_SUPPORTED_RUNTIMES: tuple[str, ...] = ("runc", "runsc") + + +def manifest_bottle_runtime(manifest: Manifest, bottle_name: str) -> str: + """Container runtime for the bottle's agent container. Returns + "runc" (Docker default) or "runsc" (gVisor opt-in). Dies if the + field is present but not one of the supported values.""" + bottle = (manifest.get("bottles") or {}).get(bottle_name) or {} + raw = bottle.get("runtime") + if raw is None: + return "runc" + if not isinstance(raw, str): + die( + f"bottle '{bottle_name}' runtime must be a string " + f"(was {_json_type(raw)})." + ) + if raw not in _SUPPORTED_RUNTIMES: + die( + f"bottle '{bottle_name}' runtime '{raw}' is not supported. " + f"Use one of: {', '.join(_SUPPORTED_RUNTIMES)}." + ) + return raw + + def manifest_bottle_egress_allowlist(manifest: Manifest, bottle_name: str) -> list[str]: """Hostnames in bottles[bottle_name].egress.allowlist. Dies if the field is present but not an array. Per-element string typing is diff --git a/tests/test_dry_run_plan.py b/tests/test_dry_run_plan.py index b279aa7..1cf00a2 100644 --- a/tests/test_dry_run_plan.py +++ b/tests/test_dry_run_plan.py @@ -48,6 +48,7 @@ class TestDryRunPlan(unittest.TestCase): # 7 baked defaults + 1 bottle entry = 8. self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted") self.assertIn("api.anthropic.com", out, "preflight: baked default shown") + self.assertRegex(out, r"runtime\s*:\s*runc", "preflight: default runtime shown") self.assertIn("dry-run requested", out, "dry-run banner present") self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt") diff --git a/tests/test_manifest_runtime.py b/tests/test_manifest_runtime.py new file mode 100644 index 0000000..4ba013d --- /dev/null +++ b/tests/test_manifest_runtime.py @@ -0,0 +1,49 @@ +"""Unit: manifest_bottle_runtime — defaults to runc, accepts runsc, +rejects unknown values and non-strings.""" + +import unittest + +from claude_bottle.log import Die +from claude_bottle.manifest import manifest_bottle_runtime + + +def _bottle(runtime_value: object | None) -> dict: + """Build a minimal manifest with one bottle whose runtime field is + set (or absent if `runtime_value is _ABSENT`).""" + bottle: dict = {} + if runtime_value is not _ABSENT: + bottle["runtime"] = runtime_value + return { + "bottles": {"dev": bottle}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + } + + +_ABSENT = object() + + +class TestManifestBottleRuntime(unittest.TestCase): + def test_default_runc_when_absent(self): + self.assertEqual("runc", manifest_bottle_runtime(_bottle(_ABSENT), "dev")) + + def test_explicit_runc(self): + self.assertEqual("runc", manifest_bottle_runtime(_bottle("runc"), "dev")) + + def test_explicit_runsc(self): + self.assertEqual("runsc", manifest_bottle_runtime(_bottle("runsc"), "dev")) + + def test_rejects_unknown_runtime(self): + with self.assertRaises(Die): + manifest_bottle_runtime(_bottle("kata-runtime"), "dev") + + def test_rejects_non_string(self): + with self.assertRaises(Die): + manifest_bottle_runtime(_bottle(42), "dev") + + def test_rejects_empty_string(self): + with self.assertRaises(Die): + manifest_bottle_runtime(_bottle(""), "dev") + + +if __name__ == "__main__": + unittest.main()