diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md new file mode 100644 index 0000000..80a76cd --- /dev/null +++ b/WORK-RECORDS.md @@ -0,0 +1,113 @@ +# Work Records — sand-boxer + +> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record +> stage 3). Do not edit by hand — edit the source file/block listed for +> each record and re-run fix-consistency to refresh this index. Archived +> workplans are omitted; closed decisions/intakes/engagements stay listed +> so recently-resolved work is still visible. [auto] + +| Kind | ID | Status | Lane | Source | +| --- | --- | --- | --- | --- | +| workplan | SAND-WP-0001 | finished | — | workplans/SAND-WP-0001-statehub-bootstrap.md | +| workplan | SAND-WP-0002 | finished | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| workplan | SAND-WP-0003 | finished | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| workplan | SAND-WP-0004 | finished | — | workplans/SAND-WP-0004-the-custodian-e2e-shim.md | +| workplan | SAND-WP-0005 | finished | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| workplan | SAND-WP-0006 | finished | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| workplan | SAND-WP-0007 | finished | — | workplans/SAND-WP-0007-snapshot-restore.md | +| workplan | SAND-WP-0008 | finished | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| workplan | SAND-WP-0009 | finished | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| workplan | SAND-WP-0010 | finished | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| workplan | SAND-WP-0011 | finished | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| workplan | SAND-WP-0012 | finished | — | workplans/SAND-WP-0012-packer-orchestration.md | +| workplan | SAND-WP-0013 | proposed | — | workplans/SAND-WP-0013-bwrap-extension.md | +| task | SAND-WP-0001-T01 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md | +| task | SAND-WP-0001-T02 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md | +| task | SAND-WP-0001-T03 | done | — | workplans/SAND-WP-0001-statehub-bootstrap.md | +| task | SAND-WP-0002-T01 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T02 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T03 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T04 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T05 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T06 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T07 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T08 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T09 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0002-T10 | done | — | workplans/SAND-WP-0002-meta-framework-foundation.md | +| task | SAND-WP-0003-T01 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T02 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T03 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T04 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T05 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T06 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T07 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T08 | done | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0003-T09 | wait | — | workplans/SAND-WP-0003-wise-validator-extraction.md | +| task | SAND-WP-0004-T01 | done | — | workplans/SAND-WP-0004-the-custodian-e2e-shim.md | +| task | SAND-WP-0004-T02 | done | — | workplans/SAND-WP-0004-the-custodian-e2e-shim.md | +| task | SAND-WP-0004-T03 | done | — | workplans/SAND-WP-0004-the-custodian-e2e-shim.md | +| task | SAND-WP-0004-T04 | done | — | workplans/SAND-WP-0004-the-custodian-e2e-shim.md | +| task | SAND-WP-0004-T05 | done | — | workplans/SAND-WP-0004-the-custodian-e2e-shim.md | +| task | SAND-WP-0005-T01 | done | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| task | SAND-WP-0005-T02 | done | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| task | SAND-WP-0005-T03 | done | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| task | SAND-WP-0005-T04 | done | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| task | SAND-WP-0005-T05 | done | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| task | SAND-WP-0005-T06 | wait | — | workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md | +| task | SAND-WP-0006-T01 | done | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| task | SAND-WP-0006-T02 | done | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| task | SAND-WP-0006-T03 | done | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| task | SAND-WP-0006-T04 | done | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| task | SAND-WP-0006-T05 | done | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| task | SAND-WP-0006-T06 | cancel | — | workplans/SAND-WP-0006-saas-extensions-and-payments.md | +| task | SAND-WP-0007-T01 | done | — | workplans/SAND-WP-0007-snapshot-restore.md | +| task | SAND-WP-0007-T02 | done | — | workplans/SAND-WP-0007-snapshot-restore.md | +| task | SAND-WP-0007-T03 | done | — | workplans/SAND-WP-0007-snapshot-restore.md | +| task | SAND-WP-0007-T04 | done | — | workplans/SAND-WP-0007-snapshot-restore.md | +| task | SAND-WP-0007-T05 | done | — | workplans/SAND-WP-0007-snapshot-restore.md | +| task | SAND-WP-0007-T06 | done | — | workplans/SAND-WP-0007-snapshot-restore.md | +| task | SAND-WP-0008-T01 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T02 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T03 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T04 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T05 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T06 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T07 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T08 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T09 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0008-T10 | done | — | workplans/SAND-WP-0008-host-telemetry-and-self-canary.md | +| task | SAND-WP-0009-T01 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T02 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T03 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T04 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T05 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T06 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T07 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T08 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0009-T09 | done | — | workplans/SAND-WP-0009-ttl-and-operational-hardening.md | +| task | SAND-WP-0010-T01 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0010-T02 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0010-T03 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0010-T04 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0010-T05 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0010-T06 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0010-T07 | done | — | workplans/SAND-WP-0010-cloud-adapters-and-billing.md | +| task | SAND-WP-0011-T01 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0011-T02 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0011-T03 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0011-T04 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0011-T05 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0011-T06 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0011-T07 | done | — | workplans/SAND-WP-0011-reachability-and-consumer-profiles.md | +| task | SAND-WP-0012-T01 | done | — | workplans/SAND-WP-0012-packer-orchestration.md | +| task | SAND-WP-0012-T02 | done | — | workplans/SAND-WP-0012-packer-orchestration.md | +| task | SAND-WP-0012-T03 | done | — | workplans/SAND-WP-0012-packer-orchestration.md | +| task | SAND-WP-0012-T04 | done | — | workplans/SAND-WP-0012-packer-orchestration.md | +| task | SAND-WP-0012-T05 | done | — | workplans/SAND-WP-0012-packer-orchestration.md | +| task | SAND-WP-0012-T06 | done | — | workplans/SAND-WP-0012-packer-orchestration.md | +| task | SAND-WP-0013-T01 | todo | — | workplans/SAND-WP-0013-bwrap-extension.md | +| task | SAND-WP-0013-T02 | todo | — | workplans/SAND-WP-0013-bwrap-extension.md | +| task | SAND-WP-0013-T03 | todo | — | workplans/SAND-WP-0013-bwrap-extension.md | +| task | SAND-WP-0013-T04 | todo | — | workplans/SAND-WP-0013-bwrap-extension.md | +| task | SAND-WP-0013-T05 | todo | — | workplans/SAND-WP-0013-bwrap-extension.md | +| task | SAND-WP-0013-T06 | todo | — | workplans/SAND-WP-0013-bwrap-extension.md | diff --git a/extensions/ext.bwrap.yaml b/extensions/ext.bwrap.yaml new file mode 100644 index 0000000..77565ef --- /dev/null +++ b/extensions/ext.bwrap.yaml @@ -0,0 +1,17 @@ +id: ext.bwrap +title: Bubblewrap local namespaces +description: > + Self-hosted extension using bubblewrap (bwrap) kernel namespaces on the + local host. No SSH hop, no container runtime, no remote placement — the + fastest path to an isolated workspace, and the first extension where + network.default: deny is real (a genuine namespace with no interface) + rather than declarative only. +handler: sandboxer.extensions.bwrap:BwrapExtension +capabilities: + isolation_levels: [process] + regions: [] + persistence: false + pricing_model: self-hosted +config: + base_dir: /tmp/sandboxer-bwrap + # ro_binds omitted — defaults to [/usr, /bin, /lib, /lib64, /etc/resolv.conf], filtered to what exists diff --git a/profiles/profile.bwrap-local.yaml b/profiles/profile.bwrap-local.yaml new file mode 100644 index 0000000..e73f234 --- /dev/null +++ b/profiles/profile.bwrap-local.yaml @@ -0,0 +1,31 @@ +id: profile.bwrap-local +version: "1.0.0" +extension: ext.bwrap +isolation: + level: process +network: + default: deny + egress: [] +workspace: + mode: mirror + access: rw +scope_default: session +ttl: + default: 4h + max: 24h + idle_reap: null +resources: + cpu: null + memory_mb: null +setup: + instructions: "" + secret_refs: [] +placement: + prefer: [localhost] + fallback: [] +reachability: + tunnel: ops-bridge + identity: ops-warden +metadata: + cost_class: self-hosted + latency_class: standard diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index a6b0554..9989009 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -65,6 +65,8 @@ class SandboxManager: "endpoint": status.inputs.get("endpoint", ""), "provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""), "provider": status.inputs.get("provider", ""), + "pid": status.inputs.get("pid", ""), + "workspace_dir": status.inputs.get("workspace_dir", ""), } def _resolved_host(self, profile, extension, host_override: str | None) -> str: @@ -154,6 +156,8 @@ class SandboxManager: status.inputs["endpoint"] = handle.get("endpoint", "") status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "") status.inputs["provider"] = handle.get("provider", "") + status.inputs["pid"] = handle.get("pid", "") + status.inputs["workspace_dir"] = handle.get("workspace_dir", "") reach = backend.wait_ready(handle) reach = enrich_reachability(reach, profile, handle) status.reachability = Reachability(**reach) diff --git a/src/sandboxer/extensions/bwrap.py b/src/sandboxer/extensions/bwrap.py new file mode 100644 index 0000000..9fccc75 --- /dev/null +++ b/src/sandboxer/extensions/bwrap.py @@ -0,0 +1,140 @@ +"""ext.bwrap — local, same-host, bubblewrap namespace isolation (SAND-WP-0013).""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +from pathlib import Path +from typing import Any + +from sandboxer.extensions.base import SandboxExtension +from sandboxer.models import Profile + + +class BwrapExtension(SandboxExtension): + """Provision a local sandbox via bubblewrap (bwrap) kernel namespaces. + + Unlike ext.compose-ssh / ext.vm-packer, this extension never leaves the + local host: no SSH hop, no container runtime, no remote placement. A new + user/mount/pid/ipc/uts/net namespace is created per sandbox, kept alive + by a long-running placeholder process (`sleep infinity`) whose pid is + the handle's exec target. `--unshare-net` with no veth/interface makes + `network.default: deny` real, rather than declarative-only like the + other self-hosted extensions. + """ + + def __init__(self, config: dict[str, Any] | None = None) -> None: + super().__init__(config) + cfg = self.config + self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap") + self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap") + self.ro_binds: list[str] = cfg.get( + "ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"] + ) + + def _bwrap_bin(self) -> str: + return os.environ.get("SANDBOXER_BWRAP_BIN", self.bwrap_bin) + + def _existing_ro_binds(self) -> list[str]: + return [path for path in self.ro_binds if Path(path).exists()] + + def _bwrap_argv(self, workspace_dir: str) -> list[str]: + argv = [ + self._bwrap_bin(), + "--die-with-parent", + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--unshare-cgroup", + "--unshare-net", + "--tmpfs", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + ] + for path in self._existing_ro_binds(): + argv += ["--ro-bind", path, path] + argv += ["--bind", workspace_dir, workspace_dir] + argv += ["--chdir", workspace_dir] + argv += ["sleep", "infinity"] + return argv + + def provision( + self, profile: Profile, inputs: dict[str, str], host: str + ) -> dict[str, str]: + sandbox_id = self.new_sandbox_id(inputs) + workspace_dir = f"{self.base_dir}/{sandbox_id}" + Path(workspace_dir).mkdir(parents=True, exist_ok=True) + + repo = inputs.get("repo") + if repo: + repo_path = Path(repo).expanduser().resolve() + if not repo_path.exists(): + raise FileNotFoundError(f"Repo path does not exist: {repo_path}") + shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True) + + argv = self._bwrap_argv(workspace_dir) + proc = subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + return { + "sandbox_id": sandbox_id, + "host": host, + "pid": str(proc.pid), + "workspace_dir": workspace_dir, + } + + def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: + pid = int(handle["pid"]) + if not self._pid_alive(pid): + raise RuntimeError(f"bwrap process {pid} is not running") + workspace_dir = handle["workspace_dir"] + if not Path(workspace_dir).is_dir(): + raise RuntimeError(f"workspace missing: {workspace_dir}") + return { + "host": handle.get("host", "localhost"), + "endpoint": f"pid:{pid}", + } + + def teardown(self, handle: dict[str, str]) -> dict[str, str]: + pid_str = handle.get("pid", "") + killed = False + if pid_str and self._pid_alive(int(pid_str)): + pid = int(pid_str) + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + try: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + killed = True + + workspace_dir = handle.get("workspace_dir", "") + removed = False + if workspace_dir and Path(workspace_dir).exists(): + shutil.rmtree(workspace_dir, ignore_errors=True) + removed = not Path(workspace_dir).exists() + + return { + "process_killed": str(killed), + "workspace_removed": str(removed), + "workspace_dir": workspace_dir, + } + + @staticmethod + def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + return True diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index d8cba64..c4922a1 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -41,7 +41,7 @@ class Consumer(BaseModel): class IsolationSpec(BaseModel): - level: Literal["container", "microvm", "policy"] = "container" + level: Literal["container", "microvm", "policy", "process"] = "container" class NetworkSpec(BaseModel): diff --git a/tests/test_bwrap.py b/tests/test_bwrap.py new file mode 100644 index 0000000..b3a73b0 --- /dev/null +++ b/tests/test_bwrap.py @@ -0,0 +1,166 @@ +"""ext.bwrap — local namespace isolation extension.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from sandboxer.extensions.base import SandboxExtension +from sandboxer.extensions.bwrap import BwrapExtension +from sandboxer.models import IsolationSpec, Profile + + +def _profile() -> Profile: + return Profile.model_validate( + { + "id": "profile.bwrap-local", + "version": "1.0.0", + "extension": "ext.bwrap", + "isolation": {"level": "process"}, + } + ) + + +def test_is_sandbox_extension_subclass() -> None: + assert issubclass(BwrapExtension, SandboxExtension) + + +def test_process_is_a_valid_isolation_level() -> None: + assert IsolationSpec(level="process").level == "process" + + +def test_bwrap_bin_env_override(monkeypatch) -> None: + monkeypatch.setenv("SANDBOXER_BWRAP_BIN", "/custom/bwrap") + ext = BwrapExtension({"bwrap_bin": "bwrap"}) + assert ext._bwrap_bin() == "/custom/bwrap" + + +def test_bwrap_argv_unshares_net_and_binds_workspace(tmp_path) -> None: + ext = BwrapExtension({"ro_binds": []}) + argv = ext._bwrap_argv(str(tmp_path)) + assert "--unshare-net" in argv + assert "--unshare-user" in argv + assert str(tmp_path) in argv + assert argv[-2:] == ["sleep", "infinity"] + + +def test_existing_ro_binds_filters_missing_paths(tmp_path) -> None: + real_dir = tmp_path / "real" + real_dir.mkdir() + ext = BwrapExtension({"ro_binds": [str(real_dir), "/definitely/does/not/exist"]}) + assert ext._existing_ro_binds() == [str(real_dir)] + + +def test_provision_spawns_bwrap_and_returns_handle(tmp_path) -> None: + ext = BwrapExtension({"base_dir": str(tmp_path)}) + fake_proc = MagicMock() + fake_proc.pid = 12345 + + with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen: + handle = ext.provision(_profile(), {"sandbox_id": "abc12345"}, "localhost") + + popen.assert_called_once() + assert handle["sandbox_id"] == "abc12345" + assert handle["host"] == "localhost" + assert handle["pid"] == "12345" + assert handle["workspace_dir"].endswith("abc12345") + + +def test_provision_copies_repo_into_workspace(tmp_path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "file.txt").write_text("hello") + base_dir = tmp_path / "sandboxes" + + ext = BwrapExtension({"base_dir": str(base_dir)}) + fake_proc = MagicMock() + fake_proc.pid = 1 + + with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc): + handle = ext.provision( + _profile(), {"sandbox_id": "cafe1234", "repo": str(repo)}, "localhost" + ) + + assert (base_dir / "cafe1234" / "file.txt").read_text() == "hello" + assert handle["workspace_dir"] == str(base_dir / "cafe1234") + + +def test_provision_missing_repo_raises(tmp_path) -> None: + ext = BwrapExtension({"base_dir": str(tmp_path)}) + with pytest.raises(FileNotFoundError): + ext.provision( + _profile(), + {"sandbox_id": "x", "repo": str(tmp_path / "missing")}, + "localhost", + ) + + +def test_wait_ready_checks_process_and_workspace(tmp_path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + ext = BwrapExtension() + handle = {"pid": str(1), "workspace_dir": str(workspace), "host": "localhost"} + + with patch.object(BwrapExtension, "_pid_alive", return_value=True): + result = ext.wait_ready(handle) + + assert result["host"] == "localhost" + assert result["endpoint"] == "pid:1" + + +def test_wait_ready_raises_if_process_dead(tmp_path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + ext = BwrapExtension() + handle = {"pid": "999999", "workspace_dir": str(workspace), "host": "localhost"} + + with patch.object(BwrapExtension, "_pid_alive", return_value=False): + with pytest.raises(RuntimeError, match="not running"): + ext.wait_ready(handle) + + +def test_wait_ready_raises_if_workspace_missing(tmp_path) -> None: + ext = BwrapExtension() + handle = {"pid": "1", "workspace_dir": str(tmp_path / "gone"), "host": "localhost"} + + with patch.object(BwrapExtension, "_pid_alive", return_value=True): + with pytest.raises(RuntimeError, match="workspace missing"): + ext.wait_ready(handle) + + +def test_teardown_kills_process_group_and_removes_workspace(tmp_path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + ext = BwrapExtension() + handle = {"pid": "42", "workspace_dir": str(workspace), "host": "localhost"} + + with ( + patch.object(BwrapExtension, "_pid_alive", return_value=True), + patch("sandboxer.extensions.bwrap.os.getpgid", return_value=42), + patch("sandboxer.extensions.bwrap.os.killpg") as killpg, + ): + result = ext.teardown(handle) + + killpg.assert_called_once_with(42, __import__("signal").SIGKILL) + assert result["process_killed"] == "True" + assert result["workspace_removed"] == "True" + assert not workspace.exists() + + +def test_teardown_handles_already_dead_process(tmp_path) -> None: + workspace = tmp_path / "ws" + workspace.mkdir() + ext = BwrapExtension() + handle = {"pid": "42", "workspace_dir": str(workspace), "host": "localhost"} + + with patch.object(BwrapExtension, "_pid_alive", return_value=False): + result = ext.teardown(handle) + + assert result["process_killed"] == "False" + assert result["workspace_removed"] == "True" + + +def test_supports_snapshots_is_false() -> None: + ext = BwrapExtension() + assert ext.supports_snapshots() is False + with pytest.raises(NotImplementedError): + ext.snapshot({}) diff --git a/workplans/SAND-WP-0013-bwrap-extension.md b/workplans/SAND-WP-0013-bwrap-extension.md index b235dab..c4b4961 100644 --- a/workplans/SAND-WP-0013-bwrap-extension.md +++ b/workplans/SAND-WP-0013-bwrap-extension.md @@ -2,6 +2,7 @@ id: SAND-WP-0013 title: "Bubblewrap (bwrap) local-exec extension" status: proposed +state_hub_workstream_id: "29d6bdc4-69a4-4d06-b7c1-eb335aa6ab5e" --- Add the first local, same-host, kernel-namespace-only sandbox extension. @@ -22,8 +23,9 @@ validation/display logic that switches on it. ```task id: SAND-WP-0013-T01 -status: todo +status: done priority: high +state_hub_task_id: "b55e6025-2d80-4dc3-aed8-50b15adbdf04" ``` ## Task: Implement `BwrapExtension` @@ -41,8 +43,9 @@ initially (`supports_snapshots()` stays `False`). ```task id: SAND-WP-0013-T02 -status: todo +status: done priority: high +state_hub_task_id: "703d09c6-2f98-44da-9b61-c0d0612b0603" ``` ## Task: Register the extension and a profile @@ -54,8 +57,9 @@ default: deny` actually meaningful this time, suitable for CI/local dev. ```task id: SAND-WP-0013-T03 -status: todo +status: done priority: medium +state_hub_task_id: "fd0ae8c8-c9ae-4cd6-a840-4b5b65574f73" ``` ## Task: Resolve local reachability vs. the SSH-based consumer contract @@ -74,6 +78,7 @@ built against the other. id: SAND-WP-0013-T04 status: todo priority: high +state_hub_task_id: "1d8b3e18-d067-4364-89e9-5d5bc5923deb" ``` ## Task: Extend manager's handle whitelist @@ -85,8 +90,9 @@ teardown/restart survives a `sandboxer` CLI/process restart. ```task id: SAND-WP-0013-T05 -status: todo +status: done priority: medium +state_hub_task_id: "9a410ce3-9e9c-42d2-abe7-95ad740279a2" ``` ## Task: Tests @@ -99,6 +105,7 @@ exercised generically in `test_extension_base.py`). ```task id: SAND-WP-0013-T06 -status: todo +status: done priority: medium +state_hub_task_id: "d9271eae-9177-4a55-bfb4-082408feb2a1" ```