maf-sandbox-bubblewrap 0.1.0__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.
- maf_sandbox_bubblewrap-0.1.0/LICENSE +21 -0
- maf_sandbox_bubblewrap-0.1.0/PKG-INFO +71 -0
- maf_sandbox_bubblewrap-0.1.0/README.md +50 -0
- maf_sandbox_bubblewrap-0.1.0/pyproject.toml +75 -0
- maf_sandbox_bubblewrap-0.1.0/pyproject.toml.orig +55 -0
- maf_sandbox_bubblewrap-0.1.0/src/maf_sandbox_bubblewrap/__init__.py +27 -0
- maf_sandbox_bubblewrap-0.1.0/src/maf_sandbox_bubblewrap/_backend.py +607 -0
- maf_sandbox_bubblewrap-0.1.0/src/maf_sandbox_bubblewrap/_config.py +42 -0
- maf_sandbox_bubblewrap-0.1.0/src/maf_sandbox_bubblewrap/_guest.py +240 -0
- maf_sandbox_bubblewrap-0.1.0/src/maf_sandbox_bubblewrap/_launch.py +20 -0
- maf_sandbox_bubblewrap-0.1.0/src/maf_sandbox_bubblewrap/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOKOLAI BV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maf-sandbox-bubblewrap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Linux namespace and cgroup sandboxes without a container engine.
|
|
5
|
+
Author: SOKOLAI BV
|
|
6
|
+
Author-email: SOKOLAI BV <info@sokolai.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Requires-Dist: maf-sandbox>=0.47.0,<0.48
|
|
16
|
+
Requires-Python: >=3.12, <3.15
|
|
17
|
+
Project-URL: Homepage, https://www.sokol.ai
|
|
18
|
+
Project-URL: Source, https://github.com/sokolaidev/maf-extensions
|
|
19
|
+
Project-URL: Issues, https://github.com/sokolaidev/maf-extensions/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# maf-sandbox-bubblewrap
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/maf-sandbox-bubblewrap/) [](https://pypi.org/project/maf-sandbox-bubblewrap/) [](https://github.com/sokolaidev/maf-extensions/blob/main/packages/maf-sandbox-bubblewrap/LICENSE)
|
|
25
|
+
|
|
26
|
+
> **Experimental.** Not yet released. `maf-sandbox-bubblewrap` may change or be removed without notice and emits `MafSandboxBubblewrapExperimentalWarning` on import.
|
|
27
|
+
|
|
28
|
+
Experimental Linux sandbox backend using Bubblewrap and cgroup v2, without Docker, an OCI image or a container engine. The runtime is a separately provisioned, trusted Linux root directory. Runtime downloads never happen during a tool call.
|
|
29
|
+
|
|
30
|
+
## Prerequisites
|
|
31
|
+
|
|
32
|
+
- Linux with unprivileged user, mount, PID, network, IPC, UTS and cgroup namespaces; Bubblewrap supporting `--disable-userns`, `--assert-userns-disabled`, `--as-pid-1` and bounded tmpfs mounts. Missing support refuses startup.
|
|
33
|
+
- A private cgroup v2 delegation with `cpu`, `memory` and `pids` enabled, `memory.swap.max` and `cgroup.kill`. The host process must already belong to that delegation so it can move children into sibling groups. A systemd service with `Delegate=cpu memory pids` can provide it; the host must place itself in a leaf and enable the controllers on its empty parent. This package does not grant privileges or change host-wide policy.
|
|
34
|
+
- A trusted runtime directory containing `/usr/bin/python3` and `/bin/sh`, with no host credentials or sockets. Keep it immutable while sandboxes run. Runtime, cgroup and state paths and their ancestors must be controlled by the host administrator. State and cgroup roots must resolve outside the runtime. The state directory must be owned by the service user and mode `0700`.
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from maf_sandbox import Isolation, SandboxRouter
|
|
41
|
+
from maf_sandbox_bubblewrap import BubblewrapSandboxBackend, BubblewrapSandboxConfig
|
|
42
|
+
|
|
43
|
+
async def make_router() -> SandboxRouter:
|
|
44
|
+
backend = await BubblewrapSandboxBackend.create(BubblewrapSandboxConfig(
|
|
45
|
+
runtime_root=Path("/opt/maf-runtime"),
|
|
46
|
+
state_root=Path("/var/lib/my-service/sandbox-state"),
|
|
47
|
+
cgroup_root=Path("/sys/fs/cgroup/my-service"),
|
|
48
|
+
runtime_id="drawio-native",
|
|
49
|
+
))
|
|
50
|
+
return SandboxRouter([backend], min_isolation=Isolation.CONTAINER)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`SandboxSpec.image` is an optional exact match against the configured `runtime_id`, not an OCI reference. `image_id` is unsupported. The storage base is `/maf-sandbox/work`; only that explicit override or `None` is accepted. Relative working directories address the base. Commands are opaque; a string runs through `/bin/sh -c`, while a sequence executes directly.
|
|
54
|
+
|
|
55
|
+
The protocol's `Isolation.CONTAINER` means Linux namespaces plus cgroups. It describes the boundary even though no container engine is used. The router's default `MICROVM` floor still refuses this backend. Keep a separate router or use per-spec routing for a native renderer alongside a stronger CodeAct backend; never lower a shared host floor implicitly.
|
|
56
|
+
|
|
57
|
+
## Boundary and lifecycle
|
|
58
|
+
|
|
59
|
+
Every sandbox gets mandatory private namespaces, no capabilities, disabled nested user namespaces, a new session and a cleared environment. The runtime is read-only. `/tmp`, `/run` and `/maf-sandbox` are separate bounded tmpfs mounts; aggregate memory, swap, process count and CPU are constrained before any guest starts. Default limits are 1 GiB memory, no swap, 256 processes, two CPU equivalents and 128 MiB per writable mount. No host home, display, network or cgroup filesystem is mounted. Only `Egress.CLOSED`, POSIX `EXEC`, `FILES_IN` and `FILES_OUT` are declared. This is a shared-kernel boundary, not a microVM or a claim of protection against kernel exploits. No seccomp syscall filter is currently supplied.
|
|
60
|
+
|
|
61
|
+
A private PID 1 broker uses no-follow directory descriptors for transfers. It runs at guest filesystem authority; its stat responses are guest-side metadata, not host attestations. Its control descriptors are protected from ptrace, and commands receive separate stdin/stdout/stderr. Individual transfers are capped at 8 MiB; the router applies its collection count and aggregate byte limits. Combined command output is capped at 1 MiB by default; excess output fails explicitly. A command's descendants, including detached processes, are killed before its reply. Timeout or cancellation after dispatch destroys the entire sandbox; while queued, it refuses only the waiting request. There is no reclamation or snapshot capability; the router uses disposal.
|
|
62
|
+
|
|
63
|
+
Key identity includes scope, thread, agent, call and kind. An exclusive host file lock prevents another backend object or process from acquiring a live instance. Repeated acquire within one backend can reuse a matching spec; another owner is refused. Files live only in tmpfs and disappear on owner death. Records allow a subsequent host to purge dead owners' cgroups; this is cleanup recovery, not restoration of guest state. Stale instance disposal cannot delete a replacement. A scope purge reports busy or failed records and retains them for retry. Hosts must stop new work across replicas before purging a conversation.
|
|
64
|
+
|
|
65
|
+
Lock files remain after disposal so a concurrent owner cannot lock a replacement inode under the same name. Remove the state directory only after stopping all owners; long-lived deployments should account for those small files in retention policy.
|
|
66
|
+
|
|
67
|
+
Ownership records are published atomically before cgroup creation, so a host that dies during startup leaves either no cgroup or a complete cleanup record. Failed setup removes its record after cgroup cleanup succeeds. Commands whose requested timeout exceeds `max_timeout` (180 seconds by default) are rejected with `ValueError` before execution. Accepted deadlines include time waiting for the request lock; only the remaining budget reaches the broker.
|
|
68
|
+
|
|
69
|
+
## Offline Draw.io
|
|
70
|
+
|
|
71
|
+
The [runtime provisioning script](https://github.com/sokolaidev/maf-extensions/blob/main/images/drawio-export/build-runtime.sh) uses debootstrap and the pinned Draw.io Desktop package directly, with the same offline assets, fonts and exporter as the image profile. Provisioning needs network access and root; rendering needs neither. Run the host outside that runtime and register this backend with `maf-sandbox-drawio`, passing the configured runtime id as `image`. Linux is the only supported host platform; native Windows and macOS backends are separate work.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# maf-sandbox-bubblewrap
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/maf-sandbox-bubblewrap/) [](https://pypi.org/project/maf-sandbox-bubblewrap/) [](https://github.com/sokolaidev/maf-extensions/blob/main/packages/maf-sandbox-bubblewrap/LICENSE)
|
|
4
|
+
|
|
5
|
+
> **Experimental.** Not yet released. `maf-sandbox-bubblewrap` may change or be removed without notice and emits `MafSandboxBubblewrapExperimentalWarning` on import.
|
|
6
|
+
|
|
7
|
+
Experimental Linux sandbox backend using Bubblewrap and cgroup v2, without Docker, an OCI image or a container engine. The runtime is a separately provisioned, trusted Linux root directory. Runtime downloads never happen during a tool call.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Linux with unprivileged user, mount, PID, network, IPC, UTS and cgroup namespaces; Bubblewrap supporting `--disable-userns`, `--assert-userns-disabled`, `--as-pid-1` and bounded tmpfs mounts. Missing support refuses startup.
|
|
12
|
+
- A private cgroup v2 delegation with `cpu`, `memory` and `pids` enabled, `memory.swap.max` and `cgroup.kill`. The host process must already belong to that delegation so it can move children into sibling groups. A systemd service with `Delegate=cpu memory pids` can provide it; the host must place itself in a leaf and enable the controllers on its empty parent. This package does not grant privileges or change host-wide policy.
|
|
13
|
+
- A trusted runtime directory containing `/usr/bin/python3` and `/bin/sh`, with no host credentials or sockets. Keep it immutable while sandboxes run. Runtime, cgroup and state paths and their ancestors must be controlled by the host administrator. State and cgroup roots must resolve outside the runtime. The state directory must be owned by the service user and mode `0700`.
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from maf_sandbox import Isolation, SandboxRouter
|
|
20
|
+
from maf_sandbox_bubblewrap import BubblewrapSandboxBackend, BubblewrapSandboxConfig
|
|
21
|
+
|
|
22
|
+
async def make_router() -> SandboxRouter:
|
|
23
|
+
backend = await BubblewrapSandboxBackend.create(BubblewrapSandboxConfig(
|
|
24
|
+
runtime_root=Path("/opt/maf-runtime"),
|
|
25
|
+
state_root=Path("/var/lib/my-service/sandbox-state"),
|
|
26
|
+
cgroup_root=Path("/sys/fs/cgroup/my-service"),
|
|
27
|
+
runtime_id="drawio-native",
|
|
28
|
+
))
|
|
29
|
+
return SandboxRouter([backend], min_isolation=Isolation.CONTAINER)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`SandboxSpec.image` is an optional exact match against the configured `runtime_id`, not an OCI reference. `image_id` is unsupported. The storage base is `/maf-sandbox/work`; only that explicit override or `None` is accepted. Relative working directories address the base. Commands are opaque; a string runs through `/bin/sh -c`, while a sequence executes directly.
|
|
33
|
+
|
|
34
|
+
The protocol's `Isolation.CONTAINER` means Linux namespaces plus cgroups. It describes the boundary even though no container engine is used. The router's default `MICROVM` floor still refuses this backend. Keep a separate router or use per-spec routing for a native renderer alongside a stronger CodeAct backend; never lower a shared host floor implicitly.
|
|
35
|
+
|
|
36
|
+
## Boundary and lifecycle
|
|
37
|
+
|
|
38
|
+
Every sandbox gets mandatory private namespaces, no capabilities, disabled nested user namespaces, a new session and a cleared environment. The runtime is read-only. `/tmp`, `/run` and `/maf-sandbox` are separate bounded tmpfs mounts; aggregate memory, swap, process count and CPU are constrained before any guest starts. Default limits are 1 GiB memory, no swap, 256 processes, two CPU equivalents and 128 MiB per writable mount. No host home, display, network or cgroup filesystem is mounted. Only `Egress.CLOSED`, POSIX `EXEC`, `FILES_IN` and `FILES_OUT` are declared. This is a shared-kernel boundary, not a microVM or a claim of protection against kernel exploits. No seccomp syscall filter is currently supplied.
|
|
39
|
+
|
|
40
|
+
A private PID 1 broker uses no-follow directory descriptors for transfers. It runs at guest filesystem authority; its stat responses are guest-side metadata, not host attestations. Its control descriptors are protected from ptrace, and commands receive separate stdin/stdout/stderr. Individual transfers are capped at 8 MiB; the router applies its collection count and aggregate byte limits. Combined command output is capped at 1 MiB by default; excess output fails explicitly. A command's descendants, including detached processes, are killed before its reply. Timeout or cancellation after dispatch destroys the entire sandbox; while queued, it refuses only the waiting request. There is no reclamation or snapshot capability; the router uses disposal.
|
|
41
|
+
|
|
42
|
+
Key identity includes scope, thread, agent, call and kind. An exclusive host file lock prevents another backend object or process from acquiring a live instance. Repeated acquire within one backend can reuse a matching spec; another owner is refused. Files live only in tmpfs and disappear on owner death. Records allow a subsequent host to purge dead owners' cgroups; this is cleanup recovery, not restoration of guest state. Stale instance disposal cannot delete a replacement. A scope purge reports busy or failed records and retains them for retry. Hosts must stop new work across replicas before purging a conversation.
|
|
43
|
+
|
|
44
|
+
Lock files remain after disposal so a concurrent owner cannot lock a replacement inode under the same name. Remove the state directory only after stopping all owners; long-lived deployments should account for those small files in retention policy.
|
|
45
|
+
|
|
46
|
+
Ownership records are published atomically before cgroup creation, so a host that dies during startup leaves either no cgroup or a complete cleanup record. Failed setup removes its record after cgroup cleanup succeeds. Commands whose requested timeout exceeds `max_timeout` (180 seconds by default) are rejected with `ValueError` before execution. Accepted deadlines include time waiting for the request lock; only the remaining budget reaches the broker.
|
|
47
|
+
|
|
48
|
+
## Offline Draw.io
|
|
49
|
+
|
|
50
|
+
The [runtime provisioning script](https://github.com/sokolaidev/maf-extensions/blob/main/images/drawio-export/build-runtime.sh) uses debootstrap and the pinned Draw.io Desktop package directly, with the same offline assets, fonts and exporter as the image profile. Provisioning needs network access and root; rendering needs neither. Run the host outside that runtime and register this backend with `maf-sandbox-drawio`, passing the configured runtime id as `image`. Linux is the only supported host platform; native Windows and macOS backends are separate work.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maf-sandbox-bubblewrap"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Linux namespace and cgroup sandboxes without a container engine."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.15"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Typing :: Typed",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["maf-sandbox>=0.47.0,<0.48"]
|
|
18
|
+
|
|
19
|
+
[[project.authors]]
|
|
20
|
+
name = "SOKOLAI BV"
|
|
21
|
+
email = "info@sokolai.com"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://www.sokol.ai"
|
|
25
|
+
Source = "https://github.com/sokolaidev/maf-extensions"
|
|
26
|
+
Issues = "https://github.com/sokolaidev/maf-extensions/issues"
|
|
27
|
+
|
|
28
|
+
[tool.uv.sources.maf-sandbox]
|
|
29
|
+
workspace = true
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
module-name = "maf_sandbox_bubblewrap"
|
|
33
|
+
module-root = "src"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py312"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
extend-select = [
|
|
41
|
+
"I",
|
|
42
|
+
"UP",
|
|
43
|
+
"D100",
|
|
44
|
+
"D101",
|
|
45
|
+
"D103",
|
|
46
|
+
"D104",
|
|
47
|
+
"E501",
|
|
48
|
+
"W505",
|
|
49
|
+
"TD",
|
|
50
|
+
"FIX",
|
|
51
|
+
"ERA001",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.per-file-ignores]
|
|
55
|
+
"tests/**" = [
|
|
56
|
+
"D101",
|
|
57
|
+
"D103",
|
|
58
|
+
"E501",
|
|
59
|
+
"W505",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint.pycodestyle]
|
|
63
|
+
max-doc-length = 100
|
|
64
|
+
|
|
65
|
+
[tool.pyright]
|
|
66
|
+
include = ["src"]
|
|
67
|
+
typeCheckingMode = "strict"
|
|
68
|
+
pythonPlatform = "Linux"
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
testpaths = ["tests"]
|
|
72
|
+
|
|
73
|
+
[build-system]
|
|
74
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
75
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maf-sandbox-bubblewrap"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Linux namespace and cgroup sandboxes without a container engine."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.15"
|
|
7
|
+
authors = [{ name = "SOKOLAI BV", email = "info@sokolai.com" }]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Typing :: Typed",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["maf-sandbox>=0.47.0,<0.48"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://www.sokol.ai"
|
|
22
|
+
Source = "https://github.com/sokolaidev/maf-extensions"
|
|
23
|
+
Issues = "https://github.com/sokolaidev/maf-extensions/issues"
|
|
24
|
+
|
|
25
|
+
[tool.uv.sources]
|
|
26
|
+
maf-sandbox = { workspace = true }
|
|
27
|
+
|
|
28
|
+
[tool.uv.build-backend]
|
|
29
|
+
module-name = "maf_sandbox_bubblewrap"
|
|
30
|
+
module-root = "src"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py312"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
extend-select = ["I", "UP", "D100", "D101", "D103", "D104", "E501", "W505", "TD", "FIX", "ERA001"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint.per-file-ignores]
|
|
40
|
+
"tests/**" = ["D101", "D103", "E501", "W505"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.pycodestyle]
|
|
43
|
+
max-doc-length = 100
|
|
44
|
+
|
|
45
|
+
[tool.pyright]
|
|
46
|
+
include = ["src"]
|
|
47
|
+
typeCheckingMode = "strict"
|
|
48
|
+
pythonPlatform = "Linux"
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
|
|
53
|
+
[build-system]
|
|
54
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
55
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Linux Bubblewrap sandboxes with closed networking and mandatory cgroup limits."""
|
|
2
|
+
|
|
3
|
+
import warnings as _warnings
|
|
4
|
+
|
|
5
|
+
from ._backend import BubblewrapSandboxBackend
|
|
6
|
+
from ._config import BubblewrapSandboxConfig
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BubblewrapSandboxBackend",
|
|
10
|
+
"BubblewrapSandboxConfig",
|
|
11
|
+
"MafSandboxBubblewrapExperimentalWarning",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MafSandboxBubblewrapExperimentalWarning(UserWarning):
|
|
16
|
+
"""The backend is experimental and requires host qualification."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
_warnings.warn(
|
|
21
|
+
"maf_sandbox_bubblewrap is experimental and may change without notice.",
|
|
22
|
+
MafSandboxBubblewrapExperimentalWarning,
|
|
23
|
+
stacklevel=2,
|
|
24
|
+
)
|
|
25
|
+
except MafSandboxBubblewrapExperimentalWarning:
|
|
26
|
+
# An informational notice must not prevent imports under warnings-as-errors.
|
|
27
|
+
pass
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Engine-free Linux namespaces, with host-owned lifecycle records and cgroup supervision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from maf_sandbox import (
|
|
19
|
+
BackendDeclarations,
|
|
20
|
+
Capability,
|
|
21
|
+
DisposalFailure,
|
|
22
|
+
Egress,
|
|
23
|
+
EntryKind,
|
|
24
|
+
ExecResult,
|
|
25
|
+
Isolation,
|
|
26
|
+
IsolationScope,
|
|
27
|
+
OsFamily,
|
|
28
|
+
Sandbox,
|
|
29
|
+
SandboxBackend,
|
|
30
|
+
SandboxEntry,
|
|
31
|
+
SandboxKey,
|
|
32
|
+
SandboxSpec,
|
|
33
|
+
SandboxTransferCapExceeded,
|
|
34
|
+
ScopePurge,
|
|
35
|
+
fold_disposal_failures,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from ._config import BubblewrapSandboxConfig
|
|
39
|
+
|
|
40
|
+
FILE_LIMIT = 8 * 1024 * 1024
|
|
41
|
+
FRAME_LIMIT = 12 * 1024 * 1024
|
|
42
|
+
_CAPABILITIES = frozenset({Capability.EXEC, Capability.FILES_IN, Capability.FILES_OUT})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _identity(key: SandboxKey, kind: str) -> list[str]:
|
|
46
|
+
return [key.scope, key.thread_id, key.agent_id, key.call_id, kind]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _digest(identity: list[str]) -> str:
|
|
50
|
+
return hashlib.sha256(json.dumps(identity).encode()).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lock(path: Path) -> int:
|
|
54
|
+
import fcntl
|
|
55
|
+
|
|
56
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
57
|
+
try:
|
|
58
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
59
|
+
return fd
|
|
60
|
+
except BaseException:
|
|
61
|
+
os.close(fd)
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_record(path: Path, value: dict[str, Any]) -> None:
|
|
66
|
+
"""Publish complete ownership under the caller's exclusive record lock."""
|
|
67
|
+
pending = path.with_suffix(".pending")
|
|
68
|
+
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
|
69
|
+
try:
|
|
70
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
71
|
+
json.dump(value, file)
|
|
72
|
+
file.flush()
|
|
73
|
+
os.fsync(file.fileno())
|
|
74
|
+
os.replace(pending, path)
|
|
75
|
+
finally:
|
|
76
|
+
pending.unlink(missing_ok=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _finish(task: asyncio.Task[None]) -> None:
|
|
80
|
+
"""Drain cleanup even if the owning task receives repeated cancellation."""
|
|
81
|
+
cancelled = False
|
|
82
|
+
while not task.done():
|
|
83
|
+
try:
|
|
84
|
+
await asyncio.shield(task)
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
cancelled = True
|
|
87
|
+
task.result()
|
|
88
|
+
if cancelled:
|
|
89
|
+
raise asyncio.CancelledError
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _diagnostics(stream: asyncio.StreamReader) -> str:
|
|
93
|
+
retained = bytearray()
|
|
94
|
+
while chunk := await stream.read(4096):
|
|
95
|
+
retained.extend(chunk[: max(0, 8192 - len(retained))])
|
|
96
|
+
return retained.decode("utf-8", errors="replace")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _arguments(config: BubblewrapSandboxConfig) -> list[str]:
|
|
100
|
+
args = [str(config.bwrap)]
|
|
101
|
+
args.extend("--unshare-" + name for name in ("user", "pid", "net", "ipc", "uts", "cgroup"))
|
|
102
|
+
args.extend(
|
|
103
|
+
[
|
|
104
|
+
"--disable-userns",
|
|
105
|
+
"--assert-userns-disabled",
|
|
106
|
+
"--as-pid-1",
|
|
107
|
+
"--new-session",
|
|
108
|
+
"--die-with-parent",
|
|
109
|
+
"--cap-drop",
|
|
110
|
+
"ALL",
|
|
111
|
+
"--clearenv",
|
|
112
|
+
"--hostname",
|
|
113
|
+
"sandbox",
|
|
114
|
+
"--ro-bind",
|
|
115
|
+
str(config.runtime_root),
|
|
116
|
+
"/",
|
|
117
|
+
"--proc",
|
|
118
|
+
"/proc",
|
|
119
|
+
"--dev",
|
|
120
|
+
"/dev",
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
for directory in ("/tmp", "/run", "/maf-sandbox"):
|
|
124
|
+
args.extend(["--size", str(config.workspace_bytes), "--tmpfs", directory])
|
|
125
|
+
args.extend(
|
|
126
|
+
[
|
|
127
|
+
"--ro-bind",
|
|
128
|
+
str(Path(__file__).with_name("_guest.py")),
|
|
129
|
+
"/run/maf-broker.py",
|
|
130
|
+
"--setenv",
|
|
131
|
+
"PATH",
|
|
132
|
+
"/usr/bin:/bin",
|
|
133
|
+
"--setenv",
|
|
134
|
+
"LANG",
|
|
135
|
+
"C.UTF-8",
|
|
136
|
+
"--setenv",
|
|
137
|
+
"HOME",
|
|
138
|
+
"/tmp",
|
|
139
|
+
"--setenv",
|
|
140
|
+
"TMPDIR",
|
|
141
|
+
"/tmp",
|
|
142
|
+
"--setenv",
|
|
143
|
+
"DRAWIO_DISABLE_UPDATE",
|
|
144
|
+
"true",
|
|
145
|
+
"--chdir",
|
|
146
|
+
"/",
|
|
147
|
+
"--",
|
|
148
|
+
"/usr/bin/python3",
|
|
149
|
+
"-I",
|
|
150
|
+
"/run/maf-broker.py",
|
|
151
|
+
]
|
|
152
|
+
)
|
|
153
|
+
return args
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Sandbox:
|
|
157
|
+
"""One supervised namespace; command output and transfer frames are independently bounded."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
backend: BubblewrapSandboxBackend,
|
|
162
|
+
instance: str,
|
|
163
|
+
process: asyncio.subprocess.Process,
|
|
164
|
+
lock_fd: int,
|
|
165
|
+
record: Path,
|
|
166
|
+
spec: SandboxSpec,
|
|
167
|
+
diagnostics: asyncio.Task[str],
|
|
168
|
+
) -> None:
|
|
169
|
+
self.backend = backend
|
|
170
|
+
self.instance_id = instance
|
|
171
|
+
self.process = process
|
|
172
|
+
self.lock_fd = lock_fd
|
|
173
|
+
self.record = record
|
|
174
|
+
self.spec = spec
|
|
175
|
+
self.diagnostics = diagnostics
|
|
176
|
+
self.dead = False
|
|
177
|
+
self._serial = asyncio.Lock()
|
|
178
|
+
self._sequence = 0
|
|
179
|
+
|
|
180
|
+
async def _request(
|
|
181
|
+
self,
|
|
182
|
+
op: str,
|
|
183
|
+
*,
|
|
184
|
+
transport_timeout: float = 10,
|
|
185
|
+
deadline: float | None = None,
|
|
186
|
+
**fields: Any,
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
loop = asyncio.get_running_loop()
|
|
189
|
+
if deadline is None:
|
|
190
|
+
deadline = loop.time() + transport_timeout
|
|
191
|
+
async with asyncio.timeout_at(deadline):
|
|
192
|
+
await self._serial.acquire()
|
|
193
|
+
try:
|
|
194
|
+
if self.dead or self.process.returncode is not None:
|
|
195
|
+
raise RuntimeError("Sandbox is no longer running")
|
|
196
|
+
remaining = deadline - loop.time()
|
|
197
|
+
if remaining <= 0:
|
|
198
|
+
raise TimeoutError("Request deadline expired before dispatch")
|
|
199
|
+
if op == "exec":
|
|
200
|
+
fields["timeout"] = min(fields["timeout"], remaining)
|
|
201
|
+
self._sequence += 1
|
|
202
|
+
payload = json.dumps({"id": self._sequence, "op": op, **fields}).encode() + b"\n"
|
|
203
|
+
if len(payload) > FRAME_LIMIT:
|
|
204
|
+
raise ValueError("Request exceeds transfer frame limit")
|
|
205
|
+
assert self.process.stdin is not None and self.process.stdout is not None
|
|
206
|
+
if loop.time() >= deadline:
|
|
207
|
+
raise TimeoutError("Request deadline expired before dispatch")
|
|
208
|
+
try:
|
|
209
|
+
async with asyncio.timeout_at(deadline):
|
|
210
|
+
self.process.stdin.write(payload)
|
|
211
|
+
await self.process.stdin.drain()
|
|
212
|
+
frame = await self.process.stdout.readline()
|
|
213
|
+
if not frame.endswith(b"\n") or len(frame) > FRAME_LIMIT:
|
|
214
|
+
raise RuntimeError("Invalid guest response frame")
|
|
215
|
+
response: dict[str, Any] = json.loads(frame)
|
|
216
|
+
if type(response) is not dict or response.get("id") != self._sequence:
|
|
217
|
+
raise RuntimeError("Invalid guest response identity")
|
|
218
|
+
except BaseException:
|
|
219
|
+
await _finish(asyncio.create_task(self.stop()))
|
|
220
|
+
raise
|
|
221
|
+
if "error" in response:
|
|
222
|
+
error = response["error"]
|
|
223
|
+
detail = str(response.get("detail", "Guest operation refused"))[:1024]
|
|
224
|
+
if error == "TransferCapExceeded":
|
|
225
|
+
raise SandboxTransferCapExceeded(detail)
|
|
226
|
+
if error == "FileNotFoundError":
|
|
227
|
+
raise FileNotFoundError(detail)
|
|
228
|
+
if error == "TimeoutError":
|
|
229
|
+
await _finish(asyncio.create_task(self.stop()))
|
|
230
|
+
raise TimeoutError(detail)
|
|
231
|
+
if error in {
|
|
232
|
+
"OSError",
|
|
233
|
+
"IsADirectoryError",
|
|
234
|
+
"NotADirectoryError",
|
|
235
|
+
"PermissionError",
|
|
236
|
+
}:
|
|
237
|
+
errors: dict[str, type[OSError]] = {
|
|
238
|
+
"OSError": OSError,
|
|
239
|
+
"IsADirectoryError": IsADirectoryError,
|
|
240
|
+
"NotADirectoryError": NotADirectoryError,
|
|
241
|
+
"PermissionError": PermissionError,
|
|
242
|
+
}
|
|
243
|
+
raise errors[error](detail)
|
|
244
|
+
raise ValueError(detail)
|
|
245
|
+
result = response.get("result")
|
|
246
|
+
if not isinstance(result, dict):
|
|
247
|
+
await _finish(asyncio.create_task(self.stop()))
|
|
248
|
+
raise RuntimeError("Invalid guest result")
|
|
249
|
+
return cast(dict[str, Any], result)
|
|
250
|
+
finally:
|
|
251
|
+
self._serial.release()
|
|
252
|
+
|
|
253
|
+
async def stop(self) -> None:
|
|
254
|
+
"""Terminate the whole resource group before releasing ownership."""
|
|
255
|
+
self.dead = True
|
|
256
|
+
await self.backend.kill_group(self.instance_id)
|
|
257
|
+
await asyncio.wait_for(self.process.wait(), 10)
|
|
258
|
+
await self.diagnostics
|
|
259
|
+
if self.process.stdin is not None:
|
|
260
|
+
self.process.stdin.close()
|
|
261
|
+
|
|
262
|
+
async def write_file(self, path: str, content: str | bytes, *, working_directory: str) -> None:
|
|
263
|
+
"""Write a regular file through no-follow guest descriptors."""
|
|
264
|
+
data = content.encode("utf-8") if isinstance(content, str) else content
|
|
265
|
+
if len(data) > FILE_LIMIT:
|
|
266
|
+
raise SandboxTransferCapExceeded("File exceeds transfer limit")
|
|
267
|
+
await self._request(
|
|
268
|
+
"write", path=path, directory=working_directory, data=base64.b64encode(data).decode()
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def exec(
|
|
272
|
+
self,
|
|
273
|
+
command: str | Sequence[str],
|
|
274
|
+
*,
|
|
275
|
+
working_directory: str,
|
|
276
|
+
timeout: float,
|
|
277
|
+
) -> ExecResult:
|
|
278
|
+
"""Execute within the configured deadline and cap combined stdout/stderr bytes."""
|
|
279
|
+
if type(timeout) not in (int, float) or not 0 < timeout < math.inf:
|
|
280
|
+
raise ValueError("timeout must be finite and positive")
|
|
281
|
+
if timeout > self.backend.config.max_timeout:
|
|
282
|
+
raise ValueError("timeout exceeds configured max_timeout")
|
|
283
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
284
|
+
result = await self._request(
|
|
285
|
+
"exec",
|
|
286
|
+
command=command if isinstance(command, str) else list(command),
|
|
287
|
+
directory=working_directory,
|
|
288
|
+
timeout=timeout,
|
|
289
|
+
transport_timeout=timeout,
|
|
290
|
+
deadline=deadline,
|
|
291
|
+
output_limit=self.backend.config.output_bytes,
|
|
292
|
+
)
|
|
293
|
+
stdout = base64.b64decode(result["stdout"], validate=True)
|
|
294
|
+
stderr = base64.b64decode(result["stderr"], validate=True)
|
|
295
|
+
if (
|
|
296
|
+
len(stdout) + len(stderr) > self.backend.config.output_bytes
|
|
297
|
+
or type(result["exit_code"]) is not int
|
|
298
|
+
):
|
|
299
|
+
raise RuntimeError("Invalid guest execution result")
|
|
300
|
+
return ExecResult(stdout_bytes=stdout, stderr_bytes=stderr, exit_code=result["exit_code"])
|
|
301
|
+
|
|
302
|
+
async def stat_file(self, path: str, *, working_directory: str) -> SandboxEntry | None:
|
|
303
|
+
"""Report a guest entry; the isolated broker is not a host filesystem attestation."""
|
|
304
|
+
try:
|
|
305
|
+
result = await self._request("stat", path=path, directory=working_directory)
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
return None
|
|
308
|
+
if result.get("missing") is True:
|
|
309
|
+
return None
|
|
310
|
+
size = result["size"]
|
|
311
|
+
if size is not None and (type(size) is not int or size < 0):
|
|
312
|
+
raise RuntimeError("Invalid guest file size")
|
|
313
|
+
return SandboxEntry(path, EntryKind(result["kind"]), size)
|
|
314
|
+
|
|
315
|
+
async def read_file(self, path: str, *, working_directory: str, max_bytes: int) -> bytes:
|
|
316
|
+
"""Read a bounded regular file without following links."""
|
|
317
|
+
if type(max_bytes) is not int or max_bytes < 0:
|
|
318
|
+
raise ValueError("max_bytes must be a nonnegative integer")
|
|
319
|
+
limit = min(max_bytes, FILE_LIMIT)
|
|
320
|
+
result = await self._request(
|
|
321
|
+
"read", path=path, directory=working_directory, max_bytes=limit
|
|
322
|
+
)
|
|
323
|
+
data = base64.b64decode(result["data"], validate=True)
|
|
324
|
+
if len(data) > limit:
|
|
325
|
+
raise SandboxTransferCapExceeded("File exceeds transfer limit")
|
|
326
|
+
return data
|
|
327
|
+
|
|
328
|
+
async def run_code(self, code: str, *, timeout: float) -> ExecResult:
|
|
329
|
+
"""RUN_CODE is not supported; use EXEC with an installed interpreter."""
|
|
330
|
+
raise NotImplementedError
|
|
331
|
+
|
|
332
|
+
async def remove(self, path: str, *, working_directory: str, recursive: bool = False) -> None:
|
|
333
|
+
"""FILES_DELETE is not declared."""
|
|
334
|
+
raise NotImplementedError
|
|
335
|
+
|
|
336
|
+
async def reclaim(self, directory: str, *, working_directory: str, timeout: float) -> None:
|
|
337
|
+
"""Use disposal; no weaker cleanup capability is declared."""
|
|
338
|
+
raise NotImplementedError
|
|
339
|
+
|
|
340
|
+
async def list_dir(self, path: str, *, working_directory: str) -> tuple[SandboxEntry, ...]:
|
|
341
|
+
"""FILES_LIST is not declared."""
|
|
342
|
+
raise NotImplementedError
|
|
343
|
+
|
|
344
|
+
async def reset(self, *, timeout: float) -> None:
|
|
345
|
+
"""SNAPSHOT is not declared."""
|
|
346
|
+
raise NotImplementedError
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class BubblewrapSandboxBackend:
|
|
350
|
+
"""A Linux backend requiring working namespaces and delegated cgroup v2 controllers.
|
|
351
|
+
|
|
352
|
+
Construct through create() to probe the actual boundary. Guest file metadata is broker
|
|
353
|
+
reported; no host file plane exists. Call admission is exclusive within one router, and
|
|
354
|
+
a host flock refuses simultaneous owners across backend objects or processes.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
name = "bubblewrap"
|
|
358
|
+
isolation = Isolation.CONTAINER
|
|
359
|
+
declarations = BackendDeclarations(
|
|
360
|
+
capabilities=_CAPABILITIES,
|
|
361
|
+
egress_modes=frozenset({Egress.CLOSED}),
|
|
362
|
+
os_families=frozenset({OsFamily.POSIX}),
|
|
363
|
+
isolation_scopes=frozenset({IsolationScope.CALL, IsolationScope.CONVERSATION}),
|
|
364
|
+
requires_exclusive_admission=True,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def __init__(self, config: BubblewrapSandboxConfig) -> None:
|
|
368
|
+
if sys.platform != "linux":
|
|
369
|
+
raise RuntimeError("Bubblewrap requires Linux; no subprocess fallback is available")
|
|
370
|
+
self.config = config
|
|
371
|
+
self._sandboxes: dict[str, _Sandbox] = {}
|
|
372
|
+
self._serial = asyncio.Lock()
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
async def create(cls, config: BubblewrapSandboxConfig) -> BubblewrapSandboxBackend:
|
|
376
|
+
"""Refuse missing runtime, resource controllers or namespace prerequisites."""
|
|
377
|
+
backend = cls(config)
|
|
378
|
+
runtime = config.runtime_root.resolve()
|
|
379
|
+
for name in ("state_root", "cgroup_root"):
|
|
380
|
+
if getattr(config, name).resolve().is_relative_to(runtime):
|
|
381
|
+
raise ValueError(f"{name} must be outside runtime_root")
|
|
382
|
+
for path in (config.runtime_root, config.cgroup_root, config.bwrap):
|
|
383
|
+
if path.is_symlink() or not path.exists():
|
|
384
|
+
raise ValueError(f"Missing or symlinked prerequisite: {path}")
|
|
385
|
+
config.state_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
386
|
+
info = config.state_root.lstat()
|
|
387
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
388
|
+
raise ValueError("state_root must be a private, owned directory")
|
|
389
|
+
if config.output_bytes > FILE_LIMIT:
|
|
390
|
+
raise ValueError("output_bytes must not exceed 8 MiB")
|
|
391
|
+
key = SandboxKey("probe-" + uuid4().hex, "probe", "probe")
|
|
392
|
+
sandbox = await backend.acquire(key, SandboxSpec(kind="probe", requires=_CAPABILITIES))
|
|
393
|
+
try:
|
|
394
|
+
result = await sandbox.exec(
|
|
395
|
+
":", working_directory=".", timeout=min(10, config.max_timeout)
|
|
396
|
+
)
|
|
397
|
+
if result.exit_code != 0:
|
|
398
|
+
raise RuntimeError("Namespace probe failed")
|
|
399
|
+
finally:
|
|
400
|
+
failure = await backend.dispose(key)
|
|
401
|
+
if failure is not None:
|
|
402
|
+
raise RuntimeError(str(failure))
|
|
403
|
+
return backend
|
|
404
|
+
|
|
405
|
+
def _group(self, instance: str) -> Path:
|
|
406
|
+
if len(instance) != 32 or any(char not in "0123456789abcdef" for char in instance):
|
|
407
|
+
raise ValueError("Invalid sandbox instance identity")
|
|
408
|
+
return self.config.cgroup_root / ("maf-" + instance)
|
|
409
|
+
|
|
410
|
+
async def kill_group(self, instance: str) -> None:
|
|
411
|
+
"""Kill an owned resource group and wait for its last process to leave."""
|
|
412
|
+
group = self._group(instance)
|
|
413
|
+
if not group.exists():
|
|
414
|
+
return
|
|
415
|
+
if not (group / "cgroup.kill").exists():
|
|
416
|
+
group.rmdir()
|
|
417
|
+
return
|
|
418
|
+
fd = os.open(group / "cgroup.kill", os.O_WRONLY | os.O_NOFOLLOW)
|
|
419
|
+
try:
|
|
420
|
+
os.write(fd, b"1")
|
|
421
|
+
finally:
|
|
422
|
+
os.close(fd)
|
|
423
|
+
async with asyncio.timeout(10):
|
|
424
|
+
while "populated 1" in (group / "cgroup.events").read_text("ascii"):
|
|
425
|
+
await asyncio.sleep(0.02)
|
|
426
|
+
group.rmdir()
|
|
427
|
+
|
|
428
|
+
async def acquire(self, key: SandboxKey, spec: SandboxSpec) -> _Sandbox:
|
|
429
|
+
"""Get or create an exact key/kind; refuse conflicting specifications or owners."""
|
|
430
|
+
if (
|
|
431
|
+
spec.egress != Egress.CLOSED
|
|
432
|
+
or not spec.required_capabilities <= _CAPABILITIES
|
|
433
|
+
or spec.work_dir not in (None, "/maf-sandbox/work")
|
|
434
|
+
or spec.image not in (None, self.config.runtime_id)
|
|
435
|
+
or spec.image_id is not None
|
|
436
|
+
):
|
|
437
|
+
raise ValueError("Unsupported Bubblewrap specification")
|
|
438
|
+
identity = _identity(key, spec.kind)
|
|
439
|
+
if len(json.dumps(identity).encode()) > 8192:
|
|
440
|
+
raise ValueError("Sandbox identity exceeds record limit")
|
|
441
|
+
digest = _digest(identity)
|
|
442
|
+
async with self._serial:
|
|
443
|
+
existing = self._sandboxes.get(digest)
|
|
444
|
+
if existing is not None:
|
|
445
|
+
if existing.dead or existing.spec != spec:
|
|
446
|
+
raise RuntimeError("Dispose the prior sandbox before replacing it")
|
|
447
|
+
return existing
|
|
448
|
+
record = self.config.state_root / (digest + ".json")
|
|
449
|
+
lock_fd = _lock(record.with_suffix(".lock"))
|
|
450
|
+
instance = uuid4().hex
|
|
451
|
+
group = self._group(instance)
|
|
452
|
+
process: asyncio.subprocess.Process | None = None
|
|
453
|
+
diagnostics: asyncio.Task[str] | None = None
|
|
454
|
+
try:
|
|
455
|
+
if record.exists():
|
|
456
|
+
previous = self._read_record(record)
|
|
457
|
+
if previous["identity"] != identity:
|
|
458
|
+
raise ValueError("Sandbox record identity mismatch")
|
|
459
|
+
await self.kill_group(previous["instance"])
|
|
460
|
+
record.unlink()
|
|
461
|
+
_write_record(
|
|
462
|
+
record,
|
|
463
|
+
{
|
|
464
|
+
"identity": identity,
|
|
465
|
+
"instance": instance,
|
|
466
|
+
"cgroup_root": str(self.config.cgroup_root),
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
group.mkdir(mode=0o700)
|
|
470
|
+
for name, value in (
|
|
471
|
+
("memory.max", str(self.config.memory_bytes)),
|
|
472
|
+
("memory.swap.max", "0"),
|
|
473
|
+
("memory.oom.group", "1"),
|
|
474
|
+
("pids.max", str(self.config.pids)),
|
|
475
|
+
("cpu.max", f"{self.config.cpu_quota} 100000"),
|
|
476
|
+
):
|
|
477
|
+
with (group / name).open("r+", encoding="ascii") as limit_file:
|
|
478
|
+
limit_file.write(value)
|
|
479
|
+
if not (group / "cgroup.kill").exists():
|
|
480
|
+
raise RuntimeError("cgroup v2 kill support is required")
|
|
481
|
+
process = await asyncio.create_subprocess_exec(
|
|
482
|
+
sys.executable,
|
|
483
|
+
"-I",
|
|
484
|
+
str(Path(__file__).with_name("_launch.py")),
|
|
485
|
+
str(os.getpid()),
|
|
486
|
+
str(group),
|
|
487
|
+
*_arguments(self.config),
|
|
488
|
+
stdin=asyncio.subprocess.PIPE,
|
|
489
|
+
stdout=asyncio.subprocess.PIPE,
|
|
490
|
+
stderr=asyncio.subprocess.PIPE,
|
|
491
|
+
limit=FRAME_LIMIT,
|
|
492
|
+
env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
|
|
493
|
+
)
|
|
494
|
+
assert process.stdout is not None
|
|
495
|
+
assert process.stderr is not None
|
|
496
|
+
diagnostics = asyncio.create_task(_diagnostics(process.stderr))
|
|
497
|
+
async with asyncio.timeout(10):
|
|
498
|
+
if await process.stdout.readline() != b'{"ready":1}\n':
|
|
499
|
+
detail = await diagnostics
|
|
500
|
+
raise RuntimeError("Bubblewrap startup failed: " + detail)
|
|
501
|
+
sandbox = _Sandbox(self, instance, process, lock_fd, record, spec, diagnostics)
|
|
502
|
+
self._sandboxes[digest] = sandbox
|
|
503
|
+
return sandbox
|
|
504
|
+
except BaseException:
|
|
505
|
+
|
|
506
|
+
async def cleanup() -> None:
|
|
507
|
+
await self.kill_group(instance)
|
|
508
|
+
if process is not None:
|
|
509
|
+
await asyncio.wait_for(process.wait(), 10)
|
|
510
|
+
if diagnostics is not None:
|
|
511
|
+
await diagnostics
|
|
512
|
+
if record.exists():
|
|
513
|
+
owned = self._read_record(record)
|
|
514
|
+
if owned["instance"] == instance:
|
|
515
|
+
record.unlink()
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
await _finish(asyncio.create_task(cleanup()))
|
|
519
|
+
finally:
|
|
520
|
+
os.close(lock_fd)
|
|
521
|
+
raise
|
|
522
|
+
|
|
523
|
+
def _read_record(self, path: Path) -> dict[str, Any]:
|
|
524
|
+
if path.is_symlink() or path.stat().st_size > 16384:
|
|
525
|
+
raise ValueError("Invalid sandbox record")
|
|
526
|
+
value: dict[str, Any] = json.loads(path.read_text("utf-8"))
|
|
527
|
+
if type(value["identity"]) is not list:
|
|
528
|
+
raise ValueError("Invalid sandbox record identity")
|
|
529
|
+
identity = cast(list[object], value["identity"])
|
|
530
|
+
if (
|
|
531
|
+
len(identity) != 5
|
|
532
|
+
or not all(isinstance(item, str) for item in identity)
|
|
533
|
+
or _digest(cast(list[str], identity)) != path.stem
|
|
534
|
+
or value["cgroup_root"] != str(self.config.cgroup_root)
|
|
535
|
+
):
|
|
536
|
+
raise ValueError("Sandbox record ownership mismatch")
|
|
537
|
+
self._group(value["instance"])
|
|
538
|
+
return value
|
|
539
|
+
|
|
540
|
+
async def _dispose_record(self, record: Path, instance_id: str | None) -> bool:
|
|
541
|
+
local = self._sandboxes.get(record.stem)
|
|
542
|
+
lock_fd = local.lock_fd if local is not None else _lock(record.with_suffix(".lock"))
|
|
543
|
+
release = local is None
|
|
544
|
+
try:
|
|
545
|
+
if not record.exists():
|
|
546
|
+
return False
|
|
547
|
+
value = self._read_record(record)
|
|
548
|
+
if instance_id is not None and value["instance"] != instance_id:
|
|
549
|
+
return False
|
|
550
|
+
if local is not None:
|
|
551
|
+
await local.stop()
|
|
552
|
+
self._sandboxes.pop(record.stem)
|
|
553
|
+
release = True
|
|
554
|
+
else:
|
|
555
|
+
await self.kill_group(value["instance"])
|
|
556
|
+
record.unlink()
|
|
557
|
+
return True
|
|
558
|
+
finally:
|
|
559
|
+
if release:
|
|
560
|
+
os.close(lock_fd)
|
|
561
|
+
|
|
562
|
+
async def dispose(
|
|
563
|
+
self,
|
|
564
|
+
key: SandboxKey,
|
|
565
|
+
*,
|
|
566
|
+
kind: str | None = None,
|
|
567
|
+
instance_id: str | None = None,
|
|
568
|
+
) -> DisposalFailure | None:
|
|
569
|
+
"""Remove only matching owned records; a stale instance never targets its replacement."""
|
|
570
|
+
failures: list[DisposalFailure] = []
|
|
571
|
+
async with self._serial:
|
|
572
|
+
try:
|
|
573
|
+
for record in self.config.state_root.glob("*.json"):
|
|
574
|
+
try:
|
|
575
|
+
value = self._read_record(record)
|
|
576
|
+
if value["identity"][:4] == _identity(key, "")[:4] and (
|
|
577
|
+
kind is None or value["identity"][4] == kind
|
|
578
|
+
):
|
|
579
|
+
await self._dispose_record(record, instance_id)
|
|
580
|
+
except Exception as error:
|
|
581
|
+
failures.append(DisposalFailure("refused", str(error)))
|
|
582
|
+
except Exception as error:
|
|
583
|
+
failures.append(DisposalFailure("refused", str(error)))
|
|
584
|
+
return fold_disposal_failures(failures)
|
|
585
|
+
|
|
586
|
+
async def dispose_scope(self, scope: str, thread_id: str) -> ScopePurge:
|
|
587
|
+
"""Sweep persisted records, retaining failures and refusing another live owner's lock."""
|
|
588
|
+
failures: list[DisposalFailure] = []
|
|
589
|
+
disposed = 0
|
|
590
|
+
async with self._serial:
|
|
591
|
+
for record in self.config.state_root.glob("*.json"):
|
|
592
|
+
try:
|
|
593
|
+
value = self._read_record(record)
|
|
594
|
+
if value["identity"][:2] == [scope, thread_id]:
|
|
595
|
+
disposed += await self._dispose_record(record, None)
|
|
596
|
+
except Exception as error:
|
|
597
|
+
failures.append(DisposalFailure("refused", str(error)))
|
|
598
|
+
return ScopePurge(disposed, fold_disposal_failures(failures))
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
if TYPE_CHECKING:
|
|
602
|
+
_: tuple[SandboxBackend, type[Sandbox]] = (
|
|
603
|
+
BubblewrapSandboxBackend(
|
|
604
|
+
BubblewrapSandboxConfig(Path("/runtime"), Path("/state"), Path("/cgroup"))
|
|
605
|
+
),
|
|
606
|
+
_Sandbox,
|
|
607
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Host-owned runtime and delegation settings."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class BubblewrapSandboxConfig:
|
|
9
|
+
"""Use a trusted Linux root directory and a private delegated cgroup v2 subtree.
|
|
10
|
+
|
|
11
|
+
Runtime and control-directory ancestors must not be writable by untrusted principals.
|
|
12
|
+
State and cgroup roots must stay outside the runtime mounted into the guest. Provision the
|
|
13
|
+
runtime separately; no archive extraction, package download or engine fallback occurs here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
runtime_root: Path
|
|
17
|
+
state_root: Path
|
|
18
|
+
cgroup_root: Path
|
|
19
|
+
runtime_id: str = "native"
|
|
20
|
+
bwrap: Path = Path("/usr/bin/bwrap")
|
|
21
|
+
memory_bytes: int = 1024 * 1024 * 1024
|
|
22
|
+
pids: int = 256
|
|
23
|
+
cpu_quota: int = 200000
|
|
24
|
+
workspace_bytes: int = 128 * 1024 * 1024
|
|
25
|
+
output_bytes: int = 1024 * 1024
|
|
26
|
+
max_timeout: float = 180
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
for name in ("runtime_root", "state_root", "cgroup_root", "bwrap"):
|
|
30
|
+
path = getattr(self, name)
|
|
31
|
+
if not isinstance(path, Path) or not (
|
|
32
|
+
path.is_absolute() or (name == "bwrap" and path.as_posix().startswith("/"))
|
|
33
|
+
):
|
|
34
|
+
raise ValueError(f"{name} must be an absolute Path")
|
|
35
|
+
for name in ("memory_bytes", "pids", "cpu_quota", "workspace_bytes", "output_bytes"):
|
|
36
|
+
value = getattr(self, name)
|
|
37
|
+
if type(value) is not int or value <= 0:
|
|
38
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
39
|
+
if type(self.max_timeout) not in (int, float) or not 0 < self.max_timeout <= 3600:
|
|
40
|
+
raise ValueError("max_timeout must be positive and at most 3600 seconds")
|
|
41
|
+
if not self.runtime_id or "\x00" in self.runtime_id:
|
|
42
|
+
raise ValueError("runtime_id must be nonempty")
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Private PID-namespace broker; runs under the same filesystem authority as guest commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import ctypes
|
|
7
|
+
import errno
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import posixpath
|
|
11
|
+
import selectors
|
|
12
|
+
import signal
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
FILE_LIMIT = 8 * 1024 * 1024
|
|
21
|
+
FRAME_LIMIT = 12 * 1024 * 1024
|
|
22
|
+
BASE = "/maf-sandbox/work"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransferCapExceeded(ValueError):
|
|
26
|
+
"""Identify file transfer overflow separately from path confinement refusals."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parts(path: str, directory: str) -> tuple[str, ...]:
|
|
30
|
+
"""Normalize within two confinement boundaries, without following filesystem links."""
|
|
31
|
+
|
|
32
|
+
def confined(value: str, root: str) -> str:
|
|
33
|
+
candidate = posixpath.normpath(posixpath.join(root, value))
|
|
34
|
+
prefix = root if root.endswith("/") else root + "/"
|
|
35
|
+
if candidate != root and not candidate.startswith(prefix):
|
|
36
|
+
raise ValueError("Path escapes its boundary")
|
|
37
|
+
return candidate
|
|
38
|
+
|
|
39
|
+
if any("\x00" in value or "\\" in value for value in (path, directory)):
|
|
40
|
+
raise ValueError("Invalid guest path")
|
|
41
|
+
work = (
|
|
42
|
+
posixpath.normpath(directory) if posixpath.isabs(directory) else confined(directory, BASE)
|
|
43
|
+
)
|
|
44
|
+
return PurePosixPath(confined(path, work)).parts[1:]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parent(path: str, directory: str, *, create: bool = False) -> tuple[int, str]:
|
|
48
|
+
"""Hold no-follow descriptors from the storage base to the operand's parent."""
|
|
49
|
+
components = parts(path, directory)
|
|
50
|
+
fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
51
|
+
try:
|
|
52
|
+
for name in components[:-1]:
|
|
53
|
+
if create:
|
|
54
|
+
try:
|
|
55
|
+
os.mkdir(name, mode=0o700, dir_fd=fd)
|
|
56
|
+
except FileExistsError:
|
|
57
|
+
# The no-follow open below still validates the existing component.
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
61
|
+
except OSError:
|
|
62
|
+
if stat.S_ISLNK(os.stat(name, dir_fd=fd, follow_symlinks=False).st_mode):
|
|
63
|
+
raise ValueError("A path component is a symlink") from None
|
|
64
|
+
raise
|
|
65
|
+
os.close(fd)
|
|
66
|
+
fd = child
|
|
67
|
+
return fd, components[-1] if components else "."
|
|
68
|
+
except BaseException:
|
|
69
|
+
os.close(fd)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def kill_children() -> None:
|
|
74
|
+
"""Kill every other process in this PID namespace, including detached descendants."""
|
|
75
|
+
try:
|
|
76
|
+
os.kill(-1, signal.SIGKILL)
|
|
77
|
+
except ProcessLookupError:
|
|
78
|
+
# Children may exit before namespace cleanup sends the signal.
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def execute(request: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
"""Drain both streams with bounded storage and remove all descendants before replying."""
|
|
84
|
+
directory = request["directory"]
|
|
85
|
+
fd, leaf = parent(".", directory)
|
|
86
|
+
try:
|
|
87
|
+
cwd = os.open(leaf, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
88
|
+
finally:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
command = request["command"]
|
|
91
|
+
if isinstance(command, str):
|
|
92
|
+
command = ["/bin/sh", "-c", command]
|
|
93
|
+
streams = [bytearray(), bytearray()]
|
|
94
|
+
deadline = time.monotonic() + request["timeout"]
|
|
95
|
+
try:
|
|
96
|
+
with subprocess.Popen(
|
|
97
|
+
command,
|
|
98
|
+
cwd=f"/proc/self/fd/{cwd}",
|
|
99
|
+
pass_fds=(cwd,),
|
|
100
|
+
stdin=subprocess.DEVNULL,
|
|
101
|
+
stdout=subprocess.PIPE,
|
|
102
|
+
stderr=subprocess.PIPE,
|
|
103
|
+
) as process:
|
|
104
|
+
assert process.stdout is not None and process.stderr is not None
|
|
105
|
+
with selectors.DefaultSelector() as selector:
|
|
106
|
+
selector.register(process.stdout, selectors.EVENT_READ, 0)
|
|
107
|
+
selector.register(process.stderr, selectors.EVENT_READ, 1)
|
|
108
|
+
try:
|
|
109
|
+
while selector.get_map():
|
|
110
|
+
if time.monotonic() >= deadline:
|
|
111
|
+
raise TimeoutError("Guest command exceeded deadline")
|
|
112
|
+
if process.poll() is not None:
|
|
113
|
+
kill_children()
|
|
114
|
+
for key, _ in selector.select(0.02):
|
|
115
|
+
data = os.read(key.fd, 65536)
|
|
116
|
+
if not data:
|
|
117
|
+
selector.unregister(key.fd)
|
|
118
|
+
else:
|
|
119
|
+
streams[int(key.data)].extend(data)
|
|
120
|
+
if sum(map(len, streams)) > request["output_limit"]:
|
|
121
|
+
raise ValueError("Guest command exceeded output limit")
|
|
122
|
+
while process.poll() is None:
|
|
123
|
+
if time.monotonic() >= deadline:
|
|
124
|
+
raise TimeoutError("Guest command exceeded deadline")
|
|
125
|
+
time.sleep(0.01)
|
|
126
|
+
finally:
|
|
127
|
+
kill_children()
|
|
128
|
+
return {
|
|
129
|
+
"exit_code": process.wait(),
|
|
130
|
+
"stdout": base64.b64encode(streams[0]).decode(),
|
|
131
|
+
"stderr": base64.b64encode(streams[1]).decode(),
|
|
132
|
+
}
|
|
133
|
+
finally:
|
|
134
|
+
os.close(cwd)
|
|
135
|
+
kill_children()
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
os.waitpid(-1, 0)
|
|
139
|
+
except ChildProcessError:
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def dispatch(request: dict[str, Any]) -> dict[str, Any]:
|
|
144
|
+
"""Serve only bounded file operations and command execution."""
|
|
145
|
+
operation = request["op"]
|
|
146
|
+
if operation == "exec":
|
|
147
|
+
try:
|
|
148
|
+
return execute(request)
|
|
149
|
+
except FileNotFoundError as error:
|
|
150
|
+
return {
|
|
151
|
+
"exit_code": 127,
|
|
152
|
+
"stdout": "",
|
|
153
|
+
"stderr": base64.b64encode(str(error).encode()).decode(),
|
|
154
|
+
}
|
|
155
|
+
if operation == "write" and parts(request["path"], request["directory"]) == parts(
|
|
156
|
+
".", request["directory"]
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("Cannot replace the working directory")
|
|
159
|
+
fd, leaf = parent(request["path"], request["directory"], create=operation == "write")
|
|
160
|
+
try:
|
|
161
|
+
if operation == "stat":
|
|
162
|
+
try:
|
|
163
|
+
info = os.stat(leaf, dir_fd=fd, follow_symlinks=False)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
return {"missing": True}
|
|
166
|
+
kind = (
|
|
167
|
+
"file"
|
|
168
|
+
if stat.S_ISREG(info.st_mode)
|
|
169
|
+
else "directory"
|
|
170
|
+
if stat.S_ISDIR(info.st_mode)
|
|
171
|
+
else "symlink"
|
|
172
|
+
if stat.S_ISLNK(info.st_mode)
|
|
173
|
+
else "other"
|
|
174
|
+
)
|
|
175
|
+
return {"kind": kind, "size": info.st_size if kind == "file" else None}
|
|
176
|
+
flags = os.O_NOFOLLOW | os.O_NONBLOCK
|
|
177
|
+
flags |= os.O_WRONLY | os.O_CREAT if operation == "write" else os.O_RDONLY
|
|
178
|
+
with os.fdopen(
|
|
179
|
+
os.open(leaf, flags, 0o600, dir_fd=fd), "wb" if operation == "write" else "rb"
|
|
180
|
+
) as file:
|
|
181
|
+
if not stat.S_ISREG(os.fstat(file.fileno()).st_mode):
|
|
182
|
+
raise OSError("Only regular files may cross the boundary")
|
|
183
|
+
if operation == "write":
|
|
184
|
+
data = base64.b64decode(request["data"], validate=True)
|
|
185
|
+
if len(data) > FILE_LIMIT:
|
|
186
|
+
raise TransferCapExceeded("File exceeds transfer limit")
|
|
187
|
+
file.truncate(0)
|
|
188
|
+
file.write(data)
|
|
189
|
+
return {}
|
|
190
|
+
if operation != "read":
|
|
191
|
+
raise ValueError("Unknown operation")
|
|
192
|
+
limit = min(request["max_bytes"], FILE_LIMIT)
|
|
193
|
+
data = file.read(limit + 1)
|
|
194
|
+
if len(data) > limit:
|
|
195
|
+
raise TransferCapExceeded("File exceeds transfer limit")
|
|
196
|
+
return {"data": base64.b64encode(data).decode()}
|
|
197
|
+
except OSError as error:
|
|
198
|
+
if error.errno == errno.ELOOP and operation == "write":
|
|
199
|
+
raise ValueError("The file is a symlink") from None
|
|
200
|
+
raise
|
|
201
|
+
finally:
|
|
202
|
+
os.close(fd)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main() -> None:
|
|
206
|
+
"""Keep control pipes private from guest commands and serve framed requests."""
|
|
207
|
+
if os.getpid() != 1:
|
|
208
|
+
raise RuntimeError("Broker requires a private PID namespace")
|
|
209
|
+
if ctypes.CDLL(None).prctl(4, 0, 0, 0, 0) != 0:
|
|
210
|
+
raise RuntimeError("Could not protect broker process descriptors")
|
|
211
|
+
os.makedirs(BASE, mode=0o700, exist_ok=True)
|
|
212
|
+
print('{"ready":1}', flush=True)
|
|
213
|
+
while True:
|
|
214
|
+
frame = sys.stdin.buffer.readline(FRAME_LIMIT + 1)
|
|
215
|
+
if not frame:
|
|
216
|
+
return
|
|
217
|
+
if len(frame) > FRAME_LIMIT or not frame.endswith(b"\n"):
|
|
218
|
+
raise ValueError("Invalid request frame")
|
|
219
|
+
request = json.loads(frame)
|
|
220
|
+
try:
|
|
221
|
+
result = dispatch(request)
|
|
222
|
+
response = {"id": request["id"], "result": result}
|
|
223
|
+
except (OSError, ValueError) as error:
|
|
224
|
+
response = {
|
|
225
|
+
"id": request["id"],
|
|
226
|
+
"error": type(error).__name__,
|
|
227
|
+
"detail": str(error)[:1024],
|
|
228
|
+
}
|
|
229
|
+
print(json.dumps(response, separators=(",", ":")), flush=True)
|
|
230
|
+
while True:
|
|
231
|
+
try:
|
|
232
|
+
pid, _ = os.waitpid(-1, os.WNOHANG)
|
|
233
|
+
if pid == 0:
|
|
234
|
+
break
|
|
235
|
+
except ChildProcessError:
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Enter the preconfigured resource group before starting any guest process."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""Bind lifetime to the owner, join its cgroup and replace this helper with Bubblewrap."""
|
|
11
|
+
owner, group, *command = sys.argv[1:]
|
|
12
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
13
|
+
if libc.prctl(1, 9, 0, 0, 0) != 0 or os.getppid() != int(owner):
|
|
14
|
+
raise RuntimeError("Could not establish owner-death supervision")
|
|
15
|
+
(Path(group) / "cgroup.procs").write_text(str(os.getpid()), encoding="ascii")
|
|
16
|
+
os.execv(command[0], command)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
File without changes
|