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
autoforge/sandbox.py ADDED
@@ -0,0 +1,1313 @@
1
+ """Outer sandbox: the launch description and the policy compiler (ADR 0005, #167).
2
+
3
+ A *launch description* (:class:`LaunchDescription`) says what one launch can
4
+ reach: the host paths projected into it (writable or read-only, at their own
5
+ path or relocated into the private home), the masks over subpaths of a
6
+ read-only projection, the overlays, the private home and temp, the
7
+ environment overrides for path-valued names, the network position and the
8
+ ``/proc``, ``/dev`` and ``/sys`` policy. It is typed, immutable and
9
+ backend-neutral, and it knows nothing of a workflow, a provider or GitHub:
10
+ a projection is a path and an access mode, never "the worktree" or "the
11
+ read context". The Linux backend that enforces it is
12
+ :mod:`autoforge.sandbox_bwrap`; :mod:`autoforge.executor` and
13
+ :mod:`autoforge.executor_duplex` take one on a request.
14
+
15
+ The *policy compiler* (:func:`compile_launch`) turns the controller's facts
16
+ and the declared requirements, both plain data (:class:`SandboxRequest`),
17
+ into a description, applying ADR 0005's rules before anything is spawned:
18
+
19
+ - **D4.1** the root is empty; every visible path is a projection, a mask, an
20
+ overlay, a link, an empty directory or a private mount the description
21
+ names;
22
+ - **C1** every source is resolved with ``realpath`` on the host, and a
23
+ relative or missing source is refused;
24
+ - **C2** a controller-owned (:attr:`Origin.ENGINE`) source must resolve to
25
+ itself: a symbolic link in any component of it is refused;
26
+ - **C3** only directories and regular files are projected;
27
+ - **C4** a projection inside a denied root is refused unless it is an engine
28
+ carve-out; a denied root inside a read-only projection must be masked by
29
+ the engine; one inside a writable projection is masked when it is a
30
+ direct child of that projection and refused when it lies deeper;
31
+ - **C5** only the engine projects inside a mask (a carve-out);
32
+ - **C6** a declared read-only projection already visible through a
33
+ read-only engine projection is dropped and recorded; every other overlap
34
+ with a declared projection is refused; engine entries nest and are
35
+ ordered parents first;
36
+ - **C7** a projection appears at its host path unless its target lies in
37
+ the private home;
38
+ - **D4.3** the forbidden sources (the operator's home and its ancestors, the
39
+ GitHub and git credential and configuration locations, ``~/.ssh``,
40
+ ``~/.gnupg``, keyrings and password stores, ``/run`` and
41
+ ``$XDG_RUNTIME_DIR``) are refused for every projection, wherever it was
42
+ declared;
43
+ - **D7.2** a keyring item is refused, and so is a writable declared
44
+ directory on a provider's operator configuration root;
45
+ - **D13.1** a spelled path (a script's absolute ``#!`` interpreter, which
46
+ the kernel looks up as written) leads inside where it leads on the host:
47
+ each link on its way that nothing shows is recreated as a link, and each
48
+ directory it leaves by ``..`` that nothing makes is made empty, never by
49
+ projecting the directory that holds it, and never in a mask, a denied
50
+ root, a forbidden source or a kernel filesystem (#225, PR #232 R2-F1).
51
+
52
+ A refusal is a :class:`~autoforge.errors.SandboxPolicyError` naming the path
53
+ and the rule, never a file's contents. The compiler is pure except for
54
+ ``realpath``, ``lstat`` and ``readlink`` of the paths it is given and of the
55
+ fixed system paths (``/usr``, ``/etc``, ``/bin``, ``/lib``, ``/lib64``,
56
+ ``/sbin``, ``/etc/resolv.conf``); it launches nothing and writes nothing.
57
+ Which paths a launch class gets (ADR 0005 §2.5) is the engine's (#168).
58
+
59
+ :func:`check_declared_path` applies the rules that need no launch to an
60
+ operator-declared read-only path when the configuration is loaded (D15.4).
61
+
62
+ :class:`SandboxFacts` is what an enforced or refused launch records:
63
+ bounded, with no environment value. The controller never reads it (D16.1).
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import enum
69
+ import os
70
+ import posixpath
71
+ import re
72
+ import stat
73
+ from collections.abc import Mapping
74
+ from dataclasses import dataclass, field
75
+
76
+ from .errors import SandboxPolicyError
77
+
78
+ # Path-valued names the compiler owns: they follow the private home and temp
79
+ # (ADR 0005 D8.1, D8.2), so a caller cannot set them.
80
+ _XDG_DIRS = (
81
+ ("XDG_CONFIG_HOME", ".config"),
82
+ ("XDG_DATA_HOME", ".local/share"),
83
+ ("XDG_STATE_HOME", ".local/state"),
84
+ ("XDG_CACHE_HOME", ".cache"),
85
+ )
86
+ _OWNED_ENV = frozenset({"HOME", "TMPDIR", "XDG_RUNTIME_DIR", *(name for name, _ in _XDG_DIRS)})
87
+ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
88
+
89
+ PRIVATE_TMP = "/tmp"
90
+ # The kernel filesystems the backend mounts itself; no projection goes there.
91
+ _KERNEL_ROOTS = ("/proc", "/dev", "/sys")
92
+ # The host's system runtime (ADR 0005 §2.5): read-only, and the top-level
93
+ # entries in the host's own form (links recreated as links).
94
+ _SYSTEM_DIRS = ("/usr", "/etc")
95
+ _SYSTEM_TOP = ("/bin", "/sbin", "/lib", "/lib64")
96
+ _RESOLV_CONF = "/etc/resolv.conf"
97
+
98
+ # Bounds of what a fact may carry (D10.4).
99
+ _MAX_FACT_TEXT = 1000
100
+ _MAX_FACT_NAMES = 64
101
+ _MAX_FACT_NAME = 128
102
+
103
+
104
+ class Access(enum.Enum):
105
+ """How a projection is visible inside."""
106
+
107
+ READ_ONLY = "read-only"
108
+ WRITABLE = "writable"
109
+
110
+
111
+ class Origin(enum.Enum):
112
+ """Who asked for a projection (ADR 0005 C2, C5, C6)."""
113
+
114
+ # A controller fact (a workspace, an admin dir, a sandbox directory, the
115
+ # read context): it must resolve to itself, it may nest inside other
116
+ # engine projections, and only it can be a carve-out.
117
+ ENGINE = "engine"
118
+ # A provider declaration or an operator path: reached through links if
119
+ # need be, never overlapping anything but a read-only engine projection
120
+ # that already shows it.
121
+ DECLARED = "declared"
122
+
123
+
124
+ class NetworkPosition(enum.Enum):
125
+ """The launch's network (ADR 0005 D9.1, D9.2)."""
126
+
127
+ # The host's network namespace, with host abstract Unix sockets
128
+ # unreachable (the backend's Landlock scope). The default.
129
+ HOST = "host"
130
+ # A network namespace of its own: no loopback, no abstract socket, no
131
+ # egress. Only when a description asks for it.
132
+ ISOLATED = "isolated"
133
+
134
+
135
+ class ProcPolicy(enum.Enum):
136
+ """``/proc``: always a new procfs in the launch's own PID namespace (D10.1)."""
137
+
138
+ NEW = "new procfs"
139
+
140
+
141
+ class DevPolicy(enum.Enum):
142
+ """``/dev``: always the backend's minimal device set (D5.1)."""
143
+
144
+ MINIMAL = "minimal"
145
+
146
+
147
+ class SysPolicy(enum.Enum):
148
+ """``/sys``."""
149
+
150
+ READ_ONLY = "read-only"
151
+ ABSENT = "absent"
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Bind:
156
+ """A host path visible inside: ``source`` (resolved) at ``target``."""
157
+
158
+ source: str
159
+ target: str
160
+ access: Access
161
+ is_dir: bool
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class Mask:
166
+ """An empty directory over ``target``, read-only once its carve-outs are in."""
167
+
168
+ target: str
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class Overlay:
173
+ """``lower`` visible at ``target`` (its own path), writes landing in ``upper``."""
174
+
175
+ lower: str
176
+ upper: str
177
+ work: str
178
+ target: str
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class PrivateTmpfs:
183
+ """A private, empty tmpfs at ``target`` (the home or the temp), gone with the launch.
184
+
185
+ ``size_bytes`` is its bound; ``None`` leaves the kernel's default.
186
+ """
187
+
188
+ target: str
189
+ mode: int
190
+ size_bytes: int | None
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class Dir:
195
+ """An empty directory made at ``target``: nothing of the host's is in it."""
196
+
197
+ target: str
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Link:
202
+ """A symbolic link at ``target`` whose contents are ``points_to``."""
203
+
204
+ target: str
205
+ points_to: str
206
+
207
+
208
+ Mount = Bind | Mask | Overlay | PrivateTmpfs | Dir | Link
209
+
210
+
211
+ def _clean(path: str) -> bool:
212
+ """An absolute, normalized path with no NUL (``//x`` is not normalized here)."""
213
+ return (
214
+ path.startswith("/")
215
+ and not path.startswith("//")
216
+ and "\0" not in path
217
+ and posixpath.normpath(path) == path
218
+ )
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class LaunchDescription:
223
+ """The compiled, backend-neutral description of one sandboxed launch.
224
+
225
+ ``mounts`` are in the order they must be applied: parents before
226
+ children, a mask after its parent and before its carve-outs (masks are
227
+ made read-only after everything else). ``env_set`` and ``env_unset``
228
+ are applied over the launch's environment; nothing else from the
229
+ controller's environment reaches the command. ``covered`` names the
230
+ declared read-only sources that were already visible and so were not
231
+ projected again (C6). Built by :func:`compile_launch`; constructing one
232
+ by hand is for tests, and a malformed one is refused.
233
+ """
234
+
235
+ mounts: tuple[Mount, ...]
236
+ env_set: tuple[tuple[str, str], ...] = ()
237
+ env_unset: tuple[str, ...] = ()
238
+ network: NetworkPosition = NetworkPosition.HOST
239
+ proc: ProcPolicy = ProcPolicy.NEW
240
+ dev: DevPolicy = DevPolicy.MINIMAL
241
+ sys: SysPolicy = SysPolicy.READ_ONLY
242
+ covered: tuple[str, ...] = ()
243
+
244
+ def __post_init__(self) -> None:
245
+ targets: set[str] = set()
246
+ for mount in self.mounts:
247
+ paths = [mount.target]
248
+ if isinstance(mount, Bind):
249
+ paths.append(mount.source)
250
+ elif isinstance(mount, Overlay):
251
+ paths += [mount.lower, mount.upper, mount.work]
252
+ elif isinstance(mount, Link) and ("\0" in mount.points_to or not mount.points_to):
253
+ raise SandboxPolicyError(mount.target, "malformed description", "empty link")
254
+ for path in paths:
255
+ if not _clean(path):
256
+ raise SandboxPolicyError(
257
+ path, "malformed description", "not an absolute, normalized path"
258
+ )
259
+ if mount.target in targets:
260
+ raise SandboxPolicyError(mount.target, "malformed description", "mounted twice")
261
+ targets.add(mount.target)
262
+ names = [name for name, _ in self.env_set] + list(self.env_unset)
263
+ for name in names:
264
+ if not _ENV_NAME_RE.match(name):
265
+ raise SandboxPolicyError(
266
+ name, "malformed description", "not an environment variable name"
267
+ )
268
+ seen_names: set[str] = set()
269
+ for name in names:
270
+ if name in seen_names:
271
+ raise SandboxPolicyError(name, "malformed description", "a variable set twice")
272
+ seen_names.add(name)
273
+ for _, value in self.env_set:
274
+ if "\0" in value:
275
+ raise SandboxPolicyError("env", "malformed description", "a value holds NUL")
276
+
277
+ def tmpfs_bounds(self) -> tuple[tuple[str, int | None], ...]:
278
+ """The private mounts and their size bounds (``None``: the kernel's default)."""
279
+ return tuple(
280
+ (mount.target, mount.size_bytes)
281
+ for mount in self.mounts
282
+ if isinstance(mount, PrivateTmpfs)
283
+ )
284
+
285
+ def inside_path(self, host_path: str) -> str | None:
286
+ """Where the resolved host path ``host_path`` is visible inside, or ``None``.
287
+
288
+ Through the bind or overlay that shows it, unless a deeper entry (a
289
+ mask, a private mount, another bind) covers it there. For the
290
+ engine's environment rewrite (ADR 0005 D8.2): a path-valued variable
291
+ passes only when what it names is visible.
292
+ """
293
+ return _inside_path(list(self.mounts), host_path)
294
+
295
+ def resolve_inside(self, path: str) -> str | None:
296
+ """Where the absolute ``path``, as spelled, leads inside: ``realpath`` there.
297
+
298
+ Each component is looked up in the description as the kernel would
299
+ look it up inside: a recreated link is followed; below a bind or an
300
+ overlay, the host entry it shows is read (``lstat``, ``readlink``)
301
+ and a link there is followed inside; a mount target, or a directory
302
+ on the way to one, is a directory; anything else is absent. From the
303
+ first absent component on, the rest is kept as spelled, as
304
+ ``realpath`` does; ``None`` for a loop of links. For the engine's
305
+ environment rewrite (ADR 0005 D8.2): a value whose spelling does not
306
+ lead to :meth:`inside_path` of its host target is replaced by it.
307
+ """
308
+ entries = list(self.mounts)
309
+ pending = [part for part in path.split("/") if part]
310
+ current = "/"
311
+ followed = 0
312
+ while pending:
313
+ part = pending.pop(0)
314
+ if part == ".":
315
+ continue
316
+ if part == "..":
317
+ current = posixpath.dirname(current)
318
+ continue
319
+ candidate = posixpath.join(current, part)
320
+ exists, link = _lookup(entries, candidate)
321
+ if not exists:
322
+ return posixpath.normpath(posixpath.join(candidate, *pending))
323
+ if link is None:
324
+ current = candidate
325
+ continue
326
+ followed += 1
327
+ if followed > _MAX_LINKS:
328
+ return None
329
+ if link.startswith("/"):
330
+ current = "/"
331
+ pending = [p for p in link.split("/") if p] + pending
332
+ return current
333
+
334
+
335
+ # As many links as one lookup follows before it is a loop (the kernel's 40).
336
+ _MAX_LINKS = 40
337
+
338
+
339
+ def _inside_path(entries: list[Mount], host_path: str) -> str | None:
340
+ """:meth:`LaunchDescription.inside_path` over ``entries``."""
341
+ for mount in entries:
342
+ if isinstance(mount, Bind) and _inside(host_path, mount.source):
343
+ inner = _rebase(host_path, mount.source, mount.target)
344
+ elif isinstance(mount, Overlay) and _inside(host_path, mount.lower):
345
+ inner = host_path
346
+ else:
347
+ continue
348
+ if _innermost(entries, inner) is mount:
349
+ return inner
350
+ return None
351
+
352
+
353
+ def _lookup(entries: list[Mount], path: str) -> tuple[bool, str | None]:
354
+ """Whether ``path`` (its parent resolved inside) exists inside, and the link it is."""
355
+ covering = _innermost(entries, path)
356
+ if isinstance(covering, Link):
357
+ return (True, covering.points_to) if covering.target == path else (False, None)
358
+ if any(_inside(entry.target, path) for entry in entries):
359
+ # A mount target, or a directory the backend makes on the way to one:
360
+ # none of them is a link (C1, C2).
361
+ return True, None
362
+ if isinstance(covering, Bind | Overlay):
363
+ source = covering.source if isinstance(covering, Bind) else covering.lower
364
+ host = _rebase(path, covering.target, source)
365
+ try:
366
+ if stat.S_ISLNK(os.lstat(host).st_mode):
367
+ return True, os.readlink(host)
368
+ except OSError:
369
+ return False, None
370
+ return True, None
371
+ return False, None # in a mask, a private mount, an empty directory or the empty root
372
+
373
+
374
+ TEARDOWN_UNCOUNTED = "removed by namespace teardown, uncounted"
375
+
376
+
377
+ def _bounded(text: str, limit: int = _MAX_FACT_TEXT) -> str:
378
+ return text if len(text) <= limit else text[: limit - 1] + "…"
379
+
380
+
381
+ @dataclass(frozen=True)
382
+ class SandboxFacts:
383
+ """What one launch carrying a description records (ADR 0005 D10.4, D10.8).
384
+
385
+ ``enforced``: the backend established the description (the command ran
386
+ inside it, or was ended inside it); False for a refusal, which then says
387
+ why in ``setup_failure`` (bounded, redacted by its maker). ``teardown``
388
+ is what happened to processes left inside: removed with the namespace,
389
+ never counted (:data:`TEARDOWN_UNCOUNTED`), so a record never says
390
+ "none". ``tmpfs`` gives each private mount's bound, ``None`` for the
391
+ kernel's default; ``unset_env`` names the variables the description
392
+ unset, names only. Nothing here is an environment value, and nothing
393
+ here is read by the controller (D16.1).
394
+ """
395
+
396
+ enforced: bool
397
+ backend: str
398
+ backend_version: str = ""
399
+ teardown: str = ""
400
+ network: str = ""
401
+ tmpfs: tuple[tuple[str, int | None], ...] = ()
402
+ unset_env: tuple[str, ...] = ()
403
+ setup_failure: str = ""
404
+
405
+ def __post_init__(self) -> None:
406
+ names = tuple(_bounded(name, _MAX_FACT_NAME) for name in self.unset_env)
407
+ if len(names) > _MAX_FACT_NAMES:
408
+ names = names[:_MAX_FACT_NAMES] + (f"... {len(names) - _MAX_FACT_NAMES} more",)
409
+ object.__setattr__(self, "unset_env", names)
410
+ object.__setattr__(self, "backend_version", _bounded(self.backend_version, 64))
411
+ object.__setattr__(self, "setup_failure", _bounded(self.setup_failure))
412
+ object.__setattr__(
413
+ self, "tmpfs", tuple((_bounded(p, 512), s) for p, s in self.tmpfs[:_MAX_FACT_NAMES])
414
+ )
415
+
416
+ def record(self) -> dict[str, object]:
417
+ """The facts as the run log writes them (``execution.json``, ``events.jsonl``)."""
418
+ return {
419
+ "enforced": self.enforced,
420
+ "backend": self.backend,
421
+ "backend_version": self.backend_version,
422
+ "teardown": self.teardown,
423
+ "network": self.network,
424
+ "tmpfs": {
425
+ path: ("kernel default" if size is None else size) for path, size in self.tmpfs
426
+ },
427
+ "unset_env": list(self.unset_env),
428
+ "setup_failure": self.setup_failure,
429
+ }
430
+
431
+
432
+ # ---------------------------------------------------------------- the request
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class ProjectionRequest:
437
+ """One host path a launch should see.
438
+
439
+ ``target`` is the path inside; ``None`` is the resolved source's own
440
+ path, and anything else must lie in the private home (C7). Only an
441
+ :attr:`Origin.ENGINE` projection may be a ``carve_out``: one that lies
442
+ inside a mask or a denied root on purpose (C5). ``label`` names it in a
443
+ refusal.
444
+ """
445
+
446
+ source: str
447
+ access: Access
448
+ origin: Origin
449
+ target: str | None = None
450
+ carve_out: bool = False
451
+ label: str = ""
452
+
453
+
454
+ @dataclass(frozen=True)
455
+ class OverlayRequest:
456
+ """An engine overlay: ``lower`` visible at its own path, writes into ``upper``.
457
+
458
+ ``upper`` and ``work`` are controller-owned directories on one
459
+ filesystem; they are layers, never visible inside. ``carve_out``: they
460
+ may lie inside a denied root (the engine's sandbox directory).
461
+ """
462
+
463
+ lower: str
464
+ upper: str
465
+ work: str
466
+ carve_out: bool = False
467
+ label: str = ""
468
+
469
+
470
+ @dataclass(frozen=True)
471
+ class SandboxRequest:
472
+ """What the compiler is given: the controller's facts and the declared needs.
473
+
474
+ ``home`` is the operator's home as the controller's environment names it
475
+ (the private home is mounted there, D8.1); ``controller_env`` is the
476
+ controller's own environment, read for the locations D4.3 forbids
477
+ (``GH_CONFIG_DIR``, ``XDG_CONFIG_HOME``, ``XDG_RUNTIME_DIR``), never the
478
+ values a child is given. ``masks`` are engine masks, as host paths inside
479
+ a projection; ``denied_roots`` are the engine's (C4);
480
+ ``operator_config_roots`` are where providers load operator configuration
481
+ from (D7.2); ``keyring_items`` are declared keyring logins, all refused.
482
+ ``spelled_paths`` are absolute paths the launch names as written, not
483
+ resolved (a script's ``#!`` interpreter, which the kernel looks up so):
484
+ each must lead inside to where its host target is shown, the compiler
485
+ recreating the links on its way (D13.1).
486
+ ``env`` and ``env_unset`` are the caller's overrides of path-valued
487
+ names; ``HOME``, ``XDG_*`` and ``TMPDIR`` are the compiler's.
488
+ """
489
+
490
+ home: str
491
+ controller_env: Mapping[str, str]
492
+ projections: tuple[ProjectionRequest, ...] = ()
493
+ masks: tuple[str, ...] = ()
494
+ overlays: tuple[OverlayRequest, ...] = ()
495
+ denied_roots: tuple[str, ...] = ()
496
+ operator_config_roots: tuple[str, ...] = ()
497
+ keyring_items: tuple[str, ...] = ()
498
+ spelled_paths: tuple[str, ...] = ()
499
+ system_runtime: bool = True
500
+ home_size_bytes: int | None = None
501
+ tmp_size_bytes: int | None = None
502
+ network: NetworkPosition = NetworkPosition.HOST
503
+ sys: SysPolicy = SysPolicy.READ_ONLY
504
+ env: tuple[tuple[str, str], ...] = ()
505
+ env_unset: tuple[str, ...] = ()
506
+
507
+
508
+ # --------------------------------------------------------------- path helpers
509
+
510
+
511
+ def _inside(path: str, root: str) -> bool:
512
+ """``path`` equals ``root`` or lies below it (both clean)."""
513
+ return path == root or path.startswith(root.rstrip("/") + "/")
514
+
515
+
516
+ def _below(path: str, root: str) -> bool:
517
+ """``path`` lies strictly below ``root``."""
518
+ return path != root and _inside(path, root)
519
+
520
+
521
+ def _related(a: str, b: str) -> bool:
522
+ """One of the two equals or contains the other."""
523
+ return _inside(a, b) or _inside(b, a)
524
+
525
+
526
+ def _rebase(path: str, old: str, new: str) -> str:
527
+ """``path`` (inside ``old``) as the same relative path inside ``new``."""
528
+ if path == old:
529
+ return new
530
+ return posixpath.join(new, path[len(old.rstrip("/")) + 1 :])
531
+
532
+
533
+ def _parts(path: str) -> tuple[str, ...]:
534
+ return tuple(part for part in path.split("/") if part)
535
+
536
+
537
+ def _kind(mode: int) -> str:
538
+ if stat.S_ISDIR(mode):
539
+ return "directory"
540
+ if stat.S_ISREG(mode):
541
+ return "regular file"
542
+ if stat.S_ISLNK(mode):
543
+ return "symbolic link"
544
+ if stat.S_ISSOCK(mode):
545
+ return "socket"
546
+ if stat.S_ISFIFO(mode):
547
+ return "FIFO"
548
+ if stat.S_ISCHR(mode):
549
+ return "character device"
550
+ if stat.S_ISBLK(mode):
551
+ return "block device"
552
+ return "special file"
553
+
554
+
555
+ def _first_link(path: str) -> str | None:
556
+ """The first component of ``path`` that is a symbolic link, if any."""
557
+ current = "/"
558
+ for part in _parts(path):
559
+ current = posixpath.join(current, part)
560
+ try:
561
+ if stat.S_ISLNK(os.lstat(current).st_mode):
562
+ return current
563
+ except OSError:
564
+ return None
565
+ return None
566
+
567
+
568
+ def _resolve_loose(path: str) -> str:
569
+ """``realpath`` without requiring the path to exist (for rule anchors)."""
570
+ return os.path.realpath(path)
571
+
572
+
573
+ @dataclass
574
+ class _Proj:
575
+ source: str
576
+ target: str
577
+ access: Access
578
+ origin: Origin
579
+ carve_out: bool
580
+ is_dir: bool
581
+ label: str
582
+ system: bool = False
583
+
584
+
585
+ @dataclass
586
+ class _Rules:
587
+ """The fixed rule anchors of one compile: forbidden sources, denied roots."""
588
+
589
+ home: str
590
+ home_real: str
591
+ forbidden: list[tuple[str, str]] = field(default_factory=list)
592
+ denied: list[str] = field(default_factory=list)
593
+ operator_roots: list[str] = field(default_factory=list)
594
+
595
+
596
+ def _refuse(path: str, rule: str, detail: str) -> SandboxPolicyError:
597
+ return SandboxPolicyError(path, rule, detail)
598
+
599
+
600
+ def _forbidden_sources(home: str, env: Mapping[str, str]) -> list[tuple[str, str]]:
601
+ """D4.3's list, each resolved, with the name a refusal gives it."""
602
+ candidates: list[tuple[str, str]] = []
603
+
604
+ def add(value: str | None, name: str) -> None:
605
+ if value and value.startswith("/"):
606
+ candidates.append((value, name))
607
+
608
+ gh_dir = env.get("GH_CONFIG_DIR")
609
+ add(gh_dir, "the gh configuration directory ($GH_CONFIG_DIR)")
610
+ xdg_config = env.get("XDG_CONFIG_HOME")
611
+ if xdg_config:
612
+ add(posixpath.join(xdg_config, "gh"), "the gh configuration directory")
613
+ add(posixpath.join(xdg_config, "git"), "the git configuration directory")
614
+ add(posixpath.join(home, ".config/gh"), "the gh configuration directory")
615
+ add(posixpath.join(home, ".config/git"), "the git configuration directory")
616
+ add(posixpath.join(home, ".git-credentials"), "the git credential store")
617
+ add(posixpath.join(home, ".gitconfig"), "the git configuration")
618
+ add(posixpath.join(home, ".netrc"), "~/.netrc")
619
+ add(posixpath.join(home, ".ssh"), "~/.ssh")
620
+ add(posixpath.join(home, ".gnupg"), "~/.gnupg")
621
+ add(posixpath.join(home, ".local/share/keyrings"), "the keyrings")
622
+ add(posixpath.join(home, ".password-store"), "the password store")
623
+ add("/run", "/run")
624
+ add(env.get("XDG_RUNTIME_DIR"), "$XDG_RUNTIME_DIR")
625
+ resolved: list[tuple[str, str]] = []
626
+ for path, name in candidates:
627
+ for spelling in {posixpath.normpath(path), _resolve_loose(path)}:
628
+ resolved.append((spelling, name))
629
+ return resolved
630
+
631
+
632
+ def _check_forbidden(path: str, rules: _Rules, label: str) -> None:
633
+ """D4.3: refuse a source that is or contains the home, or touches a forbidden one."""
634
+ for home in (rules.home, rules.home_real):
635
+ if _inside(home, path):
636
+ raise _refuse(
637
+ path,
638
+ "D4.3 forbidden source",
639
+ f"{label} is or contains the operator's home ({home})",
640
+ )
641
+ for forbidden, name in rules.forbidden:
642
+ if _related(path, forbidden):
643
+ raise _refuse(
644
+ path,
645
+ "D4.3 forbidden source",
646
+ f"{label} is, contains or lies inside {name} ({forbidden})",
647
+ )
648
+
649
+
650
+ def check_declared_path(path: str, home: str, controller_env: Mapping[str, str], label: str) -> str:
651
+ """The checks of D4.2 and D4.3 an operator-declared path needs no launch for (D15.4).
652
+
653
+ An absolute, normalized spelling (C1); never the operator's home, one of
654
+ its ancestors or a forbidden source (D4.3), in the declared spelling and
655
+ where it resolves; never under ``/proc``, ``/dev`` or ``/sys`` (C7); and,
656
+ when it exists, a directory or a regular file (C3). A path that does not
657
+ exist yet passes here: the compiler refuses it at the launch (C1), and
658
+ ``doctor`` names it. Whether it overlaps a denied root is a fact of the
659
+ checkout, checked by the engine and at every compile (C4). Returns the
660
+ resolved path; a refusal is a :class:`SandboxPolicyError` naming the path
661
+ and the rule, never a file's contents.
662
+ """
663
+ if not path.startswith("/"):
664
+ raise _refuse(path, "C1 resolved source", f"{label} is a relative path")
665
+ if not _clean(path):
666
+ raise _refuse(path, "C1 resolved source", f"{label} is not a normalized absolute path")
667
+ rules = _Rules(posixpath.normpath(home), _resolve_loose(home))
668
+ rules.forbidden = _forbidden_sources(home, controller_env)
669
+ _check_forbidden(path, rules, label)
670
+ real = _resolve_loose(path)
671
+ _check_forbidden(real, rules, label)
672
+ for root in _KERNEL_ROOTS:
673
+ if _inside(real, root):
674
+ raise _refuse(path, "C7 relocations", f"{label} would land in {root}")
675
+ if os.path.lexists(real):
676
+ _kind_of(real, label)
677
+ return real
678
+
679
+
680
+ def _resolve_engine(path: str, label: str) -> str:
681
+ """C1 and C2 for a controller-owned path: absolute and resolving to itself."""
682
+ if not path.startswith("/"):
683
+ raise _refuse(path, "C1 resolved source", f"{label} is a relative path")
684
+ norm = posixpath.normpath(path)
685
+ if norm.startswith("//"):
686
+ norm = "/" + norm.lstrip("/")
687
+ link = _first_link(norm)
688
+ if link is not None:
689
+ raise _refuse(
690
+ path,
691
+ "C2 no link in a controller-owned path",
692
+ f"{label} passes through the symbolic link {link}",
693
+ )
694
+ real = os.path.realpath(norm)
695
+ if real != norm:
696
+ raise _refuse(path, "C2 no link in a controller-owned path", f"{label} resolves to {real}")
697
+ return norm
698
+
699
+
700
+ def _resolve_declared(path: str, label: str) -> str:
701
+ """C1 for a declared path: absolute, then resolved through its links."""
702
+ if not path.startswith("/"):
703
+ raise _refuse(path, "C1 resolved source", f"{label} is a relative path")
704
+ try:
705
+ return os.path.realpath(path, strict=True)
706
+ except OSError as exc:
707
+ raise _refuse(
708
+ path, "C1 resolved source", f"{label} cannot be resolved ({exc.strerror})"
709
+ ) from None
710
+
711
+
712
+ def _kind_of(path: str, label: str) -> bool:
713
+ """C3: True for a directory, False for a regular file, refused otherwise."""
714
+ try:
715
+ mode = os.lstat(path).st_mode
716
+ except OSError as exc:
717
+ raise _refuse(
718
+ path, "C1 resolved source", f"{label} does not exist ({exc.strerror})"
719
+ ) from None
720
+ if stat.S_ISDIR(mode):
721
+ return True
722
+ if stat.S_ISREG(mode):
723
+ return False
724
+ raise _refuse(
725
+ path,
726
+ "C3 kinds",
727
+ f"{label} is a {_kind(mode)}; only directories and regular files are projected",
728
+ )
729
+
730
+
731
+ def _relocate(target: str, rules: _Rules, label: str) -> str:
732
+ """C7: a declared sandbox path must lie in the private home; as its resolved spelling."""
733
+ if not _clean(target):
734
+ raise _refuse(target, "C7 relocations", f"{label}'s target is not a clean absolute path")
735
+ for home in (rules.home, rules.home_real):
736
+ if _below(target, home):
737
+ return _rebase(target, home, rules.home_real)
738
+ raise _refuse(
739
+ target,
740
+ "C7 relocations",
741
+ f"{label} may appear elsewhere than at its host path only inside the private home "
742
+ f"({rules.home})",
743
+ )
744
+
745
+
746
+ def _check_target(target: str, label: str) -> None:
747
+ for root in _KERNEL_ROOTS:
748
+ if _inside(target, root):
749
+ raise _refuse(target, "C7 relocations", f"{label} would land in {root}")
750
+ if _inside(PRIVATE_TMP, target):
751
+ raise _refuse(
752
+ target, "C6 overlaps", f"{label} is or contains the private temp ({PRIVATE_TMP})"
753
+ )
754
+
755
+
756
+ # ------------------------------------------------------------------ compiling
757
+
758
+
759
+ def _projection(req: ProjectionRequest, rules: _Rules) -> _Proj:
760
+ label = req.label or f"{req.origin.value} projection"
761
+ if req.origin is Origin.ENGINE:
762
+ source = _resolve_engine(req.source, label)
763
+ else:
764
+ if req.carve_out:
765
+ raise _refuse(req.source, "C5 carve-outs", f"{label}: only the engine carves out")
766
+ source = _resolve_declared(req.source, label)
767
+ is_dir = _kind_of(source, label)
768
+ _check_forbidden(source, rules, label)
769
+ for denied in rules.denied:
770
+ if _inside(source, denied) and not req.carve_out:
771
+ raise _refuse(source, "C4 denied roots", f"{label} lies in the denied root {denied}")
772
+ if req.origin is Origin.DECLARED and req.access is Access.WRITABLE:
773
+ if not is_dir:
774
+ raise _refuse(
775
+ source,
776
+ "D7.1 no writable file",
777
+ f"{label} is a single file; a writable declaration is a directory, since a "
778
+ "single-file bind refuses rename",
779
+ )
780
+ for root in rules.operator_roots:
781
+ if _related(source, root):
782
+ raise _refuse(
783
+ source,
784
+ "D7.2 operator configuration root",
785
+ f"{label} is writable and is, contains or lies inside the operator "
786
+ f"configuration root {root}",
787
+ )
788
+ target = source if req.target is None else _relocate(req.target, rules, label)
789
+ _check_target(target, label)
790
+ return _Proj(source, target, req.access, req.origin, req.carve_out, is_dir, label)
791
+
792
+
793
+ def _system_runtime(rules: _Rules) -> tuple[list[_Proj], list[Link]]:
794
+ """``/usr`` and ``/etc`` read-only, the top-level entries in the host's own
795
+ form, and the target of ``/etc/resolv.conf`` when it lies elsewhere."""
796
+ projections: list[_Proj] = []
797
+ links: list[Link] = []
798
+ for path in _SYSTEM_DIRS:
799
+ real = os.path.realpath(path)
800
+ if os.path.isdir(real):
801
+ _check_forbidden(real, rules, path)
802
+ projections.append(
803
+ _Proj(real, real, Access.READ_ONLY, Origin.ENGINE, False, True, path, True)
804
+ )
805
+ for path in _SYSTEM_TOP:
806
+ try:
807
+ mode = os.lstat(path).st_mode
808
+ except OSError:
809
+ continue
810
+ if stat.S_ISLNK(mode):
811
+ links.append(Link(path, os.readlink(path)))
812
+ elif stat.S_ISDIR(mode):
813
+ _check_forbidden(path, rules, path)
814
+ projections.append(
815
+ _Proj(path, path, Access.READ_ONLY, Origin.ENGINE, False, True, path, True)
816
+ )
817
+ try:
818
+ is_link = stat.S_ISLNK(os.lstat(_RESOLV_CONF).st_mode)
819
+ except OSError:
820
+ is_link = False
821
+ if is_link:
822
+ target = os.path.realpath(_RESOLV_CONF)
823
+ visible = any(_inside(target, p.source) for p in projections)
824
+ if not visible and os.path.isfile(target) and not os.path.islink(target):
825
+ # The one system exception to D4.3: the resolver file under /run.
826
+ rules_without_run = _Rules(
827
+ rules.home,
828
+ rules.home_real,
829
+ [(path, name) for path, name in rules.forbidden if path != "/run"],
830
+ )
831
+ _check_forbidden(target, rules_without_run, "the target of /etc/resolv.conf")
832
+ projections.append(
833
+ _Proj(
834
+ target,
835
+ target,
836
+ Access.READ_ONLY,
837
+ Origin.ENGINE,
838
+ False,
839
+ False,
840
+ "the target of /etc/resolv.conf",
841
+ True,
842
+ )
843
+ )
844
+ return projections, links
845
+
846
+
847
+ def _home_links(home: str) -> list[Link]:
848
+ """The links on the way to the home, recreated inside (D8.1)."""
849
+ links: list[Link] = []
850
+ current = "/"
851
+ for part in _parts(home):
852
+ step = posixpath.join(current, part)
853
+ try:
854
+ is_link = stat.S_ISLNK(os.lstat(step).st_mode)
855
+ except OSError:
856
+ is_link = False
857
+ if is_link:
858
+ links.append(Link(step, os.readlink(step)))
859
+ current = os.path.realpath(step)
860
+ else:
861
+ current = step
862
+ return links
863
+
864
+
865
+ def _innermost(entries: list[Mount], path: str, exclude: Mount | None = None) -> Mount | None:
866
+ """The deepest entry whose target is or contains ``path``."""
867
+ best: Mount | None = None
868
+ for entry in entries:
869
+ if entry is exclude or not _inside(path, entry.target):
870
+ continue
871
+ if best is None or len(entry.target) > len(best.target):
872
+ best = entry
873
+ return best
874
+
875
+
876
+ def _overlay(req: OverlayRequest, rules: _Rules) -> Overlay:
877
+ label = req.label or "overlay"
878
+ lower = _resolve_engine(req.lower, f"{label} lower directory")
879
+ upper = _resolve_engine(req.upper, f"{label} upper directory")
880
+ work = _resolve_engine(req.work, f"{label} work directory")
881
+ for path, what in ((lower, "lower"), (upper, "upper"), (work, "work")):
882
+ if not _kind_of(path, f"{label} {what} directory"):
883
+ raise _refuse(path, "C3 kinds", f"{label}'s {what} layer is not a directory")
884
+ _check_forbidden(path, rules, f"{label} {what} directory")
885
+ for path in (upper, work):
886
+ for denied in rules.denied:
887
+ if _inside(path, denied) and not req.carve_out:
888
+ raise _refuse(path, "C4 denied roots", f"{label} lies in the denied root {denied}")
889
+ for a, b in ((lower, upper), (lower, work), (upper, work)):
890
+ if _related(a, b):
891
+ raise _refuse(b, "C6 overlaps", f"{label}'s layers {a} and {b} overlap")
892
+ _check_target(lower, label)
893
+ return Overlay(lower, upper, work, lower)
894
+
895
+
896
+ def compile_launch(request: SandboxRequest) -> LaunchDescription:
897
+ """Compile ``request`` into a :class:`LaunchDescription`, or refuse it.
898
+
899
+ Raises :class:`~autoforge.errors.SandboxPolicyError` naming the path and
900
+ the rule; nothing is spawned or written either way.
901
+ """
902
+ if not _clean(request.home) or request.home == "/":
903
+ raise _refuse(request.home, "D8.1 private home", "the home is not a clean absolute path")
904
+ for path in (*request.denied_roots, *request.operator_config_roots):
905
+ if not path.startswith("/"):
906
+ raise _refuse(path, "C1 resolved source", "a rule anchor is a relative path")
907
+ rules = _Rules(request.home, os.path.realpath(request.home))
908
+ rules.forbidden = _forbidden_sources(request.home, request.controller_env)
909
+ rules.denied = sorted({_resolve_loose(p) for p in request.denied_roots})
910
+ rules.operator_roots = sorted({_resolve_loose(p) for p in request.operator_config_roots})
911
+ for item in request.keyring_items:
912
+ raise _refuse(
913
+ item,
914
+ "D7.2 keyring",
915
+ "a login stored in a keyring cannot be projected without exposing the keyring",
916
+ )
917
+ for size in (request.home_size_bytes, request.tmp_size_bytes):
918
+ if size is not None and size <= 0:
919
+ raise _refuse(str(size), "D8.1 private home", "a tmpfs bound must be positive")
920
+
921
+ engine: list[_Proj] = []
922
+ declared: list[_Proj] = []
923
+ links = _home_links(request.home)
924
+ if request.system_runtime:
925
+ system, system_links = _system_runtime(rules)
926
+ engine += system
927
+ links += system_links
928
+ for req in request.projections:
929
+ proj = _projection(req, rules)
930
+ (engine if req.origin is Origin.ENGINE else declared).append(proj)
931
+ overlays = [_overlay(req, rules) for req in request.overlays]
932
+
933
+ entries: list[Mount] = [
934
+ PrivateTmpfs(rules.home_real, 0o700, request.home_size_bytes),
935
+ PrivateTmpfs(PRIVATE_TMP, 0o1777, request.tmp_size_bytes),
936
+ ]
937
+ entries += links
938
+ # Each engine bind's request, for the rules and the refusals that name it.
939
+ proj_of: dict[int, _Proj] = {}
940
+ for proj in engine:
941
+ bind = Bind(proj.source, proj.target, proj.access, proj.is_dir)
942
+ proj_of[id(bind)] = proj
943
+ entries.append(bind)
944
+ entries += overlays
945
+ _check_unique(entries)
946
+ _check_links(entries)
947
+
948
+ masks = _masks(request.masks, entries)
949
+ entries += masks
950
+ entries += _denied_masks(rules, entries)
951
+ _check_unique(entries)
952
+ _check_engine_nesting(entries, proj_of, request.overlays)
953
+
954
+ covered: list[str] = []
955
+ for proj in declared:
956
+ entry = _place_declared(proj, entries, declared)
957
+ if entry is None:
958
+ covered.append(proj.source)
959
+ else:
960
+ entries.append(entry)
961
+ for path in request.spelled_paths:
962
+ entries += _spelled_links(path, entries, rules)
963
+ _check_links(entries)
964
+ _check_hidden(rules, entries)
965
+
966
+ entries.sort(key=lambda entry: (_parts(entry.target), isinstance(entry, Link)))
967
+ env_set, env_unset = _environment(request)
968
+ return LaunchDescription(
969
+ mounts=tuple(entries),
970
+ env_set=env_set,
971
+ env_unset=env_unset,
972
+ network=request.network,
973
+ sys=request.sys,
974
+ covered=tuple(covered),
975
+ )
976
+
977
+
978
+ def _check_unique(entries: list[Mount]) -> None:
979
+ seen: dict[str, Mount] = {}
980
+ for entry in entries:
981
+ other = seen.get(entry.target)
982
+ if other is not None:
983
+ raise _refuse(
984
+ entry.target, "C6 overlaps", "two entries of the description share this path"
985
+ )
986
+ seen[entry.target] = entry
987
+
988
+
989
+ def _check_links(entries: list[Mount]) -> None:
990
+ """Nothing is mounted at or below a recreated link: it would follow it."""
991
+ for link in entries:
992
+ if not isinstance(link, Link):
993
+ continue
994
+ for entry in entries:
995
+ if entry is not link and _inside(entry.target, link.target):
996
+ raise _refuse(
997
+ entry.target,
998
+ "C6 overlaps",
999
+ f"lies at or below the link {link.target} the sandbox recreates",
1000
+ )
1001
+
1002
+
1003
+ def _enclosing_bind(entries: list[Mount], source: str) -> Bind | None:
1004
+ """The deepest bind whose source is or contains the host path ``source``."""
1005
+ best: Bind | None = None
1006
+ for entry in entries:
1007
+ if isinstance(entry, Bind) and entry.is_dir and _inside(source, entry.source):
1008
+ if best is None or len(entry.source) > len(best.source):
1009
+ best = entry
1010
+ return best
1011
+
1012
+
1013
+ def _masks(paths: tuple[str, ...], entries: list[Mount]) -> list[Mount]:
1014
+ """The engine's masks: inside a read-only bind, or a direct child of a writable one."""
1015
+ masks: list[Mount] = []
1016
+ for raw in paths:
1017
+ path = _resolve_engine(raw, "mask")
1018
+ if not os.path.lexists(path):
1019
+ continue # nothing there to hide
1020
+ if not _kind_of(path, "mask"):
1021
+ raise _refuse(path, "C3 kinds", "a mask covers a directory, not a file")
1022
+ bind = _enclosing_bind(entries, path)
1023
+ if bind is None or bind.source == path:
1024
+ raise _refuse(path, "C4 denied roots", "a mask lies inside no projection")
1025
+ if bind.access is Access.WRITABLE and posixpath.dirname(path) != bind.source:
1026
+ raise _refuse(
1027
+ path,
1028
+ "C4 denied roots",
1029
+ f"a mask inside the writable projection {bind.source} must be a direct child "
1030
+ "of it: a deeper one could be moved from inside by renaming an ancestor",
1031
+ )
1032
+ masks.append(Mask(_rebase(path, bind.source, bind.target)))
1033
+ return masks
1034
+
1035
+
1036
+ def _denied_masks(rules: _Rules, entries: list[Mount]) -> list[Mount]:
1037
+ """C4 for each denied root inside a projection: masked, or refused."""
1038
+ added: list[Mount] = []
1039
+ for denied in rules.denied:
1040
+ if not os.path.lexists(denied):
1041
+ continue # nothing there to show or hide
1042
+ bind = _enclosing_bind(entries, denied)
1043
+ if bind is None or bind.source == denied:
1044
+ continue # absent, or an engine carve-out (checked per projection)
1045
+ inner = _rebase(denied, bind.source, bind.target)
1046
+ hiding = _innermost(entries + added, inner)
1047
+ if isinstance(hiding, Mask | PrivateTmpfs) and _inside(hiding.target, bind.target):
1048
+ continue # already masked by the engine
1049
+ if bind.access is Access.READ_ONLY:
1050
+ raise _refuse(
1051
+ denied,
1052
+ "C4 denied roots",
1053
+ f"a denied root inside the read-only projection {bind.source} must be masked "
1054
+ "by the engine",
1055
+ )
1056
+ if posixpath.dirname(denied) != bind.source:
1057
+ raise _refuse(
1058
+ denied,
1059
+ "C4 denied roots",
1060
+ f"a denied root inside the writable projection {bind.source} is masked only "
1061
+ "as a direct child of it, and this one lies deeper",
1062
+ )
1063
+ if not _kind_of(denied, "denied root"):
1064
+ raise _refuse(
1065
+ denied, "C4 denied roots", "a file in a writable projection cannot be masked"
1066
+ )
1067
+ added.append(Mask(inner))
1068
+ return added
1069
+
1070
+
1071
+ def _check_engine_nesting(
1072
+ entries: list[Mount], proj_of: dict[int, _Proj], requests: tuple[OverlayRequest, ...]
1073
+ ) -> None:
1074
+ """C5 and C6 among the engine's own entries."""
1075
+ carved = {posixpath.normpath(req.lower): req.carve_out for req in requests if req.lower}
1076
+ for entry in entries:
1077
+ outer = _innermost(entries, entry.target, exclude=entry)
1078
+ if isinstance(entry, Bind):
1079
+ proj = proj_of[id(entry)]
1080
+ if isinstance(outer, Mask) and not proj.carve_out:
1081
+ raise _refuse(
1082
+ entry.source,
1083
+ "C5 carve-outs",
1084
+ f"{proj.label} lies inside the mask {outer.target} and is not a carve-out",
1085
+ )
1086
+ if isinstance(outer, Overlay):
1087
+ raise _refuse(entry.source, "C6 overlaps", f"{proj.label} lies inside an overlay")
1088
+ elif isinstance(entry, Overlay):
1089
+ if isinstance(outer, Mask) and not carved.get(entry.lower, False):
1090
+ raise _refuse(
1091
+ entry.target,
1092
+ "C5 carve-outs",
1093
+ f"the overlay lies inside the mask {outer.target} and is not a carve-out",
1094
+ )
1095
+ if isinstance(outer, Overlay):
1096
+ raise _refuse(entry.target, "C6 overlaps", "an overlay lies inside an overlay")
1097
+ if isinstance(outer, Bind) and outer.access is Access.WRITABLE:
1098
+ raise _refuse(
1099
+ entry.target, "C6 overlaps", "an overlay lies inside a writable projection"
1100
+ )
1101
+ elif isinstance(entry, Mask) and isinstance(outer, Overlay):
1102
+ raise _refuse(entry.target, "C6 overlaps", "a mask lies inside an overlay")
1103
+ for overlay in (entry for entry in entries if isinstance(entry, Overlay)):
1104
+ for path in (overlay.upper, overlay.work):
1105
+ if _visible(entries, path):
1106
+ raise _refuse(
1107
+ path, "C6 overlaps", "an overlay layer would be visible inside the sandbox"
1108
+ )
1109
+
1110
+
1111
+ def _visible(entries: list[Mount], host_path: str) -> bool:
1112
+ """Whether the host path is reachable inside through some bind or overlay."""
1113
+ for entry in entries:
1114
+ if isinstance(entry, Bind) and _inside(host_path, entry.source):
1115
+ inner = _rebase(host_path, entry.source, entry.target)
1116
+ if _innermost(entries, inner) is entry:
1117
+ return True
1118
+ if isinstance(entry, Overlay) and _inside(host_path, entry.lower):
1119
+ if _innermost(entries, host_path) is entry:
1120
+ return True
1121
+ return False
1122
+
1123
+
1124
+ def _place_declared(proj: _Proj, entries: list[Mount], declared: list[_Proj]) -> Bind | None:
1125
+ """C5 and C6 for one declared projection; ``None`` when it is already visible."""
1126
+ for other in declared:
1127
+ if other is not proj and _related(other.target, proj.target):
1128
+ raise _refuse(
1129
+ proj.source,
1130
+ "C6 overlaps",
1131
+ f"{proj.label} overlaps the declared projection {other.source}",
1132
+ )
1133
+ for entry in entries:
1134
+ if _below(entry.target, proj.target) and not isinstance(entry, PrivateTmpfs):
1135
+ raise _refuse(
1136
+ proj.source,
1137
+ "C6 overlaps",
1138
+ f"{proj.label} contains {entry.target}, which the engine places",
1139
+ )
1140
+ outer = _innermost(entries, proj.target)
1141
+ if isinstance(outer, Mask):
1142
+ raise _refuse(
1143
+ proj.source,
1144
+ "C5 carve-outs",
1145
+ f"{proj.label} lies inside the mask {outer.target}; only the engine carves out",
1146
+ )
1147
+ if isinstance(outer, Bind):
1148
+ consistent = _rebase(proj.target, outer.target, outer.source) == proj.source
1149
+ if outer.access is Access.READ_ONLY and proj.access is Access.READ_ONLY and consistent:
1150
+ return None
1151
+ raise _refuse(
1152
+ proj.source, "C6 overlaps", f"{proj.label} overlaps the projection {outer.source}"
1153
+ )
1154
+ if isinstance(outer, Overlay | Link):
1155
+ raise _refuse(proj.source, "C6 overlaps", f"{proj.label} overlaps {outer.target}")
1156
+ return Bind(proj.source, proj.target, proj.access, proj.is_dir)
1157
+
1158
+
1159
+ _SPELLED = "D13.1 spelled path"
1160
+
1161
+
1162
+ def _spelled_links(path: str, entries: list[Mount], rules: _Rules) -> list[Link | Dir]:
1163
+ """The links and directories to recreate so that ``path``, as written,
1164
+ leads inside where it leads on the host.
1165
+
1166
+ Each component is looked up as the kernel would look it up inside
1167
+ (:meth:`LaunchDescription.resolve_inside`). One that is absent inside
1168
+ but lies where nothing is shown (the empty root, a private mount, an
1169
+ empty directory) is looked up on the host: a link is recreated and
1170
+ followed, and a directory is made on the way to a link below it. The
1171
+ kernel leaves a directory by ``..`` only from inside it, so one left
1172
+ that way is made empty when nothing else makes it (PR #232 R2-F1). The
1173
+ directories on the way are never projected, so nothing beside a link is
1174
+ visible (D4.1). Refused, naming the path, when a link or a directory
1175
+ made would lie in a mask, a denied root, a forbidden source or a kernel
1176
+ filesystem, when the path loops, and when it would not reach where its
1177
+ host target is shown.
1178
+ """
1179
+ label = f"the spelled path {path}"
1180
+ if not path.startswith("/") or "\0" in path:
1181
+ raise _refuse(path, "C1 resolved source", f"{label} is not an absolute path")
1182
+ try:
1183
+ real = os.path.realpath(path, strict=True)
1184
+ except OSError as exc:
1185
+ raise _refuse(
1186
+ path, "C1 resolved source", f"{label} cannot be resolved ({exc.strerror})"
1187
+ ) from None
1188
+ added: list[Link | Dir] = []
1189
+ left: list[str] = [] # directories made on the way and left by ``..``
1190
+ pending = list(_parts(path))
1191
+ current = "/"
1192
+ followed = 0
1193
+ while pending:
1194
+ part = pending.pop(0)
1195
+ if part == ".":
1196
+ continue
1197
+ if part == "..":
1198
+ if current != "/" and not _lookup([*entries, *added], current)[0]:
1199
+ left.append(current)
1200
+ current = posixpath.dirname(current)
1201
+ continue
1202
+ candidate = posixpath.join(current, part)
1203
+ known = [*entries, *added]
1204
+ exists, link = _lookup(known, candidate)
1205
+ if not exists:
1206
+ covering = _innermost(known, candidate)
1207
+ if covering is not None and not isinstance(covering, PrivateTmpfs | Dir):
1208
+ raise _refuse(
1209
+ path, _SPELLED, f"{label} passes through {candidate}, which is not visible"
1210
+ )
1211
+ try:
1212
+ mode = os.lstat(candidate).st_mode
1213
+ except OSError as exc:
1214
+ raise _refuse(
1215
+ path, _SPELLED, f"{label} passes through {candidate} ({exc.strerror})"
1216
+ ) from None
1217
+ if stat.S_ISDIR(mode):
1218
+ current = candidate # made inside: by a link below it, or left by ``..``
1219
+ continue
1220
+ if not stat.S_ISLNK(mode):
1221
+ raise _refuse(
1222
+ path, _SPELLED, f"{label} passes through {candidate}, which is not visible"
1223
+ )
1224
+ _check_place(candidate, rules, label, "a link")
1225
+ link = os.readlink(candidate)
1226
+ added.append(Link(candidate, link))
1227
+ elif link is None:
1228
+ current = candidate
1229
+ continue
1230
+ followed += 1
1231
+ if followed > _MAX_LINKS:
1232
+ raise _refuse(path, _SPELLED, f"{label} passes through too many links")
1233
+ if link.startswith("/"):
1234
+ current = "/"
1235
+ pending = [p for p in link.split("/") if p] + pending
1236
+ # Deepest first: a directory made makes its parents on the way.
1237
+ for directory in sorted(set(left), key=lambda d: (-len(_parts(d)), d)):
1238
+ if not any(_inside(entry.target, directory) for entry in [*entries, *added]):
1239
+ _check_place(directory, rules, label, "a directory it leaves by '..'")
1240
+ added.append(Dir(directory))
1241
+ shown = _inside_path([*entries, *added], real)
1242
+ if shown is None or current != shown:
1243
+ raise _refuse(
1244
+ path,
1245
+ _SPELLED,
1246
+ f"{label} resolves to {real}, which "
1247
+ + ("nothing of the launch shows" if shown is None else f"is shown at {shown}")
1248
+ + f", and leads inside to {current}",
1249
+ )
1250
+ return added
1251
+
1252
+
1253
+ def _check_place(target: str, rules: _Rules, label: str, what: str) -> None:
1254
+ """Where a link or a directory on a spelled path may be recreated: never
1255
+ in a denied root, a forbidden source or a kernel filesystem."""
1256
+ for root in _KERNEL_ROOTS:
1257
+ if _inside(target, root):
1258
+ raise _refuse(target, _SPELLED, f"{label} passes through {what} in {root}")
1259
+ for denied in rules.denied:
1260
+ if _inside(target, denied):
1261
+ raise _refuse(
1262
+ target,
1263
+ "C4 denied roots",
1264
+ f"{label} passes through {what} in the denied root {denied}",
1265
+ )
1266
+ for forbidden, name in rules.forbidden:
1267
+ if _inside(target, forbidden):
1268
+ raise _refuse(
1269
+ target,
1270
+ "D4.3 forbidden source",
1271
+ f"{label} passes through {what} in {name} ({forbidden})",
1272
+ )
1273
+
1274
+
1275
+ def _check_hidden(rules: _Rules, entries: list[Mount]) -> None:
1276
+ """The description shows no existing denied root itself, and no forbidden source."""
1277
+ for denied in rules.denied:
1278
+ if not os.path.lexists(denied):
1279
+ continue
1280
+ if _visible(entries, denied) and not any(
1281
+ isinstance(e, Bind) and e.source == denied for e in entries
1282
+ ):
1283
+ raise _refuse(denied, "C4 denied roots", "the denied root would be visible")
1284
+ for forbidden, name in rules.forbidden:
1285
+ if _visible(entries, forbidden):
1286
+ raise _refuse(forbidden, "D4.3 forbidden source", f"{name} would be visible")
1287
+
1288
+
1289
+ def _environment(
1290
+ request: SandboxRequest,
1291
+ ) -> tuple[tuple[tuple[str, str], ...], tuple[str, ...]]:
1292
+ """HOME, XDG and TMPDIR follow the private mounts; the caller's overrides after."""
1293
+ home = request.home
1294
+ env: dict[str, str] = {"HOME": home}
1295
+ for name, rel in _XDG_DIRS:
1296
+ env[name] = posixpath.join(home, rel)
1297
+ env["TMPDIR"] = PRIVATE_TMP
1298
+ unset = {"XDG_RUNTIME_DIR"}
1299
+ for name, value in request.env:
1300
+ if name in _OWNED_ENV:
1301
+ raise _refuse(name, "D8.2 environment", "the private home and temp decide this name")
1302
+ if not _ENV_NAME_RE.match(name):
1303
+ raise _refuse(name, "D8.2 environment", "not an environment variable name")
1304
+ if name in env:
1305
+ raise _refuse(name, "D8.2 environment", "set twice")
1306
+ env[name] = value
1307
+ for name in request.env_unset:
1308
+ if name in env:
1309
+ raise _refuse(name, "D8.2 environment", "both set and unset")
1310
+ if not _ENV_NAME_RE.match(name):
1311
+ raise _refuse(name, "D8.2 environment", "not an environment variable name")
1312
+ unset.add(name)
1313
+ return tuple(sorted(env.items())), tuple(sorted(unset))