diff --git a/src/hqt/sandbox.py b/src/hqt/sandbox.py index f1d5219..1763d12 100644 --- a/src/hqt/sandbox.py +++ b/src/hqt/sandbox.py @@ -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 … -- ` 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 diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 52884f5..b39e650 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -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