@redue/cli 0.1.0-alpha.25 → 0.1.0-alpha.26
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 +44 -0
- package/README.md +19 -11
- package/bin/redue.mjs +56 -8
- package/docs/README.md +1 -0
- package/docs/user/node-script.md +10 -6
- package/docs/user/sandbox.md +169 -0
- package/docs/user/supported-projects.md +8 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +52 -14
- package/src/cli.mjs +85 -15
- package/src/daemon.mjs +14 -4
- package/src/decision-validation.mjs +2 -1
- package/src/declared-plan.mjs +10 -1
- package/src/index.mjs +36 -3
- package/src/landlock.py +183 -0
- package/src/node-onboarding.mjs +41 -20
- package/src/node-script.mjs +16 -1
- package/src/presentation.mjs +1 -1
- package/src/python-onboarding.mjs +87 -0
- package/src/sandbox.mjs +396 -0
- package/src/setup.mjs +7 -2
package/src/cli.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
|
7
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
|
-
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
10
|
+
import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
11
|
+
import {denialLine,endWithChild,runToEnd,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,sandboxInterpretation,sandboxPlaces,
|
|
12
|
+
sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
|
|
11
13
|
import path from 'node:path';
|
|
12
14
|
import net from 'node:net';
|
|
13
15
|
import os from 'node:os';
|
|
@@ -83,7 +85,8 @@ function environmentReads() {
|
|
|
83
85
|
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
84
86
|
}
|
|
85
87
|
const recordedRecipes=[testsQualification,scriptQualification];
|
|
86
|
-
|
|
88
|
+
// A sandboxed check's run is recorded where its sandbox cannot start (execute).
|
|
89
|
+
function contextHashes() {const reads=config.checks.some(check=>[...recordedRecipes,sandboxQualification].includes(check.qualification))?environmentReads():{};
|
|
87
90
|
return Object.fromEntries(config.checks.map(check=>[
|
|
88
91
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
89
92
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
@@ -95,6 +98,8 @@ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.
|
|
|
95
98
|
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
96
99
|
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
97
100
|
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
|
|
101
|
+
// A sandboxed run's environment, toolchain and places (sandbox.mjs).
|
|
102
|
+
...(check.qualification===sandboxQualification?Object.entries(sandboxContext(check,process.env,root)):[]),
|
|
98
103
|
// The variables the last test run read directly, compared here: which
|
|
99
104
|
// list was compared, and the first variable whose value now differs.
|
|
100
105
|
// Control requests are small, so the values stay out of them.
|
|
@@ -211,10 +216,11 @@ async function execute() {
|
|
|
211
216
|
const lockStarted=performance.now(),runLock=acquireRunLock(state);
|
|
212
217
|
timing('cli.run_lock',lockStarted);
|
|
213
218
|
const captureIssues=[];
|
|
214
|
-
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
|
|
219
|
+
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false,ownGroup=false;
|
|
215
220
|
const forward=signal=>{requestedSignal=signal;
|
|
216
221
|
if(activeChild&&activeChild.exitCode===null){
|
|
217
|
-
|
|
222
|
+
// A Linux run, and a sandboxed one on macOS, has its own process group.
|
|
223
|
+
if((process.platform==='linux'||ownGroup)&&activeChild.pid){
|
|
218
224
|
try{process.kill(-activeChild.pid,signal);}
|
|
219
225
|
catch(e){if(e.code!=='ESRCH')throw e;}
|
|
220
226
|
}else if(process.platform==='win32'&&activeChild.pid){
|
|
@@ -290,10 +296,15 @@ async function execute() {
|
|
|
290
296
|
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
291
297
|
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
292
298
|
...command.slice(1)]:command;
|
|
299
|
+
// A sandboxed check runs inside the OS sandbox. Where the sandbox cannot
|
|
300
|
+
// start, a script the general recipe takes is recorded as that recipe
|
|
301
|
+
// records it (`fallback`); any other runs as it is, and is not reused.
|
|
302
|
+
const sandboxed=selected.qualification===sandboxQualification,support=sandboxed?sandboxSupport():null;
|
|
303
|
+
const fallback=Boolean(support?.issue&&typeof selected.script==='string'&&!generalIssue(root,selected.script));
|
|
293
304
|
// A test run's processes record what the guard checks (test-recorder.cjs),
|
|
294
305
|
// and so do a general recipe run's, through the package manager.
|
|
295
|
-
const general=selected.qualification===scriptQualification;
|
|
296
|
-
const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
|
|
306
|
+
const general=selected.qualification===scriptQualification||fallback;
|
|
307
|
+
const testRun=(recordedRecipes.includes(selected.qualification)||fallback)&&['darwin','linux'].includes(process.platform);
|
|
297
308
|
let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
|
|
298
309
|
if(testRun){
|
|
299
310
|
// The package directory and installed node_modules, as the plan has them.
|
|
@@ -313,6 +324,27 @@ async function execute() {
|
|
|
313
324
|
// npm's and pnpm's update checks read state outside the checkout and the network.
|
|
314
325
|
if(general)launchEnv.npm_config_update_notifier='false';
|
|
315
326
|
}
|
|
327
|
+
// The sandboxed run, with the environment REDUE gives it.
|
|
328
|
+
let sandbox=null;
|
|
329
|
+
if(sandboxed&&!fallback){
|
|
330
|
+
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
331
|
+
else{
|
|
332
|
+
const dirs=sandboxDirs(state,name);
|
|
333
|
+
const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
334
|
+
sandbox={mode:support.mode,dirs,argv:sandboxArgv(support.mode,command,{reads,tmp:dirs.tmp,python:support.python}),
|
|
335
|
+
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const plainEnv={...process.env,...selected.script?{npm_config_update_notifier:'false'}:{}};
|
|
339
|
+
// Output kept, and its own process group, for a run as this recipe makes it.
|
|
340
|
+
const own=sandboxed&&!fallback;
|
|
341
|
+
ownGroup=own&&process.platform!=='win32';
|
|
342
|
+
// A sandboxed run's output is passed through and its start and end kept,
|
|
343
|
+
// to tell a refusal by the sandbox from a real failure.
|
|
344
|
+
const output={head:'',tail:''};
|
|
345
|
+
const keep=chunk=>{const text=chunk.toString('utf8');
|
|
346
|
+
if(output.head.length<65536)output.head+=text.slice(0,65536-output.head.length);
|
|
347
|
+
output.tail=(output.tail+text).slice(-65536);};
|
|
316
348
|
let runnerPid=null;
|
|
317
349
|
const record=[];
|
|
318
350
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
@@ -335,11 +367,19 @@ async function execute() {
|
|
|
335
367
|
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
336
368
|
let child,spawnError=null;
|
|
337
369
|
try{runLock.phase('launching');
|
|
338
|
-
const launch=windowsLaunch(launched);
|
|
370
|
+
const launch=windowsLaunch(sandbox?.argv??launched);
|
|
339
371
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
372
|
+
// A sandboxed run's own group gets no terminal input, which would stop it.
|
|
373
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:own?['ignore','pipe','pipe']:'inherit',
|
|
374
|
+
env:sandbox?.env??(own?plainEnv:launchEnv),
|
|
375
|
+
detached:process.platform==='linux'||ownGroup,...launch.options});
|
|
376
|
+
child.stdio[3]?.on('data',chunk=>record.push(chunk));
|
|
377
|
+
if(own){child.stdout.on('data',chunk=>{process.stdout.write(chunk);keep(chunk);});
|
|
378
|
+
child.stderr.on('data',chunk=>{process.stderr.write(chunk);keep(chunk);});
|
|
379
|
+
// What the check left running is stopped when it exits, as a CI
|
|
380
|
+
// job's end does (on Linux the launcher also stops daemons that
|
|
381
|
+
// left the group), and the run ends a second after its exit.
|
|
382
|
+
endWithChild(child);}}
|
|
343
383
|
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
344
384
|
activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
|
|
345
385
|
mark('cli.child_started',{childPid:child.pid});
|
|
@@ -347,7 +387,7 @@ async function execute() {
|
|
|
347
387
|
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
348
388
|
child.on('close',async(exit,signal)=>{
|
|
349
389
|
if(treeStop)await treeStop;
|
|
350
|
-
if(process.platform==='linux'&&requestedSignal&&child.pid){
|
|
390
|
+
if((process.platform==='linux'||ownGroup)&&requestedSignal&&child.pid){
|
|
351
391
|
// A verification command may have forked children in its process
|
|
352
392
|
// group. Normal cancellation must reach them even if the leader has
|
|
353
393
|
// already exited. Commands that daemonize into a new session remain
|
|
@@ -369,7 +409,7 @@ async function execute() {
|
|
|
369
409
|
invocation.argc=command.length;
|
|
370
410
|
invocation.runId=runId;
|
|
371
411
|
invocation.reporting='normal';
|
|
372
|
-
|
|
412
|
+
let result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
373
413
|
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
374
414
|
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
375
415
|
timing('cli.context_after',afterContextStarted);
|
|
@@ -411,7 +451,7 @@ async function execute() {
|
|
|
411
451
|
// The start checkpoint's probe may have raised the deadline.
|
|
412
452
|
const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
413
453
|
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
414
|
-
...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
454
|
+
...((testRun||sandboxed)&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
415
455
|
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
416
456
|
endWait.timeout,endWait.deadline);}
|
|
417
457
|
catch{captureIssues.push('end observation unavailable');}
|
|
@@ -476,12 +516,40 @@ async function execute() {
|
|
|
476
516
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
477
517
|
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
478
518
|
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
519
|
+
// A sandboxed run that failed with what looks like a refusal by the
|
|
520
|
+
// sandbox runs again outside it. When that run exits differently, the
|
|
521
|
+
// sandbox changed the result: the plain run's result is kept, not reused.
|
|
522
|
+
// An interrupt before or during it stops there: the run is interrupted.
|
|
523
|
+
let sandboxRerun=null;
|
|
524
|
+
if(sandbox&&result==='FAIL'&&!requestedSignal){
|
|
525
|
+
const line=denialLine(output.head+'\n'+output.tail);
|
|
526
|
+
if(line){
|
|
527
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal by the sandbox (${line}); running it again outside the sandbox`);
|
|
528
|
+
// Interrupting REDUE interrupts the rerun's group too (forward).
|
|
529
|
+
const plain=await runToEnd(command,{cwd:plan.cwd,env:plainEnv,echo:true,started:child=>{activeChild=child;}});
|
|
530
|
+
activeChild=null;
|
|
531
|
+
sandboxRerun={line,exitCode:plain.status,signal:plain.signal||null,...plain.error?{errorCode:plain.error.code||plain.error.name}:{}};
|
|
532
|
+
if(requestedSignal){
|
|
533
|
+
captureIssues.push('interrupted during the run outside the sandbox');
|
|
534
|
+
Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
|
|
535
|
+
}
|
|
536
|
+
// A rerun that did not start or finish tells nothing: the sandboxed result stands, not reused.
|
|
537
|
+
else if(plain.error||plain.status===null)
|
|
538
|
+
captureIssues.push(`the run outside the sandbox did not finish (${plain.error?.code||plain.signal}), so a refusal by the sandbox is not ruled out (${line})`);
|
|
539
|
+
else if(plain.status!==invocation.exitCode){
|
|
540
|
+
captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line})`);
|
|
541
|
+
invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
479
546
|
const stable=captureIssues.length===0;
|
|
480
547
|
// Two runs on the same inputs, environment and plan that disagree make the
|
|
481
548
|
// check nondeterministic until its inputs change.
|
|
482
549
|
let nondeterministic=false;
|
|
483
550
|
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
484
|
-
if(
|
|
551
|
+
if(sandboxRerun)result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
552
|
+
if((testRun||sandboxed)&&stable&&result){
|
|
485
553
|
const prior=readReceipts(state).checks[name];
|
|
486
554
|
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
487
555
|
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
@@ -494,7 +562,7 @@ async function execute() {
|
|
|
494
562
|
// A recorded run's receipt carries its run facts, even none; observers that
|
|
495
563
|
// predate run facts do not reuse version 2.
|
|
496
564
|
// The recipe that made the receipt, which status and the hook name.
|
|
497
|
-
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
|
|
565
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:(fallback?scriptQualification:selected.qualification)||null,
|
|
498
566
|
...(recorded||testRun?{runFacts}:{}),
|
|
499
567
|
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
500
568
|
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
@@ -508,6 +576,8 @@ async function execute() {
|
|
|
508
576
|
planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
|
|
509
577
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
510
578
|
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
579
|
+
...(own?{verificationRecipe:sandboxInterpretation,sandbox:{mode:sandbox?.mode??null,...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
580
|
+
...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
|
|
511
581
|
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
512
582
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
513
583
|
const persistenceStarted=performance.now();
|
package/src/daemon.mjs
CHANGED
|
@@ -25,6 +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 {sandboxChange} from './sandbox.mjs';
|
|
28
29
|
|
|
29
30
|
const configFile=path.resolve(process.argv[2]);
|
|
30
31
|
const config=JSON.parse(fs.readFileSync(configFile));
|
|
@@ -63,7 +64,9 @@ let identityChanged=false;
|
|
|
63
64
|
let identityGapKey=null;
|
|
64
65
|
let triggerHashes=new Map();
|
|
65
66
|
let candidateDescendants=new Map();
|
|
66
|
-
|
|
67
|
+
// A Python project may have no package.json.
|
|
68
|
+
let workspacePatterns=(()=>{let w;try{w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;}
|
|
69
|
+
catch(e){if(e.code==='ENOENT')return [];throw e;}
|
|
67
70
|
return Array.isArray(w)?w:w?.packages||[];})();
|
|
68
71
|
let planningWorker=null;
|
|
69
72
|
let validatedPlan=null;
|
|
@@ -396,13 +399,19 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
396
399
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
397
400
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
398
401
|
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
399
|
-
|
|
402
|
+
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
403
|
+
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox)/.test(issue));
|
|
404
|
+
item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
|
|
400
405
|
const names=[...(row.plan.environment.variables||[]),
|
|
401
406
|
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
402
407
|
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
403
408
|
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
404
409
|
`__vstate_path_executable_${name}__`),
|
|
405
410
|
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
411
|
+
// A sandboxed run's variables, toolchain, PATH directories and places
|
|
412
|
+
// (sandbox.mjs): every entry either side has, and the first change named.
|
|
413
|
+
const sandboxChanged=row.plan.qualification==='sandbox-v1'&&contextHashes?.[name]?
|
|
414
|
+
sandboxChange(r?.environmentHashes,contextHashes[name]):null;
|
|
406
415
|
if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
|
|
407
416
|
item.freshness='STALE';
|
|
408
417
|
if(r.planId!==row.plan.id){
|
|
@@ -411,6 +420,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
411
420
|
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
412
421
|
if(changed)item.changed_inputs=[changed.path];
|
|
413
422
|
}
|
|
423
|
+
else if(sandboxChanged)item.reason=sandboxChanged;
|
|
414
424
|
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
415
425
|
item.reason='declared execution environment changed';
|
|
416
426
|
// Every variable a test run read directly must have the same value. The
|
|
@@ -796,8 +806,8 @@ function markInputEvent(name,rel,type,replay) {
|
|
|
796
806
|
if(!hasHistory||historyDisabled||replay){
|
|
797
807
|
const serial=(inputEventSerial.get(name)||0)+1;
|
|
798
808
|
inputEventSerial.set(name,serial);
|
|
799
|
-
// Kept only for recorded test and script checks; trimmed in batches.
|
|
800
|
-
if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
|
|
809
|
+
// Kept only for recorded test and script checks and sandboxed ones; trimmed in batches.
|
|
810
|
+
if(['npm-tests-direct-v1','node-script-v1','sandbox-v1'].includes(index?.checks.get(name)?.plan.qualification)){
|
|
801
811
|
const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
|
|
802
812
|
if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
|
|
803
813
|
}
|
|
@@ -17,7 +17,8 @@ export function planGuard(bundle) {
|
|
|
17
17
|
...bundle.workspaceManifests||[],...bundle.configurationInputs||[],
|
|
18
18
|
...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[])]);
|
|
19
19
|
const patterns=bundle.workspacePatterns?.length?bundle.workspacePatterns:
|
|
20
|
-
(()=>{
|
|
20
|
+
(()=>{let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));}
|
|
21
|
+
catch(e){if(e.code==='ENOENT')return [];throw e;}
|
|
21
22
|
return Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages||[];})();
|
|
22
23
|
if(patterns.length){
|
|
23
24
|
const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
|
package/src/declared-plan.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {qualifyTypeScript} from './typescript-qualification.mjs';
|
|
|
8
8
|
import {directBuildInfo} from './direct-typescript.mjs';
|
|
9
9
|
import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
|
|
10
10
|
import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
|
|
11
|
+
import {qualifySandbox,sandboxInstalledExclusions,sandboxQualification} from './sandbox.mjs';
|
|
11
12
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
12
13
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
13
14
|
import {checkRun} from './typescript-invocation.mjs';
|
|
@@ -78,12 +79,17 @@ export function discoverDeclared(config) {
|
|
|
78
79
|
if(selected.qualification==='npm-typescript-direct-v1')
|
|
79
80
|
try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
|
|
80
81
|
const excluded=buildInfo?['!'+buildInfo]:[];
|
|
82
|
+
// A sandboxed check leaves out the .venv's links that lead out of the
|
|
83
|
+
// checkout; its qualification names them.
|
|
84
|
+
const sandboxed=selected.qualification===sandboxQualification;
|
|
85
|
+
if(sandboxed)excluded.push(...sandboxInstalledExclusions(root));
|
|
81
86
|
const installedStarted=performance.now();
|
|
82
87
|
const installed=resolveDeclaredInstalled(root,
|
|
83
88
|
[...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
|
|
84
89
|
timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
|
|
85
90
|
const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
|
|
86
91
|
selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
|
|
92
|
+
sandboxed?qualifySandbox(root,selected):
|
|
87
93
|
qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
|
|
88
94
|
const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
|
|
89
95
|
...(workspace?.generated||[]),...excluded])].sort();
|
|
@@ -97,7 +103,8 @@ export function discoverDeclared(config) {
|
|
|
97
103
|
// A workspace test check, and a general check in a workspace, is qualified
|
|
98
104
|
// against the current package list: a package manifest appearing or pnpm's
|
|
99
105
|
// package list changing re-plans.
|
|
100
|
-
if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification
|
|
106
|
+
if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification||
|
|
107
|
+
sandboxed&&typeof selected.script==='string')try{
|
|
101
108
|
for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
|
|
102
109
|
workspacePatterns.add(item);
|
|
103
110
|
if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
|
|
@@ -136,6 +143,8 @@ export function discoverDeclared(config) {
|
|
|
136
143
|
probes:selected.probes||[],environment:selected.environment||{},
|
|
137
144
|
qualification:selected.qualification||null,
|
|
138
145
|
...(buildInfo?{buildInfo}:{}),
|
|
146
|
+
// A sandboxed run's reads are not recorded: git-ignored files are inputs too.
|
|
147
|
+
...(sandboxed?{ignoredInputs:true}:{}),
|
|
139
148
|
executionProvenance:'declared-command',
|
|
140
149
|
unresolved:[...new Set(unresolved)].sort()};
|
|
141
150
|
plan.resolutionCandidates=workspace?.resolutionCandidates||[];
|
package/src/index.mjs
CHANGED
|
@@ -11,6 +11,11 @@ const tracked = root => [...new Set(execFileSync('git',
|
|
|
11
11
|
['ls-files','--cached','--others','--exclude-standard','-z'],
|
|
12
12
|
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
13
13
|
.toString().split('\0').filter(Boolean))];
|
|
14
|
+
// Git-ignored paths at the top of each ignored tree: a directory ends in '/'.
|
|
15
|
+
const ignored = root => [...new Set(execFileSync('git',
|
|
16
|
+
['ls-files','--others','--ignored','--exclude-standard','--directory','-z'],
|
|
17
|
+
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
18
|
+
.toString().split('\0').filter(Boolean))];
|
|
14
19
|
const xor = (left,right) => { for(let i=0;i<32;i++) left[i]^=right[i]; };
|
|
15
20
|
const token = (file,hash) => createHash('sha256').update(file+'\0'+hash).digest();
|
|
16
21
|
const stablePrefix = glob => {
|
|
@@ -178,12 +183,13 @@ export class InputIndex {
|
|
|
178
183
|
}
|
|
179
184
|
summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
|
|
180
185
|
revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
|
|
181
|
-
|
|
186
|
+
// `only`, when given, limits which checks may take the file.
|
|
187
|
+
record(rel, changeReason=rel+' changed', observedStat=null, only=null) {
|
|
182
188
|
if(!validInputRelative(rel)) {
|
|
183
189
|
this.unavailable='invalid event path'; return false;
|
|
184
190
|
}
|
|
185
191
|
const names=this.candidates(rel), old=this.files.get(rel), matched=[];
|
|
186
|
-
for(const name of names) if(this.matches(this.checks.get(name),rel)) matched.push(name);
|
|
192
|
+
for(const name of names) if((!only||only(name))&&this.matches(this.checks.get(name),rel)) matched.push(name);
|
|
187
193
|
if(!matched.length && old===undefined) return false;
|
|
188
194
|
let fresh,stat,inspection;
|
|
189
195
|
try{stat=observedStat||fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
@@ -198,7 +204,7 @@ export class InputIndex {
|
|
|
198
204
|
// (its package.json stands for them).
|
|
199
205
|
// The second form is for recorded checks only: their recorder
|
|
200
206
|
// classifies a read through the link where it leads.
|
|
201
|
-
const recorded=name=>['node-script-v1','npm-tests-direct-v1'].includes(this.checks.get(name).plan.qualification);
|
|
207
|
+
const recorded=name=>['node-script-v1','npm-tests-direct-v1','sandbox-v1'].includes(this.checks.get(name).plan.qualification);
|
|
202
208
|
const observedDir=installed?physical=>!this.includeInstalled&&
|
|
203
209
|
([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
|
|
204
210
|
!physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
|
|
@@ -267,6 +273,10 @@ export class InputIndex {
|
|
|
267
273
|
this.unavailable=null;
|
|
268
274
|
const trackedStarted=performance.now();
|
|
269
275
|
const names=new Set(this.trackGit?tracked(this.root):[]);
|
|
276
|
+
// A sandboxed check's reads are not recorded, so git-ignored files are its
|
|
277
|
+
// inputs too: every ignored file its patterns take, outside the scan roots
|
|
278
|
+
// (installed trees) and the directories no pattern can take a file from.
|
|
279
|
+
const ignoredTrees=this.trackGit&&[...this.checks.values()].some(row=>row.plan.ignoredInputs)?ignored(this.root):[];
|
|
270
280
|
this.profile.trackedMs+=performance.now()-trackedStarted;
|
|
271
281
|
const enumerationStarted=performance.now();
|
|
272
282
|
// Traverse declared input roots to include ignored/generated inputs. Avoid unrelated
|
|
@@ -310,6 +320,29 @@ export class InputIndex {
|
|
|
310
320
|
}
|
|
311
321
|
visit(base,prefix);
|
|
312
322
|
}
|
|
323
|
+
// Only the checks that take ignored files get them here, as before for the others.
|
|
324
|
+
const ignoring=name=>this.checks.get(name).plan.ignoredInputs===true;
|
|
325
|
+
const takes=rel=>[...this.checks.values()].some(row=>row.plan.ignoredInputs&&this.matches(row,rel));
|
|
326
|
+
// A node_modules that is not installed input is skipped, as above. An
|
|
327
|
+
// ignored link is taken when it leads to a file in the checkout; a link
|
|
328
|
+
// to a directory or out of the checkout is not a file input.
|
|
329
|
+
const linkToFile=rel=>{try{const target=fs.realpathSync(path.join(this.root,rel));
|
|
330
|
+
return fs.statSync(target).isFile()&&target.startsWith(fs.realpathSync(this.root)+path.sep);}catch{return false;}};
|
|
331
|
+
const visitIgnored=rel=>{
|
|
332
|
+
const base=path.posix.basename(rel);
|
|
333
|
+
if(this.prefixes.has(rel)||this.installedPath(rel)&&!this.installedAncestors.has(rel)||base==='.git'||
|
|
334
|
+
base==='node_modules'&&!this.installedPath(rel))return;
|
|
335
|
+
let st;try{st=fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
336
|
+
catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
337
|
+
this.profile.enumeratedEntries++;
|
|
338
|
+
if(st.isSymbolicLink()&&!linkToFile(rel))return;
|
|
339
|
+
if(st.isDirectory()){
|
|
340
|
+
if(!takes(rel+'/.redue-probe'))return;
|
|
341
|
+
for(const ent of fs.readdirSync(path.join(this.root,rel),{withFileTypes:true}))visitIgnored(rel+'/'+ent.name);
|
|
342
|
+
}else if(this.candidates(rel).size){
|
|
343
|
+
const started=performance.now();this.record(rel,'cold reconciliation',st,ignoring);processingMs+=performance.now()-started;scanned++;}
|
|
344
|
+
};
|
|
345
|
+
for(const entry of ignoredTrees)visitIgnored(entry.replace(/\/$/,''));
|
|
313
346
|
this.profile.enumerationMs+=performance.now()-enumerationStarted-processingMs;
|
|
314
347
|
const recordStarted=performance.now();
|
|
315
348
|
for(const rel of names) {
|
package/src/landlock.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# The sandboxed recipe's Linux launcher (src/sandbox.mjs): runs a command
|
|
3
|
+
# under a Landlock ruleset (Linux >= 5.13), with the standard library only.
|
|
4
|
+
# Reads and executes are allowed beneath the --read paths; with --no-tcp
|
|
5
|
+
# (ABI >= 4), every TCP bind and connect is refused, and (ABI >= 6) so is a
|
|
6
|
+
# connection to an abstract Unix socket outside the run. Writes are not
|
|
7
|
+
# handled, so they are not restricted.
|
|
8
|
+
#
|
|
9
|
+
# The launcher stays as the command's parent and a child subreaper: when the
|
|
10
|
+
# command exits, it stops every process the command left running, daemons
|
|
11
|
+
# that started their own session included (they are reparented to it), as a
|
|
12
|
+
# CI job's end does. The command runs in its own process group; a signal the
|
|
13
|
+
# launcher gets is passed to that group once. The launcher then ends as the
|
|
14
|
+
# command did: with its exit status, or by its signal.
|
|
15
|
+
#
|
|
16
|
+
# landlock.py --read /usr --read /tmp ... [--no-tcp] -- cmd args...
|
|
17
|
+
# landlock.py --probe # prints {"abi": N} or {"abi": null, "errno": E}
|
|
18
|
+
import ctypes
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
SYS_CREATE, SYS_ADD, SYS_RESTRICT = 444, 445, 446 # the same on x86_64 and aarch64
|
|
26
|
+
EXECUTE, READ_FILE, READ_DIR = 1 << 0, 1 << 2, 1 << 3
|
|
27
|
+
BIND_TCP, CONNECT_TCP = 1 << 0, 1 << 1
|
|
28
|
+
SCOPE_ABSTRACT_UNIX_SOCKET = 1 << 0
|
|
29
|
+
PR_SET_NO_NEW_PRIVS, PR_SET_CHILD_SUBREAPER = 38, 36
|
|
30
|
+
|
|
31
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
32
|
+
libc.syscall.restype = ctypes.c_long
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RulesetAttr(ctypes.Structure):
|
|
36
|
+
_fields_ = [('fs', ctypes.c_uint64), ('net', ctypes.c_uint64), ('scoped', ctypes.c_uint64)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PathBeneath(ctypes.Structure):
|
|
40
|
+
# Packed, as the kernel's struct is: 12 bytes. Python 3.14 asks for the layout.
|
|
41
|
+
_layout_ = 'ms'
|
|
42
|
+
_pack_ = 1
|
|
43
|
+
_fields_ = [('allowed', ctypes.c_uint64), ('fd', ctypes.c_int32)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def abi():
|
|
47
|
+
v = libc.syscall(SYS_CREATE, None, ctypes.c_size_t(0), ctypes.c_uint32(1))
|
|
48
|
+
return (v, 0) if v >= 0 else (None, ctypes.get_errno())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv):
|
|
52
|
+
if argv == ['--probe']:
|
|
53
|
+
v, err = abi()
|
|
54
|
+
print(json.dumps({'abi': v, 'errno': err}))
|
|
55
|
+
return 0
|
|
56
|
+
reads, no_tcp = [], False
|
|
57
|
+
while argv and argv[0] != '--':
|
|
58
|
+
if argv[0] == '--read':
|
|
59
|
+
reads.append(argv[1]); argv = argv[2:]
|
|
60
|
+
elif argv[0] == '--no-tcp':
|
|
61
|
+
no_tcp = True; argv = argv[1:]
|
|
62
|
+
else:
|
|
63
|
+
sys.exit(f'landlock.py: unknown option {argv[0]}')
|
|
64
|
+
cmd = argv[1:]
|
|
65
|
+
version, err = abi()
|
|
66
|
+
if version is None:
|
|
67
|
+
sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
|
|
68
|
+
handled = EXECUTE | READ_FILE | READ_DIR
|
|
69
|
+
attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
|
|
70
|
+
SCOPE_ABSTRACT_UNIX_SOCKET if no_tcp and version >= 6 else 0)
|
|
71
|
+
size = 24 if attr.scoped else 16 if attr.net else 8
|
|
72
|
+
ruleset = libc.syscall(SYS_CREATE, ctypes.byref(attr), ctypes.c_size_t(size), ctypes.c_uint32(0))
|
|
73
|
+
if ruleset < 0:
|
|
74
|
+
sys.exit(f'landlock.py: create_ruleset errno {ctypes.get_errno()}')
|
|
75
|
+
for place in reads:
|
|
76
|
+
try:
|
|
77
|
+
fd = os.open(place, os.O_PATH | os.O_CLOEXEC)
|
|
78
|
+
except OSError:
|
|
79
|
+
continue
|
|
80
|
+
# A file takes file rights only.
|
|
81
|
+
rights = handled if os.path.isdir(place) else EXECUTE | READ_FILE
|
|
82
|
+
rule = PathBeneath(rights, fd)
|
|
83
|
+
if libc.syscall(SYS_ADD, ctypes.c_int(ruleset), ctypes.c_int(1), ctypes.byref(rule), ctypes.c_uint32(0)) < 0:
|
|
84
|
+
sys.exit(f'landlock.py: add_rule {place} errno {ctypes.get_errno()}')
|
|
85
|
+
os.close(fd)
|
|
86
|
+
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
|
|
87
|
+
sys.exit(f'landlock.py: no_new_privs errno {ctypes.get_errno()}')
|
|
88
|
+
if libc.syscall(SYS_RESTRICT, ctypes.c_int(ruleset), ctypes.c_uint32(0)) < 0:
|
|
89
|
+
sys.exit(f'landlock.py: restrict_self errno {ctypes.get_errno()}')
|
|
90
|
+
os.close(ruleset)
|
|
91
|
+
libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)
|
|
92
|
+
pid = os.fork()
|
|
93
|
+
if pid == 0:
|
|
94
|
+
os.setpgid(0, 0)
|
|
95
|
+
try:
|
|
96
|
+
os.execvp(cmd[0], cmd)
|
|
97
|
+
except OSError as e:
|
|
98
|
+
sys.stderr.write(f'landlock.py: exec {cmd[0]}: {e}\n')
|
|
99
|
+
os._exit(127)
|
|
100
|
+
# Also here, so a signal that comes before the child runs finds its group.
|
|
101
|
+
try:
|
|
102
|
+
os.setpgid(pid, pid)
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
def forward(number, frame):
|
|
106
|
+
try:
|
|
107
|
+
os.killpg(pid, number)
|
|
108
|
+
except (ProcessLookupError, PermissionError):
|
|
109
|
+
pass
|
|
110
|
+
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
111
|
+
signal.signal(number, forward)
|
|
112
|
+
_, status = os.waitpid(pid, 0)
|
|
113
|
+
stop_left()
|
|
114
|
+
code = os.waitstatus_to_exitcode(status)
|
|
115
|
+
if code >= 0:
|
|
116
|
+
return code
|
|
117
|
+
# Killed by a signal: end the same way, so the caller sees the signal.
|
|
118
|
+
try:
|
|
119
|
+
signal.signal(-code, signal.SIG_DFL)
|
|
120
|
+
except (OSError, ValueError):
|
|
121
|
+
pass
|
|
122
|
+
os.kill(os.getpid(), -code)
|
|
123
|
+
return 128 - code
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def children(me):
|
|
127
|
+
# The kernel's list of this process's children, where it keeps one.
|
|
128
|
+
try:
|
|
129
|
+
found = set()
|
|
130
|
+
for task in os.listdir('/proc/self/task'):
|
|
131
|
+
with open(f'/proc/self/task/{task}/children') as f:
|
|
132
|
+
found.update(int(pid) for pid in f.read().split())
|
|
133
|
+
return list(found)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
# Else every process's parent. The fields after the command name, which
|
|
137
|
+
# may hold spaces or parentheses.
|
|
138
|
+
found = []
|
|
139
|
+
for entry in os.listdir('/proc'):
|
|
140
|
+
if not entry.isdigit():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
with open(f'/proc/{entry}/stat') as f:
|
|
144
|
+
stat = f.read()
|
|
145
|
+
except OSError:
|
|
146
|
+
continue
|
|
147
|
+
if int(stat[stat.rindex(')') + 2:].split()[1]) == me:
|
|
148
|
+
found.append(int(entry))
|
|
149
|
+
return found
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Every process left under the launcher: SIGTERM, then SIGKILL after a second.
|
|
153
|
+
# A process's own children are reparented here when it ends, so this repeats
|
|
154
|
+
# until none is left, for at most three seconds: a process in uninterruptible
|
|
155
|
+
# sleep is named and left.
|
|
156
|
+
def stop_left():
|
|
157
|
+
me, start, number = os.getpid(), time.monotonic(), signal.SIGTERM
|
|
158
|
+
while True:
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
done, _ = os.waitpid(-1, os.WNOHANG)
|
|
162
|
+
except ChildProcessError:
|
|
163
|
+
done = 0
|
|
164
|
+
if done == 0:
|
|
165
|
+
break
|
|
166
|
+
left = children(me)
|
|
167
|
+
if not left:
|
|
168
|
+
return
|
|
169
|
+
if time.monotonic() > start + 3:
|
|
170
|
+
sys.stderr.write(f'landlock.py: processes {left} did not stop\n')
|
|
171
|
+
return
|
|
172
|
+
if time.monotonic() > start + 1:
|
|
173
|
+
number = signal.SIGKILL
|
|
174
|
+
for pid in left:
|
|
175
|
+
try:
|
|
176
|
+
os.kill(pid, number)
|
|
177
|
+
except ProcessLookupError:
|
|
178
|
+
pass
|
|
179
|
+
time.sleep(0.05)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == '__main__':
|
|
183
|
+
sys.exit(main(sys.argv[1:]))
|