@goodandready/dsh-moa 0.2.55 → 0.2.57
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/README.md +3 -2
- package/README.ru.md +2 -1
- package/README.zh.md +4 -0
- package/lib/client.js +495 -0
- package/lib/index.js +9 -0
- package/lib/moa-prompts.js +2 -0
- package/lib/moa-runner.js +19 -14
- package/lib/moa-sandbox.js +287 -0
- package/lib/moa-schema.js +1 -0
- package/lib/moa-test-gate.js +46 -155
- package/lib/moa-workbench.js +481 -0
- package/lib/routes.js +17 -3
- package/package.json +1 -1
package/lib/moa-test-gate.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness Mixture of Agents (MoA) — Test Execution Gate (#161, #242, #260)
|
|
3
|
+
*
|
|
4
|
+
* Runs candidate code through verification tests in an isolated sandbox.
|
|
5
|
+
* Enforces Node.js permission model containment, fence interception,
|
|
6
|
+
* and rejection of child process evasion without OS-level isolation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import os from 'node:os'
|
|
2
10
|
import fs from 'node:fs/promises'
|
|
3
11
|
import fsSync from 'node:fs'
|
|
@@ -6,31 +14,20 @@ import { spawn } from 'node:child_process'
|
|
|
6
14
|
import { resolveCandidateFolder } from './file-workspace.js'
|
|
7
15
|
import { bestEffort } from './best-effort.js'
|
|
8
16
|
import { logger } from './logger.js'
|
|
17
|
+
import {
|
|
18
|
+
isSandboxEvasion,
|
|
19
|
+
isOsSandboxAvailable,
|
|
20
|
+
resolveTestCommand,
|
|
21
|
+
scanPathTokensForLeak,
|
|
22
|
+
getScrubbedEnv,
|
|
23
|
+
buildPermOpts,
|
|
24
|
+
generateFenceCode,
|
|
25
|
+
} from './moa-sandbox.js'
|
|
26
|
+
|
|
27
|
+
export { getScrubbedEnv, scanPathTokensForLeak }
|
|
9
28
|
|
|
10
29
|
const MAX_CAPTURE_BYTES = 256 * 1024
|
|
11
30
|
|
|
12
|
-
/**
|
|
13
|
-
* Returns a sanitized environment stripping sensitive tokens and secrets (#161).
|
|
14
|
-
*/
|
|
15
|
-
export function getScrubbedEnv(overrides = {}) {
|
|
16
|
-
const safeVars = new Set([
|
|
17
|
-
'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TEMP', 'TMP',
|
|
18
|
-
'USER', 'LOGNAME', 'HOME', 'SHELL', 'TERM', 'NODE_ENV', 'SYSTEMROOT',
|
|
19
|
-
'WINDIR', 'COMSPEC', 'PATHEXT',
|
|
20
|
-
])
|
|
21
|
-
const scrubbed = {}
|
|
22
|
-
for (const [key, val] of Object.entries(process.env)) {
|
|
23
|
-
if (!val || key.startsWith('MOA_')) continue
|
|
24
|
-
if (/(API_KEY|TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|KEY|ACCESS)/i.test(key)) {
|
|
25
|
-
continue
|
|
26
|
-
}
|
|
27
|
-
if (safeVars.has(key) || key.startsWith('LC_')) {
|
|
28
|
-
scrubbed[key] = val
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return { ...scrubbed, ...overrides }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
31
|
/**
|
|
35
32
|
* Resolves the directory for candidate sandbox.
|
|
36
33
|
*/
|
|
@@ -43,46 +40,6 @@ export function resolveCandidateDir(baseDir, candidateIndex, options = {}) {
|
|
|
43
40
|
}
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
export function decodeEscapedStrings(str) {
|
|
47
|
-
if (!str || typeof str !== 'string') return ''
|
|
48
|
-
return str.replace(/\\+/g, '\\').replace(/\\(?:([0-7]{1,3})|x([0-9a-fA-F]{2}))/g, (m, oct, hex) =>
|
|
49
|
-
oct ? String.fromCharCode(parseInt(oct, 8)) : hex ? String.fromCharCode(parseInt(hex, 16)) : m
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const COMPUTED_SHELL_EVASION = /(?:base64\s+(?:-d|--decode|-D)[^|\n;]*\|\s*(?:ba|z|da)?sh|\beval\s+["']?\$|\bxxd\s+-r[^|\n;]*\|\s*(?:ba|z|da)?sh|\bprintf\s+[^|\n]+\|\s*(?:ba|z|da)?sh|\bopenssl\s+enc\s+-d[^|\n;]*\|\s*(?:ba|z|da)?sh|\bprintf\s+(?:%s|["']%s["'])\s+.*["'](?:\/|mnt|home|tmp|etc|var|usr|root|opt)["']|\$\(\s*(?:printf|echo)\s+.*["'](?:\/|mnt|home|tmp|etc|var|usr|root|opt)["']|\x60\s*(?:printf|echo)\s+.*["'](?:\/|mnt|home|tmp|etc|var|usr|root|opt)["']|\b(?:cat|head|tail|source|\.|touch|rm|cp|mv)\s+["']?\$[A-Za-z_]|>\s*["']?\$[A-Za-z_]|>>\s*["']?\$[A-Za-z_])/i
|
|
54
|
-
|
|
55
|
-
const UNIX_ROOT_PREFIX = /^\/(?:home|root|etc|var|tmp|opt|mnt|srv|media|proc|sys|dev|usr|bin|lib|lib64|run\/(?:user\/\d+|systemd|lock|credentials|secrets|udev|initramfs|mount|shm|dbus|sshd|motd)|sbin|boot|Users\/(?:[a-zA-Z0-9_\-.]+)|Applications|Library|System|Volumes|private)(?:\/|$)/
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Validates that an executed script or command does not reference files outside allowed roots (#161, #225).
|
|
59
|
-
*/
|
|
60
|
-
function scanPathTokensForLeak(text, allowedRoots, executionDir) {
|
|
61
|
-
if (!text || typeof text !== 'string') return null
|
|
62
|
-
const checkText = (t) => {
|
|
63
|
-
const sanitized = t.replace(/https?:\/\/[^\s"'<>]+/gi, '').replace(/\b(?:node|file|npm|data):[^\s"'<>]+/gi, '')
|
|
64
|
-
const matches = sanitized.match(/(?:[a-zA-Z]:[\\/][^\s"'<>]+|(?<![\w.@\/\\])\/(?:[a-zA-Z0-9_\-.]+\/)*[a-zA-Z0-9_\-.]+|(?<![\w.@\/\\])\.\.[\\/][^\s"'<>]*)/g) || []
|
|
65
|
-
for (const raw of matches) {
|
|
66
|
-
if (/^\/(usr\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\/|$)/.test(raw) && !/\.(txt|json|env|npmrc|key|ssh|md)/i.test(raw)) continue
|
|
67
|
-
const isAbs = path.isAbsolute(raw)
|
|
68
|
-
if ((!isAbs && !raw.startsWith('..')) || (isAbs && process.platform !== 'win32' && !UNIX_ROOT_PREFIX.test(raw))) continue
|
|
69
|
-
const resolved = path.resolve(executionDir, raw)
|
|
70
|
-
if (!allowedRoots.some((r) => r && (resolved === r || resolved.startsWith(r + path.sep)))) return raw
|
|
71
|
-
}
|
|
72
|
-
return null
|
|
73
|
-
}
|
|
74
|
-
const rawLeak = checkText(text)
|
|
75
|
-
if (rawLeak) return rawLeak
|
|
76
|
-
const segmented = text.match(/\bprintf\s+(?:%s|['"]%s['"])\s+((?:['"][^'"]*['"]\s*)+)/i)
|
|
77
|
-
if (segmented) {
|
|
78
|
-
const assembled = (segmented[1].match(/['"]([^'"]*)['"]/g) || []).map((s) => s.slice(1, -1)).join('')
|
|
79
|
-
const assembledLeak = checkText(assembled)
|
|
80
|
-
if (assembledLeak) return assembledLeak
|
|
81
|
-
}
|
|
82
|
-
const decoded = decodeEscapedStrings(text)
|
|
83
|
-
return decoded !== text ? checkText(decoded) : null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
43
|
/**
|
|
87
44
|
* Executes a test command in a candidate sandbox or ephemeral test staging directory.
|
|
88
45
|
*/
|
|
@@ -142,10 +99,10 @@ export async function runCandidateTestGate({
|
|
|
142
99
|
return res
|
|
143
100
|
}
|
|
144
101
|
|
|
145
|
-
let resolvedCmd = trimmedCmd
|
|
146
|
-
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '
|
|
147
|
-
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{
|
|
148
|
-
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '
|
|
102
|
+
let resolvedCmd = resolveTestCommand({ testCommand: trimmedCmd, executionDir })
|
|
103
|
+
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '<CANDIDATE_DIR>', executionDir)
|
|
104
|
+
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{candidateDir}', executionDir)
|
|
105
|
+
resolvedCmd = replacePlaceholderSafely(resolvedCmd, '$CANDIDATE_DIR', executionDir)
|
|
149
106
|
|
|
150
107
|
let exitCode = 0
|
|
151
108
|
let stdout = ''
|
|
@@ -163,7 +120,9 @@ export async function runCandidateTestGate({
|
|
|
163
120
|
stderr = customRes?.stderr || ''
|
|
164
121
|
} else {
|
|
165
122
|
const allowedRoots = [executionDir, cwd, ...(candidateDir && candidateDir !== executionDir ? [candidateDir] : [])]
|
|
166
|
-
|
|
123
|
+
|
|
124
|
+
const cmdEvasion = isSandboxEvasion(resolvedCmd)
|
|
125
|
+
if (cmdEvasion) {
|
|
167
126
|
const durationMs = Date.now() - startTime
|
|
168
127
|
return {
|
|
169
128
|
candidateIndex,
|
|
@@ -171,7 +130,7 @@ export async function runCandidateTestGate({
|
|
|
171
130
|
exitCode: 1,
|
|
172
131
|
durationMs,
|
|
173
132
|
command: resolvedCmd,
|
|
174
|
-
output:
|
|
133
|
+
output: `Access denied: test command contains ${cmdEvasion}`,
|
|
175
134
|
summary: `FAIL (code 1, ${durationMs}ms)`,
|
|
176
135
|
}
|
|
177
136
|
}
|
|
@@ -201,7 +160,7 @@ export async function runCandidateTestGate({
|
|
|
201
160
|
}
|
|
202
161
|
}
|
|
203
162
|
|
|
204
|
-
// 2. Scan script files referenced by command or present in candidate workspace (#161)
|
|
163
|
+
// 2. Scan script files referenced by command or present in candidate workspace (#161, #260)
|
|
205
164
|
const candFiles = fsSync.existsSync(executionDir) ? fsSync.readdirSync(executionDir, { recursive: true }) : []
|
|
206
165
|
const scriptTokens = new Set([
|
|
207
166
|
...cmdTokens.map((t) => t.replace(/^["']|["']$/g, '')),
|
|
@@ -213,7 +172,8 @@ export async function runCandidateTestGate({
|
|
|
213
172
|
if (fsSync.existsSync(scriptPath) && !fsSync.statSync(scriptPath).isDirectory()) {
|
|
214
173
|
try {
|
|
215
174
|
const scriptText = fsSync.readFileSync(scriptPath, 'utf8')
|
|
216
|
-
|
|
175
|
+
const scriptEvasion = isSandboxEvasion(scriptText)
|
|
176
|
+
if (scriptEvasion) {
|
|
217
177
|
const durationMs = Date.now() - startTime
|
|
218
178
|
return {
|
|
219
179
|
candidateIndex,
|
|
@@ -221,7 +181,7 @@ export async function runCandidateTestGate({
|
|
|
221
181
|
exitCode: 1,
|
|
222
182
|
durationMs,
|
|
223
183
|
command: resolvedCmd,
|
|
224
|
-
output:
|
|
184
|
+
output: `Access denied: test script contains ${scriptEvasion}`,
|
|
225
185
|
summary: `FAIL (code 1, ${durationMs}ms)`,
|
|
226
186
|
}
|
|
227
187
|
}
|
|
@@ -244,13 +204,14 @@ export async function runCandidateTestGate({
|
|
|
244
204
|
}
|
|
245
205
|
}
|
|
246
206
|
|
|
247
|
-
// 3. Scan package.json scripts if package.json exists in executionDir (#161, #206)
|
|
207
|
+
// 3. Scan package.json scripts if package.json exists in executionDir (#161, #206, #260)
|
|
248
208
|
const candPkgFile = path.join(executionDir, 'package.json')
|
|
249
209
|
if (fsSync.existsSync(candPkgFile)) {
|
|
250
210
|
try {
|
|
251
211
|
const pkgObj = JSON.parse(fsSync.readFileSync(candPkgFile, 'utf8'))
|
|
252
212
|
const scriptsContent = Object.values(pkgObj.scripts || {}).join('\n')
|
|
253
|
-
|
|
213
|
+
const pkgEvasion = isSandboxEvasion(scriptsContent)
|
|
214
|
+
if (pkgEvasion) {
|
|
254
215
|
const durationMs = Date.now() - startTime
|
|
255
216
|
return {
|
|
256
217
|
candidateIndex,
|
|
@@ -258,7 +219,7 @@ export async function runCandidateTestGate({
|
|
|
258
219
|
exitCode: 1,
|
|
259
220
|
durationMs,
|
|
260
221
|
command: resolvedCmd,
|
|
261
|
-
output:
|
|
222
|
+
output: `Access denied: package.json test script contains ${pkgEvasion}`,
|
|
262
223
|
summary: `FAIL (code 1, ${durationMs}ms)`,
|
|
263
224
|
}
|
|
264
225
|
}
|
|
@@ -304,90 +265,20 @@ export async function runCandidateTestGate({
|
|
|
304
265
|
: ['/usr', '/lib', '/lib64', '/etc']),
|
|
305
266
|
].filter(Boolean)
|
|
306
267
|
|
|
307
|
-
// Create security fence module for child process containment (#161, #206)
|
|
268
|
+
// Create security fence module for child process containment (#161, #206, #260)
|
|
269
|
+
const hasOsSandbox = isOsSandboxAvailable()
|
|
308
270
|
fenceFile = path.join(executionDir, `.moa-fence-${Date.now()}.cjs`)
|
|
309
|
-
const fenceCode =
|
|
310
|
-
const cp = require('node:child_process');
|
|
311
|
-
const path = require('node:path');
|
|
312
|
-
const allowed = ${JSON.stringify(allowedReads)};
|
|
313
|
-
function decodeEscaped(str) {
|
|
314
|
-
if (!str || typeof str !== 'string') return '';
|
|
315
|
-
const norm = str.split(/\\+/).join(String.fromCharCode(92));
|
|
316
|
-
return norm.replace(new RegExp(String.fromCharCode(92, 92) + '(?:([0-7]{1,3})|x([0-9a-fA-F]{2}))', 'g'), (m, oct, hex) => {
|
|
317
|
-
if (oct) return String.fromCharCode(parseInt(oct, 8));
|
|
318
|
-
if (hex) return String.fromCharCode(parseInt(hex, 16));
|
|
319
|
-
return m;
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
const COMPUTED_SHELL_EVASION = /(?:base64\\s+(?:-d|--decode|-D)[^|\\n;]*\\|\\s*(?:ba|z|da)?sh|\\beval\\s+["']?\\$|\\bxxd\\s+-r[^|\\n;]*\\|\\s*(?:ba|z|da)?sh|\\bprintf\\s+[^|\\n]+\\|\\s*(?:ba|z|da)?sh|\\bopenssl\\s+enc\\s+-d[^|\\n;]*\\|\\s*(?:ba|z|da)?sh|\\bprintf\\s+(?:%s|["']%s["'])\\s+.*["'](?:\\/|mnt|home|tmp|etc|var|usr|root|opt)["']|\\$\\(\\s*(?:printf|echo)\\s+.*["'](?:\\/|mnt|home|tmp|etc|var|usr|root|opt)["']|\\x60\\s*(?:printf|echo)\\s+.*["'](?:\\/|mnt|home|tmp|etc|var|usr|root|opt)["']|\\b(?:cat|head|tail|source|\\.|touch|rm|cp|mv)\\s+["']?\\$[A-Za-z_]|>\\s*["']?\\$[A-Za-z_]|>>\\s*["']?\\$[A-Za-z_])/i;
|
|
323
|
-
const UNIX_ROOT_PREFIX = /^\\/(?:home|root|etc|var|tmp|opt|mnt|srv|media|proc|sys|dev|usr|bin|lib|lib64|run\\/(?:user\\/\\d+|systemd|lock|credentials|secrets|udev|initramfs|mount|shm|dbus|sshd|motd)|sbin|boot|Users\\/(?:[a-zA-Z0-9_\\-.]+)|Applications|Library|System|Volumes|private)(?:\\/|$)/;
|
|
324
|
-
function checkSinglePath(p) {
|
|
325
|
-
if (!p || typeof p !== 'string') return true;
|
|
326
|
-
if (/^\\/(usr\\/(bin|lib|include)|bin|lib|lib64|etc|dev)(\\/|$)/.test(p) && !/\\.(txt|json|env|npmrc|key|ssh|md)/i.test(p)) return true;
|
|
327
|
-
const isAbs = path.isAbsolute(p);
|
|
328
|
-
const isUp = p.startsWith('..');
|
|
329
|
-
if (!isAbs && !isUp) return true;
|
|
330
|
-
if (isAbs && process.platform !== 'win32' && !UNIX_ROOT_PREFIX.test(p)) return true;
|
|
331
|
-
const res = path.resolve(process.cwd(), p);
|
|
332
|
-
return allowed.some(root => root && (res === root || res.startsWith(root + path.sep)));
|
|
333
|
-
}
|
|
334
|
-
function scanStringForPaths(str) {
|
|
335
|
-
if (!str || typeof str !== 'string') return true;
|
|
336
|
-
if (!checkSinglePath(str)) return false;
|
|
337
|
-
const dec = decodeEscaped(str);
|
|
338
|
-
if (dec !== str && !checkSinglePath(dec)) return false;
|
|
339
|
-
const matches = str.match(/(?:[a-zA-Z]:[\\\\/][^\\s"'<>]+|(?<![\\w.@/\\\\])\\/(?:[a-zA-Z0-9_\\-.]+\\/)*[a-zA-Z0-9_\\-.]+|(?<![\\w.@/\\\\])\\.\\.[\\\\/][^\\s"'<>]*)/g) || [];
|
|
340
|
-
for (const m of matches) {
|
|
341
|
-
if (!checkSinglePath(m)) return false;
|
|
342
|
-
}
|
|
343
|
-
if (dec !== str) {
|
|
344
|
-
const decMatches = dec.match(/(?:[a-zA-Z]:[\\\\/][^\\s"'<>]+|(?<![\\w.@/\\\\])\\/(?:[a-zA-Z0-9_\\-.]+\\/)*[a-zA-Z0-9_\\-.]+|(?<![\\w.@/\\\\])\\.\\.[\\\\/][^\\s"'<>]*)/g) || [];
|
|
345
|
-
for (const m of decMatches) {
|
|
346
|
-
if (!checkSinglePath(m)) return false;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
function guardArgs(cmd, args) {
|
|
352
|
-
const full = [cmd, ...(Array.isArray(args) ? args : [])].join(' ');
|
|
353
|
-
if (COMPUTED_SHELL_EVASION.test(full)) {
|
|
354
|
-
throw new Error('ERR_ACCESS_DENIED: child process computed execution pattern blocked: ' + full);
|
|
355
|
-
}
|
|
356
|
-
if (cmd && !scanStringForPaths(String(cmd))) {
|
|
357
|
-
throw new Error('ERR_ACCESS_DENIED: child process command outside sandbox: ' + cmd);
|
|
358
|
-
}
|
|
359
|
-
if (Array.isArray(args)) {
|
|
360
|
-
for (const a of args) {
|
|
361
|
-
if (a && !scanStringForPaths(String(a))) {
|
|
362
|
-
throw new Error('ERR_ACCESS_DENIED: child process argument outside sandbox: ' + a);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
const origSpawn = cp.spawn; cp.spawn = function(cmd, args, opts) { guardArgs(cmd, args); return origSpawn.apply(this, arguments); };
|
|
368
|
-
const origSpawnSync = cp.spawnSync; cp.spawnSync = function(cmd, args, opts) { guardArgs(cmd, args); return origSpawnSync.apply(this, arguments); };
|
|
369
|
-
const origExecFile = cp.execFile; cp.execFile = function(file, args, opts, cb) { guardArgs(file, args); return origExecFile.apply(this, arguments); };
|
|
370
|
-
const origExecFileSync = cp.execFileSync; cp.execFileSync = function(file, args, opts) { guardArgs(file, args); return origExecFileSync.apply(this, arguments); };
|
|
371
|
-
const origExec = cp.exec; cp.exec = function(cmd, opts, cb) { guardArgs(cmd, [cmd]); return origExec.apply(this, arguments); };
|
|
372
|
-
const origExecSync = cp.execSync; cp.execSync = function(cmd, opts) { guardArgs(cmd, [cmd]); return origExecSync.apply(this, arguments); };
|
|
373
|
-
const fsMod = require('node:fs');
|
|
374
|
-
function guardFs(p, isWrite) {
|
|
375
|
-
if (!p || typeof p !== 'string') return;
|
|
376
|
-
const isAbs = path.isAbsolute(p);
|
|
377
|
-
const isUp = p.startsWith('..');
|
|
378
|
-
if (!isAbs && !isUp) return;
|
|
379
|
-
const resolved = path.resolve(process.cwd(), p);
|
|
380
|
-
const checkRoots = isWrite ? [process.cwd(), process.env.HOME].filter(Boolean) : allowed;
|
|
381
|
-
const ok = checkRoots.some(r => r && (resolved === r || resolved.startsWith(r + path.sep)));
|
|
382
|
-
if (!ok) throw new Error('ERR_ACCESS_DENIED: fs access outside sandbox: ' + p);
|
|
383
|
-
}
|
|
384
|
-
const origRF = fsMod.readFileSync; fsMod.readFileSync = function(p) { guardFs(p, false); return origRF.apply(this, arguments); };
|
|
385
|
-
const origWF = fsMod.writeFileSync; fsMod.writeFileSync = function(p) { guardFs(p, true); return origWF.apply(this, arguments); };
|
|
386
|
-
const origAF = fsMod.appendFileSync; fsMod.appendFileSync = function(p) { guardFs(p, true); return origAF.apply(this, arguments); };
|
|
387
|
-
`
|
|
271
|
+
const fenceCode = generateFenceCode({ allowedReads, hasOsSandbox })
|
|
388
272
|
fsSync.writeFileSync(fenceFile, fenceCode, 'utf8')
|
|
389
273
|
|
|
390
|
-
const permOpts =
|
|
274
|
+
const permOpts = buildPermOpts({
|
|
275
|
+
allowedReads,
|
|
276
|
+
executionDir,
|
|
277
|
+
effectiveHome,
|
|
278
|
+
fenceFile,
|
|
279
|
+
hasOsSandbox,
|
|
280
|
+
})
|
|
281
|
+
|
|
391
282
|
const nodePath = path.join(cwd, 'node_modules')
|
|
392
283
|
const env = getScrubbedEnv({
|
|
393
284
|
HOME: effectiveHome,
|