dsh-standards 1.2.1 → 1.3.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 (36) hide show
  1. package/40-checker/README.md +25 -3
  2. package/40-checker/bin/dsh-levels.mjs +7 -1
  3. package/40-checker/scripts/build-offline-kit.mjs +284 -0
  4. package/40-checker/scripts/build-sidecar.mjs +484 -0
  5. package/40-checker/scripts/flow-collect.mjs +147 -22
  6. package/40-checker/scripts/level-audit.mjs +57 -9
  7. package/40-checker/scripts/run-tests.mjs +17 -0
  8. package/40-checker/src/author-cli.mjs +139 -0
  9. package/40-checker/src/author-report.mjs +260 -0
  10. package/40-checker/src/cli.mjs +73 -8
  11. package/40-checker/src/flow-evidence.mjs +189 -3
  12. package/40-checker/src/index.mjs +3 -3
  13. package/40-checker/src/report.mjs +125 -24
  14. package/40-checker/src/sandbox-run.mjs +403 -0
  15. package/CHANGELOG.md +26 -0
  16. package/README.en.md +352 -0
  17. package/README.es.md +352 -0
  18. package/README.hi.md +352 -0
  19. package/README.md +300 -82
  20. package/README.pt.md +352 -0
  21. package/bin/dsh-standards.mjs +13 -2
  22. package/docs/assets/dsh-standards-flow-dark.svg +38 -0
  23. package/docs/assets/dsh-standards-flow-light.svg +38 -0
  24. package/docs/assets/dsh-standards-flow-v3-dark.svg +87 -0
  25. package/docs/assets/dsh-standards-flow-v3-light.svg +87 -0
  26. package/docs/assets/dsh-standards-flow-v4-dark.svg +79 -0
  27. package/docs/assets/dsh-standards-flow-v4-light.svg +79 -0
  28. package/docs/assets/dsh-standards-hero-en-dark.webp +0 -0
  29. package/docs/assets/dsh-standards-hero-en-light.webp +0 -0
  30. package/docs/assets/dsh-standards-hero-zh-dark.webp +0 -0
  31. package/docs/assets/dsh-standards-hero-zh-light.webp +0 -0
  32. package/package.json +2 -2
  33. package/spec/README.md +2 -0
  34. package/spec/flow/README.md +19 -6
  35. package/spec/flow-evidence.schema.json +76 -6
  36. package/spec/schemas/advice.schema.json +92 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Author-facing renderers for a `dsh-conformance/1` report.
