@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.
@@ -1,6 +1,7 @@
1
1
  import {directProject,directQualification} from './direct-typescript.mjs';
2
- import {testProject,testRecipeScript,testCheckInputs,testEnvironmentIssue,testsQualification} from './test-direct.mjs';
3
- import {commandLineIssue,scriptCheckInputs,scriptPath,scriptProject,scriptQualification} from './node-script.mjs';
2
+ import {testProject,testRecipeScript,testCheckInputs,testsQualification} from './test-direct.mjs';
3
+ import {generalIssue,scriptCheckInputs,scriptProject,scriptQualification} from './node-script.mjs';
4
+ import {sandboxCheckInputs,sandboxKinds,sandboxQualification} from './sandbox.mjs';
4
5
  // Init proposes the general recipe for typecheck and lint scripts; test
5
6
  // scripts use the direct test recipe (Vitest, Jest).
6
7
  const generalKinds=['typecheck','lint'];
@@ -110,7 +111,7 @@ const proposable=(dir,scripts)=>exists(path.join(dir,'tsconfig.json'))&&!typeche
110
111
  // The inputs a TypeScript contract adds to its check's config: no exclusions,
111
112
  // which would outlive the tsconfig they came from; the plan adds the current ones.
112
113
  const contractInputs=contract=>[...contract.configs,...contract.sourcePatterns.filter(p=>!p.startsWith('!'))];
