@redue/cli 0.1.0-alpha.27 → 0.1.0-alpha.28
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 +40 -0
- package/README.md +32 -11
- package/bin/redue.mjs +1 -1
- package/docs/user/node-script.md +2 -2
- package/docs/user/sandbox.md +61 -12
- package/docs/user/supported-projects.md +7 -4
- package/package.json +1 -1
- package/src/claude-hook.mjs +1 -1
- package/src/cli.mjs +27 -5
- package/src/daemon.mjs +8 -31
- package/src/landlock.py +319 -20
- package/src/python-onboarding.mjs +9 -7
- package/src/sandbox.mjs +88 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.28 — 2026-11-02
|
|
4
|
+
|
|
5
|
+
Test suites can run in the sandbox, so REDUE can reuse a passing test run
|
|
6
|
+
for more projects, Python included.
|
|
7
|
+
|
|
8
|
+
- **Sandboxed tests.** A root test script that the direct test recipe does
|
|
9
|
+
not take, and pytest in a Python project's `.venv`, can now use the
|
|
10
|
+
sandboxed recipe. `init` keeps it only when the run in the sandbox gives the
|
|
11
|
+
same exit status and test counts as a plain run. On top of what a
|
|
12
|
+
typecheck or lint gets, a test run gets:
|
|
13
|
+
- **Loopback only.** It may listen on any address and connect to the local
|
|
14
|
+
host and Unix sockets, never to a remote host. On macOS, Seatbelt
|
|
15
|
+
enforces that. On Linux, a seccomp filter refuses UDP and other non-TCP
|
|
16
|
+
sockets, TCP Fast Open and `io_uring`, and hands each `connect` to
|
|
17
|
+
REDUE's launcher. The launcher allows a local port nothing listens on or
|
|
18
|
+
one the run's own processes listen on, and refuses everything else,
|
|
19
|
+
including a service you started outside the run (a local database). If
|
|
20
|
+
the launcher cannot start or stops early, the result is UNVERIFIED.
|
|
21
|
+
- **A private `/tmp`.** Its `TMPDIR` is new for each run, and it may not
|
|
22
|
+
write elsewhere in `/tmp` or `/var/tmp`.
|
|
23
|
+
- **A fresh home** for each run, as on CI.
|
|
24
|
+
- **Ignored files hidden** when the trial agrees, else the whole checkout.
|
|
25
|
+
- **Measured.** In the product census, test suites with a qualifying check
|
|
26
|
+
(alpha.27 in brackets): TypeScript on Linux 11 (2), on macOS 12 (2);
|
|
27
|
+
Python on Linux 17 (0), on macOS 14 (0). The strace cross-check found no
|
|
28
|
+
read outside the recorded inputs. The Linux connect supervisor added at
|
|
29
|
+
most 1.5% over the sandbox without it on suites with worker processes
|
|
30
|
+
(pytest-xdist, Jest, Vitest): apprise 233 s to 237 s, run-to-run spread
|
|
31
|
+
218-243 s. In a pilot with Claude Code on two pytest projects, the hook
|
|
32
|
+
skipped both "do the tests still pass?" reruns (19 s to 6 s and 28 s to
|
|
33
|
+
7 s), and plain reruns agreed with both skips.
|
|
34
|
+
- **macOS hides contents only.** On macOS, a hidden file's contents and
|
|
35
|
+
execution are refused, but its metadata is not, as on Linux. A test that
|
|
36
|
+
writes into an ignored folder works.
|
|
37
|
+
- Known limits: a suite that reads back ignored files it writes in the same
|
|
38
|
+
run (a coverage database, a build it starts) gets the whole checkout, and
|
|
39
|
+
one that writes tracked files or coverage data during the run ends
|
|
40
|
+
UNVERIFIED. On Linux, a test that needs a service started outside the run
|
|
41
|
+
fails in the sandbox, so `init` leaves it to a plain run.
|
|
42
|
+
|
|
3
43
|
## 0.1.0-alpha.27 — 2026-11-01
|
|
4
44
|
|
|
5
45
|
A build or test run no longer makes a sandboxed typecheck or lint STALE,
|
package/README.md
CHANGED
|
@@ -65,26 +65,47 @@ on your machine.
|
|
|
65
65
|
|
|
66
66
|
To undo everything setup did: `redue remove`.
|
|
67
67
|
|
|
68
|
+
## Still CURRENT after your build and tests
|
|
69
|
+
|
|
70
|
+
Running the project's build or tests writes files (`dist/`, `.next/`,
|
|
71
|
+
coverage). REDUE leaves those out when the files are git-ignored, so a
|
|
72
|
+
typecheck or lint that passed stays CURRENT. In the census, after each
|
|
73
|
+
project's own build, tests, typecheck and lint ran, the share of sandboxed
|
|
74
|
+
checks still CURRENT:
|
|
75
|
+
|
|
76
|
+
| | alpha.26 | alpha.27 |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| TypeScript, Linux | 5 of 43 | 31 of 37 |
|
|
79
|
+
| TypeScript, macOS | 5 of 38 | 24 of 34 |
|
|
80
|
+
| Python, Linux | 24 of 37 | 31 of 38 |
|
|
81
|
+
| Python, macOS | 20 of 33 | 28 of 34 |
|
|
82
|
+
|
|
83
|
+
The rest changed for real: the build rewrote a tracked file (a sitemap, a
|
|
84
|
+
feed, a generated source), added a file git doesn't ignore, or installed
|
|
85
|
+
packages.
|
|
86
|
+
|
|
68
87
|
## What it covers today
|
|
69
88
|
|
|
70
89
|
In a census of 120 active public TypeScript repositories (November 2026, REDUE
|
|
71
|
-
at commit
|
|
72
|
-
with dependencies' install scripts on,
|
|
90
|
+
at commit a2401cc, before 0.1.0-alpha.28), each installed as its users would,
|
|
91
|
+
with dependencies' install scripts on, 89 installed cleanly on a macOS runner.
|
|
73
92
|
A check counts when two recorded runs on the same code both came out CURRENT
|
|
74
|
-
on the same inputs.
|
|
93
|
+
on the same inputs. 38 repositories had at least one such check:
|
|
75
94
|
|
|
76
95
|
| Check | Repositories | How |
|
|
77
96
|
|---|---|---|
|
|
78
|
-
| Typecheck |
|
|
79
|
-
| Lint |
|
|
80
|
-
| Tests |
|
|
97
|
+
| Typecheck | 20 | 14 by running the project's script in an OS sandbox ([sandboxed recipe](docs/user/sandbox.md)), 13 of them with the files git ignores hidden; 5 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)); 1 by running the script with every Node process recorded ([general recipe](docs/user/node-script.md)) |
|
|
98
|
+
| Lint | 22 | 19 sandboxed recipe (18 with ignored files hidden), 3 general recipe |
|
|
99
|
+
| Tests | 12 | 10 sandboxed recipe, with ignored files hidden and loopback-only network; 2 Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
|
|
100
|
+
|
|
101
|
+
In alpha.27 the same census found 2 repositories with a qualifying test check
|
|
102
|
+
on macOS and on Linux; now 12 on macOS and 11 on Linux.
|
|
81
103
|
|
|
82
104
|
In a census of 120 Python repositories, 74 installed on a Linux runner
|
|
83
|
-
and
|
|
84
|
-
22, all through the sandboxed recipe
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
checks and 31 of 38 Python checks were still CURRENT on Linux. On Linux, every sandboxed run was traced
|
|
105
|
+
and 31 had a qualifying check: typecheck (mypy, pyright, ty) 15, lint (ruff)
|
|
106
|
+
22, tests (pytest) 17, all through the sandboxed recipe (14 of the test
|
|
107
|
+
suites with ignored files hidden, 3 with the whole checkout). In alpha.27, no
|
|
108
|
+
Python test suite qualified. On Linux, every sandboxed run was traced
|
|
88
109
|
with strace: each file it read in the checkout was a recorded input, a cache
|
|
89
110
|
or its own output.
|
|
90
111
|
|
package/bin/redue.mjs
CHANGED
|
@@ -381,7 +381,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
381
381
|
const windowsContext=process.platform==='win32'?
|
|
382
382
|
['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
|
|
383
383
|
const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):
|
|
384
|
-
sandboxed?sandboxEnvironment(Boolean(pythonTool(check)),check.script?manager:null):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
|
|
384
|
+
sandboxed?sandboxEnvironment(Boolean(pythonTool(check)),check.script?manager:null,check.kind):npmAutomatic?{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME',
|
|
385
385
|
'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
|
|
386
386
|
pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
|
|
387
387
|
{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
|
package/docs/user/node-script.md
CHANGED
|
@@ -8,8 +8,8 @@ pnpm and Yarn TypeScript recipes, the [direct test recipe](tests-direct.md)
|
|
|
8
8
|
and, on macOS and Linux, the [sandboxed recipe](sandbox.md) first. It uses
|
|
9
9
|
the general recipe for a root typecheck or lint script none of them takes,
|
|
10
10
|
and where the sandbox cannot run (at init, or at run time inside Claude
|
|
11
|
-
Code's macOS sandbox). Test scripts use the direct test recipe
|
|
12
|
-
not propose the general recipe for them.
|
|
11
|
+
Code's macOS sandbox). Test scripts use the direct test recipe or the
|
|
12
|
+
sandboxed recipe; `init` does not propose the general recipe for them.
|
|
13
13
|
|
|
14
14
|
It supports a project installed by npm, pnpm 9-12 (isolated or hoisted
|
|
15
15
|
`node_modules`) or Yarn with the `node-modules` linker, with one lockfile kind
|
package/docs/user/sandbox.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# Sandboxed recipe (typecheck and
|
|
1
|
+
# Sandboxed recipe (typecheck, lint and tests, any language)
|
|
2
2
|
|
|
3
|
-
The sandboxed recipe runs a typecheck or
|
|
3
|
+
The sandboxed recipe runs a typecheck, lint or test command inside an operating
|
|
4
4
|
system sandbox and reports later whether that result still applies to the
|
|
5
5
|
files you have. The sandbox holds for every process the command starts, so it
|
|
6
6
|
covers programs REDUE cannot record: TypeScript 7's native compiler, esbuild,
|
|
7
7
|
oxlint and tsgolint, biome, turbo, and Python tools such as mypy, pyright, ty
|
|
8
|
-
and ruff.
|
|
8
|
+
and ruff, and test runners such as pytest.
|
|
9
9
|
|
|
10
10
|
**The claim.** A sandboxed result holds for a run that sees only the project's
|
|
11
11
|
files that git doesn't ignore, its installed packages and the toolchain, the
|
|
@@ -19,6 +19,8 @@ and the toolchain. See [files git ignores](#files-git-ignores).
|
|
|
19
19
|
|
|
20
20
|
## Where it applies
|
|
21
21
|
|
|
22
|
+
- **Tests:** a root `test` script the [direct test recipe](tests-direct.md)
|
|
23
|
+
does not take, and pytest in the project's `.venv`. See [tests](#tests).
|
|
22
24
|
- **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
|
|
23
25
|
with the `node-modules` linker). `init` tries the
|
|
24
26
|
[direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
|
|
@@ -96,7 +98,8 @@ It may read and execute only beneath:
|
|
|
96
98
|
`/System`, `/Library`, `/opt/homebrew` and the like on macOS);
|
|
97
99
|
- a private home directory and a fresh temporary directory, in REDUE's state.
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
A typecheck or lint has no network: no TCP connection, loopback included
|
|
102
|
+
(a test run gets loopback; see [tests](#tests)). On macOS its only
|
|
100
103
|
Unix sockets are in its own temporary directory, which is new for each run.
|
|
101
104
|
On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
|
|
102
105
|
run.
|
|
@@ -124,6 +127,41 @@ It gets a controlled environment, not yours:
|
|
|
124
127
|
- `HOME`, `TMPDIR` and the XDG directories in its private directories;
|
|
125
128
|
`VIRTUAL_ENV` for a Python check; Corepack's home where Corepack keeps it.
|
|
126
129
|
|
|
130
|
+
## Tests
|
|
131
|
+
|
|
132
|
+
A test run gets what a typecheck or lint gets, and also:
|
|
133
|
+
|
|
134
|
+
- **Loopback, and no remote host.** It may listen on any address and accept
|
|
135
|
+
connections, and connect only to the local host (`127.0.0.0/8`, `::1`,
|
|
136
|
+
`0.0.0.0`, `::`) and Unix sockets.
|
|
137
|
+
- On macOS, Seatbelt enforces that.
|
|
138
|
+
- On Linux, Landlock cannot tell a loopback address from a remote one, so a
|
|
139
|
+
seccomp filter in the test's processes does instead. It refuses sockets
|
|
140
|
+
other than Unix, netlink and TCP when they are created (UDP included),
|
|
141
|
+
TCP Fast Open (`MSG_FASTOPEN`) and `io_uring`. It hands each `connect` to
|
|
142
|
+
REDUE's launcher. The launcher allows a local port no process listens on
|
|
143
|
+
(the test gets "connection refused") or one the run's own processes
|
|
144
|
+
listen on, and a Unix socket file in the checkout, the run's home or its
|
|
145
|
+
temporary directory. It refuses a service started outside the run (your
|
|
146
|
+
local database) and every remote host. Nothing else goes through the
|
|
147
|
+
launcher, so worker processes talk to each other as usual. If the
|
|
148
|
+
launcher's supervisor cannot start, or stops before the run ends, the
|
|
149
|
+
result is UNVERIFIED, never CURRENT.
|
|
150
|
+
- **A fresh home.** Its home directory is new and empty for each run, as on
|
|
151
|
+
CI: what a test leaves there is not observed, so it must not reach a later
|
|
152
|
+
run.
|
|
153
|
+
- **A private `/tmp`.** Its `TMPDIR` is its own, new for each run, and it may
|
|
154
|
+
not write in `/tmp` or `/var/tmp` outside it. A test that writes to a fixed
|
|
155
|
+
path there is refused.
|
|
156
|
+
|
|
157
|
+
`init` compares a test run's exit status and the counts its summary prints
|
|
158
|
+
(passed, failed, skipped), not every line: test runners print in the order
|
|
159
|
+
their workers finish. Git-ignored files are hidden from a test run when the
|
|
160
|
+
trial agrees, as for typecheck and lint. A suite that reads back git-ignored
|
|
161
|
+
files it writes in the same run (a coverage database, a build it starts
|
|
162
|
+
itself) gets the whole checkout. Files a hidden run writes beneath hidden
|
|
163
|
+
folders, or new files git ignores, are not changes to its inputs.
|
|
164
|
+
|
|
127
165
|
## What REDUE claims
|
|
128
166
|
|
|
129
167
|
CURRENT means the last sandboxed run's inputs are unchanged:
|
|
@@ -166,7 +204,7 @@ every failure twice to find out:
|
|
|
166
204
|
|
|
167
205
|
## The private home
|
|
168
206
|
|
|
169
|
-
|
|
207
|
+
A typecheck or lint's home directory is kept between runs, per check, so caches tools
|
|
170
208
|
build there (font caches, a type checker's cache) are not rebuilt every run.
|
|
171
209
|
It starts empty: your configuration files are not in it. What a run leaves
|
|
172
210
|
there can change a later run, and REDUE trusts it like a runner cache. Remove
|
|
@@ -201,14 +239,25 @@ it with `redue remove-state`.
|
|
|
201
239
|
- A script that changes files (`--fix`, `--write`) does not get this recipe:
|
|
202
240
|
`init` would run it twice.
|
|
203
241
|
- With `sandbox-v2`, a run cannot read a hidden file, but it can still see
|
|
204
|
-
that one exists
|
|
205
|
-
|
|
206
|
-
also list a hidden folder and see its files' sizes and times: Landlock
|
|
207
|
-
restricts neither beneath a folder the run may list. A tool that acts on
|
|
242
|
+
that one exists, its size and its times, and write beneath it. On Linux it
|
|
243
|
+
can also list a hidden folder; on macOS it cannot. A tool that acts on
|
|
208
244
|
whether a git-ignored file exists, without reading it, is not covered: a
|
|
209
|
-
new or removed one does not make the result STALE.
|
|
210
|
-
|
|
211
|
-
|
|
245
|
+
new or removed one does not make the result STALE. In a census of 136
|
|
246
|
+
test suites with ignored files after their build, none did.
|
|
247
|
+
- On Linux, a test run's UDP is refused, loopback included: the filter
|
|
248
|
+
decides when the socket is made, before its address is known. On macOS UDP
|
|
249
|
+
to loopback works. A test that needs it fails its trial and keeps another
|
|
250
|
+
recipe.
|
|
251
|
+
- On macOS, a test run can connect to a service on loopback that it did not
|
|
252
|
+
start (a local database on `127.0.0.1:5432`), and that service's state is
|
|
253
|
+
not observed: a change to it does not make the result STALE. On Linux the
|
|
254
|
+
launcher refuses it.
|
|
255
|
+
- On Linux, the launcher decides a `connect` from the address it reads in
|
|
256
|
+
the process's memory, and the kernel reads it again after. A process that
|
|
257
|
+
changes the address in between, from another thread, could reach another
|
|
258
|
+
host. Honest test code does not.
|
|
259
|
+
- On Linux, a typecheck or lint can still connect to a Unix socket file
|
|
260
|
+
outside it (a local database's).
|
|
212
261
|
- On Linux, a file a `sandbox-v2` run creates in a folder that holds hidden
|
|
213
262
|
files (the checkout's root, when anything in it is hidden) cannot be read
|
|
214
263
|
back in the same run. `init`'s trial and the rerun without hiding catch a
|
|
@@ -19,13 +19,14 @@ build scripts. It prefers familiar script names (for typecheck: `typecheck`,
|
|
|
19
19
|
`tsc`, Vitest/Jest, ESLint, or Vite commands under other names. Multiple
|
|
20
20
|
matches are reported as ambiguous rather than guessed. It never creates a new
|
|
21
21
|
project script. Without `--recipe`, `init` tries the strict recipes below first, then,
|
|
22
|
-
for a root typecheck or
|
|
22
|
+
for a root typecheck, lint or test script, the [sandboxed recipe](sandbox.md) on
|
|
23
23
|
macOS and Linux (the script run inside an OS sandbox, with the files git
|
|
24
24
|
ignores hidden when it gives the same result that way, else on the whole
|
|
25
|
-
checkout
|
|
25
|
+
checkout; a test run also gets loopback-only network and a private `/tmp`),
|
|
26
|
+
then, for typecheck and lint, the [general recipe](node-script.md): the script run
|
|
26
27
|
through the package manager with every Node process recorded and the whole
|
|
27
28
|
checkout as its input. Python projects get the sandboxed recipe for mypy,
|
|
28
|
-
pyright, ty and
|
|
29
|
+
pyright, ty, ruff and pytest in their `.venv`. `--recipe typescript-direct`,
|
|
29
30
|
`tests-direct`, `node-script` or `sandbox` selects one recipe alone. The direct TypeScript recipe supports
|
|
30
31
|
a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
|
|
31
32
|
or no typecheck script at all (a proposed `tsc --noEmit`; see
|
|
@@ -56,7 +57,9 @@ typechecks, Vitest/Jest, ESLint, and builds in workspace packages start
|
|
|
56
57
|
recording-only unless a separately justified explicit contract is provided.
|
|
57
58
|
For a single-package project whose test script runs Vitest 4/5 or Jest 29
|
|
58
59
|
directly, `init` uses the direct test recipe, which records the run and guards
|
|
59
|
-
its inputs; see [direct tests](tests-direct.md).
|
|
60
|
+
its inputs; see [direct tests](tests-direct.md). Other root test scripts get
|
|
61
|
+
the sandboxed recipe when its trial gives the same exit status and test counts
|
|
62
|
+
as a plain run.
|
|
60
63
|
In particular, test caches and build-generated inputs are not excluded to
|
|
61
64
|
make evidence green.
|
|
62
65
|
|
package/package.json
CHANGED
package/src/claude-hook.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','tes
|
|
|
27
27
|
sandboxed(check))&&typeof check.script==='string';
|
|
28
28
|
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
29
29
|
// sandbox.mjs's isSandboxed, written out: the hook does not load the sandbox module.
|
|
30
|
-
const sandboxed=check=>['sandbox-v1','sandbox-v2'].includes(check?.qualification)&&['typecheck','lint'].includes(check.kind);
|
|
30
|
+
const sandboxed=check=>['sandbox-v1','sandbox-v2'].includes(check?.qualification)&&['typecheck','lint','test'].includes(check.kind);
|
|
31
31
|
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
32
32
|
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
33
33
|
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
package/src/cli.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {patternMatcher} from './glob.mjs';
|
|
|
8
8
|
import {observedBy} from './installed-observation.mjs';
|
|
9
9
|
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
10
10
|
import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
11
|
-
import {denialLine,endWithChild,hiddenEntries,hiddenQualification,hiddenRefusal,isSandboxed,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
11
|
+
import {denialLine,endWithChild,hiddenEntries,hiddenQualification,hiddenRefusal,isSandboxed,runToEnd,supervisorIssue,visibleChange,testRun as sandboxTestRun,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
12
12
|
sandboxInterpretation,sandboxInterpretationOf,sandboxPlaces,sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import net from 'node:net';
|
|
@@ -331,12 +331,21 @@ async function execute() {
|
|
|
331
331
|
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
332
332
|
else{
|
|
333
333
|
const dirs=sandboxDirs(state,name);
|
|
334
|
+
// A test run: loopback networking, supervised on Linux (the launcher
|
|
335
|
+
// reports in REDUE's state, outside what the test may touch), a
|
|
336
|
+
// private /tmp, and a fresh home each run: what a test leaves in its
|
|
337
|
+
// home is not observed, so it must not reach a later run.
|
|
338
|
+
const tests=sandboxTestRun(selected),stats=tests&&support.mode==='landlock'?path.join(state,`launcher-${runId}.json`):null;
|
|
339
|
+
if(tests){dirs.home=path.join(dirs.tmp,'home');fs.mkdirSync(dirs.home,{mode:0o700});}
|
|
334
340
|
const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
335
341
|
// sandbox-v2 hides the checkout's git-ignored entries. Where git
|
|
336
342
|
// cannot list them, nothing is hidden, and the receipt compares every file.
|
|
337
343
|
const hidden=selected.qualification===hiddenQualification?hiddenEntries(root):null;
|
|
338
|
-
|
|
339
|
-
|
|
344
|
+
const options={reads,tmp:dirs.tmp,python:support.python,stats,
|
|
345
|
+
...tests?{network:'loopback',writes:[root,dirs.home,dirs.tmp],
|
|
346
|
+
fault:process.env.VSTATE_TEST_FAULTS==='1'?process.env.VSTATE_TEST_SUPERVISOR||null:null}:{}};
|
|
347
|
+
sandbox={mode:support.mode,dirs,reads,hidden,options,stats,
|
|
348
|
+
argv:sandboxArgv(support.mode,command,{...options,hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,'')))}),
|
|
340
349
|
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
341
350
|
}
|
|
342
351
|
}
|
|
@@ -481,6 +490,17 @@ async function execute() {
|
|
|
481
490
|
unexplained=touched.filter(file=>!ignored.has(file));
|
|
482
491
|
if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
483
492
|
}
|
|
493
|
+
// A run that hid git-ignored files: what it wrote beneath them (test
|
|
494
|
+
// output, coverage data) is not a change to its inputs (visibleChange);
|
|
495
|
+
// anything else is named.
|
|
496
|
+
let hiddenOnly=false;
|
|
497
|
+
if(sandbox?.hidden&&snap&&end&&snap.planId===end.planId&&snap.observationHealthy&&end.observationHealthy&&
|
|
498
|
+
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
499
|
+
const before=snap.files||{},after=end.files||{},events=end.inputEventPaths||[];
|
|
500
|
+
// During the run a new file outside the hidden entries was readable: it counts.
|
|
501
|
+
const found=visibleChange(root,sandbox.hidden,before,after,[...Object.keys(before),...Object.keys(after),...events],{during:true});
|
|
502
|
+
if(found===null){eventsExplained=true;hiddenOnly=true;}else unexplained=[found];
|
|
503
|
+
}
|
|
484
504
|
// The end certificate, recorded or probed, must cover what this compile received.
|
|
485
505
|
// With no input event, gap or content change in between, it also holds at the start.
|
|
486
506
|
// A run checked by full probes is compared with it too, for its run facts.
|
|
@@ -519,7 +539,7 @@ async function execute() {
|
|
|
519
539
|
`: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
|
|
520
540
|
}
|
|
521
541
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
522
|
-
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
542
|
+
if(!outputs&&!hiddenOnly&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
523
543
|
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
524
544
|
// A run that hid git-ignored files and failed with a refusal that names
|
|
525
545
|
// one runs again in the sandbox without hiding them: the check needs
|
|
@@ -530,7 +550,7 @@ async function execute() {
|
|
|
530
550
|
const line=hiddenRefusal(output.head+'\n'+output.tail,sandbox.hidden);
|
|
531
551
|
if(line){
|
|
532
552
|
console.error(`redue: the sandboxed run failed with what looks like a refusal of a file git ignores (${line}); running it again in the sandbox without hiding them`);
|
|
533
|
-
const again=await runToEnd(sandboxArgv(sandbox.mode,command,
|
|
553
|
+
const again=await runToEnd(sandboxArgv(sandbox.mode,command,sandbox.options),
|
|
534
554
|
{cwd:plan.cwd,env:sandbox.env,echo:true,started:child=>{activeChild=child;}});
|
|
535
555
|
activeChild=null;
|
|
536
556
|
hiddenRerun={line,exitCode:again.status,signal:again.signal||null,...again.error?{errorCode:again.error.code||again.error.name}:{}};
|
|
@@ -586,6 +606,8 @@ async function execute() {
|
|
|
586
606
|
}
|
|
587
607
|
}
|
|
588
608
|
}
|
|
609
|
+
// A test run whose network was not supervised throughout is not reused.
|
|
610
|
+
if(sandbox?.stats){const issue=supervisorIssue(sandbox.stats);if(issue)captureIssues.push(issue);fs.rmSync(sandbox.stats,{force:true});}
|
|
589
611
|
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
590
612
|
const stable=captureIssues.length===0;
|
|
591
613
|
// Two runs on the same inputs, environment and plan that disagree make the
|
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,15 @@ 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
|
-
// not list it as ignored now, as the next run's hidden entries would not.
|
|
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): the first
|
|
352
|
+
// changed input it could see (sandbox.mjs's visibleChange), or null; once
|
|
353
|
+
// per receipt and pair of fingerprints.
|
|
358
354
|
const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
|
|
359
355
|
function visibleChange(row,r,name) {
|
|
360
356
|
const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
361
|
-
if(row.visibleKey
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const current=combinedFiles(name);
|
|
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;
|
|
357
|
+
if(row.visibleKey!==key){const current=combinedFiles(name);
|
|
358
|
+
row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)]);}
|
|
359
|
+
return row.visibleChange;
|
|
383
360
|
}
|
|
384
361
|
function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
385
362
|
if(!index)return [];
|
|
@@ -433,7 +410,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
433
410
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
434
411
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
435
412
|
// 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));
|
|
413
|
+
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
414
|
item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
|
|
438
415
|
const names=[...(row.plan.environment.variables||[]),
|
|
439
416
|
...(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
|
|
@@ -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.
|
package/src/sandbox.mjs
CHANGED
|
@@ -31,7 +31,10 @@ export const hiddenInterpretation='sandbox@2';
|
|
|
31
31
|
export const hiddenListIssue='git cannot list the ignored files';
|
|
32
32
|
export const isSandboxed=qualification=>qualification===sandboxQualification||qualification===hiddenQualification;
|
|
33
33
|
export const sandboxInterpretationOf=qualification=>qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation;
|
|
34
|
-
export const sandboxKinds=['typecheck','lint'];
|
|
34
|
+
export const sandboxKinds=['typecheck','lint','test'];
|
|
35
|
+
// A test run gets loopback networking and a private /tmp (sandboxArgv's
|
|
36
|
+
// `network` and `writes`); a typecheck or lint gets no network.
|
|
37
|
+
export const testRun=check=>check?.kind==='test';
|
|
35
38
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
36
39
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
37
40
|
const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
|
|
@@ -62,10 +65,12 @@ export const pythonTool=check=>Array.isArray(check?.command)&&/^\.venv\/bin\/[A-
|
|
|
62
65
|
// script check names its package manager first among the executables, as the
|
|
63
66
|
// general recipe does.
|
|
64
67
|
const managers=['npm','pnpm','yarn','corepack'];
|
|
65
|
-
export function sandboxEnvironment(python=false,manager=null){
|
|
68
|
+
export function sandboxEnvironment(python=false,manager=null,kind=null){
|
|
66
69
|
return {variables:['TZ','LANG','LANGUAGE','CI','USER','LOGNAME'],
|
|
67
70
|
prefixes:['LC_','DYLD_','LD_',...python?['PYTHON','MYPY','RUFF_','PYRIGHT','TY_']:
|
|
68
|
-
['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_']
|
|
71
|
+
['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_'],
|
|
72
|
+
// A test runner's own variables.
|
|
73
|
+
...kind==='test'?python?['PYTEST_']:['VITEST_','JEST_']:[]],
|
|
69
74
|
...manager?{pathExecutables:['node',manager,...managers.filter(name=>name!==manager)]}:{}};
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -273,6 +278,34 @@ export function trackedBeneath(root,entries){
|
|
|
273
278
|
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
274
279
|
return r.status===0?new Set(r.stdout.split('\0').filter(Boolean)):null;
|
|
275
280
|
}
|
|
281
|
+
// For a run that hid `hidden` (relative entries), the first path of `paths`
|
|
282
|
+
// that changes its inputs, between `before` and `current` ({rel: identity}),
|
|
283
|
+
// or null. A file the run saw always counts. A file beneath a hidden entry
|
|
284
|
+
// counts only once git tracks it, and a new file only when git does not list
|
|
285
|
+
// it as ignored now, as the next run's hidden entries would not. When git
|
|
286
|
+
// cannot tell, every change counts. `external:` paths always count. With
|
|
287
|
+
// `during` (changes while the run ran), a new file outside the hidden
|
|
288
|
+
// entries counts too: the run could read it.
|
|
289
|
+
export function visibleChange(root,hidden,before,current,paths,{during=false}={}){
|
|
290
|
+
const entries=hidden.map(entry=>entry.replace(/\/$/,''));
|
|
291
|
+
const beneath=file=>entries.some(entry=>file===entry||file.startsWith(entry+'/'));
|
|
292
|
+
const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]).sort();
|
|
293
|
+
const seen=changed.filter(file=>file in before&&!beneath(file));
|
|
294
|
+
if(seen.length)return seen[0];
|
|
295
|
+
const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
|
|
296
|
+
if(covered.length){
|
|
297
|
+
const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
|
|
298
|
+
const found=covered.find(file=>!tracked||tracked.has(file));
|
|
299
|
+
if(found)return found;
|
|
300
|
+
}
|
|
301
|
+
if(!fresh.length)return null;
|
|
302
|
+
if(during)return fresh[0];
|
|
303
|
+
// Asked once per directory the run had no file in, then the files outside one.
|
|
304
|
+
const dirs=new Set(Object.keys(before).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
|
|
305
|
+
const newDir=file=>{const parts=file.split('/');for(let i=1;i<parts.length;i++){const dir=parts.slice(0,i).join('/');if(!dirs.has(dir))return dir+'/';}return null;};
|
|
306
|
+
const ignored=ignoredNow(root,[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))]);
|
|
307
|
+
return fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
|
|
308
|
+
}
|
|
276
309
|
// A refusal line that names a hidden entry (relative): a refusal by the
|
|
277
310
|
// sandbox, or a missing file, which Linux tools report for an unreadable one.
|
|
278
311
|
export function hiddenRefusal(text,hidden){
|
|
@@ -288,29 +321,59 @@ const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=pat
|
|
|
288
321
|
// metadata of their parent directories, and no network but Unix sockets in
|
|
289
322
|
// the run's own temporary directory (`own`), which is new for each run: a
|
|
290
323
|
// daemon an earlier run left behind cannot answer a later one. `hidden`:
|
|
291
|
-
// absolute paths it may not read
|
|
292
|
-
|
|
324
|
+
// absolute paths whose contents it may not read or execute.
|
|
325
|
+
// A test run (`network` "loopback") may bind and accept anywhere and
|
|
326
|
+
// connect to loopback only; with `writes`, it writes nothing beneath /tmp
|
|
327
|
+
// and /var/tmp but beneath those places (its checkout, home and own
|
|
328
|
+
// temporary directory).
|
|
329
|
+
export function seatbeltProfile(reads,own=null,hidden=[],{network='none',writes=null}={}){
|
|
293
330
|
const quote=value=>JSON.stringify(value);
|
|
331
|
+
const both=list=>[...new Set(list.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
|
|
294
332
|
const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
|
|
295
333
|
return ['(version 1)','(allow default)','(deny file-read* process-exec*)',
|
|
296
334
|
`(allow file-read* process-exec* ${all.map(dir=>`(subpath ${quote(dir)})`).join(' ')} (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
|
|
297
335
|
`(allow file-read-metadata ${ancestors(all).map(dir=>`(literal ${quote(dir)})`).join(' ')})`,
|
|
298
336
|
// The entry's own path, by its directory's real path: a hidden link's target is not hidden.
|
|
299
|
-
|
|
337
|
+
// Its contents only, as on Linux: a run may still see that it exists, and write beneath it.
|
|
338
|
+
...hidden.length?[`(deny file-read-data process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
|
|
300
339
|
.map(file=>`(subpath ${quote(file)})`).join(' ')})`]:[],
|
|
301
|
-
'(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]
|
|
340
|
+
'(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[],
|
|
341
|
+
...network==='loopback'?['(allow network-bind)','(allow network-inbound)','(allow network-outbound (remote ip "localhost:*"))']:[],
|
|
342
|
+
...writes?['(deny file-write* (subpath "/tmp") (subpath "/private/tmp") (subpath "/var/tmp") (subpath "/private/var/tmp"))',
|
|
343
|
+
`(allow file-write* ${both(writes).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
|
|
302
344
|
}
|
|
303
345
|
// The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
|
|
304
346
|
// Unix sockets beneath `tmp` on macOS, nothing beneath `hidden` (absolute).
|
|
305
347
|
// On Linux a hidden directory can still be listed and its entries stat'ed:
|
|
306
348
|
// Landlock restricts neither beneath a listable parent.
|
|
307
|
-
|
|
308
|
-
|
|
349
|
+
// `network` "loopback" and `writes`: a test run's (seatbeltProfile). On Linux
|
|
350
|
+
// its network is supervised: `stats` then says whether the supervisor served
|
|
351
|
+
// the whole run (supervisorIssue).
|
|
352
|
+
export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null}){
|
|
353
|
+
if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{network,writes}),...argv];
|
|
309
354
|
// The launcher's own warnings would become the check's output.
|
|
310
355
|
if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),
|
|
311
|
-
...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
|
|
356
|
+
...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
|
|
357
|
+
...network==='loopback'?['--loopback']:['--no-tcp'],
|
|
358
|
+
...writes?['--deny-write','/tmp','--deny-write','/var/tmp',...writes.flatMap(dir=>['--write',dir])]:[],
|
|
359
|
+
...fault?['--test-fault',fault]:[],'--',...argv];
|
|
312
360
|
throw Error(`unknown sandbox ${mode}`);
|
|
313
361
|
}
|
|
362
|
+
// Why a supervised run's network was not supervised throughout, from the
|
|
363
|
+
// launcher's stats file, or null when it was.
|
|
364
|
+
export function supervisorIssue(stats){
|
|
365
|
+
let value=null;try{value=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
|
|
366
|
+
return value?.supervisor==='ok'?null:`the network supervisor ${value?.supervisor?`reported: ${value.supervisor}`:'left no report'}`;
|
|
367
|
+
}
|
|
368
|
+
// The counts a test runner's summary gives, the last of each: pytest, Vitest,
|
|
369
|
+
// Jest, Mocha, node --test.
|
|
370
|
+
const ansi=/\u001b\[[0-9;]*m/g;
|
|
371
|
+
export function testCounts(text){
|
|
372
|
+
const plain=String(text).replace(ansi,'');
|
|
373
|
+
const last=(...patterns)=>{for(const re of patterns){let m,v=null;const g=new RegExp(re,'gim');while((m=g.exec(plain)))v=Number(m[1]);if(v!==null)return v;}return null;};
|
|
374
|
+
return {passed:last('(\\d+) (?:tests? )?(?:passed|passing)\\b','^(?:#|ℹ) pass (\\d+)'),failed:last('(\\d+) (?:tests? )?(?:failed|failing)\\b','^(?:#|ℹ) fail (\\d+)'),
|
|
375
|
+
skipped:last('(\\d+) (?:tests? )?(?:skipped|pending|todo)\\b','^(?:#|ℹ) (?:skip|skipped) (\\d+)')};
|
|
376
|
+
}
|
|
314
377
|
// A line of a failed run's output that looks like the sandbox refused
|
|
315
378
|
// something: a refused read or write, or the network unreachable. Such a
|
|
316
379
|
// failure is run again outside the sandbox to tell a denial from a real one.
|
|
@@ -463,18 +526,22 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
|
|
|
463
526
|
if(supported.issue)return {issue:supported.issue};
|
|
464
527
|
const python=Boolean(pythonTool(check));
|
|
465
528
|
const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
|
|
466
|
-
const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager)};
|
|
529
|
+
const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager,check.kind)};
|
|
530
|
+
const tests=testRun(check);
|
|
467
531
|
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),home=path.join(scratch,'home'),tmp=path.join(scratch,'tmp');
|
|
468
532
|
fs.mkdirSync(home);fs.mkdirSync(tmp);
|
|
469
533
|
try{
|
|
470
534
|
const entries=hide?hiddenEntries(root):[];
|
|
471
535
|
if(!entries)return {issue:hiddenListIssue};
|
|
472
|
-
const hidden=entries.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=hide&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
|
|
536
|
+
const hidden=entries.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
|
|
473
537
|
const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
|
|
474
538
|
return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
|
|
475
539
|
const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
|
|
476
540
|
const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
477
|
-
const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats
|
|
541
|
+
const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
|
|
542
|
+
...tests?{network:'loopback',writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
|
|
543
|
+
// A test run's network was supervised throughout, on Linux.
|
|
544
|
+
const supervisor=tests&&stats?supervisorIssue(stats):null;
|
|
478
545
|
// A failure both ways counts as the same only without a sign of a refusal.
|
|
479
546
|
const denied=sandboxed.status===0?null:denialLine(sandboxed.output,entries);
|
|
480
547
|
const realOf=dir=>{try{return fs.realpathSync(dir);}catch{return null;}},plainTmp=process.env.TMPDIR||os.tmpdir();
|
|
@@ -486,8 +553,14 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
|
|
|
486
553
|
if(at<0&&b.length>a.length)at=a.length;
|
|
487
554
|
const output=at<0?null:{plain:(a[at]??'(no more output)').slice(0,160),sandboxed:(b[at]??'(no more output)').slice(0,160)};
|
|
488
555
|
let launcher=null;if(stats)try{launcher=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
|
|
489
|
-
|
|
490
|
-
|
|
556
|
+
// Tests print in an order their workers decide: the counts their summary
|
|
557
|
+
// gives are compared, not every line.
|
|
558
|
+
// When neither summary can be read, every line is compared, as for a typecheck.
|
|
559
|
+
const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
|
|
560
|
+
const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
|
|
561
|
+
const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
|
|
562
|
+
return {same:plain.status===sandboxed.status&&!denied&&!differs&&!supervisor,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
|
|
563
|
+
line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
|
|
491
564
|
...hide?{hidden:entries.length,entries,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
|
|
492
565
|
}finally{fs.rmSync(scratch,{recursive:true,force:true});}
|
|
493
566
|
}
|