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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
@@ -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,8 @@
1
+ from docketeer_bubblewrap.executor import BubblewrapExecutor
2
+
3
+
4
+ def create_executor() -> BubblewrapExecutor:
5
+ return BubblewrapExecutor()
6
+
7
+
8
+ __all__ = ["BubblewrapExecutor", "create_executor"]
@@ -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