feat(bottle): opt-in gVisor runtime per bottle
test / run tests/run_tests.py (push) Successful in 19s
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user