@goodandready/dsh-moa 0.2.52 → 0.2.55

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
@@ -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 (288 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
@@ -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/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
+ }
@@ -403,9 +403,9 @@ export async function cleanMoaWorkspaces(baseDir, options = {}) {
403
403
  if (!baseDir) return
404
404
  const rawRunId = typeof options === 'string' ? options : options?.runId
405
405
  const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
406
- const moaRoot = path.join(baseDir, '.moa')
406
+ const moaRoot = assertPathContained(baseDir, '.moa')
407
407
  if (safeRunId) {
408
- await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(path.join(moaRoot, safeRunId), { recursive: true, force: true }))
408
+ await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(assertPathContained(moaRoot, safeRunId), { recursive: true, force: true }))
409
409
  await bestEffort('clean empty moaRoot', async () => {
410
410
  if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
411
411
  })
package/lib/routes.js CHANGED
@@ -3,8 +3,10 @@
3
3
  */
4
4
 
5
5
  import path from 'node:path'
6
- import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory } from './history.js'
6
+ import os from 'node:os'
7
+ import { getMoaHistory, getMoaLeaderboard, getMoaRunById, exportMoaHistory, isTestEnvironment } from './history.js'
7
8
  import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff, isSafeRelativePath, isLoopbackUrl } from './file-workspace.js'
9
+ import { writeJson, readBody, isAllowedWorkspace, assertAuthorizedRequest } from './access.js'
8
10
  import { isSafeWriteRequest } from './updater.js'
9
11
  import { runMoAPipeline } from './moa-runner.js'
10
12
  import { generateMoABenchmarkReport } from './moa-report.js'
@@ -13,30 +15,14 @@ import { resolvePresetForPrompt } from './moa-router.js'
13
15
  let _modelsCache = { timestamp: 0, models: [] }
14
16
  const MODELS_CACHE_TTL = 60 * 1000
15
17
 
