@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 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. The test sandbox uses process containment fences and path validation to block unauthorized host file access and computed shell evasion (base64/eval/pipes), while allowing safe HTTP route literals. The judge receives concrete test outcomes, durations, and output logs to ground decisions in objective verification.
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 (288 tests)
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`) каждый кандидат запускается в изолированном оверлейном окружении с защитой от выхода за пределы песочницы (блокировка вычисляемых shell-команд base64/eval/pipes при сохранении безопасных строковых REST-маршрутов). Судья получает объективный вердикт с кодами возврата, временем исполнения и логами тестов до вынесения итогового решения.
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
+ }
@@ -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, '{candidateDir}', candidateDir)
147
- resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{baseDir}', cwd)
148
- resolvedCmd = replacePlaceholderSafely(resolvedCmd, '{stageDir}', executionDir)
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
- if (COMPUTED_SHELL_EVASION.test(resolvedCmd)) {
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: 'Access denied: test command contains obfuscated or computed command execution',
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
- if (COMPUTED_SHELL_EVASION.test(scriptText)) {
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: 'Access denied: test script contains obfuscated or computed command execution',
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
- if (COMPUTED_SHELL_EVASION.test(scriptsContent)) {
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: 'Access denied: package.json test script contains obfuscated or computed command execution',
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 = `--permission ${allowedReads.map((p) => `--allow-fs-read=${p}`).join(' ')} --allow-fs-write=${executionDir} --allow-fs-write=${effectiveHome} --allow-child-process --require ${fenceFile}`
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.55",
3
+ "version": "0.2.56",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",