docketeer-bubblewrap 0.0.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docketeer_bubblewrap-0.0.7/.gitignore +11 -0
- docketeer_bubblewrap-0.0.7/PKG-INFO +42 -0
- docketeer_bubblewrap-0.0.7/README.md +24 -0
- docketeer_bubblewrap-0.0.7/pyproject.toml +60 -0
- docketeer_bubblewrap-0.0.7/src/docketeer_bubblewrap/__init__.py +8 -0
- docketeer_bubblewrap-0.0.7/src/docketeer_bubblewrap/executor.py +126 -0
- docketeer_bubblewrap-0.0.7/tests/test_executor.py +260 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docketeer-bubblewrap
|
|
3
|
+
Version: 0.0.7
|
|
4
|
+
Summary: Bubblewrap sandbox executor plugin for Docketeer
|
|
5
|
+
Project-URL: Homepage, https://github.com/chrisguidry/docketeer
|
|
6
|
+
Project-URL: Repository, https://github.com/chrisguidry/docketeer
|
|
7
|
+
Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
|
|
8
|
+
Author-email: Chris Guidry <guid@omg.lol>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: docketeer
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# docketeer-bubblewrap
|
|
20
|
+
|
|
21
|
+
Sandboxed command execution for [Docketeer](https://github.com/chrisguidry/docketeer)
|
|
22
|
+
using [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`).
|
|
23
|
+
|
|
24
|
+
This plugin provides a `CommandExecutor` implementation that runs external
|
|
25
|
+
programs inside a lightweight Linux sandbox using unprivileged user namespaces.
|
|
26
|
+
Each process gets its own PID, UTS, IPC, and cgroup namespaces, and network
|
|
27
|
+
access is denied by default. The `--die-with-parent` flag ensures sandboxed
|
|
28
|
+
processes are cleaned up if the parent exits.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Linux with unprivileged user namespaces enabled
|
|
33
|
+
- `bwrap` on `PATH` (install via your distro's `bubblewrap` package)
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
The executor builds a minimal filesystem view inside the sandbox:
|
|
38
|
+
|
|
39
|
+
- Read-only binds for system directories (`/usr`, `/bin`, `/lib`, `/etc/ssl`, etc.)
|
|
40
|
+
- `/proc`, `/dev`, and a tmpfs `/tmp`
|
|
41
|
+
- User-specified mounts (read-only or writable)
|
|
42
|
+
- Optional network access via `--share-net`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# docketeer-bubblewrap
|
|
2
|
+
|
|
3
|
+
Sandboxed command execution for [Docketeer](https://github.com/chrisguidry/docketeer)
|
|
4
|
+
using [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`).
|
|
5
|
+
|
|
6
|
+
This plugin provides a `CommandExecutor` implementation that runs external
|
|
7
|
+
programs inside a lightweight Linux sandbox using unprivileged user namespaces.
|
|
8
|
+
Each process gets its own PID, UTS, IPC, and cgroup namespaces, and network
|
|
9
|
+
access is denied by default. The `--die-with-parent` flag ensures sandboxed
|
|
10
|
+
processes are cleaned up if the parent exits.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- Linux with unprivileged user namespaces enabled
|
|
15
|
+
- `bwrap` on `PATH` (install via your distro's `bubblewrap` package)
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
The executor builds a minimal filesystem view inside the sandbox:
|
|
20
|
+
|
|
21
|
+
- Read-only binds for system directories (`/usr`, `/bin`, `/lib`, `/etc/ssl`, etc.)
|
|
22
|
+
- `/proc`, `/dev`, and a tmpfs `/tmp`
|
|
23
|
+
- User-specified mounts (read-only or writable)
|
|
24
|
+
- Optional network access via `--share-net`
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "docketeer-bubblewrap"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Bubblewrap sandbox executor plugin for Docketeer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Chris Guidry", email = "guid@omg.lol" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Topic :: Security",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"docketeer",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/chrisguidry/docketeer"
|
|
24
|
+
Repository = "https://github.com/chrisguidry/docketeer"
|
|
25
|
+
Issues = "https://github.com/chrisguidry/docketeer/issues"
|
|
26
|
+
|
|
27
|
+
[project.entry-points."docketeer.executor"]
|
|
28
|
+
bubblewrap = "docketeer_bubblewrap"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.version]
|
|
35
|
+
source = "vcs"
|
|
36
|
+
raw-options.root = ".."
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/docketeer_bubblewrap"]
|
|
40
|
+
|
|
41
|
+
[tool.uv.sources]
|
|
42
|
+
docketeer = { workspace = true }
|
|
43
|
+
|
|
44
|
+
[tool.docketeer.ci]
|
|
45
|
+
apt-packages = ["bubblewrap"]
|
|
46
|
+
sysctl = ["kernel.apparmor_restrict_unprivileged_userns=0"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
minversion = "9.0"
|
|
50
|
+
addopts = [
|
|
51
|
+
"--import-mode=importlib",
|
|
52
|
+
"--cov=docketeer_bubblewrap",
|
|
53
|
+
"--cov-branch",
|
|
54
|
+
"--cov-report=term-missing",
|
|
55
|
+
"--cov-fail-under=100",
|
|
56
|
+
]
|
|
57
|
+
asyncio_mode = "auto"
|
|
58
|
+
filterwarnings = [
|
|
59
|
+
"error",
|
|
60
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Bubblewrap-based sandboxed command executor."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from docketeer.executor import CommandExecutor, CompletedProcess, Mount, RunningProcess
|
|
10
|
+
|
|
11
|
+
SYSTEM_RO_BINDS = [
|
|
12
|
+
"/usr",
|
|
13
|
+
"/bin",
|
|
14
|
+
"/lib",
|
|
15
|
+
"/lib64",
|
|
16
|
+
"/etc/ssl",
|
|
17
|
+
"/etc/resolv.conf",
|
|
18
|
+
"/etc/hosts",
|
|
19
|
+
"/etc/alternatives",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SandboxedProcess(RunningProcess):
|
|
24
|
+
"""RunningProcess that cleans up a temporary directory after the process exits."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
process: asyncio.subprocess.Process,
|
|
29
|
+
tmp_ctx: tempfile.TemporaryDirectory[str] | None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(process)
|
|
32
|
+
self._tmp_ctx = tmp_ctx
|
|
33
|
+
|
|
34
|
+
async def wait(self) -> CompletedProcess:
|
|
35
|
+
result = await super().wait()
|
|
36
|
+
if self._tmp_ctx:
|
|
37
|
+
self._tmp_ctx.cleanup()
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BubblewrapExecutor(CommandExecutor):
|
|
42
|
+
"""Runs commands inside a bubblewrap sandbox."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
if not shutil.which("bwrap"):
|
|
46
|
+
raise RuntimeError("bwrap not found on PATH")
|
|
47
|
+
|
|
48
|
+
async def start(
|
|
49
|
+
self,
|
|
50
|
+
command: list[str],
|
|
51
|
+
*,
|
|
52
|
+
env: dict[str, str] | None = None,
|
|
53
|
+
mounts: list[Mount] | None = None,
|
|
54
|
+
network_access: bool = False,
|
|
55
|
+
username: str | None = None,
|
|
56
|
+
) -> RunningProcess:
|
|
57
|
+
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
|
|
58
|
+
tmp_dir: Path | None = None
|
|
59
|
+
if username:
|
|
60
|
+
tmp_ctx = tempfile.TemporaryDirectory()
|
|
61
|
+
tmp_dir = Path(tmp_ctx.name)
|
|
62
|
+
|
|
63
|
+
args = _build_args(
|
|
64
|
+
mounts=mounts or [],
|
|
65
|
+
network_access=network_access,
|
|
66
|
+
username=username,
|
|
67
|
+
tmp_dir=tmp_dir,
|
|
68
|
+
)
|
|
69
|
+
args.extend(command)
|
|
70
|
+
|
|
71
|
+
process = await asyncio.create_subprocess_exec(
|
|
72
|
+
*args,
|
|
73
|
+
stdin=asyncio.subprocess.PIPE,
|
|
74
|
+
stdout=asyncio.subprocess.PIPE,
|
|
75
|
+
stderr=asyncio.subprocess.PIPE,
|
|
76
|
+
env=env or {},
|
|
77
|
+
)
|
|
78
|
+
return _SandboxedProcess(process, tmp_ctx)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_args(
|
|
82
|
+
*,
|
|
83
|
+
mounts: list[Mount],
|
|
84
|
+
network_access: bool,
|
|
85
|
+
username: str | None = None,
|
|
86
|
+
tmp_dir: Path | None = None,
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
args = ["bwrap", "--die-with-parent"]
|
|
89
|
+
|
|
90
|
+
# Namespace isolation
|
|
91
|
+
args.extend(["--unshare-pid", "--unshare-uts", "--unshare-ipc", "--unshare-cgroup"])
|
|
92
|
+
if not network_access:
|
|
93
|
+
args.append("--unshare-net")
|
|
94
|
+
|
|
95
|
+
# Read-only system binds (skip paths that don't exist)
|
|
96
|
+
for path in SYSTEM_RO_BINDS:
|
|
97
|
+
if Path(path).exists():
|
|
98
|
+
args.extend(["--ro-bind", path, path])
|
|
99
|
+
|
|
100
|
+
# Virtual filesystems
|
|
101
|
+
args.extend(["--proc", "/proc"])
|
|
102
|
+
args.extend(["--dev", "/dev"])
|
|
103
|
+
args.extend(["--tmpfs", "/tmp"])
|
|
104
|
+
|
|
105
|
+
# Identity mapping
|
|
106
|
+
if username:
|
|
107
|
+
uid = os.getuid()
|
|
108
|
+
gid = os.getgid()
|
|
109
|
+
args.extend(["--uid", str(uid), "--gid", str(gid)])
|
|
110
|
+
|
|
111
|
+
stub_dir = tmp_dir if tmp_dir else Path(tempfile.mkdtemp())
|
|
112
|
+
|
|
113
|
+
passwd_file = stub_dir / "passwd"
|
|
114
|
+
passwd_file.write_text(f"{username}:x:{uid}:{gid}::/home/{username}:/bin/sh\n")
|
|
115
|
+
args.extend(["--ro-bind", str(passwd_file), "/etc/passwd"])
|
|
116
|
+
|
|
117
|
+
group_file = stub_dir / "group"
|
|
118
|
+
group_file.write_text(f"{username}:x:{gid}:\n")
|
|
119
|
+
args.extend(["--ro-bind", str(group_file), "/etc/group"])
|
|
120
|
+
|
|
121
|
+
# User-specified mounts
|
|
122
|
+
for mount in mounts:
|
|
123
|
+
flag = "--bind" if mount.writable else "--ro-bind"
|
|
124
|
+
args.extend([flag, str(mount.source), str(mount.target)])
|
|
125
|
+
|
|
126
|
+
return args
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Tests for the bubblewrap executor."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from docketeer.executor import Mount
|
|
11
|
+
from docketeer_bubblewrap import BubblewrapExecutor, create_executor
|
|
12
|
+
from docketeer_bubblewrap.executor import (
|
|
13
|
+
_build_args,
|
|
14
|
+
_SandboxedProcess,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
has_bwrap = shutil.which("bwrap") is not None
|
|
18
|
+
requires_bwrap = pytest.mark.skipif(not has_bwrap, reason="bwrap not on PATH")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- Factory ---
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@requires_bwrap
|
|
25
|
+
def test_create_executor():
|
|
26
|
+
executor = create_executor()
|
|
27
|
+
assert isinstance(executor, BubblewrapExecutor)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_create_executor_bwrap_missing():
|
|
31
|
+
with patch("shutil.which", return_value=None):
|
|
32
|
+
with pytest.raises(RuntimeError, match="bwrap not found"):
|
|
33
|
+
BubblewrapExecutor()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- _build_args ---
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_build_args_default_flags():
|
|
40
|
+
args = _build_args(mounts=[], network_access=False)
|
|
41
|
+
assert args[0] == "bwrap"
|
|
42
|
+
assert "--die-with-parent" in args
|
|
43
|
+
assert "--unshare-pid" in args
|
|
44
|
+
assert "--unshare-uts" in args
|
|
45
|
+
assert "--unshare-ipc" in args
|
|
46
|
+
assert "--unshare-cgroup" in args
|
|
47
|
+
assert "--unshare-net" in args
|
|
48
|
+
assert "--proc" in args
|
|
49
|
+
assert "--dev" in args
|
|
50
|
+
assert "--tmpfs" in args
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_build_args_network_access():
|
|
54
|
+
args = _build_args(mounts=[], network_access=True)
|
|
55
|
+
assert "--unshare-net" not in args
|
|
56
|
+
assert "--unshare-pid" in args
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_build_args_custom_mounts():
|
|
60
|
+
mounts = [
|
|
61
|
+
Mount(source=Path("/data"), target=Path("/mnt/data"), writable=True),
|
|
62
|
+
Mount(source=Path("/config"), target=Path("/mnt/config")),
|
|
63
|
+
]
|
|
64
|
+
args = _build_args(mounts=mounts, network_access=False)
|
|
65
|
+
|
|
66
|
+
# Find the writable mount
|
|
67
|
+
bind_idx = args.index("--bind")
|
|
68
|
+
assert args[bind_idx + 1] == "/data"
|
|
69
|
+
assert args[bind_idx + 2] == "/mnt/data"
|
|
70
|
+
|
|
71
|
+
# Find the read-only mount (after system binds, so find from the writable mount onward)
|
|
72
|
+
remaining = args[bind_idx + 3 :]
|
|
73
|
+
ro_idx = remaining.index("--ro-bind")
|
|
74
|
+
assert remaining[ro_idx + 1] == "/config"
|
|
75
|
+
assert remaining[ro_idx + 2] == "/mnt/config"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_build_args_skips_missing_system_paths():
|
|
79
|
+
with patch("docketeer_bubblewrap.executor.SYSTEM_RO_BINDS", ["/no/such/path"]):
|
|
80
|
+
args = _build_args(mounts=[], network_access=False)
|
|
81
|
+
|
|
82
|
+
# /no/such/path doesn't exist, so no --ro-bind should appear
|
|
83
|
+
ro_bind_count = args.count("--ro-bind")
|
|
84
|
+
assert ro_bind_count == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_build_args_without_username():
|
|
88
|
+
args = _build_args(mounts=[], network_access=False)
|
|
89
|
+
assert "--uid" not in args
|
|
90
|
+
assert "--gid" not in args
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_build_args_with_username():
|
|
94
|
+
args = _build_args(mounts=[], network_access=False, username="nix")
|
|
95
|
+
uid = os.getuid()
|
|
96
|
+
gid = os.getgid()
|
|
97
|
+
|
|
98
|
+
uid_idx = args.index("--uid")
|
|
99
|
+
assert args[uid_idx + 1] == str(uid)
|
|
100
|
+
|
|
101
|
+
gid_idx = args.index("--gid")
|
|
102
|
+
assert args[gid_idx + 1] == str(gid)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_build_args_with_username_creates_passwd_file(tmp_path: Path):
|
|
106
|
+
args = _build_args(
|
|
107
|
+
mounts=[], network_access=False, username="nix", tmp_dir=tmp_path
|
|
108
|
+
)
|
|
109
|
+
uid = os.getuid()
|
|
110
|
+
gid = os.getgid()
|
|
111
|
+
|
|
112
|
+
passwd_path = tmp_path / "passwd"
|
|
113
|
+
assert passwd_path.exists()
|
|
114
|
+
content = passwd_path.read_text()
|
|
115
|
+
assert f"nix:x:{uid}:{gid}::" in content
|
|
116
|
+
assert "/home/nix" in content
|
|
117
|
+
|
|
118
|
+
# --ro-bind <source> /etc/passwd
|
|
119
|
+
passwd_idx = args.index(str(passwd_path))
|
|
120
|
+
assert args[passwd_idx - 1] == "--ro-bind"
|
|
121
|
+
assert args[passwd_idx + 1] == "/etc/passwd"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_build_args_with_username_creates_group_file(tmp_path: Path):
|
|
125
|
+
args = _build_args(
|
|
126
|
+
mounts=[], network_access=False, username="nix", tmp_dir=tmp_path
|
|
127
|
+
)
|
|
128
|
+
gid = os.getgid()
|
|
129
|
+
|
|
130
|
+
group_path = tmp_path / "group"
|
|
131
|
+
assert group_path.exists()
|
|
132
|
+
content = group_path.read_text()
|
|
133
|
+
assert f"nix:x:{gid}:" in content
|
|
134
|
+
|
|
135
|
+
# --ro-bind <source> /etc/group
|
|
136
|
+
group_idx = args.index(str(group_path))
|
|
137
|
+
assert args[group_idx - 1] == "--ro-bind"
|
|
138
|
+
assert args[group_idx + 1] == "/etc/group"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- Integration tests (require bwrap) ---
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.fixture()
|
|
145
|
+
def executor() -> BubblewrapExecutor:
|
|
146
|
+
if not has_bwrap:
|
|
147
|
+
pytest.skip("bwrap not on PATH")
|
|
148
|
+
return BubblewrapExecutor()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_run_echo(executor: BubblewrapExecutor):
|
|
152
|
+
rp = await executor.start(["echo", "hello sandbox"])
|
|
153
|
+
result = await rp.wait()
|
|
154
|
+
assert result.returncode == 0
|
|
155
|
+
assert b"hello sandbox" in result.stdout
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_writable_mount(executor: BubblewrapExecutor, tmp_path: Path):
|
|
159
|
+
test_file = tmp_path / "output.txt"
|
|
160
|
+
rp = await executor.start(
|
|
161
|
+
["sh", "-c", "echo written > /mnt/work/output.txt"],
|
|
162
|
+
mounts=[Mount(source=tmp_path, target=Path("/mnt/work"), writable=True)],
|
|
163
|
+
)
|
|
164
|
+
result = await rp.wait()
|
|
165
|
+
assert result.returncode == 0
|
|
166
|
+
assert test_file.read_text().strip() == "written"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def test_readonly_mount(executor: BubblewrapExecutor, tmp_path: Path):
|
|
170
|
+
source_file = tmp_path / "input.txt"
|
|
171
|
+
source_file.write_text("readonly content")
|
|
172
|
+
rp = await executor.start(
|
|
173
|
+
["cat", "/mnt/ro/input.txt"],
|
|
174
|
+
mounts=[Mount(source=tmp_path, target=Path("/mnt/ro"))],
|
|
175
|
+
)
|
|
176
|
+
result = await rp.wait()
|
|
177
|
+
assert result.returncode == 0
|
|
178
|
+
assert b"readonly content" in result.stdout
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def test_readonly_mount_rejects_writes(
|
|
182
|
+
executor: BubblewrapExecutor, tmp_path: Path
|
|
183
|
+
):
|
|
184
|
+
rp = await executor.start(
|
|
185
|
+
["sh", "-c", "echo fail > /mnt/ro/nope.txt"],
|
|
186
|
+
mounts=[Mount(source=tmp_path, target=Path("/mnt/ro"))],
|
|
187
|
+
)
|
|
188
|
+
result = await rp.wait()
|
|
189
|
+
assert result.returncode != 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def test_terminate(executor: BubblewrapExecutor):
|
|
193
|
+
rp = await executor.start(["sleep", "60"])
|
|
194
|
+
rp.terminate()
|
|
195
|
+
result = await rp.wait()
|
|
196
|
+
assert result.returncode != 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_env_empty_by_default(executor: BubblewrapExecutor):
|
|
200
|
+
rp = await executor.start(["env"])
|
|
201
|
+
result = await rp.wait()
|
|
202
|
+
assert result.returncode == 0
|
|
203
|
+
env_vars = dict(
|
|
204
|
+
line.split("=", 1)
|
|
205
|
+
for line in result.stdout.decode().strip().splitlines()
|
|
206
|
+
if "=" in line
|
|
207
|
+
)
|
|
208
|
+
# PWD is set by the kernel, not inherited — that's fine
|
|
209
|
+
env_vars.pop("PWD", None)
|
|
210
|
+
assert env_vars == {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def test_env_passed_to_subprocess(executor: BubblewrapExecutor):
|
|
214
|
+
rp = await executor.start(
|
|
215
|
+
["sh", "-c", "echo $MY_VAR"],
|
|
216
|
+
env={"MY_VAR": "test_value", "PATH": "/usr/bin:/bin"},
|
|
217
|
+
)
|
|
218
|
+
result = await rp.wait()
|
|
219
|
+
assert result.returncode == 0
|
|
220
|
+
assert b"test_value" in result.stdout
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def test_username_stubs_cleaned_up_after_wait(executor: BubblewrapExecutor):
|
|
224
|
+
rp = await executor.start(["true"], username="nix")
|
|
225
|
+
assert isinstance(rp, _SandboxedProcess)
|
|
226
|
+
assert rp._tmp_ctx is not None
|
|
227
|
+
stub_dir = Path(rp._tmp_ctx.name)
|
|
228
|
+
assert stub_dir.exists()
|
|
229
|
+
assert (stub_dir / "passwd").exists()
|
|
230
|
+
assert (stub_dir / "group").exists()
|
|
231
|
+
|
|
232
|
+
await rp.wait()
|
|
233
|
+
assert not stub_dir.exists()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def test_no_cleanup_without_username(executor: BubblewrapExecutor):
|
|
237
|
+
rp = await executor.start(["true"])
|
|
238
|
+
assert isinstance(rp, _SandboxedProcess)
|
|
239
|
+
assert rp._tmp_ctx is None
|
|
240
|
+
await rp.wait()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def test_username_sets_identity(executor: BubblewrapExecutor):
|
|
244
|
+
rp = await executor.start(
|
|
245
|
+
["sh", "-c", "whoami && id -gn"],
|
|
246
|
+
username="nix",
|
|
247
|
+
)
|
|
248
|
+
result = await rp.wait()
|
|
249
|
+
assert result.returncode == 0
|
|
250
|
+
lines = result.stdout.decode().strip().splitlines()
|
|
251
|
+
assert lines[0] == "nix"
|
|
252
|
+
assert lines[1] == "nix"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def test_network_isolated_by_default(executor: BubblewrapExecutor):
|
|
256
|
+
rp = await executor.start(
|
|
257
|
+
["sh", "-c", "cat /proc/net/if_inet6 2>/dev/null || echo no-network"],
|
|
258
|
+
)
|
|
259
|
+
result = await rp.wait()
|
|
260
|
+
assert result.returncode == 0
|