16
- function writeJson(res, status, body) {
17
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
18
- res.end(JSON.stringify(body))
19
- }
20
-
21
- function readBody(req) {
22
- return new Promise((resolve, reject) => {
23
- let acc = ''
24
- req.on('data', (chunk) => {
25
- acc += chunk
26
- if (acc.length > 5 * 1024 * 1024) { req.destroy(); reject(new Error('Payload Too Large')) }
27
- })
28
- req.on('end', () => resolve(acc))
29
- req.on('error', reject)
30
- })
31
- }
32
-
33
- export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine }) {
18
+ export function registerMoaRoutes(ctx, { live, callLlm, liveCanvas, saveConfig, Config, plainConfig, webServerPort, timeMachineEngine, workspaceRoot }) {
34
19
 
35
20
  ctx.effect(() => {
36
21
  return ctx.webServer.register({
37
22
  kind: 'exact',
38
23
  path: '/dsh-moa/status',
39
24
  handler: (req, res) => {
25
+ if (!assertAuthorizedRequest(req, res, ctx)) return
40
26
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
41
27
  try {
42
28
  const cfg = live()
@@ -57,6 +43,7 @@ ctx.effect(() => {
57
43
  kind: 'exact',
58
44
  path: '/dsh-moa/models',
59
45
  handler: async (req, res) => {
46
+ if (!assertAuthorizedRequest(req, res, ctx)) return
60
47
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
61
48
  if (_modelsCache.models.length > 0 && Date.now() - _modelsCache.timestamp < MODELS_CACHE_TTL) {
62
49
  writeJson(res, 200, { ok: true, models: _modelsCache.models, cached: true })
@@ -153,6 +140,7 @@ ctx.effect(() => {
153
140
  kind: 'exact',
154
141
  path: '/dsh-moa/presets',
155
142
  handler: async (req, res) => {
143
+ if (!assertAuthorizedRequest(req, res, ctx)) return
156
144
  const cfg = live()
157
145
  if (req.method === 'GET') {
158
146
  writeJson(res, 200, {
@@ -222,6 +210,7 @@ ctx.effect(() => {
222
210
  kind: 'exact',
223
211
  path: '/dsh-moa/history',
224
212
  handler: async (req, res) => {
213
+ if (!assertAuthorizedRequest(req, res, ctx)) return
225
214
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
226
215
  try {
227
216
  const u = new URL(req.url, 'http://localhost')
@@ -240,6 +229,7 @@ ctx.effect(() => {
240
229
  kind: 'exact',
241
230
  path: '/dsh-moa/leaderboard',
242
231
  handler: async (req, res) => {
232
+ if (!assertAuthorizedRequest(req, res, ctx)) return
243
233
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
244
234
  try {
245
235
  const url = new URL(req.url || '', 'http://127.0.0.1')
@@ -269,6 +259,7 @@ ctx.effect(() => {
269
259
  kind: 'exact',
270
260
  path: '/dsh-moa/promote',
271
261
  handler: async (req, res) => {
262
+ if (!assertAuthorizedRequest(req, res, ctx)) return
272
263
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
273
264
  if (!isSafeWriteRequest(req)) {
274
265
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
@@ -321,6 +312,7 @@ ctx.effect(() => {
321
312
  kind: 'exact',
322
313
  path: '/dsh-moa/diff',
323
314
  handler: async (req, res) => {
315
+ if (!assertAuthorizedRequest(req, res, ctx)) return
324
316
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
325
317
  try {
326
318
  const u = new URL(req.url, 'http://localhost')
@@ -393,6 +385,7 @@ ctx.effect(() => {
393
385
  kind: 'exact',
394
386
  path: '/dsh-moa/history/export',
395
387
  handler: async (req, res) => {
388
+ if (!assertAuthorizedRequest(req, res, ctx)) return
396
389
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
397
390
  try {
398
391
  const url = new URL(req.url || '', 'http://127.0.0.1')
@@ -415,8 +408,9 @@ ctx.effect(() => {
415
408
  ctx.effect(() => {
416
409
  return ctx.webServer.register({
417
410
  kind: 'prefix',
418
- path: '/dsh-moa/runs/',
411
+ path: '/dsh-moa/runs',
419
412
  handler: (req, res) => {
413
+ if (!assertAuthorizedRequest(req, res, ctx)) return
420
414
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
421
415
  const urlPath = req.url.split('?')[0]
422
416
  const parts = urlPath.split('/').filter(Boolean)
@@ -462,6 +456,7 @@ ctx.effect(() => {
462
456
  kind: 'exact',
463
457
  path: '/dsh-moa/route-preset',
464
458
  handler: async (req, res) => {
459
+ if (!assertAuthorizedRequest(req, res, ctx)) return
465
460
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
466
461
  if (!isSafeWriteRequest(req)) {
467
462
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
@@ -502,6 +497,7 @@ ctx.effect(() => {
502
497
  kind: 'exact',
503
498
  path: '/dsh-moa/run',
504
499
  handler: async (req, res) => {
500
+ if (!assertAuthorizedRequest(req, res, ctx)) return
505
501
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST required' })
506
502
  if (!isSafeWriteRequest(req)) {
507
503
  writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
@@ -535,13 +531,28 @@ ctx.effect(() => {
535
531
  const presetName = payload.preset || cfg.default_preset || 'default'
536
532
  const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
537
533
 
534
+ const baseWorkspaceRoot = workspaceRoot || ctx.baseDir || ctx.workspaceDir || process.cwd()
535
+ const allowedRoots = [
536
+ baseWorkspaceRoot,
537
+ process.cwd(),
538
+ cfg.workspace_root,
539
+ ...(Array.isArray(cfg.allowed_workspaces) ? cfg.allowed_workspaces : (cfg.allowed_workspaces ? [cfg.allowed_workspaces] : [])),
540
+ ...(isTestEnvironment() ? [os.tmpdir()] : []),
541
+ ].filter(Boolean)
542
+
543
+ const targetCwd = payload.cwd ? path.resolve(payload.cwd) : baseWorkspaceRoot
544
+ if (payload.cwd && !isAllowedWorkspace(targetCwd, allowedRoots)) {
545
+ writeJson(res, 403, { ok: false, error: 'Forbidden: cwd escapes allowed workspace boundary' })
546
+ return
547
+ }
548
+
538
549
  const result = await runMoAPipeline({
539
550
  userPrompt: prompt, messages: payload.messages || [], preset, callLlm,
540
- cwd: payload.cwd || process.cwd(), prices: cfg.prices || {}, liveCanvas,
551
+ cwd: targetCwd, prices: cfg.prices || {}, liveCanvas,
541
552
  webServerPort: webServerPort || ctx.webServer?.port,
542
553
  timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
543
- timeMachineUrl: payload.timeMachineUrl, force: payload.force,
544
- checkpoint: payload.checkpoint,
554
+ timeMachineUrl: payload.timeMachineUrl, force: false,
555
+ checkpoint: true,
545
556
  runId: payload.runId,
546
557
  })
547
558
 
package/lib/updater.js CHANGED
@@ -44,8 +44,7 @@ export function isTrustedUpdateRequest(request) {
44
44
  /** General safe write check for plugin write endpoints (CSRF / Cross-Site protection, #234) */
45
45
  export function isSafeWriteRequest(request) {
46
46
  const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress;
47
- const isLoop = !remote || isLoopback(remote);
48
- if (!isLoop) return false;
47
+ if (!remote || !isLoopback(remote)) return false;
49
48
 
50
49
  const site = header(request, 'sec-fetch-site');
51
50
  if (site === 'cross-site') return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.52",
3
+ "version": "0.2.55",
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",