@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 +5 -2
- package/README.ru.md +4 -1
- package/README.zh.md +3 -0
- package/lib/access.js +203 -0
- package/lib/moa-sandbox.js +287 -0
- package/lib/moa-test-gate.js +46 -155
- package/lib/routes.js +14 -33
- 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.
|
|
@@ -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 (
|
|
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`) каждый кандидат запускается в изолированном оверлейном окружении
|
|
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
|
+
}
|
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,
|
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
|
|
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' })
|