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
@@ -9,9 +9,14 @@
9
9
  * 2. copy the package to a temp dir and run every journey against that copy
10
10
  * only; the original directory is never written;
11
11
  * 3. static preflight (`journey.mjs`) for J1-J8, then the runtime probe
12
- * (`journey-runtime.mjs`) for J1/J2/J4/J6 with a temp HOME/DSH_HOME and
13
- * an allowlisted child env;
14
- * 4. re-hash the original tree and record `subject.treeStable`;
12
+ * (`journey-runtime.mjs`, which runs `dsh plugin add` and boots the tree)
13
+ * with a temp HOME/DSH_HOME and an allowlisted child env; with
14
+ * `--sandbox` the runtime probe runs behind `unshare` (network),
15
+ * `prlimit` (nproc/as), and a process-group timeout that kills the whole
16
+ * tree. Any missing isolation item makes `producer.kind`
17
+ * `sandbox-degraded`, which is never runtime;
18
+ * 4. re-hash the original tree and record `subject.treeStable`; a changed
19
+ * tree invalidates the evidence;
15
20
  * 5. write the JSON, per-check logs, the chain hash, and SHA256SUMS.
16
21
  *
17
22
  * Missing prerequisites (no dsh, no --previous, host compatibility gate
@@ -22,7 +27,8 @@
22
27
  * Usage:
23
28
  * node 40-checker/bin/dsh-flow-collect.mjs --package <dir> [--out flow-evidence.json]
24
29
  * [--logs <dir>] [--previous <dir>] [--dsh <bin>] [--timeout <ms>]
25
- * [--static] [--ci] [--json]
30
+ * [--static] [--ci] [--sandbox [--sandbox-no-network] [--sandbox-no-limits]
31
+ * [--sandbox-nproc <n>] [--sandbox-as <bytes>]] [--json]
26
32
  */
27
33
  import fs from 'node:fs'
28
34
  import os from 'node:os'
@@ -30,10 +36,13 @@ import path from 'node:path'
30
36
  import { spawnSync } from 'node:child_process'
31
37
  import { fileURLToPath } from 'node:url'
32
38
  import {
33
- FLOW_IDS, FLOW_EVIDENCE_SCHEMA, clauseAnchor, finalizeEvidence, journeyAggregate,
39
+ FLOW_IDS, FLOW_EVIDENCE_SCHEMA, ciAttribution, clauseAnchor, finalizeEvidence, journeyAggregate,
34
40
  sha256File, sha256Value, treeSha256, validateFlowEvidence,
35
41
  } from '../src/flow-evidence.mjs'
36
42
  import { sanitizedEnv } from '../src/sandbox-env.mjs'
43
+ import {
44
+ assertNoCredentialEnv, buildSandboxPlan, runInProcessTree, sandboxArgv,
45
+ } from '../src/sandbox-run.mjs'
37
46
 
38
47
  const HERE = path.dirname(fileURLToPath(import.meta.url))
39
48
  const STD = path.resolve(HERE, '..', '..')
@@ -68,6 +77,15 @@ Options:
68
77
  --timeout <ms> per-command budget (default: 120000)
69
78
  --static skip the runtime probe (J1/J2/J4/J6 become static/blocked)
70
79
  --ci mark producer.kind=ci, claims.claimLevel=ci-attested
80
+ (CI attribution is also read from GITHUB_* and stays self-declared)
81
+ --sandbox run the runtime probe under unshare/prlimit/process-group
82
+ timeout with an allowlisted env; producer.kind=sandbox only
83
+ when every isolation item is effective (otherwise
84
+ sandbox-degraded, never runtime)
85
+ --sandbox-no-network force the degraded path (records unshare-missing)
86
+ --sandbox-no-limits force the degraded path (records prlimit-missing)
87
+ --sandbox-nproc <n> RLIMIT_NPROC (default: current task count + headroom)
88
+ --sandbox-as <bytes> RLIMIT_AS (default: 4 GiB)
71
89
  --no-assert-unchanged skip the before/after tree hash comparison (not recommended)
72
90
  --json print the evidence JSON instead of the summary
73
91
  --help show this help
@@ -82,6 +100,8 @@ function parse(argv) {
82
100
  package: null, out: path.resolve('flow-evidence.json'), logs: null, previous: null,
83
101
  dsh: process.env.DSH_BIN || null, timeout: 120_000, static: false, ci: false,
84
102
  assertUnchanged: true, json: false,
103
+ sandbox: false, sandboxNoNetwork: false, sandboxNoLimits: false,
104
+ sandboxNproc: null, sandboxAs: 4 * 1024 ** 3,
85
105
  }
86
106
  for (let i = 0; i < argv.length; i += 1) {
87
107
  const next = () => {
@@ -99,6 +119,11 @@ function parse(argv) {
99
119
  else if (arg === '--timeout') out.timeout = Number(next())
100
120
  else if (arg === '--static' || arg === '--no-runtime') out.static = true
101
121
  else if (arg === '--ci') out.ci = true
122
+ else if (arg === '--sandbox') out.sandbox = true
123
+ else if (arg === '--sandbox-no-network' || arg === '--no-sandbox-network') out.sandboxNoNetwork = true
124
+ else if (arg === '--sandbox-no-limits' || arg === '--no-sandbox-limits') out.sandboxNoLimits = true
125
+ else if (arg === '--sandbox-nproc') out.sandboxNproc = Number(next())
126
+ else if (arg === '--sandbox-as') out.sandboxAs = Number(next())
102
127
  else if (arg === '--no-assert-unchanged') out.assertUnchanged = false
103
128
  else if (arg === '--json') out.json = true
104
129
  else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
@@ -106,6 +131,10 @@ function parse(argv) {
106
131
  }
107
132
  if (!out.package) throw new Error('--package is required')
108
133
  if (!Number.isFinite(out.timeout) || out.timeout < 1000) throw new Error('--timeout must be >= 1000')
134
+ if (out.sandboxNproc !== null && (!Number.isFinite(out.sandboxNproc) || out.sandboxNproc < 2)) throw new Error('--sandbox-nproc must be >= 2')
135
+ if (!Number.isFinite(out.sandboxAs) || out.sandboxAs < 64 * 1024 * 1024) throw new Error('--sandbox-as must be >= 67108864')
136
+ if (out.sandbox && out.static) throw new Error('--sandbox cannot be combined with --static')
137
+ if (out.sandbox && out.ci) throw new Error('--sandbox cannot be combined with --ci (a sandboxed run is sandbox-attested)')
109
138
  return out
110
139
  }
111
140
 
@@ -144,7 +173,7 @@ function writeLog(logDir, name, text) {
144
173
  return { file, sha256: sha256Value(text ?? '') }
145
174
  }
146
175
 
147
- function main() {
176
+ async function main() {
148
177
  const options = parse(process.argv.slice(2))
149
178
  const pkg = path.resolve(options.package)
150
179
  if (!fs.existsSync(path.join(pkg, 'package.json'))) throw new Error(`not a package directory: ${pkg}`)
@@ -162,12 +191,25 @@ function main() {
162
191
  fs.mkdirSync(tmpHome, { recursive: true })
163
192
  fs.mkdirSync(tmpCache, { recursive: true })
164
193
  const childEnv = () => sanitizedEnv({
165
- HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
194
+ HOME: tmpHome, DSH_HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
166
195
  npm_config_cache: tmpCache, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false',
167
196
  })
197
+ // The sandbox is planned before any plugin code runs. `assertNoCredentialEnv`
198
+ // is a hard gate: a surviving token aborts collection instead of booting with it.
199
+ let sandboxPlan = null
168
200
 
169
201
  const warnings = []
170
202
  try {
203
+ if (options.sandbox) {
204
+ assertNoCredentialEnv(childEnv())
205
+ sandboxPlan = buildSandboxPlan({
206
+ env: childEnv(),
207
+ nproc: options.sandboxNproc,
208
+ asBytes: options.sandboxAs,
209
+ force: { network: options.sandboxNoNetwork, limits: options.sandboxNoLimits },
210
+ })
211
+ sandboxPlan.env = assertNoCredentialEnv(childEnv())
212
+ }
171
213
  // 0. Never let our own output/log dirs count as a tree change.
172
214
  const treeExclude = []
173
215
  for (const candidate of [outFile, logDir]) {
@@ -245,13 +287,59 @@ function main() {
245
287
  if (options.static) runtimeSkipped = 'runtime collection disabled (--static)'
246
288
  else if (dshVersion === null) runtimeSkipped = `dsh executable not usable: ${dshBin} (exit ${versionRun.exitCode})`
247
289
  let runtimeVerdict = null
290
+ let sandboxExecution = null
248
291
  if (runtimeSkipped === null) {
249
292
  const args = [JOURNEY_RUNTIME, '--package', copy, '--dsh', dshBin, '--json', '--log-dir', logDir, '--timeout', String(options.timeout)]
250
293
  if (previousCopy) args.push('--previous', previousCopy)
251
- const runtimeRun = run(process.execPath, args, { env: childEnv(), timeout: options.timeout * 2 + 60_000 })
252
- writeLog(logDir, 'runtime-journey.json', runtimeRun.stdoutText)
253
- if (runtimeRun.stderrText) writeLog(logDir, 'runtime-journey.err.log', runtimeRun.stderrText)
254
- try { runtimeVerdict = JSON.parse(runtimeRun.stdoutText) } catch { runtimeSkipped = `runtime probe produced no JSON (exit ${runtimeRun.exitCode}): ${tailLines(runtimeRun.stderrText, 5)}` }
294
+ let runtimeRun
295
+ if (sandboxPlan) {
296
+ // journey-runtime spawns `dsh plugin add` and the boot as its own
297
+ // children; they inherit this process group and network namespace.
298
+ const argv = sandboxArgv(sandboxPlan, process.execPath, args)
299
+ runtimeRun = await runInProcessTree({
300
+ command: argv[0],
301
+ args: argv.slice(1),
302
+ cwd: copy,
303
+ env: childEnv(),
304
+ timeoutMs: options.timeout * 2 + 60_000,
305
+ })
306
+ sandboxExecution = {
307
+ command: argv,
308
+ exitCode: runtimeRun.exitCode,
309
+ timedOut: runtimeRun.timedOut,
310
+ killed: runtimeRun.killed,
311
+ spawnError: runtimeRun.spawnError ?? null,
312
+ durationMs: runtimeRun.durationMs,
313
+ pid: runtimeRun.pid ?? null,
314
+ processTree: {
315
+ mechanism: runtimeRun.treeKill?.mechanism ?? sandboxPlan.processTreeKill.mechanism,
316
+ signalled: runtimeRun.treeKill?.signalled ?? null,
317
+ cleared: runtimeRun.treeKill?.cleared ?? null,
318
+ },
319
+ }
320
+ if (runtimeRun.spawnError) {
321
+ // The probe passed but the sandboxed spawn itself failed: do not keep
322
+ // calling this run `sandbox`.
323
+ sandboxPlan.degraded = [...new Set([...sandboxPlan.degraded, 'sandbox-execution-failed'])]
324
+ sandboxPlan.mode = 'sandbox-degraded'
325
+ }
326
+ if (runtimeRun.timedOut) {
327
+ runtimeSkipped = `sandboxed runtime probe timed out after ${options.timeout * 2 + 60_000} ms; whole process tree killed (${sandboxExecution.processTree.mechanism}, cleared=${sandboxExecution.processTree.cleared})`
328
+ }
329
+ } else {
330
+ runtimeRun = run(process.execPath, args, { env: childEnv(), timeout: options.timeout * 2 + 60_000 })
331
+ }
332
+ const stdoutText = runtimeRun.stdoutText ?? runtimeRun.stdout ?? ''
333
+ const stderrText = runtimeRun.stderrText ?? runtimeRun.stderr ?? ''
334
+ writeLog(logDir, 'runtime-journey.json', stdoutText)
335
+ if (stderrText) writeLog(logDir, 'runtime-journey.err.log', stderrText)
336
+ if (runtimeSkipped === null) {
337
+ try {
338
+ runtimeVerdict = JSON.parse(stdoutText)
339
+ } catch {
340
+ runtimeSkipped = `runtime probe produced no JSON (exit ${runtimeRun.exitCode}): ${tailLines(stderrText, 5)}`
341
+ }
342
+ }
255
343
  if (runtimeVerdict) {
256
344
  for (const check of runtimeVerdict.checks ?? []) {
257
345
  const mapped = toEvidenceCheck(check, logDir, toEvidencePath)
@@ -267,6 +355,9 @@ function main() {
267
355
  }
268
356
  }
269
357
  }
358
+ if (sandboxPlan) {
359
+ writeLog(logDir, 'sandbox-plan.json', JSON.stringify({ ...sandboxPlan, execution: sandboxExecution }, null, 2))
360
+ }
270
361
  if (runtimeSkipped !== null) warnings.push(`runtime probe skipped: ${runtimeSkipped}`)
271
362
 
272
363
  // 6. Journey statuses + reasons (never upgrade a skip to a pass).
@@ -342,18 +433,45 @@ function main() {
342
433
  ? 'flow-fail'
343
434
  : statuses.includes('blocked') ? 'flow-blocked' : statuses.every(status => status === 'pass') ? 'flow-pass' : 'flow-warn'
344
435
  const producerCommand = [process.execPath, path.relative(STD, fileURLToPath(import.meta.url)).split(path.sep).join('/'), ...process.argv.slice(2)]
436
+ const sandboxExecuted = Boolean(sandboxExecution)
437
+ const sandboxKind = sandboxPlan && sandboxExecuted
438
+ ? (sandboxPlan.degraded.length === 0 ? 'sandbox' : 'sandbox-degraded')
439
+ : null
440
+ const producerKind = sandboxKind ?? (options.ci ? 'ci' : 'local')
441
+ const notClaimed = [...NOT_CLAIMED]
442
+ if (sandboxKind === 'sandbox') {
443
+ const localIndex = notClaimed.findIndex(item => item.startsWith('sandbox confinement'))
444
+ if (localIndex >= 0) notClaimed.splice(localIndex, 1)
445
+ notClaimed.push('OCI/container image, seccomp, and cgroup memory/pids limits (not implemented)', 'signature (not produced)')
446
+ } else if (sandboxKind === 'sandbox-degraded') {
447
+ notClaimed.push(`sandbox confinement (degraded: ${sandboxPlan.degraded.join(', ')})`)
448
+ }
449
+ const claimLevel = sandboxKind === 'sandbox' ? 'sandbox-attested' : options.ci ? 'ci-attested' : 'self-attested'
450
+ const method = sandboxKind === 'sandbox'
451
+ ? 'sandboxed collector: allowlisted env (no tokens), temporary HOME/DSH_HOME, network+user namespace, prlimit nproc/as, process-group SIGKILL timeout'
452
+ : sandboxKind === 'sandbox-degraded'
453
+ ? `sandboxed collector DEGRADED (${sandboxPlan.degraded.join(', ')}): allowlisted env, temporary HOME/DSH_HOME, process-group SIGKILL timeout; every missing isolation item is recorded`
454
+ : 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption'
455
+ const sandbox = sandboxPlan ? { ...sandboxPlan, executed: sandboxExecuted, execution: sandboxExecution } : null
456
+ // CI attribution is display-only and self-declared. It is recorded from the
457
+ // runner environment even without --ci; it never upgrades the claim level.
458
+ const ci = ciAttribution()
459
+ const ciFields = ci ? Object.fromEntries(Object.entries(ci).filter(([, value]) => value !== null)) : {}
345
460
  const document = finalizeEvidence({
346
461
  schema: FLOW_EVIDENCE_SCHEMA,
347
462
  generatedAt,
348
- producer: { command: producerCommand, at: generatedAt, kind: options.ci ? 'ci' : 'local', runner: 'flow-collect/1' },
463
+ producer: {
464
+ command: producerCommand,
465
+ at: generatedAt,
466
+ kind: producerKind,
467
+ runner: sandboxPlan ? 'flow-collect/2-sandbox' : 'flow-collect/1',
468
+ ...ciFields,
469
+ },
470
+ ...(sandbox ? { sandbox } : {}),
349
471
  subject,
350
472
  host,
351
473
  journeys,
352
- claims: {
353
- claimLevel: options.ci ? 'ci-attested' : 'self-attested',
354
- notClaimed: NOT_CLAIMED,
355
- method: 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption',
356
- },
474
+ claims: { claimLevel, notClaimed, method },
357
475
  conclusion,
358
476
  })
359
477
  const verdict = validateFlowEvidence(document)
@@ -385,8 +503,17 @@ function main() {
385
503
  ` totals : ${JSON.stringify(counts)}`,
386
504
  ` conclusion: ${conclusion}`,
387
505
  ` chain : ${document.chain.evidenceHash}`,
506
+ ...(sandboxPlan ? [
507
+ ` sandbox : ${sandboxPlan.mode}${sandboxPlan.degraded.length > 0 ? ` degraded=[${sandboxPlan.degraded.join(', ')}]` : ''}`,
508
+ ` env=${sandboxPlan.env.policy} tokens-absent=${sandboxPlan.env.absent} network=${sandboxPlan.network.isolated ? sandboxPlan.network.mechanism : 'off'} limits=${sandboxPlan.limits.available ? sandboxPlan.limits.applied.join('+') : 'off'} home=${sandboxPlan.home.isolated ? 'temp' : 'real'}`,
509
+ ` execution=${sandboxExecuted ? `exit=${sandboxExecution.exitCode} timedOut=${sandboxExecution.timedOut} killed=${sandboxExecution.killed} treeCleared=${sandboxExecution.processTree.cleared}` : 'not-run'}`,
510
+ ] : []),
388
511
  ` logs : ${logDir} (+ ${path.join(outDir, 'SHA256SUMS')})`,
389
- 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
512
+ sandboxKind === 'sandbox'
513
+ ? 'Claims: sandbox-attested (containment only; no OCI image, seccomp, cgroup, signature); not a certification or security audit.'
514
+ : sandboxKind === 'sandbox-degraded'
515
+ ? `Claims: self-attested; sandbox DEGRADED (${sandboxPlan.degraded.join(', ')}). Degraded evidence is never FLOW runtime.`
516
+ : 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
390
517
  ]
391
518
  if (warnings.length > 0) {
392
519
  lines.push('Warnings:')
@@ -486,9 +613,7 @@ function collectHints(journeyId, checks, hints, outDir, logDir, toEvidencePath)
486
613
  }
487
614
  }
488
615
 
489
- try {
490
- main()
491
- } catch (error) {
616
+ main().catch(error => {
492
617
  console.error(`flow-collect: ${error.message}`)
493
618
  process.exitCode = 3
494
- }
619
+ })
@@ -31,7 +31,7 @@ import fs from 'node:fs'
31
31
  import path from 'node:path'
32
32
  import { spawnSync } from 'node:child_process'
33
33
  import { fileURLToPath } from 'node:url'
34
- import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, validateFlowEvidence } from '../src/flow-evidence.mjs'
34
+ import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, bindEvidenceSubject, validateFlowEvidence } from '../src/flow-evidence.mjs'
35
35
 
36
36
  const HERE = path.dirname(fileURLToPath(import.meta.url))
37
37
  const STD = path.resolve(HERE, '..', '..')
@@ -52,6 +52,10 @@ Options:
52
52
  --flow-evidence <file> dsh-flow-evidence/2 JSON from dsh-flow-collect.mjs (J1-J8 checks
53
53
  with command/exitCode/times/stdout sha256/assertions). v1 and bare
54
54
  {Jx:{status}} files are reported deprecated and never count as pass.
55
+ --dir <dir> bind the flow evidence to this package directory: name/version,
56
+ recomputed treeSha256 and npm-pack tarball hash must match the
57
+ evidence subject, otherwise FLOW becomes invalid. If the positional
58
+ directory is omitted, --dir is used for the ENTRY check too.
55
59
  --boost-evidence <file> JSON: { "LEV-3": { "status": "measured|not-measured|failed", "evidenceCard": { ... } }, ... }
56
60
  measured requires a valid evidence card; a status-only row is downgraded to not-measured
57
61
  --timeout <ms> checker timeout, default 150000
@@ -65,7 +69,7 @@ are reported as not-run / not-measured.`)
65
69
 
66
70
  function parseArgs(argv) {
67
71
  const options = {
68
- dir: null, host: null, base: null, bare: null, runtime: true,
72
+ dir: null, bindDir: null, host: null, base: null, bare: null, runtime: true,
69
73
  flowEvidence: null, boostEvidence: null, timeout: 150_000,
70
74
  format: 'text', out: null,
71
75
  }
@@ -82,6 +86,7 @@ function parseArgs(argv) {
82
86
  else if (arg === '--bare') options.bare = next()
83
87
  else if (arg === '--no-runtime') options.runtime = false
84
88
  else if (arg === '--flow-evidence') options.flowEvidence = next()
89
+ else if (arg === '--dir') options.bindDir = next()
85
90
  else if (arg === '--boost-evidence') options.boostEvidence = next()
86
91
  else if (arg === '--timeout') options.timeout = Number(next())
87
92
  else if (arg === '--format') options.format = next()
@@ -91,11 +96,17 @@ function parseArgs(argv) {
91
96
  else if (options.dir === null) options.dir = arg
92
97
  else throw new Error(`unexpected argument: ${arg}`)
93
98
  }
94
- if (!options.dir) throw new Error('a plugin directory is required')
99
+ if (options.bindDir !== null && options.dir !== null && path.resolve(options.bindDir) !== path.resolve(options.dir)) {
100
+ throw new Error('the positional directory and --dir point at different packages')
101
+ }
102
+ if (!options.dir) options.dir = options.bindDir
103
+ if (!options.dir) throw new Error('a plugin directory is required (positional or --dir)')
95
104
  if (!['text', 'json'].includes(options.format)) throw new Error('--format must be text or json')
96
105
  if (!Number.isFinite(options.timeout) || options.timeout < 1000) throw new Error('--timeout must be >= 1000')
97
106
  options.dir = path.resolve(options.dir)
107
+ if (options.bindDir !== null) options.bindDir = path.resolve(options.bindDir)
98
108
  if (!fs.existsSync(path.join(options.dir, 'package.json'))) throw new Error(`not a package directory: ${options.dir}`)
109
+ if (options.bindDir !== null && !fs.existsSync(path.join(options.bindDir, 'package.json'))) throw new Error(`not a package directory: ${options.bindDir}`)
99
110
  return options
100
111
  }
101
112
 
@@ -158,15 +169,26 @@ function classifyEntry(report, invoked) {
158
169
  }
159
170
  }
160
171
 
161
- function classifyFlow(evidence) {
172
+ function attestationOf(evidence) {
173
+ const producer = evidence && typeof evidence.producer === 'object' ? evidence.producer : null
174
+ if (producer?.kind === 'ci') return 'ci'
175
+ if (typeof producer?.runUrl === 'string' && producer.runUrl !== '') return 'ci'
176
+ return 'self-declared'
177
+ }
178
+
179
+ function classifyFlow(evidence, binding = null) {
162
180
  const ids = FLOW_IDS
163
181
  const emptyJourneys = () => Object.fromEntries(ids.map(id => [id, { status: 'not-run', declared: null, checks: 0, passes: false }]))
164
182
  if (!evidence) {
165
183
  return {
166
184
  state: 'not-run',
185
+ attestation: 'self-declared',
186
+ subject: binding,
167
187
  schema: null,
168
188
  claimLevel: null,
169
189
  producerKind: null,
190
+ sandboxMode: null,
191
+ sandboxDegraded: [],
170
192
  sandboxAttested: false,
171
193
  deprecated: false,
172
194
  invalid: false,
@@ -183,9 +205,13 @@ function classifyFlow(evidence) {
183
205
  return row?.status ?? null
184
206
  }
185
207
  const base = {
208
+ attestation: attestationOf(evidence),
209
+ subject: binding,
186
210
  schema: verdict.schema,
187
211
  claimLevel: verdict.claimLevel ?? null,
188
212
  producerKind: verdict.producerKind ?? null,
213
+ sandboxMode: verdict.sandbox?.mode ?? null,
214
+ sandboxDegraded: verdict.sandbox?.degraded ?? [],
189
215
  sandboxAttested: verdict.sandboxAttested === true,
190
216
  deprecated: verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy',
191
217
  invalid: false,
@@ -201,12 +227,19 @@ function classifyFlow(evidence) {
201
227
  journeys: Object.fromEntries(ids.map(id => [id, { status: 'deprecated', declared: declaredOf(id), checks: 0, passes: false }])),
202
228
  }
203
229
  }
204
- if (!verdict.valid) {
230
+ const bindingErrors = binding && !binding.ok
231
+ ? [...(binding.errors ?? []), ...(binding.mismatches ?? [])].map(message => `subject binding: ${message}`)
232
+ : []
233
+ if (!verdict.valid || bindingErrors.length > 0) {
234
+ const errors = [...verdict.errors, ...bindingErrors]
205
235
  return {
206
236
  ...base,
207
237
  state: 'invalid',
208
238
  invalid: true,
209
- reason: `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
239
+ errors,
240
+ reason: bindingErrors.length > 0
241
+ ? `evidence subject does not match the inspected package: ${[...(binding.errors ?? []), ...(binding.mismatches ?? [])].join('; ')}`
242
+ : `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
210
243
  evidenceHash: {
211
244
  declared: verdict.declaredHash,
212
245
  computed: verdict.computedHash,
@@ -245,7 +278,11 @@ function classifyFlow(evidence) {
245
278
  ? 'runtime'
246
279
  : 'not-run'
247
280
  const reasons = []
248
- if (!runtimeEligible && failed.length === 0) {
281
+ const degraded = Array.isArray(base.sandboxDegraded) ? base.sandboxDegraded : []
282
+ if (degraded.length > 0) {
283
+ reasons.push(`sandbox-degraded (${degraded.join(', ')}); runtime requires every isolation item effective`)
284
+ }
285
+ if (!runtimeEligible && failed.length === 0 && degraded.length === 0) {
249
286
  reasons.push(`${base.claimLevel ?? 'non-sandbox'} evidence; runtime requires sandbox-attested`)
250
287
  }
251
288
  if (failed.length > 0) reasons.push(`failed journeys: ${failed.join(', ')}`)
@@ -362,7 +399,9 @@ function nextActions(entry, flow, boost) {
362
399
  if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
363
400
  for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
364
401
  if (entry.state === 'recognized') actions.push('ENTRY: run with --host to replace static recognition with a real G0-G3 boot')
365
- if (flow.state === 'not-run' && flow.claimLevel && flow.sandboxAttested !== true) {
402
+ if (flow.sandboxDegraded?.length > 0) {
403
+ actions.push(`FLOW: sandbox degraded (${flow.sandboxDegraded.join(', ')}); re-run where unshare and prlimit are effective; degraded evidence is never runtime`)
404
+ } else if (flow.state === 'not-run' && flow.claimLevel && flow.sandboxAttested !== true) {
366
405
  actions.push(`FLOW: ${flow.claimLevel} evidence keeps ${flow.passed}/8 journey passes but is not runtime; re-run under a sandbox and set claims.claimLevel=sandbox-attested (or producer.kind=sandbox)`)
367
406
  } else if (flow.state === 'not-run') {
368
407
  actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
@@ -381,6 +420,7 @@ function render(result) {
381
420
  `DSH level audit — ${result.target.dir}`,
382
421
  ` ENTRY : ${result.levels.entry.state.padEnd(12)} (${result.levels.entry.checkerLevel ?? 'no level'})`,
383
422
  ` FLOW : ${result.levels.flow.state.padEnd(12)} ${result.levels.flow.reason}`,
423
+ ` attest: ${result.levels.flow.attestation}${result.levels.flow.subject ? ` (subject ${result.levels.flow.subject.ok ? 'bound' : 'MISMATCH'})` : ''}`,
384
424
  ` BOOST : ${result.levels.boost.state.padEnd(12)} ${result.levels.boost.reason}`,
385
425
  ]
386
426
  if (result.nextActions.length > 0) {
@@ -399,7 +439,13 @@ function main() {
399
439
 
400
440
  const checker = runChecker(options)
401
441
  const entry = classifyEntry(checker.report, checker.invoked)
402
- const flow = classifyFlow(flowEvidence)
442
+ // Subject binding is opt-in via --dir: it re-reads the package and re-packs
443
+ // it, so an evidence file can no longer vouch for a different version/hash.
444
+ let binding = null
445
+ if (flowEvidence && options.bindDir) {
446
+ binding = bindEvidenceSubject(flowEvidence, options.bindDir, { timeoutMs: options.timeout })
447
+ }
448
+ const flow = classifyFlow(flowEvidence, binding)
403
449
  const boost = classifyBoost(boostEvidence)
404
450
 
405
451
  const result = {
@@ -408,6 +454,8 @@ function main() {
408
454
  target: { dir: options.dir },
409
455
  levelsSpec: { schema: levels.schema, version: levels.version, status: levels.status },
410
456
  levels: { entry, flow, boost },
457
+ attestation: flow.attestation,
458
+ subject: binding,
411
459
  nextActions: nextActions(entry, flow, boost),
412
460
  notClaimed: ['official standard', 'certification', 'security audit', 'ranking', 'production baseline'],
413
461
  checker: {
@@ -0,0 +1,17 @@
1
+ // Runs the checker tests when the dev-only test tree is present. The npm package
2
+ // does not ship 40-checker/test, so an installed copy must not fail `npm test`
3
+ // on a missing directory.
4
+ import { existsSync, readdirSync } from 'node:fs'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url))
10
+ const dirs = [join(here, '..', 'test'), join(here, '..', '..', 'test')]
11
+ const files = dirs.filter((dir) => existsSync(dir)).flatMap((dir) => readdirSync(dir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(dir, f)))
12
+ if (files.length === 0) {
13
+ console.log('checker tests are not shipped in the npm package; nothing to run')
14
+ process.exit(0)
15
+ }
16
+ const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
17
+ process.exit(r.status ?? 1)
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `dsh-standards check [dir]` — the author-facing entry point.
3
+ *
4
+ * It shells out to the existing checker in JSON mode and only changes how the
5
+ * same report is presented: Markdown by default, `--json` for the raw
6
+ * `dsh-conformance/1` document, `--html <file>` for a single self-contained
7
+ * file. Exit codes are the checker's, never rewritten here.
8
+ */
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { renderHtml, renderMarkdown } from './author-report.mjs'
14
+
15
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
16
+ const CHECKER = path.resolve(HERE, '..', 'bin', 'dsh-plugin-verify.mjs')
17
+ const MAX_BUFFER = 64 * 1024 * 1024
18
+
19
+ /** Checker flags that take a value, so `check --scope entry` is not read as a directory. */
20
+ const VALUE_FLAGS = new Set([
21
+ '--scope', '--profile', '--runtime', '--runtime-version', '--cordis-version', '--commit',
22
+ '--install-spec', '--market-index', '--plugin-name', '--timeout', '--host', '--base',
23
+ '--web', '--bare', '--entry-runtime', '--entry-claim',
24
+ ])
25
+
26
+ export function checkUsage() {
27
+ return `dsh-standards check — author-facing standards report
28
+
29
+ Usage:
30
+ dsh-standards check [dir] [options]
31
+
32
+ dir defaults to the current directory. Output defaults to Markdown.
33
+
34
+ Options:
35
+ --json print the raw dsh-conformance/1 report
36
+ --html <file> write a single-file HTML report (inline CSS, no assets)
37
+ --help show this help
38
+
39
+ Any other flag is forwarded to the checker, e.g.:
40
+ --scope entry --entry-claim static --no-exec
41
+ --runtime-version 0.2.2-rc.1
42
+ --market-index index.json --plugin-name @scope/name
43
+
44
+ Exit codes (same as the checker):
45
+ 0 completed, no FAIL (PASS or WARN)
46
+ 1 completed, at least one FAIL
47
+ 2 incomplete: a mandatory check did not run
48
+ 3 usage error
49
+ `
50
+ }
51
+
52
+ export function parseCheckArgs(argv = []) {
53
+ const options = { dir: null, json: false, html: null, help: false, passthrough: [] }
54
+ for (let index = 0; index < argv.length; index += 1) {
55
+ const arg = argv[index]
56
+ if (arg === '--help' || arg === '-h') { options.help = true; continue }
57
+ if (arg === '--json') { options.json = true; continue }
58
+ if (arg === '--html' || arg.startsWith('--html=')) {
59
+ const inline = arg.startsWith('--html=') ? arg.slice('--html='.length) : undefined
60
+ if (inline !== undefined) {
61
+ if (inline.trim() === '') throw new Error('--html needs a file path')
62
+ options.html = inline
63
+ } else {
64
+ const next = argv[index + 1]
65
+ if (next === undefined || next.startsWith('--')) throw new Error('--html needs a file path')
66
+ options.html = next
67
+ index += 1
68
+ }
69
+ continue
70
+ }
71
+ if (arg === '--format' || arg.startsWith('--format=')) throw new Error('`check` owns the output format; use --json or --html <file>')
72
+ if (arg === '--out' || arg.startsWith('--out=')) throw new Error('`check` writes to stdout or --html <file>; --out is not forwarded')
73
+ if (arg.startsWith('-')) {
74
+ options.passthrough.push(arg)
75
+ if (arg.includes('=')) continue
76
+ if (VALUE_FLAGS.has(arg)) {
77
+ const next = argv[index + 1]
78
+ if (next === undefined) throw new Error(`${arg} needs a value`)
79
+ options.passthrough.push(next)
80
+ index += 1
81
+ }
82
+ continue
83
+ }
84
+ if (options.dir !== null) throw new Error(`unexpected positional argument: ${arg}`)
85
+ options.dir = arg
86
+ }
87
+ if (options.json && options.html) throw new Error('--json and --html are mutually exclusive')
88
+ if (options.dir === null) options.dir = '.'
89
+ options.dir = path.resolve(options.dir)
90
+ return options
91
+ }
92
+
93
+ function invokeChecker(options) {
94
+ const args = [CHECKER, options.dir, '--format', 'json', ...options.passthrough]
95
+ const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 600_000, maxBuffer: MAX_BUFFER })
96
+ if (run.error) throw new Error(`could not run the checker: ${run.error.message}`)
97
+ let report
98
+ try {
99
+ report = JSON.parse(run.stdout)
100
+ } catch {
101
+ throw new Error(`checker produced no JSON (exit ${run.status}): ${String(run.stderr ?? '').slice(0, 400)}`)
102
+ }
103
+ return { report, status: run.status }
104
+ }
105
+
106
+ export function runCheck(argv = process.argv.slice(2)) {
107
+ let options
108
+ try {
109
+ options = parseCheckArgs(argv)
110
+ } catch (error) {
111
+ process.stderr.write(`dsh-standards check: ${error.message}\n`)
112
+ process.stderr.write(checkUsage())
113
+ return 3
114
+ }
115
+ if (options.help) {
116
+ process.stdout.write(checkUsage())
117
+ return 0
118
+ }
119
+ let result
120
+ try {
121
+ result = invokeChecker(options)
122
+ } catch (error) {
123
+ process.stderr.write(`dsh-standards check: ${error.message}\n`)
124
+ return 3
125
+ }
126
+ const { report, status } = result
127
+ const exitCode = Number.isInteger(report?.conclusion?.exitCode) ? report.conclusion.exitCode : (status ?? 3)
128
+ if (options.html) {
129
+ const out = path.resolve(options.html)
130
+ fs.mkdirSync(path.dirname(out), { recursive: true })
131
+ fs.writeFileSync(out, renderHtml(report))
132
+ process.stdout.write(`wrote ${out}\n`)
133
+ } else if (options.json) {
134
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
135
+ } else {
136
+ process.stdout.write(renderMarkdown(report))
137
+ }
138
+ return exitCode
139
+ }