@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/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
- function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
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
- if(process.platform==='linux'&&activeChild.pid){
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
- stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
341
- detached:process.platform==='linux',...launch.options});
342
- child.stdio[3]?.on('data',chunk=>record.push(chunk));}
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
- const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
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(testRun&&stable&&result){
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
- let workspacePatterns=(()=>{const w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;
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
- item.reason=`inputs changed during execution or observation gap${issues}`;return item;}
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
- (()=>{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
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',
@@ -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)try{
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
- record(rel, changeReason=rel+' changed', observedStat=null) {
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) {
@@ -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:]))