feat(bottle): opt-in gVisor runtime per bottle
test / run tests/run_tests.py (push) Successful in 19s

Bottles can now set "runtime": "runsc" to launch the agent container
under gVisor instead of runc, adding a userspace syscall barrier
between the agent and the host kernel. Default is runc (Docker
default). Pipelock stays on the default runtime per the research doc's
minimum-diff prescription.

The launcher verifies runsc is registered with the daemon before
launch, surfaces the runtime in the preflight plan, and dies with an
install pointer (and a macOS-not-supported note) when runsc is
requested but unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 00:48:11 -04:00
parent 3eff1e0b6e
commit e3f5a5907a
7 changed files with 122 additions and 5 deletions
+23 -5
View File
@@ -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 container is the boundary against an uncoordinated agent reaching
the host: a misbehaving Claude Code session can't read files outside the host: a misbehaving Claude Code session can't read files outside
the bottle, can't reach the host's network without going through 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 pipelock, and can't see other bottles. By default it is not a hardened
against a determined attacker with kernel-level escape capability — boundary against a determined attacker with kernel-level escape
that's a v2 question (see capability — see `docs/research/stronger-isolation-alternatives.md`
`docs/research/stronger-isolation-alternatives.md`). The egress proxy for the broader v2 discussion.
and OAuth-token handling below are the load-bearing pieces of v1.
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 ## Quickstart
+1
View File
@@ -13,6 +13,7 @@
}, },
"gitea-dev": { "gitea-dev": {
"runtime": "runsc",
"env": { "env": {
"GITEA_TOKEN": "?paste your Gitea API token", "GITEA_TOKEN": "?paste your Gitea API token",
"GITHUB_TOKEN": "${GH_PAT}", "GITHUB_TOKEN": "${GH_PAT}",
+8
View File
@@ -21,6 +21,7 @@ from ..env_resolve import env_resolve
from ..log import die, info from ..log import die, info
from ..manifest import ( from ..manifest import (
manifest_agent_bottle, manifest_agent_bottle,
manifest_bottle_runtime,
manifest_env_names, manifest_env_names,
manifest_prompt, manifest_prompt,
manifest_require_agent, manifest_require_agent,
@@ -101,6 +102,10 @@ def cmd_start(argv: list[str]) -> int:
) )
manifest_require_bottle(manifest, bottle_name) 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) ssh_entries = manifest_ssh(manifest, name)
if ssh_entries: if ssh_entries:
ssh_mod.ssh_validate_entries(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("skills : " + (" ".join(skill_names) if skill_names else "(none)"))
info(f"bottle : {bottle_name}") info(f"bottle : {bottle_name}")
info(f" runtime : {runtime}{' (gVisor)' if runtime == 'runsc' else ''}")
if ssh_entries: if ssh_entries:
ssh_names = ", ".join(e.get("Host", "") for e in ssh_entries) ssh_names = ", ".join(e.get("Host", "") for e in ssh_entries)
info(f" ssh hosts : {ssh_names}") info(f" ssh hosts : {ssh_names}")
@@ -216,6 +222,8 @@ def cmd_start(argv: list[str]) -> int:
"-e", f"HTTP_PROXY={proxy_url}", "-e", f"HTTP_PROXY={proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1", "-e", "NO_PROXY=localhost,127.0.0.1",
] ]
if runtime != "runc":
docker_args.extend(["--runtime", runtime])
if env_file.stat().st_size > 0: if env_file.stat().st_size > 0:
docker_args.extend(["--env-file", str(env_file)]) docker_args.extend(["--env-file", str(env_file)])
+16
View File
@@ -19,6 +19,22 @@ def require_docker() -> None:
die("docker not found") 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: def image_exists(ref: str) -> bool:
return _silent_run(["docker", "image", "inspect", ref]) == 0 return _silent_run(["docker", "image", "inspect", ref]) == 0
+24
View File
@@ -154,6 +154,30 @@ def manifest_bottle_ssh(manifest: Manifest, bottle_name: str) -> list[dict[str,
return list(bottle.get("ssh") or []) 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]: def manifest_bottle_egress_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
"""Hostnames in bottles[bottle_name].egress.allowlist. Dies if the """Hostnames in bottles[bottle_name].egress.allowlist. Dies if the
field is present but not an array. Per-element string typing is field is present but not an array. Per-element string typing is
+1
View File
@@ -48,6 +48,7 @@ class TestDryRunPlan(unittest.TestCase):
# 7 baked defaults + 1 bottle entry = 8. # 7 baked defaults + 1 bottle entry = 8.
self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted") self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted")
self.assertIn("api.anthropic.com", out, "preflight: baked default shown") 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.assertIn("dry-run requested", out, "dry-run banner present")
self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt") self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt")
+49
View File
@@ -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()