@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.31
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.
- package/CHANGELOG.md +831 -0
- package/README.md +157 -99
- package/bin/redue.mjs +322 -66
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +50 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +326 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +659 -0
- package/src/setup.mjs +224 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/landlock.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# The sandboxed recipe's Linux launcher (src/sandbox.mjs): runs a command
|
|
3
|
+
# under a Landlock ruleset (Linux >= 5.13), with the standard library only.
|
|
4
|
+
# Reads and executes are allowed beneath the --read paths; with --no-tcp
|
|
5
|
+
# (ABI >= 4), every TCP bind and connect is refused, and (ABI >= 6) so is a
|
|
6
|
+
# connection to an abstract Unix socket outside the run. Writes are not
|
|
7
|
+
# handled, so they are not restricted.
|
|
8
|
+
#
|
|
9
|
+
# --loopback (a test run) leaves TCP to a seccomp filter in the command's
|
|
10
|
+
# process instead. Sockets other than Unix, netlink and inet TCP streams are
|
|
11
|
+
# refused when they are created, and so are sendto, sendmsg and sendmmsg with
|
|
12
|
+
# MSG_FASTOPEN (a connect without connect), io_uring_setup, and any foreign
|
|
13
|
+
# architecture's or x32 system call. Every connect goes to the launcher
|
|
14
|
+
# (seccomp user notification). It lets through AF_UNSPEC, an unnamed or
|
|
15
|
+
# abstract Unix socket, a Unix socket file beneath a --write place, and the
|
|
16
|
+
# local host (loopback, 0.0.0.0, ::) on a port no process listens on or one
|
|
17
|
+
# of the run's own processes listens on: not a service started outside the
|
|
18
|
+
# run. It refuses the rest. Nothing else goes through the launcher, so worker
|
|
19
|
+
# processes' IPC is untouched; bind is allowed anywhere.
|
|
20
|
+
#
|
|
21
|
+
# --deny-write PATH (a test run's /tmp and /var/tmp) handles writes too: they
|
|
22
|
+
# are allowed everywhere but beneath those paths, and beneath each --write
|
|
23
|
+
# PATH (the run's own temporary directory).
|
|
24
|
+
#
|
|
25
|
+
# --hide PATH keeps the run from reading or executing beneath PATH, inside a
|
|
26
|
+
# --read place. Landlock only grants, beneath a whole directory, so such a
|
|
27
|
+
# place gets listing rights (beneath it all, hidden entries included) and
|
|
28
|
+
# read and execute rights on each of its entries that is not hidden and
|
|
29
|
+
# holds nothing hidden, descending into the ones that do. A hidden path can
|
|
30
|
+
# still be listed and stat'ed. --stats FILE writes the rule count and the
|
|
31
|
+
# time the rules took, as JSON; with --loopback, rewritten when the command
|
|
32
|
+
# ends with `supervisor`: "ok" when it served the whole run, else what went
|
|
33
|
+
# wrong. A missing or other value means the network was not supervised.
|
|
34
|
+
#
|
|
35
|
+
# The launcher stays as the command's parent and a child subreaper: when the
|
|
36
|
+
# command exits, it stops every process the command left running, daemons
|
|
37
|
+
# that started their own session included (they are reparented to it), as a
|
|
38
|
+
# CI job's end does. The command runs in its own process group; a signal the
|
|
39
|
+
# launcher gets is passed to that group once. The launcher then ends as the
|
|
40
|
+
# command did: with its exit status, or by its signal.
|
|
41
|
+
#
|
|
42
|
+
# landlock.py --read /usr --read /tmp ... [--hide P ...] [--stats F] [--no-tcp | --loopback]
|
|
43
|
+
# [--deny-write P ... --write P ...] -- cmd args...
|
|
44
|
+
# landlock.py --probe # prints {"abi": N} or {"abi": null, "errno": E}
|
|
45
|
+
import ctypes
|
|
46
|
+
import errno
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import select
|
|
50
|
+
import signal
|
|
51
|
+
import socket
|
|
52
|
+
import sys
|
|
53
|
+
import threading
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
SYS_CREATE, SYS_ADD, SYS_RESTRICT = 444, 445, 446 # the same on x86_64 and aarch64
|
|
57
|
+
EXECUTE, READ_FILE, READ_DIR = 1 << 0, 1 << 2, 1 << 3
|
|
58
|
+
# Writing, making and removing files, renaming across directories (ABI 2), truncating (ABI 3).
|
|
59
|
+
WRITE = sum(1 << bit for bit in (1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14))
|
|
60
|
+
BIND_TCP, CONNECT_TCP = 1 << 0, 1 << 1
|
|
61
|
+
SCOPE_ABSTRACT_UNIX_SOCKET = 1 << 0
|
|
62
|
+
PR_SET_NO_NEW_PRIVS, PR_SET_CHILD_SUBREAPER = 38, 36
|
|
63
|
+
|
|
64
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
65
|
+
libc.syscall.restype = ctypes.c_long
|
|
66
|
+
libc.ioctl.argtypes = [ctypes.c_int, ctypes.c_ulong, ctypes.c_void_p]
|
|
67
|
+
|
|
68
|
+
# The seccomp filter --loopback installs, per architecture: its audit value
|
|
69
|
+
# and the system calls it looks at.
|
|
70
|
+
ARCH = {'x86_64': (0xC000003E, {'socket': 41, 'connect': 42, 'sendto': 44, 'sendmsg': 46, 'sendmmsg': 307,
|
|
71
|
+
'io_uring_setup': 425, 'seccomp': 317}),
|
|
72
|
+
'aarch64': (0xC00000B7, {'socket': 198, 'connect': 203, 'sendto': 206, 'sendmsg': 211, 'sendmmsg': 269,
|
|
73
|
+
'io_uring_setup': 425, 'seccomp': 277})}
|
|
74
|
+
AF_UNSPEC, AF_UNIX, AF_INET, AF_INET6, AF_NETLINK = 0, 1, 2, 10, 16
|
|
75
|
+
SOCK_STREAM, IPPROTO_TCP, IPPROTO_MPTCP, MSG_FASTOPEN = 1, 6, 262, 0x20000000
|
|
76
|
+
RET_ALLOW, RET_NOTIFY, RET_ERRNO = 0x7fff0000, 0x7fc00000, 0x00050000
|
|
77
|
+
LD, JEQ, JGE, JSET, AND, RET = 0x20, 0x15, 0x35, 0x45, 0x54, 0x06
|
|
78
|
+
SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, SECCOMP_USER_NOTIF_FLAG_CONTINUE = 1, 1 << 3, 1
|
|
79
|
+
NOTIF_RECV, NOTIF_SEND, NOTIF_ID_VALID, NOTIF_ID_VALID_OLD = 0xc0502100, 0xc0182101, 0x40082102, 0x80082102
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SockFilter(ctypes.Structure):
|
|
83
|
+
_fields_ = [('code', ctypes.c_uint16), ('jt', ctypes.c_uint8), ('jf', ctypes.c_uint8), ('k', ctypes.c_uint32)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SockFprog(ctypes.Structure):
|
|
87
|
+
_fields_ = [('len', ctypes.c_ushort), ('filter', ctypes.POINTER(SockFilter))]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SeccompData(ctypes.Structure):
|
|
91
|
+
_fields_ = [('nr', ctypes.c_int), ('arch', ctypes.c_uint32), ('ip', ctypes.c_uint64), ('args', ctypes.c_uint64 * 6)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Notif(ctypes.Structure):
|
|
95
|
+
_fields_ = [('id', ctypes.c_uint64), ('pid', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('data', SeccompData)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NotifResp(ctypes.Structure):
|
|
99
|
+
_fields_ = [('id', ctypes.c_uint64), ('val', ctypes.c_int64), ('error', ctypes.c_int32), ('flags', ctypes.c_uint32)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def filter_program(machine):
|
|
103
|
+
# Little-endian: an argument's low 32 bits are at 16 + 8 * index.
|
|
104
|
+
audit, nr = ARCH[machine]
|
|
105
|
+
program, labels = [], {}
|
|
106
|
+
def op(code, k, jt=None, jf=None):
|
|
107
|
+
program.append([code, jt, jf, k])
|
|
108
|
+
def label(name):
|
|
109
|
+
labels[name] = len(program)
|
|
110
|
+
op(LD, 4); op(JEQ, audit, None, 'deny')
|
|
111
|
+
op(LD, 0)
|
|
112
|
+
if machine == 'x86_64':
|
|
113
|
+
op(JGE, 0x40000000, 'deny')
|
|
114
|
+
op(JEQ, nr['connect'], 'notify'); op(JEQ, nr['io_uring_setup'], 'deny'); op(JEQ, nr['socket'], 'socket')
|
|
115
|
+
op(JEQ, nr['sendto'], 'flags3'); op(JEQ, nr['sendmmsg'], 'flags3'); op(JEQ, nr['sendmsg'], 'flags2')
|
|
116
|
+
op(RET, RET_ALLOW)
|
|
117
|
+
# Unix and netlink sockets; inet only as TCP (or MPTCP) streams, which reach a host through connect alone.
|
|
118
|
+
label('socket'); op(LD, 16)
|
|
119
|
+
op(JEQ, AF_UNIX, 'allow'); op(JEQ, AF_NETLINK, 'allow'); op(JEQ, AF_INET, 'inet'); op(JEQ, AF_INET6, 'inet'); op(RET, RET_ERRNO | errno.EPERM)
|
|
120
|
+
label('inet'); op(LD, 24); op(AND, 0xf); op(JEQ, SOCK_STREAM, 'protocol', 'deny')
|
|
121
|
+
label('protocol'); op(LD, 32); op(JEQ, 0, 'allow'); op(JEQ, IPPROTO_TCP, 'allow'); op(JEQ, IPPROTO_MPTCP, 'allow', 'deny')
|
|
122
|
+
label('flags3'); op(LD, 16 + 8 * 3); op(JSET, MSG_FASTOPEN, 'deny', 'allow')
|
|
123
|
+
label('flags2'); op(LD, 16 + 8 * 2); op(JSET, MSG_FASTOPEN, 'deny', 'allow')
|
|
124
|
+
label('allow'); op(RET, RET_ALLOW)
|
|
125
|
+
label('notify'); op(RET, RET_NOTIFY)
|
|
126
|
+
label('deny'); op(RET, RET_ERRNO | errno.EPERM)
|
|
127
|
+
jump = lambda at, name: labels[name] - at - 1 if name else 0
|
|
128
|
+
return [SockFilter(code, jump(at, jt), jump(at, jf), k) for at, (code, jt, jf, k) in enumerate(program)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# In the command's process, before exec: the filter, and its listener for the launcher.
|
|
132
|
+
def install_filter():
|
|
133
|
+
machine = os.uname().machine
|
|
134
|
+
if machine not in ARCH:
|
|
135
|
+
raise OSError(f'no seccomp filter for {machine}')
|
|
136
|
+
program = filter_program(machine)
|
|
137
|
+
buffer = (SockFilter * len(program))(*program)
|
|
138
|
+
fprog = SockFprog(len(program), ctypes.cast(buffer, ctypes.POINTER(SockFilter)))
|
|
139
|
+
fd = libc.syscall(ARCH[machine][1]['seccomp'], SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, ctypes.byref(fprog))
|
|
140
|
+
if fd < 0:
|
|
141
|
+
raise OSError(ctypes.get_errno(), f'seccomp: {os.strerror(ctypes.get_errno())}')
|
|
142
|
+
return fd
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# The run's processes: the launcher's descendants (it is their subreaper).
|
|
146
|
+
def run_processes(launcher):
|
|
147
|
+
parents = {}
|
|
148
|
+
for entry in os.listdir('/proc'):
|
|
149
|
+
if entry.isdigit():
|
|
150
|
+
try:
|
|
151
|
+
with open(f'/proc/{entry}/stat') as f:
|
|
152
|
+
stat = f.read()
|
|
153
|
+
parents[int(entry)] = int(stat[stat.rindex(')') + 2:].split()[1])
|
|
154
|
+
except (OSError, ValueError, IndexError):
|
|
155
|
+
pass
|
|
156
|
+
def descends(pid, seen=0):
|
|
157
|
+
while pid in parents and seen < 64:
|
|
158
|
+
pid, seen = parents[pid], seen + 1
|
|
159
|
+
if pid == launcher:
|
|
160
|
+
return True
|
|
161
|
+
return False
|
|
162
|
+
return [pid for pid in parents if descends(pid)]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Who listens on a local TCP port: None when nobody does, else whether one of
|
|
166
|
+
# the run's processes does.
|
|
167
|
+
def listener_in_run(port, launcher):
|
|
168
|
+
inodes = set()
|
|
169
|
+
for table in ('/proc/net/tcp', '/proc/net/tcp6'):
|
|
170
|
+
try:
|
|
171
|
+
with open(table) as f:
|
|
172
|
+
rows = f.read().splitlines()[1:]
|
|
173
|
+
except OSError:
|
|
174
|
+
continue
|
|
175
|
+
for row in rows:
|
|
176
|
+
fields = row.split()
|
|
177
|
+
if len(fields) > 9 and fields[3] == '0A' and int(fields[1].rsplit(':', 1)[1], 16) == port:
|
|
178
|
+
inodes.add(fields[9])
|
|
179
|
+
if not inodes:
|
|
180
|
+
return None
|
|
181
|
+
links = {f'socket:[{inode}]' for inode in inodes}
|
|
182
|
+
for pid in run_processes(launcher):
|
|
183
|
+
try:
|
|
184
|
+
for fd in os.listdir(f'/proc/{pid}/fd'):
|
|
185
|
+
try:
|
|
186
|
+
if os.readlink(f'/proc/{pid}/fd/{fd}') in links:
|
|
187
|
+
return True
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Whether a process may connect to the address it names (see --loopback).
|
|
196
|
+
def connect_allowed(pid, address, length, places, launcher, ports):
|
|
197
|
+
if not address or length < 2:
|
|
198
|
+
return False
|
|
199
|
+
try:
|
|
200
|
+
fd = os.open(f'/proc/{pid}/mem', os.O_RDONLY | os.O_CLOEXEC)
|
|
201
|
+
try:
|
|
202
|
+
raw = os.pread(fd, min(length, 128), address)
|
|
203
|
+
finally:
|
|
204
|
+
os.close(fd)
|
|
205
|
+
except OSError:
|
|
206
|
+
return False
|
|
207
|
+
family = int.from_bytes(raw[:2], sys.byteorder)
|
|
208
|
+
if family == AF_UNSPEC:
|
|
209
|
+
return True
|
|
210
|
+
if family == AF_UNIX:
|
|
211
|
+
name = raw[2:].split(b'\0', 1)[0] if len(raw) > 2 and raw[2] != 0 else b''
|
|
212
|
+
if not name:
|
|
213
|
+
return True
|
|
214
|
+
try:
|
|
215
|
+
target = os.path.realpath(os.path.join(os.readlink(f'/proc/{pid}/cwd'), os.fsdecode(name)))
|
|
216
|
+
except OSError:
|
|
217
|
+
return False
|
|
218
|
+
return any(target == place or target.startswith(place.rstrip('/') + '/') for place in places)
|
|
219
|
+
if family == AF_INET and len(raw) >= 8:
|
|
220
|
+
local = raw[4] == 127 or raw[4:8] == bytes(4)
|
|
221
|
+
elif family == AF_INET6 and len(raw) >= 24:
|
|
222
|
+
host = raw[8:24]
|
|
223
|
+
mapped = host[:12] == bytes(10) + b'\xff\xff'
|
|
224
|
+
local = host in (bytes(15) + b'\x01', bytes(16)) or mapped and (host[12] == 127 or host[12:] == bytes(4))
|
|
225
|
+
else:
|
|
226
|
+
return False
|
|
227
|
+
if not local:
|
|
228
|
+
return False
|
|
229
|
+
port = int.from_bytes(raw[2:4], 'big')
|
|
230
|
+
if port in ports:
|
|
231
|
+
return True
|
|
232
|
+
owner = listener_in_run(port, launcher)
|
|
233
|
+
if owner:
|
|
234
|
+
ports.add(port)
|
|
235
|
+
return owner is not False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# The launcher's side: every connect the command's processes make, until none is left.
|
|
239
|
+
def supervise(fd, state, places, die=False):
|
|
240
|
+
def valid(id):
|
|
241
|
+
value = ctypes.c_uint64(id)
|
|
242
|
+
if libc.ioctl(fd, NOTIF_ID_VALID, ctypes.byref(value)) == 0:
|
|
243
|
+
return True
|
|
244
|
+
return ctypes.get_errno() in (errno.EINVAL, errno.ENOTTY) and libc.ioctl(fd, NOTIF_ID_VALID_OLD, ctypes.byref(value)) == 0
|
|
245
|
+
launcher, ports = os.getpid(), set()
|
|
246
|
+
places = [os.path.realpath(place) for place in places]
|
|
247
|
+
try:
|
|
248
|
+
poller = select.poll()
|
|
249
|
+
poller.register(fd, select.POLLIN)
|
|
250
|
+
while True:
|
|
251
|
+
events = dict(poller.poll())
|
|
252
|
+
if events.get(fd, 0) & select.POLLIN == 0:
|
|
253
|
+
break
|
|
254
|
+
notif = Notif()
|
|
255
|
+
if libc.ioctl(fd, NOTIF_RECV, ctypes.byref(notif)) < 0:
|
|
256
|
+
if ctypes.get_errno() in (errno.EINTR, errno.ENOENT):
|
|
257
|
+
continue
|
|
258
|
+
raise OSError(ctypes.get_errno(), 'receive')
|
|
259
|
+
if die:
|
|
260
|
+
raise RuntimeError('test fault')
|
|
261
|
+
allowed = connect_allowed(notif.pid, notif.data.args[1], notif.data.args[2], places, launcher, ports)
|
|
262
|
+
state['connects'] = state.get('connects', 0) + 1
|
|
263
|
+
if not valid(notif.id):
|
|
264
|
+
continue
|
|
265
|
+
response = NotifResp(notif.id, 0, 0 if allowed else -errno.EPERM, SECCOMP_USER_NOTIF_FLAG_CONTINUE if allowed else 0)
|
|
266
|
+
if libc.ioctl(fd, NOTIF_SEND, ctypes.byref(response)) < 0 and ctypes.get_errno() != errno.ENOENT:
|
|
267
|
+
raise OSError(ctypes.get_errno(), 'respond')
|
|
268
|
+
state['supervisor'] = 'ok'
|
|
269
|
+
except Exception as e:
|
|
270
|
+
state['supervisor'] = f'failed: {e}'
|
|
271
|
+
# A closed listener makes every later connect fail (ENOSYS).
|
|
272
|
+
try:
|
|
273
|
+
os.close(fd)
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class RulesetAttr(ctypes.Structure):
|
|
279
|
+
_fields_ = [('fs', ctypes.c_uint64), ('net', ctypes.c_uint64), ('scoped', ctypes.c_uint64)]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class PathBeneath(ctypes.Structure):
|
|
283
|
+
# Packed, as the kernel's struct is: 12 bytes. Python 3.14 asks for the layout.
|
|
284
|
+
_layout_ = 'ms'
|
|
285
|
+
_pack_ = 1
|
|
286
|
+
_fields_ = [('allowed', ctypes.c_uint64), ('fd', ctypes.c_int32)]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def abi():
|
|
290
|
+
v = libc.syscall(SYS_CREATE, None, ctypes.c_size_t(0), ctypes.c_uint32(1))
|
|
291
|
+
return (v, 0) if v >= 0 else (None, ctypes.get_errno())
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main(argv):
|
|
295
|
+
if argv == ['--probe']:
|
|
296
|
+
v, err = abi()
|
|
297
|
+
print(json.dumps({'abi': v, 'errno': err}))
|
|
298
|
+
return 0
|
|
299
|
+
reads, hidden, stats, no_tcp, loopback, deny_write, writes, fault = [], set(), None, False, False, set(), [], None
|
|
300
|
+
while argv and argv[0] != '--':
|
|
301
|
+
if argv[0] == '--read':
|
|
302
|
+
reads.append(argv[1]); argv = argv[2:]
|
|
303
|
+
elif argv[0] == '--loopback':
|
|
304
|
+
loopback = True; argv = argv[1:]
|
|
305
|
+
elif argv[0] == '--deny-write':
|
|
306
|
+
deny_write.add(os.path.normpath(argv[1])); argv = argv[2:]
|
|
307
|
+
elif argv[0] == '--write':
|
|
308
|
+
writes.append(argv[1]); argv = argv[2:]
|
|
309
|
+
elif argv[0] == '--test-fault':
|
|
310
|
+
# Tests only: the supervisor cannot start (unavailable) or stops at the first connect (die).
|
|
311
|
+
fault = argv[1]; argv = argv[2:]
|
|
312
|
+
elif argv[0] == '--hide':
|
|
313
|
+
hidden.add(os.path.normpath(argv[1])); argv = argv[2:]
|
|
314
|
+
elif argv[0] == '--stats':
|
|
315
|
+
stats = argv[1]; argv = argv[2:]
|
|
316
|
+
elif argv[0] == '--no-tcp':
|
|
317
|
+
no_tcp = True; argv = argv[1:]
|
|
318
|
+
else:
|
|
319
|
+
sys.exit(f'landlock.py: unknown option {argv[0]}')
|
|
320
|
+
cmd = argv[1:]
|
|
321
|
+
version, err = abi()
|
|
322
|
+
if version is None:
|
|
323
|
+
sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
|
|
324
|
+
reading = EXECUTE | READ_FILE | READ_DIR
|
|
325
|
+
handled = reading | (WRITE if deny_write else 0)
|
|
326
|
+
attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
|
|
327
|
+
SCOPE_ABSTRACT_UNIX_SOCKET if (no_tcp or loopback) and version >= 6 else 0)
|
|
328
|
+
size = 24 if attr.scoped else 16 if attr.net else 8
|
|
329
|
+
ruleset = libc.syscall(SYS_CREATE, ctypes.byref(attr), ctypes.c_size_t(size), ctypes.c_uint32(0))
|
|
330
|
+
if ruleset < 0:
|
|
331
|
+
sys.exit(f'landlock.py: create_ruleset errno {ctypes.get_errno()}')
|
|
332
|
+
started, count = time.monotonic(), 0
|
|
333
|
+
def add(place, rights=None):
|
|
334
|
+
nonlocal count
|
|
335
|
+
try:
|
|
336
|
+
fd = os.open(place, os.O_PATH | os.O_CLOEXEC)
|
|
337
|
+
except OSError:
|
|
338
|
+
return
|
|
339
|
+
# A file takes file rights only.
|
|
340
|
+
if rights is None:
|
|
341
|
+
rights = reading if os.path.isdir(place) else EXECUTE | READ_FILE
|
|
342
|
+
if not os.path.isdir(place):
|
|
343
|
+
rights &= EXECUTE | READ_FILE | (1 << 1) | (1 << 14)
|
|
344
|
+
rule = PathBeneath(rights, fd)
|
|
345
|
+
if libc.syscall(SYS_ADD, ctypes.c_int(ruleset), ctypes.c_int(1), ctypes.byref(rule), ctypes.c_uint32(0)) < 0:
|
|
346
|
+
sys.exit(f'landlock.py: add_rule {place} errno {ctypes.get_errno()}')
|
|
347
|
+
os.close(fd)
|
|
348
|
+
count += 1
|
|
349
|
+
# The directories that hold an excluded path, up to the root.
|
|
350
|
+
def holders_of(excluded):
|
|
351
|
+
holders = set()
|
|
352
|
+
for path in excluded:
|
|
353
|
+
at = os.path.dirname(path)
|
|
354
|
+
while at not in holders and at != os.path.dirname(at):
|
|
355
|
+
holders.add(at); at = os.path.dirname(at)
|
|
356
|
+
return holders
|
|
357
|
+
# `rights` on every entry beneath `directory` but the excluded ones.
|
|
358
|
+
def grant(directory, excluded, holders, rights):
|
|
359
|
+
# Links are not followed: what they lead to is granted on its own or not at all.
|
|
360
|
+
try:
|
|
361
|
+
entries = list(os.scandir(directory))
|
|
362
|
+
except OSError:
|
|
363
|
+
return
|
|
364
|
+
for entry in entries:
|
|
365
|
+
if entry.path in excluded or entry.is_symlink():
|
|
366
|
+
continue
|
|
367
|
+
if entry.path in holders:
|
|
368
|
+
grant(entry.path, excluded, holders, rights)
|
|
369
|
+
else:
|
|
370
|
+
add(entry.path, rights)
|
|
371
|
+
holders = holders_of(hidden)
|
|
372
|
+
for place in reads:
|
|
373
|
+
place = os.path.normpath(place)
|
|
374
|
+
if place in holders and os.path.isdir(place):
|
|
375
|
+
add(place, READ_DIR)
|
|
376
|
+
grant(place, hidden, holders, reading)
|
|
377
|
+
else:
|
|
378
|
+
add(place)
|
|
379
|
+
if deny_write:
|
|
380
|
+
grant('/', deny_write, holders_of(deny_write), WRITE)
|
|
381
|
+
for place in writes:
|
|
382
|
+
add(place, WRITE)
|
|
383
|
+
state = {'rules': count, 'ms': round((time.monotonic() - started) * 1000, 1)}
|
|
384
|
+
# Opened before the ruleset applies, so a write rule cannot refuse it later; not inherited.
|
|
385
|
+
stats_fd = os.open(stats, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_CLOEXEC, 0o600) if stats else None
|
|
386
|
+
def report():
|
|
387
|
+
if stats_fd is not None:
|
|
388
|
+
data = json.dumps(state).encode()
|
|
389
|
+
os.ftruncate(stats_fd, 0)
|
|
390
|
+
os.pwrite(stats_fd, data, 0)
|
|
391
|
+
report()
|
|
392
|
+
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
|
|
393
|
+
sys.exit(f'landlock.py: no_new_privs errno {ctypes.get_errno()}')
|
|
394
|
+
if libc.syscall(SYS_RESTRICT, ctypes.c_int(ruleset), ctypes.c_uint32(0)) < 0:
|
|
395
|
+
sys.exit(f'landlock.py: restrict_self errno {ctypes.get_errno()}')
|
|
396
|
+
os.close(ruleset)
|
|
397
|
+
libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)
|
|
398
|
+
# The command's process sends its filter's listener back over this pair.
|
|
399
|
+
if loopback:
|
|
400
|
+
state['supervisor'] = 'not started'
|
|
401
|
+
report()
|
|
402
|
+
parent_end, child_end = socket.socketpair()
|
|
403
|
+
pid = os.fork()
|
|
404
|
+
if pid == 0:
|
|
405
|
+
os.setpgid(0, 0)
|
|
406
|
+
if loopback:
|
|
407
|
+
parent_end.close()
|
|
408
|
+
try:
|
|
409
|
+
if fault == 'unavailable':
|
|
410
|
+
raise OSError('test fault')
|
|
411
|
+
listener = install_filter()
|
|
412
|
+
socket.send_fds(child_end, [b'x'], [listener])
|
|
413
|
+
os.close(listener)
|
|
414
|
+
child_end.close()
|
|
415
|
+
except OSError as e:
|
|
416
|
+
# Never run the command unsupervised.
|
|
417
|
+
sys.stderr.write(f'landlock.py: network supervisor unavailable: {e}\n')
|
|
418
|
+
os._exit(125)
|
|
419
|
+
try:
|
|
420
|
+
os.execvp(cmd[0], cmd)
|
|
421
|
+
except OSError as e:
|
|
422
|
+
sys.stderr.write(f'landlock.py: exec {cmd[0]}: {e}\n')
|
|
423
|
+
os._exit(127)
|
|
424
|
+
# Also here, so a signal that comes before the child runs finds its group.
|
|
425
|
+
try:
|
|
426
|
+
os.setpgid(pid, pid)
|
|
427
|
+
except OSError:
|
|
428
|
+
pass
|
|
429
|
+
def forward(number, frame):
|
|
430
|
+
try:
|
|
431
|
+
os.killpg(pid, number)
|
|
432
|
+
except (ProcessLookupError, PermissionError):
|
|
433
|
+
pass
|
|
434
|
+
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
435
|
+
signal.signal(number, forward)
|
|
436
|
+
supervisor = None
|
|
437
|
+
if loopback:
|
|
438
|
+
child_end.close()
|
|
439
|
+
try:
|
|
440
|
+
_, fds, _, _ = socket.recv_fds(parent_end, 1, 1)
|
|
441
|
+
except OSError:
|
|
442
|
+
fds = []
|
|
443
|
+
parent_end.close()
|
|
444
|
+
if fds:
|
|
445
|
+
state['supervisor'] = 'running'
|
|
446
|
+
supervisor = threading.Thread(target=supervise, args=(fds[0], state, writes, fault == 'die'), daemon=True)
|
|
447
|
+
supervisor.start()
|
|
448
|
+
else:
|
|
449
|
+
state['supervisor'] = 'unavailable'
|
|
450
|
+
_, status = os.waitpid(pid, 0)
|
|
451
|
+
stop_left()
|
|
452
|
+
# Every filtered process is gone: the supervisor sees its listener hang up.
|
|
453
|
+
if supervisor:
|
|
454
|
+
supervisor.join(5)
|
|
455
|
+
if supervisor.is_alive():
|
|
456
|
+
state['supervisor'] = 'did not end with the command'
|
|
457
|
+
report()
|
|
458
|
+
code = os.waitstatus_to_exitcode(status)
|
|
459
|
+
if code >= 0:
|
|
460
|
+
return code
|
|
461
|
+
# Killed by a signal: end the same way, so the caller sees the signal.
|
|
462
|
+
try:
|
|
463
|
+
signal.signal(-code, signal.SIG_DFL)
|
|
464
|
+
except (OSError, ValueError):
|
|
465
|
+
pass
|
|
466
|
+
os.kill(os.getpid(), -code)
|
|
467
|
+
return 128 - code
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def children(me):
|
|
471
|
+
# The kernel's list of this process's children, where it keeps one.
|
|
472
|
+
try:
|
|
473
|
+
found = set()
|
|
474
|
+
for task in os.listdir('/proc/self/task'):
|
|
475
|
+
with open(f'/proc/self/task/{task}/children') as f:
|
|
476
|
+
found.update(int(pid) for pid in f.read().split())
|
|
477
|
+
return list(found)
|
|
478
|
+
except OSError:
|
|
479
|
+
pass
|
|
480
|
+
# Else every process's parent. The fields after the command name, which
|
|
481
|
+
# may hold spaces or parentheses.
|
|
482
|
+
found = []
|
|
483
|
+
for entry in os.listdir('/proc'):
|
|
484
|
+
if not entry.isdigit():
|
|
485
|
+
continue
|
|
486
|
+
try:
|
|
487
|
+
with open(f'/proc/{entry}/stat') as f:
|
|
488
|
+
stat = f.read()
|
|
489
|
+
except OSError:
|
|
490
|
+
continue
|
|
491
|
+
if int(stat[stat.rindex(')') + 2:].split()[1]) == me:
|
|
492
|
+
found.append(int(entry))
|
|
493
|
+
return found
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# Every process left under the launcher: SIGTERM, then SIGKILL after a second.
|
|
497
|
+
# A process's own children are reparented here when it ends, so this repeats
|
|
498
|
+
# until none is left, for at most three seconds: a process in uninterruptible
|
|
499
|
+
# sleep is named and left.
|
|
500
|
+
def stop_left():
|
|
501
|
+
me, start, number = os.getpid(), time.monotonic(), signal.SIGTERM
|
|
502
|
+
while True:
|
|
503
|
+
while True:
|
|
504
|
+
try:
|
|
505
|
+
done, _ = os.waitpid(-1, os.WNOHANG)
|
|
506
|
+
except ChildProcessError:
|
|
507
|
+
done = 0
|
|
508
|
+
if done == 0:
|
|
509
|
+
break
|
|
510
|
+
left = children(me)
|
|
511
|
+
if not left:
|
|
512
|
+
return
|
|
513
|
+
if time.monotonic() > start + 3:
|
|
514
|
+
sys.stderr.write(f'landlock.py: processes {left} did not stop\n')
|
|
515
|
+
return
|
|
516
|
+
if time.monotonic() > start + 1:
|
|
517
|
+
number = signal.SIGKILL
|
|
518
|
+
for pid in left:
|
|
519
|
+
try:
|
|
520
|
+
os.kill(pid, number)
|
|
521
|
+
except ProcessLookupError:
|
|
522
|
+
pass
|
|
523
|
+
time.sleep(0.05)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
if __name__ == '__main__':
|
|
527
|
+
sys.exit(main(sys.argv[1:]))
|
package/src/linux-inotify.py
CHANGED
|
@@ -73,18 +73,35 @@ def main(root):
|
|
|
73
73
|
file_paths = {}
|
|
74
74
|
intentional_removals = set()
|
|
75
75
|
|
|
76
|
+
# No input lies in a .git directory (config refuses such patterns and the
|
|
77
|
+
# index skips them), so none is watched: git creates object directories
|
|
78
|
+
# there on most commits, and each would be an observation gap.
|
|
79
|
+
def in_git(target):
|
|
80
|
+
return '.git' in os.path.relpath(target, root).split(os.sep)
|
|
81
|
+
|
|
76
82
|
def add_tree(folder):
|
|
77
83
|
stack = [folder]
|
|
78
84
|
while stack:
|
|
79
85
|
current = stack.pop()
|
|
86
|
+
if in_git(current):
|
|
87
|
+
continue
|
|
80
88
|
wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
|
|
81
89
|
if wd < 0:
|
|
82
|
-
|
|
90
|
+
error = ctypes.get_errno()
|
|
91
|
+
# A new directory deleted before its watch: nothing to watch,
|
|
92
|
+
# and its parent reports the deletion.
|
|
93
|
+
if current != root and error in (errno.ENOENT, errno.ENOTDIR):
|
|
94
|
+
continue
|
|
95
|
+
raise OSError(error, 'inotify_add_watch failed')
|
|
83
96
|
watches[wd] = current
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
try:
|
|
98
|
+
with os.scandir(current) as entries:
|
|
99
|
+
for entry in entries:
|
|
100
|
+
if entry.is_dir(follow_symlinks=False):
|
|
101
|
+
stack.append(entry.path)
|
|
102
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
103
|
+
if current == root:
|
|
104
|
+
raise
|
|
88
105
|
|
|
89
106
|
def drain():
|
|
90
107
|
while True:
|
|
@@ -119,6 +136,15 @@ def main(root):
|
|
|
119
136
|
emit('gap', reason='inotify_watch_unknown')
|
|
120
137
|
return False
|
|
121
138
|
target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
|
|
139
|
+
if in_git(target):
|
|
140
|
+
continue
|
|
141
|
+
if mask & IN_DELETE_SELF and parent != root:
|
|
142
|
+
# A watched directory was deleted: its IN_IGNORED follows
|
|
143
|
+
# and is expected. The engine rescans the path.
|
|
144
|
+
del watches[wd]
|
|
145
|
+
intentional_removals.add(wd)
|
|
146
|
+
emit('event', type='delete', path=parent)
|
|
147
|
+
continue
|
|
122
148
|
if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
|
|
123
149
|
emit('gap', reason='inotify_watch_lost')
|
|
124
150
|
return False
|
|
@@ -128,11 +154,14 @@ def main(root):
|
|
|
128
154
|
except OSError as error:
|
|
129
155
|
emit('gap', reason='inotify_watch_add_failed', errno=error.errno)
|
|
130
156
|
return False
|
|
131
|
-
# Contents can appear before
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
157
|
+
# Contents can appear before the new directory has its
|
|
158
|
+
# watch. The watches exist now, so later changes raise
|
|
159
|
+
# events; the engine rescans the directory on this event
|
|
160
|
+
# and finds what came before.
|
|
161
|
+
emit('event', type='create', path=target)
|
|
162
|
+
continue
|
|
163
|
+
# A moved-away directory keeps watches under its old paths.
|
|
164
|
+
if (mask & IN_ISDIR) and (mask & IN_MOVED_FROM):
|
|
136
165
|
emit('gap', reason='inotify_directory_removed')
|
|
137
166
|
return False
|
|
138
167
|
event = ('delete' if mask & (IN_DELETE | IN_MOVED_FROM) else
|