dsh-standards 1.4.0 → 1.5.0
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/40-checker/scripts/sign-evidence.mjs +169 -0
- package/40-checker/src/cli.mjs +394 -70
- package/40-checker/src/flow-evidence.mjs +21 -0
- package/40-checker/src/remediation.mjs +1 -1
- package/40-checker/src/report.mjs +49 -3
- package/40-checker/src/runtime-child.mjs +68 -0
- package/40-checker/src/runtime.mjs +3 -1
- package/40-checker/src/sandbox-run.mjs +406 -35
- package/40-checker/src/signature.mjs +249 -0
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/spec/schemas/conformance.schema.json +5 -0
package/40-checker/src/cli.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
|
|
@@ -33,7 +34,8 @@ Options:
|
|
|
33
34
|
--format text|json|sarif output format (default: text)
|
|
34
35
|
--scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
|
|
35
36
|
--profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
|
|
36
|
-
--runtime
|
|
37
|
+
--runtime [path|auto] built @deepseek-ai/cordis lib/index.js (or its package dir); bare or
|
|
38
|
+
'auto' resolves from the installed dsh; enables real L1-L3
|
|
37
39
|
--runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
|
|
38
40
|
--cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
|
|
39
41
|
--commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
|
|
@@ -48,7 +50,9 @@ Options:
|
|
|
48
50
|
--treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
|
|
49
51
|
--entry-runtime require|server-entry entry scope: require full G0-G3 (default) or accept server-entry proxy (not bundle)
|
|
50
52
|
--entry-claim runtime|static entry scope: runtime (default, needs DSC-14) or static recognized (no runtime claim)
|
|
51
|
-
--host
|
|
53
|
+
--host [file|auto] app-boot lib/index.js (or its package dir); bare or 'auto'
|
|
54
|
+
resolves from the installed dsh; enables real bundle G0-G3 smoke.
|
|
55
|
+
Nothing executes unless this or --runtime is given.
|
|
52
56
|
--base <file> optional base cordis.patch.yml for bundle smoke
|
|
53
57
|
--web <file> optional web-app cordis.patch.yml for bundle smoke
|
|
54
58
|
--bare <dir> optional bare-module base for bundle smoke (host farm)
|
|
@@ -117,7 +121,14 @@ function parseArgs(argv) {
|
|
|
117
121
|
case '--format': options.format = value(); break
|
|
118
122
|
case '--scope': options.scope = value(); break
|
|
119
123
|
case '--profile': options.profile = value(); break
|
|
120
|
-
case '--runtime':
|
|
124
|
+
case '--runtime': {
|
|
125
|
+
if (inline !== undefined) { options.runtime = inline; break }
|
|
126
|
+
const next = argv[index + 1]
|
|
127
|
+
if (next === undefined || next.startsWith('--')) { options.runtime = 'auto'; break }
|
|
128
|
+
index += 1
|
|
129
|
+
options.runtime = next
|
|
130
|
+
break
|
|
131
|
+
}
|
|
121
132
|
case '--runtime-version': options.runtimeVersion = value(); break
|
|
122
133
|
case '--cordis-version': options.cordisVersion = value(); break
|
|
123
134
|
case '--commit': options.commit = value(); break
|
|
@@ -133,7 +144,14 @@ function parseArgs(argv) {
|
|
|
133
144
|
case '--treat-as-plugin': options.treatAsPlugin = true; break
|
|
134
145
|
case '--entry-runtime': options.entryRuntime = value(); break
|
|
135
146
|
case '--entry-claim': options.entryClaim = value(); break
|
|
136
|
-
case '--host':
|
|
147
|
+
case '--host': {
|
|
148
|
+
if (inline !== undefined) { options.host = inline; break }
|
|
149
|
+
const next = argv[index + 1]
|
|
150
|
+
if (next === undefined || next.startsWith('--')) { options.host = 'auto'; break }
|
|
151
|
+
index += 1
|
|
152
|
+
options.host = next
|
|
153
|
+
break
|
|
154
|
+
}
|
|
137
155
|
case '--base': options.base = value(); break
|
|
138
156
|
case '--web': options.web = value(); break
|
|
139
157
|
case '--bare': options.bare = value(); break
|
|
@@ -168,77 +186,283 @@ function runtimeNotRun(reason) {
|
|
|
168
186
|
}))
|
|
169
187
|
}
|
|
170
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Resolve an installed dsh's own package root. `--runtime auto` / `--host auto`
|
|
191
|
+
* use this so a real run needs no hand-copied path. The launcher may be a shim
|
|
192
|
+
* (`dsh`, `dsh.cmd`, `dsh.ps1`); its directory is the npm global prefix, and
|
|
193
|
+
* the package lives under node_modules/@deepseek-ai/dsh.
|
|
194
|
+
*/
|
|
195
|
+
export function resolveDshInstall({ env = process.env, bin = null } = {}) {
|
|
196
|
+
const candidates = []
|
|
197
|
+
if (typeof env.DSH_INSTALL_DIR === 'string' && env.DSH_INSTALL_DIR.trim() !== '') {
|
|
198
|
+
candidates.push(env.DSH_INSTALL_DIR.trim())
|
|
199
|
+
}
|
|
200
|
+
const launcher = bin || env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
|
|
201
|
+
const launcherPaths = []
|
|
202
|
+
if (path.isAbsolute(launcher) || launcher.includes('/') || launcher.includes('\\')) {
|
|
203
|
+
launcherPaths.push(launcher)
|
|
204
|
+
} else {
|
|
205
|
+
// `which dsh` is not available in every child env; walk PATH directly so
|
|
206
|
+
// `--runtime auto` also works when dsh is only reachable as a command name.
|
|
207
|
+
const separator = process.platform === 'win32' ? ';' : ':'
|
|
208
|
+
for (const dir of String(env.PATH ?? '').split(separator)) {
|
|
209
|
+
if (!dir) continue
|
|
210
|
+
for (const name of process.platform === 'win32' ? [`${launcher}.cmd`, `${launcher}.ps1`, launcher] : [launcher]) {
|
|
211
|
+
const candidate = path.join(dir, name)
|
|
212
|
+
try { if (fs.existsSync(candidate)) launcherPaths.push(candidate) } catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const launcherPath of launcherPaths) {
|
|
217
|
+
const absolute = (() => { try { return fs.realpathSync(launcherPath) } catch { return path.resolve(launcherPath) } })()
|
|
218
|
+
candidates.push(path.join(path.dirname(absolute), 'node_modules', '@deepseek-ai', 'dsh'))
|
|
219
|
+
}
|
|
220
|
+
for (const prefix of [env.APPDATA && path.join(env.APPDATA, 'npm'), env.npm_config_prefix, '/usr/local', '/usr']) {
|
|
221
|
+
if (!prefix) continue
|
|
222
|
+
candidates.push(path.join(prefix, 'node_modules', '@deepseek-ai', 'dsh'))
|
|
223
|
+
}
|
|
224
|
+
for (const candidate of candidates) {
|
|
225
|
+
try {
|
|
226
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate
|
|
227
|
+
} catch { /* unreadable candidate */ }
|
|
228
|
+
}
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The file a `--runtime`/`--host` value points at. Directories are expanded to
|
|
234
|
+
* their package entry, and `auto` is resolved from the installed dsh. A value
|
|
235
|
+
* that stays missing is returned as-is on purpose: the run then fails with a
|
|
236
|
+
* recorded reason instead of pretending the check passed.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveRuntimeTarget(value, kind = 'cordis', { env = process.env } = {}) {
|
|
239
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
240
|
+
const raw = value.trim()
|
|
241
|
+
if (raw === 'auto' || raw === 'host') {
|
|
242
|
+
const install = resolveDshInstall({ env })
|
|
243
|
+
if (!install) return null
|
|
244
|
+
const name = kind === 'host' ? 'dsh-app-boot' : 'cordis'
|
|
245
|
+
const candidates = [
|
|
246
|
+
path.join(install, 'node_modules', '@deepseek-ai', name, 'lib', 'index.js'),
|
|
247
|
+
// npm may hoist the package next to @deepseek-ai/dsh instead of nesting it.
|
|
248
|
+
path.join(install, '..', '..', '@deepseek-ai', name, 'lib', 'index.js'),
|
|
249
|
+
]
|
|
250
|
+
for (const candidate of candidates) {
|
|
251
|
+
try { if (fs.existsSync(candidate)) return path.resolve(candidate) } catch { /* keep looking */ }
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
const absolute = path.resolve(raw)
|
|
256
|
+
try {
|
|
257
|
+
if (fs.statSync(absolute).isDirectory()) return path.join(absolute, 'lib', 'index.js')
|
|
258
|
+
} catch { /* not a directory; treat as a file */ }
|
|
259
|
+
return absolute
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A disposable HOME/DSH_HOME/TMPDIR for one explicit runtime run. */
|
|
263
|
+
export function createRuntimeIsolation(base = os.tmpdir()) {
|
|
264
|
+
const root = fs.mkdtempSync(path.join(base, 'dsh-checker-runtime-'))
|
|
265
|
+
const home = path.join(root, 'home')
|
|
266
|
+
const tmp = path.join(root, 'tmp')
|
|
267
|
+
fs.mkdirSync(home, { recursive: true })
|
|
268
|
+
fs.mkdirSync(tmp, { recursive: true })
|
|
269
|
+
let cleaned = false
|
|
270
|
+
return {
|
|
271
|
+
root,
|
|
272
|
+
home,
|
|
273
|
+
tmp,
|
|
274
|
+
cleanup() {
|
|
275
|
+
if (cleaned) return
|
|
276
|
+
cleaned = true
|
|
277
|
+
try { fs.rmSync(root, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The only environment a child that may import plugin code receives. */
|
|
283
|
+
export function isolationEnv(isolation) {
|
|
284
|
+
if (!isolation) return {}
|
|
285
|
+
return {
|
|
286
|
+
HOME: isolation.home,
|
|
287
|
+
USERPROFILE: isolation.home,
|
|
288
|
+
DSH_HOME: isolation.home,
|
|
289
|
+
TMPDIR: isolation.tmp,
|
|
290
|
+
TEMP: isolation.tmp,
|
|
291
|
+
TMP: isolation.tmp,
|
|
292
|
+
XDG_CONFIG_HOME: path.join(isolation.home, '.config'),
|
|
293
|
+
XDG_DATA_HOME: path.join(isolation.home, '.local', 'share'),
|
|
294
|
+
XDG_CACHE_HOME: path.join(isolation.home, '.cache'),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const RUNTIME_MARKER = '__DSH_RUNTIME_CHECKS__'
|
|
299
|
+
|
|
300
|
+
function parseRuntimeOutput(text) {
|
|
301
|
+
const index = text.lastIndexOf(RUNTIME_MARKER)
|
|
302
|
+
if (index >= 0) {
|
|
303
|
+
const line = text.slice(index + RUNTIME_MARKER.length).split('\n', 1)[0]
|
|
304
|
+
try { return JSON.parse(line) } catch { /* fall through to the raw form */ }
|
|
305
|
+
}
|
|
306
|
+
const raw = text.trim()
|
|
307
|
+
if (raw.startsWith('{')) {
|
|
308
|
+
try { return JSON.parse(raw) } catch { /* not complete yet */ }
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
171
313
|
/**
|
|
172
314
|
* L1-L3 run in a child process so that a plugin cannot block the event loop,
|
|
173
|
-
* read the checker's environment, or kill the process. The child
|
|
174
|
-
*
|
|
315
|
+
* read the checker's environment, or kill the process. The child writes one
|
|
316
|
+
* marker line as soon as the checks finish; the parent resolves on that line
|
|
317
|
+
* and terminates the child, so a plugin that leaves timers behind cannot turn
|
|
318
|
+
* a completed run into "killed after timeout". A kill before the marker is
|
|
319
|
+
* reported as not-run, never as a pass.
|
|
175
320
|
*/
|
|
176
321
|
function isolatedRuntimeChecks(input) {
|
|
177
322
|
return new Promise((resolve) => {
|
|
178
|
-
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
|
|
323
|
+
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'runtime-child.mjs')
|
|
324
|
+
const startedAt = Date.now()
|
|
179
325
|
// The child imports the inspected package: never inherit the caller's env.
|
|
180
|
-
const child = spawn(process.execPath, [script], {
|
|
326
|
+
const child = spawn(process.execPath, [script], {
|
|
327
|
+
// fd 3 is the result pipe: plugin stdout/stderr cannot forge the payload.
|
|
328
|
+
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
|
|
329
|
+
env: sanitizedEnv(isolationEnv(input.isolation)),
|
|
330
|
+
})
|
|
181
331
|
let out = ''
|
|
182
332
|
let err = ''
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
333
|
+
let result = ''
|
|
334
|
+
const RESULT_LIMIT = 4 * 1024 * 1024
|
|
335
|
+
let settled = false
|
|
336
|
+
let timer = null
|
|
337
|
+
const finish = (payload, failure = null) => {
|
|
338
|
+
if (settled) return
|
|
339
|
+
settled = true
|
|
340
|
+
if (timer) clearTimeout(timer)
|
|
341
|
+
try { child.kill('SIGKILL') } catch { /* already gone */ }
|
|
342
|
+
const wallMs = Date.now() - startedAt
|
|
343
|
+
if (!payload) {
|
|
344
|
+
resolve({ checks: runtimeNotRun(failure), durationMs: wallMs, error: failure })
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
const error = payload.error ? String(payload.error).slice(0, 500) : null
|
|
348
|
+
const checks = Array.isArray(payload.checks) && payload.checks.length > 0 ? payload.checks : runtimeNotRun(error || 'L1 child produced no checks')
|
|
349
|
+
resolve({ checks, durationMs: Number.isFinite(payload.durationMs) ? payload.durationMs : wallMs, error })
|
|
350
|
+
}
|
|
351
|
+
timer = setTimeout(() => {
|
|
352
|
+
finish(null, `L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`)
|
|
353
|
+
}, input.timeoutMs + 3000)
|
|
354
|
+
child.stdout.on('data', (chunk) => {
|
|
355
|
+
if (out.length < RESULT_LIMIT) out += chunk
|
|
189
356
|
})
|
|
357
|
+
child.stdio[3]?.on('data', (chunk) => {
|
|
358
|
+
if (result.length >= RESULT_LIMIT) return
|
|
359
|
+
result += chunk
|
|
360
|
+
const payload = parseRuntimeOutput(result)
|
|
361
|
+
if (payload) finish(payload)
|
|
362
|
+
})
|
|
363
|
+
child.stderr.on('data', (chunk) => {
|
|
364
|
+
if (err.length < RESULT_LIMIT) err += chunk
|
|
365
|
+
})
|
|
366
|
+
child.on('error', (error) => finish(null, `L1 child failed to start: ${error.message}`))
|
|
190
367
|
child.on('exit', (code, signal) => {
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
const parsed = JSON.parse(out)
|
|
197
|
-
resolve(parsed.checks)
|
|
198
|
-
} catch {
|
|
199
|
-
resolve(runtimeNotRun(`L1 child produced no JSON (exit ${code}): ${String(err).slice(0, 200)}`))
|
|
200
|
-
}
|
|
368
|
+
const payload = parseRuntimeOutput(result) ?? parseRuntimeOutput(out)
|
|
369
|
+
if (payload) return finish(payload)
|
|
370
|
+
if (signal === 'SIGKILL' || signal === 'SIGTERM') return finish(null, `L1 killed after ${input.timeoutMs}ms; plugin blocked the event loop`)
|
|
371
|
+
finish(null, `L1 child produced no JSON (exit ${code}): ${String(err).slice(0, 200)}`)
|
|
201
372
|
})
|
|
202
373
|
child.stdin.end(JSON.stringify(input))
|
|
203
374
|
})
|
|
204
375
|
}
|
|
205
376
|
|
|
206
|
-
async function entryRunCheck(kind, runtimeChecks, options, dir) {
|
|
377
|
+
async function entryRunCheck(kind, runtimeChecks, options, dir, context = {}) {
|
|
207
378
|
const byId = new Map(runtimeChecks.map(item => [item.id, item]))
|
|
208
379
|
const c4 = byId.get('DSC-04')
|
|
380
|
+
const runtimeKind = context.runtimeKind ?? kind
|
|
381
|
+
const runtimeUsable = context.runtimeUsable === true
|
|
382
|
+
const noRuntime = { executed: false, modes: [], durationMs: null, failure: null }
|
|
209
383
|
const base = {
|
|
210
384
|
id: 'DSC-14', clause: 'C-14', layer: 'L1', method: 'M', advisory: false,
|
|
211
385
|
details: [], evidence: c4?.evidence ?? [],
|
|
212
386
|
}
|
|
213
387
|
if (options.entryClaim === 'static') {
|
|
214
|
-
return { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }
|
|
388
|
+
return { check: { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }, runtime: noRuntime }
|
|
215
389
|
}
|
|
216
390
|
if (['client', 'preset', 'skill', 'theme', 'mcp-config', 'unknown'].includes(kind)) {
|
|
217
|
-
return { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }
|
|
391
|
+
return { check: { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }, runtime: noRuntime }
|
|
218
392
|
}
|
|
219
393
|
if (kind === 'bundle' && options.entryRuntime !== 'server-entry') {
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
394
|
+
if (options.host) {
|
|
395
|
+
const startedAt = Date.now()
|
|
396
|
+
const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'bundle-smoke.mjs')
|
|
397
|
+
const smokeTimeout = Math.max(options.timeoutMs, 5000)
|
|
398
|
+
const args = [script, '--package', dir, '--host', options.host, '--json', '--timeout', String(smokeTimeout)]
|
|
399
|
+
if (options.base) args.push('--base', options.base)
|
|
400
|
+
if (options.web) args.push('--web', options.web)
|
|
401
|
+
if (options.bare) args.push('--bare', options.bare)
|
|
402
|
+
const run = spawnSync(process.execPath, args, {
|
|
403
|
+
encoding: 'utf8',
|
|
404
|
+
timeout: smokeTimeout + 5000,
|
|
405
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
406
|
+
env: sanitizedEnv(isolationEnv(context.isolation)),
|
|
407
|
+
})
|
|
408
|
+
const durationMs = Date.now() - startedAt
|
|
409
|
+
const meta = mode => ({ executed: true, modes: ['bundle-host'], durationMs, failure: null, ...mode })
|
|
410
|
+
if (run.error) {
|
|
411
|
+
const reason = `bundle smoke failed to start: ${run.error.message}`
|
|
412
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: meta({ failure: reason }) }
|
|
413
|
+
}
|
|
414
|
+
let smoke
|
|
415
|
+
try { smoke = JSON.parse(run.stdout) } catch {
|
|
416
|
+
const reason = `bundle smoke produced no JSON: ${(run.stderr || '').slice(0, 300)}`
|
|
417
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: meta({ failure: reason }) }
|
|
418
|
+
}
|
|
419
|
+
const state = smoke.g3?.state
|
|
420
|
+
if (smoke.g2?.compatibility === 'reject') {
|
|
421
|
+
const reason = 'G2 gate rejected: host would skip this bundle'
|
|
422
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason, details: [{ item: 'G2', result: 'FAIL', reason: JSON.stringify(smoke.g2) }] }, runtime: meta({ failure: reason }) }
|
|
423
|
+
}
|
|
424
|
+
if (state === 'verified' && smoke.g4?.ok === false) {
|
|
425
|
+
const reason = 'G4 failed: dsh.client has no package-root row or missing client export'
|
|
426
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason, details: [{ item: 'G4', result: 'FAIL', reason: JSON.stringify(smoke.g4) }] }, runtime: meta({}) }
|
|
427
|
+
}
|
|
428
|
+
if (state === 'verified') {
|
|
429
|
+
const reason = `G0-G3 verified; rows=${smoke.g3.active}/${smoke.g3.rows.length}${smoke.g4?.hasClient ? '; G4 static ok' : ''}`
|
|
430
|
+
return {
|
|
431
|
+
check: { ...base, result: 'PASS', coverage: 'full', reason, details: [{ item: 'G0', result: 'PASS', reason: 'patch parsed' }, { item: 'G1', result: 'PASS', reason: `rows ${smoke.g1.targetRows}` }, { item: 'G3', result: 'PASS', reason: `rows ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE` }] },
|
|
432
|
+
runtime: meta({}),
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (state === 'partial') {
|
|
436
|
+
const reason = `G3 partial: ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE; target profile does not provide all services`
|
|
437
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason, details: [{ item: 'G3', result: 'WARN', reason: JSON.stringify(smoke.g3.rows) }] }, runtime: meta({ failure: reason }) }
|
|
438
|
+
}
|
|
439
|
+
const reason = `bundle smoke failed: ${JSON.stringify(smoke.g3?.rows ?? [])}`
|
|
440
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason }, runtime: meta({ failure: reason }) }
|
|
441
|
+
}
|
|
442
|
+
// A bundle with a real server entry can still be exercised by L1-L3 with
|
|
443
|
+
// --treat-as-plugin. That is runtime evidence for the entry, but it is not
|
|
444
|
+
// a full G0-G3 boot; the reason says so instead of requiring --host forever.
|
|
445
|
+
if (runtimeKind === 'plugin' && runtimeUsable) {
|
|
446
|
+
return {
|
|
447
|
+
check: { ...base, result: 'PASS', coverage: 'full', reason: 'server-entry L1-L3 PASS; bundle G0-G3 not run (pass --host <app-boot/lib/index.js> for the full boot)' },
|
|
448
|
+
runtime: { executed: true, modes: [], durationMs: 0, failure: null },
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const reason = 'bundle runtime requires --host <app-boot/lib/index.js> or --treat-as-plugin --runtime <path>'
|
|
452
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: { ...noRuntime, failure: reason } }
|
|
236
453
|
}
|
|
237
454
|
if (c4?.result === 'PASS') {
|
|
238
|
-
return {
|
|
455
|
+
return {
|
|
456
|
+
check: { ...base, result: 'PASS', coverage: 'full', reason: kind === 'bundle' ? 'server-entry ACTIVE only; bundle G0-G4 not run' : '' },
|
|
457
|
+
runtime: { executed: true, modes: [], durationMs: 0, failure: null },
|
|
458
|
+
}
|
|
239
459
|
}
|
|
240
|
-
if (c4?.result === 'FAIL')
|
|
241
|
-
|
|
460
|
+
if (c4?.result === 'FAIL') {
|
|
461
|
+
const reason = c4.reason || 'runtime failed'
|
|
462
|
+
return { check: { ...base, result: 'FAIL', coverage: 'full', reason }, runtime: { executed: true, modes: [], durationMs: 0, failure: reason } }
|
|
463
|
+
}
|
|
464
|
+
const reason = c4?.reason || 'no runtime evidence'
|
|
465
|
+
return { check: { ...base, result: 'ERROR', coverage: 'not-run', reason }, runtime: { ...noRuntime, failure: reason } }
|
|
242
466
|
}
|
|
243
467
|
|
|
244
468
|
/**
|
|
@@ -334,6 +558,25 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
334
558
|
const host = detectHostRuntime()
|
|
335
559
|
const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
|
|
336
560
|
|
|
561
|
+
// `--runtime auto` / `--host auto` resolve from the installed dsh. A value
|
|
562
|
+
// that still cannot be resolved is a usage error: silently skipping the run
|
|
563
|
+
// would turn "asked for runtime" into a static report.
|
|
564
|
+
try {
|
|
565
|
+
if (options.runtime) {
|
|
566
|
+
const resolved = resolveRuntimeTarget(options.runtime, 'cordis')
|
|
567
|
+
if (!resolved) throw new Error("--runtime auto could not locate the installed dsh's cordis lib/index.js; pass an explicit path")
|
|
568
|
+
options.runtime = resolved
|
|
569
|
+
}
|
|
570
|
+
if (options.host) {
|
|
571
|
+
const resolved = resolveRuntimeTarget(options.host, 'host')
|
|
572
|
+
if (!resolved) throw new Error("--host auto could not locate the installed dsh's dsh-app-boot/lib/index.js; pass an explicit path")
|
|
573
|
+
options.host = resolved
|
|
574
|
+
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
process.stderr.write(`dsh-plugin-verify: ${error.message}\n`)
|
|
577
|
+
return 3
|
|
578
|
+
}
|
|
579
|
+
|
|
337
580
|
const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
|
|
338
581
|
runtimeVersion: runtimeInfo.dsh,
|
|
339
582
|
installSpec: options.installSpec,
|
|
@@ -363,7 +606,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
363
606
|
if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
|
|
364
607
|
let runtimeKind = kind
|
|
365
608
|
let runtimeEntry = entry
|
|
366
|
-
|
|
609
|
+
// An explicit --runtime is already an opt-in to execute code. A bundle that
|
|
610
|
+
// carries a real server entry can therefore be exercised without repeating
|
|
611
|
+
// --treat-as-plugin; a patch-only bundle still has no entry and needs --host.
|
|
612
|
+
const runServerEntry = options.treatAsPlugin || (Boolean(options.runtime) && kind === 'bundle' && entry?.exists === true)
|
|
613
|
+
if (runServerEntry && manifest) {
|
|
367
614
|
runtimeKind = 'plugin'
|
|
368
615
|
if (!runtimeEntry?.exists) {
|
|
369
616
|
const candidates = ['lib/index.js', 'index.js', 'lib/public-entry.js', 'src/index.ts', 'src/index.js', 'src/index.mjs']
|
|
@@ -374,32 +621,100 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
374
621
|
}
|
|
375
622
|
}
|
|
376
623
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
624
|
+
const runtimeWillExecute = !options.noExec && Boolean(manifest) && Boolean(options.runtime) && runtimeKind === 'plugin' && runtimeEntry?.exists === true
|
|
625
|
+
const hostWillExecute = !options.noExec && Boolean(manifest) && Boolean(options.host) && kind === 'bundle'
|
|
626
|
+
const isolation = runtimeWillExecute || hostWillExecute ? createRuntimeIsolation() : null
|
|
627
|
+
|
|
628
|
+
let runtimeChecks = null
|
|
629
|
+
let runCheck = null
|
|
630
|
+
let runtimeRun = { checks: [], durationMs: null, error: null }
|
|
631
|
+
let entryRun = { executed: false, modes: [], durationMs: null, failure: null }
|
|
632
|
+
let runtimeSummary = null
|
|
633
|
+
try {
|
|
634
|
+
if (options.noExec || !manifest) {
|
|
635
|
+
const reason = options.noExec ? '--no-exec' : 'manifest unavailable'
|
|
636
|
+
runtimeChecks = ['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].map(id => ({
|
|
637
|
+
id, clause: { 'DSC-04': 'C-4', 'DSC-05': 'C-5', 'DSC-06': 'C-6', 'DSC-07': 'C-7', 'DSC-08': 'C-8', 'DSC-09': 'C-9' }[id],
|
|
638
|
+
layer: { 'DSC-04': 'L1', 'DSC-05': 'L1', 'DSC-06': 'L1+L2', 'DSC-07': 'L2', 'DSC-08': 'L3', 'DSC-09': 'L1' }[id],
|
|
639
|
+
result: 'ERROR', coverage: 'not-run', reason, details: [], evidence: [], method: 'M', advisory: false,
|
|
640
|
+
}))
|
|
641
|
+
} else if (!options.runtime) {
|
|
642
|
+
runtimeChecks = runtimeNotRun('no --runtime supplied; L1-L3 not run')
|
|
643
|
+
} else if (runtimeKind !== 'plugin') {
|
|
644
|
+
const reason = kind === 'bundle' && !entry?.exists
|
|
645
|
+
? 'kind=bundle has no server entry; L1-L3 not run (use --host <app-boot/lib/index.js> for G0-G3)'
|
|
646
|
+
: `kind=${runtimeKind}; L1-L3 apply to plugin entries only (pass --treat-as-plugin for a bundle's server entry)`
|
|
647
|
+
runtimeChecks = runtimeNotRun(reason)
|
|
648
|
+
} else if (!runtimeEntry?.exists) {
|
|
649
|
+
runtimeChecks = runtimeNotRun('no resolvable runtime entry; L1-L3 not run')
|
|
650
|
+
} else {
|
|
651
|
+
runtimeRun = await isolatedRuntimeChecks({
|
|
652
|
+
dir, manifest, kind: runtimeKind, entry: runtimeEntry,
|
|
653
|
+
runtimePath: options.runtime, timeoutMs: options.timeoutMs,
|
|
654
|
+
home: isolation?.home, tmp: isolation?.tmp, isolation,
|
|
655
|
+
})
|
|
656
|
+
runtimeChecks = runtimeRun.checks
|
|
657
|
+
}
|
|
658
|
+
const rawRuntimeChecks = runtimeChecks
|
|
659
|
+
const rawRuntimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
660
|
+
if (runtimeWillExecute && !rawRuntimeUsable && !runtimeRun.error) {
|
|
661
|
+
// The child may exit cleanly while every check is not-run (bad
|
|
662
|
+
// --runtime path, missing entry, blocked import). That is a failed run,
|
|
663
|
+
// not a quiet static result: surface its reason in runtimeChecks.
|
|
664
|
+
const reason = runtimeChecks.find(item => String(item.reason ?? '') !== '')?.reason || 'runtime produced no usable result'
|
|
665
|
+
runtimeRun = { ...runtimeRun, error: reason }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// A bundle/client has no plugin entry for L1-L3. Instead of leaving all six
|
|
669
|
+
// runtime checks NA, cover their statically decidable part explicitly and
|
|
670
|
+
// mark the coverage partial (the report says what still needs --host/--runtime).
|
|
671
|
+
const runtimeUsable = runtimeChecks.some(item => ['PASS', 'WARN', 'FAIL'].includes(item.result))
|
|
672
|
+
if (manifest && ['bundle', 'client'].includes(kind) && !runtimeUsable) {
|
|
673
|
+
const staticChecks = runStaticBundleChecks({ manifest, kind, entry, patch, sources, g4, scan, dir })
|
|
674
|
+
const staticById = new Map(staticChecks.map(item => [item.id, item]))
|
|
675
|
+
runtimeChecks = runtimeChecks.map(item => staticById.get(item.id) ?? item)
|
|
676
|
+
}
|
|
391
677
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
678
|
+
const run = await entryRunCheck(kind, runtimeChecks, options, dir, { runtimeKind, runtimeUsable, isolation })
|
|
679
|
+
runCheck = run.check
|
|
680
|
+
entryRun = run.runtime
|
|
681
|
+
|
|
682
|
+
// Runtime provenance is additive: what actually ran, for how long, under
|
|
683
|
+
// which isolation, and why it failed. Static rows stay exactly as they are.
|
|
684
|
+
const modes = []
|
|
685
|
+
if (runtimeWillExecute) modes.push('cordis')
|
|
686
|
+
if (entryRun.modes.includes('bundle-host')) modes.push('bundle-host')
|
|
687
|
+
const failure = [runtimeRun.error, entryRun.executed ? entryRun.failure : null].filter(Boolean).join('; ') || null
|
|
688
|
+
const durationMs = (Number.isFinite(runtimeRun.durationMs) ? runtimeRun.durationMs : 0) + (Number.isFinite(entryRun.durationMs) ? entryRun.durationMs : 0)
|
|
689
|
+
let notRunReason = null
|
|
690
|
+
if (!runtimeWillExecute && !hostWillExecute) {
|
|
691
|
+
if (options.noExec) notRunReason = '--no-exec: nothing executed'
|
|
692
|
+
else if (options.runtime) notRunReason = (rawRuntimeChecks ?? []).find(item => String(item.reason ?? '') !== '')?.reason ?? '--runtime supplied but it was not executed'
|
|
693
|
+
else if (options.host) notRunReason = `--host supplied but kind=${kind} is not a bundle; G0-G3 not run`
|
|
694
|
+
else notRunReason = 'no --runtime/--host supplied; nothing executed'
|
|
695
|
+
}
|
|
696
|
+
const summary = {
|
|
697
|
+
schema: 'dsh-runtime-checks/1',
|
|
698
|
+
executed: runtimeWillExecute || entryRun.executed === true,
|
|
699
|
+
modes,
|
|
700
|
+
durationMs,
|
|
701
|
+
isolation: {
|
|
702
|
+
isolatedHome: Boolean(isolation),
|
|
703
|
+
env: 'allowlist',
|
|
704
|
+
timeoutMs: options.timeoutMs,
|
|
705
|
+
},
|
|
706
|
+
reason: notRunReason,
|
|
707
|
+
failure,
|
|
708
|
+
checks: (rawRuntimeChecks ?? []).map(item => ({
|
|
709
|
+
id: item.id, result: item.result, coverage: item.coverage, reason: item.reason ?? '',
|
|
710
|
+
})),
|
|
711
|
+
entry: runCheck ? { id: 'DSC-14', result: runCheck.result, coverage: runCheck.coverage, reason: runCheck.reason ?? '', durationMs: entryRun.durationMs ?? null } : null,
|
|
712
|
+
}
|
|
713
|
+
runtimeSummary = summary
|
|
714
|
+
} finally {
|
|
715
|
+
isolation?.cleanup()
|
|
400
716
|
}
|
|
401
717
|
|
|
402
|
-
const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
|
|
403
718
|
const checks = [
|
|
404
719
|
manifestChecks[0], manifestChecks[1], manifestChecks[2],
|
|
405
720
|
...runtimeChecks,
|
|
@@ -440,7 +755,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
440
755
|
entryClaim: options.entryClaim,
|
|
441
756
|
advice: options.advice,
|
|
442
757
|
declarations: declarationsOf(manifest),
|
|
758
|
+
runtimeChecks: runtimeSummary,
|
|
443
759
|
})
|
|
760
|
+
// An explicitly requested runtime/host run that could not produce a result
|
|
761
|
+
// is not a clean static pass: the caller asked for execution, so say the run
|
|
762
|
+
// is incomplete. A static report (no flag) is unaffected.
|
|
763
|
+
if ((runtimeWillExecute || hostWillExecute) && runtimeSummary?.failure && report.conclusion.status !== 'fail') {
|
|
764
|
+
report.conclusion.status = 'incomplete'
|
|
765
|
+
report.conclusion.exitCode = 2
|
|
766
|
+
report.conclusion.reason = [report.conclusion.reason, `runtime/host did not complete: ${runtimeSummary.failure}`].filter(Boolean).join('; ')
|
|
767
|
+
}
|
|
444
768
|
const output = writeReport(report, options.format)
|
|
445
769
|
if (options.out) {
|
|
446
770
|
const outPath = path.resolve(options.out)
|
|
@@ -28,6 +28,15 @@ import fs from 'node:fs'
|
|
|
28
28
|
import os from 'node:os'
|
|
29
29
|
import path from 'node:path'
|
|
30
30
|
import { spawnSync } from 'node:child_process'
|
|
31
|
+
import { signEvidence } from './signature.mjs'
|
|
32
|
+
|
|
33
|
+
// Signing helpers live in signature.mjs; re-exported here so callers that
|
|
34
|
+
// already import the evidence model do not need a second module path.
|
|
35
|
+
export {
|
|
36
|
+
SIGNATURE_ALG, SIGNATURE_SCHEMA, canonicalJson as signatureCanonicalJson,
|
|
37
|
+
evidenceSigningPayload, generateEd25519KeyPair, keyIdFromPublicKey,
|
|
38
|
+
signEvidence, verifyEvidenceSignature,
|
|
39
|
+
} from './signature.mjs'
|
|
31
40
|
|
|
32
41
|
export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
|
|
33
42
|
export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
|
|
@@ -107,6 +116,18 @@ export function evidenceHashMatches(document) {
|
|
|
107
116
|
return declared === evidenceHash(document)
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Attach an Ed25519 signature to a flow-evidence document. The signature
|
|
121
|
+
* covers canonical JSON minus `signature` (the body plus `chain`), so it binds
|
|
122
|
+
* the declared chain hash while never changing `chain.evidenceHash`; it never
|
|
123
|
+
* upgrades a claim level. Verification is `verifyEvidenceSignature()`
|
|
124
|
+
* (re-exported from signature.mjs); structural acceptance remains
|
|
125
|
+
* `validateFlowEvidence()`.
|
|
126
|
+
*/
|
|
127
|
+
export function attachEvidenceSignature(document, privateKey, options = {}) {
|
|
128
|
+
return signEvidence(document, privateKey, options)
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
const DEFAULT_TREE_EXCLUDE = ['.git', 'node_modules']
|
|
111
132
|
const TREE_EXCLUDE_ALWAYS = new Set(['.git', 'node_modules'])
|
|
112
133
|
|
|
@@ -13,7 +13,7 @@ import path from 'node:path'
|
|
|
13
13
|
import packageJson from '../../package.json' with { type: 'json' }
|
|
14
14
|
|
|
15
15
|
/** Release-pinned guide base. `docs` in the rule table is a relative path. */
|
|
16
|
-
export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.
|
|
16
|
+
export const DOCS_BASE = 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.6/'
|
|
17
17
|
|
|
18
18
|
const LIVE = new Set(['FAIL', 'WARN'])
|
|
19
19
|
const SEVERITY_RANK = { FAIL: 2, WARN: 1 }
|