@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 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 e264a67, before 0.1.0-alpha.27), each installed as its users would,
72
- with dependencies' install scripts on, 90 installed cleanly on a macOS runner.
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. 35 repositories had at least one such check:
93
+ on the same inputs. 38 repositories had at least one such check:
75
94
 
76
95
  | Check | Repositories | How |
77
96
  |---|---|---|
78
- | Typecheck | 22 | 16 by running the project's script in an OS sandbox ([sandboxed recipe](docs/user/sandbox.md)), 15 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)) |
79
- | Lint | 24 | 21 sandboxed recipe (19 with ignored files hidden), 3 general recipe |
80
- | Tests | 2 | Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
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 23 had a qualifying check: typecheck (mypy, pyright, ty) 15, lint (ruff)
84
- 22, all through the sandboxed recipe with ignored files hidden. A check whose
85
- git-ignored files are hidden stays CURRENT when a build or test run rewrites
86
- them: after each project's build and tests ran, 31 of 37 such TypeScript
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],
@@ -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; `init` does
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
@@ -1,11 +1,11 @@
1
- # Sandboxed recipe (typecheck and lint, any language)
1
+ # Sandboxed recipe (typecheck, lint and tests, any language)
2
2
 
3
- The sandboxed recipe runs a typecheck or lint command inside an operating
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
- It has no network: no TCP connection, loopback included. On macOS its only
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
- The run's home directory is kept between runs, per check, so caches tools
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. On macOS it sees the names of hidden files and folders at
205
- the level where they start (`dist` in the checkout's root). On Linux it can
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. Typecheckers and
210
- linters read what they act on; a test that skips itself when a built file
211
- is missing does not, which is why hidden files are not used for tests yet.
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 lint script, the [sandboxed recipe](sandbox.md) on
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), then the [general recipe](node-script.md): the script run
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 ruff in their `.venv`. `--recipe typescript-direct`,
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.27",
4
+ "version": "0.1.0-alpha.28",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
@@ -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
- sandbox={mode:support.mode,dirs,reads,hidden,argv:sandboxArgv(support.mode,command,{reads,tmp:dirs.tmp,python:support.python,
339
- hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,'')))}),
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,{reads:sandbox.reads,tmp:sandbox.dirs.tmp,python:support.python}),
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 {ignoredNow,isSandboxed,sandboxChange,trackedBeneath} from './sandbox.mjs';
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) did not see
352
- // them. A changed file the run saw always counts. A file beneath a hidden
353
- // entry counts only once git tracks it, and a new file only when git does
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===key)return row.visibleChange;
362
- const hidden=r.sandbox.hidden.map(entry=>entry.replace(/\/$/,''));
363
- const beneath=file=>hidden.some(entry=>file===entry||file.startsWith(entry+'/'));
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] -- cmd args...
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
- handled = EXECUTE | READ_FILE | READ_DIR
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 = handled if os.path.isdir(place) else EXECUTE | READ_FILE
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 a hidden path, up to the root.
103
- holders = set()
104
- for path in hidden:
105
- at = os.path.dirname(path)
106
- while at not in holders and at != os.path.dirname(at):
107
- holders.add(at); at = os.path.dirname(at)
108
- def grant(directory):
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 hidden or entry.is_symlink():
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 stats:
129
- with open(stats, 'w') as f:
130
- json.dump({'rules': count, 'ms': round((time.monotonic() - started) * 1000, 1)}, f)
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 lint checks that run mypy,
2
- // pyright, ty or ruff from the project's .venv under the sandboxed recipe
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`, and `mypy` with
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
- // or ones that change files or install packages, are not taken.
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 at all, not even their metadata.
292
- export function seatbeltProfile(reads,own=null,hidden=[]){
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
- ...hidden.length?[`(deny file-read* process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
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(' ')})`]:[]].join('\n');
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
- export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null}){
308
- if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden),...argv];
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]:[],'--no-tcp','--',...argv];
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}),sandboxEnv(selected,process.env,{root,home,tmp}));
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
- return {same:plain.status===sandboxed.status&&!denied&&!output,plain:plain.status,sandboxed:sandboxed.status,output,plainRun:plain,
490
- line:denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
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
  }