dsh-standards 0.0.3 → 1.0.1

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 (139) hide show
  1. package/40-checker/README.md +251 -0
  2. package/40-checker/bin/dsh-levels.mjs +15 -0
  3. package/40-checker/bin/dsh-plugin-verify.mjs +8 -0
  4. package/40-checker/fixtures/bad/index.mjs +12 -0
  5. package/40-checker/fixtures/bad/package.json +15 -0
  6. package/40-checker/fixtures/bad-manifest/index.js +1 -0
  7. package/40-checker/fixtures/bad-manifest/package.json +5 -0
  8. package/40-checker/fixtures/bundle/cordis.patch.yml +3 -0
  9. package/40-checker/fixtures/bundle/package.json +15 -0
  10. package/40-checker/fixtures/bundle/server.mjs +7 -0
  11. package/40-checker/fixtures/bundle-v0/cordis.patch.yml +3 -0
  12. package/40-checker/fixtures/bundle-v0/package.json +21 -0
  13. package/40-checker/fixtures/bundle-v0/server.mjs +7 -0
  14. package/40-checker/fixtures/client-missing/client.js +1 -0
  15. package/40-checker/fixtures/client-missing/index.js +1 -0
  16. package/40-checker/fixtures/client-missing/package.json +6 -0
  17. package/40-checker/fixtures/flow-config/index.js +9 -0
  18. package/40-checker/fixtures/flow-config/package.json +10 -0
  19. package/40-checker/fixtures/fs-write/index.mjs +7 -0
  20. package/40-checker/fixtures/fs-write/package.json +10 -0
  21. package/40-checker/fixtures/good/index.mjs +10 -0
  22. package/40-checker/fixtures/good/package.json +19 -0
  23. package/40-checker/fixtures/hang/index.mjs +5 -0
  24. package/40-checker/fixtures/hang/package.json +10 -0
  25. package/40-checker/fixtures/known-answer/expected.json +18 -0
  26. package/40-checker/fixtures/market-index-real.json +18 -0
  27. package/40-checker/fixtures/market-index.json +18 -0
  28. package/40-checker/fixtures/negative/bad-entry/package.json +1 -0
  29. package/40-checker/fixtures/negative/no-entry/package.json +1 -0
  30. package/40-checker/fixtures/negative/no-patch/package.json +1 -0
  31. package/40-checker/fixtures/negative/root-missing/cordis.patch.yml +2 -0
  32. package/40-checker/fixtures/negative/root-missing/package.json +1 -0
  33. package/40-checker/fixtures/negative/workspace/package.json +1 -0
  34. package/40-checker/package.json +22 -0
  35. package/40-checker/samples/README.md +8 -0
  36. package/40-checker/samples/entry18.json +139 -0
  37. package/40-checker/samples/frame.json +485 -0
  38. package/40-checker/scripts/apply-overlay.mjs +59 -0
  39. package/40-checker/scripts/attestation-check.mjs +151 -0
  40. package/40-checker/scripts/bundle-smoke.mjs +126 -0
  41. package/40-checker/scripts/claim-check.mjs +145 -0
  42. package/40-checker/scripts/clause-map.mjs +94 -0
  43. package/40-checker/scripts/collect-fixtures.mjs +44 -0
  44. package/40-checker/scripts/dashboard.mjs +85 -0
  45. package/40-checker/scripts/dco-check.mjs +147 -0
  46. package/40-checker/scripts/dependabot-check.mjs +40 -0
  47. package/40-checker/scripts/doc-commands-check.mjs +92 -0
  48. package/40-checker/scripts/entry-scan.mjs +190 -0
  49. package/40-checker/scripts/entry-validate.mjs +291 -0
  50. package/40-checker/scripts/entry18-attribution.mjs +66 -0
  51. package/40-checker/scripts/entry18-ci.mjs +685 -0
  52. package/40-checker/scripts/entry18-realweb-summary.mjs +71 -0
  53. package/40-checker/scripts/entry18-web.mjs +307 -0
  54. package/40-checker/scripts/evidence-chain.mjs +100 -0
  55. package/40-checker/scripts/flow-index.mjs +82 -0
  56. package/40-checker/scripts/flow-sample-ci.mjs +529 -0
  57. package/40-checker/scripts/governance-check.mjs +210 -0
  58. package/40-checker/scripts/host-farm.mjs +165 -0
  59. package/40-checker/scripts/journey-config-observe.mjs +109 -0
  60. package/40-checker/scripts/journey-disclosure-shape.mjs +83 -0
  61. package/40-checker/scripts/journey-runtime.mjs +178 -0
  62. package/40-checker/scripts/journey.mjs +76 -0
  63. package/40-checker/scripts/label-drift.mjs +74 -0
  64. package/40-checker/scripts/label-sync.mjs +87 -0
  65. package/40-checker/scripts/label-verify.mjs +90 -0
  66. package/40-checker/scripts/landlock-exec.c +101 -0
  67. package/40-checker/scripts/level-audit.mjs +336 -0
  68. package/40-checker/scripts/m1-sidecar.mjs +126 -0
  69. package/40-checker/scripts/m2-runner.mjs +213 -0
  70. package/40-checker/scripts/round-robin.mjs +88 -0
  71. package/40-checker/scripts/sample-frame.mjs +72 -0
  72. package/40-checker/scripts/sample20-ci.mjs +25 -0
  73. package/40-checker/scripts/sample60-ci.mjs +25 -0
  74. package/40-checker/scripts/stale-check.mjs +59 -0
  75. package/40-checker/scripts/surveillance.mjs +88 -0
  76. package/40-checker/scripts/web-probe.mjs +504 -0
  77. package/40-checker/src/cli.mjs +300 -0
  78. package/40-checker/src/client-shape.mjs +37 -0
  79. package/40-checker/src/declarations.mjs +242 -0
  80. package/40-checker/src/index.mjs +8 -0
  81. package/40-checker/src/install-spec.mjs +67 -0
  82. package/40-checker/src/label.mjs +130 -0
  83. package/40-checker/src/manifest.mjs +381 -0
  84. package/40-checker/src/permissions.mjs +60 -0
  85. package/40-checker/src/report.mjs +204 -0
  86. package/40-checker/src/result.mjs +60 -0
  87. package/40-checker/src/runtime.mjs +299 -0
  88. package/40-checker/src/semver.mjs +194 -0
  89. package/40-checker/src/util.mjs +148 -0
  90. package/40-checker/test/attestation.test.mjs +59 -0
  91. package/40-checker/test/checks.test.mjs +172 -0
  92. package/40-checker/test/claim.test.mjs +28 -0
  93. package/40-checker/test/client-shape.test.mjs +35 -0
  94. package/40-checker/test/dco.test.mjs +51 -0
  95. package/40-checker/test/entry-validate.test.mjs +172 -0
  96. package/40-checker/test/governance.test.mjs +76 -0
  97. package/40-checker/test/label.test.mjs +97 -0
  98. package/40-checker/test/level-audit.test.mjs +99 -0
  99. package/40-checker/test/levels.test.mjs +31 -0
  100. package/40-checker/test/ops.test.mjs +117 -0
  101. package/40-checker/test/permissions.test.mjs +40 -0
  102. package/40-checker/test/semver.test.mjs +32 -0
  103. package/40-checker/test/verify.test.mjs +57 -0
  104. package/CHANGELOG.md +24 -0
  105. package/LICENSE +28 -0
  106. package/LICENSE-APACHE +190 -0
  107. package/LICENSE-CC-BY +23 -0
  108. package/NOTICE +14 -0
  109. package/NOTICE.md +11 -0
  110. package/README.md +146 -3
  111. package/bin/dsh-levels.mjs +4 -0
  112. package/bin/dsh-standards.mjs +19 -0
  113. package/docs/BOOST-FIXTURES-2026-11-02.md +38 -0
  114. package/docs/DEVELOPER-JOURNEY.md +40 -0
  115. package/docs/HOST-J3-BUG-REPORT-DRAFT.md +27 -0
  116. package/docs/HOST-J3-CONFIG-REPRO-2026-11-02.md +36 -0
  117. package/docs/NAMING-FALLBACK.md +11 -0
  118. package/docs/fork-policy.md +12 -0
  119. package/docs/full-test-plan-2026-11-02.md +207 -0
  120. package/docs/incident.md +37 -0
  121. package/docs/onboarding.md +27 -0
  122. package/docs/privacy.md +12 -0
  123. package/docs/release.md +16 -0
  124. package/docs/scope-security.md +62 -0
  125. package/docs/status-taxonomy.md +54 -0
  126. package/docs/sunset.md +44 -0
  127. package/docs/translating.md +12 -0
  128. package/package.json +58 -12
  129. package/spec/README.md +27 -0
  130. package/spec/boost/README.md +8 -0
  131. package/spec/clauses.json +253 -0
  132. package/spec/entry/README.md +9 -0
  133. package/spec/flow/README.md +8 -0
  134. package/spec/levels.json +371 -0
  135. package/spec/levels.schema.json +45 -0
  136. package/spec/reference-suite.json +35 -0
  137. package/spec/schemas/attestation.schema.json +284 -0
  138. package/spec/schemas/evidence.schema.json +331 -0
  139. package/spec/schemas/report.schema.json +64 -0
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * level-audit.mjs — one-command self-check against the three DSH adoption levels.
4
+ *
5
+ * What it does:
6
+ * ENTRY runs the existing ENTRY checker (static by default; real G0-G3 boot
7
+ * when --host is supplied) and maps its conclusion to an evidence state.
8
+ * FLOW reads an optional J1-J8 evidence file; with no file it reports the
9
+ * checklist as `not-run` and never invents a pass.
10
+ * BOOST reads an optional LEV evidence file; with no file it reports
11
+ * `not-measured`. It does not run benchmarks for you.
12
+ *
13
+ * The machine source of truth is `spec/levels.json`; this tool only reads it.
14
+ * Zero dependencies: it shells out to the checker already in this repo.
15
+ *
16
+ * Usage:
17
+ * node 40-checker/scripts/level-audit.mjs <plugin-dir>
18
+ * node 40-checker/scripts/level-audit.mjs <bundle-dir> --host <app-boot/lib/index.js> \
19
+ * --base <dsh-base/cordis.patch.yml> --bare <farm> [--timeout 150000]
20
+ * node 40-checker/scripts/level-audit.mjs <dir> --flow-evidence flow.json \
21
+ * --boost-evidence boost.json --format json --out audit.json
22
+ *
23
+ * Exit codes: 0 ENTRY recognized/runtime; 2 ENTRY failed; 3 audit could not run.
24
+ */
25
+ import fs from 'node:fs'
26
+ import path from 'node:path'
27
+ import { spawnSync } from 'node:child_process'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
31
+ const STD = path.resolve(HERE, '..', '..')
32
+ const LEVELS_PATH = path.join(STD, 'spec', 'levels.json')
33
+ const CHECKER = path.join(STD, '40-checker', 'bin', 'dsh-plugin-verify.mjs')
34
+
35
+ function usage() {
36
+ console.log(`level-audit — DSH-ENTRY / FLOW / BOOST self-check
37
+
38
+ Usage:
39
+ node 40-checker/scripts/level-audit.mjs <plugin-dir> [options]
40
+
41
+ Options:
42
+ --host <file> app-boot lib/index.js; enables real ENTRY G0-G3
43
+ --base <file> dsh-base cordis.patch.yml
44
+ --bare <dir> bare-module farm for the bundle smoke
45
+ --no-runtime force static ENTRY even when --host is given
46
+ --flow-evidence <file> JSON: { "J1": { "status": "pass|fail|not-run", "evidence": "..." }, ... }
47
+ --boost-evidence <file> JSON: { "LEV-3": { "status": "measured|not-measured|failed", "evidenceCard": { ... } }, ... }
48
+ measured requires a valid evidence card; a status-only row is downgraded to not-measured
49
+ --timeout <ms> checker timeout, default 150000
50
+ --format text|json default text
51
+ --out <file> write the JSON result
52
+ --help show this help
53
+
54
+ The tool never upgrades a skip to a pass. FLOW and BOOST without evidence files
55
+ are reported as not-run / not-measured.`)
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const options = {
60
+ dir: null, host: null, base: null, bare: null, runtime: true,
61
+ flowEvidence: null, boostEvidence: null, timeout: 150_000,
62
+ format: 'text', out: null,
63
+ }
64
+ for (let i = 0; i < argv.length; i += 1) {
65
+ const arg = argv[i]
66
+ const next = () => {
67
+ const value = argv[i + 1]
68
+ if (value === undefined) throw new Error(`${arg} needs a value`)
69
+ i += 1
70
+ return value
71
+ }
72
+ if (arg === '--host') options.host = next()
73
+ else if (arg === '--base') options.base = next()
74
+ else if (arg === '--bare') options.bare = next()
75
+ else if (arg === '--no-runtime') options.runtime = false
76
+ else if (arg === '--flow-evidence') options.flowEvidence = next()
77
+ else if (arg === '--boost-evidence') options.boostEvidence = next()
78
+ else if (arg === '--timeout') options.timeout = Number(next())
79
+ else if (arg === '--format') options.format = next()
80
+ else if (arg === '--out') options.out = next()
81
+ else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
82
+ else if (arg.startsWith('-')) throw new Error(`unknown option: ${arg}`)
83
+ else if (options.dir === null) options.dir = arg
84
+ else throw new Error(`unexpected argument: ${arg}`)
85
+ }
86
+ if (!options.dir) throw new Error('a plugin directory is required')
87
+ if (!['text', 'json'].includes(options.format)) throw new Error('--format must be text or json')
88
+ if (!Number.isFinite(options.timeout) || options.timeout < 1000) throw new Error('--timeout must be >= 1000')
89
+ options.dir = path.resolve(options.dir)
90
+ if (!fs.existsSync(path.join(options.dir, 'package.json'))) throw new Error(`not a package directory: ${options.dir}`)
91
+ return options
92
+ }
93
+
94
+ function readJson(file, label) {
95
+ try {
96
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
97
+ } catch (error) {
98
+ throw new Error(`${label} is not valid JSON: ${error.message}`)
99
+ }
100
+ }
101
+
102
+ function runChecker(options) {
103
+ const args = [CHECKER, options.dir, '--scope', 'entry', '--format', 'json']
104
+ const canRun = options.runtime && options.host !== null
105
+ args.push('--entry-claim', canRun ? 'runtime' : 'static')
106
+ if (canRun) args.push('--host', options.host)
107
+ if (options.base) args.push('--base', options.base)
108
+ if (options.bare) args.push('--bare', options.bare)
109
+ const run = spawnSync(process.execPath, args, {
110
+ encoding: 'utf8',
111
+ timeout: options.timeout,
112
+ maxBuffer: 64 * 1024 * 1024,
113
+ })
114
+ let report = null
115
+ try {
116
+ report = JSON.parse(run.stdout)
117
+ } catch {
118
+ throw new Error(`checker did not emit JSON (exit ${run.status}): ${(run.stderr || '').slice(-500)}`)
119
+ }
120
+ return {
121
+ invoked: canRun ? 'runtime' : 'static',
122
+ exit: run.status,
123
+ report,
124
+ }
125
+ }
126
+
127
+ function checkById(report, id) {
128
+ return (report.checks ?? []).find(check => check.id === id)
129
+ }
130
+
131
+ function classifyEntry(report, invoked) {
132
+ const conclusion = report.conclusion ?? {}
133
+ const status = conclusion.status
134
+ const failed = (report.checks ?? []).filter(check => check.result === 'FAIL')
135
+ const warnings = (report.checks ?? []).filter(check => check.result === 'WARN')
136
+ const dsc14 = checkById(report, 'DSC-14')
137
+ let state
138
+ if (status === 'fail' || failed.length > 0) state = 'failed'
139
+ else if (status === 'pass' || status === 'warn') state = invoked === 'runtime' && dsc14?.result === 'PASS' ? 'runtime' : 'recognized'
140
+ else state = 'not-run'
141
+ return {
142
+ state,
143
+ claim: invoked,
144
+ checkerStatus: status ?? 'unknown',
145
+ checkerLevel: conclusion.level ?? null,
146
+ failed: failed.map(check => ({ id: check.id, reason: check.reason })),
147
+ warnings: warnings.map(check => ({ id: check.id, reason: check.reason })),
148
+ runtimeEvidence: dsc14 ? { result: dsc14.result, reason: dsc14.reason } : null,
149
+ g4: report.g4 ?? null,
150
+ }
151
+ }
152
+
153
+ function classifyFlow(evidence) {
154
+ const ids = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
155
+ if (!evidence) {
156
+ return {
157
+ state: 'not-run',
158
+ reason: 'no --flow-evidence file; run the J1-J8 journeys and record pass/fail/not-run',
159
+ journeys: Object.fromEntries(ids.map(id => [id, { status: 'not-run' }])),
160
+ }
161
+ }
162
+ const journeys = {}
163
+ for (const id of ids) {
164
+ const row = evidence[id] ?? evidence[id.toLowerCase()]
165
+ const status = row?.status
166
+ journeys[id] = {
167
+ status: ['pass', 'fail', 'not-run', 'manual'].includes(status) ? status : 'not-run',
168
+ evidence: row?.evidence ?? null,
169
+ }
170
+ }
171
+ const failed = ids.filter(id => journeys[id].status === 'fail')
172
+ const passed = ids.filter(id => journeys[id].status === 'pass')
173
+ return {
174
+ state: failed.length > 0 ? 'failed' : passed.length === ids.length ? 'runtime' : 'not-run',
175
+ reason: failed.length > 0
176
+ ? `failed journeys: ${failed.join(', ')}`
177
+ : passed.length === ids.length
178
+ ? 'all J1-J8 have pass evidence'
179
+ : `${passed.length}/8 journeys have pass evidence`,
180
+ journeys,
181
+ }
182
+ }
183
+
184
+ const BOOST_CARD_FIELDS = [
185
+ 'subject', 'host', 'method', 'scope', 'command', 'result', 'negativeControl',
186
+ 'generatedAt', 'expiresAt', 'recordHash', 'claimLevel', 'issuer', 'selfAssessed',
187
+ ]
188
+ const BOOST_CLAIM_LEVELS = new Set(['self-reported', 'self-assessed', 'host-reviewed', 'independent', 'reference'])
189
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
190
+ const HASH_RE = /^sha256:[0-9a-f]{64}$/
191
+
192
+ function validateEvidenceCard(card) {
193
+ if (!card || typeof card !== 'object' || Array.isArray(card)) {
194
+ return { valid: false, missing: ['evidenceCard'], reason: 'missing evidence card' }
195
+ }
196
+ const missing = []
197
+ for (const field of BOOST_CARD_FIELDS) {
198
+ if (card[field] === undefined || card[field] === null || card[field] === '') missing.push(field)
199
+ }
200
+ if (!DATE_RE.test(String(card.generatedAt ?? ''))) missing.push('generatedAt(date)')
201
+ if (!DATE_RE.test(String(card.expiresAt ?? ''))) missing.push('expiresAt(date)')
202
+ if (!HASH_RE.test(String(card.recordHash ?? ''))) missing.push('recordHash(sha256)')
203
+ if (!BOOST_CLAIM_LEVELS.has(String(card.claimLevel ?? ''))) missing.push('claimLevel')
204
+ if (typeof card.selfAssessed !== 'boolean') missing.push('selfAssessed(boolean)')
205
+ if (missing.length > 0) {
206
+ return { valid: false, missing, reason: `evidence card missing/invalid: ${missing.join(', ')}` }
207
+ }
208
+ const generated = Date.parse(`${card.generatedAt}T00:00:00Z`)
209
+ const expires = Date.parse(`${card.expiresAt}T00:00:00Z`)
210
+ if (!Number.isFinite(generated) || !Number.isFinite(expires) || expires < generated) {
211
+ return { valid: false, missing: ['validity'], reason: 'evidence card has invalid generatedAt/expiresAt' }
212
+ }
213
+ return { valid: true, missing: [], reason: null }
214
+ }
215
+
216
+ function classifyBoost(evidence) {
217
+ const ids = ['LEV-1', 'LEV-2', 'LEV-3', 'LEV-4', 'LEV-5', 'LEV-6', 'LEV-7', 'LEV-8']
218
+ if (!evidence) {
219
+ return {
220
+ state: 'not-measured',
221
+ reason: 'no --boost-evidence file; benchmarks/evidence cards are not run by this tool',
222
+ practices: Object.fromEntries(ids.map(id => [id, { status: 'not-measured' }])),
223
+ }
224
+ }
225
+ const practices = {}
226
+ for (const id of ids) {
227
+ const row = evidence[id] ?? evidence[id.toLowerCase()]
228
+ const declared = row?.status
229
+ const card = row?.evidenceCard ?? (row?.evidence && typeof row.evidence === 'object' ? row.evidence : null)
230
+ const cardCheck = validateEvidenceCard(card)
231
+ let status = 'not-measured'
232
+ if (declared === 'failed') status = 'failed'
233
+ else if (declared === 'measured' && cardCheck.valid) status = 'measured'
234
+ practices[id] = {
235
+ status,
236
+ declared: ['measured', 'not-measured', 'failed'].includes(declared) ? declared : 'not-measured',
237
+ evidence: typeof row?.evidence === 'string' ? row.evidence : null,
238
+ evidenceCard: card,
239
+ card: cardCheck,
240
+ }
241
+ }
242
+ const failed = ids.filter(id => practices[id].status === 'failed')
243
+ const measured = ids.filter(id => practices[id].status === 'measured')
244
+ const today = new Date().toISOString().slice(0, 10)
245
+ const stale = measured.filter(id => String(practices[id].evidenceCard.expiresAt) < today)
246
+ const invalid = ids.filter(id => practices[id].declared === 'measured' && !practices[id].card.valid)
247
+ let state = 'not-measured'
248
+ if (failed.length > 0) state = 'failed'
249
+ else if (stale.length > 0) state = 'stale'
250
+ else if (measured.length === ids.length) state = 'measured'
251
+ const reasons = []
252
+ if (failed.length > 0) reasons.push(`failed practices: ${failed.join(', ')}`)
253
+ if (stale.length > 0) reasons.push(`stale evidence cards: ${stale.join(', ')}`)
254
+ if (invalid.length > 0) reasons.push(`measured without a valid evidence card: ${invalid.join(', ')}`)
255
+ if (failed.length === 0 && stale.length === 0 && measured.length !== ids.length) {
256
+ reasons.push(`${measured.length}/8 practices have valid measured evidence cards`)
257
+ }
258
+ return {
259
+ state,
260
+ reason: reasons.join('; '),
261
+ practices,
262
+ validCards: measured.length,
263
+ invalidCards: invalid.length,
264
+ }
265
+ }
266
+
267
+ function nextActions(entry, flow, boost) {
268
+ const actions = []
269
+ for (const failure of entry.failed) actions.push(`ENTRY: fix ${failure.id} — ${failure.reason}`)
270
+ if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
271
+ for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
272
+ if (entry.state === 'recognized') actions.push('ENTRY: run with --host to replace static recognition with a real G0-G3 boot')
273
+ if (flow.state === 'not-run') actions.push('FLOW: record J1-J8 in a flow-evidence JSON; do not claim FLOW until they run')
274
+ if (boost.state === 'not-measured') actions.push('BOOST: attach LEV evidence cards (subject/host/method/scope/command/result/negativeControl/generatedAt/expiresAt/recordHash/claimLevel/issuer/selfAssessed); a status alone is not measured')
275
+ if (boost.state === 'stale') actions.push('BOOST: refresh stale evidence cards before claiming measured')
276
+ return actions
277
+ }
278
+
279
+ function render(result) {
280
+ const lines = [
281
+ `DSH level audit — ${result.target.dir}`,
282
+ ` ENTRY : ${result.levels.entry.state.padEnd(12)} (${result.levels.entry.checkerLevel ?? 'no level'})`,
283
+ ` FLOW : ${result.levels.flow.state.padEnd(12)} ${result.levels.flow.reason}`,
284
+ ` BOOST : ${result.levels.boost.state.padEnd(12)} ${result.levels.boost.reason}`,
285
+ ]
286
+ if (result.nextActions.length > 0) {
287
+ lines.push('Next:')
288
+ for (const action of result.nextActions) lines.push(` - ${action}`)
289
+ }
290
+ lines.push('Claims: not official, not a certification, not a security audit; static is not runtime.')
291
+ return lines.join('\n')
292
+ }
293
+
294
+ function main() {
295
+ const options = parseArgs(process.argv.slice(2))
296
+ const levels = JSON.parse(fs.readFileSync(LEVELS_PATH, 'utf8'))
297
+ const flowEvidence = options.flowEvidence ? readJson(options.flowEvidence, '--flow-evidence') : null
298
+ const boostEvidence = options.boostEvidence ? readJson(options.boostEvidence, '--boost-evidence') : null
299
+
300
+ const checker = runChecker(options)
301
+ const entry = classifyEntry(checker.report, checker.invoked)
302
+ const flow = classifyFlow(flowEvidence)
303
+ const boost = classifyBoost(boostEvidence)
304
+
305
+ const result = {
306
+ schema: 'dsh-level-audit/1',
307
+ generatedAt: new Date().toISOString(),
308
+ target: { dir: options.dir },
309
+ levelsSpec: { schema: levels.schema, version: levels.version, status: levels.status },
310
+ levels: { entry, flow, boost },
311
+ nextActions: nextActions(entry, flow, boost),
312
+ notClaimed: ['official standard', 'certification', 'security audit', 'ranking', 'production baseline'],
313
+ checker: {
314
+ invoked: checker.invoked,
315
+ exit: checker.exit,
316
+ schema: checker.report.schema,
317
+ anchor: checker.report.anchor,
318
+ coverage: checker.report.coverage,
319
+ },
320
+ }
321
+
322
+ const output = options.format === 'json' ? JSON.stringify(result, null, 2) : render(result)
323
+ if (options.out) {
324
+ fs.mkdirSync(path.dirname(path.resolve(options.out)), { recursive: true })
325
+ fs.writeFileSync(options.out, `${output}\n`)
326
+ }
327
+ process.stdout.write(`${output}\n`)
328
+ process.exit(entry.state === 'failed' ? 2 : entry.state === 'not-run' ? 3 : 0)
329
+ }
330
+
331
+ try {
332
+ main()
333
+ } catch (error) {
334
+ console.error(`level-audit: ${error.message}`)
335
+ process.exit(3)
336
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate a levels.json sidecar for a format-1 market index.
4
+ *
5
+ * Tier A (index-only): every entry gets index-field validation
6
+ * (exact SemVer, npm/git source pinned, engines.dsh presence).
7
+ * No package is downloaded or executed; level is null.
8
+ * Tier B (static-only): optional pilot JSON files from
9
+ * `dsh-plugin-verify --static-only` are merged in by name@version.
10
+ *
11
+ * Usage:
12
+ * node scripts/m1-sidecar.mjs \
13
+ * --index /tmp/dsh-catalog/index.json \
14
+ * --pilot results/m1 \
15
+ * --out results/m1/levels.json
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+
20
+ const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
21
+ const COMMIT = /^[0-9a-f]{40}$/
22
+
23
+ function parseArgs(argv) {
24
+ const options = { index: null, out: null, pilot: null, indexUrl: 'https://awesome-dsh-plugin.com/index.json' }
25
+ for (let i = 0; i < argv.length; i += 1) {
26
+ const arg = argv[i]
27
+ if (arg === '--index') options.index = argv[++i]
28
+ else if (arg === '--out') options.out = argv[++i]
29
+ else if (arg === '--pilot') options.pilot = argv[++i]
30
+ else if (arg === '--index-url') options.indexUrl = argv[++i]
31
+ else if (arg === '--help' || arg === '-h') { console.log('usage: node scripts/m1-sidecar.mjs --index <format-1.json> [--pilot <dir>] --out <levels.json>'); process.exit(0) }
32
+ else throw new Error(`unknown option: ${arg}`)
33
+ }
34
+ if (!options.index) throw new Error('--index is required')
35
+ if (!options.out) throw new Error('--out is required')
36
+ return options
37
+ }
38
+
39
+ function readPilots(directory) {
40
+ const map = new Map()
41
+ if (!directory || !fs.existsSync(directory)) return map
42
+ for (const entry of fs.readdirSync(directory)) {
43
+ if (!entry.endsWith('.json')) continue
44
+ const file = path.join(directory, entry)
45
+ let report
46
+ try { report = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { continue }
47
+ if (report?.schema !== 'dsh-conformance/1' || !report.subject?.name || !report.subject?.version) continue
48
+ map.set(`${report.subject.name}@${report.subject.version}`, { file, report })
49
+ }
50
+ return map
51
+ }
52
+
53
+ function pilotLevel(report, file) {
54
+ const status = { pass: 'static-only', warn: 'static-only-warn', fail: 'failed', incomplete: 'inconclusive' }[report.conclusion?.status] ?? 'inconclusive'
55
+ return {
56
+ standard: 'DS-C',
57
+ standardVersion: report.standard?.version ?? '1.0.0',
58
+ profile: 'listing',
59
+ status,
60
+ coverage: report.coverage,
61
+ anchor: report.anchor,
62
+ checkedAt: new Date().toISOString().slice(0, 10),
63
+ expiresAt: new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10),
64
+ evidenceUrl: `file://${path.resolve(file)}`,
65
+ }
66
+ }
67
+
68
+ function main() {
69
+ const options = parseArgs(process.argv.slice(2))
70
+ const index = JSON.parse(fs.readFileSync(options.index, 'utf8'))
71
+ if (index.format !== 1 || !Array.isArray(index.plugins)) throw new Error('not a format-1 index')
72
+ const pilots = readPilots(options.pilot)
73
+ const levels = {}
74
+ const summary = { total: 0, indexOnly: 0, staticOnly: 0, staticOnlyWarn: 0, failed: 0, inconclusive: 0, withEnginesDsh: 0, npm: 0, git: 0 }
75
+ for (const plugin of [...index.plugins].sort((a, b) => String(a.name).localeCompare(String(b.name)))) {
76
+ const versions = Array.isArray(plugin.versions) ? [...plugin.versions].sort((a, b) => String(a.version).localeCompare(String(b.version))) : []
77
+ for (const version of versions) {
78
+ summary.total += 1
79
+ const source = version.source ?? {}
80
+ const sourcePinned = source.type === 'npm' || (source.type === 'git' && COMMIT.test(source.commit ?? ''))
81
+ const enginesDsh = typeof version.engines?.dsh === 'string' && version.engines.dsh !== ''
82
+ if (enginesDsh) summary.withEnginesDsh += 1
83
+ if (source.type === 'npm') summary.npm += 1
84
+ if (source.type === 'git') summary.git += 1
85
+ const key = `${plugin.name}@${version.version}`
86
+ const pilot = pilots.get(key)
87
+ const base = {
88
+ plugin: plugin.name,
89
+ version: version.version,
90
+ status: 'index-only',
91
+ index: {
92
+ versionExact: EXACT.test(version.version ?? ''),
93
+ sourceType: source.type ?? null,
94
+ sourcePinned,
95
+ enginesDsh,
96
+ hasUpstream: plugin.upstream !== undefined,
97
+ },
98
+ reason: 'package tarball not fetched; only index fields were checked',
99
+ anchor: { indexUrl: options.indexUrl, parser: 'm1-sidecar/1', checkedAt: new Date().toISOString().slice(0, 10) },
100
+ }
101
+ if (pilot) {
102
+ const level = pilotLevel(pilot.report, pilot.file)
103
+ levels[key] = { ...base, status: level.status, reason: '', level }
104
+ if (level.status === 'static-only-warn') summary.staticOnlyWarn += 1
105
+ else if (level.status === 'failed') summary.failed += 1
106
+ else if (level.status === 'inconclusive') summary.inconclusive += 1
107
+ else summary.staticOnly += 1
108
+ } else {
109
+ levels[key] = base
110
+ summary.indexOnly += 1
111
+ }
112
+ }
113
+ }
114
+ const output = {
115
+ schema: 'dsh-levels/1',
116
+ indexUrl: options.indexUrl,
117
+ generatedAt: new Date().toISOString(),
118
+ summary,
119
+ levels,
120
+ }
121
+ fs.mkdirSync(path.dirname(path.resolve(options.out)), { recursive: true })
122
+ fs.writeFileSync(options.out, JSON.stringify(output, null, 2))
123
+ console.log(JSON.stringify({ out: path.resolve(options.out), summary }, null, 2))
124
+ }
125
+
126
+ try { main() } catch (error) { console.error(`m1-sidecar: ${error.message}`); process.exitCode = 3 }
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * M2 runner (MVP): execute one package's L1-L3 in an isolated child process.
4
+ *
5
+ * This is NOT a security sandbox. It isolates the checker from the developer's
6
+ * real DSH_HOME/profile and kills the process tree on timeout; it does not
7
+ * restrict the child's filesystem or network. The standard's M2 contract
8
+ * (50 §5) still needs a container/image for untrusted packages.
9
+ *
10
+ * Usage:
11
+ * node scripts/m2-runner.mjs \
12
+ * --package /tmp/dsh-host/plugins/ci-diet \
13
+ * --runtime <dsh-checkout>/vendor/cordis/lib/index.js \
14
+ * --runtime-version 0.2.2-alpha.2 \
15
+ * --out results/m2/ci-diet.json
16
+ */
17
+ import fs from 'node:fs'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+ import { spawn, spawnSync } from 'node:child_process'
21
+
22
+ function parse(argv) {
23
+ const options = {
24
+ package: null, runtime: null, runtimeVersion: '0.2.2-alpha.2', cordisVersion: '4.0.6-alpha.1',
25
+ timeoutMs: 60_000, out: null, keep: false, json: false, modules: null, backend: 'process', image: null,
26
+ }
27
+ for (let i = 0; i < argv.length; i += 1) {
28
+ const arg = argv[i]
29
+ if (arg === '--package') options.package = argv[++i]
30
+ else if (arg === '--runtime') options.runtime = argv[++i]
31
+ else if (arg === '--runtime-version') options.runtimeVersion = argv[++i]
32
+ else if (arg === '--cordis-version') options.cordisVersion = argv[++i]
33
+ else if (arg === '--timeout') options.timeoutMs = Number(argv[++i])
34
+ else if (arg === '--out') options.out = argv[++i]
35
+ else if (arg === '--keep') options.keep = true
36
+ else if (arg === '--modules') options.modules = argv[++i]
37
+ else if (arg === '--backend') options.backend = argv[++i]
38
+ else if (arg === '--image') options.image = argv[++i]
39
+ else if (arg === '--json') options.json = true
40
+ else if (arg === '--help' || arg === '-h') { console.log('usage: node scripts/m2-runner.mjs --package <dir> --runtime <cordis-lib> [--backend process|unshare|landlock|unshare-landlock] [--image ref@sha256:<64hex>] [--out level.json]'); process.exit(0) }
41
+ else throw new Error(`unknown option: ${arg}`)
42
+ }
43
+ if (!options.package) throw new Error('--package is required')
44
+ if (!options.runtime) throw new Error('--runtime is required')
45
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1000) throw new Error('--timeout must be >= 1000 ms')
46
+ if (!['process', 'unshare', 'landlock', 'unshare-landlock'].includes(options.backend)) throw new Error('--backend must be process, unshare, landlock, or unshare-landlock')
47
+ if (options.image && !/^[^@\s]+@sha256:[0-9a-f]{64}$/.test(options.image)) throw new Error('--image must be ref@sha256:<64 hex>')
48
+ return options
49
+ }
50
+
51
+ function killTree(child, signal = 'SIGKILL') {
52
+ if (!child.pid) return
53
+ if (process.platform === 'win32') {
54
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
55
+ return
56
+ }
57
+ try { process.kill(-child.pid, signal) } catch { try { child.kill(signal) } catch { /* gone */ } }
58
+ }
59
+
60
+ function runChecker(command, args, cwd, env, timeoutMs) {
61
+ return new Promise((resolve) => {
62
+ const child = spawn(command, args, { cwd, env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] })
63
+ let stdout = ''
64
+ let stderr = ''
65
+ let killed = false
66
+ const timer = setTimeout(() => { killed = true; killTree(child) }, timeoutMs)
67
+ child.stdout.on('data', chunk => { stdout += chunk })
68
+ child.stderr.on('data', chunk => { stderr += chunk })
69
+ child.on('error', error => { clearTimeout(timer); resolve({ code: null, stdout, stderr: `${stderr}${error.message}`, killed }) })
70
+ child.on('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr, killed }) })
71
+ })
72
+ }
73
+
74
+ function linkModules(pkg, modules) {
75
+ if (!modules) return 0
76
+ const source = path.resolve(modules)
77
+ if (!fs.existsSync(source)) throw new Error(`--modules not found: ${source}`)
78
+ const target = path.join(pkg, 'node_modules')
79
+ fs.mkdirSync(target, { recursive: true })
80
+ let linked = 0
81
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
82
+ const from = path.join(source, entry.name)
83
+ const to = path.join(target, entry.name)
84
+ if (entry.isDirectory() && entry.name.startsWith('@')) {
85
+ fs.mkdirSync(to, { recursive: true })
86
+ for (const child of fs.readdirSync(from, { withFileTypes: true })) {
87
+ const dest = path.join(to, child.name)
88
+ if (fs.existsSync(dest)) continue
89
+ try { fs.symlinkSync(path.join(from, child.name), dest, process.platform === 'win32' ? 'junction' : 'dir'); linked += 1 } catch { /* skip */ }
90
+ }
91
+ } else if (!fs.existsSync(to)) {
92
+ try { fs.symlinkSync(from, to, process.platform === 'win32' ? 'junction' : 'dir'); linked += 1 } catch { /* skip */ }
93
+ }
94
+ }
95
+ return linked
96
+ }
97
+
98
+ function ensureLandlock() {
99
+ const source = path.resolve(path.dirname(new URL(import.meta.url).pathname), 'landlock-exec.c')
100
+ const binary = path.join(os.tmpdir(), 'dsh-landlock-exec')
101
+ try {
102
+ const sourceStat = fs.statSync(source)
103
+ const binaryStat = fs.statSync(binary, { throwIfNoEntry: false })
104
+ if (binaryStat && binaryStat.mtimeMs >= sourceStat.mtimeMs) return binary
105
+ } catch { /* compile below */ }
106
+ const result = spawnSync('cc', ['-O2', source, '-o', binary], { stdio: 'inherit' })
107
+ if (result.error || result.status !== 0) throw new Error('cannot build landlock-exec (need a C compiler)')
108
+ return binary
109
+ }
110
+
111
+ function mapStatus(report) {
112
+ const status = report?.conclusion?.status
113
+ if (status === 'pass') return 'verified'
114
+ if (status === 'warn') return 'verified-warn'
115
+ if (status === 'fail') return 'failed'
116
+ return 'inconclusive'
117
+ }
118
+
119
+ async function main() {
120
+ const options = parse(process.argv.slice(2))
121
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-m2-'))
122
+ const home = path.join(root, 'home')
123
+ const work = path.join(root, 'work')
124
+ const pkg = path.join(root, 'package')
125
+ fs.mkdirSync(home, { recursive: true })
126
+ fs.mkdirSync(work, { recursive: true })
127
+ fs.cpSync(path.resolve(options.package), pkg, { recursive: true, dereference: false, filter: source => !source.includes(`${path.sep}.git${path.sep}`) })
128
+ const linkedModules = linkModules(pkg, options.modules)
129
+ const reportPath = path.join(work, 'report.json')
130
+ const cli = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'dsh-plugin-verify.mjs')
131
+ const args = [cli, pkg, '--format', 'json', '--treat-as-plugin', '--runtime', path.resolve(options.runtime), '--runtime-version', options.runtimeVersion, '--cordis-version', options.cordisVersion, '--out', reportPath]
132
+ const env = {
133
+ ...process.env,
134
+ DSH_HOME: home,
135
+ HOME: home,
136
+ USERPROFILE: home,
137
+ TMPDIR: path.join(root, 'tmp'),
138
+ TEMP: path.join(root, 'tmp'),
139
+ TMP: path.join(root, 'tmp'),
140
+ }
141
+ fs.mkdirSync(env.TMPDIR, { recursive: true })
142
+ const started = Date.now()
143
+ const needsLandlock = options.backend === 'landlock' || options.backend === 'unshare-landlock'
144
+ const needsUnshare = options.backend === 'unshare' || options.backend === 'unshare-landlock'
145
+ const landlock = needsLandlock ? ensureLandlock() : null
146
+ const command = needsUnshare ? 'unshare' : needsLandlock ? landlock : process.execPath
147
+ const commandArgs = []
148
+ if (needsUnshare) {
149
+ commandArgs.push('-Urn', '--fork', '--pid', '--mount-proc')
150
+ if (needsLandlock) commandArgs.push(landlock)
151
+ }
152
+ if (needsLandlock) commandArgs.push('--rw', home, '--rw', work, '--rw', env.TMPDIR, '--')
153
+ commandArgs.push(process.execPath, ...args)
154
+ const result = await runChecker(command, commandArgs, work, env, options.timeoutMs)
155
+ const elapsedMs = Date.now() - started
156
+ let report = null
157
+ try { report = JSON.parse(fs.readFileSync(reportPath, 'utf8')) } catch { /* report missing */ }
158
+ const imageUnverified = Boolean(options.image)
159
+ const status = result.killed || imageUnverified ? 'inconclusive' : mapStatus(report)
160
+ const level = {
161
+ schema: 'dsh-m2-level/1',
162
+ status,
163
+ standard: report?.standard ?? { id: 'DS-C', version: '1.0.0' },
164
+ subject: report?.subject ?? { name: null, version: null, path: path.resolve(options.package) },
165
+ anchor: report?.anchor ?? null,
166
+ coverage: report?.coverage ?? null,
167
+ conclusion: report?.conclusion ?? null,
168
+ m1: { coverage: report?.coverage ?? null, conclusion: report?.conclusion ?? null },
169
+ reason: imageUnverified ? 'image metadata is unverified; M2 cannot assert verified' : undefined,
170
+ sandbox: {
171
+ runner: 'm2-runner/1',
172
+ node: process.version,
173
+ timeoutMs: options.timeoutMs,
174
+ elapsedMs,
175
+ killed: result.killed,
176
+ exitCode: result.code,
177
+ isolatedHome: true,
178
+ backend: options.backend,
179
+ networkNamespace: needsUnshare,
180
+ fsWriteRestricted: needsLandlock,
181
+ securityBoundary: false,
182
+ containment: options.backend === 'unshare-landlock'
183
+ ? 'landlock-fs+network-namespace+process'
184
+ : options.backend === 'landlock'
185
+ ? 'landlock-fs+process'
186
+ : options.backend === 'unshare'
187
+ ? 'network-namespace+process'
188
+ : 'none',
189
+ linkedModules,
190
+ image: options.image ? { reference: options.image.split('@')[0], digest: `sha256:${options.image.split('@sha256:')[1]}`, verified: false, assertion: 'unverified-metadata', reason: 'OCI/Docker backend not enabled; metadata only; cannot yield verified' } : null,
191
+ note: needsLandlock
192
+ ? 'Landlock restricts writes to HOME/TMP/cwd; read is allowed everywhere. Network isolation only with unshare-landlock.'
193
+ : needsUnshare
194
+ ? 'user+network namespace + process isolation; no filesystem write restriction'
195
+ : 'process isolation only; no filesystem/network restriction',
196
+ },
197
+ checks: report?.checks ?? [],
198
+ stderr: result.stderr.slice(0, 4000),
199
+ ranAt: new Date().toISOString(),
200
+ }
201
+ if (!options.keep) fs.rmSync(root, { recursive: true, force: true })
202
+ const output = JSON.stringify(level, null, 2)
203
+ if (options.out) {
204
+ const out = path.resolve(options.out)
205
+ fs.mkdirSync(path.dirname(out), { recursive: true })
206
+ fs.writeFileSync(out, output)
207
+ }
208
+ if (options.json || !options.out) process.stdout.write(`${output}\n`)
209
+ const exit = status === 'verified' || status === 'verified-warn' ? 0 : status === 'failed' ? 1 : 2
210
+ process.exitCode = exit
211
+ }
212
+
213
+ main().catch(error => { console.error(`m2-runner: ${error.message}`); process.exitCode = 3 })