3
+ *
4
+ * Markdown is the default `dsh-standards check` output; HTML is a single
5
+ * self-contained file with inline CSS, no scripts, no external assets, and
6
+ * every dynamic value escaped. Both read the same report object the checker
7
+ * already produced, so the verdict and exit code never change here.
8
+ */
9
+
10
+ const DISCLAIMER =
11
+ 'Non-official report. This is not a certification, security audit, endorsement, or ranking. '
12
+ + 'Hygiene warnings are not a clean pass; items under "Not checked" were not inspected.'
13
+
14
+ function text(value) {
15
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
16
+ }
17
+
18
+ function mdCell(value) {
19
+ return text(value).replace(/\|/g, '\\|')
20
+ }
21
+
22
+ export function escapeHtml(value) {
23
+ return String(value ?? '').replace(/[&<>"']/g, character => ({
24
+ '&': '&amp;',
25
+ '<': '&lt;',
26
+ '>': '&gt;',
27
+ '"': '&quot;',
28
+ "'": '&#39;',
29
+ }[character]))
30
+ }
31
+
32
+ function headlineOf(report) {
33
+ return text(report?.conclusion?.headline) || text(report?.conclusion?.reason) || 'no conclusion'
34
+ }
35
+
36
+ export function priorityFixes(report, limit = 3) {
37
+ const advice = Array.isArray(report?.advice) ? report.advice : []
38
+ const seen = new Set()
39
+ const fixes = []
40
+ for (const item of advice) {
41
+ const key = item?.key ?? item?.title
42
+ if (key === undefined || seen.has(key)) continue
43
+ seen.add(key)
44
+ fixes.push(item)
45
+ if (fixes.length >= limit) break
46
+ }
47
+ if (fixes.length > 0) return fixes
48
+ // Advice may be disabled; fall back to the FAIL/WARN rows themselves.
49
+ const rows = [...(report?.buckets?.blocking ?? []), ...(report?.buckets?.hygiene ?? [])]
50
+ for (const row of rows) {
51
+ if (fixes.length >= limit) break
52
+ fixes.push({ key: row.id, gate: row.result, severity: row.result, source: row.id, fix: row.reason, why: null, docsUrl: null })
53
+ }
54
+ return fixes
55
+ }
56
+
57
+ function passedIds(report) {
58
+ return (report?.checks ?? []).filter(item => item.result === 'PASS').map(item => item.id)
59
+ }
60
+
61
+ function adviceRows(report) {
62
+ return Array.isArray(report?.advice) ? report.advice : []
63
+ }
64
+
65
+ const BUCKETS = [
66
+ { key: 'blocking', title: 'Blocking (FAIL)' },
67
+ { key: 'hygiene', title: 'Hygiene (WARN)' },
68
+ { key: 'notChecked', title: 'Not checked (NA / not-run / ERROR)' },
69
+ ]
70
+
71
+ function bucketRows(report, key) {
72
+ return Array.isArray(report?.buckets?.[key]) ? report.buckets[key] : []
73
+ }
74
+
75
+ export function renderMarkdown(report) {
76
+ const subject = report?.subject ?? {}
77
+ const conclusion = report?.conclusion ?? {}
78
+ const coverage = report?.coverage ?? {}
79
+ const metrics = report?.metrics ?? {}
80
+ const anchor = report?.anchor ?? {}
81
+ const lines = []
82
+ lines.push(`# DSH standards check — ${text(subject.name) || '<unknown>'}@${text(subject.version) || '?'}`)
83
+ lines.push('')
84
+ lines.push(`**Conclusion:** ${headlineOf(report)}`)
85
+ lines.push('')
86
+ lines.push(`- Status: **${text(conclusion.status) || 'unknown'}** (exit ${conclusion.exitCode ?? '?'})`)
87
+ if (conclusion.level) lines.push(`- Level: ${text(conclusion.level)}`)
88
+ lines.push(`- Anchor: dsh=${text(anchor.dsh) || '?'}${anchor.runtimeSource ? ` (source=${anchor.runtimeSource}${anchor.suspect ? ', suspect=true' : ''})` : ''}`)
89
+ lines.push(`- Coverage: ${metrics.checked ?? coverage.checked ?? 0}/${metrics.total ?? coverage.total ?? (report?.checks ?? []).length} checks evaluated · declared fields ${metrics.declaredFields ?? 0}/5`)
90
+ lines.push('')
91
+
92
+ lines.push('## Priority fixes (up to 3)')
93
+ lines.push('')
94
+ const fixes = priorityFixes(report)
95
+ if (fixes.length === 0) {
96
+ lines.push('_None: no FAIL or WARN finding produced advice._')
97
+ } else {
98
+ fixes.forEach((item, index) => {
99
+ lines.push(`${index + 1}. **${text(item.severity ?? item.gate ?? 'WARN')} ${text(item.key) || text(item.title)}** (${text(item.source)}) — ${text(item.fix ?? item.title ?? item.reason)}`)
100
+ if (item.why) lines.push(` - Why: ${text(item.why)}`)
101
+ if (item.docsUrl) lines.push(` - Docs: ${text(item.docsUrl)}`)
102
+ })
103
+ }
104
+ lines.push('')
105
+
106
+ for (const bucket of BUCKETS) {
107
+ const rows = bucketRows(report, bucket.key)
108
+ lines.push(`## ${bucket.title} — ${rows.length}`)
109
+ lines.push('')
110
+ if (rows.length === 0) {
111
+ lines.push('_None._')
112
+ } else {
113
+ lines.push('| id | clause | result | coverage | reason |')
114
+ lines.push('|---|---|---|---|---|')
115
+ for (const row of rows) {
116
+ const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
117
+ const reason = tags.length > 0 ? `${text(row.reason)} [${tags.join(',')}]` : text(row.reason)
118
+ lines.push(`| ${mdCell(row.id)} | ${mdCell(row.clause ?? '-')} | ${mdCell(row.result)} | ${mdCell(row.coverage ?? '-')} | ${mdCell(reason)} |`)
119
+ }
120
+ }
121
+ lines.push('')
122
+ }
123
+
124
+ const passed = passedIds(report)
125
+ lines.push(`## Passed — ${passed.length}`)
126
+ lines.push('')
127
+ lines.push(passed.length > 0 ? passed.join(', ') : '_None._')
128
+ lines.push('')
129
+
130
+ const advice = adviceRows(report)
131
+ lines.push(`## Advice (non-blocking) — ${advice.length}`)
132
+ lines.push('')
133
+ if (advice.length === 0) {
134
+ lines.push('_None._')
135
+ } else {
136
+ for (const item of advice) {
137
+ const missing = item.ruleMissing ? ' [ruleMissing]' : ''
138
+ lines.push(`- ${text(item.severity)} ${text(item.key)} (${text(item.source)}${missing}): ${text(item.fix ?? item.title ?? '')}`)
139
+ if (item.docsUrl) lines.push(` ${text(item.docsUrl)}`)
140
+ }
141
+ }
142
+ lines.push('')
143
+
144
+ const notChecked = bucketRows(report, 'notChecked')
145
+ lines.push('## What this report does not check')
146
+ lines.push('')
147
+ if (notChecked.length === 0) {
148
+ lines.push('- No check was skipped in this run.')
149
+ } else {
150
+ for (const row of notChecked) {
151
+ lines.push(`- ${text(row.id)}${row.clause ? ` (${text(row.clause)})` : ''}: ${text(row.reason) || 'not checked'}`)
152
+ }
153
+ }
154
+ lines.push(`- Runtime execution beyond the checks listed above is not observed by this report${anchor.suspect ? '; the anchor differs from the host and is flagged suspect' : ''}.`)
155
+ lines.push('- It does not certify, rank, audit security, or endorse the package.')
156
+ lines.push('')
157
+ lines.push('---')
158
+ lines.push('')
159
+ lines.push(DISCLAIMER)
160
+ lines.push('')
161
+ return `${lines.join('\n')}\n`
162
+ }
163
+
164
+ export function renderHtml(report) {
165
+ const subject = report?.subject ?? {}
166
+ const conclusion = report?.conclusion ?? {}
167
+ const metrics = report?.metrics ?? {}
168
+ const anchor = report?.anchor ?? {}
169
+ const title = `DSH standards check — ${text(subject.name) || '<unknown>'}@${text(subject.version) || '?'}`
170
+ const fixes = priorityFixes(report)
171
+ const advice = adviceRows(report)
172
+ const passed = passedIds(report)
173
+ const notChecked = bucketRows(report, 'notChecked')
174
+
175
+ const table = (key) => {
176
+ const rows = bucketRows(report, key)
177
+ if (rows.length === 0) return '<p class="none">None.</p>'
178
+ const body = rows.map(row => {
179
+ const tags = [row.declaration ? 'declaration' : null, row.advisory ? 'advisory' : null].filter(Boolean)
180
+ const reason = tags.length > 0 ? `${text(row.reason)} [${tags.join(',')}]` : text(row.reason)
181
+ return `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.clause ?? '-')}</td><td class="result ${escapeHtml(String(row.result).toLowerCase())}">${escapeHtml(row.result)}</td><td>${escapeHtml(row.coverage ?? '-')}</td><td>${escapeHtml(reason)}</td></tr>`
182
+ }).join('\n')
183
+ return `<table><thead><tr><th>id</th><th>clause</th><th>result</th><th>coverage</th><th>reason</th></tr></thead><tbody>\n${body}\n</tbody></table>`
184
+ }
185
+
186
+ const fixList = fixes.length === 0
187
+ ? '<p class="none">None: no FAIL or WARN finding produced advice.</p>'
188
+ : `<ol>${fixes.map(item => {
189
+ const why = item.why ? `<br><span class="muted">Why: ${escapeHtml(text(item.why))}</span>` : ''
190
+ const docs = item.docsUrl ? `<br><span class="muted">Docs: ${link(item.docsUrl)}</span>` : ''
191
+ return `<li><strong>${escapeHtml(text(item.severity ?? item.gate ?? 'WARN'))} ${escapeHtml(text(item.key) || text(item.title))}</strong> (${escapeHtml(text(item.source))}) — ${escapeHtml(text(item.fix ?? item.title ?? item.reason))}${why}${docs}</li>`
192
+ }).join('\n')}</ol>`
193
+
194
+ const adviceList = advice.length === 0
195
+ ? '<p class="none">None.</p>'
196
+ : `<ul>${advice.map(item => `<li><strong>${escapeHtml(text(item.severity))} ${escapeHtml(text(item.key))}</strong> (${escapeHtml(text(item.source))}${item.ruleMissing ? ' [ruleMissing]' : ''}): ${escapeHtml(text(item.fix ?? item.title ?? ''))}${item.docsUrl ? ` ${link(item.docsUrl)}` : ''}</li>`).join('\n')}</ul>`
197
+
198
+ const notCheckedList = notChecked.length === 0
199
+ ? '<li>No check was skipped in this run.</li>'
200
+ : notChecked.map(row => `<li><strong>${escapeHtml(text(row.id))}</strong>${row.clause ? ` (${escapeHtml(text(row.clause))})` : ''}: ${escapeHtml(text(row.reason) || 'not checked')}</li>`).join('\n')
201
+
202
+ return `<!doctype html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="utf-8">
206
+ <meta name="viewport" content="width=device-width, initial-scale=1">
207
+ <title>${escapeHtml(title)}</title>
208
+ <style>
209
+ :root{color-scheme:light dark}
210
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif;max-width:980px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#1f2328;background:#fff}
211
+ h1{font-size:1.45rem;margin:0 0 .35rem}
212
+ h2{font-size:1.1rem;margin:1.8rem 0 .5rem;border-bottom:1px solid #d0d7de;padding-bottom:.25rem}
213
+ .headline{font-size:1.05rem;font-weight:600;background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:.65rem .8rem}
214
+ .meta{margin:.6rem 0 0;padding-left:1.1rem;color:#57606a}
215
+ .muted{color:#57606a;font-size:.9rem}
216
+ table{border-collapse:collapse;width:100%;margin:.4rem 0 1rem;font-size:.9rem}
217
+ th,td{border:1px solid #d0d7de;padding:.3rem .5rem;text-align:left;vertical-align:top}
218
+ th{background:#f6f8fa}
219
+ td.result{font-weight:600;white-space:nowrap}
220
+ td.result.fail{color:#b42318}
221
+ td.result.warn{color:#9a6700}
222
+ td.result.na,td.result.error,td.result.not-run{color:#57606a}
223
+ .none{color:#57606a}
224
+ footer{margin-top:2rem;padding-top:.8rem;border-top:1px solid #d0d7de;color:#57606a;font-size:.9rem}
225
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
226
+ @media (prefers-color-scheme:dark){body{background:#0d1117;color:#e6edf3}h2,.headline,th,td,footer{border-color:#30363d}.headline,th{background:#161b22}.meta,.muted,.none,footer{color:#8b949e}td.result.fail{color:#ff7b72}td.result.warn{color:#d29922}}
227
+ </style>
228
+ </head>
229
+ <body>
230
+ <h1>${escapeHtml(title)}</h1>
231
+ <p class="headline">${escapeHtml(headlineOf(report))}</p>
232
+ <ul class="meta">
233
+ <li>Status: <strong>${escapeHtml(text(conclusion.status) || 'unknown')}</strong> (exit ${escapeHtml(conclusion.exitCode ?? '?')})${conclusion.level ? ` · Level: ${escapeHtml(text(conclusion.level))}` : ''}</li>
234
+ <li>Anchor: dsh=${escapeHtml(text(anchor.dsh) || '?')}${anchor.runtimeSource ? ` (source=${escapeHtml(anchor.runtimeSource)}${anchor.suspect ? ', suspect=true' : ''})` : ''}</li>
235
+ <li>Coverage: ${escapeHtml(metrics.checked ?? 0)}/${escapeHtml(metrics.total ?? (report?.checks ?? []).length)} checks evaluated · declared fields ${escapeHtml(metrics.declaredFields ?? 0)}/5</li>
236
+ </ul>
237
+ <h2>Priority fixes (up to 3)</h2>
238
+ ${fixList}
239
+ ${BUCKETS.map(bucket => `<h2>${escapeHtml(bucket.title)} — ${bucketRows(report, bucket.key).length}</h2>\n${table(bucket.key)}`).join('\n')}
240
+ <h2>Passed — ${passed.length}</h2>
241
+ <p>${passed.length > 0 ? passed.map(escapeHtml).join(', ') : '<span class="none">None.</span>'}</p>
242
+ <h2>Advice (non-blocking) — ${advice.length}</h2>
243
+ ${adviceList}
244
+ <h2>What this report does not check</h2>
245
+ <ul>
246
+ ${notCheckedList}
247
+ <li>Runtime execution beyond the checks listed above is not observed by this report${anchor.suspect ? '; the anchor differs from the host and is flagged suspect' : ''}.</li>
248
+ <li>It does not certify, rank, audit security, or endorse the package.</li>
249
+ </ul>
250
+ <footer>${escapeHtml(DISCLAIMER)}</footer>
251
+ </body>
252
+ </html>
253
+ `
254
+ }
255
+
256
+ function link(url) {
257
+ const value = text(url)
258
+ if (!/^https:\/\//i.test(value)) return escapeHtml(value)
259
+ return `<a href="${escapeHtml(value)}" rel="noreferrer noopener">${escapeHtml(value)}</a>`
260
+ }
@@ -12,19 +12,28 @@ import { buildReport, writeReport } from './report.mjs'
12
12
  const VERSION = '0.1.0'
13
13
  const DEFAULT_COMMIT = 'ce7073d51254c26cd63b22a60835b8f948deb447'
14
14
  const DEFAULT_CORDIS = '4.0.6-alpha.1'
15
+ const HOST_RUNTIME_TIMEOUT_MS = 5000
16
+
17
+ function binName() {
18
+ const brand = process.env.DSH_CHECKER_BRAND
19
+ if (typeof brand === 'string' && brand.trim() !== '') return brand.trim().split(/\s+/)[0]
20
+ return 'dsh-plugin-verify'
21
+ }
15
22
 
16
23
  function usage() {
24
+ const bin = binName()
17
25
  return `${process.env.DSH_CHECKER_BRAND || 'dsh-plugin-verify ' + VERSION} — DS-C conformance checker (MVP)
18
26
 
19
27
  Usage:
20
- dsh-plugin-verify <package-dir> [options]
28
+ ${bin} check [dir] [--json] [--html <file>] author-facing report (Markdown by default)
29
+ ${bin} <package-dir> [options] raw conformance report
21
30
 
22
31
  Options:
23
32
  --format text|json|sarif output format (default: text)
24
33
  --scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
25
34
  --profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
26
35
  --runtime <path> path to a built @deepseek-ai/cordis lib/index.js; enables L1-L3
27
- --runtime-version <v> dsh runtime version used for C-2 (default: ${DEFAULT_RUNTIME_VERSION})
36
+ --runtime-version <v> dsh runtime version used for C-2 (default: host via \`dsh --version\`, else ${DEFAULT_RUNTIME_VERSION})
28
37
  --cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
29
38
  --commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
30
39
  --install-spec <spec> recommended install spec for C-3 (listing/install check)
@@ -61,7 +70,7 @@ function parseArgs(argv) {
61
70
  scope: 'ds-c',
62
71
  profile: null,
63
72
  runtime: null,
64
- runtimeVersion: DEFAULT_RUNTIME_VERSION,
73
+ runtimeVersion: null,
65
74
  cordisVersion: DEFAULT_CORDIS,
66
75
  commit: DEFAULT_COMMIT,
67
76
  installSpec: null,
@@ -223,14 +232,66 @@ async function entryRunCheck(kind, runtimeChecks, options, dir) {
223
232
  return { ...base, result: 'ERROR', coverage: 'not-run', reason: c4?.reason || 'no runtime evidence' }
224
233
  }
225
234
 
226
- function anchorOf(options, runtime) {
235
+ /**
236
+ * Read the runtime version the host actually runs. The anchor used to be a
237
+ * hard-coded `0.2.2-alpha.2`, which manufactured dozens of C-2.2 failures on a
238
+ * host that had already moved on. Detection is best-effort: when `dsh` is not
239
+ * usable the anchor falls back to the checked-in constant and says so.
240
+ */
241
+ export function detectHostRuntime({ bin = process.env.DSH_BIN || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh'), timeoutMs = HOST_RUNTIME_TIMEOUT_MS } = {}) {
242
+ const run = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: timeoutMs })
243
+ if (run.error || run.status !== 0) return { version: null, bin, error: run.error?.message ?? `exit ${run.status}` }
244
+ const text = `${run.stdout ?? ''}\n${run.stderr ?? ''}`
245
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(text)
246
+ if (!match) return { version: null, bin, error: 'no semver in `dsh --version` output' }
247
+ return { version: match[1], bin, error: null }
248
+ }
249
+
250
+ /**
251
+ * Resolve what `anchor.dsh` means:
252
+ * cli the caller passed --runtime-version; suspect when it differs from the host
253
+ * host no flag: the host version is the anchor, so it cannot be suspect
254
+ * fallback dsh unavailable: keep the constant, flagged as not host-derived
255
+ */
256
+ export function resolveRuntimeAnchor(requestedVersion = null, hostVersion = null) {
257
+ if (typeof requestedVersion === 'string' && requestedVersion.trim() !== '') {
258
+ const requested = requestedVersion.trim()
259
+ return {
260
+ dsh: requested,
261
+ runtimeSource: 'cli',
262
+ hostVersion: hostVersion ?? null,
263
+ suspect: typeof hostVersion === 'string' && hostVersion !== '' && hostVersion !== requested,
264
+ }
265
+ }
266
+ if (typeof hostVersion === 'string' && hostVersion !== '') {
267
+ return { dsh: hostVersion, runtimeSource: 'host', hostVersion, suspect: false }
268
+ }
269
+ return { dsh: DEFAULT_RUNTIME_VERSION, runtimeSource: 'fallback', hostVersion: null, suspect: false }
270
+ }
271
+
272
+ /** The five optional declaration surfaces, counted but never scored. */
273
+ export function declarationsOf(manifest) {
274
+ const dsh = manifest?.dsh ?? {}
275
+ const present = []
276
+ if (Array.isArray(dsh.capabilities) && dsh.capabilities.length > 0) present.push('capabilities')
277
+ if (Array.isArray(dsh.labels) && dsh.labels.length > 0) present.push('labels')
278
+ if (dsh.commitments && typeof dsh.commitments === 'object' && !Array.isArray(dsh.commitments)) present.push('commitments')
279
+ if (dsh.disclosure && typeof dsh.disclosure === 'object' && !Array.isArray(dsh.disclosure)) present.push('disclosure')
280
+ if (typeof manifest?.engines?.dsh === 'string' && manifest.engines.dsh.trim() !== '') present.push('engines.dsh')
281
+ return { total: 5, present }
282
+ }
283
+
284
+ function anchorOf(options, runtimeInfo, runtimePath) {
227
285
  return {
228
286
  repo: 'deepseek-ai/deepseek-harness',
229
287
  commit: options.commit,
230
288
  scanner: `dsh-plugin-verify@${VERSION}`,
231
- dsh: options.runtimeVersion,
289
+ dsh: runtimeInfo.dsh,
232
290
  cordis: options.cordisVersion,
233
- runtime: runtime ? path.resolve(runtime) : null,
291
+ runtime: runtimePath ? path.resolve(runtimePath) : null,
292
+ runtimeSource: runtimeInfo.runtimeSource,
293
+ hostVersion: runtimeInfo.hostVersion,
294
+ suspect: runtimeInfo.suspect,
234
295
  }
235
296
  }
236
297
 
@@ -261,8 +322,11 @@ export async function main(argv = process.argv.slice(2)) {
261
322
  return 3
262
323
  }
263
324
 
325
+ const host = detectHostRuntime()
326
+ const runtimeInfo = resolveRuntimeAnchor(options.runtimeVersion, host.version)
327
+
264
328
  const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
265
- runtimeVersion: options.runtimeVersion,
329
+ runtimeVersion: runtimeInfo.dsh,
266
330
  installSpec: options.installSpec,
267
331
  marketIndex: options.marketIndex,
268
332
  pluginName: options.pluginName,
@@ -341,7 +405,7 @@ export async function main(argv = process.argv.slice(2)) {
341
405
  }
342
406
  const report = buildReport({
343
407
  subject,
344
- anchor: anchorOf(options, options.runtime),
408
+ anchor: anchorOf(options, runtimeInfo, options.runtime),
345
409
  checks,
346
410
  g4,
347
411
  scope: options.scope,
@@ -351,6 +415,7 @@ export async function main(argv = process.argv.slice(2)) {
351
415
  entryRuntime: options.entryRuntime,
352
416
  entryClaim: options.entryClaim,
353
417
  advice: options.advice,
418
+ declarations: declarationsOf(manifest),
354
419
  })
355
420
  const output = writeReport(report, options.format)
356
421
  if (options.out) {
@@ -19,10 +19,15 @@
19
19
  * `producer.kind === 'sandbox'`) may be reported as `flow.state = 'runtime'`.
20
20
  * `self-attested`/`ci-attested` documents keep their `passed` count but stay
21
21
  * `not-run`, because structural validation cannot prove where commands ran.
22
+ * A `sandbox-degraded` producer (any isolation item missing) is never runtime,
23
+ * and a document whose `subject.treeStable` is false is structurally invalid:
24
+ * a collector that changed the inspected tree has no trustworthy evidence.
22
25
  */
23
26
  import crypto from 'node:crypto'
24
27
  import fs from 'node:fs'
28
+ import os from 'node:os'
25
29
  import path from 'node:path'
30
+ import { spawnSync } from 'node:child_process'
26
31
 
27
32
  export const FLOW_IDS = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
28
33
  export const FLOW_EVIDENCE_SCHEMA = 'dsh-flow-evidence/2'
@@ -141,6 +146,103 @@ export function treeSha256(dir, { exclude = [] } = {}) {
141
146
  return { hash: sha256Value(rows.join('\n')), files: rows.length, bytes }
142
147
  }
143
148
 
149
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
150
+
151
+ function subjectFields(subject) {
152
+ if (!isPlainObject(subject)) return null
153
+ return {
154
+ name: subject.name ?? null,
155
+ version: subject.version ?? null,
156
+ integrity: subject.integrity ?? null,
157
+ tarballSha256: subject.tarballSha256 ?? null,
158
+ treeSha256: subject.treeSha256 ?? null,
159
+ commit: subject.commit ?? null,
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Recompute the subject binding from a package directory: package.json
165
+ * name/version, the deterministic tree hash, and the npm-pack tarball hash +
166
+ * integrity. npm packs the same tree byte-identically, so this is the same
167
+ * binding `dsh-flow-collect` recorded.
168
+ */
169
+ export function recomputeSubject(dir, { exclude = [], timeoutMs = 120_000 } = {}) {
170
+ const root = path.resolve(dir)
171
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
172
+ const tree = treeSha256(root, { exclude })
173
+ const destination = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-subject-'))
174
+ try {
175
+ const run = spawnSync(NPM, ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], {
176
+ cwd: root, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024,
177
+ })
178
+ if (run.error) throw new Error(`npm pack failed to start: ${run.error.message}`)
179
+ if (run.status !== 0) throw new Error(`npm pack failed (exit ${run.status}): ${String(run.stderr ?? '').replace(/\s+/g, ' ').slice(0, 300)}`)
180
+ let parsed
181
+ try { parsed = JSON.parse(run.stdout) } catch { throw new Error('npm pack did not print JSON') }
182
+ const info = Array.isArray(parsed) ? parsed[0] : parsed
183
+ if (!info?.filename) throw new Error('npm pack did not report a tarball filename')
184
+ return {
185
+ name: typeof manifest.name === 'string' ? manifest.name : null,
186
+ version: typeof manifest.version === 'string' ? manifest.version : null,
187
+ treeSha256: tree.hash,
188
+ tarballSha256: sha256File(path.join(destination, info.filename)),
189
+ integrity: typeof info.integrity === 'string' ? info.integrity : null,
190
+ }
191
+ } finally {
192
+ fs.rmSync(destination, { recursive: true, force: true })
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Bind a flow-evidence document to a package directory. Any mismatch in
198
+ * name/version/treeSha256/tarballSha256 (or a declared integrity) is a hard
199
+ * failure: the audit must not accept evidence for a different subject.
200
+ */
201
+ export function bindEvidenceSubject(document, dir, options = {}) {
202
+ const declared = isPlainObject(document?.subject) ? document.subject : null
203
+ const base = { dir: path.resolve(dir), declared: subjectFields(declared), recomputed: null, mismatches: [], errors: [] }
204
+ if (!declared) return { ...base, ok: false, errors: ['subject is missing'] }
205
+ let recomputed
206
+ try {
207
+ recomputed = recomputeSubject(dir, {
208
+ exclude: Array.isArray(declared.treeExclude) ? declared.treeExclude : [],
209
+ timeoutMs: options.timeoutMs,
210
+ })
211
+ } catch (error) {
212
+ return { ...base, ok: false, errors: [error.message] }
213
+ }
214
+ const mismatches = []
215
+ for (const key of ['name', 'version', 'treeSha256', 'tarballSha256']) {
216
+ if (declared[key] !== recomputed[key]) {
217
+ mismatches.push(`${key}: declared ${declared[key] ?? 'null'} != recomputed ${recomputed[key] ?? 'null'}`)
218
+ }
219
+ }
220
+ if (typeof declared.integrity === 'string' && recomputed.integrity !== null && declared.integrity !== recomputed.integrity) {
221
+ mismatches.push('integrity: declared value != recomputed value')
222
+ }
223
+ return { ...base, ok: mismatches.length === 0, recomputed, mismatches }
224
+ }
225
+
226
+ /**
227
+ * Plain CI attribution for display. These fields are read from the runner
228
+ * environment and are self-declared: CI attribution is not proof.
229
+ */
230
+ export function ciAttribution(env = process.env) {
231
+ const read = key => (typeof env?.[key] === 'string' ? env[key].trim() : '')
232
+ const runId = read('GITHUB_RUN_ID')
233
+ const repository = read('GITHUB_REPOSITORY')
234
+ const commit = read('GITHUB_SHA')
235
+ if (!runId && !repository && !commit) return null
236
+ const server = (read('GITHUB_SERVER_URL') || 'https://github.com').replace(/\/+$/, '')
237
+ return {
238
+ provider: 'github-actions',
239
+ runId: runId || null,
240
+ repository: repository || null,
241
+ commit: commit || null,
242
+ runUrl: runId && repository ? `${server}/${repository}/actions/runs/${runId}` : null,
243
+ }
244
+ }
245
+
144
246
  export function isIsoTimestamp(value) {
145
247
  return typeof value === 'string' && ISO_RE.test(value) && Number.isFinite(Date.parse(value))
146
248
  }
@@ -178,6 +280,11 @@ export function journeyCountsAsPass(journey) {
178
280
  */
179
281
  export function isSandboxAttested(document) {
180
282
  if (!isPlainObject(document)) return false
283
+ // Degradation is disqualifying even if a claim was left at sandbox-attested:
284
+ // the producer admits an isolation item was missing.
285
+ if (document.producer?.kind === 'sandbox-degraded') return false
286
+ if (document.sandbox?.mode === 'sandbox-degraded') return false
287
+ if (Array.isArray(document.sandbox?.degraded) && document.sandbox.degraded.length > 0) return false
181
288
  return document.claims?.claimLevel === 'sandbox-attested' || document.producer?.kind === 'sandbox'
182
289
  }
183
290
 
@@ -268,12 +375,18 @@ function validateV2(document) {
268
375
  if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
269
376
  errors.push('producer.command must be a non-empty array of strings')
270
377
  }
271
- if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox'].includes(document.producer.kind)) {
272
- errors.push('producer.kind must be local, ci, or sandbox')
378
+ if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox', 'sandbox-degraded'].includes(document.producer.kind)) {
379
+ errors.push('producer.kind must be local, ci, sandbox, or sandbox-degraded')
273
380
  }
274
- for (const key of ['runner', 'actor', 'runUrl']) {
381
+ for (const key of ['runner', 'actor', 'runUrl', 'provider', 'runId', 'repository', 'commit']) {
275
382
  if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
276
383
  }
384
+ if (typeof document.producer.commit === 'string' && !COMMIT_RE.test(document.producer.commit)) {
385
+ errors.push('producer.commit must be a 40-hex commit')
386
+ }
387
+ if (typeof document.producer.runUrl === 'string' && !/^https:\/\//.test(document.producer.runUrl)) {
388
+ errors.push('producer.runUrl must be an https URL')
389
+ }
277
390
  }
278
391
 
279
392
  if (requireObject(document.subject, 'subject')) {
@@ -292,6 +405,12 @@ function validateV2(document) {
292
405
  }
293
406
  }
294
407
  if ('source' in subject) requireString(subject.source, 'subject.source')
408
+ if (subject.treeStable === false) {
409
+ errors.push('subject.treeStable is false: the package tree changed during collection')
410
+ }
411
+ if ('treeSha256After' in subject && subject.treeSha256After && subject.treeSha256After !== subject.treeSha256 && subject.treeStable !== false) {
412
+ errors.push('subject.treeSha256After differs from subject.treeSha256: the package tree changed during collection')
413
+ }
295
414
  }
296
415
 
297
416
  if (requireObject(document.host, 'host')) {
@@ -344,6 +463,8 @@ function validateV2(document) {
344
463
  if ('method' in claims) requireString(claims.method, 'claims.method')
345
464
  }
346
465
 
466
+ validateSandbox(document, errors)
467
+
347
468
  if (requireObject(document.chain, 'chain')) {
348
469
  requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
349
470
  for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
@@ -389,9 +510,74 @@ function validateV2(document) {
389
510
  conclusion: document.conclusion ?? null,
390
511
  claimLevel: isPlainObject(document.claims) ? document.claims.claimLevel ?? null : null,
391
512
  producerKind: isPlainObject(document.producer) ? document.producer.kind ?? null : null,
513
+ sandbox: isPlainObject(document.sandbox)
514
+ ? {
515
+ mode: document.sandbox.mode ?? null,
516
+ degraded: Array.isArray(document.sandbox.degraded) ? [...document.sandbox.degraded] : [],
517
+ }
518
+ : null,
392
519
  sandboxAttested: isSandboxAttested(document),
393
520
  }
394
521
 
522
+ function validateSandbox(doc, errors) {
523
+ const kind = isPlainObject(doc.producer) ? doc.producer.kind ?? null : null
524
+ const declaredSandbox = 'sandbox' in doc
525
+ if (!declaredSandbox && kind !== 'sandbox' && kind !== 'sandbox-degraded') return
526
+ if (!declaredSandbox) {
527
+ errors.push('sandbox is required when producer.kind is sandbox or sandbox-degraded')
528
+ return
529
+ }
530
+ const sandbox = doc.sandbox
531
+ if (!isPlainObject(sandbox)) {
532
+ errors.push('sandbox must be an object')
533
+ return
534
+ }
535
+ if (!['sandbox', 'sandbox-degraded'].includes(sandbox.mode)) {
536
+ errors.push('sandbox.mode must be sandbox or sandbox-degraded')
537
+ }
538
+ const degraded = Array.isArray(sandbox.degraded) ? sandbox.degraded : null
539
+ if (!degraded || degraded.some(item => typeof item !== 'string' || item.length === 0)) {
540
+ errors.push('sandbox.degraded must be an array of non-empty strings')
541
+ }
542
+ const list = degraded ?? []
543
+ if (sandbox.mode === 'sandbox' && list.length > 0) errors.push('sandbox.mode is sandbox but sandbox.degraded is not empty')
544
+ if (sandbox.mode === 'sandbox-degraded' && list.length === 0) errors.push('sandbox.mode is sandbox-degraded but sandbox.degraded is empty')
545
+ if ('env' in sandbox) {
546
+ if (!isPlainObject(sandbox.env)) errors.push('sandbox.env must be an object')
547
+ else if (typeof sandbox.env.absent !== 'boolean') errors.push('sandbox.env.absent must be a boolean')
548
+ }
549
+ if ('network' in sandbox && (!isPlainObject(sandbox.network) || typeof sandbox.network.isolated !== 'boolean')) {
550
+ errors.push('sandbox.network.isolated must be a boolean')
551
+ }
552
+ if ('limits' in sandbox) {
553
+ if (!isPlainObject(sandbox.limits)) errors.push('sandbox.limits must be an object')
554
+ else {
555
+ if ('available' in sandbox.limits && typeof sandbox.limits.available !== 'boolean') errors.push('sandbox.limits.available must be a boolean')
556
+ if ('applied' in sandbox.limits && (!Array.isArray(sandbox.limits.applied) || sandbox.limits.applied.some(item => typeof item !== 'string'))) {
557
+ errors.push('sandbox.limits.applied must be an array of strings')
558
+ }
559
+ }
560
+ }
561
+ if ('home' in sandbox && (!isPlainObject(sandbox.home) || typeof sandbox.home.isolated !== 'boolean')) {
562
+ errors.push('sandbox.home.isolated must be a boolean')
563
+ }
564
+ if ('executed' in sandbox && typeof sandbox.executed !== 'boolean') errors.push('sandbox.executed must be a boolean')
565
+ if (kind === 'sandbox') {
566
+ if (sandbox.mode !== 'sandbox') errors.push('producer.kind=sandbox requires sandbox.mode=sandbox')
567
+ if (list.length > 0) errors.push('producer.kind=sandbox requires an empty sandbox.degraded list')
568
+ if (sandbox.executed !== true) errors.push('producer.kind=sandbox requires sandbox.executed=true')
569
+ if (sandbox.env?.absent !== true) errors.push('producer.kind=sandbox requires sandbox.env.absent=true')
570
+ if (sandbox.network?.isolated !== true) errors.push('producer.kind=sandbox requires sandbox.network.isolated=true')
571
+ if (sandbox.limits?.available !== true) errors.push('producer.kind=sandbox requires sandbox.limits.available=true')
572
+ if (sandbox.home?.isolated !== true) errors.push('producer.kind=sandbox requires sandbox.home.isolated=true')
573
+ }
574
+ if (kind === 'sandbox-degraded') {
575
+ if (sandbox.mode !== 'sandbox-degraded') errors.push('producer.kind=sandbox-degraded requires sandbox.mode=sandbox-degraded')
576
+ if (list.length === 0) errors.push('producer.kind=sandbox-degraded requires at least one sandbox.degraded reason')
577
+ if (doc.claims?.claimLevel === 'sandbox-attested') errors.push('producer.kind=sandbox-degraded cannot claim sandbox-attested')
578
+ }
579
+ }
580
+
395
581
  function validateCheck(check, label) {
396
582
  if (!isPlainObject(check)) {
397
583
  errors.push(`${label} must be an object`)
@@ -1,11 +1,11 @@
1
1
  /** Programmatic API for dsh-plugin-verify. */
2
- export { main, parseArgs, usage, VERSION } from './cli.mjs'
2
+ export { main, parseArgs, usage, VERSION, detectHostRuntime, resolveRuntimeAnchor, declarationsOf } from './cli.mjs'
3
3
  export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind, resolveEntry } from './manifest.mjs'
4
4
  export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
5
5
  export { runRuntimeChecks } from './runtime.mjs'
6
- export { buildReport, formatText, formatSarif, writeReport } from './report.mjs'
6
+ export { buildReport, formatText, formatSarif, writeReport, summarizeBlocking, DECLARATION_CHECKS } from './report.mjs'
7
7
  export { joinAdvice, DOCS_BASE } from './remediation.mjs'
8
8
  export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
9
9
  export { parseInstallSpec } from './install-spec.mjs'
10
- export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
10
+ export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence, recomputeSubject, bindEvidenceSubject, ciAttribution } from './flow-evidence.mjs'
11
11
  export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'