@redue/cli 0.1.0-alpha.26 → 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 +80 -0
- package/README.md +39 -11
- package/bin/redue.mjs +35 -16
- package/docs/user/node-script.md +2 -2
- package/docs/user/sandbox.md +121 -20
- package/docs/user/supported-projects.md +9 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +9 -1
- package/src/cli.mjs +82 -10
- package/src/daemon.mjs +18 -6
- package/src/declared-plan.mjs +2 -2
- package/src/index.mjs +2 -1
- package/src/landlock.py +351 -7
- package/src/presentation.mjs +2 -1
- package/src/python-onboarding.mjs +9 -7
- package/src/sandbox.mjs +197 -27
- package/src/stats.mjs +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
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
|
+
|
|
43
|
+
## 0.1.0-alpha.27 — 2026-11-01
|
|
44
|
+
|
|
45
|
+
A build or test run no longer makes a sandboxed typecheck or lint STALE,
|
|
46
|
+
and `redue stats` shows what REDUE saved you.
|
|
47
|
+
|
|
48
|
+
- **Files git ignores are hidden from sandboxed runs.** A sandboxed
|
|
49
|
+
typecheck or lint now runs without the files git ignores (`dist/`,
|
|
50
|
+
`.next/`, coverage files, `.env`), except `node_modules`, `.venv` and
|
|
51
|
+
runner caches. Those files are not its inputs, so a build or test run that
|
|
52
|
+
writes them leaves the result CURRENT. The claim: the result holds for a
|
|
53
|
+
run that sees only the project's files that git doesn't ignore, its
|
|
54
|
+
installed packages and the toolchain. Only the project's `.gitignore`
|
|
55
|
+
files and `.git/info/exclude` count, not your global excludes file.
|
|
56
|
+
- **Checks that need ignored files keep working.** `init` tries the
|
|
57
|
+
sandbox with ignored files hidden first (`sandbox-v2`), then with the
|
|
58
|
+
whole checkout (`sandbox-v1`), then the general recipe. A hidden run that
|
|
59
|
+
fails on a hidden file runs again in the sandbox without hiding, and that
|
|
60
|
+
result is kept. Checks set up by alpha.26 stay `sandbox-v1` until you run
|
|
61
|
+
`redue init` again.
|
|
62
|
+
- **Measured.** In the product census, after each project's own build and
|
|
63
|
+
tests ran, checks that stayed CURRENT (alpha.26 in brackets, from the
|
|
64
|
+
measurement census): TypeScript on Linux 31 of 37 (5 of 43), on macOS 24
|
|
65
|
+
of 34 (5 of 38); Python on Linux 31 of 38 (24 of 37), on macOS 28 of 34
|
|
66
|
+
(20 of 33). Where a check qualifies with both, the sandbox stayed CURRENT
|
|
67
|
+
16 times and the general recipe 9 of 23 (Linux). The strace cross-check
|
|
68
|
+
found no read outside the recorded inputs.
|
|
69
|
+
- **`redue stats`.** Per check: reruns the hook skipped, the time that
|
|
70
|
+
saved, recorded runs, and the top reasons a check was UNVERIFIED, from a
|
|
71
|
+
log in REDUE's local state.
|
|
72
|
+
- `redue remove-state` no longer fails on Windows while the observer is
|
|
73
|
+
still closing its files.
|
|
74
|
+
- Known limits: a sandboxed run can still see that a hidden file exists
|
|
75
|
+
(its name on macOS; listings, sizes and times on Linux), so a tool that
|
|
76
|
+
acts on existence alone is not covered; tests do not use hidden files for
|
|
77
|
+
that reason. On Linux, a file a run creates in a folder that holds hidden
|
|
78
|
+
files cannot be read back in the same run.
|
|
79
|
+
- Contributors: eval entry points that run third-party code refuse to start
|
|
80
|
+
unless `GITHUB_ACTIONS=true`, and importing any eval entry point throws
|
|
81
|
+
before it does anything.
|
|
82
|
+
|
|
3
83
|
## 0.1.0-alpha.26 — 2026-10-31
|
|
4
84
|
|
|
5
85
|
Typecheck and lint checks run inside an OS sandbox, so REDUE can reuse them
|
package/README.md
CHANGED
|
@@ -59,25 +59,53 @@ what the agent asks. Plain `redue status` is a fast cached answer and can say
|
|
|
59
59
|
UNVERIFIED ("cannot confirm your execution context") where `--sync` would say
|
|
60
60
|
CURRENT.
|
|
61
61
|
|
|
62
|
+
`redue stats` shows, per check, the reruns the hook skipped, the time saved,
|
|
63
|
+
the recorded runs and the top reasons a check was UNVERIFIED, from a log kept
|
|
64
|
+
on your machine.
|
|
65
|
+
|
|
62
66
|
To undo everything setup did: `redue remove`.
|
|
63
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
|
+
|
|
64
87
|
## What it covers today
|
|
65
88
|
|
|
66
|
-
In a census of 120 active public TypeScript repositories (
|
|
67
|
-
at commit
|
|
68
|
-
with dependencies' install scripts on,
|
|
89
|
+
In a census of 120 active public TypeScript repositories (November 2026, REDUE
|
|
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.
|
|
69
92
|
A check counts when two recorded runs on the same code both came out CURRENT
|
|
70
|
-
on the same inputs.
|
|
93
|
+
on the same inputs. 38 repositories had at least one such check:
|
|
71
94
|
|
|
72
95
|
| Check | Repositories | How |
|
|
73
96
|
|---|---|---|
|
|
74
|
-
| Typecheck |
|
|
75
|
-
| Lint |
|
|
76
|
-
| Tests |
|
|
77
|
-
|
|
78
|
-
In
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
103
|
+
|
|
104
|
+
In a census of 120 Python repositories, 74 installed on a Linux runner
|
|
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
|
|
81
109
|
with strace: each file it read in the checkout was a recorded input, a cache
|
|
82
110
|
or its own output.
|
|
83
111
|
|
package/bin/redue.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {directCompiler,directQualification,directEnvironment,directInstalledInpu
|
|
|
7
7
|
import {runArgv,tscRun} from '../src/typescript-invocation.mjs';
|
|
8
8
|
import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
|
|
9
9
|
import {scriptEnvironment,scriptInterpretation,scriptKinds,scriptQualification} from '../src/node-script.mjs';
|
|
10
|
-
import {pythonTool,sandboxEnvironment,
|
|
10
|
+
import {hiddenListIssue,hiddenQualification,isSandboxed,pythonTool,sandboxEnvironment,sandboxInterpretationOf,sandboxKinds,sandboxQualification} from '../src/sandbox.mjs';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import {createHash} from 'node:crypto';
|
|
@@ -46,6 +46,7 @@ Commands:
|
|
|
46
46
|
detail [CHECK] [--json] Alias for explain
|
|
47
47
|
run CHECK [--if-due] Execute one configured check and record its outcome; with --if-due,
|
|
48
48
|
only when its last pass does not apply to the files as they are now
|
|
49
|
+
stats [--json] Per check: skips, time saved, recorded runs, why checks were UNVERIFIED
|
|
49
50
|
remove-state Stop and remove only this configuration's owned local state
|
|
50
51
|
|
|
51
52
|
No checks run in the background. Direct commands outside redue create no receipts.`;
|
|
@@ -95,7 +96,7 @@ if(command==='agent'){
|
|
|
95
96
|
catch(e){error(e.message);}
|
|
96
97
|
await exit(0);
|
|
97
98
|
}
|
|
98
|
-
if(!['init','start','stop','status','detail','explain','run','remove-state'].includes(command))
|
|
99
|
+
if(!['init','start','stop','status','detail','explain','run','stats','remove-state'].includes(command))
|
|
99
100
|
error(`unknown command ${command||'<none>'}; use --help`);
|
|
100
101
|
const json=args.includes('--json');
|
|
101
102
|
// run --if-due: run the check only when its last pass does not apply now.
|
|
@@ -150,21 +151,30 @@ if(command==='init'){
|
|
|
150
151
|
if(!chosen.length)error('no discovered check matches the selected workspace and kind');
|
|
151
152
|
discovery.checks=chosen;discovery.config.checks=chosen.map(row=>row.config);
|
|
152
153
|
}
|
|
153
|
-
// A sandboxed check is proposed only when it gives the same exit status
|
|
154
|
-
// inside the sandbox as outside it
|
|
154
|
+
// A sandboxed check is proposed only when it gives the same exit status and
|
|
155
|
+
// output inside the sandbox as outside it: first with the checkout's
|
|
156
|
+
// git-ignored files hidden (sandbox-v2), else with the whole checkout
|
|
157
|
+
// (sandbox-v1), reusing the plain run. Otherwise init writes what it would
|
|
155
158
|
// have without the sandboxed recipe, with the reason.
|
|
159
|
+
const trialIssue=trial=>trial.issue?`the sandbox cannot run here (${trial.issue})`:
|
|
160
|
+
// A failure both ways that shows a refusal cannot be told from a real one.
|
|
161
|
+
trial.same?null:trial.plain!==trial.sandboxed?`exit ${trial.sandboxed} inside the sandbox, ${trial.plain} outside it${trial.line?` (${trial.line})`:''}`:
|
|
162
|
+
trial.line?`it fails both ways (exit ${trial.plain}), with a sign of a refusal inside the sandbox (${trial.line})`:
|
|
163
|
+
`the output differs inside the sandbox: ${JSON.stringify(trial.output.sandboxed)} instead of ${JSON.stringify(trial.output.plain)}`;
|
|
156
164
|
let trials=0;
|
|
157
165
|
for(const [at,row] of [...discovery.checks.entries()]){
|
|
158
166
|
if(row.qualification!==sandboxQualification||row.level!=='ready')continue;
|
|
159
167
|
if(!json)console.error(`Running ${row.config.name} inside and outside the sandbox to confirm it...`);
|
|
160
|
-
const
|
|
161
|
-
if(!
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
const hidden=await sandboxTrial(discovery.root,row.config,{hide:true});
|
|
169
|
+
if(!hidden.issue)trials++;
|
|
170
|
+
if(!hidden.issue&&hidden.same){
|
|
171
|
+
row.config={...row.config,qualification:hiddenQualification};row.qualification=hiddenQualification;
|
|
172
|
+
row.reason=row.reason.replace('the whole checkout is its input',"the checkout's files git doesn't ignore are its input")+`; ran both ways at init, exit ${hidden.plain}`;continue;}
|
|
173
|
+
// Without git, nothing is hidden: the whole checkout is tried on its own.
|
|
174
|
+
const trial=hidden.plainRun?await sandboxTrial(discovery.root,row.config,{plain:hidden.plainRun}):
|
|
175
|
+
hidden.issue===hiddenListIssue?await sandboxTrial(discovery.root,row.config):hidden;
|
|
176
|
+
const issue=trialIssue(trial);
|
|
177
|
+
if(!issue){row.reason+=`; ran both ways at init, exit ${trial.plain}; it needs files git ignores (${hidden.issue??trialIssue(hidden)})`;continue;}
|
|
168
178
|
if(row.fallback)discovery.checks[at]={...row.fallback,reason:`Sandboxed recipe unavailable: ${issue}; ${row.fallback.reason}`};
|
|
169
179
|
else{discovery.checks[at]=null;discovery.skipped.push({kind:row.config.name,reason:`${row.config.name}: sandboxed recipe unavailable: ${issue}`});}
|
|
170
180
|
}
|
|
@@ -310,7 +320,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
310
320
|
const pnpmTypecheck=check.qualification==='pnpm-tsc-v1';
|
|
311
321
|
const testsDirect=check.qualification===testsQualification;
|
|
312
322
|
const generalScript=check.qualification===scriptQualification;
|
|
313
|
-
const sandboxed=check.qualification
|
|
323
|
+
const sandboxed=isSandboxed(check.qualification);
|
|
314
324
|
if(check.script&&check.command&&!pnpmTypecheck&&!npmDirect&&!testsDirect)
|
|
315
325
|
error(`${check.name}: choose script or command, not both`);
|
|
316
326
|
const manager=publicConfig.packageManager;
|
|
@@ -371,7 +381,7 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
371
381
|
const windowsContext=process.platform==='win32'?
|
|
372
382
|
['ComSpec','COMSPEC','PATHEXT','USERPROFILE']:[];
|
|
373
383
|
const autoEnvironment=npmDirect?directEnvironment:testsDirect?testEnvironment:generalScript?scriptEnvironment(manager):
|
|
374
|
-
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',
|
|
375
385
|
'NODE_ENV','BASH_ENV','ENV',...windowsContext],prefixes:['npm_config_','DYLD_','TSGO_'],
|
|
376
386
|
pathExecutables:['node','npm'],executableIdentity:true}:yarnWorkspace?
|
|
377
387
|
{variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
|
|
@@ -398,12 +408,18 @@ const checks=publicConfig.checks.map(check=>{
|
|
|
398
408
|
...(npmDirect?{qualificationInterpretation:directInterpretation}:{}),
|
|
399
409
|
...(testsDirect?{qualificationInterpretation:testsInterpretation}:{}),
|
|
400
410
|
...(generalScript?{qualificationInterpretation:scriptInterpretation}:{}),
|
|
401
|
-
...(sandboxed?{qualificationInterpretation:
|
|
411
|
+
...(sandboxed?{qualificationInterpretation:sandboxInterpretationOf(check.qualification)}:{}),
|
|
402
412
|
workspace:check.workspace||null,kind:check.kind||null};
|
|
403
413
|
});
|
|
404
414
|
const runtime={schema:1,provider:'declared-project@1',root,state,checks};
|
|
405
415
|
try{assertOwnedStatePlacement(runtime);}catch(e){error(e.message);}
|
|
406
416
|
const serialized=JSON.stringify(runtime,null,2)+'\n';
|
|
417
|
+
if(command==='stats'){
|
|
418
|
+
const {readStats,renderStats}=await import('../src/stats.mjs');
|
|
419
|
+
const value=readStats(state,publicConfig.checks.map(item=>item.name));
|
|
420
|
+
console.log(json?JSON.stringify(value):renderStats(value));
|
|
421
|
+
await exit(0);
|
|
422
|
+
}
|
|
407
423
|
const maintenance=command==='stop'||command==='remove-state';
|
|
408
424
|
if(maintenance&&!fs.existsSync(runtimeFile)){
|
|
409
425
|
console.log(`No REDUE state exists for ${configFile}.`);
|
|
@@ -443,11 +459,14 @@ const internal=command==='remove-state'?'uninstall':
|
|
|
443
459
|
// Asked when this part of a command runs, after earlier parts may have
|
|
444
460
|
// changed files. REDUE_FORCE=1 runs it anyway.
|
|
445
461
|
if(ifDue&&process.env.REDUE_FORCE!=='1'){
|
|
446
|
-
const {verdict}=await import('../src/claude-hook.mjs');
|
|
462
|
+
const [{verdict},{note,skipNote,dueNote}]=await Promise.all([import('../src/claude-hook.mjs'),import('../src/stats.mjs')]);
|
|
447
463
|
const asked=spawnSync(process.execPath,[fileURLToPath(import.meta.url),'--config',configFile,...(stateOverride?['--state-dir',stateOverride]:[]),
|
|
448
464
|
'status','--sync','--json'],{encoding:'utf8',stdio:['ignore','pipe','ignore'],env:process.env,timeout:600_000});
|
|
449
465
|
let status=null;try{status=JSON.parse(asked.stdout);}catch{}
|
|
466
|
+
const due=dueNote(status,check);
|
|
467
|
+
if(due)note(state,due);
|
|
450
468
|
if(verdict(status,check)){
|
|
469
|
+
note(state,skipNote(status,check,'if-due'));
|
|
451
470
|
const row=status.checks.find(item=>item.name===check);
|
|
452
471
|
if(json)console.log(JSON.stringify({check,skipped:true,result:'PASS',run_id:row.invocation.runId}));
|
|
453
472
|
else console.error(`REDUE: ${check} passed on these exact inputs (run ${row.invocation.runId}); not run again. REDUE_FORCE=1 runs it anyway.`);
|
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,27 +1,34 @@
|
|
|
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
|
-
**The claim.** A sandboxed result holds for a run that sees only the project
|
|
11
|
-
its installed packages and the toolchain, the
|
|
12
|
-
that a run with your own configuration files, a
|
|
13
|
-
network would give the same result.
|
|
10
|
+
**The claim.** A sandboxed result holds for a run that sees only the project's
|
|
11
|
+
files that git doesn't ignore, its installed packages and the toolchain, the
|
|
12
|
+
way CI does. It does not say that a run with your own configuration files, a
|
|
13
|
+
service on your machine or the network would give the same result.
|
|
14
|
+
|
|
15
|
+
A check that needs files git ignores (generated code, another package's
|
|
16
|
+
built declarations) gets the whole checkout instead: its result holds for a
|
|
17
|
+
run that sees the project, git-ignored files included, its installed packages
|
|
18
|
+
and the toolchain. See [files git ignores](#files-git-ignores).
|
|
14
19
|
|
|
15
20
|
## Where it applies
|
|
16
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).
|
|
17
24
|
- **Node projects:** a root `typecheck` or `lint` script (npm, pnpm, or Yarn
|
|
18
25
|
with the `node-modules` linker). `init` tries the
|
|
19
26
|
[direct TypeScript recipe](direct-typescript.md) and the pnpm and Yarn
|
|
20
27
|
TypeScript recipes first; they stay first where they apply. Then the
|
|
21
28
|
sandboxed recipe, before the [general recipe](node-script.md), since it
|
|
22
|
-
also covers the native programs a script starts. Where init's
|
|
23
|
-
difference, or the sandbox cannot run, `init` writes the general recipe
|
|
24
|
-
before. Where the sandbox cannot start at run time (inside Claude Code's
|
|
29
|
+
also covers the native programs a script starts. Where init's trials show
|
|
30
|
+
a difference, or the sandbox cannot run, `init` writes the general recipe
|
|
31
|
+
as before. Where the sandbox cannot start at run time (inside Claude Code's
|
|
25
32
|
macOS sandbox), a script the general recipe takes is recorded as it records
|
|
26
33
|
it.
|
|
27
34
|
- **Python projects:** mypy, pyright, ty and ruff installed in the project's
|
|
@@ -44,12 +51,14 @@ redue status --sync
|
|
|
44
51
|
A Python check in the config:
|
|
45
52
|
|
|
46
53
|
```json
|
|
47
|
-
{"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-
|
|
54
|
+
{"name": "typecheck", "kind": "typecheck", "qualification": "sandbox-v2",
|
|
48
55
|
"command": [".venv/bin/mypy", "src"], "inputs": ["**", "..."], "installedInputs": [".venv/**", "..."]}
|
|
49
56
|
```
|
|
50
57
|
|
|
51
58
|
A Node check names its script, as the general recipe's do:
|
|
52
|
-
`{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-
|
|
59
|
+
`{"name": "lint", "script": "lint", "kind": "lint", "qualification": "sandbox-v2", ...}`.
|
|
60
|
+
`sandbox-v2` hides the files git ignores from the run; `sandbox-v1` runs it on
|
|
61
|
+
the whole checkout.
|
|
53
62
|
|
|
54
63
|
With the Claude Code hook, `uv run mypy src`, `poetry run mypy src`,
|
|
55
64
|
`python -m mypy src`, `.venv/bin/mypy src` and `mypy src` are recognized as
|
|
@@ -57,18 +66,40 @@ that check. The last two count only when PATH finds the tool or Python in the
|
|
|
57
66
|
project's `.venv/bin` first. When the hook runs the check through REDUE, it
|
|
58
67
|
keeps `uv run` or `poetry run` in front, so the environment still syncs first.
|
|
59
68
|
|
|
69
|
+
## Files git ignores
|
|
70
|
+
|
|
71
|
+
`init` runs each check three ways at most: plainly, sandboxed with the files
|
|
72
|
+
git ignores hidden, and, when those two differ, sandboxed with the whole
|
|
73
|
+
checkout (reusing the plain run). The first sandboxed way that gives the same
|
|
74
|
+
result is written: `sandbox-v2` or `sandbox-v1`.
|
|
75
|
+
|
|
76
|
+
With `sandbox-v2`, each run hides what `git ls-files --others --ignored
|
|
77
|
+
--exclude-standard --directory` lists, except `node_modules`, `.venv` and
|
|
78
|
+
runner caches: build output (`dist/`, `.next/`), coverage files, `.env` files.
|
|
79
|
+
Only the project's rules count: its `.gitignore` files and
|
|
80
|
+
`.git/info/exclude`, not your global excludes file. Those files are not
|
|
81
|
+
inputs, so a build or test run that writes them leaves the result CURRENT.
|
|
82
|
+
|
|
83
|
+
When a `sandbox-v2` run fails with a refusal that names a hidden file (a
|
|
84
|
+
missing file counts only when it names one, since Linux tools report an
|
|
85
|
+
unreadable file as missing), REDUE runs the check again in the sandbox
|
|
86
|
+
without hiding anything. That result is kept and compared on every file,
|
|
87
|
+
git-ignored ones included. Run `redue init` again to write `sandbox-v1` for a
|
|
88
|
+
check that keeps needing them.
|
|
89
|
+
|
|
60
90
|
## What the run sees
|
|
61
91
|
|
|
62
92
|
It may read and execute only beneath:
|
|
63
93
|
|
|
64
|
-
- the checkout
|
|
94
|
+
- the checkout, without the files git ignores for `sandbox-v2`;
|
|
65
95
|
- the toolchain: Node's installation and the package managers' (with
|
|
66
96
|
Corepack's downloads), or the Python installation the `.venv` was made from;
|
|
67
97
|
- system directories (`/usr`, `/bin`, `/etc` and the like on Linux; `/usr`,
|
|
68
98
|
`/System`, `/Library`, `/opt/homebrew` and the like on macOS);
|
|
69
99
|
- a private home directory and a fresh temporary directory, in REDUE's state.
|
|
70
100
|
|
|
71
|
-
|
|
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
|
|
72
103
|
Unix sockets are in its own temporary directory, which is new for each run.
|
|
73
104
|
On Linux 6.12 and later it cannot reach an abstract Unix socket outside the
|
|
74
105
|
run.
|
|
@@ -96,14 +127,50 @@ It gets a controlled environment, not yours:
|
|
|
96
127
|
- `HOME`, `TMPDIR` and the XDG directories in its private directories;
|
|
97
128
|
`VIRTUAL_ENV` for a Python check; Corepack's home where Corepack keeps it.
|
|
98
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
|
+
|
|
99
165
|
## What REDUE claims
|
|
100
166
|
|
|
101
167
|
CURRENT means the last sandboxed run's inputs are unchanged:
|
|
102
168
|
|
|
103
|
-
- every file in the checkout
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
169
|
+
- every file in the checkout git doesn't ignore (`sandbox-v2`), or every file
|
|
170
|
+
in it, git-ignored files and git-ignored links to files in it included
|
|
171
|
+
(`sandbox-v1`), except `.git` and runner caches (the general recipe's, plus
|
|
172
|
+
`__pycache__`, `.mypy_cache`, `.ruff_cache` and `.pytest_cache`);
|
|
173
|
+
- `node_modules` and `.venv`, observed as installed files, by identity;
|
|
107
174
|
- the environment the run gets (without display variables such as `TERM`
|
|
108
175
|
and `COLUMNS`), and the places it may read;
|
|
109
176
|
- Node's and the package managers' executables, or the interpreter the
|
|
@@ -111,7 +178,8 @@ CURRENT means the last sandboxed run's inputs are unchanged:
|
|
|
111
178
|
- every directory on PATH, by identity: installing, removing or upgrading a
|
|
112
179
|
program in one makes the result STALE;
|
|
113
180
|
- `.git/info/exclude` and `.git/config`, by content: ruff and biome read the
|
|
114
|
-
ignore rules there.
|
|
181
|
+
ignore rules there, and they decide what `sandbox-v2` hides. For a project
|
|
182
|
+
in a subdirectory of its repository, also the `.gitignore` files above it.
|
|
115
183
|
|
|
116
184
|
## Telling a refusal from a real failure
|
|
117
185
|
|
|
@@ -124,6 +192,9 @@ every failure twice to find out:
|
|
|
124
192
|
timings, the temporary and home directories, and lines about caches.
|
|
125
193
|
Otherwise `init` writes what it would have without this recipe and says why.
|
|
126
194
|
These are the only runs `init` makes.
|
|
195
|
+
- When a `sandbox-v2` run fails with a refusal that names a hidden file, it
|
|
196
|
+
runs again in the sandbox without hiding (see
|
|
197
|
+
[files git ignores](#files-git-ignores)).
|
|
127
198
|
- When a sandboxed run fails and its output has a line that looks like a
|
|
128
199
|
refusal ("Operation not permitted", "Permission denied", "Read-only file
|
|
129
200
|
system", a name lookup or a connection that failed), REDUE runs the check
|
|
@@ -133,7 +204,7 @@ every failure twice to find out:
|
|
|
133
204
|
|
|
134
205
|
## The private home
|
|
135
206
|
|
|
136
|
-
|
|
207
|
+
A typecheck or lint's home directory is kept between runs, per check, so caches tools
|
|
137
208
|
build there (font caches, a type checker's cache) are not rebuilt every run.
|
|
138
209
|
It starts empty: your configuration files are not in it. What a run leaves
|
|
139
210
|
there can change a later run, and REDUE trusts it like a runner cache. Remove
|
|
@@ -167,3 +238,33 @@ it with `redue remove-state`.
|
|
|
167
238
|
nested example project's).
|
|
168
239
|
- A script that changes files (`--fix`, `--write`) does not get this recipe:
|
|
169
240
|
`init` would run it twice.
|
|
241
|
+
- With `sandbox-v2`, a run cannot read a hidden file, but it can still see
|
|
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
|
|
244
|
+
whether a git-ignored file exists, without reading it, is not covered: a
|
|
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).
|
|
261
|
+
- On Linux, a file a `sandbox-v2` run creates in a folder that holds hidden
|
|
262
|
+
files (the checkout's root, when anything in it is hidden) cannot be read
|
|
263
|
+
back in the same run. `init`'s trial and the rerun without hiding catch a
|
|
264
|
+
check that needs to.
|
|
265
|
+
- A file whose git status changes without a change to its content (`git add
|
|
266
|
+
-f` of an ignored file, `git rm --cached` of a tracked one) is noticed only
|
|
267
|
+
when its content next changes. A change to `.gitignore` or
|
|
268
|
+
`.git/info/exclude` makes the result STALE.
|
|
269
|
+
- A run that moves a hidden folder into a visible place can read it there.
|
|
270
|
+
That changes the checkout during the run, so the run is not reused.
|
|
@@ -19,12 +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
|
|
23
|
-
macOS and Linux (the script run inside an OS sandbox, the
|
|
24
|
-
|
|
22
|
+
for a root typecheck, lint or test script, the [sandboxed recipe](sandbox.md) on
|
|
23
|
+
macOS and Linux (the script run inside an OS sandbox, with the files git
|
|
24
|
+
ignores hidden when it gives the same result that way, else on the whole
|
|
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
|
|
25
27
|
through the package manager with every Node process recorded and the whole
|
|
26
28
|
checkout as its input. Python projects get the sandboxed recipe for mypy,
|
|
27
|
-
pyright, ty and
|
|
29
|
+
pyright, ty, ruff and pytest in their `.venv`. `--recipe typescript-direct`,
|
|
28
30
|
`tests-direct`, `node-script` or `sandbox` selects one recipe alone. The direct TypeScript recipe supports
|
|
29
31
|
a typecheck script of `tsc --noEmit` runs in any argument order, chained runs,
|
|
30
32
|
or no typecheck script at all (a proposed `tsc --noEmit`; see
|
|
@@ -55,7 +57,9 @@ typechecks, Vitest/Jest, ESLint, and builds in workspace packages start
|
|
|
55
57
|
recording-only unless a separately justified explicit contract is provided.
|
|
56
58
|
For a single-package project whose test script runs Vitest 4/5 or Jest 29
|
|
57
59
|
directly, `init` uses the direct test recipe, which records the run and guards
|
|
58
|
-
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.
|
|
59
63
|
In particular, test caches and build-generated inputs are not excluded to
|
|
60
64
|
make evidence green.
|
|
61
65
|
|
package/package.json
CHANGED
package/src/claude-hook.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {processAlive} from './process-liveness.mjs';
|
|
|
13
13
|
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
14
|
import {recipeLabel} from './presentation.mjs';
|
|
15
15
|
import {findExecutable} from './executable-lookup.mjs';
|
|
16
|
+
import {note,skipNote,dueNote} from './stats.mjs';
|
|
16
17
|
|
|
17
18
|
export const hookCommand='redue agent hook claude';
|
|
18
19
|
// No shell metacharacters; `@` starts scoped package names.
|
|
@@ -25,7 +26,8 @@ const tests=check=>check?.qualification==='npm-tests-direct-v1'&&Array.isArray(c
|
|
|
25
26
|
const general=check=>(check?.qualification==='node-script-v1'&&['typecheck','test','lint'].includes(check.kind)||
|
|
26
27
|
sandboxed(check))&&typeof check.script==='string';
|
|
27
28
|
// A sandboxed check: a root package script, or a tool from the project's .venv.
|
|
28
|
-
|
|
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','test'].includes(check.kind);
|
|
29
31
|
const python=check=>sandboxed(check)&&Array.isArray(check.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')&&
|
|
30
32
|
check.command.every(word=>typeof word==='string'&&safe.test(word));
|
|
31
33
|
const eligibleCheck=check=>typecheck(check)||tests(check)||general(check)||python(check);
|
|
@@ -505,6 +507,9 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
505
507
|
const project=hookProject(root);
|
|
506
508
|
if(!project)return null;
|
|
507
509
|
const {record,config,parsed}=project;
|
|
510
|
+
// For `redue stats`: the checks a decision skipped, or the ones that were due.
|
|
511
|
+
const tally=(status,names,skipped)=>{let state;try{state=projectState(project.projectRoot,config);}catch{return;}
|
|
512
|
+
for(const name of names){const entry=skipped?skipNote(status,name,'hook'):dueNote(status,name);if(entry)note(state,entry);}};
|
|
508
513
|
const entered=enteredDir(command,root,project.projectRoot);
|
|
509
514
|
if(!entered)return null;
|
|
510
515
|
let scripts={};
|
|
@@ -537,6 +542,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
537
542
|
if(!names)return null;
|
|
538
543
|
const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
|
|
539
544
|
reason=fanoutVerdict(status,names,match.rest);
|
|
545
|
+
tally(status,names,Boolean(reason));
|
|
540
546
|
return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
|
|
541
547
|
}
|
|
542
548
|
// A compiler script: its checks in the order of its runs, or nothing when
|
|
@@ -550,6 +556,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
550
556
|
else{
|
|
551
557
|
const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
|
|
552
558
|
reason=fanoutVerdict(status,names,match.rest);
|
|
559
|
+
tally(status,names,Boolean(reason));
|
|
553
560
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
554
561
|
const pending=names.filter(name=>!verdict(status,name));
|
|
555
562
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
@@ -561,6 +568,7 @@ export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
|
561
568
|
}
|
|
562
569
|
const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
|
|
563
570
|
reason=verdict(status,name,match.rest);
|
|
571
|
+
tally(status,[name],Boolean(reason));
|
|
564
572
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
565
573
|
permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
566
574
|
// Rewrite only on a healthy answer for this check: without an observer,
|