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
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))
|