codecalc 0.1.0__py3-none-win_amd64.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.
codecalc/guarded.py ADDED
@@ -0,0 +1,300 @@
1
+ """Run SymPy where it can be KILLED, rather than where it must be trusted.
2
+
3
+ `safe_expr` screens what reaches SymPy and bounds the shapes known to explode.
4
+ Both are denylists, and a denylist is a bet that the list is complete. SymPy's
5
+ own maintainers made and lost that bet: PR sympy/sympy#12524 added a `safe=`
6
+ flag built on an AST whitelist plus a name blacklist and was never merged,
7
+ abandoned since 2020, with the author calling it "security theater that leads
8
+ users into a false sense of security, because it can still be bypassed".
9
+
10
+ So the screen buys time and this buys a bound. The distinction that matters is
11
+ that a bound does not have to anticipate anything: whatever the expression
12
+ does, it does it in a process the parent can kill.
13
+
14
+ WHY FORK, AND NOT A FRESH INTERPRETER:
15
+ A spawned child re-imports SymPy, which is 437ms measured on this project and
16
+ the reason the import is already lazy — that would be paid on every symbolic
17
+ call. A forked child inherits the parent's already-imported SymPy through
18
+ copy-on-write and costs single-digit milliseconds. The work is CPU-bound and
19
+ short-lived, so there is nothing a fresh interpreter buys here except latency.
20
+
21
+ THE THREADING HAZARD, STATED PLAINLY:
22
+ `fork()` in a process with threads copies only the calling thread, so a lock
23
+ held by another thread at that instant is held forever in the child. Python
24
+ 3.12 warns about exactly this. Two things bound the risk rather than removing
25
+ it: the fork is serialised under `_FORK_LOCK` so this module never races
26
+ itself, and the child touches only SymPy and `os.write` before `os._exit`,
27
+ taking no path through logging, subprocess or the import system. It is the same
28
+ trade `executor.py` already makes for `preexec_fn`, which is likewise not
29
+ thread-safe and likewise serialised. A child that does deadlock is killed by
30
+ the parent's wall clock like any other overrun, which is the property this
31
+ module exists to provide.
32
+
33
+ WINDOWS HAS NO FORK.
34
+ There is no equivalent that avoids the import cost, so the call runs in-process
35
+ and the result says so in `unenforced`, in the same vocabulary the executor
36
+ uses for a ceiling it could not apply. Reporting a bound that is not there
37
+ would be the failure this repository keeps correcting.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import os
44
+ import select
45
+ import signal
46
+ import threading
47
+ from time import monotonic as _monotonic
48
+ from time import sleep as _sleep
49
+
50
+ from . import errors
51
+
52
+ #: Serialises forks from this module. See the threading note above.
53
+ _FORK_LOCK = threading.Lock()
54
+
55
+ #: The child's own ceilings. CPU is the one that stops a runaway computation;
56
+ #: RLIMIT_AS stops the other way an expression gets out of hand, which is by
57
+ #: allocating rather than by spinning.
58
+ DEFAULT_CPU_SECONDS = 10
59
+ DEFAULT_MEMORY_MB = 2048
60
+
61
+ #: Wall clock, enforced by the PARENT. RLIMIT_CPU alone is not enough: a child
62
+ #: blocked rather than computing burns no CPU and would never hit it.
63
+ DEFAULT_WALL_SECONDS = 15
64
+
65
+ #: A result is a small dict of strings. The cap is here so a child that decides
66
+ #: to emit something enormous cannot make the parent hold it.
67
+ _MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
68
+
69
+ #: Reported when the platform cannot provide the bound, in the executor's own
70
+ #: style: a caller reading this knows the guarantee was not applied.
71
+ UNENFORCED_NO_FORK = "expression_bound_not_enforced_without_fork"
72
+
73
+ CAN_FORK = hasattr(os, "fork") and hasattr(os, "waitpid")
74
+
75
+ #: The only fields a failed guarded call passes on. Everything else the child
76
+ #: or the parent put in the outcome — `value` above all — stops here.
77
+ _CARRIED_ON_FAILURE = ("ok", "code", "error", "remedy", "code_inferred")
78
+
79
+ #: The out-of-memory answer, serialised AT IMPORT TIME.
80
+ #:
81
+ #: The OOM branch in `_child` cannot afford to build anything: under MemoryError
82
+ #: even formatting a message can raise again, which is why that branch answered
83
+ #: with a bare literal. The literal carried no `code`, so the one failure in the
84
+ #: taxonomy that is unambiguously `resource_exhausted` arrived at the server
85
+ #: boundary codeless and had its code guessed back out of the words "memory
86
+ #: ceiling" — the weak claim, for the case we are most certain about.
87
+ #:
88
+ #: Building the same JSON here costs nothing at failure time and keeps the
89
+ #: property that made the literal correct: by the time a child hits MemoryError,
90
+ #: this string has existed since import.
91
+ _OOM_PAYLOAD = json.dumps(errors.error_result(
92
+ errors.RESOURCE_EXHAUSTED, "expression exhausted the memory ceiling"))
93
+
94
+
95
+ def _child(write_fd: int, fn, args: tuple, kwargs: dict,
96
+ cpu_seconds: int, memory_mb: int) -> None:
97
+ """Run `fn` under rlimits and write its result back as JSON. Never returns.
98
+
99
+ Every exception is caught, including the ones nobody predicted. That is the
100
+ point of running here rather than in the parent: `nextprime(100)` raised
101
+ `AttributeError: 'int' object has no attribute 'is_number'` out of
102
+ evaluate_expression, because some SymPy functions return plain Python
103
+ objects rather than expressions. A guard that only caught the failures
104
+ already known would not have contained that one.
105
+ """
106
+ import resource
107
+
108
+ try:
109
+ resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
110
+ if memory_mb > 0:
111
+ as_bytes = memory_mb * 1024 * 1024
112
+ resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes))
113
+ except (ValueError, OSError):
114
+ # A ceiling that could not be set is reported by the parent as
115
+ # unenforced rather than silently skipped — but only the wall clock is
116
+ # truly load-bearing, and that one is the parent's.
117
+ pass
118
+
119
+ try:
120
+ payload = json.dumps({"ok": True, "value": fn(*args, **kwargs)})
121
+ except MemoryError:
122
+ # Formatting the error can itself allocate, so this one is answered
123
+ # with a constant — see _OOM_PAYLOAD, which is built at import time
124
+ # precisely so that nothing is constructed here.
125
+ payload = _OOM_PAYLOAD
126
+ except BaseException as exc: # containment is the job — every failure, known or not
127
+ try:
128
+ payload = json.dumps(errors.error_result(
129
+ errors.classify(exc), f"{type(exc).__name__}: {exc}"[:2000]))
130
+ except Exception:
131
+ payload = '{"ok": false, "error": "evaluation failed"}'
132
+
133
+ data = payload.encode("utf-8", "replace")[:_MAX_PAYLOAD_BYTES]
134
+ try:
135
+ os.write(write_fd, data)
136
+ except OSError:
137
+ pass
138
+ # _exit, not sys.exit: no atexit handlers, no buffered flushes, nothing
139
+ # inherited from the parent that could run twice.
140
+ os._exit(0)
141
+
142
+
143
+ def run_guarded(fn, *args, cpu_seconds: int = DEFAULT_CPU_SECONDS,
144
+ memory_mb: int = DEFAULT_MEMORY_MB,
145
+ wall_seconds: int = DEFAULT_WALL_SECONDS,
146
+ **kwargs) -> dict:
147
+ """Run `fn(*args, **kwargs)` in a killable child; return a result dict.
148
+
149
+ Returns `{"ok": True, "value": ...}` or `{"ok": False, "error": ...}`, plus
150
+ `"unenforced"` when the platform could not apply the bound. `fn` must return
151
+ something JSON-serialisable, which every tool result in this project is.
152
+ """
153
+ if not CAN_FORK:
154
+ # In-process, and SAYING SO. The screen in safe_expr still applies; what
155
+ # is missing is the ability to stop something it did not anticipate.
156
+ try:
157
+ return {"ok": True, "value": fn(*args, **kwargs),
158
+ "unenforced": [UNENFORCED_NO_FORK]}
159
+ except BaseException as exc: # containment is the job
160
+ return errors.error_result(
161
+ errors.classify(exc), f"{type(exc).__name__}: {exc}"[:2000],
162
+ unenforced=[UNENFORCED_NO_FORK])
163
+
164
+ read_fd, write_fd = os.pipe()
165
+ with _FORK_LOCK:
166
+ pid = os.fork()
167
+ if pid == 0:
168
+ os.close(read_fd)
169
+ _child(write_fd, fn, args, kwargs, cpu_seconds, memory_mb)
170
+ # unreachable: _child always _exits
171
+
172
+ os.close(write_fd)
173
+ chunks: list[bytes] = []
174
+ total = 0
175
+ timed_out = False
176
+ deadline = _monotonic() + wall_seconds
177
+ try:
178
+ while True:
179
+ remaining = deadline - _monotonic()
180
+ if remaining <= 0:
181
+ timed_out = True
182
+ break
183
+ ready, _, _ = select.select([read_fd], [], [], remaining)
184
+ if not ready:
185
+ timed_out = True
186
+ break
187
+ chunk = os.read(read_fd, 65536)
188
+ if not chunk:
189
+ break # child closed the pipe: it is done
190
+ total += len(chunk)
191
+ if total > _MAX_PAYLOAD_BYTES:
192
+ timed_out = False
193
+ chunks.append(chunk)
194
+ break
195
+ chunks.append(chunk)
196
+ finally:
197
+ os.close(read_fd)
198
+ if timed_out:
199
+ # SIGKILL, not SIGTERM. The child may be inside a C loop in SymPy
200
+ # with no opportunity to handle a catchable signal.
201
+ try:
202
+ os.kill(pid, signal.SIGKILL)
203
+ except OSError:
204
+ pass
205
+ _reap(pid)
206
+
207
+ # The three failures below are the PARENT's own diagnosis, not the child's.
208
+ # Each one knows exactly what happened, so each names its code here rather
209
+ # than leaving `ensure_code` to guess it back out of the sentence. A code
210
+ # chosen where the failure is detected is the strong claim; one recovered
211
+ # from prose is the weak one, and these are not cases where we have to
212
+ # settle for the weak one.
213
+ if timed_out:
214
+ return errors.error_result(
215
+ errors.TIMEOUT,
216
+ f"expression exceeded the {wall_seconds}s evaluation limit and was killed")
217
+ raw = b"".join(chunks)
218
+ if not raw:
219
+ # The child died without answering: RLIMIT_CPU (SIGXCPU) and the kernel
220
+ # OOM killer both land here. Distinguishable from a timeout, which is
221
+ # the parent's doing, and worth saying so rather than reporting a
222
+ # generic failure.
223
+ return errors.error_result(
224
+ errors.RESOURCE_EXHAUSTED,
225
+ "expression was terminated by a resource limit "
226
+ f"(CPU {cpu_seconds}s / memory {memory_mb}MB) before producing a result")
227
+ try:
228
+ return json.loads(raw.decode("utf-8", "replace"))
229
+ except ValueError:
230
+ return errors.error_result(
231
+ errors.INTERNAL, "the guarded evaluation returned no usable result")
232
+
233
+
234
+ def _reap(pid: int) -> None:
235
+ """Wait for `pid`, without letting a stuck child block the caller forever."""
236
+ for _ in range(100):
237
+ try:
238
+ done, _ = os.waitpid(pid, os.WNOHANG)
239
+ except ChildProcessError:
240
+ return
241
+ if done:
242
+ return
243
+ _sleep(0.01)
244
+ # Still alive after a second: it ignored the kill or is unkillable. Reaping
245
+ # is abandoned rather than blocking the server; the process is already
246
+ # SIGKILLed and init will collect it.
247
+ try:
248
+ os.kill(pid, signal.SIGKILL)
249
+ os.waitpid(pid, os.WNOHANG)
250
+ except OSError:
251
+ pass
252
+
253
+
254
+ def guarded_call(fn, *args, **kwargs) -> dict:
255
+ """Run a tool function under the bound and hand back its own result dict.
256
+
257
+ Every symbolic tool in this project returns `{"ok": ..., ...}`, so the
258
+ unwrapping is identical for all of them and lives here rather than being
259
+ written out at each call site — six copies of this would be six chances to
260
+ drop the `unenforced` passthrough, which is the part a caller cannot
261
+ reconstruct.
262
+ """
263
+ outcome = run_guarded(fn, *args, **kwargs)
264
+ if outcome.get("ok"):
265
+ result = outcome["value"]
266
+ else:
267
+ # Carry the failure through whole. This used to rebuild a bare
268
+ # `{"ok": False, "error": ...}`, which DISCARDED the `code` the child's
269
+ # `errors.classify()` had chosen at the raise site — and `ensure_code`
270
+ # at the server boundary then re-derived one from the message text and
271
+ # stamped it `code_inferred: True`. Two things were wrong with that.
272
+ #
273
+ # It downgraded every guarded failure's provenance from "chosen where it
274
+ # was raised" to "guessed from prose", which is the exact distinction
275
+ # errors.py exists to preserve and which check_claims counts.
276
+ #
277
+ # And for one code in six it did not round-trip at all. Measured across
278
+ # the taxonomy: FileNotFoundError classifies as `runtime_unavailable`
279
+ # (the language is not installed — remedy: install it), while its
280
+ # message "ghc" matches no hint and falls through to `internal` (remedy:
281
+ # report a bug). A caller was told to file an issue about a missing
282
+ # compiler. Wrong in the actionable direction, which is the failure mode
283
+ # errors.py's own comments call out.
284
+ # An explicit allowlist, not "everything except unenforced". The
285
+ # blocklist form was the first fix and a cross-vendor review flagged it:
286
+ # no current producer in run_guarded returns a `value` on the failure
287
+ # path, so nothing leaks today, but the shape of the code invited one.
288
+ # A future `{"ok": False, "value": ...}` — or a child payload shaped by
289
+ # something other than error_result — would have been forwarded whole
290
+ # into a tool result. Naming the four fields that belong to the error
291
+ # contract costs nothing and cannot acquire a fifth by accident.
292
+ result = {k: outcome[k] for k in _CARRIED_ON_FAILURE if k in outcome}
293
+ result["ok"] = False
294
+ result.setdefault("error", "evaluation failed")
295
+ if outcome.get("unenforced"):
296
+ # The platform could not apply the bound. Merged rather than replacing
297
+ # any `unenforced` the tool itself reported.
298
+ existing = list(result.get("unenforced") or [])
299
+ result = {**result, "unenforced": existing + list(outcome["unenforced"])}
300
+ return result
codecalc/landlock.py ADDED
@@ -0,0 +1,231 @@
1
+ """Confine a process to a set of paths, using the Landlock LSM.
2
+
3
+ LAYER 2 of the #23 mitigation. Layer 1 stops the package managers from running
4
+ third-party code at all (`--ignore-scripts` and friends in packages.py). This
5
+ is for what still runs: the manager itself, and anything a future entry cannot
6
+ disable.
7
+
8
+ Landlock is an unprivileged, stackable Linux LSM. A process declares which
9
+ paths it may touch, calls `landlock_restrict_self`, and the kernel enforces
10
+ that on it and every process it goes on to create. No root, no daemon, no
11
+ container runtime, and — the part that matters here — no delegated cgroup
12
+ write access, which is what ruled out `cgroup v2 pids.max` for a stdio MCP
13
+ server launched by an arbitrary client.
14
+
15
+ WHY ctypes AND NOT A LIBRARY:
16
+ `py-landlock` is at 0.1.1 with two releases, and `landlock` is at 1.0.0.dev5.
17
+ Both may well be fine; neither is something to put a security boundary on in a
18
+ project that gates this carefully, when the interface being wrapped is three
19
+ syscalls. Nothing here is imported at module scope that a fork would inherit
20
+ badly, and there is no new dependency to pin, audit or keep current.
21
+
22
+ WHAT LANDLOCK DOES NOT DO, from the kernel documentation, because a boundary
23
+ whose gaps are not written down gets mistaken for a complete one:
24
+
25
+ - It cannot restrict `chdir`, `stat`, `flock`, `chmod`, `chown`, `setxattr`,
26
+ `utime`, `fcntl` or `access`. Metadata is readable and changeable on paths
27
+ the process can reach.
28
+ - `chroot(2)` remains allowed.
29
+ - A file descriptor opened BEFORE the ruleset is applied stays usable. This
30
+ is why the ruleset is applied in `preexec_fn`, after fork and before exec,
31
+ with `close_fds=True` doing the rest.
32
+ - UDP needs ABI 10. On anything lower — including the ABI 7 this was
33
+ developed against — a confined process can still reach the network over
34
+ UDP, DNS included. TCP needs ABI 4.
35
+ - Multithreaded `restrict_self` needs ABI 8 (TSYNC). Irrelevant here and
36
+ deliberately so: this runs in a freshly forked child, which has exactly one
37
+ thread.
38
+
39
+ Everything in that list is reported by `unenforced_reasons()` rather than
40
+ being left for a reader to discover in a kernel manual.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import ctypes
46
+ import os
47
+ import pathlib
48
+
49
+ #: Syscall numbers. Identical on x86_64 and aarch64 — Landlock arrived after
50
+ #: both moved to the generic table — and verified on aarch64 before use.
51
+ _NR_CREATE_RULESET = 444
52
+ _NR_ADD_RULE = 445
53
+ _NR_RESTRICT_SELF = 446
54
+
55
+ _LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
56
+ _LANDLOCK_RULE_PATH_BENEATH = 1
57
+
58
+ _PR_SET_NO_NEW_PRIVS = 38
59
+
60
+ # Filesystem access rights, with the ABI that introduced each. The set is
61
+ # masked against the running kernel's ABI: asking for a right it does not know
62
+ # makes the whole ruleset fail, which would turn a partial boundary into none.
63
+ _FS = {
64
+ "execute": (1 << 0, 1), "write_file": (1 << 1, 1), "read_file": (1 << 2, 1),
65
+ "read_dir": (1 << 3, 1), "remove_dir": (1 << 4, 1), "remove_file": (1 << 5, 1),
66
+ "make_char": (1 << 6, 1), "make_dir": (1 << 7, 1), "make_reg": (1 << 8, 1),
67
+ "make_sock": (1 << 9, 1), "make_fifo": (1 << 10, 1), "make_block": (1 << 11, 1),
68
+ "make_sym": (1 << 12, 1),
69
+ "refer": (1 << 13, 2), "truncate": (1 << 14, 3), "ioctl_dev": (1 << 15, 5),
70
+ }
71
+
72
+ _READ_RIGHTS = ("execute", "read_file", "read_dir")
73
+ _WRITE_RIGHTS = ("write_file", "remove_dir", "remove_file", "make_char",
74
+ "make_dir", "make_reg", "make_sock", "make_fifo",
75
+ "make_block", "make_sym", "refer", "truncate", "ioctl_dev")
76
+
77
+ #: Rights that mean anything for a NON-directory. The kernel rejects a rule
78
+ #: carrying directory-only rights on a file with EINVAL, so a mask has to be
79
+ #: narrowed by what the path actually is. Found by adding /dev/null to a
80
+ #: ruleset and watching the whole confinement fail to apply — which is the
81
+ #: right direction to fail, but only if the caller notices.
82
+ _FILE_RIGHTS = ("execute", "read_file", "write_file", "truncate", "ioctl_dev")
83
+
84
+
85
+ class _RulesetAttr(ctypes.Structure):
86
+ """struct landlock_ruleset_attr.
87
+
88
+ Only `handled_access_fs` is declared. The syscall takes an explicit size,
89
+ so passing the ABI-1 size asks the kernel for exactly the ABI-1 semantics —
90
+ which is what this module uses. Network handling would add
91
+ `handled_access_net` (ABI 4) and a larger size; it is deliberately absent,
92
+ because a TCP-only network boundary on a kernel where UDP cannot be
93
+ restricted is worth less than saying so plainly.
94
+ """
95
+
96
+ _fields_ = [("handled_access_fs", ctypes.c_uint64)]
97
+
98
+
99
+ class _PathBeneathAttr(ctypes.Structure):
100
+ """struct landlock_path_beneath_attr — PACKED.
101
+
102
+ 12 bytes, not 16. The kernel struct carries __attribute__((packed)), and a
103
+ default-aligned ctypes Structure would pad to 16 and be rejected with
104
+ EINVAL. This is the single easiest thing to get wrong here.
105
+ """
106
+
107
+ _pack_ = 1
108
+ _fields_ = [("allowed_access", ctypes.c_uint64),
109
+ ("parent_fd", ctypes.c_int32)]
110
+
111
+
112
+ def _libc() -> ctypes.CDLL:
113
+ return ctypes.CDLL("libc.so.6", use_errno=True)
114
+
115
+
116
+ def abi_version() -> int:
117
+ """The kernel's Landlock ABI, or 0 if Landlock is unavailable/disabled."""
118
+ if not hasattr(os, "uname") or os.uname().sysname != "Linux":
119
+ return 0
120
+ try:
121
+ version = _libc().syscall(_NR_CREATE_RULESET, None, 0,
122
+ _LANDLOCK_CREATE_RULESET_VERSION)
123
+ except OSError:
124
+ return 0
125
+ return version if version > 0 else 0
126
+
127
+
128
+ def available() -> bool:
129
+ return abi_version() > 0
130
+
131
+
132
+ def unenforced_reasons(abi: int | None = None, scope: str = "install") -> list[str]:
133
+ """What this confinement does NOT cover, in the executor's own vocabulary.
134
+
135
+ Returned whether or not Landlock applied, because "we could not confine
136
+ the installer" and "we confined it but UDP is still open" are different
137
+ facts and a caller acting on either needs to know which it has.
138
+
139
+ `scope` names what was being confined. It defaults to "install" so the
140
+ package-install strings (#23) are unchanged, and sessions pass "session"
141
+ (#104) — a session result reading `install_udp_egress_unrestricted` is
142
+ describing the wrong subject, and a caller who greps for the guarantee
143
+ they lost would not find it under a name about installing packages.
144
+ """
145
+ abi = abi_version() if abi is None else abi
146
+ if abi == 0:
147
+ return [f"{scope}_not_confined_no_landlock"] if scope != "install" else [
148
+ "package_install_not_confined_no_landlock"]
149
+ out = [
150
+ # True at every ABI: these are documented gaps, not version gaps.
151
+ f"{scope}_metadata_syscalls_unrestricted", # chmod/chown/stat/utime/...
152
+ ]
153
+ if abi < 10:
154
+ out.append(f"{scope}_udp_egress_unrestricted")
155
+ if abi < 4:
156
+ out.append(f"{scope}_tcp_egress_unrestricted")
157
+ return out
158
+
159
+
160
+ def _mask_for(abi: int, names: tuple[str, ...]) -> int:
161
+ mask = 0
162
+ for name in names:
163
+ bit, since = _FS[name]
164
+ if abi >= since:
165
+ mask |= bit
166
+ return mask
167
+
168
+
169
+ def restrict_self(read_write: list[str], read_only: list[str]) -> None:
170
+ """Confine THIS process (and its children) to the given paths.
171
+
172
+ Irreversible, by design. Raises OSError if the kernel refuses, and the
173
+ caller must treat that as a failure to confine rather than proceeding —
174
+ silently continuing unconfined is the failure mode this whole module
175
+ exists to remove.
176
+
177
+ Intended for use in `preexec_fn`: after fork, before exec, in a child with
178
+ one thread and no inherited descriptors beyond the ones subprocess keeps.
179
+ """
180
+ abi = abi_version()
181
+ if abi == 0:
182
+ raise OSError("Landlock unavailable on this kernel")
183
+
184
+ libc = _libc()
185
+ handled = _mask_for(abi, tuple(_FS))
186
+ attr = _RulesetAttr(handled_access_fs=handled)
187
+ ruleset_fd = libc.syscall(_NR_CREATE_RULESET, ctypes.byref(attr),
188
+ ctypes.sizeof(attr), 0)
189
+ if ruleset_fd < 0:
190
+ raise OSError(ctypes.get_errno(), "landlock_create_ruleset failed")
191
+
192
+ try:
193
+ rw_names = _READ_RIGHTS + _WRITE_RIGHTS
194
+ ro_names = _READ_RIGHTS
195
+ for paths, names in ((read_write, rw_names), (read_only, ro_names)):
196
+ for path in paths:
197
+ # A file gets the file-applicable subset; a directory gets the
198
+ # lot. Passing the lot for a file makes landlock_add_rule
199
+ # return EINVAL and takes the ENTIRE ruleset down with it.
200
+ is_dir = pathlib.Path(path).is_dir()
201
+ mask = _mask_for(abi, tuple(n for n in names
202
+ if is_dir or n in _FILE_RIGHTS))
203
+ if not mask:
204
+ continue
205
+ try:
206
+ path_fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
207
+ except OSError:
208
+ # A path that does not exist is skipped rather than fatal:
209
+ # the allow-list is a superset covering several platforms
210
+ # and toolchain layouts, and refusing to confine at all
211
+ # because /opt is absent would be the worst outcome.
212
+ continue
213
+ try:
214
+ rule = _PathBeneathAttr(allowed_access=mask, parent_fd=path_fd)
215
+ if libc.syscall(_NR_ADD_RULE, ruleset_fd,
216
+ _LANDLOCK_RULE_PATH_BENEATH,
217
+ ctypes.byref(rule), 0) < 0:
218
+ raise OSError(ctypes.get_errno(),
219
+ f"landlock_add_rule failed for {path}")
220
+ finally:
221
+ os.close(path_fd)
222
+
223
+ # REQUIRED before restrict_self, and it is its own hardening: without
224
+ # it a confined process could still gain privileges through a setuid
225
+ # binary it is allowed to execute.
226
+ if libc.prctl(_PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
227
+ raise OSError(ctypes.get_errno(), "prctl(PR_SET_NO_NEW_PRIVS) failed")
228
+ if libc.syscall(_NR_RESTRICT_SELF, ruleset_fd, 0) < 0:
229
+ raise OSError(ctypes.get_errno(), "landlock_restrict_self failed")
230
+ finally:
231
+ os.close(ruleset_fd)