autoforge-cli 0.1.0a0__py3-none-any.whl
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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
"""The Linux backend of the outer sandbox: Bubblewrap (ADR 0005 D13.4, #167).
|
|
2
|
+
|
|
3
|
+
The only module that knows a ``bwrap`` flag. :func:`prepare` turns a
|
|
4
|
+
:class:`~autoforge.sandbox.LaunchDescription` and a command into a
|
|
5
|
+
:class:`BwrapLaunch` that the executor spawns in place of the command
|
|
6
|
+
(``executor.py``, ``executor_duplex.py``); it never spawns the command
|
|
7
|
+
itself, and it creates no process owner: ``bwrap`` is the executor's
|
|
8
|
+
child for exactly one invocation, in a session of its own as any command
|
|
9
|
+
is, and ADR 0002's group kill, exit grace and containment apply to it
|
|
10
|
+
unchanged (D10.2).
|
|
11
|
+
|
|
12
|
+
What every launch gets, whatever the description says:
|
|
13
|
+
|
|
14
|
+
- user, mount, PID and IPC namespaces (``--unshare-user``,
|
|
15
|
+
``--unshare-pid``, ``--unshare-ipc``; D10.1), a new procfs and the
|
|
16
|
+
minimal ``/dev``; ``--unshare-net`` only for a description that asks for
|
|
17
|
+
an isolated network, never by default (D9.1);
|
|
18
|
+
- an empty tmpfs root (``bwrap``'s own) with exactly the description's
|
|
19
|
+
mounts, in its order, and every mask made read-only after its carve-outs
|
|
20
|
+
are in (``--remount-ro`` last; ADR 0005 §1.2);
|
|
21
|
+
- ``--clearenv`` followed by exactly the computed environment (the
|
|
22
|
+
executor's selection with the description's overrides), handed over in a
|
|
23
|
+
memory file through ``--args`` with every other option, so no value
|
|
24
|
+
appears on a command line any process can read (``/proc/<pid>/cmdline``);
|
|
25
|
+
- ``--die-with-parent``: ``PR_SET_PDEATHSIG``, which fires when the
|
|
26
|
+
*thread* that spawned ``bwrap`` exits, so a sandboxed launch is spawned
|
|
27
|
+
only from a thread that outlives it (D10.6);
|
|
28
|
+
- in the launch's own process, before ``bwrap`` runs and never in the
|
|
29
|
+
controller: a new anonymous session keyring (D10.1) and a Landlock domain
|
|
30
|
+
scoped to abstract Unix sockets (``LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET``,
|
|
31
|
+
Landlock ABI 6; D9.1), so a host abstract socket (an X server, a session
|
|
32
|
+
bus) cannot be connected from inside;
|
|
33
|
+
- ``--json-status-fd``: whether the command ran is read from its status
|
|
34
|
+
records after the fact (D10.8, :meth:`BwrapLaunch.outcome`);
|
|
35
|
+
- never ``--disable-userns`` (D11.1), ``--new-session`` (D10.2) or
|
|
36
|
+
``--as-pid-1``.
|
|
37
|
+
|
|
38
|
+
A launch that cannot be established is a
|
|
39
|
+
:class:`~autoforge.errors.SandboxLaunchRefusedError`, never a run without
|
|
40
|
+
the sandbox: a platform that is not Linux, no ``bwrap`` on the controller's
|
|
41
|
+
``PATH``, a kernel without the Landlock scope, an architecture whose
|
|
42
|
+
keyring call is unknown (D14.2), and, after the spawn, a setup failure that
|
|
43
|
+
``bwrap`` reported (a bind source gone, an executable the sandbox cannot
|
|
44
|
+
run, a namespace refused).
|
|
45
|
+
|
|
46
|
+
Teardown inside the namespace is the kernel's: when the command exits, or
|
|
47
|
+
``bwrap`` is killed, everything still inside is SIGKILLed at once, so inner
|
|
48
|
+
processes get no exit grace and no kill grace, and they are never counted
|
|
49
|
+
(D10.3-D10.5, ADR 0002 §4e).
|
|
50
|
+
|
|
51
|
+
:func:`probe` is the minimal launch that proves a host can enforce
|
|
52
|
+
(D14.1's shape) and names the :class:`Capability` a host lacks; its
|
|
53
|
+
operator-facing uses, the engine's pre-launch gate and ``doctor``, are
|
|
54
|
+
#169's (D13.5).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import ctypes
|
|
60
|
+
import enum
|
|
61
|
+
import errno
|
|
62
|
+
import json
|
|
63
|
+
import mmap
|
|
64
|
+
import os
|
|
65
|
+
import platform
|
|
66
|
+
import shutil
|
|
67
|
+
import sys
|
|
68
|
+
import tempfile
|
|
69
|
+
import threading
|
|
70
|
+
from collections.abc import Callable, Mapping
|
|
71
|
+
from dataclasses import dataclass
|
|
72
|
+
from typing import NoReturn
|
|
73
|
+
|
|
74
|
+
from .errors import SandboxLaunchRefusedError, SandboxPolicyError
|
|
75
|
+
from .redaction import redact
|
|
76
|
+
from .sandbox import (
|
|
77
|
+
TEARDOWN_UNCOUNTED,
|
|
78
|
+
Access,
|
|
79
|
+
Bind,
|
|
80
|
+
Dir,
|
|
81
|
+
LaunchDescription,
|
|
82
|
+
Link,
|
|
83
|
+
Mask,
|
|
84
|
+
NetworkPosition,
|
|
85
|
+
Origin,
|
|
86
|
+
Overlay,
|
|
87
|
+
OverlayRequest,
|
|
88
|
+
PrivateTmpfs,
|
|
89
|
+
ProjectionRequest,
|
|
90
|
+
SandboxFacts,
|
|
91
|
+
SandboxRequest,
|
|
92
|
+
SysPolicy,
|
|
93
|
+
compile_launch,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
BACKEND = "bubblewrap"
|
|
97
|
+
EXECUTABLE = "bwrap"
|
|
98
|
+
|
|
99
|
+
# Landlock (linux/landlock.h). The syscall numbers are the same on every
|
|
100
|
+
# architecture that has the generic table, which is every one Linux added
|
|
101
|
+
# them to after 5.13.
|
|
102
|
+
_SYS_LANDLOCK_CREATE_RULESET = 444
|
|
103
|
+
_SYS_LANDLOCK_RESTRICT_SELF = 446
|
|
104
|
+
_LANDLOCK_CREATE_RULESET_VERSION = 1
|
|
105
|
+
_LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET = 1
|
|
106
|
+
# The first ABI with the abstract-socket scope (Linux 6.12).
|
|
107
|
+
_LANDLOCK_MIN_ABI = 6
|
|
108
|
+
# keyctl(2): its number is per architecture.
|
|
109
|
+
_SYS_KEYCTL = {
|
|
110
|
+
"x86_64": 250,
|
|
111
|
+
"amd64": 250,
|
|
112
|
+
"aarch64": 219,
|
|
113
|
+
"arm64": 219,
|
|
114
|
+
"riscv64": 219,
|
|
115
|
+
"loongarch64": 219,
|
|
116
|
+
"ppc64le": 271,
|
|
117
|
+
"ppc64": 271,
|
|
118
|
+
"s390x": 280,
|
|
119
|
+
}
|
|
120
|
+
_KEYCTL_JOIN_SESSION_KEYRING = 1
|
|
121
|
+
_PR_SET_NO_NEW_PRIVS = 38
|
|
122
|
+
# The steps of :meth:`_ProcessScope.apply`, in order: the call, and what the
|
|
123
|
+
# launch's process could not do when it failed.
|
|
124
|
+
_SCOPE_STEPS = (
|
|
125
|
+
("keyctl(KEYCTL_JOIN_SESSION_KEYRING)", "join a new session keyring"),
|
|
126
|
+
("prctl(PR_SET_NO_NEW_PRIVS)", "set no_new_privs, which the Landlock scope requires"),
|
|
127
|
+
("landlock_create_ruleset", "create its Landlock abstract-socket scope"),
|
|
128
|
+
("landlock_restrict_self", "restrict itself to the Landlock abstract-socket scope"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Bounds on what is read back from ``bwrap``.
|
|
132
|
+
_MAX_STATUS_BYTES = 64 * 1024
|
|
133
|
+
_MAX_REASON_CHARS = 500
|
|
134
|
+
_PROBE_TIMEOUT_SECONDS = 30
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _LandlockAttr(ctypes.Structure):
|
|
138
|
+
_fields_ = [
|
|
139
|
+
("handled_access_fs", ctypes.c_uint64),
|
|
140
|
+
("handled_access_net", ctypes.c_uint64),
|
|
141
|
+
("scoped", ctypes.c_uint64),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _refused(reason: str, facts: SandboxFacts | None = None) -> SandboxLaunchRefusedError:
|
|
146
|
+
return SandboxLaunchRefusedError(f"the sandbox cannot be established: {reason}", facts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class _BackendInfo:
|
|
151
|
+
version: str
|
|
152
|
+
has_size: bool
|
|
153
|
+
has_perms: bool
|
|
154
|
+
has_overlay: bool
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_INFO_LOCK = threading.Lock()
|
|
158
|
+
_INFO: dict[tuple[str, int, int], _BackendInfo] = {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _backend_info(path: str) -> _BackendInfo:
|
|
162
|
+
"""``bwrap``'s version and the options this build has, once per binary.
|
|
163
|
+
|
|
164
|
+
Read with ``--version`` and ``--help`` through the executor, uncontained
|
|
165
|
+
and outside any sandbox, before the invocation's own containment begins.
|
|
166
|
+
Cached for the controller process, keyed by the binary's identity, so a
|
|
167
|
+
replaced binary is read again.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
st = os.stat(path)
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise _refused(f"{path} cannot be read ({exc.strerror})") from None
|
|
173
|
+
key = (path, st.st_ino, st.st_mtime_ns)
|
|
174
|
+
with _INFO_LOCK:
|
|
175
|
+
cached = _INFO.get(key)
|
|
176
|
+
if cached is not None:
|
|
177
|
+
return cached
|
|
178
|
+
from . import executor # the executor imports this module
|
|
179
|
+
|
|
180
|
+
def run(flag: str) -> str:
|
|
181
|
+
res = executor.execute(
|
|
182
|
+
executor.ExecutionRequest(
|
|
183
|
+
command=[path, flag], timeout_seconds=10, env_allowlist=(), max_output_bytes=65536
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
if not res.ok:
|
|
187
|
+
raise _refused(f"{path} {flag} failed (exit {res.exit_code})")
|
|
188
|
+
return res.stdout
|
|
189
|
+
|
|
190
|
+
version_text = run("--version").strip()
|
|
191
|
+
help_text = run("--help")
|
|
192
|
+
version = version_text.split()[-1] if version_text else ""
|
|
193
|
+
info = _BackendInfo(
|
|
194
|
+
version=version[:64],
|
|
195
|
+
has_size="--size" in help_text,
|
|
196
|
+
has_perms="--perms" in help_text,
|
|
197
|
+
has_overlay="--overlay" in help_text,
|
|
198
|
+
)
|
|
199
|
+
with _INFO_LOCK:
|
|
200
|
+
_INFO[key] = info
|
|
201
|
+
return info
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _landlock_abi(syscall: Callable[..., int]) -> int:
|
|
205
|
+
"""The kernel's Landlock ABI version; 0 when Landlock is absent or disabled."""
|
|
206
|
+
abi = syscall(
|
|
207
|
+
ctypes.c_long(_SYS_LANDLOCK_CREATE_RULESET),
|
|
208
|
+
ctypes.c_void_p(None),
|
|
209
|
+
ctypes.c_size_t(0),
|
|
210
|
+
ctypes.c_uint32(_LANDLOCK_CREATE_RULESET_VERSION),
|
|
211
|
+
)
|
|
212
|
+
return max(int(abi), 0)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _ProcessScope:
|
|
216
|
+
"""What the launch's own process does between fork and exec (D9.1, D10.1).
|
|
217
|
+
|
|
218
|
+
Everything it calls is resolved before the fork, so the child runs only
|
|
219
|
+
foreign calls on prepared arguments: join a new anonymous session
|
|
220
|
+
keyring, set ``no_new_privs`` (Landlock's precondition, and what
|
|
221
|
+
``bwrap`` sets for the command anyway), and restrict itself to a
|
|
222
|
+
Landlock domain that handles no filesystem or network access and only
|
|
223
|
+
scopes abstract Unix sockets. ``bwrap`` and the command inherit the
|
|
224
|
+
domain and the keyring; the controller keeps neither. A failing step
|
|
225
|
+
raises, which fails the spawn (:meth:`BwrapLaunch.spawn_error`).
|
|
226
|
+
|
|
227
|
+
Python replaces an exception raised between fork and exec with a
|
|
228
|
+
generic ``SubprocessError`` ("Exception occurred in preexec_fn."),
|
|
229
|
+
errno included, so the step that failed and its errno are also written
|
|
230
|
+
to a shared anonymous mapping, which the forked child and the
|
|
231
|
+
controller both see: :meth:`failure` reads them back after the spawn.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self) -> None:
|
|
235
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
236
|
+
self._syscall = libc.syscall
|
|
237
|
+
self._syscall.restype = ctypes.c_long
|
|
238
|
+
self._prctl = libc.prctl
|
|
239
|
+
machine = platform.machine().lower()
|
|
240
|
+
keyctl = _SYS_KEYCTL.get(machine)
|
|
241
|
+
if keyctl is None:
|
|
242
|
+
raise _refused(
|
|
243
|
+
f"the session keyring call is not known for this architecture ({machine})"
|
|
244
|
+
)
|
|
245
|
+
abi = _landlock_abi(self._syscall)
|
|
246
|
+
if abi < _LANDLOCK_MIN_ABI:
|
|
247
|
+
raise _refused(
|
|
248
|
+
f"the kernel's Landlock ABI is {abi or 'unavailable'}; the abstract-socket scope "
|
|
249
|
+
f"needs ABI {_LANDLOCK_MIN_ABI} (Linux 6.12) with Landlock enabled"
|
|
250
|
+
)
|
|
251
|
+
# Every argument is built here, so the forked child allocates as
|
|
252
|
+
# little as it can: other threads of the controller may hold the
|
|
253
|
+
# allocator's locks at the fork.
|
|
254
|
+
self._attr = _LandlockAttr(0, 0, _LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET)
|
|
255
|
+
self._join = (
|
|
256
|
+
ctypes.c_long(keyctl),
|
|
257
|
+
ctypes.c_long(_KEYCTL_JOIN_SESSION_KEYRING),
|
|
258
|
+
ctypes.c_void_p(None),
|
|
259
|
+
)
|
|
260
|
+
self._no_new_privs = (
|
|
261
|
+
ctypes.c_int(_PR_SET_NO_NEW_PRIVS),
|
|
262
|
+
ctypes.c_ulong(1),
|
|
263
|
+
ctypes.c_ulong(0),
|
|
264
|
+
ctypes.c_ulong(0),
|
|
265
|
+
ctypes.c_ulong(0),
|
|
266
|
+
)
|
|
267
|
+
self._create = (
|
|
268
|
+
ctypes.c_long(_SYS_LANDLOCK_CREATE_RULESET),
|
|
269
|
+
ctypes.byref(self._attr),
|
|
270
|
+
ctypes.c_size_t(ctypes.sizeof(self._attr)),
|
|
271
|
+
ctypes.c_uint32(0),
|
|
272
|
+
)
|
|
273
|
+
self._restrict = ctypes.c_long(_SYS_LANDLOCK_RESTRICT_SELF)
|
|
274
|
+
self._no_flags = ctypes.c_uint32(0)
|
|
275
|
+
# MAP_SHARED and anonymous: byte 0 is the failed step's number plus
|
|
276
|
+
# one (0: none failed), byte 1 its errno, capped at 255.
|
|
277
|
+
self._record = mmap.mmap(-1, 2)
|
|
278
|
+
|
|
279
|
+
def apply(self) -> None:
|
|
280
|
+
"""Runs in the forked child, before ``bwrap`` is executed."""
|
|
281
|
+
if self._syscall(*self._join) < 0:
|
|
282
|
+
self._fail(0)
|
|
283
|
+
if self._prctl(*self._no_new_privs) != 0:
|
|
284
|
+
self._fail(1)
|
|
285
|
+
fd = self._syscall(*self._create)
|
|
286
|
+
if fd < 0:
|
|
287
|
+
self._fail(2)
|
|
288
|
+
try:
|
|
289
|
+
if self._syscall(self._restrict, ctypes.c_long(fd), self._no_flags) != 0:
|
|
290
|
+
self._fail(3)
|
|
291
|
+
finally:
|
|
292
|
+
os.close(fd)
|
|
293
|
+
|
|
294
|
+
def _fail(self, step: int) -> NoReturn:
|
|
295
|
+
"""Record ``step`` and its errno for the controller, then fail the spawn."""
|
|
296
|
+
code = ctypes.get_errno()
|
|
297
|
+
self._record[1] = min(max(code, 0), 255)
|
|
298
|
+
self._record[0] = step + 1
|
|
299
|
+
raise OSError(code, _SCOPE_STEPS[step][0])
|
|
300
|
+
|
|
301
|
+
def failure(self) -> str:
|
|
302
|
+
"""The step that failed in the child, by its call and errno; "" when none is recorded."""
|
|
303
|
+
if self._record.closed or not 0 < self._record[0] <= len(_SCOPE_STEPS):
|
|
304
|
+
return ""
|
|
305
|
+
call, purpose = _SCOPE_STEPS[self._record[0] - 1]
|
|
306
|
+
code = self._record[1]
|
|
307
|
+
why = f"{errno.errorcode.get(code, code)}: {os.strerror(code)}" if code else "no errno"
|
|
308
|
+
return f"could not {purpose}: {call} failed ({why})"
|
|
309
|
+
|
|
310
|
+
def close(self) -> None:
|
|
311
|
+
"""Release the shared record. Idempotent."""
|
|
312
|
+
self._record.close()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _options(
|
|
316
|
+
description: LaunchDescription,
|
|
317
|
+
info: _BackendInfo,
|
|
318
|
+
*,
|
|
319
|
+
status_fd: int,
|
|
320
|
+
cwd: str | None,
|
|
321
|
+
env: Mapping[str, str],
|
|
322
|
+
) -> list[str]:
|
|
323
|
+
"""The ``bwrap`` options for ``description``, in order."""
|
|
324
|
+
opts = ["--unshare-user", "--unshare-pid", "--unshare-ipc"]
|
|
325
|
+
if description.network is NetworkPosition.ISOLATED:
|
|
326
|
+
opts.append("--unshare-net")
|
|
327
|
+
opts += ["--die-with-parent", "--json-status-fd", str(status_fd)]
|
|
328
|
+
opts += ["--proc", "/proc", "--dev", "/dev"]
|
|
329
|
+
if description.sys is SysPolicy.READ_ONLY:
|
|
330
|
+
opts += ["--ro-bind", "/sys", "/sys"]
|
|
331
|
+
masks: list[str] = []
|
|
332
|
+
for mount in description.mounts:
|
|
333
|
+
if isinstance(mount, Bind):
|
|
334
|
+
flag = "--ro-bind" if mount.access is Access.READ_ONLY else "--bind"
|
|
335
|
+
opts += [flag, mount.source, mount.target]
|
|
336
|
+
elif isinstance(mount, Mask):
|
|
337
|
+
if info.has_perms:
|
|
338
|
+
opts += ["--perms", "0755"]
|
|
339
|
+
opts += ["--tmpfs", mount.target]
|
|
340
|
+
masks.append(mount.target)
|
|
341
|
+
elif isinstance(mount, PrivateTmpfs):
|
|
342
|
+
if info.has_perms:
|
|
343
|
+
opts += ["--perms", f"{mount.mode:04o}"]
|
|
344
|
+
if mount.size_bytes is not None and info.has_size:
|
|
345
|
+
opts += ["--size", str(mount.size_bytes)]
|
|
346
|
+
opts += ["--tmpfs", mount.target]
|
|
347
|
+
elif isinstance(mount, Overlay):
|
|
348
|
+
opts += ["--overlay-src", mount.lower, "--overlay", mount.upper, mount.work]
|
|
349
|
+
opts.append(mount.target)
|
|
350
|
+
elif isinstance(mount, Dir):
|
|
351
|
+
opts += ["--dir", mount.target]
|
|
352
|
+
elif isinstance(mount, Link):
|
|
353
|
+
opts += ["--symlink", mount.points_to, mount.target]
|
|
354
|
+
for target in masks:
|
|
355
|
+
opts += ["--remount-ro", target]
|
|
356
|
+
if cwd is not None:
|
|
357
|
+
opts += ["--chdir", cwd]
|
|
358
|
+
opts.append("--clearenv")
|
|
359
|
+
for name, value in env.items():
|
|
360
|
+
opts += ["--setenv", name, value]
|
|
361
|
+
return opts
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _environment(description: LaunchDescription, env: Mapping[str, str]) -> dict[str, str]:
|
|
365
|
+
"""The command's environment: ``env`` with the description's overrides."""
|
|
366
|
+
out = {name: value for name, value in env.items() if name not in description.env_unset}
|
|
367
|
+
out.update(description.env_set)
|
|
368
|
+
return out
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _facts(
|
|
372
|
+
description: LaunchDescription,
|
|
373
|
+
info: _BackendInfo | None,
|
|
374
|
+
*,
|
|
375
|
+
enforced: bool,
|
|
376
|
+
setup_failure: str = "",
|
|
377
|
+
) -> SandboxFacts:
|
|
378
|
+
bounds = tuple(
|
|
379
|
+
(path, size if info is not None and info.has_size else None)
|
|
380
|
+
for path, size in description.tmpfs_bounds()
|
|
381
|
+
)
|
|
382
|
+
return SandboxFacts(
|
|
383
|
+
enforced=enforced,
|
|
384
|
+
backend=BACKEND,
|
|
385
|
+
backend_version="" if info is None else info.version,
|
|
386
|
+
teardown=TEARDOWN_UNCOUNTED if enforced else "",
|
|
387
|
+
network=description.network.value,
|
|
388
|
+
tmpfs=bounds,
|
|
389
|
+
unset_env=description.env_unset,
|
|
390
|
+
setup_failure=setup_failure,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class BwrapLaunch:
|
|
395
|
+
"""One prepared sandboxed launch: the argv to spawn and what to read back.
|
|
396
|
+
|
|
397
|
+
Made by :func:`prepare`; the executor spawns :attr:`argv` with
|
|
398
|
+
:attr:`spawn_env`, :attr:`pass_fds` and :attr:`preexec_fn`, calls
|
|
399
|
+
:meth:`spawned` once the spawn returned or failed, :meth:`collect` once
|
|
400
|
+
``bwrap`` has been reaped, :meth:`outcome` to classify the end, and
|
|
401
|
+
:meth:`close` in every case.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
description: LaunchDescription,
|
|
407
|
+
info: _BackendInfo,
|
|
408
|
+
executable: str,
|
|
409
|
+
command: list[str],
|
|
410
|
+
*,
|
|
411
|
+
cwd: str | None,
|
|
412
|
+
env: Mapping[str, str],
|
|
413
|
+
) -> None:
|
|
414
|
+
self.description = description
|
|
415
|
+
self._info = info
|
|
416
|
+
self._scope = _ProcessScope()
|
|
417
|
+
self._status_r, self._status_w = os.pipe()
|
|
418
|
+
self._args_fd = -1
|
|
419
|
+
try:
|
|
420
|
+
options = _options(
|
|
421
|
+
description,
|
|
422
|
+
info,
|
|
423
|
+
status_fd=self._status_w,
|
|
424
|
+
cwd=cwd,
|
|
425
|
+
env=_environment(description, env),
|
|
426
|
+
)
|
|
427
|
+
data = b"".join(os.fsencode(option) + b"\0" for option in options)
|
|
428
|
+
self._args_fd = os.memfd_create("autoforge-bwrap-args", os.MFD_CLOEXEC)
|
|
429
|
+
view = memoryview(data)
|
|
430
|
+
while view:
|
|
431
|
+
view = view[os.write(self._args_fd, view) :]
|
|
432
|
+
os.lseek(self._args_fd, 0, os.SEEK_SET)
|
|
433
|
+
except BaseException:
|
|
434
|
+
self.close()
|
|
435
|
+
raise
|
|
436
|
+
self.argv = [executable, "--args", str(self._args_fd), "--", *command]
|
|
437
|
+
# ``bwrap``'s own environment: nothing; the command's is in the options.
|
|
438
|
+
self.spawn_env: dict[str, str] = {}
|
|
439
|
+
self.pass_fds = (self._args_fd, self._status_w)
|
|
440
|
+
self.preexec_fn: Callable[[], None] = self._scope.apply
|
|
441
|
+
self._status = b""
|
|
442
|
+
self._collected = False
|
|
443
|
+
self._closed = False
|
|
444
|
+
|
|
445
|
+
def spawned(self) -> None:
|
|
446
|
+
"""Drop the controller's copies of what only ``bwrap`` holds. Idempotent."""
|
|
447
|
+
for name in ("_args_fd", "_status_w"):
|
|
448
|
+
fd = getattr(self, name)
|
|
449
|
+
if fd >= 0:
|
|
450
|
+
os.close(fd)
|
|
451
|
+
setattr(self, name, -1)
|
|
452
|
+
|
|
453
|
+
def spawn_error(self, exc: BaseException) -> SandboxLaunchRefusedError:
|
|
454
|
+
"""The refusal for a failure of the launch's own process before ``bwrap`` ran.
|
|
455
|
+
|
|
456
|
+
It names the step that failed, as the process recorded it
|
|
457
|
+
(:meth:`_ProcessScope.failure`): ``exc`` is Python's generic error
|
|
458
|
+
for any failure between fork and exec. A failure the process did not
|
|
459
|
+
record names neither step, so :func:`classify` cannot tell which
|
|
460
|
+
capability is missing; the launch is refused all the same.
|
|
461
|
+
"""
|
|
462
|
+
failed = self._scope.failure()
|
|
463
|
+
if not failed:
|
|
464
|
+
failed = (
|
|
465
|
+
"could not join a new session keyring or restrict itself to the Landlock "
|
|
466
|
+
f"abstract-socket scope ({exc})"
|
|
467
|
+
)
|
|
468
|
+
return _refused(
|
|
469
|
+
f"the launch's process {failed}",
|
|
470
|
+
_facts(self.description, self._info, enforced=False, setup_failure=failed),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def collect(self) -> None:
|
|
474
|
+
"""Read the status records ``bwrap`` wrote, without waiting. Idempotent.
|
|
475
|
+
|
|
476
|
+
Called once ``bwrap`` has been reaped, so its records are in the
|
|
477
|
+
pipe; nothing inside holds the pipe (``bwrap`` does not pass it on,
|
|
478
|
+
and the namespace is gone), and the read is non-blocking and bounded
|
|
479
|
+
anyway.
|
|
480
|
+
"""
|
|
481
|
+
if self._collected or self._status_r < 0:
|
|
482
|
+
return
|
|
483
|
+
self._collected = True
|
|
484
|
+
os.set_blocking(self._status_r, False)
|
|
485
|
+
chunks: list[bytes] = []
|
|
486
|
+
size = 0
|
|
487
|
+
while size < _MAX_STATUS_BYTES:
|
|
488
|
+
try:
|
|
489
|
+
chunk = os.read(self._status_r, _MAX_STATUS_BYTES - size)
|
|
490
|
+
except BlockingIOError:
|
|
491
|
+
break
|
|
492
|
+
if not chunk:
|
|
493
|
+
break
|
|
494
|
+
chunks.append(chunk)
|
|
495
|
+
size += len(chunk)
|
|
496
|
+
self._status = b"".join(chunks)
|
|
497
|
+
|
|
498
|
+
def _command_ran(self) -> bool:
|
|
499
|
+
"""Whether ``bwrap`` wrote an ``exit-code`` record: only then did the command run."""
|
|
500
|
+
for line in self._status.splitlines():
|
|
501
|
+
try:
|
|
502
|
+
record = json.loads(line)
|
|
503
|
+
except ValueError:
|
|
504
|
+
continue
|
|
505
|
+
if isinstance(record, dict) and "exit-code" in record:
|
|
506
|
+
return True
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
def outcome(
|
|
510
|
+
self, *, returncode: int | None, ended_by_executor: bool, stderr: str
|
|
511
|
+
) -> SandboxFacts:
|
|
512
|
+
"""Classify the end of the launch (D10.8); raise a refusal for a setup failure.
|
|
513
|
+
|
|
514
|
+
- The executor ended it (a limit, a stop, a kill before the exit):
|
|
515
|
+
the command's end, whatever the records say.
|
|
516
|
+
- ``bwrap`` was ended by a signal the executor did not send (the OOM
|
|
517
|
+
killer, a kill from outside, the command signalling its own
|
|
518
|
+
process group, which ``bwrap`` is in): the command's abnormal end,
|
|
519
|
+
never a refusal, because a killed ``bwrap`` leaves the same records
|
|
520
|
+
as a setup failure.
|
|
521
|
+
- ``bwrap`` exited by itself with an ``exit-code`` record: the
|
|
522
|
+
command ran; its status is ``bwrap``'s.
|
|
523
|
+
- ``bwrap`` exited by itself without one: a setup failure. The
|
|
524
|
+
command never ran, and the refusal carries ``bwrap``'s message,
|
|
525
|
+
bounded and redacted.
|
|
526
|
+
"""
|
|
527
|
+
self.collect()
|
|
528
|
+
if ended_by_executor or returncode is None or returncode < 0 or self._command_ran():
|
|
529
|
+
return _facts(self.description, self._info, enforced=True)
|
|
530
|
+
reason = _setup_reason(stderr, returncode)
|
|
531
|
+
raise _refused(
|
|
532
|
+
f"{BACKEND} failed to set the sandbox up: {reason}",
|
|
533
|
+
_facts(self.description, self._info, enforced=False, setup_failure=reason),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def close(self) -> None:
|
|
537
|
+
"""Release every descriptor, and the scope's record. Idempotent."""
|
|
538
|
+
self._closed = True
|
|
539
|
+
self._scope.close()
|
|
540
|
+
for name in ("_args_fd", "_status_w", "_status_r"):
|
|
541
|
+
fd = getattr(self, name, -1)
|
|
542
|
+
if fd >= 0:
|
|
543
|
+
try:
|
|
544
|
+
os.close(fd)
|
|
545
|
+
except OSError:
|
|
546
|
+
pass
|
|
547
|
+
setattr(self, name, -1)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _setup_reason(stderr: str, returncode: int) -> str:
|
|
551
|
+
"""``bwrap``'s own message about the failure: its last lines, bounded and redacted."""
|
|
552
|
+
lines = [line.strip() for line in stderr.strip().splitlines() if line.strip()]
|
|
553
|
+
text = " / ".join(lines[-3:]) if lines else f"exit status {returncode}, no message"
|
|
554
|
+
text = redact(text)
|
|
555
|
+
if len(text) > _MAX_REASON_CHARS:
|
|
556
|
+
text = text[: _MAX_REASON_CHARS - 1] + "…"
|
|
557
|
+
return text
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def prepare(
|
|
561
|
+
description: LaunchDescription,
|
|
562
|
+
command: list[str],
|
|
563
|
+
*,
|
|
564
|
+
cwd: str | None,
|
|
565
|
+
env: Mapping[str, str],
|
|
566
|
+
) -> BwrapLaunch:
|
|
567
|
+
"""Prepare ``command`` to run inside ``description``, or refuse (D14.2).
|
|
568
|
+
|
|
569
|
+
``env`` is the environment the executor selected for the command; the
|
|
570
|
+
description's overrides are applied over it and ``bwrap`` hands exactly
|
|
571
|
+
that to the command. Nothing is spawned except ``bwrap --version`` and
|
|
572
|
+
``bwrap --help``, once per binary per controller process.
|
|
573
|
+
"""
|
|
574
|
+
if not sys.platform.startswith("linux"):
|
|
575
|
+
raise _refused(f"the outer sandbox has no backend on {sys.platform}; only Linux has one")
|
|
576
|
+
executable = shutil.which(EXECUTABLE)
|
|
577
|
+
if executable is None:
|
|
578
|
+
raise _refused(f"{EXECUTABLE} was not found on the controller's PATH")
|
|
579
|
+
info = _backend_info(executable)
|
|
580
|
+
if any(isinstance(mount, Overlay) for mount in description.mounts) and not info.has_overlay:
|
|
581
|
+
raise _refused(f"{executable} {info.version} cannot mount an overlay")
|
|
582
|
+
return BwrapLaunch(description, info, executable, command, cwd=cwd, env=env)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ---------------------------------------------------------------- the probe
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class Capability(enum.Enum):
|
|
589
|
+
"""What a host must provide for a launch to be enforced (ADR 0005 D14.1).
|
|
590
|
+
|
|
591
|
+
The probe names the first one it finds missing; the value is how the
|
|
592
|
+
gate's refusal, ``doctor`` and the documentation name it.
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
PLATFORM = "a Linux kernel (the only platform with a backend)"
|
|
596
|
+
BACKEND = "bwrap (bubblewrap) on the controller's PATH"
|
|
597
|
+
LANDLOCK = "the Landlock abstract-socket scope (Landlock ABI 6, Linux 6.12 or later)"
|
|
598
|
+
SESSION_KEYRING = "a new session keyring"
|
|
599
|
+
USER_NAMESPACE = "an unprivileged user namespace"
|
|
600
|
+
PID_NAMESPACE = "a PID namespace"
|
|
601
|
+
IPC_NAMESPACE = "an IPC namespace"
|
|
602
|
+
PROCFS = "a new procfs"
|
|
603
|
+
BIND = "the sandbox's bind and tmpfs mounts"
|
|
604
|
+
OVERLAY = "an unprivileged overlay mount"
|
|
605
|
+
UNKNOWN = "a setup step the probe could not name"
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def key(self) -> str:
|
|
609
|
+
"""The machine-readable name: ``user_namespace``, ``procfs``, ..."""
|
|
610
|
+
return self.name.lower()
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# How a refusal's text names the capability it lacks, first match wins:
|
|
614
|
+
# this module's own refusals, then ``bwrap``'s messages (bubblewrap 0.12.0's
|
|
615
|
+
# strings; an older build words them the same way). A refusal of the
|
|
616
|
+
# launch's own process (:meth:`BwrapLaunch.spawn_error`) is matched by the
|
|
617
|
+
# call that failed, which the process recorded; one it did not record names
|
|
618
|
+
# no call and is ``UNKNOWN``.
|
|
619
|
+
_CLASSIFIERS: tuple[tuple[Capability, tuple[str, ...]], ...] = (
|
|
620
|
+
(Capability.PLATFORM, ("has no backend on",)),
|
|
621
|
+
(
|
|
622
|
+
Capability.BACKEND,
|
|
623
|
+
(
|
|
624
|
+
"was not found on the controller's path",
|
|
625
|
+
"cannot be read (",
|
|
626
|
+
"--version failed",
|
|
627
|
+
"--help failed",
|
|
628
|
+
),
|
|
629
|
+
),
|
|
630
|
+
(Capability.SESSION_KEYRING, ("keyctl", "session keyring call")),
|
|
631
|
+
(Capability.LANDLOCK, ("landlock abi", "landlock_", "pr_set_no_new_privs")),
|
|
632
|
+
(
|
|
633
|
+
Capability.OVERLAY,
|
|
634
|
+
("cannot mount an overlay", "overlay mount", "overlay workdir", "overlay source"),
|
|
635
|
+
),
|
|
636
|
+
(
|
|
637
|
+
Capability.USER_NAMESPACE,
|
|
638
|
+
(
|
|
639
|
+
"non-privileged user namespaces",
|
|
640
|
+
"does not support user namespaces",
|
|
641
|
+
"setting up uid map",
|
|
642
|
+
"setting up gid map",
|
|
643
|
+
"unprivileged_userns",
|
|
644
|
+
),
|
|
645
|
+
),
|
|
646
|
+
(Capability.PROCFS, ("mount proc", "creating proc", "mounting proc")),
|
|
647
|
+
(
|
|
648
|
+
Capability.BIND,
|
|
649
|
+
(
|
|
650
|
+
"bind mount",
|
|
651
|
+
"find source path",
|
|
652
|
+
"open source",
|
|
653
|
+
"type of source",
|
|
654
|
+
"remount readonly",
|
|
655
|
+
"mount tmpfs",
|
|
656
|
+
"mount devpts",
|
|
657
|
+
"mount mqueue",
|
|
658
|
+
"mkdir parents",
|
|
659
|
+
),
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
# ``bwrap`` creates the user, PID and IPC namespaces in one call and words a
|
|
663
|
+
# refusal of any of them the same way; :func:`_narrow_namespaces` tells them
|
|
664
|
+
# apart.
|
|
665
|
+
_NAMESPACE_FAILURE = "creating new namespace failed"
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def classify(reason: str) -> Capability:
|
|
669
|
+
"""The capability a refusal's text says is missing; ``UNKNOWN`` when none."""
|
|
670
|
+
text = reason.lower()
|
|
671
|
+
for capability, needles in _CLASSIFIERS:
|
|
672
|
+
if any(needle in text for needle in needles):
|
|
673
|
+
return capability
|
|
674
|
+
return Capability.UNKNOWN
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@dataclass(frozen=True)
|
|
678
|
+
class ProbeResult:
|
|
679
|
+
"""Whether this host can enforce a launch, and if not, which capability it lacks.
|
|
680
|
+
|
|
681
|
+
``capability`` is ``None`` exactly when ``supported``; ``reason`` is the
|
|
682
|
+
refusal, bounded and redacted as every refusal is; ``version`` the
|
|
683
|
+
backend's.
|
|
684
|
+
"""
|
|
685
|
+
|
|
686
|
+
supported: bool
|
|
687
|
+
reason: str = ""
|
|
688
|
+
version: str = ""
|
|
689
|
+
capability: Capability | None = None
|
|
690
|
+
|
|
691
|
+
def describe(self) -> str:
|
|
692
|
+
"""One line for the gate's refusal and ``doctor``; no value but the reason."""
|
|
693
|
+
if self.supported:
|
|
694
|
+
return f"supported ({BACKEND} {self.version or 'version unknown'})"
|
|
695
|
+
missing = (self.capability or Capability.UNKNOWN).value
|
|
696
|
+
return f"unsupported: this host cannot provide {missing} ({self.reason})"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _true() -> str:
|
|
700
|
+
return "/usr/bin/true" if os.path.exists("/usr/bin/true") else "/bin/true"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _narrow_namespaces(executable: str) -> Capability:
|
|
704
|
+
"""Which namespace a refusal ``bwrap`` words generically was about.
|
|
705
|
+
|
|
706
|
+
Each is tried on its own, in the order a launch needs them, with the
|
|
707
|
+
trivial command, the host root bound read-only and nothing else: the
|
|
708
|
+
first that ``bwrap`` refuses is named. Uncontained and outside any
|
|
709
|
+
sandbox, like ``--version``; nothing is written.
|
|
710
|
+
"""
|
|
711
|
+
from . import executor # the executor imports this module
|
|
712
|
+
|
|
713
|
+
steps = (
|
|
714
|
+
(Capability.USER_NAMESPACE, ["--unshare-user"]),
|
|
715
|
+
(Capability.PID_NAMESPACE, ["--unshare-user", "--unshare-pid"]),
|
|
716
|
+
(Capability.IPC_NAMESPACE, ["--unshare-user", "--unshare-pid", "--unshare-ipc"]),
|
|
717
|
+
)
|
|
718
|
+
for capability, flags in steps:
|
|
719
|
+
try:
|
|
720
|
+
result = executor.execute(
|
|
721
|
+
executor.ExecutionRequest(
|
|
722
|
+
command=[executable, *flags, "--ro-bind", "/", "/", _true()],
|
|
723
|
+
env_allowlist=(),
|
|
724
|
+
timeout_seconds=_PROBE_TIMEOUT_SECONDS,
|
|
725
|
+
max_output_bytes=65536,
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
except Exception:
|
|
729
|
+
return Capability.UNKNOWN
|
|
730
|
+
if result.timed_out or result.exit_code != 0:
|
|
731
|
+
return capability
|
|
732
|
+
return Capability.UNKNOWN
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _unsupported(reason: str) -> ProbeResult:
|
|
736
|
+
capability = classify(reason)
|
|
737
|
+
if capability is Capability.UNKNOWN and _NAMESPACE_FAILURE in reason.lower():
|
|
738
|
+
executable = shutil.which(EXECUTABLE)
|
|
739
|
+
if executable is not None:
|
|
740
|
+
capability = _narrow_namespaces(executable)
|
|
741
|
+
return ProbeResult(False, reason, "", capability)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def probe(home: str | None = None, environ: Mapping[str, str] | None = None) -> ProbeResult:
|
|
745
|
+
"""Run one trivial command under a description of a real launch's shape (D14.1).
|
|
746
|
+
|
|
747
|
+
User, PID, mount and IPC namespaces, a new procfs, a minimal ``/dev``, a
|
|
748
|
+
read-only bind, a mask with a carve-out, an overlay with a writable upper
|
|
749
|
+
directory, the Landlock scope, a new session keyring and the status
|
|
750
|
+
descriptor. Writes nothing outside a temporary directory it removes.
|
|
751
|
+
A refusal is typed: :attr:`ProbeResult.capability` names what the host
|
|
752
|
+
lacks, read from the refusal's text, and a namespace refusal ``bwrap``
|
|
753
|
+
words generically is narrowed by trying each namespace alone.
|
|
754
|
+
"""
|
|
755
|
+
from . import executor # the executor imports this module
|
|
756
|
+
|
|
757
|
+
environ = os.environ if environ is None else environ
|
|
758
|
+
home = home or environ.get("HOME", "")
|
|
759
|
+
true = _true()
|
|
760
|
+
with tempfile.TemporaryDirectory(prefix="autoforge-sandbox-probe-") as raw:
|
|
761
|
+
base = os.path.realpath(raw)
|
|
762
|
+
for rel in ("ro/masked/carved", "ro/lower", "upper", "work"):
|
|
763
|
+
os.makedirs(os.path.join(base, rel))
|
|
764
|
+
request = SandboxRequest(
|
|
765
|
+
home=home,
|
|
766
|
+
controller_env=environ,
|
|
767
|
+
projections=(
|
|
768
|
+
ProjectionRequest(os.path.join(base, "ro"), Access.READ_ONLY, Origin.ENGINE),
|
|
769
|
+
ProjectionRequest(
|
|
770
|
+
os.path.join(base, "ro/masked/carved"),
|
|
771
|
+
Access.WRITABLE,
|
|
772
|
+
Origin.ENGINE,
|
|
773
|
+
carve_out=True,
|
|
774
|
+
),
|
|
775
|
+
),
|
|
776
|
+
masks=(os.path.join(base, "ro/masked"),),
|
|
777
|
+
overlays=(
|
|
778
|
+
OverlayRequest(
|
|
779
|
+
os.path.join(base, "ro/lower"),
|
|
780
|
+
os.path.join(base, "upper"),
|
|
781
|
+
os.path.join(base, "work"),
|
|
782
|
+
),
|
|
783
|
+
),
|
|
784
|
+
tmp_size_bytes=1024 * 1024,
|
|
785
|
+
)
|
|
786
|
+
try:
|
|
787
|
+
description = compile_launch(request)
|
|
788
|
+
result = executor.execute(
|
|
789
|
+
executor.ExecutionRequest(
|
|
790
|
+
command=[true],
|
|
791
|
+
env_allowlist=(),
|
|
792
|
+
timeout_seconds=_PROBE_TIMEOUT_SECONDS,
|
|
793
|
+
sandbox=description,
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
except (SandboxPolicyError, SandboxLaunchRefusedError) as exc:
|
|
797
|
+
return _unsupported(str(exc))
|
|
798
|
+
finally:
|
|
799
|
+
_make_removable(os.path.join(base, "work"))
|
|
800
|
+
if result.exit_code != 0:
|
|
801
|
+
return ProbeResult(
|
|
802
|
+
False, f"the probe command exited {result.exit_code}", "", Capability.UNKNOWN
|
|
803
|
+
)
|
|
804
|
+
version = result.sandbox.backend_version if result.sandbox is not None else ""
|
|
805
|
+
return ProbeResult(True, "", version)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _make_removable(path: str) -> None:
|
|
809
|
+
"""overlayfs leaves its work directory's entries mode 000; let the cleanup in."""
|
|
810
|
+
for root, dirs, _files in os.walk(path):
|
|
811
|
+
for name in dirs:
|
|
812
|
+
try:
|
|
813
|
+
os.chmod(os.path.join(root, name), 0o700)
|
|
814
|
+
except OSError:
|
|
815
|
+
pass
|