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/SKILL.md +87 -0
- codecalc/__init__.py +3 -0
- codecalc/__main__.py +19 -0
- codecalc/_worker_bootstrap.py +211 -0
- codecalc/bin/codecalc-exec.exe +0 -0
- codecalc/complexity.py +162 -0
- codecalc/contract.py +624 -0
- codecalc/doctor.py +428 -0
- codecalc/errors.py +214 -0
- codecalc/exact.py +969 -0
- codecalc/execution_service.py +302 -0
- codecalc/executor.py +1324 -0
- codecalc/guarded.py +300 -0
- codecalc/landlock.py +231 -0
- codecalc/logic.py +500 -0
- codecalc/mcp_middleware.py +135 -0
- codecalc/optimization.py +296 -0
- codecalc/optional.py +107 -0
- codecalc/packages.py +360 -0
- codecalc/parsing.py +266 -0
- codecalc/provider_adapters/__init__.py +1 -0
- codecalc/provider_adapters/piston.py +34 -0
- codecalc/providers.py +802 -0
- codecalc/py.typed +0 -0
- codecalc/registry.py +254 -0
- codecalc/run_supervisor.py +178 -0
- codecalc/runtimes.py +542 -0
- codecalc/safe_expr.py +346 -0
- codecalc/server.py +1103 -0
- codecalc/sessions.py +1060 -0
- codecalc/strict_runtime.py +232 -0
- codecalc/tools.py +326 -0
- codecalc/translation.py +286 -0
- codecalc/units.py +410 -0
- codecalc-0.1.0.dist-info/METADATA +838 -0
- codecalc-0.1.0.dist-info/RECORD +39 -0
- codecalc-0.1.0.dist-info/WHEEL +4 -0
- codecalc-0.1.0.dist-info/entry_points.txt +2 -0
- codecalc-0.1.0.dist-info/licenses/LICENSE +201 -0
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)
|