113
- function sourceLinkIssue(root) {
114
+ export function sourceLinkIssue(root) {
114
115
  const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
115
116
  let inspected=0;
116
117
  while(stack.length){
@@ -207,22 +208,16 @@ export function workspacePackages(root){
207
208
  // supported package manager and platform, and a script (with its pre and post
208
209
  // scripts) that, read as text, starts only Node programs.
209
210
  function generalCheck(root,kind,script){
210
- if(!['darwin','linux'].includes(process.platform))throw Error('the general recipe records runs on macOS and Linux only');
211
- const environmentIssue=testEnvironmentIssue();
212
- if(environmentIssue)throw Error(environmentIssue);
213
- let project;
214
- try{project=scriptProject(root,script);}catch(e){throw Error(e.code?.replace(/^test-/,'')||e.message);}
215
- for(const name of ['pre'+script,script,'post'+script]){
216
- if(typeof project.scripts[name]!=='string')continue;
217
- const issue=commandLineIssue(project.scripts[name],{cwd:root,searchPath:scriptPath(root,process.env.PATH)});
218
- if(issue)throw Error(name===script?issue:`${name}: ${issue}`);
219
- }
211
+ const issue=generalIssue(root,script);
212
+ if(issue)throw Error(issue);
213
+ const project=scriptProject(root,script);
220
214
  return {config:{name:kind,script,kind,qualification:scriptQualification,...scriptCheckInputs(project.dirs)},manager:project.manager};
221
215
  }
222
216
  export function discoverNodeProject(root,{recipe=null}={}) {
223
- if(recipe&&!['typescript-direct','tests-direct','node-script'].includes(recipe))
224
- throw Error('unknown recipe; use typescript-direct, tests-direct or node-script');
225
- // Without --recipe, init tries the strict recipes first, then the general one.
217
+ if(recipe&&!['typescript-direct','tests-direct','node-script','sandbox'].includes(recipe))
218
+ throw Error('unknown recipe; use typescript-direct, tests-direct, node-script or sandbox');
219
+ // Without --recipe, init tries the strict recipes first, then the general
220
+ // one, then the sandboxed one.
226
221
  const auto=recipe===null;
227
222
  root=realObservedPath(root);
228
223
  const packageFile=path.join(root,'package.json');
@@ -281,7 +276,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
281
276
  reason:`explicit local ${project.runner} ${project.version} run of script ${JSON.stringify(chosen.script)}; the whole checkout is its input; npm and lifecycle scripts are not executed`};
282
277
  }catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
283
278
  }
284
- if(recipe!=='node-script'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
279
+ if(recipe!=='node-script'&&recipe!=='sandbox'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
285
280
  try{
286
281
  parts=tscChecks('typecheck',script??null,scripts,run=>{
287
282
  const contract=pnpmTypecheckInputs(root,'.',run);
@@ -294,14 +289,40 @@ export function discoverNodeProject(root,{recipe=null}={}) {
294
289
  qualification:'pnpm-tsc-v1'};}
295
290
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
296
291
  }
292
+ // The general recipe's row, when it takes the script.
293
+ let generalIssue=null,generalRow=null;
297
294
  if((auto||recipe==='node-script')&&script&&generalKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue){
298
295
  try{
299
296
  const general=generalCheck(root,kind,script);
300
- checks.push({config:general.config,level:'ready',qualification:scriptQualification,
301
- reason:`general recipe: ${general.manager} runs script ${JSON.stringify(script)} with REDUE's recorder in every Node process; the whole checkout is its input`});
297
+ generalRow={config:general.config,level:'ready',qualification:scriptQualification,
298
+ reason:`general recipe: ${general.manager} runs script ${JSON.stringify(script)} with REDUE's recorder in every Node process; the whole checkout is its input`};
299
+ }catch(e){generalIssue=e.message;classification={...classification,reason:`General recipe unavailable (${e.message}); ${classification.reason}`};}
300
+ }
301
+ // After the strict recipes, a root typecheck or lint script gets the
302
+ // sandboxed recipe, which also covers programs the general recipe cannot
303
+ // record. It is ready only once init has run it both ways (bin/redue.mjs);
304
+ // `fallback` is the row init writes when that shows a difference or the
305
+ // sandbox cannot run here: the general recipe's, when it takes the script.
306
+ // Where the sandbox cannot start at run time, a script the general recipe
307
+ // takes is recorded as it would record it (cli.mjs).
308
+ // A script whose program is not installed gets none, and neither does one
309
+ // that changes files (`--fix`, `--write`): init would run it twice.
310
+ const writes=script&&['pre'+script,script,'post'+script].map(name=>scripts[name]).find(text=>typeof text==='string'&&
311
+ /(?:^|\s)--(?:fix|write|unsafe-fixes)(?=[\s=]|$)/.test(text));
312
+ if(writes)classification={...classification,reason:`Sandboxed recipe unavailable (the script changes files: ${writes.match(/--(?:fix|write|unsafe-fixes)/)[0]}); ${classification.reason}`};
313
+ if((auto||recipe==='sandbox')&&script&&sandboxKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue&&
314
+ !/REDUE cannot find/.test(generalIssue||'')&&!writes){
315
+ try{
316
+ const project=scriptProject(root,script);
317
+ const fallback=generalRow??{config:{...check,...classification.qualification?{qualification:classification.qualification}:{}},...classification};
318
+ checks.push({config:{name:kind,script,kind,qualification:sandboxQualification,...sandboxCheckInputs(project.dirs)},level:'ready',
319
+ qualification:sandboxQualification,fallback,
320
+ reason:`sandboxed recipe: ${project.manager} runs script ${JSON.stringify(script)} inside the OS sandbox${generalIssue?` (the general recipe cannot record it: ${generalIssue})`:
321
+ generalRow?'; where the sandbox cannot run, the general recipe records it':''}; the whole checkout is its input`});
302
322
  continue;
303
- }catch(e){classification={...classification,reason:`General recipe unavailable (${e.message}); ${classification.reason}`};}
323
+ }catch(e){classification={...classification,reason:`Sandboxed recipe unavailable (${e.code?.replace(/^test-/,'')||e.message}); ${classification.reason}`};}
304
324
  }
325
+ if(generalRow){checks.push(generalRow);continue;}
305
326
  if(!script&&classification.level!=='ready')continue;
306
327
  if(parts&&classification.level==='ready'){
307
328
  for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
@@ -12,7 +12,7 @@ import fs from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import {pnpmShimTarget} from './pnpm-shims.mjs';
15
- import {testPackageManager,testWorkspaceDirs,testEnvironment} from './test-direct.mjs';
15
+ import {testPackageManager,testWorkspaceDirs,testEnvironment,testEnvironmentIssue} from './test-direct.mjs';
16
16
 
17
17
  export const scriptQualification='node-script-v1';
18
18
  export const scriptInterpretation='node-script@1';
@@ -358,6 +358,21 @@ export function childProgram({file,args,cwd,searchPath,env=null,script=null},{to
358
358
  return {needsHeader:false};
359
359
  return {issue:`ran ${name}, a program REDUE does not record`};
360
360
  }
361
+ // Why the general recipe could not record a root script now, or null: what
362
+ // init checks before it proposes it. A sandboxed check whose sandbox cannot
363
+ // start is recorded this way instead (cli.mjs).
364
+ export function generalIssue(root,script){
365
+ if(!['darwin','linux'].includes(process.platform))return 'the general recipe records runs on macOS and Linux only';
366
+ const environment=testEnvironmentIssue();
367
+ if(environment)return environment;
368
+ let project;try{project=scriptProject(root,script);}catch(e){return e.code?.replace(/^test-/,'')||e.message;}
369
+ for(const name of ['pre'+script,script,'post'+script]){
370
+ if(typeof project.scripts[name]!=='string')continue;
371
+ const issue=commandLineIssue(project.scripts[name],{cwd:root,searchPath:scriptPath(root,process.env.PATH)});
372
+ if(issue)return name===script?issue:`${name}: ${issue}`;
373
+ }
374
+ return null;
375
+ }
361
376
  // What a recorded run needs from the check: the workspace package directories
362
377
  // whose node_modules its installed inputs list. The script must still exist.
363
378
  export function scriptRunFacts(root,check){
@@ -2,7 +2,7 @@
2
2
  // The recipe that made a receipt, by the qualification it names.
3
3
  const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
4
4
  'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
5
- 'typescript-noemit-v1':'npm TypeScript recipe'};
5
+ 'typescript-noemit-v1':'npm TypeScript recipe','sandbox-v1':'sandboxed recipe'};
6
6
  export const recipeLabel=recipe=>recipes[recipe]??null;
7
7
  export function checkLabel(row) {
8
8
  return row.result==='FAIL'?'FAILED':row.freshness;
@@ -0,0 +1,87 @@
1
+ // Python projects for `redue init`: typecheck and lint checks that run mypy,
2
+ // pyright, ty or ruff from the project's .venv under the sandboxed recipe
3
+ // (sandbox.mjs). A tool is proposed when it is installed in .venv/bin. Its
4
+ // arguments are the ones the project runs it with, read from CI workflows,
5
+ // the Makefile, tox, nox, a justfile and shell scripts; without one, the
6
+ // tool's own default (`ruff check`, `ty check`, `pyright`, and `mypy` with
7
+ // its configured files or `.`).
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {realObservedPath} from './path-identity.mjs';
11
+ import {sandboxCheckInputs,sandboxQualification} from './sandbox.mjs';
12
+
13
+ const read=file=>{try{return fs.readFileSync(file,'utf8');}catch{return '';}};
14
+ const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
15
+ const executable=file=>{try{fs.accessSync(file,fs.constants.X_OK);return fs.statSync(file).isFile();}catch{return false;}};
16
+ // Each tool, its kind, and the subcommand it runs as a check.
17
+ export const pythonTools=[{tool:'mypy',kind:'typecheck',sub:[]},{tool:'pyright',kind:'typecheck',sub:[]},
18
+ {tool:'ty',kind:'typecheck',sub:['check']},{tool:'ruff',kind:'lint',sub:['check']}];
19
+ // The text of a TOML or INI section, up to the next section header.
20
+ const section=(text,header)=>{const at=text.split(/\r?\n/).findIndex(line=>line.trim()===header);
21
+ if(at<0)return null;const rest=text.split(/\r?\n/).slice(at+1);const end=rest.findIndex(line=>/^\s*\[/.test(line));
22
+ return (end<0?rest:rest.slice(0,end)).join('\n');};
23
+ // Ways a project starts a tool: through uv, poetry or pdm, `python -m`, or from .venv/bin.
24
+ const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|all-groups))*(?: --(?:group|extra|with|python|package) [^ ]+)* /,
25
+ /^poetry run /,/^pdm run /,/^python3?(?:\.\d+)? -m /,/^(?:\.\/)?\.venv\/bin\//,
26
+ // A variable that names the environment's bin directory: `${PREFIX}mypy`.
27
+ /^\$\{?[A-Za-z_]+\}?(?=[A-Za-z])/];
28
+ // A tool's arguments in one line of a script or workflow, or null. The line
29
+ // must run the tool and its subcommand plainly; arguments with shell syntax,
30
+ // or ones that change files or install packages, are not taken.
31
+ export function toolArgs(line,{tool,sub}){
32
+ let text=line.trim().replace(/^-\s+/,'').replace(/^run:\s*/,'').replace(/^[@-]+/,'');
33
+ text=text.split(/\s(?:&&|\|\||;|\|)\s/)[0].replace(/\s+/g,' ').trim();
34
+ for(let changed=true;changed;){changed=false;for(const wrapper of wrappers)if(wrapper.test(text)){text=text.replace(wrapper,'');changed=true;}}
35
+ const words=text.split(' ');
36
+ if(words[0]!==tool||JSON.stringify(words.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
37
+ const args=words.slice(1+sub.length);
38
+ if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?:fix|unsafe-fixes|write|install-types|watch)(?:=|$)/.test(arg)||arg.startsWith('..')||arg.startsWith('/')))return null;
39
+ return args;
40
+ }
41
+ // The places a project writes how it runs its tools, in order of preference.
42
+ function scriptTexts(root){
43
+ const texts=[];
44
+ const workflows=path.join(root,'.github','workflows');
45
+ let files=[];try{files=fs.readdirSync(workflows).filter(file=>/\.ya?ml$/.test(file)).sort();}catch{}
46
+ for(const file of files)texts.push(read(path.join(workflows,file)));
47
+ for(const file of ['Makefile','makefile','justfile','tox.ini','noxfile.py'])texts.push(read(path.join(root,file)));
48
+ // Shell scripts: *.sh, or a file whose #! line runs a shell (scripts/check).
49
+ for(const dir of ['scripts','script','bin','ci']){let names=[];try{names=fs.readdirSync(path.join(root,dir)).sort();}catch{}
50
+ for(const name of names){const text=read(path.join(root,dir,name)).slice(0,65536);
51
+ if(name.endsWith('.sh')||/^#!\S*(?:\/|env\s+)(?:ba|z|da)?sh\b/.test(text))texts.push(text);}}
52
+ return texts;
53
+ }
54
+ // The arguments the project runs `tool` with, or its default.
55
+ export function projectArgs(root,spec,texts=scriptTexts(root)){
56
+ for(const text of texts)for(const line of text.split(/\r?\n/)){
57
+ if(/^\s*#/.test(line))continue;
58
+ const args=toolArgs(line,spec);
59
+ if(args)return {args,source:'project'};
60
+ }
61
+ if(spec.tool!=='mypy')return {args:[],source:'default'};
62
+ // mypy without targets needs `files` in its configuration.
63
+ const configured=[section(read(path.join(root,'pyproject.toml')),'[tool.mypy]'),section(read(path.join(root,'mypy.ini')),'[mypy]'),
64
+ section(read(path.join(root,'.mypy.ini')),'[mypy]'),section(read(path.join(root,'setup.cfg')),'[mypy]')].some(text=>text&&/^\s*files\s*=/m.test(text));
65
+ return {args:configured?[]:['.'],source:'default'};
66
+ }
67
+ // The Python checks `init` proposes, or null when the root is not a Python
68
+ // project. Each row is ready only once init has run it both ways (bin/redue.mjs).
69
+ export function discoverPythonProject(root){
70
+ root=realObservedPath(root);
71
+ if(!['pyproject.toml','setup.cfg','setup.py','requirements.txt'].some(file=>exists(path.join(root,file))))return null;
72
+ const checks=[],skipped=[];
73
+ if(!exists(path.join(root,'.venv','pyvenv.cfg'))){
74
+ skipped.push({kind:'python',reason:'no .venv in the project root; REDUE runs Python tools from the project\'s .venv (uv sync, or Poetry with virtualenvs.in-project)'});
75
+ return {root,checks,skipped};
76
+ }
77
+ const texts=scriptTexts(root);
78
+ const found=pythonTools.filter(spec=>executable(path.join(root,'.venv','bin',spec.tool)));
79
+ for(const spec of found){
80
+ const {args,source}=projectArgs(root,spec,texts);
81
+ const several=found.filter(other=>other.kind===spec.kind).length>1;
82
+ checks.push({config:{name:several?`${spec.kind}:${spec.tool}`:spec.kind,kind:spec.kind,qualification:sandboxQualification,
83
+ command:[`.venv/bin/${spec.tool}`,...spec.sub,...args],...sandboxCheckInputs()},level:'ready',qualification:sandboxQualification,
84
+ reason:`sandboxed recipe: ${[spec.tool,...spec.sub,...args].join(' ')} from .venv${source==='project'?' as the project runs it':''}; the whole checkout is its input`});
85
+ }
86
+ return {root,checks,skipped};
87
+ }
@@ -0,0 +1,396 @@
1
+ // The sandboxed recipe: a typecheck or lint command run inside an OS sandbox
2
+ // that lets it read only the checkout, its installed packages, the toolchain,
3
+ // system directories and a private home and temporary directory, with no
4
+ // network: Seatbelt (sandbox-exec) on macOS, Landlock (landlock.py) on Linux.
5
+ // The sandbox holds for every process the run starts, so programs REDUE
6
+ // cannot record (native compilers and linters, Python tools) are covered.
7
+ // The claim: the result holds for a run that sees only the project, its
8
+ // installed packages and the toolchain, the way CI does.
9
+ //
10
+ // The run gets a controlled environment: the check's declared variables and
11
+ // prefixes, a normalized PATH, a private HOME kept between runs, and a fresh
12
+ // TMPDIR. All of it is fingerprinted (sandboxContext), with the identity of
13
+ // the toolchain's executables.
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import {createHash} from 'node:crypto';
18
+ import {spawn,spawnSync} from 'node:child_process';
19
+ import {fileURLToPath} from 'node:url';
20
+ import {lookup,scriptCaches,scriptCheckInputs,scriptProject,toolchainDirs} from './node-script.mjs';
21
+ import {displayVariables} from './test-direct.mjs';
22
+
23
+ export const sandboxQualification='sandbox-v1';
24
+ export const sandboxInterpretation='sandbox@1';
25
+ export const sandboxKinds=['typecheck','lint'];
26
+ const fail=code=>{throw Object.assign(Error(code),{code});};
27
+ const digest=value=>createHash('sha256').update(value).digest('hex');
28
+ const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
29
+ const within=(file,dir)=>file===dir||file.startsWith(dir.endsWith('/')?dir:dir+'/');
30
+ const landlockScript=path.join(path.dirname(fileURLToPath(import.meta.url)),'landlock.py');
31
+
32
+ // Python's caches in the checkout, as runner caches: not inputs.
33
+ const pythonCaches=['**/__pycache__/**','**/.mypy_cache/**','**/.ruff_cache/**','**/.pytest_cache/**'];
34
+ export const sandboxCaches=[...scriptCaches,...pythonCaches];
35
+ // The project's virtual environment: installed files, observed by identity,
36
+ // without Python's bytecode caches. Its interpreter links lead out of the
37
+ // checkout to the interpreter it was made from, which sandboxContext
38
+ // identifies instead.
39
+ // Python 3.14 adds a `𝜋thon` link beside `python`.
40
+ const venvInstalled=['.venv/**','!.venv/**/__pycache__/**','!.venv/bin/python*','!.venv/bin/pypy*','!.venv/bin/𝜋thon*'];
41
+ // Inputs: the whole checkout, git-ignored files included, except .git, the
42
+ // installed trees (node_modules, .venv) and runner caches.
43
+ export function sandboxCheckInputs(dirs=[]){
44
+ const base=scriptCheckInputs(dirs);
45
+ return {inputs:[...base.inputs,'!.venv/**',...pythonCaches.map(pattern=>`!${pattern}`)],
46
+ installedInputs:[...base.installedInputs,...venvInstalled]};
47
+ }
48
+ // A Python check runs a tool from the project's .venv: `.venv/bin/TOOL ARGS`.
49
+ export const pythonTool=check=>Array.isArray(check?.command)&&/^\.venv\/bin\/[A-Za-z0-9_.-]+$/.test(check.command[0]||'')?
50
+ path.basename(check.command[0]):null;
51
+ // The variables a run gets from the caller, compared before reuse. HOME,
52
+ // TMPDIR, PATH and the XDG directories are set by REDUE (sandboxEnv). A
53
+ // script check names its package manager first among the executables, as the
54
+ // general recipe does.
55
+ const managers=['npm','pnpm','yarn','corepack'];
56
+ export function sandboxEnvironment(python=false,manager=null){
57
+ return {variables:['TZ','LANG','LANGUAGE','CI','USER','LOGNAME'],
58
+ prefixes:['LC_','DYLD_','LD_',...python?['PYTHON','MYPY','RUFF_','PYRIGHT','TY_']:
59
+ ['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_']],
60
+ ...manager?{pathExecutables:['node',manager,...managers.filter(name=>name!==manager)]}:{}};
61
+ }
62
+
63
+ // Whether this machine can sandbox a run: {mode} or {issue}. Asked once per process.
64
+ let support=null;
65
+ export function sandboxSupport(){
66
+ if(support)return support;
67
+ if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_SANDBOX==='unavailable')
68
+ return support={mode:null,issue:'the sandbox is turned off for this test'};
69
+ if(process.platform==='darwin'){
70
+ const r=spawnSync('/usr/bin/sandbox-exec',['-p','(version 1)(allow default)','/usr/bin/true'],{encoding:'utf8',timeout:10000});
71
+ // Seatbelt cannot start inside another Seatbelt sandbox (Claude Code's, for one).
72
+ return support=r.status===0?{mode:'seatbelt'}:{mode:null,issue:/Operation not permitted/.test(r.stderr||'')?
73
+ 'sandbox-exec cannot start inside another sandbox':`sandbox-exec cannot start (${(r.stderr||r.error?.code||r.status||'').toString().trim().slice(0,120)})`};
74
+ }
75
+ if(process.platform==='linux'){
76
+ const python=launcherPython();
77
+ if(!python)return support={mode:null,issue:'the Landlock launcher needs Python 3'};
78
+ let abi=null;
79
+ try{abi=JSON.parse(spawnSync(python,['-I','-W','ignore',landlockScript,'--probe'],{encoding:'utf8',timeout:10000}).stdout).abi;}catch{}
80
+ if(!Number.isInteger(abi))return support={mode:null,issue:'Landlock is unavailable in this kernel'};
81
+ // ABI 4 (Linux 6.7) is the first that can refuse TCP connections.
82
+ if(abi<4)return support={mode:null,issue:`Landlock ABI ${abi} cannot keep a run off the network; it needs ABI 4 (Linux 6.7)`};
83
+ return support={mode:'landlock',abi,python};
84
+ }
85
+ return support={mode:null,issue:'the sandboxed recipe runs on macOS and Linux only'};
86
+ }
87
+ const launcherPython=()=>['/usr/bin/python3','/bin/python3'].find(file=>{try{fs.accessSync(file,fs.constants.X_OK);return true;}catch{return false;}})||
88
+ lookup('python3',process.env.PATH,process.cwd());
89
+
90
+ export const systemPlaces=process.platform==='darwin'?
91
+ ['/usr','/bin','/sbin','/System','/Library','/Applications','/opt/homebrew','/private/etc','/private/var/db','/private/var/select','/dev']:
92
+ ['/usr','/bin','/sbin','/lib','/lib32','/lib64','/libx32','/etc','/proc','/sys','/dev','/run'];
93
+ // The PATH a run gets: the caller's, without the project's .venv/bin, which
94
+ // `uv run`, `poetry run` and an activated environment put first; a Python
95
+ // check gets it first again. So the forms of a command agree.
96
+ export function sandboxPath(check,env,root){
97
+ const venvBin=path.join(root,'.venv','bin');
98
+ const rest=String(env.PATH??'').split(path.delimiter).filter(dir=>dir&&path.resolve(root,dir)!==venvBin);
99
+ return [...pythonTool(check)?[venvBin]:[],...rest].join(path.delimiter);
100
+ }
101
+ // The interpreter a .venv was made from: its real executable and installation
102
+ // (pyvenv.cfg's `home` is the directory of the executable it was made with).
103
+ export function venvBase(root){
104
+ const venv=path.join(root,'.venv');
105
+ let home=null;
106
+ try{home=/^home\s*=\s*(.+)$/m.exec(fs.readFileSync(path.join(venv,'pyvenv.cfg'),'utf8'))?.[1].trim()??null;}catch{}
107
+ const executable=real(path.join(venv,'bin','python'));
108
+ return {home,executable,prefix:home&&path.isAbsolute(home)?path.dirname(home):null};
109
+ }
110
+ // Program files a run starts from outside the checkout: Node, the package
111
+ // managers, and a Python check's interpreter. Their identity is compared
112
+ // before reuse.
113
+ function toolchainExecutables(check,env,root){
114
+ if(pythonTool(check))return [venvBase(root).executable];
115
+ const searchPath=sandboxPath(check,env,root);
116
+ return ['node','npm','npx','pnpm','yarn','corepack'].map(name=>lookup(name,searchPath,root));
117
+ }
118
+ const sharedPrefixes=()=>{const home=os.homedir();return [home,path.join(home,'.local'),'/usr/local','/usr','/opt/homebrew','/'];};
119
+ // What a run may read and execute outside the checkout: the toolchain's
120
+ // installations and stores, and system directories.
121
+ export function sandboxPlaces(check,env,root){
122
+ const home=os.homedir(),places=new Set();
123
+ const add=dir=>{const resolved=dir&&path.isAbsolute(dir)?real(dir):null;
124
+ if(resolved&&resolved!=='/'&&resolved!==home&&!within(home,resolved))places.add(resolved);};
125
+ // An installation root (bin/..), or the program alone when bin/.. is shared (~/.local, /usr/local).
126
+ const addRoot=file=>{const dir=path.dirname(path.dirname(file));add(sharedPrefixes().includes(dir)?file:dir);};
127
+ for(const file of toolchainExecutables(check,env,root))if(file)addRoot(file);
128
+ if(pythonTool(check)){const base=venvBase(root);add(base.prefix);add(base.home);}
129
+ else{
130
+ for(const dir of toolchainDirs(env))add(dir);
131
+ const cache=path.isAbsolute(env.XDG_CACHE_HOME||'')?env.XDG_CACHE_HOME:path.join(home,'.cache');
132
+ add(path.isAbsolute(env.COREPACK_HOME||'')?env.COREPACK_HOME:path.join(cache,'node','corepack'));
133
+ add(path.join(home,'.yarn','berry'));
134
+ }
135
+ return [...places].sort();
136
+ }
137
+ // The environment a run gets.
138
+ export function sandboxEnv(check,env,{root,home,tmp}){
139
+ const variables=check.environment?.variables||[],prefixes=(check.environment?.prefixes||[]).map(prefix=>prefix.toLowerCase());
140
+ const out={};
141
+ for(const [key,value] of Object.entries(env))
142
+ if(variables.includes(key)||prefixes.some(prefix=>key.toLowerCase().startsWith(prefix))||displayVariables.includes(key))out[key]=value;
143
+ delete out._;delete out.REDUE_RUN_RECORD;
144
+ Object.assign(out,{PATH:sandboxPath(check,env,root),HOME:home,XDG_CONFIG_HOME:path.join(home,'.config'),
145
+ XDG_CACHE_HOME:path.join(home,'.cache'),XDG_DATA_HOME:path.join(home,'.local','share'),XDG_STATE_HOME:path.join(home,'.local','state'),
146
+ TMPDIR:tmp,TMP:tmp,TEMP:tmp,
147
+ // The checkout's .git is not an input. Tools may still see it, as in CI,
148
+ // but git commands find no repository.
149
+ GIT_DIR:path.join(home,'.no-repository'),GIT_CEILING_DIRECTORIES:path.dirname(root)});
150
+ if(pythonTool(check))out.VIRTUAL_ENV=path.join(root,'.venv');
151
+ else{
152
+ // Corepack's downloads stay where Corepack keeps them; npm's and pnpm's
153
+ // update checks read the network; Nx would hand work to its daemon.
154
+ const cache=path.isAbsolute(env.XDG_CACHE_HOME||'')?env.XDG_CACHE_HOME:path.join(os.homedir(),'.cache');
155
+ out.COREPACK_HOME=path.isAbsolute(env.COREPACK_HOME||'')?env.COREPACK_HOME:path.join(cache,'node','corepack');
156
+ Object.assign(out,{npm_config_update_notifier:'false',NX_DAEMON:'false'});
157
+ }
158
+ return out;
159
+ }
160
+ // The files in the checkout's .git that tools read to choose files (ruff's
161
+ // and biome's ignore rules): the repository's exclude file and config. The
162
+ // observer does not watch .git, so they are compared as context.
163
+ function gitIgnoreSources(root){
164
+ let dir=path.join(root,'.git');
165
+ try{if(fs.statSync(dir).isFile()){
166
+ const gitdir=path.resolve(root,/^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dir,'utf8'))?.[1].trim()??'');
167
+ let common=gitdir;try{common=path.resolve(gitdir,fs.readFileSync(path.join(gitdir,'commondir'),'utf8').trim());}catch{}
168
+ dir=common;}}catch{return [];}
169
+ return ['info/exclude','config'].map(rel=>{try{return [rel,digest(fs.readFileSync(path.join(dir,rel)))];}catch{return [rel,null];}});
170
+ }
171
+ // What a sandboxed result depends on outside the observed files, compared
172
+ // before reuse, one entry each so a change can be named (sandboxChange): every
173
+ // variable the run gets (but its private directories and display variables),
174
+ // the places it may read, the identity of the toolchain's executables and of
175
+ // every directory on PATH (an installed or upgraded program changes its
176
+ // directory), and the ignore rules in .git.
177
+ export const sandboxPrefix='__redue_sandbox_';
178
+ export function sandboxContext(check,env,root){
179
+ const identity=(file,keys=['dev','ino','mode','size','mtimeNs','ctimeNs'])=>{try{const st=fs.statSync(file,{bigint:true});
180
+ return [file,...keys.map(key=>String(st[key]))];}catch{return [file,'<unavailable>'];}};
181
+ const environment=sandboxEnv(check,env,{root,home:'<home>',tmp:'<tmp>'});
182
+ for(const key of displayVariables)delete environment[key];
183
+ const out={[`${sandboxPrefix}places__`]:digest(JSON.stringify(sandboxPlaces(check,env,root))),
184
+ [`${sandboxPrefix}executables__`]:digest(JSON.stringify(toolchainExecutables(check,env,root).map(file=>file?identity(file):null))),
185
+ [`${sandboxPrefix}git__`]:digest(JSON.stringify(gitIgnoreSources(root)))};
186
+ for(const [key,value] of Object.entries(environment))out[`${sandboxPrefix}variable:${key}__`]=digest(value);
187
+ for(const dir of environment.PATH.split(path.delimiter).filter(dir=>path.isAbsolute(dir)))
188
+ out[`${sandboxPrefix}directory:${dir}__`]=digest(JSON.stringify(identity(dir,['dev','ino','mtimeNs','ctimeNs'])));
189
+ return out;
190
+ }
191
+ // What changed between two sandboxed contexts, in words, or null.
192
+ export function sandboxChange(before={},now={}){
193
+ const keys=[...new Set([...Object.keys(before),...Object.keys(now)])].filter(key=>key.startsWith(sandboxPrefix)).sort();
194
+ const key=keys.find(key=>before[key]!==now[key]);
195
+ if(!key)return null;
196
+ const name=key.slice(sandboxPrefix.length,-2);
197
+ return name==='places'?'the places a sandboxed run may read changed':
198
+ name==='executables'?'Node, a package manager or the interpreter changed':
199
+ name==='git'?'.git/info/exclude or .git/config changed':
200
+ name.startsWith('variable:')?`environment variable ${name.slice(9)} differs from the last run`:
201
+ !(key in now)?`PATH no longer has directory ${name.slice(10)}`:!(key in before)?`PATH has a new directory ${name.slice(10)}`:
202
+ `PATH directory ${name.slice(10)} changed (a program in it was installed, removed or upgraded)`;
203
+ }
204
+ // A run's private directories, in REDUE's state: the home it keeps between
205
+ // runs, and a fresh temporary directory.
206
+ // The temporary directory is under the system's, so a Unix socket path in it
207
+ // stays short enough.
208
+ export function sandboxDirs(state,name){
209
+ const home=path.join(state,'sandbox',digest(name).slice(0,16),'home');
210
+ fs.mkdirSync(home,{recursive:true,mode:0o700});
211
+ return {home,tmp:fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'redue-run-')))};
212
+ }
213
+
214
+ const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=path.dirname(dir);at!=='/'&&at!=='.';at=path.dirname(at))out.push(at);return out;}))];
215
+ // The Seatbelt profile: reads and executes only beneath the places, the
216
+ // metadata of their parent directories, and no network but Unix sockets in
217
+ // the run's own temporary directory (`own`), which is new for each run: a
218
+ // daemon an earlier run left behind cannot answer a later one.
219
+ export function seatbeltProfile(reads,own=null){
220
+ const quote=value=>JSON.stringify(value);
221
+ const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
222
+ return ['(version 1)','(allow default)','(deny file-read* process-exec*)',
223
+ `(allow file-read* process-exec* ${all.map(dir=>`(subpath ${quote(dir)})`).join(' ')} (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
224
+ `(allow file-read-metadata ${ancestors(all).map(dir=>`(literal ${quote(dir)})`).join(' ')})`,
225
+ '(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
226
+ }
227
+ // The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
228
+ // Unix sockets beneath `tmp` on macOS.
229
+ export function sandboxArgv(mode,argv,{reads,tmp=null,python}){
230
+ if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp),...argv];
231
+ // The launcher's own warnings would become the check's output.
232
+ if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),'--no-tcp','--',...argv];
233
+ throw Error(`unknown sandbox ${mode}`);
234
+ }
235
+ // A line of a failed run's output that looks like the sandbox refused
236
+ // something: a refused read or write, or the network unreachable. Such a
237
+ // failure is run again outside the sandbox to tell a denial from a real one.
238
+ const denial=/operation not permitted|permission denied|read-only file system|\bE(?:PERM|ACCES|ROFS|NOTFOUND|AI_AGAIN|CONNREFUSED|NETUNREACH|HOSTUNREACH)\b|sandbox-exec:|landlock\.py:|network is unreachable|could not resolve host|name or service not known|nodename nor servname|temporary failure in name resolution|getaddrinfo|connection refused/i;
239
+ export function denialLine(text){
240
+ for(const line of String(text).split(/\r?\n/))if(denial.test(line))return line.trim().slice(0,200);
241
+ return null;
242
+ }
243
+
244
+ // Links in the .venv that lead out of the checkout, other than the
245
+ // interpreter's in bin/: REDUE cannot observe what they point to. Looks in
246
+ // the .venv itself, bin/, and the top of each site-packages.
247
+ export function venvOutsideLinks(root){
248
+ const venv=path.join(root,'.venv'),found=[],realRoot=real(root)||root;
249
+ const look=dir=>{let entries;try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{return;}
250
+ for(const entry of entries){
251
+ if(!entry.isSymbolicLink())continue;
252
+ const file=path.join(dir,entry.name),rel=path.relative(root,file).split(path.sep).join('/');
253
+ if(/^\.venv\/bin\/(?:python|pypy|𝜋thon)/.test(rel))continue;
254
+ const target=real(file);
255
+ if(!target||!within(target,realRoot))found.push(rel);
256
+ }};
257
+ look(venv);look(path.join(venv,'bin'));
258
+ // lib64 is often a link to lib: looked at once.
259
+ for(const lib of ['lib','lib64']){let versions=[];
260
+ try{if(lib==='lib64'&&fs.lstatSync(path.join(venv,lib)).isSymbolicLink())continue;versions=fs.readdirSync(path.join(venv,lib));}catch{}
261
+ for(const version of versions)look(path.join(venv,lib,version,'site-packages'));}
262
+ return [...new Set(found)].sort();
263
+ }
264
+ // Plan qualification: the check is what `init` writes for the project now.
265
+ export function qualifySandbox(root,selected){
266
+ const issues=[];
267
+ if(!['darwin','linux'].includes(process.platform))issues.push('the sandboxed recipe runs on macOS and Linux only');
268
+ if(!sandboxKinds.includes(selected.kind))issues.push(`the sandboxed recipe takes ${sandboxKinds.join(' and ')} checks`);
269
+ if(selected.cwd&&selected.cwd!=='.')issues.push('the sandboxed recipe runs checks at the project root');
270
+ const tool=pythonTool(selected);
271
+ try{
272
+ let dirs=[];
273
+ if(tool){
274
+ try{fs.accessSync(path.join(root,selected.command[0]),fs.constants.X_OK);}catch{fail(`${selected.command[0]} is not installed`);}
275
+ const base=venvBase(root);
276
+ if(!base.home||!base.executable)fail('.venv has no pyvenv.cfg home or bin/python');
277
+ // The interpreter's own site-packages are not observed.
278
+ if(/^include-system-site-packages\s*=\s*true\s*$/mi.test(fs.readFileSync(path.join(root,'.venv','pyvenv.cfg'),'utf8')))
279
+ issues.push('.venv uses the system site-packages, which REDUE does not observe');
280
+ for(const rel of venvOutsideLinks(root))issues.push(`${rel} links outside the checkout, where REDUE does not observe it`);
281
+ }else{
282
+ if(typeof selected.script!=='string')fail('script-missing');
283
+ const project=scriptProject(root,selected.script);
284
+ if(JSON.stringify(selected.command?.slice(1))!==JSON.stringify(['run',selected.script])||selected.environment?.pathExecutables?.[1]!==project.manager)
285
+ issues.push('the package manager or script changed; run redue init again');
286
+ dirs=project.dirs;
287
+ }
288
+ const expected=sandboxCheckInputs(dirs),sorted=list=>JSON.stringify([...(list||[])].sort());
289
+ if(sorted(selected.inputs)!==sorted(expected.inputs)||sorted(selected.installedInputs)!==sorted(expected.installedInputs))
290
+ issues.push('inputs must be the whole checkout, with node_modules and .venv observed as installed files; run redue init again');
291
+ const environment=sandboxEnvironment(Boolean(tool));
292
+ if(!environment.variables.every(key=>selected.environment?.variables?.includes(key))||
293
+ !environment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
294
+ issues.push('execution context incomplete; run redue init again');
295
+ }catch(e){issues.push(e.code||'sandboxed check prerequisites unavailable');}
296
+ if(selected.probes?.length)issues.push('the sandboxed recipe takes no probes');
297
+ if(selected.generatedInputs?.length)issues.push('the sandboxed recipe takes no generated inputs');
298
+ return {qualified:issues.length===0,issues};
299
+ }
300
+ // Installed paths a sandboxed plan leaves out: the .venv's links that lead out
301
+ // of the checkout, which the index cannot observe. Qualification reports them.
302
+ export const sandboxInstalledExclusions=root=>venvOutsideLinks(root).map(rel=>`!${rel}`);
303
+ // A run's output for comparison: without colors, timings, the temporary and
304
+ // home directories each run had, and lines about caches, which differ
305
+ // between two runs of the same check (a task runner's cache hit).
306
+ const units='(?:ns|µs|us|ms|s|sec|secs|seconds?|m|min|mins|minutes?|h)';
307
+ export function comparableOutput(text,paths=[]){
308
+ let out=String(text).replace(/\x1b\[[0-9;?]*[A-Za-z]/g,'');
309
+ for(const [from,to] of paths.filter(([from])=>from).sort((a,b)=>b[0].length-a[0].length))out=out.split(from).join(to);
310
+ return out.split(/\r?\n/).filter(line=>!/\bcache[ds]?\b|\bcached\b/i.test(line))
311
+ .map(line=>line.replace(new RegExp(`\\d+(?:[.,]\\d+)?\\s?${units}\\b`,'g'),'<time>')
312
+ .replace(/\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?\b/g,'<clock>').replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g,'<date>').trimEnd())
313
+ .filter(Boolean).join('\n');
314
+ }
315
+ // `init` runs a proposed check both ways before it writes it: plainly, as the
316
+ // project would, and sandboxed. The check is proposed only when both give
317
+ // the same exit status and the same output (comparableOutput), and a failure
318
+ // shows no sign of a refusal by the sandbox; else the reason names the
319
+ // difference.
320
+ // Stops what a run left running when it ends, as a CI job's end does: the
321
+ // run's process group gets SIGTERM, and SIGKILL a second later if it still
322
+ // has members. Its pid is not signalled again once the group is empty, so a
323
+ // reused number is never hit. The timer keeps REDUE alive until then, and
324
+ // the promise settles then. On Linux the Landlock launcher also stops
325
+ // processes that left the group (landlock.py).
326
+ export function stopGroup(pid){
327
+ const signal=name=>{try{process.kill(-pid,name);return true;}catch{return false;}};
328
+ if(!signal('SIGTERM'))return Promise.resolve();
329
+ const until=Date.now()+1000;
330
+ return new Promise(resolve=>{const check=setInterval(()=>{
331
+ if(!signal(0)){clearInterval(check);resolve();return;}
332
+ if(Date.now()>=until){signal('SIGKILL');clearInterval(check);resolve();}
333
+ },100);});
334
+ }
335
+ // A sandboxed run's end: when the command exits, what it left running is
336
+ // stopped, and its pipes are closed a second later, so a helper that holds
337
+ // them open does not hold the run.
338
+ export function endWithChild(child){
339
+ child.on('exit',()=>{stopGroup(child.pid);setTimeout(()=>{child.stdout?.destroy();child.stderr?.destroy();},1000).unref();});
340
+ }
341
+ // Runs argv in its own process group to the end, with no terminal input:
342
+ // {status, signal, error, timedOut, stdout, stderr}, each output kept up to
343
+ // 64 MB and decoded at the end. `echo` also passes the output through.
344
+ // `started` gets the child, so a caller can forward signals to its group;
345
+ // without one, an interrupt stops the group and then REDUE.
346
+ const outputCap=64*1024*1024;
347
+ export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
348
+ return new Promise(resolve=>{
349
+ const kept={stdout:[],stderr:[]},size={stdout:0,stderr:0};
350
+ let timer=null,timedOut=false;
351
+ const child=spawn(argv[0],argv.slice(1),{cwd,env,detached:true,stdio:['ignore','pipe','pipe']});
352
+ const stop=async name=>{process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
353
+ await stopGroup(child.pid);process.kill(process.pid,name);};
354
+ const interrupt=()=>stop('SIGINT'),terminate=()=>stop('SIGTERM');
355
+ if(started)started(child);else{process.once('SIGINT',interrupt);process.once('SIGTERM',terminate);}
356
+ for(const name of ['stdout','stderr'])child[name].on('data',chunk=>{
357
+ if(echo)process[name].write(chunk);
358
+ if(size[name]<outputCap){kept[name].push(chunk);size[name]+=chunk.length;}
359
+ });
360
+ if(timeout)timer=setTimeout(()=>{timedOut=true;stopGroup(child.pid);},timeout);
361
+ endWithChild(child);
362
+ const finish=value=>{clearTimeout(timer);process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
363
+ resolve({...value,timedOut,stdout:Buffer.concat(kept.stdout).toString('utf8'),stderr:Buffer.concat(kept.stderr).toString('utf8')});};
364
+ child.on('error',error=>finish({status:null,signal:null,error}));
365
+ child.on('close',(status,signal)=>finish({status,signal}));
366
+ });
367
+ }
368
+ // {same, plain, sandboxed, line} or {issue} when the sandbox cannot run here.
369
+ export async function sandboxTrial(root,check,{timeout=300000}={}){
370
+ const supported=sandboxSupport();
371
+ if(supported.issue)return {issue:supported.issue};
372
+ const python=Boolean(pythonTool(check));
373
+ const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
374
+ const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager)};
375
+ const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),home=path.join(scratch,'home'),tmp=path.join(scratch,'tmp');
376
+ fs.mkdirSync(home);fs.mkdirSync(tmp);
377
+ try{
378
+ const run=async(argv,env)=>{const r=await runToEnd(argv,{cwd:root,env,timeout});
379
+ return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`};};
380
+ const plain=await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
381
+ const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
382
+ const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python}),sandboxEnv(selected,process.env,{root,home,tmp}));
383
+ // A failure both ways counts as the same only without a sign of a refusal.
384
+ const denied=sandboxed.status===0?null:denialLine(sandboxed.output);
385
+ const realOf=dir=>{try{return fs.realpathSync(dir);}catch{return null;}},plainTmp=process.env.TMPDIR||os.tmpdir();
386
+ const paths=[[home,'<home>'],[realOf(home),'<home>'],[tmp,'<tmp>'],[realOf(tmp),'<tmp>'],[plainTmp,'<tmp>'],[realOf(plainTmp),'<tmp>'],
387
+ [os.homedir(),'<home>'],[realOf(os.homedir()),'<home>']];
388
+ const [a,b]=[plain,sandboxed].map(r=>comparableOutput(r.output,paths).split('\n'));
389
+ // The first line that differs; past the end of the shorter output when one goes on.
390
+ let at=a.findIndex((line,i)=>line!==b[i]);
391
+ if(at<0&&b.length>a.length)at=a.length;
392
+ const output=at<0?null:{plain:(a[at]??'(no more output)').slice(0,160),sandboxed:(b[at]??'(no more output)').slice(0,160)};
393
+ return {same:plain.status===sandboxed.status&&!denied&&!output,plain:plain.status,sandboxed:sandboxed.status,output,
394
+ line:denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null)};
395
+ }finally{fs.rmSync(scratch,{recursive:true,force:true});}
396
+ }