Implement ext.bwrap: local bubblewrap namespace extension (SAND-WP-0013)
Adds the first local, same-host, kernel-namespace-only sandbox extension: no SSH hop, no container runtime. Extends IsolationSpec.level with "process", implements BwrapExtension (provision/wait_ready/ teardown) spawning bwrap with unshared user/mount/pid/ipc/uts/net namespaces, registers ext.bwrap + profile.bwrap-local, and extends manager._handle_from_status to carry pid/workspace_dir. Verified with a live bwrap smoke run in addition to the mocked test suite. T04 (reachability vs. the SSH-based glas-harness consumer contract) deliberately left open pending glas-harness's harness contract. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d2186e8ac8
commit
76c38e758c
8 changed files with 484 additions and 6 deletions
113
WORK-RECORDS.md
Normal file
113
WORK-RECORDS.md
Normal file
|
|
@ -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 |
|
||||
17
extensions/ext.bwrap.yaml
Normal file
17
extensions/ext.bwrap.yaml
Normal file
|
|
@ -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
|
||||
31
profiles/profile.bwrap-local.yaml
Normal file
31
profiles/profile.bwrap-local.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
140
src/sandboxer/extensions/bwrap.py
Normal file
140
src/sandboxer/extensions/bwrap.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
166
tests/test_bwrap.py
Normal file
166
tests/test_bwrap.py
Normal file
|
|
@ -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({})
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue