@goodandready/dsh-moa 0.2.55 → 0.2.56
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 +2 -2
- package/README.ru.md +1 -1
- package/README.zh.md +3 -0
- package/lib/moa-sandbox.js +287 -0
- package/lib/moa-test-gate.js +46 -155
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ Inspect line-by-line differences between candidate proposals and the curator's s
|
|
|
150
150
|
Before promoting any winning candidate files over the workspace root, `dsh-moa` invokes the local `dsh-time-machine` service to create a shadow Git checkpoint (`moa-pre-promotion: candidate-N`). If `dsh-time-machine` is absent or unreachable, file promotion is blocked (fail-closed) to protect uncheckpointed workspace files against unintended overwrites (override with `force: true` only when automated checkpoints are explicitly unneeded).
|
|
151
151
|
|
|
152
152
|
### 9. Automated Test Execution Gate
|
|
153
|
-
When `test_gate_enabled: true` and `test_command` (e.g. `npm test` or `pytest`) are configured, candidate code is executed in an ephemeral sandbox overlay.
|
|
153
|
+
When `test_gate_enabled: true` and `test_command` (e.g. `npm test` or `pytest`) are configured, candidate code is executed in an ephemeral sandbox overlay under Node.js native permission model (`--permission`) and an ephemeral runtime fence (`lib/moa-sandbox.js`). Without OS-level container isolation (bubblewrap), child process execution is strictly prohibited (`--allow-child-process` is omitted) to prevent candidate scripts from stripping `NODE_OPTIONS` and escaping sandbox restrictions (#260). Package manager commands (`npm test`, `pnpm test`) are resolved directly to underlying `package.json` test scripts, while any evasion patterns (`env -u NODE_OPTIONS`, `unset NODE_OPTIONS`, computed shell evasion) are detected and fail-closed prior to execution. The judge receives concrete test outcomes, durations, and output logs to ground decisions in objective verification.
|
|
154
154
|
|
|
155
155
|
### 10. Multi-Judge Panel & Consensus Voting
|
|
156
156
|
When `multi_judge_enabled: true`, candidate solutions are independently evaluated by a panel of judge models. Winner selection supports `majority`, `highest_score`, or `unanimous` consensus strategies.
|
|
@@ -335,7 +335,7 @@ npm test
|
|
|
335
335
|
## 🛠️ Internal Tooling & Development
|
|
336
336
|
|
|
337
337
|
For local verification and package integrity validation:
|
|
338
|
-
- `npm test`: runs the full test suite (
|
|
338
|
+
- `npm test`: runs the full test suite (294 tests)
|
|
339
339
|
- `./deploy.sh`: local infrastructure validation script (verifies package size, identity parity in package.json/cordis.patch.yml/client.js, and tests). Excluded from the published npm package.
|
|
340
340
|
|
|
341
341
|
## 📄 License
|
package/README.ru.md
CHANGED
|
@@ -150,7 +150,7 @@ graph TD
|
|
|
150
150
|
Перед промоушном файлов победителя в корень рабочей области `dsh-moa` автоматически обращается к сервису `dsh-time-machine` для создания снимка (`moa-pre-promotion: candidate-N`). Если плагин недоступен или создание снимка завершилось ошибкой, перезапись файлов блокируется во избежание потери несохранённых данных (переопределяется параметром `force: true`, только если создание снимков намеренно отключено).
|
|
151
151
|
|
|
152
152
|
### 9. Автоматический шлюз выполнения тестов (Automated Test Execution Gate)
|
|
153
|
-
При включении `test_gate_enabled: true` и указании тестовой команды (`test_command`, например `npm test` или `pytest`) каждый кандидат запускается в изолированном оверлейном окружении
|
|
153
|
+
При включении `test_gate_enabled: true` и указании тестовой команды (`test_command`, например `npm test` или `pytest`) каждый кандидат запускается в изолированном оверлейном окружении под нативной моделью прав Node.js (`--permission`) и рантайм-фенсом (`lib/moa-sandbox.js`). При отсутствии изоляции уровня ОС (bubblewrap) создание дочерних процессов строго запрещено (флаг `--allow-child-process` исключён), что предотвращает обход песочницы через сброс `NODE_OPTIONS` (#260). Команды пакетных менеджеров (`npm test`, `pnpm test`) разрешаются напрямую в скрипты `package.json`, а любые векторы уклонения (`env -u NODE_OPTIONS`, `unset NODE_OPTIONS`, base64/eval/pipes) блокируются до запуска. Судья получает объективный вердикт с кодами возврата, временем исполнения и логами тестов до вынесения итогового решения.
|
|
154
154
|
|
|
155
155
|
### 10. Коллегия судей и консенсусное голосование (Multi-Judge Panel)
|
|
156
156
|
При активации опции `multi_judge_enabled` решения кандидатов оценивает независимая панель судейских моделей с поддержкой стратегий голосования: `majority` (большинство), `highest_score` (наивысший средний балл) или `unanimous` (единогласный консенсус).
|
package/README.zh.md
CHANGED
|
@@ -150,6 +150,9 @@ graph TD
|
|
|
150
150
|
### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
|
|
151
151
|
在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如 `dsh-time-machine` 未安装或无法访问,文件提升将被阻断以防止未备份的项目文件被意外覆盖(仅在明确不需要检查点时可通过 `force: true` 覆盖)。
|
|
152
152
|
|
|
153
|
+
### 9. 自动化测试执行网关 (Automated Test Execution Gate)
|
|
154
|
+
当启用 `test_gate_enabled: true` 并配置 `test_command`(如 `npm test` 或 `pytest`)时,候选代码将在临时沙箱覆盖层中执行。沙箱基于 Node.js 原生权限模型(`--permission`)与运行时防护栏(`lib/moa-sandbox.js`)。在无操作系统级容器隔离(bubblewrap)时,严格禁用子进程创建(省略 `--allow-child-process`),杜绝候选代码通过剥离 `NODE_OPTIONS` 逃逸沙箱 (#260)。包管理器命令(`npm test`)直接映射到底层脚本,并在执行前严密拦截任何规避意图(`env -u NODE_OPTIONS`、`unset NODE_OPTIONS` 等)。裁判在最终决策前将获取客观的测试结果、耗时与输出日志。
|
|
155
|
+
|
|
153
156
|
### 18. 平滑降级与本地容灾恢复 (Local Fallback Resilience)
|
|
154
157
|
当 `local_fallback_enabled: true` 时,因网络中断、超时或速率限制(429/500)而失败的候选模型会自动使用配置的本地模型(Ollama / MiniPC)恢复。每个后备模型都拥有独立的超时执行配额与关联主 turn signal 的隔离取消控制器,防止云端超时耗尽本地恢复时间。同时严格检测终止错误与流中断,确保安全容灾且杜绝已取消 turn 的延迟文件提升。
|
|
155
158
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness Mixture of Agents (MoA) — Test Gate OS & Node Sandbox (#260)
|
|
3
|
+
*
|
|
4
|
+
* Implements OS-level isolation probing, permission model flags,
|
|
5
|
+
* fence module generation, environment sanitization, and evasion inspection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import cp from 'node:child_process'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import fsSync from 'node:fs'
|
|
11
|
+
import { logger } from './logger.js'
|
|
12
|
+
|
|
13
|
+
export 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
|
|
14
|
+
|
|
15
|
+
export const NODE_OPTIONS_EVASION = /(?:\benv\b[^|;\n]*(?:-[^\s]*u\s*NODE_OPTIONS|--unset=NODE_OPTIONS)|\bunset\s+NODE_OPTIONS|\bexport\s+NODE_OPTIONS\s*=|\bNODE_OPTIONS\s*=)/i
|
|
16
|
+
|
|
17
|
+
export const DANGEROUS_BINARIES = /\b(?:sudo|su|bwrap|chroot|systemd-run)\b/i
|
|
18
|
+
|
|
19
|
+
export 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)(?:\/|$)/
|
|
20
|
+
|
|
21
|
+
export const SAFE_SYS_DIRS = /^\/(?:usr\/(?:bin|lib|include)|bin|lib|lib64|etc|dev)(?:\/|$)/
|
|
22
|
+
|
|
23
|
+
export const SENSITIVE_EXTS = /\.(?:txt|json|env|npmrc|key|ssh|md)/i
|
|
24
|
+
|
|
25
|
+
export const PATH_TOKEN_REGEX = /(?:[a-zA-Z]:[\\/][^\s"'<>]+|(?<![\w.@/\\])\/(?:[a-zA-Z0-9_\-.]+\/)*[a-zA-Z0-9_\-.]+|(?<![\w.@/\\])\.\.[\\/][^\s"'<>]*)/g
|
|
26
|
+
|
|
27
|
+
export const OCTAL_HEX_ESCAPE = /\\(?:([0-7]{1,3})|x([0-9a-fA-F]{2}))/g
|
|
28
|
+
|
|
29
|
+
let cachedOsSandbox = null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks whether OS-level unprivileged sandbox (bubblewrap) is fully operational (#260).
|
|
33
|
+
*/
|
|
34
|
+
export function isOsSandboxAvailable() {
|
|
35
|
+
if (cachedOsSandbox !== null) return cachedOsSandbox
|
|
36
|
+
if (process.platform !== 'linux') {
|
|
37
|
+
cachedOsSandbox = false
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const probe = cp.spawnSync(
|
|
42
|
+
'bwrap',
|
|
43
|
+
['--ro-bind', '/usr', '/usr', '--proc', '/proc', '--dev', '/dev', '--', 'true'],
|
|
44
|
+
{ timeout: 2000, stdio: 'ignore' }
|
|
45
|
+
)
|
|
46
|
+
cachedOsSandbox = probe.status === 0
|
|
47
|
+
} catch (probeErr) {
|
|
48
|
+
logger.debug('[dsh-moa:sandbox] OS sandbox probe error:', probeErr?.message || probeErr)
|
|
49
|
+
cachedOsSandbox = false
|
|
50
|
+
}
|
|
51
|
+
return cachedOsSandbox
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves package manager runner commands (e.g. `npm test`) to direct script invocations
|
|
56
|
+
* to allow execution under Node permission model without requiring --allow-child-process (#260).
|
|
57
|
+
*/
|
|
58
|
+
export function resolveTestCommand({ testCommand, executionDir }) {
|
|
59
|
+
if (!testCommand || typeof testCommand !== 'string') return ''
|
|
60
|
+
const trimmed = testCommand.trim()
|
|
61
|
+
const npmMatch = trimmed.match(/^(?:npm\s+(?:run\s+)?test|npm\s+t|pnpm\s+(?:run\s+)?test|pnpm\s+t|yarn\s+test)(?:\s+--?\s*(.*))?$/i)
|
|
62
|
+
if (npmMatch && executionDir) {
|
|
63
|
+
const candPkgFile = path.join(executionDir, 'package.json')
|
|
64
|
+
if (fsSync.existsSync(candPkgFile)) {
|
|
65
|
+
try {
|
|
66
|
+
const pkgObj = JSON.parse(fsSync.readFileSync(candPkgFile, 'utf8'))
|
|
67
|
+
const scriptCmd = pkgObj.scripts?.test
|
|
68
|
+
if (scriptCmd && typeof scriptCmd === 'string') {
|
|
69
|
+
const extraArgs = npmMatch[1] ? ` ${npmMatch[1]}` : ''
|
|
70
|
+
return scriptCmd.trim() + extraArgs
|
|
71
|
+
}
|
|
72
|
+
} catch (pkgErr) {
|
|
73
|
+
logger.debug('[dsh-moa:sandbox] package.json parse error in resolveTestCommand:', pkgErr?.message || pkgErr)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return trimmed
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Inspects a command or script string for obfuscation or security environment evasion (#161, #260).
|
|
82
|
+
*/
|
|
83
|
+
export function isSandboxEvasion(text) {
|
|
84
|
+
if (!text || typeof text !== 'string') return null
|
|
85
|
+
if (COMPUTED_SHELL_EVASION.test(text)) {
|
|
86
|
+
return 'obfuscated or computed command execution'
|
|
87
|
+
}
|
|
88
|
+
if (NODE_OPTIONS_EVASION.test(text)) {
|
|
89
|
+
return 'attempts to bypass sandbox or strip security environment (NODE_OPTIONS)'
|
|
90
|
+
}
|
|
91
|
+
if (DANGEROUS_BINARIES.test(text)) {
|
|
92
|
+
return 'unauthorized privileged or sandbox-escape binary execution'
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Decodes octal and hex escape sequences in strings.
|
|
99
|
+
*/
|
|
100
|
+
export function decodeEscapedStrings(str) {
|
|
101
|
+
if (!str || typeof str !== 'string') return ''
|
|
102
|
+
const normalized = str.split(/\\+/).join('\\')
|
|
103
|
+
return normalized.replace(OCTAL_HEX_ESCAPE, (m, oct, hex) => {
|
|
104
|
+
if (oct) return String.fromCharCode(parseInt(oct, 8))
|
|
105
|
+
if (hex) return String.fromCharCode(parseInt(hex, 16))
|
|
106
|
+
return m
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validates that an executed script or command does not reference files outside allowed roots (#161, #225, #260).
|
|
112
|
+
*/
|
|
113
|
+
export function scanPathTokensForLeak(text, allowedRoots, executionDir) {
|
|
114
|
+
if (!text || typeof text !== 'string') return null
|
|
115
|
+
const checkText = (t) => {
|
|
116
|
+
const sanitized = t.replace(/https?:\/\/[^\s"'<>]+/gi, '').replace(/\b(?:node|file|npm|data):[^\s"'<>]+/gi, '')
|
|
117
|
+
const matches = sanitized.match(PATH_TOKEN_REGEX) || []
|
|
118
|
+
for (const raw of matches) {
|
|
119
|
+
if (SAFE_SYS_DIRS.test(raw) && !SENSITIVE_EXTS.test(raw)) continue
|
|
120
|
+
const isAbs = path.isAbsolute(raw)
|
|
121
|
+
if ((!isAbs && !raw.startsWith('..')) || (isAbs && process.platform !== 'win32' && !UNIX_ROOT_PREFIX.test(raw))) continue
|
|
122
|
+
const resolved = path.resolve(executionDir, raw)
|
|
123
|
+
if (!allowedRoots.some((r) => r && (resolved === r || resolved.startsWith(r + path.sep)))) return raw
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
const rawLeak = checkText(text)
|
|
128
|
+
if (rawLeak) return rawLeak
|
|
129
|
+
const segmented = text.match(/\bprintf\s+(?:%s|['"]%s['"])\s+((?:['"][^'"]*['"]\s*)+)/i)
|
|
130
|
+
if (segmented) {
|
|
131
|
+
const assembled = (segmented[1].match(/['"]([^'"]*)['"]/g) || []).map((s) => s.slice(1, -1)).join('')
|
|
132
|
+
const assembledLeak = checkText(assembled)
|
|
133
|
+
if (assembledLeak) return assembledLeak
|
|
134
|
+
}
|
|
135
|
+
const decoded = decodeEscapedStrings(text)
|
|
136
|
+
return decoded !== text ? checkText(decoded) : null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sanitizes process environment for untrusted candidate tests (#244, #260).
|
|
141
|
+
*/
|
|
142
|
+
export function getScrubbedEnv(overrides = {}) {
|
|
143
|
+
const safeVars = new Set([
|
|
144
|
+
'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TEMP', 'TMP',
|
|
145
|
+
'USER', 'LOGNAME', 'HOME', 'SHELL', 'TERM', 'NODE_ENV', 'SYSTEMROOT',
|
|
146
|
+
'WINDIR', 'COMSPEC', 'PATHEXT',
|
|
147
|
+
])
|
|
148
|
+
const scrubbed = {}
|
|
149
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
150
|
+
if (!val || key.startsWith('MOA_') || key.startsWith('DSH_') || key.startsWith('SSH_')) continue
|
|
151
|
+
if (/(API_KEY|TOKEN|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|KEY|ACCESS)/i.test(key)) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (safeVars.has(key) || key.startsWith('LC_')) {
|
|
155
|
+
scrubbed[key] = val
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { ...scrubbed, ...overrides }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Builds Node.js permission model CLI options (#260).
|
|
163
|
+
*
|
|
164
|
+
* CRITICAL SECURITY CONTRACT:
|
|
165
|
+
* When OS-level container isolation is NOT active, --allow-child-process MUST NOT be used,
|
|
166
|
+
* because child processes can strip NODE_OPTIONS and bypass the permission model.
|
|
167
|
+
*/
|
|
168
|
+
export function buildPermOpts({ allowedReads, executionDir, effectiveHome, fenceFile, hasOsSandbox = false }) {
|
|
169
|
+
const readFlags = allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')
|
|
170
|
+
const writeFlags = `--allow-fs-write=${executionDir} --allow-fs-write=${effectiveHome}`
|
|
171
|
+
const childFlag = hasOsSandbox ? ' --allow-child-process' : ''
|
|
172
|
+
return `--permission ${readFlags} ${writeFlags}${childFlag} --require ${fenceFile}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Generates the source code for the ephemeral runtime fence module (#161, #260).
|
|
177
|
+
*/
|
|
178
|
+
export function generateFenceCode({ allowedReads, hasOsSandbox = false }) {
|
|
179
|
+
const allowedJson = JSON.stringify(allowedReads)
|
|
180
|
+
const evasionPattern = JSON.stringify(COMPUTED_SHELL_EVASION.source)
|
|
181
|
+
const nodeOptsPattern = JSON.stringify(NODE_OPTIONS_EVASION.source)
|
|
182
|
+
const unixRootPattern = JSON.stringify(UNIX_ROOT_PREFIX.source)
|
|
183
|
+
const sysDirsPattern = JSON.stringify(SAFE_SYS_DIRS.source)
|
|
184
|
+
const sensExtsPattern = JSON.stringify(SENSITIVE_EXTS.source)
|
|
185
|
+
const pathTokenPattern = JSON.stringify(PATH_TOKEN_REGEX.source)
|
|
186
|
+
const octalHexPattern = JSON.stringify(OCTAL_HEX_ESCAPE.source)
|
|
187
|
+
|
|
188
|
+
return [
|
|
189
|
+
"const cp = require('node:child_process');",
|
|
190
|
+
"const path = require('node:path');",
|
|
191
|
+
`const allowed = ${allowedJson};`,
|
|
192
|
+
`const hasOsSandbox = ${hasOsSandbox ? 'true' : 'false'};`,
|
|
193
|
+
"",
|
|
194
|
+
`const COMPUTED_SHELL_EVASION = new RegExp(${evasionPattern}, 'i');`,
|
|
195
|
+
`const NODE_OPTIONS_EVASION = new RegExp(${nodeOptsPattern}, 'i');`,
|
|
196
|
+
`const UNIX_ROOT_PREFIX = new RegExp(${unixRootPattern});`,
|
|
197
|
+
`const SAFE_SYS_DIRS = new RegExp(${sysDirsPattern});`,
|
|
198
|
+
`const SENSITIVE_EXTS = new RegExp(${sensExtsPattern}, 'i');`,
|
|
199
|
+
`const PATH_TOKEN_REGEX = new RegExp(${pathTokenPattern}, 'g');`,
|
|
200
|
+
`const OCTAL_HEX_ESCAPE = new RegExp(${octalHexPattern}, 'g');`,
|
|
201
|
+
"",
|
|
202
|
+
"function decodeEscaped(str) {",
|
|
203
|
+
" if (!str || typeof str !== 'string') return '';",
|
|
204
|
+
" const norm = str.replace(/\\+/g, '\\\\');",
|
|
205
|
+
" return norm.replace(OCTAL_HEX_ESCAPE, (m, oct, hex) => {",
|
|
206
|
+
" if (oct) return String.fromCharCode(parseInt(oct, 8));",
|
|
207
|
+
" if (hex) return String.fromCharCode(parseInt(hex, 16));",
|
|
208
|
+
" return m;",
|
|
209
|
+
" });",
|
|
210
|
+
"}",
|
|
211
|
+
"",
|
|
212
|
+
"function checkSinglePath(p) {",
|
|
213
|
+
" if (!p || typeof p !== 'string') return true;",
|
|
214
|
+
" if (SAFE_SYS_DIRS.test(p) && !SENSITIVE_EXTS.test(p)) return true;",
|
|
215
|
+
" const isAbs = path.isAbsolute(p);",
|
|
216
|
+
" const isUp = p.startsWith('..');",
|
|
217
|
+
" if (!isAbs && !isUp) return true;",
|
|
218
|
+
" if (isAbs && process.platform !== 'win32' && !UNIX_ROOT_PREFIX.test(p)) return true;",
|
|
219
|
+
" const res = path.resolve(process.cwd(), p);",
|
|
220
|
+
" return allowed.some(root => root && (res === root || res.startsWith(root + path.sep)));",
|
|
221
|
+
"}",
|
|
222
|
+
"",
|
|
223
|
+
"function scanStringForPaths(str) {",
|
|
224
|
+
" if (!str || typeof str !== 'string') return true;",
|
|
225
|
+
" if (!checkSinglePath(str)) return false;",
|
|
226
|
+
" const dec = decodeEscaped(str);",
|
|
227
|
+
" if (dec !== str && !checkSinglePath(dec)) return false;",
|
|
228
|
+
" const matches = str.match(PATH_TOKEN_REGEX) || [];",
|
|
229
|
+
" for (const m of matches) {",
|
|
230
|
+
" if (!checkSinglePath(m)) return false;",
|
|
231
|
+
" }",
|
|
232
|
+
" if (dec !== str) {",
|
|
233
|
+
" const decMatches = dec.match(PATH_TOKEN_REGEX) || [];",
|
|
234
|
+
" for (const m of decMatches) {",
|
|
235
|
+
" if (!checkSinglePath(m)) return false;",
|
|
236
|
+
" }",
|
|
237
|
+
" }",
|
|
238
|
+
" return true;",
|
|
239
|
+
"}",
|
|
240
|
+
"",
|
|
241
|
+
"function guardChildCall(methodName, cmd, args) {",
|
|
242
|
+
" if (!hasOsSandbox) {",
|
|
243
|
+
" throw new Error('ERR_ACCESS_DENIED: child process execution restricted without OS-level sandbox: ' + methodName);",
|
|
244
|
+
" }",
|
|
245
|
+
" const full = [cmd, ...(Array.isArray(args) ? args : [])].join(' ');",
|
|
246
|
+
" if (COMPUTED_SHELL_EVASION.test(full)) {",
|
|
247
|
+
" throw new Error('ERR_ACCESS_DENIED: child process computed execution pattern blocked: ' + full);",
|
|
248
|
+
" }",
|
|
249
|
+
" if (NODE_OPTIONS_EVASION.test(full)) {",
|
|
250
|
+
" throw new Error('ERR_ACCESS_DENIED: child process attempts to strip security environment: ' + full);",
|
|
251
|
+
" }",
|
|
252
|
+
" if (cmd && !scanStringForPaths(String(cmd))) {",
|
|
253
|
+
" throw new Error('ERR_ACCESS_DENIED: child process command outside sandbox: ' + cmd);",
|
|
254
|
+
" }",
|
|
255
|
+
" if (Array.isArray(args)) {",
|
|
256
|
+
" for (const a of args) {",
|
|
257
|
+
" if (a && !scanStringForPaths(String(a))) {",
|
|
258
|
+
" throw new Error('ERR_ACCESS_DENIED: child process argument outside sandbox: ' + a);",
|
|
259
|
+
" }",
|
|
260
|
+
" }",
|
|
261
|
+
" }",
|
|
262
|
+
"}",
|
|
263
|
+
"",
|
|
264
|
+
"const origSpawn = cp.spawn; cp.spawn = function(cmd, args, opts) { guardChildCall('spawn', cmd, args); return origSpawn.apply(this, arguments); };",
|
|
265
|
+
"const origSpawnSync = cp.spawnSync; cp.spawnSync = function(cmd, args, opts) { guardChildCall('spawnSync', cmd, args); return origSpawnSync.apply(this, arguments); };",
|
|
266
|
+
"const origExecFile = cp.execFile; cp.execFile = function(file, args, opts, cb) { guardChildCall('execFile', file, args); return origExecFile.apply(this, arguments); };",
|
|
267
|
+
"const origExecFileSync = cp.execFileSync; cp.execFileSync = function(file, args, opts) { guardChildCall('execFileSync', file, args); return origExecFileSync.apply(this, arguments); };",
|
|
268
|
+
"const origExec = cp.exec; cp.exec = function(cmd, opts, cb) { guardChildCall('exec', cmd, [cmd]); return origExec.apply(this, arguments); };",
|
|
269
|
+
"const origExecSync = cp.execSync; cp.execSync = function(cmd, opts) { guardChildCall('execSync', cmd, [cmd]); return origExecSync.apply(this, arguments); };",
|
|
270
|
+
"const origFork = cp.fork; cp.fork = function(modulePath, args, opts) { guardChildCall('fork', modulePath, args); return origFork.apply(this, arguments); };",
|
|
271
|
+
"",
|
|
272
|
+
"const fsMod = require('node:fs');",
|
|
273
|
+
"function guardFs(p, isWrite) {",
|
|
274
|
+
" if (!p || typeof p !== 'string') return;",
|
|
275
|
+
" const isAbs = path.isAbsolute(p);",
|
|
276
|
+
" const isUp = p.startsWith('..');",
|
|
277
|
+
" if (!isAbs && !isUp) return;",
|
|
278
|
+
" const resolved = path.resolve(process.cwd(), p);",
|
|
279
|
+
" const checkRoots = isWrite ? [process.cwd(), process.env.HOME].filter(Boolean) : allowed;",
|
|
280
|
+
" const ok = checkRoots.some(r => r && (resolved === r || resolved.startsWith(r + path.sep)));",
|
|
281
|
+
" if (!ok) throw new Error('ERR_ACCESS_DENIED: fs access outside sandbox: ' + p);",
|
|
282
|
+
"}",
|
|
283
|
+
"const origRF = fsMod.readFileSync; fsMod.readFileSync = function(p) { guardFs(p, false); return origRF.apply(this, arguments); };",
|
|
284
|
+
"const origWF = fsMod.writeFileSync; fsMod.writeFileSync = function(p) { guardFs(p, true); return origWF.apply(this, arguments); };",
|
|
285
|
+
"const origAF = fsMod.appendFileSync; fsMod.appendFileSync = function(p) { guardFs(p, true); return origAF.apply(this, arguments); };",
|
|
286
|
+
].join('\n')
|
|
287
|
+
}
|
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,
|