@goodandready/dsh-moa 0.2.53 → 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.
@@ -302,6 +302,9 @@ dsh-moa:
302
302
 
303
303
  ## 📊 REST API & Endpoints
304
304
 
305
+ > [!IMPORTANT]
306
+ > **Authentication & Access Control**: All HTTP endpoints enforce fail-closed authentication via the DeepSeek Harness Connection Service (`ctx.connection.requestRejection`). Unauthenticated remote callers over LAN/WAN receive HTTP 401 (`Authentication required`) or 403 (`Forbidden`), strictly preventing disclosure of prompts, workspace structures, and candidate files. Mutation endpoints additionally require loopback origin and CSRF validation (`sec-fetch-site`).
307
+
305
308
  | Endpoint | Method | Description |
306
309
  |:---|:---|:---|
307
310
  | `/dsh-moa/status` | `GET` | Health/enablement snapshot used by the settings card status chip |
@@ -332,7 +335,7 @@ npm test
332
335
  ## 🛠️ Internal Tooling & Development
333
336
 
334
337
  For local verification and package integrity validation:
335
- - `npm test`: runs the full test suite (95 tests)
338
+ - `npm test`: runs the full test suite (294 tests)
336
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.
337
340
 
338
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` (единогласный консенсус).
@@ -302,6 +302,9 @@ dsh-moa:
302
302
 
303
303
  ## 📊 REST API эндпоинты
304
304
 
305
+ > [!IMPORTANT]
306
+ > **Аутентификация и контроль доступа**: Все HTTP-эндпоинты плагина защищены каноническим сервисом соединений DeepSeek Harness (`ctx.connection.requestRejection`). Неаутентифицированные удалённые запросы из сети LAN/WAN отклоняются с кодом HTTP 401 (`Authentication required`) или 403 (`Forbidden`), предотвращая утечку промптов, структуры рабочей директории и файлов кандидатов. Мутирующие эндпоинты дополнительно требуют loopback-вызова и проверки CSRF (`sec-fetch-site`).
307
+
305
308
  | Эндпоинт | Метод | Описание |
306
309
  |:---|:---|:---|
307
310
  | `/dsh-moa/status` | `GET` | Снимок здоровья/включенности для статус-бейджа карточки настроек |
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
 
package/lib/access.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * DeepSeek Harness Mixture of Agents (MoA) — Access Control & Route Guard (#257)
3
+ */
4
+
5
+ import crypto from 'node:crypto'
6
+ import { assertPathContained } from './file-workspace.js'
7
+
8
+ export function writeJson(res, status, body) {
9
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
10
+ res.end(JSON.stringify(body))
11
+ }
12
+
13
+ export function readBody(req) {
14
+ return new Promise((resolve, reject) => {
15
+ let acc = ''
16
+ req.on('data', (chunk) => {
17
+ acc += chunk
18
+ if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
19
+ })
20
+ req.on('end', () => resolve(acc))
21
+ req.on('error', reject)
22
+ })
23
+ }
24
+
25
+ export function isAllowedWorkspace(targetCwd, allowedRoots) {
26
+ if (!targetCwd || typeof targetCwd !== 'string') return false
27
+ for (const root of allowedRoots) {
28
+ if (!root) continue
29
+ try {
30
+ assertPathContained(root, targetCwd)
31
+ return true
32
+ } catch {
33
+ // not within root
34
+ }
35
+ }
36
+ return false
37
+ }
38
+
39
+ export function getConnection(ctx) {
40
+ try {
41
+ const fromCtx = ctx?.reflect?.get
42
+ ? (ctx.reflect.get('connection') || ctx?.connection)
43
+ : (ctx?.get ? (ctx.get('connection') || ctx?.connection) : Reflect.get(ctx || {}, 'connection'))
44
+ if (fromCtx) return fromCtx
45
+ if (ctx?.root) return getConnection(ctx.root)
46
+ return undefined
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ export function isLoopbackIp(ip) {
53
+ if (!ip || typeof ip !== 'string') return false
54
+ const clean = ip.trim().toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
55
+ return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
56
+ }
57
+
58
+ export function cookieNameForAuthority(authority) {
59
+ if (!authority || typeof authority !== 'string') return ''
60
+ const hash = crypto.createHash('sha256').update(authority.trim()).digest('base64url')
61
+ return 'dsh-auth-' + hash
62
+ }
63
+
64
+ export function isLoopbackCookieName(name, host = '') {
65
+ if (!name || typeof name !== 'string' || !name.startsWith('dsh-auth-')) return false
66
+ const authorities = [
67
+ host,
68
+ '127.0.0.1:3000',
69
+ '127.0.0.1:3080',
70
+ '127.0.0.1:3082',
71
+ '127.0.0.1',
72
+ 'localhost:3000',
73
+ 'localhost:3080',
74
+ 'localhost:3082',
75
+ 'localhost',
76
+ '[::1]:3000',
77
+ '[::1]',
78
+ ]
79
+ for (const auth of authorities) {
80
+ if (auth && isLoopbackIp(auth.split(':')[0])) {
81
+ if (cookieNameForAuthority(auth) === name) return true
82
+ }
83
+ }
84
+ return false
85
+ }
86
+
87
+ export function hasClientCredentials(req) {
88
+ if (!req || !req.headers) return false
89
+ const cookie = req.headers.cookie
90
+ if (!cookie || typeof cookie !== 'string') return false
91
+ if (
92
+ cookie.includes('dsh_token=') ||
93
+ cookie.includes('dsh_lan_pin=') ||
94
+ cookie.includes('dsh_auth_session=')
95
+ ) {
96
+ return true
97
+ }
98
+
99
+ const parts = cookie.split(';')
100
+ for (const part of parts) {
101
+ const trimmed = part.trim()
102
+ const eqIdx = trimmed.indexOf('=')
103
+ if (eqIdx === -1) continue
104
+ const name = trimmed.slice(0, eqIdx).trim()
105
+ if (name.startsWith('dsh-auth-')) {
106
+ if (!isLoopbackCookieName(name, req.headers.host)) {
107
+ return true
108
+ }
109
+ }
110
+ }
111
+ return false
112
+ }
113
+
114
+ /**
115
+ * Validates whether an incoming HTTP request is authenticated and admitted (#257).
116
+ *
117
+ * Requirements:
118
+ * 1. CSRF protection: rejects sec-fetch-site: cross-site with 403.
119
+ * 2. Reverse proxy inspection: if request is forwarded with non-loopback x-forwarded-for,
120
+ * verify that the client itself supplied authentic credentials (not just injected loopback proxy cookie).
121
+ * 3. Authoritative DSH connection gate: if connection.requestRejection is present,
122
+ * its verdict (401, 403, or undefined) is authoritative.
123
+ * 4. Network boundary: non-loopback callers without connection service authentication
124
+ * fail closed with 401. Loopback callers are admitted.
125
+ *
126
+ * @param {import('node:http').IncomingMessage} req
127
+ * @param {any} [ctx]
128
+ * @returns {number|undefined} Rejection status (401, 403) or undefined if admitted.
129
+ */
130
+ export function getReadRejectionStatus(req, ctx = null) {
131
+ if (!req || !req.headers) return 403
132
+
133
+ // 1. Strict CSRF protection: reject cross-site requests
134
+ const site = req.headers['sec-fetch-site']
135
+ if (site === 'cross-site') {
136
+ return 403
137
+ }
138
+
139
+ // 2. Reverse-proxy / forwarded client verification (#257)
140
+ const xff = req.headers['x-forwarded-for']
141
+ let isForwardedRemote = false
142
+ if (xff && typeof xff === 'string') {
143
+ const firstIp = xff.split(',')[0].trim()
144
+ if (firstIp && !isLoopbackIp(firstIp)) {
145
+ isForwardedRemote = true
146
+ if (!hasClientCredentials(req)) {
147
+ return 401
148
+ }
149
+ }
150
+ }
151
+
152
+ // 3. Authoritative DSH connection service check (#257)
153
+ const conn = getConnection(ctx) || req?.connectionService || ctx?.connection
154
+ if (conn && typeof conn.requestRejection === 'function') {
155
+ let rejection
156
+ try {
157
+ rejection = conn.requestRejection(req)
158
+ } catch {
159
+ rejection = 403
160
+ }
161
+ if (rejection !== undefined) {
162
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
163
+ return rejection
164
+ }
165
+ return rejection === 401 ? 401 : 403
166
+ }
167
+ return undefined
168
+ }
169
+
170
+ // 4. Network boundary evaluation when connection service is absent
171
+ const rawRemote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''
172
+ const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '')
173
+
174
+ if (cleanRemote && !isLoopbackIp(cleanRemote)) {
175
+ return 401
176
+ }
177
+
178
+ if (isForwardedRemote) {
179
+ return 401
180
+ }
181
+
182
+ const host = String(req.headers.host || '').trim().toLowerCase()
183
+ if (!cleanRemote && host) {
184
+ const hostName = host.split(':')[0]
185
+ if (!isLoopbackIp(hostName)) {
186
+ return 401
187
+ }
188
+ }
189
+
190
+ return undefined
191
+ }
192
+
193
+ export function assertAuthorizedRequest(req, res, ctx = null) {
194
+ const status = getReadRejectionStatus(req, ctx)
195
+ if (status !== undefined) {
196
+ writeJson(res, status, {
197
+ ok: false,
198
+ error: status === 401 ? 'Authentication required' : 'Forbidden',
199
+ })
200
+ return false
201
+ }
202
+ return true
203
+ }
@@ -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/lib/routes.js CHANGED
@@ -5,7 +5,8 @@
5
5
  import path from 'node:path'
6
6
  import os from 'node:os'
7
7
  import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory, isTestEnvironment } from './history.js'
8
- import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath, isLoopbackUrl, assertPathContained } from './file-workspace.js'
8
+ import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath, isLoopbackUrl } from './file-workspace.js'
9
+ import { writeJson, readBody, isAllowedWorkspace, assertAuthorizedRequest } from './access.js'
9
10
  import { isSafeWriteRequest } from './updater.js'
10
11
  import { runMoAPipeline } from './moa-runner.js'
11
12
  import { generateMoABenchmarkReport } from './moa-report.js'
@@ -14,37 +15,6 @@ import { resolvePresetForPrompt } from './moa-router.js'
14
15
  let _modelsCache = { timestamp: 0, models: [] }
15
16
  const MODELS_CACHE_TTL = 60 * 1000
16
17
 
17
- function writeJson(res, status, body) {
18
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
19
- res.end(JSON.stringify(body))
20
- }
21
-
22
- function readBody(req) {
23
- return new Promise((resolve, reject) => {
24
- let acc = ''
25
- req.on('data', (chunk) => {
26
- acc += chunk
27
- if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
28
- })
29
- req.on('end', () => resolve(acc))
30
- req.on('error', reject)
31
- })
32
- }
33
-
34
- function isAllowedWorkspace(targetCwd, allowedRoots) {
35
- if (!targetCwd || typeof targetCwd !== 'string') return false
36
- for (const root of allowedRoots) {
37
- if (!root) continue
38
- try {
39
- assertPathContained(root, targetCwd)
40
- return true
41
- } catch {
42
- // not within root
43
- }
44
- }
45
- return false
46
- }
47
-
48
18
  export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine, workspaceRoot }) {
49
19
 
50
20
  ctx.effect(() => {
@@ -52,6 +22,7 @@ ctx.effect(() => {
52
22
  kind: 'exact',
53
23
  path: '/dsh-moa/status',
54
24
  handler: (req, res) => {
25
+ if (!assertAuthorizedRequest(req, res, ctx)) return
55
26
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
56
27
  try {
57
28
  const cfg = live()
@@ -72,6 +43,7 @@ ctx.effect(() => {
72
43
  kind: 'exact',
73
44
  path: '/dsh-moa/models',
74
45
  handler: async (req, res) => {
46
+ if (!assertAuthorizedRequest(req, res, ctx)) return
75
47
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
76
48
  if (_modelsCache.models.length > 0 && Date.now() - _modelsCache.timestamp < MODELS_CACHE_TTL) {
77
49
  writeJson(res, 200, { ok: true, models: _modelsCache.models, cached: true })
@@ -168,6 +140,7 @@ ctx.effect(() => {
168
140
  kind: 'exact',
169
141
  path: '/dsh-moa/presets',
170
142
  handler: async (req, res) => {
143
+ if (!assertAuthorizedRequest(req, res, ctx)) return
171
144
  const cfg = live()
172
145
  if (req.method === 'GET') {
173
146
  writeJson(res, 200, {
@@ -237,6 +210,7 @@ ctx.effect(() => {
237
210
  kind: 'exact',
238
211
  path: '/dsh-moa/history',
239
212
  handler: async (req, res) => {
213
+ if (!assertAuthorizedRequest(req, res, ctx)) return
240
214
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
241
215
  try {
242
216
  const u = new URL(req.url, 'http://localhost')
@@ -255,6 +229,7 @@ ctx.effect(() => {
255
229
  kind: 'exact',
256
230
  path: '/dsh-moa/leaderboard',
257
231
  handler: async (req, res) => {
232
+ if (!assertAuthorizedRequest(req, res, ctx)) return
258
233
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
259
234
  try {
260
235
  const url = new URL(req.url || '', 'http://127.0.0.1')
@@ -284,6 +259,7 @@ ctx.effect(() => {
284
259
  kind: 'exact',
285
260
  path: '/dsh-moa/promote',
286
261
  handler: async (req, res) => {
262
+ if (!assertAuthorizedRequest(req, res, ctx)) return
287
263
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
288
264
  if (!isSafeWriteRequest(req)) {
289
265
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
@@ -336,6 +312,7 @@ ctx.effect(() => {
336
312
  kind: 'exact',
337
313
  path: '/dsh-moa/diff',
338
314
  handler: async (req, res) => {
315
+ if (!assertAuthorizedRequest(req, res, ctx)) return
339
316
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
340
317
  try {
341
318
  const u = new URL(req.url, 'http://localhost')
@@ -408,6 +385,7 @@ ctx.effect(() => {
408
385
  kind: 'exact',
409
386
  path: '/dsh-moa/history/export',
410
387
  handler: async (req, res) => {
388
+ if (!assertAuthorizedRequest(req, res, ctx)) return
411
389
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
412
390
  try {
413
391
  const url = new URL(req.url || '', 'http://127.0.0.1')
@@ -430,8 +408,9 @@ ctx.effect(() => {
430
408
  ctx.effect(() => {
431
409
  return ctx.webServer.register({
432
410
  kind: 'prefix',
433
- path: '/dsh-moa/runs/',
411
+ path: '/dsh-moa/runs',
434
412
  handler: (req, res) => {
413
+ if (!assertAuthorizedRequest(req, res, ctx)) return
435
414
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
436
415
  const urlPath = req.url.split('?')[0]
437
416
  const parts = urlPath.split('/').filter(Boolean)
@@ -477,6 +456,7 @@ ctx.effect(() => {
477
456
  kind: 'exact',
478
457
  path: '/dsh-moa/route-preset',
479
458
  handler: async (req, res) => {
459
+ if (!assertAuthorizedRequest(req, res, ctx)) return
480
460
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
481
461
  if (!isSafeWriteRequest(req)) {
482
462
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
@@ -517,6 +497,7 @@ ctx.effect(() => {
517
497
  kind: 'exact',
518
498
  path: '/dsh-moa/run',
519
499
  handler: async (req, res) => {
500
+ if (!assertAuthorizedRequest(req, res, ctx)) return
520
501
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
521
502
  if (!isSafeWriteRequest(req)) {
522
503
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.53",
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",