feat: sandbox.wrap builds bwrap argv from policy and binds
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+44
-1
@@ -1,4 +1,4 @@
|
||||
import os # noqa: F401
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -31,3 +31,46 @@ def is_available() -> bool:
|
||||
The single source of truth for both `hqt doctor` and the New Session dialog.
|
||||
"""
|
||||
return sys.platform.startswith("linux") and shutil.which("bwrap") is not None
|
||||
|
||||
|
||||
# System paths bound read-only so binaries and shared libraries resolve.
|
||||
_SYSTEM_RO_PATHS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt"]
|
||||
|
||||
# Environment variables passed through into the jail.
|
||||
_PASSTHROUGH_ENV = ["PATH", "HOME", "TERM", "LANG", "LC_ALL", "USER", "SHELL"]
|
||||
|
||||
|
||||
def wrap(
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
policy: SandboxPolicy,
|
||||
binds: list[Bind],
|
||||
) -> list[str]:
|
||||
"""Build the `bwrap … -- <command>` argv for a sandboxed harness.
|
||||
|
||||
Minimal-allowlist model: $HOME and other user data are NOT exposed. System
|
||||
dirs are read-only; the project dir (cwd) and each existing harness bind are
|
||||
bound explicitly. Network is all-or-nothing: shared only when policy.net.
|
||||
Binds whose source does not exist are skipped (bwrap would otherwise error).
|
||||
"""
|
||||
args: list[str] = ["bwrap", "--die-with-parent", "--unshare-all"]
|
||||
if policy.net:
|
||||
args.append("--share-net")
|
||||
for path in _SYSTEM_RO_PATHS:
|
||||
if Path(path).exists():
|
||||
args += ["--ro-bind", path, path]
|
||||
args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]
|
||||
for bind in binds:
|
||||
if not bind.src.exists():
|
||||
continue
|
||||
flag = "--bind" if bind.writable else "--ro-bind"
|
||||
args += [flag, str(bind.src), str(bind.src)]
|
||||
cwd_flag = "--bind" if policy.fs == "rw" else "--ro-bind"
|
||||
args += [cwd_flag, str(cwd), str(cwd)]
|
||||
for name in _PASSTHROUGH_ENV:
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
args += ["--setenv", name, value]
|
||||
args.append("--")
|
||||
args += command
|
||||
return args
|
||||
|
||||
@@ -32,3 +32,73 @@ def test_is_available_false_off_linux(monkeypatch):
|
||||
monkeypatch.setattr(sandbox.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(sandbox.shutil, "which", lambda name: "/usr/bin/bwrap")
|
||||
assert sandbox.is_available() is False
|
||||
|
||||
|
||||
def _split(args, flag):
|
||||
"""Return list of (src, dst) pairs that follow each occurrence of flag."""
|
||||
out = []
|
||||
for i, a in enumerate(args):
|
||||
if a == flag:
|
||||
out.append((args[i + 1], args[i + 2]))
|
||||
return out
|
||||
|
||||
|
||||
def test_wrap_command_after_double_dash(tmp_path):
|
||||
cmd = ["claude", "--session-id", "x"]
|
||||
args = sandbox.wrap(cmd, tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||
assert args[0] == "bwrap"
|
||||
assert "--" in args
|
||||
assert args[args.index("--") + 1 :] == cmd
|
||||
|
||||
|
||||
def test_wrap_base_flags(tmp_path):
|
||||
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||
assert "--die-with-parent" in args
|
||||
assert "--unshare-all" in args
|
||||
assert "--proc" in args and "--dev" in args
|
||||
|
||||
|
||||
def test_wrap_net_on_shares_net(tmp_path):
|
||||
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||
assert "--share-net" in args
|
||||
|
||||
|
||||
def test_wrap_net_off_does_not_share(tmp_path):
|
||||
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=False), [])
|
||||
assert "--share-net" not in args
|
||||
|
||||
|
||||
def test_wrap_cwd_rw_is_bind(tmp_path):
|
||||
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
|
||||
assert (str(tmp_path), str(tmp_path)) in _split(args, "--bind")
|
||||
|
||||
|
||||
def test_wrap_cwd_ro_is_ro_bind(tmp_path):
|
||||
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="ro", net=True), [])
|
||||
assert (str(tmp_path), str(tmp_path)) in _split(args, "--ro-bind")
|
||||
|
||||
|
||||
def test_wrap_writable_bind_spliced(tmp_path):
|
||||
cred = tmp_path / "cred"
|
||||
cred.mkdir()
|
||||
args = sandbox.wrap(
|
||||
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(cred, writable=True)]
|
||||
)
|
||||
assert (str(cred), str(cred)) in _split(args, "--bind")
|
||||
|
||||
|
||||
def test_wrap_readonly_bind_spliced(tmp_path):
|
||||
cred = tmp_path / "cred"
|
||||
cred.mkdir()
|
||||
args = sandbox.wrap(
|
||||
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(cred)]
|
||||
)
|
||||
assert (str(cred), str(cred)) in _split(args, "--ro-bind")
|
||||
|
||||
|
||||
def test_wrap_skips_missing_binds(tmp_path):
|
||||
missing = tmp_path / "nope"
|
||||
args = sandbox.wrap(
|
||||
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(missing)]
|
||||
)
|
||||
assert str(missing) not in args
|
||||
|
||||
Reference in New Issue
Block a user