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