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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. 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