@redue/cli 0.1.0-alpha.27 → 0.1.0-alpha.29
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 +91 -0
- package/README.md +50 -22
- package/bin/redue.mjs +24 -12
- package/docs/user/agents.md +14 -7
- package/docs/user/getting-started.md +4 -3
- package/docs/user/json.md +2 -2
- package/docs/user/node-script.md +2 -2
- package/docs/user/sandbox.md +119 -42
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +10 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +49 -29
- package/src/cli.mjs +48 -17
- package/src/daemon.mjs +10 -31
- package/src/landlock.py +319 -20
- package/src/node-onboarding.mjs +3 -1
- package/src/presentation.mjs +4 -4
- package/src/python-onboarding.mjs +9 -7
- package/src/sandbox.mjs +168 -22
- package/src/setup.mjs +26 -4
package/src/daemon.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import {npmCacheDir} from './npm-integrity.mjs';
|
|
|
25
25
|
import {runFromArgv} from './typescript-invocation.mjs';
|
|
26
26
|
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
27
27
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
28
|
-
import {
|
|
28
|
+
import {isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
|
|
29
29
|
|
|
30
30
|
const configFile=path.resolve(process.argv[2]);
|
|
31
31
|
const config=JSON.parse(fs.readFileSync(configFile));
|
|
@@ -348,38 +348,17 @@ function describeChangedInput(plan,file) {
|
|
|
348
348
|
.map(mapping=>mapping.logical).slice(0,2);
|
|
349
349
|
return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
|
|
350
350
|
}
|
|
351
|
-
// A run that hid the checkout's git-ignored entries (sandbox@2)
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// Installed trees count whatever git says. The first changed input that
|
|
356
|
-
// counts, or null; once per receipt and pair of fingerprints. When git
|
|
357
|
-
// cannot tell, every change counts.
|
|
351
|
+
// A run that hid the checkout's git-ignored entries (sandbox@2), and maybe
|
|
352
|
+
// documentation files (sandbox@3, the receipt's sandbox.docs): the first
|
|
353
|
+
// changed input it could see (sandbox.mjs's visibleChange), or null; once
|
|
354
|
+
// per receipt and pair of fingerprints.
|
|
358
355
|
const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
|
|
359
356
|
function visibleChange(row,r,name) {
|
|
360
357
|
const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
361
|
-
if(row.visibleKey
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const changed=[...new Set([...Object.keys(r.files),...Object.keys(current)])].filter(file=>r.files[file]!==current[file]).sort();
|
|
366
|
-
const seen=changed.filter(file=>file in r.files&&!beneath(file));
|
|
367
|
-
const covered=changed.filter(file=>beneath(file)),fresh=changed.filter(file=>!(file in r.files)&&!beneath(file));
|
|
368
|
-
let found=seen[0]??null;
|
|
369
|
-
if(!found&&covered.length){
|
|
370
|
-
const tracked=trackedBeneath(root,hidden.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
|
|
371
|
-
found=covered.find(file=>!tracked||tracked.has(file))??null;
|
|
372
|
-
}
|
|
373
|
-
if(!found&&fresh.length){
|
|
374
|
-
// Ask once per directory the run had no file in, then the files outside one.
|
|
375
|
-
const before=new Set(Object.keys(r.files).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
|
|
376
|
-
const newDir=file=>{const parts=file.split('/');for(let i=1;i<parts.length;i++){const dir=parts.slice(0,i).join('/');if(!before.has(dir))return dir+'/';}return null;};
|
|
377
|
-
const asked=[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))];
|
|
378
|
-
const ignored=ignoredNow(root,asked);
|
|
379
|
-
found=fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
|
|
380
|
-
}
|
|
381
|
-
row.visibleKey=key;row.visibleChange=found;
|
|
382
|
-
return found;
|
|
358
|
+
if(row.visibleKey!==key){const current=combinedFiles(name);
|
|
359
|
+
row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
|
|
360
|
+
{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
|
|
361
|
+
return row.visibleChange;
|
|
383
362
|
}
|
|
384
363
|
function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
385
364
|
if(!index)return [];
|
|
@@ -433,7 +412,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
433
412
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
434
413
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
435
414
|
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
436
|
-
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox)/.test(issue));
|
|
415
|
+
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox|network supervisor)/.test(issue));
|
|
437
416
|
item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
|
|
438
417
|
const names=[...(row.plan.environment.variables||[]),
|
|
439
418
|
...(row.plan.environment.prefixes||[]).map(prefix=>
|
package/src/landlock.py
CHANGED
|
@@ -6,13 +6,31 @@
|
|
|
6
6
|
# connection to an abstract Unix socket outside the run. Writes are not
|
|
7
7
|
# handled, so they are not restricted.
|
|
8
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
|
+
#
|
|
9
25
|
# --hide PATH keeps the run from reading or executing beneath PATH, inside a
|
|
10
26
|
# --read place. Landlock only grants, beneath a whole directory, so such a
|
|
11
27
|
# place gets listing rights (beneath it all, hidden entries included) and
|
|
12
28
|
# read and execute rights on each of its entries that is not hidden and
|
|
13
29
|
# holds nothing hidden, descending into the ones that do. A hidden path can
|
|
14
30
|
# still be listed and stat'ed. --stats FILE writes the rule count and the
|
|
15
|
-
# time the rules took, as JSON
|
|
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.
|
|
16
34
|
#
|
|
17
35
|
# The launcher stays as the command's parent and a child subreaper: when the
|
|
18
36
|
# command exits, it stops every process the command left running, daemons
|
|
@@ -21,23 +39,240 @@
|
|
|
21
39
|
# launcher gets is passed to that group once. The launcher then ends as the
|
|
22
40
|
# command did: with its exit status, or by its signal.
|
|
23
41
|
#
|
|
24
|
-
# landlock.py --read /usr --read /tmp ... [--hide P ...] [--stats F] [--no-tcp
|
|
42
|
+
# landlock.py --read /usr --read /tmp ... [--hide P ...] [--stats F] [--no-tcp | --loopback]
|
|
43
|
+
# [--deny-write P ... --write P ...] -- cmd args...
|
|
25
44
|
# landlock.py --probe # prints {"abi": N} or {"abi": null, "errno": E}
|
|
26
45
|
import ctypes
|
|
46
|
+
import errno
|
|
27
47
|
import json
|
|
28
48
|
import os
|
|
49
|
+
import select
|
|
29
50
|
import signal
|
|
51
|
+
import socket
|
|
30
52
|
import sys
|
|
53
|
+
import threading
|
|
31
54
|
import time
|
|
32
55
|
|
|
33
56
|
SYS_CREATE, SYS_ADD, SYS_RESTRICT = 444, 445, 446 # the same on x86_64 and aarch64
|
|
34
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))
|
|
35
60
|
BIND_TCP, CONNECT_TCP = 1 << 0, 1 << 1
|
|
36
61
|
SCOPE_ABSTRACT_UNIX_SOCKET = 1 << 0
|
|
37
62
|
PR_SET_NO_NEW_PRIVS, PR_SET_CHILD_SUBREAPER = 38, 36
|
|
38
63
|
|
|
39
64
|
libc = ctypes.CDLL(None, use_errno=True)
|
|
40
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
|
|
41
276
|
|
|
42
277
|
|
|
43
278
|
class RulesetAttr(ctypes.Structure):
|
|
@@ -61,10 +296,19 @@ def main(argv):
|
|
|
61
296
|
v, err = abi()
|
|
62
297
|
print(json.dumps({'abi': v, 'errno': err}))
|
|
63
298
|
return 0
|
|
64
|
-
reads, hidden, stats, no_tcp = [], set(), None, False
|
|
299
|
+
reads, hidden, stats, no_tcp, loopback, deny_write, writes, fault = [], set(), None, False, False, set(), [], None
|
|
65
300
|
while argv and argv[0] != '--':
|
|
66
301
|
if argv[0] == '--read':
|
|
67
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:]
|
|
68
312
|
elif argv[0] == '--hide':
|
|
69
313
|
hidden.add(os.path.normpath(argv[1])); argv = argv[2:]
|
|
70
314
|
elif argv[0] == '--stats':
|
|
@@ -77,9 +321,10 @@ def main(argv):
|
|
|
77
321
|
version, err = abi()
|
|
78
322
|
if version is None:
|
|
79
323
|
sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
|
|
80
|
-
|
|
324
|
+
reading = EXECUTE | READ_FILE | READ_DIR
|
|
325
|
+
handled = reading | (WRITE if deny_write else 0)
|
|
81
326
|
attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
|
|
82
|
-
SCOPE_ABSTRACT_UNIX_SOCKET if no_tcp and version >= 6 else 0)
|
|
327
|
+
SCOPE_ABSTRACT_UNIX_SOCKET if (no_tcp or loopback) and version >= 6 else 0)
|
|
83
328
|
size = 24 if attr.scoped else 16 if attr.net else 8
|
|
84
329
|
ruleset = libc.syscall(SYS_CREATE, ctypes.byref(attr), ctypes.c_size_t(size), ctypes.c_uint32(0))
|
|
85
330
|
if ruleset < 0:
|
|
@@ -93,50 +338,84 @@ def main(argv):
|
|
|
93
338
|
return
|
|
94
339
|
# A file takes file rights only.
|
|
95
340
|
if rights is None:
|
|
96
|
-
rights =
|
|
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)
|
|
97
344
|
rule = PathBeneath(rights, fd)
|
|
98
345
|
if libc.syscall(SYS_ADD, ctypes.c_int(ruleset), ctypes.c_int(1), ctypes.byref(rule), ctypes.c_uint32(0)) < 0:
|
|
99
346
|
sys.exit(f'landlock.py: add_rule {place} errno {ctypes.get_errno()}')
|
|
100
347
|
os.close(fd)
|
|
101
348
|
count += 1
|
|
102
|
-
# The directories that hold
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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):
|
|
109
359
|
# Links are not followed: what they lead to is granted on its own or not at all.
|
|
110
360
|
try:
|
|
111
361
|
entries = list(os.scandir(directory))
|
|
112
362
|
except OSError:
|
|
113
363
|
return
|
|
114
364
|
for entry in entries:
|
|
115
|
-
if entry.path in
|
|
365
|
+
if entry.path in excluded or entry.is_symlink():
|
|
116
366
|
continue
|
|
117
367
|
if entry.path in holders:
|
|
118
|
-
grant(entry.path)
|
|
368
|
+
grant(entry.path, excluded, holders, rights)
|
|
119
369
|
else:
|
|
120
|
-
add(entry.path)
|
|
370
|
+
add(entry.path, rights)
|
|
371
|
+
holders = holders_of(hidden)
|
|
121
372
|
for place in reads:
|
|
122
373
|
place = os.path.normpath(place)
|
|
123
374
|
if place in holders and os.path.isdir(place):
|
|
124
375
|
add(place, READ_DIR)
|
|
125
|
-
grant(place)
|
|
376
|
+
grant(place, hidden, holders, reading)
|
|
126
377
|
else:
|
|
127
378
|
add(place)
|
|
128
|
-
if
|
|
129
|
-
|
|
130
|
-
|
|
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()
|
|
131
392
|
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
|
|
132
393
|
sys.exit(f'landlock.py: no_new_privs errno {ctypes.get_errno()}')
|
|
133
394
|
if libc.syscall(SYS_RESTRICT, ctypes.c_int(ruleset), ctypes.c_uint32(0)) < 0:
|
|
134
395
|
sys.exit(f'landlock.py: restrict_self errno {ctypes.get_errno()}')
|
|
135
396
|
os.close(ruleset)
|
|
136
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()
|
|
137
403
|
pid = os.fork()
|
|
138
404
|
if pid == 0:
|
|
139
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)
|
|
140
419
|
try:
|
|
141
420
|
os.execvp(cmd[0], cmd)
|
|
142
421
|
except OSError as e:
|
|
@@ -154,8 +433,28 @@ def main(argv):
|
|
|
154
433
|
pass
|
|
155
434
|
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
156
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'
|
|
157
450
|
_, status = os.waitpid(pid, 0)
|
|
158
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()
|
|
159
458
|
code = os.waitstatus_to_exitcode(status)
|
|
160
459
|
if code >= 0:
|
|
161
460
|
return code
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -323,7 +323,9 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
323
323
|
}catch(e){classification={...classification,reason:`Sandboxed recipe unavailable (${e.code?.replace(/^test-/,'')||e.message}); ${classification.reason}`};}
|
|
324
324
|
}
|
|
325
325
|
if(generalRow){checks.push(generalRow);continue;}
|
|
326
|
-
|
|
326
|
+
// A proposed `tsc --noEmit` that no recipe takes is left out, with why.
|
|
327
|
+
if(!script&&classification.level!=='ready'){
|
|
328
|
+
skipped.push({kind,reason:`${kind}: no typecheck script, and a proposed tsc --noEmit was not taken (${classification.reason})`});continue;}
|
|
327
329
|
if(parts&&classification.level==='ready'){
|
|
328
330
|
for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
|
|
329
331
|
continue;
|
package/src/presentation.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
|
|
4
4
|
'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
|
|
5
5
|
'typescript-noemit-v1':'npm TypeScript recipe','sandbox-v1':'sandboxed recipe',
|
|
6
|
-
'sandbox-v2':'sandboxed recipe, git-ignored files hidden'};
|
|
6
|
+
'sandbox-v2':'sandboxed recipe, git-ignored files hidden','sandbox-v3':'sandboxed recipe, git-ignored files and docs hidden'};
|
|
7
7
|
export const recipeLabel=recipe=>recipes[recipe]??null;
|
|
8
8
|
export function checkLabel(row) {
|
|
9
9
|
return row.result==='FAIL'?'FAILED':row.freshness;
|
|
@@ -12,8 +12,8 @@ export function explanation(row) {
|
|
|
12
12
|
const reason=row.reason||'No verification evidence is available.';
|
|
13
13
|
if(reason==='no receipt')return 'No execution has been recorded. Run this check when verification is needed.';
|
|
14
14
|
if(reason==='declared inputs match')return 'Recorded evidence still applies to the declared inputs.';
|
|
15
|
-
if(reason==='execution environment context not supplied')return 'Cached status cannot confirm your execution context.
|
|
16
|
-
if(/requires? a synchronized content read/.test(reason))return 'Cached status cannot establish applicability here.
|
|
15
|
+
if(reason==='execution environment context not supplied')return 'Cached status cannot confirm your execution context. Run redue status without --cached, or rerun the check.';
|
|
16
|
+
if(/requires? a synchronized content read/.test(reason))return 'Cached status cannot establish applicability here. Run redue status without --cached, or rerun the check.';
|
|
17
17
|
if(reason.startsWith('input coverage unresolved: '))return 'Recording-only: '+reason.slice(27);
|
|
18
18
|
if(/input plan (rebuilding|indexing)|observer initialization|input observation reconciliation pending/.test(reason))
|
|
19
19
|
return 'Checking inputs after startup or changes. Wait for observation to finish, then query again.';
|
|
@@ -56,6 +56,6 @@ export function renderRun(value) {
|
|
|
56
56
|
if(value.invocation!=='exited')lines.push(`Invocation ${value.invocation}; no reusable passing evidence.`);
|
|
57
57
|
else if(!value.coverage_qualified)lines.push('Applicability remains UNVERIFIED: input coverage is incomplete.');
|
|
58
58
|
else if(!value.stable)lines.push('Applicability remains UNVERIFIED: inputs changed or observation was unavailable during execution.');
|
|
59
|
-
else lines.push('
|
|
59
|
+
else lines.push('Check redue status before reusing this evidence.');
|
|
60
60
|
return lines.join('\n');
|
|
61
61
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Python projects for `redue init`: typecheck and
|
|
2
|
-
// pyright, ty or
|
|
1
|
+
// Python projects for `redue init`: typecheck, lint and test checks that run
|
|
2
|
+
// mypy, pyright, ty, ruff or pytest from the project's .venv under the sandboxed recipe
|
|
3
3
|
// (sandbox.mjs). A tool is proposed when it is installed in .venv/bin. Its
|
|
4
4
|
// arguments are the ones the project runs it with, read from CI workflows,
|
|
5
5
|
// the Makefile, tox, nox, a justfile and shell scripts; without one, the
|
|
6
|
-
// tool's own default (`ruff check`, `ty check`, `pyright`,
|
|
7
|
-
// its configured files or `.`).
|
|
6
|
+
// tool's own default (`ruff check`, `ty check`, `pyright`, `pytest`, and
|
|
7
|
+
// `mypy` with its configured files or `.`).
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import {realObservedPath} from './path-identity.mjs';
|
|
@@ -15,7 +15,7 @@ const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
|
|
|
15
15
|
const executable=file=>{try{fs.accessSync(file,fs.constants.X_OK);return fs.statSync(file).isFile();}catch{return false;}};
|
|
16
16
|
// Each tool, its kind, and the subcommand it runs as a check.
|
|
17
17
|
export const pythonTools=[{tool:'mypy',kind:'typecheck',sub:[]},{tool:'pyright',kind:'typecheck',sub:[]},
|
|
18
|
-
{tool:'ty',kind:'typecheck',sub:['check']},{tool:'ruff',kind:'lint',sub:['check']}];
|
|
18
|
+
{tool:'ty',kind:'typecheck',sub:['check']},{tool:'ruff',kind:'lint',sub:['check']},{tool:'pytest',kind:'test',sub:[]}];
|
|
19
19
|
// The text of a TOML or INI section, up to the next section header.
|
|
20
20
|
const section=(text,header)=>{const at=text.split(/\r?\n/).findIndex(line=>line.trim()===header);
|
|
21
21
|
if(at<0)return null;const rest=text.split(/\r?\n/).slice(at+1);const end=rest.findIndex(line=>/^\s*\[/.test(line));
|
|
@@ -27,7 +27,9 @@ const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|
|
|
|
27
27
|
/^\$\{?[A-Za-z_]+\}?(?=[A-Za-z])/];
|
|
28
28
|
// A tool's arguments in one line of a script or workflow, or null. The line
|
|
29
29
|
// must run the tool and its subcommand plainly; arguments with shell syntax,
|
|
30
|
-
//
|
|
30
|
+
// ones that change files or install packages, and pytest's that run a set
|
|
31
|
+
// chosen from its cache (--lf, --ff, --sw, --nf), show the cache, loop
|
|
32
|
+
// (-f), rewrite snapshots or add to earlier coverage data, are not taken.
|
|
31
33
|
export function toolArgs(line,{tool,sub}){
|
|
32
34
|
let text=line.trim().replace(/^-\s+/,'').replace(/^run:\s*/,'').replace(/^[@-]+/,'');
|
|
33
35
|
text=text.split(/\s(?:&&|\|\||;|\|)\s/)[0].replace(/\s+/g,' ').trim();
|
|
@@ -35,7 +37,7 @@ export function toolArgs(line,{tool,sub}){
|
|
|
35
37
|
const words=text.split(' ');
|
|
36
38
|
if(words[0]!==tool||JSON.stringify(words.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
|
|
37
39
|
const args=words.slice(1+sub.length);
|
|
38
|
-
if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?:fix|unsafe-fixes|write|install-types|watch)(?:=|$)/.test(arg)||arg.startsWith('..')||arg.startsWith('/')))return null;
|
|
40
|
+
if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?:fix|unsafe-fixes|write|install-types|watch|lf|last-failed|ff|failed-first|sw|sw-skip|sw-reset|stepwise|stepwise-skip|stepwise-reset|nf|new-first|cache-show|looponfail|snapshot-update|update-snapshots|inline-snapshot|cov-append)(?:=|$)/.test(arg)||arg==='-f'||arg.startsWith('..')||arg.startsWith('/')))return null;
|
|
39
41
|
return args;
|
|
40
42
|
}
|
|
41
43
|
// The places a project writes how it runs its tools, in order of preference.
|