dsh-standards 1.2.0 → 1.2.2

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 +8 -3
  2. package/40-checker/bin/dsh-levels.mjs +7 -1
  3. package/40-checker/scripts/flow-collect.mjs +140 -21
  4. package/40-checker/scripts/level-audit.mjs +43 -5
  5. package/40-checker/scripts/run-tests.mjs +21 -0
  6. package/40-checker/src/flow-evidence.mjs +107 -2
  7. package/40-checker/src/index.mjs +1 -1
  8. package/40-checker/src/remediation.mjs +14 -9
  9. package/40-checker/src/sandbox-run.mjs +403 -0
  10. package/CHANGELOG.md +8 -1
  11. package/README.en.md +347 -0
  12. package/README.es.md +347 -0
  13. package/README.hi.md +347 -0
  14. package/README.md +295 -82
  15. package/README.pt.md +347 -0
  16. package/docs/assets/dsh-standards-flow-dark.svg +38 -0
  17. package/docs/assets/dsh-standards-flow-light.svg +38 -0
  18. package/package.json +2 -3
  19. package/spec/flow/README.md +21 -4
  20. package/spec/flow-evidence.schema.json +73 -3
  21. package/40-checker/test/attestation.test.mjs +0 -59
  22. package/40-checker/test/checks.test.mjs +0 -414
  23. package/40-checker/test/claim.test.mjs +0 -28
  24. package/40-checker/test/client-shape.test.mjs +0 -35
  25. package/40-checker/test/dco.test.mjs +0 -51
  26. package/40-checker/test/entry-validate.test.mjs +0 -172
  27. package/40-checker/test/flow-evidence.test.mjs +0 -295
  28. package/40-checker/test/governance.test.mjs +0 -76
  29. package/40-checker/test/label.test.mjs +0 -97
  30. package/40-checker/test/level-audit.test.mjs +0 -99
  31. package/40-checker/test/levels.test.mjs +0 -31
  32. package/40-checker/test/ops.test.mjs +0 -117
  33. package/40-checker/test/permissions.test.mjs +0 -40
  34. package/40-checker/test/remediation.test.mjs +0 -300
  35. package/40-checker/test/semver.test.mjs +0 -32
  36. package/40-checker/test/verify.test.mjs +0 -57
@@ -19,7 +19,7 @@ node bin/dsh-plugin-verify.mjs /path/to/plugin --format json --out report.json
19
19
  node bin/dsh-plugin-verify.mjs /path/to/plugin --format sarif --out report.sarif
20
20
  ```
21
21
 
22
- `buildReport()` 与 `--format json` 默认带**加性** `advice[]`(来自 `spec/remediation.json` 的 68 条规则;FAIL/WARN detail 优先,无 detail 的运行时 FAIL 用 `check.clause` 兜底)。`--no-advice` 可关闭;它不改变任何 check 的结果或 exit code。
22
+ `buildReport()` 与 `--format json` 默认带**加性** `advice[]`(来自 `spec/remediation.json` 的 68 条规则;FAIL/WARN detail 优先,无 detail 的运行时 FAIL 用 `check.clause` 兜底)。每条 advice 的 `gate` 是该 check 的最终结果(PASS/WARN/FAIL),`severity` 跟随 `gate`:被降级为 WARN 的 check 即使 detail 仍是 FAIL,也只给 WARN,detail 原因保留在 `reason`。`--no-advice` 可关闭;它不改变任何 check 的结果或 exit code。
23
23
 
24
24
  ## 2. 检查项
25
25
 
@@ -131,13 +131,18 @@ node scripts/level-audit.mjs ./my-bundle \
131
131
  ```bash
132
132
  node bin/dsh-flow-collect.mjs --package ./my-plugin --out flow-evidence.json
133
133
  # 等价:node bin/dsh-levels.mjs flow collect --package ./my-plugin --out flow-evidence.json
134
+ # 隔离运行(unshare 无网 + prlimit + 进程组超时杀树 + 白名单 env):
135
+ node bin/dsh-flow-collect.mjs --package ./my-plugin --sandbox --out flow-evidence.json
136
+ # 等价:node bin/dsh-levels.mjs flow sandbox --package ./my-plugin --out flow-evidence.json
134
137
  ```
135
138
 
136
139
  - 只接受 `dsh-flow-evidence/2`(`spec/flow-evidence.schema.json`)。`level-audit --flow-evidence` 对 v1(`dsh-flow-journey/1`、`dsh-flow-runtime/1`)和裸 `{Jx:{status}}` 标 `deprecated`,不计 pass。
137
140
  - 每个 J 必须至少 1 条 `checks[]` 且全部 `exitCode === 0`、断言成立、至少一条非 `static` 观测检查,才计 pass;`static` preflight 不是 runtime,`warn`/`blocked`/`na` 都不计 8/8。
138
- - collector 把包复制到临时目录再跑,前后 `treeSha256` 必须一致;子进程 env 走白名单(不含 token/key/secret);不授予 `allowBuilds`,不自动 `allow-version`。
141
+ - collector 把包复制到临时目录再跑,前后 `treeSha256` 必须一致;`treeStable:false` 或 `treeSha256After` 不同直接判证据无效(invalid),不是 warn。子进程 env 走白名单(不含 token/key/secret);不授予 `allowBuilds`,不自动 `allow-version`。
142
+ - `--sandbox` 在临时 HOME/DSH_HOME 下跑 `dsh plugin add` + boot:先试 `unshare -n`,不行再试 `unshare -Urn`;用 `prlimit` 设 `nproc`/`as` 并从 `/proc/self/limits` 回读实际生效项;超时 SIGKILL 整个进程组(Windows `taskkill /T /F`)。硬断言 `GH_TOKEN`/`NPM_TOKEN`/`GITHUB_TOKEN` 不在子进程 env,发现即中止。
143
+ - 只有**所有**隔离项都生效才写 `producer.kind=sandbox` / `claims.claimLevel=sandbox-attested`;任何一项缺失(`unshare-missing`、`prlimit-missing`、`env-token-present`、`home-not-isolated`)写 `sandbox-degraded` + `sandbox.degraded[]`,仍是 self-attested,`level-audit` 永不判 runtime。`--sandbox-no-network` / `--sandbox-no-limits` 用于强制验证降级路径。
139
144
  - 前置缺失(无 dsh、无 `--previous`、宿主兼容门禁拒绝)记 `blocked` + reason,出口码 0;真失败出口码 1,并打印五要素提示(子检查 / 命令 / exitCode / stderr 末 20 行 / 日志绝对路径 + 条款锚)。
140
- - 本地证据只是 `self-attested`:结构自洽 + 绑定此包 + 未过期,**不证明真跑过**,也不是沙箱、认证或安全审计。
145
+ - 本地证据默认 `self-attested`:结构自洽 + 绑定此包 + 未过期,**不证明真跑过**。即使 `--sandbox` 通过,它也只是 containment(无 seccomp/cgroup/OCI 镜像/签名),不是认证或安全审计。`level-audit` 只有见到 `claims.claimLevel=sandbox-attested` 或 `producer.kind=sandbox` 才判 `runtime`;self/ci/degraded 证据保留 `passed` 计数但记 `not-run`。
141
146
 
142
147
  ## 4. 运行 L1–L3
143
148
 
@@ -16,8 +16,14 @@ let target = AUDIT
16
16
  if (args[0] === 'flow' && args[1] === 'collect') {
17
17
  args.splice(0, 2)
18
18
  target = FLOW_COLLECT
19
+ } else if (args[0] === 'flow' && args[1] === 'sandbox') {
20
+ // Convenience alias for `flow collect --sandbox`.
21
+ args.splice(0, 2)
22
+ args.unshift('--sandbox')
23
+ target = FLOW_COLLECT
19
24
  } else if (args[0] === 'flow') {
20
- console.error('usage: dsh-levels flow collect --package <dir> [--out <file>] [--static] [--previous <dir>]')
25
+ console.error('usage: dsh-levels flow collect --package <dir> [--out <file>] [--static|--sandbox] [--previous <dir>]')
26
+ console.error(' dsh-levels flow sandbox --package <dir> [--out <file>] (= flow collect --sandbox)')
21
27
  process.exit(2)
22
28
  } else if (args[0] === 'audit') {
23
29
  args.shift()
@@ -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'
@@ -34,6 +40,9 @@ import {
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,14 @@ 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
+ --sandbox run the runtime probe under unshare/prlimit/process-group
81
+ timeout with an allowlisted env; producer.kind=sandbox only
82
+ when every isolation item is effective (otherwise
83
+ sandbox-degraded, never runtime)
84
+ --sandbox-no-network force the degraded path (records unshare-missing)
85
+ --sandbox-no-limits force the degraded path (records prlimit-missing)
86
+ --sandbox-nproc <n> RLIMIT_NPROC (default: current task count + headroom)
87
+ --sandbox-as <bytes> RLIMIT_AS (default: 4 GiB)
71
88
  --no-assert-unchanged skip the before/after tree hash comparison (not recommended)
72
89
  --json print the evidence JSON instead of the summary
73
90
  --help show this help
@@ -82,6 +99,8 @@ function parse(argv) {
82
99
  package: null, out: path.resolve('flow-evidence.json'), logs: null, previous: null,
83
100
  dsh: process.env.DSH_BIN || null, timeout: 120_000, static: false, ci: false,
84
101
  assertUnchanged: true, json: false,
102
+ sandbox: false, sandboxNoNetwork: false, sandboxNoLimits: false,
103
+ sandboxNproc: null, sandboxAs: 4 * 1024 ** 3,
85
104
  }
86
105
  for (let i = 0; i < argv.length; i += 1) {
87
106
  const next = () => {
@@ -99,6 +118,11 @@ function parse(argv) {
99
118
  else if (arg === '--timeout') out.timeout = Number(next())
100
119
  else if (arg === '--static' || arg === '--no-runtime') out.static = true
101
120
  else if (arg === '--ci') out.ci = true
121
+ else if (arg === '--sandbox') out.sandbox = true
122
+ else if (arg === '--sandbox-no-network' || arg === '--no-sandbox-network') out.sandboxNoNetwork = true
123
+ else if (arg === '--sandbox-no-limits' || arg === '--no-sandbox-limits') out.sandboxNoLimits = true
124
+ else if (arg === '--sandbox-nproc') out.sandboxNproc = Number(next())
125
+ else if (arg === '--sandbox-as') out.sandboxAs = Number(next())
102
126
  else if (arg === '--no-assert-unchanged') out.assertUnchanged = false
103
127
  else if (arg === '--json') out.json = true
104
128
  else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
@@ -106,6 +130,10 @@ function parse(argv) {
106
130
  }
107
131
  if (!out.package) throw new Error('--package is required')
108
132
  if (!Number.isFinite(out.timeout) || out.timeout < 1000) throw new Error('--timeout must be >= 1000')
133
+ if (out.sandboxNproc !== null && (!Number.isFinite(out.sandboxNproc) || out.sandboxNproc < 2)) throw new Error('--sandbox-nproc must be >= 2')
134
+ if (!Number.isFinite(out.sandboxAs) || out.sandboxAs < 64 * 1024 * 1024) throw new Error('--sandbox-as must be >= 67108864')
135
+ if (out.sandbox && out.static) throw new Error('--sandbox cannot be combined with --static')
136
+ if (out.sandbox && out.ci) throw new Error('--sandbox cannot be combined with --ci (a sandboxed run is sandbox-attested)')
109
137
  return out
110
138
  }
111
139
 
@@ -144,7 +172,7 @@ function writeLog(logDir, name, text) {
144
172
  return { file, sha256: sha256Value(text ?? '') }
145
173
  }
146
174
 
147
- function main() {
175
+ async function main() {
148
176
  const options = parse(process.argv.slice(2))
149
177
  const pkg = path.resolve(options.package)
150
178
  if (!fs.existsSync(path.join(pkg, 'package.json'))) throw new Error(`not a package directory: ${pkg}`)
@@ -162,12 +190,25 @@ function main() {
162
190
  fs.mkdirSync(tmpHome, { recursive: true })
163
191
  fs.mkdirSync(tmpCache, { recursive: true })
164
192
  const childEnv = () => sanitizedEnv({
165
- HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
193
+ HOME: tmpHome, DSH_HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
166
194
  npm_config_cache: tmpCache, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false',
167
195
  })
196
+ // The sandbox is planned before any plugin code runs. `assertNoCredentialEnv`
197
+ // is a hard gate: a surviving token aborts collection instead of booting with it.
198
+ let sandboxPlan = null
168
199
 
169
200
  const warnings = []
170
201
  try {
202
+ if (options.sandbox) {
203
+ assertNoCredentialEnv(childEnv())
204
+ sandboxPlan = buildSandboxPlan({
205
+ env: childEnv(),
206
+ nproc: options.sandboxNproc,
207
+ asBytes: options.sandboxAs,
208
+ force: { network: options.sandboxNoNetwork, limits: options.sandboxNoLimits },
209
+ })
210
+ sandboxPlan.env = assertNoCredentialEnv(childEnv())
211
+ }
171
212
  // 0. Never let our own output/log dirs count as a tree change.
172
213
  const treeExclude = []
173
214
  for (const candidate of [outFile, logDir]) {
@@ -245,13 +286,59 @@ function main() {
245
286
  if (options.static) runtimeSkipped = 'runtime collection disabled (--static)'
246
287
  else if (dshVersion === null) runtimeSkipped = `dsh executable not usable: ${dshBin} (exit ${versionRun.exitCode})`
247
288
  let runtimeVerdict = null
289
+ let sandboxExecution = null
248
290
  if (runtimeSkipped === null) {
249
291
  const args = [JOURNEY_RUNTIME, '--package', copy, '--dsh', dshBin, '--json', '--log-dir', logDir, '--timeout', String(options.timeout)]
250
292
  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)}` }
293
+ let runtimeRun
294
+ if (sandboxPlan) {
295
+ // journey-runtime spawns `dsh plugin add` and the boot as its own
296
+ // children; they inherit this process group and network namespace.
297
+ const argv = sandboxArgv(sandboxPlan, process.execPath, args)
298
+ runtimeRun = await runInProcessTree({
299
+ command: argv[0],
300
+ args: argv.slice(1),
301
+ cwd: copy,
302
+ env: childEnv(),
303
+ timeoutMs: options.timeout * 2 + 60_000,
304
+ })
305
+ sandboxExecution = {
306
+ command: argv,
307
+ exitCode: runtimeRun.exitCode,
308
+ timedOut: runtimeRun.timedOut,
309
+ killed: runtimeRun.killed,
310
+ spawnError: runtimeRun.spawnError ?? null,
311
+ durationMs: runtimeRun.durationMs,
312
+ pid: runtimeRun.pid ?? null,
313
+ processTree: {
314
+ mechanism: runtimeRun.treeKill?.mechanism ?? sandboxPlan.processTreeKill.mechanism,
315
+ signalled: runtimeRun.treeKill?.signalled ?? null,
316
+ cleared: runtimeRun.treeKill?.cleared ?? null,
317
+ },
318
+ }
319
+ if (runtimeRun.spawnError) {
320
+ // The probe passed but the sandboxed spawn itself failed: do not keep
321
+ // calling this run `sandbox`.
322
+ sandboxPlan.degraded = [...new Set([...sandboxPlan.degraded, 'sandbox-execution-failed'])]
323
+ sandboxPlan.mode = 'sandbox-degraded'
324
+ }
325
+ if (runtimeRun.timedOut) {
326
+ runtimeSkipped = `sandboxed runtime probe timed out after ${options.timeout * 2 + 60_000} ms; whole process tree killed (${sandboxExecution.processTree.mechanism}, cleared=${sandboxExecution.processTree.cleared})`
327
+ }
328
+ } else {
329
+ runtimeRun = run(process.execPath, args, { env: childEnv(), timeout: options.timeout * 2 + 60_000 })
330
+ }
331
+ const stdoutText = runtimeRun.stdoutText ?? runtimeRun.stdout ?? ''
332
+ const stderrText = runtimeRun.stderrText ?? runtimeRun.stderr ?? ''
333
+ writeLog(logDir, 'runtime-journey.json', stdoutText)
334
+ if (stderrText) writeLog(logDir, 'runtime-journey.err.log', stderrText)
335
+ if (runtimeSkipped === null) {
336
+ try {
337
+ runtimeVerdict = JSON.parse(stdoutText)
338
+ } catch {
339
+ runtimeSkipped = `runtime probe produced no JSON (exit ${runtimeRun.exitCode}): ${tailLines(stderrText, 5)}`
340
+ }
341
+ }
255
342
  if (runtimeVerdict) {
256
343
  for (const check of runtimeVerdict.checks ?? []) {
257
344
  const mapped = toEvidenceCheck(check, logDir, toEvidencePath)
@@ -267,6 +354,9 @@ function main() {
267
354
  }
268
355
  }
269
356
  }
357
+ if (sandboxPlan) {
358
+ writeLog(logDir, 'sandbox-plan.json', JSON.stringify({ ...sandboxPlan, execution: sandboxExecution }, null, 2))
359
+ }
270
360
  if (runtimeSkipped !== null) warnings.push(`runtime probe skipped: ${runtimeSkipped}`)
271
361
 
272
362
  // 6. Journey statuses + reasons (never upgrade a skip to a pass).
@@ -342,18 +432,40 @@ function main() {
342
432
  ? 'flow-fail'
343
433
  : statuses.includes('blocked') ? 'flow-blocked' : statuses.every(status => status === 'pass') ? 'flow-pass' : 'flow-warn'
344
434
  const producerCommand = [process.execPath, path.relative(STD, fileURLToPath(import.meta.url)).split(path.sep).join('/'), ...process.argv.slice(2)]
435
+ const sandboxExecuted = Boolean(sandboxExecution)
436
+ const sandboxKind = sandboxPlan && sandboxExecuted
437
+ ? (sandboxPlan.degraded.length === 0 ? 'sandbox' : 'sandbox-degraded')
438
+ : null
439
+ const producerKind = sandboxKind ?? (options.ci ? 'ci' : 'local')
440
+ const notClaimed = [...NOT_CLAIMED]
441
+ if (sandboxKind === 'sandbox') {
442
+ const localIndex = notClaimed.findIndex(item => item.startsWith('sandbox confinement'))
443
+ if (localIndex >= 0) notClaimed.splice(localIndex, 1)
444
+ notClaimed.push('OCI/container image, seccomp, and cgroup memory/pids limits (not implemented)', 'signature (not produced)')
445
+ } else if (sandboxKind === 'sandbox-degraded') {
446
+ notClaimed.push(`sandbox confinement (degraded: ${sandboxPlan.degraded.join(', ')})`)
447
+ }
448
+ const claimLevel = sandboxKind === 'sandbox' ? 'sandbox-attested' : options.ci ? 'ci-attested' : 'self-attested'
449
+ const method = sandboxKind === 'sandbox'
450
+ ? 'sandboxed collector: allowlisted env (no tokens), temporary HOME/DSH_HOME, network+user namespace, prlimit nproc/as, process-group SIGKILL timeout'
451
+ : sandboxKind === 'sandbox-degraded'
452
+ ? `sandboxed collector DEGRADED (${sandboxPlan.degraded.join(', ')}): allowlisted env, temporary HOME/DSH_HOME, process-group SIGKILL timeout; every missing isolation item is recorded`
453
+ : 'local-collector: read-only copy, allowlisted child env, no allowBuilds, no exemption'
454
+ const sandbox = sandboxPlan ? { ...sandboxPlan, executed: sandboxExecuted, execution: sandboxExecution } : null
345
455
  const document = finalizeEvidence({
346
456
  schema: FLOW_EVIDENCE_SCHEMA,
347
457
  generatedAt,
348
- producer: { command: producerCommand, at: generatedAt, kind: options.ci ? 'ci' : 'local', runner: 'flow-collect/1' },
458
+ producer: {
459
+ command: producerCommand,
460
+ at: generatedAt,
461
+ kind: producerKind,
462
+ runner: sandboxPlan ? 'flow-collect/2-sandbox' : 'flow-collect/1',
463
+ },
464
+ ...(sandbox ? { sandbox } : {}),
349
465
  subject,
350
466
  host,
351
467
  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
- },
468
+ claims: { claimLevel, notClaimed, method },
357
469
  conclusion,
358
470
  })
359
471
  const verdict = validateFlowEvidence(document)
@@ -385,8 +497,17 @@ function main() {
385
497
  ` totals : ${JSON.stringify(counts)}`,
386
498
  ` conclusion: ${conclusion}`,
387
499
  ` chain : ${document.chain.evidenceHash}`,
500
+ ...(sandboxPlan ? [
501
+ ` sandbox : ${sandboxPlan.mode}${sandboxPlan.degraded.length > 0 ? ` degraded=[${sandboxPlan.degraded.join(', ')}]` : ''}`,
502
+ ` 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'}`,
503
+ ` execution=${sandboxExecuted ? `exit=${sandboxExecution.exitCode} timedOut=${sandboxExecution.timedOut} killed=${sandboxExecution.killed} treeCleared=${sandboxExecution.processTree.cleared}` : 'not-run'}`,
504
+ ] : []),
388
505
  ` logs : ${logDir} (+ ${path.join(outDir, 'SHA256SUMS')})`,
389
- 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
506
+ sandboxKind === 'sandbox'
507
+ ? 'Claims: sandbox-attested (containment only; no OCI image, seccomp, cgroup, signature); not a certification or security audit.'
508
+ : sandboxKind === 'sandbox-degraded'
509
+ ? `Claims: self-attested; sandbox DEGRADED (${sandboxPlan.degraded.join(', ')}). Degraded evidence is never FLOW runtime.`
510
+ : 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
390
511
  ]
391
512
  if (warnings.length > 0) {
392
513
  lines.push('Warnings:')
@@ -486,9 +607,7 @@ function collectHints(journeyId, checks, hints, outDir, logDir, toEvidencePath)
486
607
  }
487
608
  }
488
609
 
489
- try {
490
- main()
491
- } catch (error) {
610
+ main().catch(error => {
492
611
  console.error(`flow-collect: ${error.message}`)
493
612
  process.exitCode = 3
494
- }
613
+ })
@@ -8,6 +8,10 @@
8
8
  * FLOW reads an optional `dsh-flow-evidence/2` file; with no file it reports
9
9
  * the checklist as `not-run` and never invents a pass. v1 schemas and
10
10
  * bare {Jx:{status}} rows are `deprecated` and never count as a pass.
11
+ * Only sandbox-attested evidence (claims.claimLevel =
12
+ * 'sandbox-attested' or producer.kind = 'sandbox') may become
13
+ * `runtime`; self-attested/ci-attested keeps its pass count but stays
14
+ * `not-run`.
11
15
  * BOOST reads an optional LEV evidence file; with no file it reports
12
16
  * `not-measured`. It does not run benchmarks for you.
13
17
  *
@@ -161,6 +165,11 @@ function classifyFlow(evidence) {
161
165
  return {
162
166
  state: 'not-run',
163
167
  schema: null,
168
+ claimLevel: null,
169
+ producerKind: null,
170
+ sandboxMode: null,
171
+ sandboxDegraded: [],
172
+ sandboxAttested: false,
164
173
  deprecated: false,
165
174
  invalid: false,
166
175
  evidenceHash: null,
@@ -177,6 +186,11 @@ function classifyFlow(evidence) {
177
186
  }
178
187
  const base = {
179
188
  schema: verdict.schema,
189
+ claimLevel: verdict.claimLevel ?? null,
190
+ producerKind: verdict.producerKind ?? null,
191
+ sandboxMode: verdict.sandbox?.mode ?? null,
192
+ sandboxDegraded: verdict.sandbox?.degraded ?? [],
193
+ sandboxAttested: verdict.sandboxAttested === true,
180
194
  deprecated: verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy',
181
195
  invalid: false,
182
196
  evidenceHash: null,
@@ -222,19 +236,37 @@ function classifyFlow(evidence) {
222
236
  const passed = ids.filter(id => journeys[id].passes)
223
237
  const failed = ids.filter(id => journeys[id].status === 'fail' && journeys[id].declared !== 'blocked')
224
238
  const blocked = ids.filter(id => journeys[id].declared === 'blocked')
239
+ // Passing journeys are necessary but not sufficient: only sandbox-attested
240
+ // evidence may be reported as runtime. A self-attested/ci-attested document
241
+ // keeps its pass count but never upgrades to runtime. An honest FAIL/BLOCKED
242
+ // stays visible instead of being hidden behind the attestation level.
243
+ const runtimeEligible = base.sandboxAttested
225
244
  const state = failed.length > 0
226
245
  ? 'failed'
227
- : passed.length === ids.length
228
- ? 'runtime'
229
- : blocked.length > 0 ? 'blocked' : 'not-run'
246
+ : blocked.length > 0
247
+ ? 'blocked'
248
+ : runtimeEligible && passed.length === ids.length
249
+ ? 'runtime'
250
+ : 'not-run'
230
251
  const reasons = []
252
+ const degraded = Array.isArray(base.sandboxDegraded) ? base.sandboxDegraded : []
253
+ if (degraded.length > 0) {
254
+ reasons.push(`sandbox-degraded (${degraded.join(', ')}); runtime requires every isolation item effective`)
255
+ }
256
+ if (!runtimeEligible && failed.length === 0 && degraded.length === 0) {
257
+ reasons.push(`${base.claimLevel ?? 'non-sandbox'} evidence; runtime requires sandbox-attested`)
258
+ }
231
259
  if (failed.length > 0) reasons.push(`failed journeys: ${failed.join(', ')}`)
232
260
  if (blocked.length > 0) reasons.push(`blocked journeys (never a pass): ${blocked.join(', ')}`)
233
261
  if (failed.length === 0 && passed.length !== ids.length) {
234
262
  const rest = ids.filter(id => !passed.includes(id) && !blocked.includes(id))
235
263
  if (rest.length > 0) reasons.push(`${passed.length}/8 journeys pass; ${rest.length} static/warn/not-run`)
236
264
  }
237
- if (passed.length === ids.length) reasons.push('all J1-J8 journey checks pass (exitCode 0, assertions held)')
265
+ if (passed.length === ids.length) {
266
+ reasons.push(runtimeEligible
267
+ ? 'all J1-J8 journey checks pass (exitCode 0, assertions held)'
268
+ : 'all J1-J8 journey checks pass but the evidence is not sandbox-attested; not runtime')
269
+ }
238
270
  return {
239
271
  ...base,
240
272
  state,
@@ -338,7 +370,13 @@ function nextActions(entry, flow, boost) {
338
370
  if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
339
371
  for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
340
372
  if (entry.state === 'recognized') actions.push('ENTRY: run with --host to replace static recognition with a real G0-G3 boot')
341
- if (flow.state === 'not-run') actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
373
+ if (flow.sandboxDegraded?.length > 0) {
374
+ actions.push(`FLOW: sandbox degraded (${flow.sandboxDegraded.join(', ')}); re-run where unshare and prlimit are effective; degraded evidence is never runtime`)
375
+ } else if (flow.state === 'not-run' && flow.claimLevel && flow.sandboxAttested !== true) {
376
+ 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)`)
377
+ } else if (flow.state === 'not-run') {
378
+ actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
379
+ }
342
380
  if (flow.state === 'deprecated') actions.push('FLOW: v1/bare evidence is deprecated; collect a dsh-flow-evidence/2 file (spec/flow-evidence.schema.json)')
343
381
  if (flow.state === 'invalid') actions.push('FLOW: flow evidence failed structural validation; fix the listed fields or recollect')
344
382
  if (flow.state === 'failed') actions.push(`FLOW: fix failed journeys: ${Object.entries(flow.journeys).filter(([, row]) => row.status === 'fail').map(([id]) => id).join(', ')}`)
@@ -0,0 +1,21 @@
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 testDir = join(here, '..', 'test')
11
+ if (!existsSync(testDir)) {
12
+ console.log('checker tests are not shipped in the npm package; nothing to run')
13
+ process.exit(0)
14
+ }
15
+ const files = readdirSync(testDir).filter((f) => f.endsWith('.test.mjs')).map((f) => join(testDir, f))
16
+ if (files.length === 0) {
17
+ console.log('no test files found; nothing to run')
18
+ process.exit(0)
19
+ }
20
+ const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' })
21
+ process.exit(r.status ?? 1)
@@ -13,6 +13,15 @@
13
13
  * status alone): at least one check, every check has exitCode 0, at least one
14
14
  * observed (non-static) check, and every observed check's assertions hold.
15
15
  * A journey declared `warn`/`blocked`/`na`/`static`/`not-run` never counts.
16
+ *
17
+ * Runtime rule: passing journeys are necessary but not sufficient. Only
18
+ * sandbox-attested evidence (`claims.claimLevel === 'sandbox-attested'` or
19
+ * `producer.kind === 'sandbox'`) may be reported as `flow.state = 'runtime'`.
20
+ * `self-attested`/`ci-attested` documents keep their `passed` count but stay
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.
16
25
  */
17
26
  import crypto from 'node:crypto'
18
27
  import fs from 'node:fs'
@@ -163,6 +172,23 @@ export function journeyCountsAsPass(journey) {
163
172
  return declared === null || declared === 'pass'
164
173
  }
165
174
 
175
+ /**
176
+ * Runtime eligibility. A local collector run is `self-attested`
177
+ * (`ci-attested` with `--ci`): structurally valid, but it does not prove that
178
+ * commands ran, let alone in a sandbox. Only `claims.claimLevel ===
179
+ * 'sandbox-attested'` or the producer-side equivalent `producer.kind ===
180
+ * 'sandbox'` may be reported as FLOW runtime.
181
+ */
182
+ export function isSandboxAttested(document) {
183
+ if (!isPlainObject(document)) return false
184
+ // Degradation is disqualifying even if a claim was left at sandbox-attested:
185
+ // the producer admits an isolation item was missing.
186
+ if (document.producer?.kind === 'sandbox-degraded') return false
187
+ if (document.sandbox?.mode === 'sandbox-degraded') return false
188
+ if (Array.isArray(document.sandbox?.degraded) && document.sandbox.degraded.length > 0) return false
189
+ return document.claims?.claimLevel === 'sandbox-attested' || document.producer?.kind === 'sandbox'
190
+ }
191
+
166
192
  function emptyVerdict(kind, schema, errors) {
167
193
  return {
168
194
  kind,
@@ -176,6 +202,9 @@ function emptyVerdict(kind, schema, errors) {
176
202
  host: null,
177
203
  generatedAt: null,
178
204
  conclusion: null,
205
+ claimLevel: null,
206
+ producerKind: null,
207
+ sandboxAttested: false,
179
208
  }
180
209
  }
181
210
 
@@ -247,8 +276,8 @@ function validateV2(document) {
247
276
  if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
248
277
  errors.push('producer.command must be a non-empty array of strings')
249
278
  }
250
- if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox'].includes(document.producer.kind)) {
251
- errors.push('producer.kind must be local, ci, or sandbox')
279
+ if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox', 'sandbox-degraded'].includes(document.producer.kind)) {
280
+ errors.push('producer.kind must be local, ci, sandbox, or sandbox-degraded')
252
281
  }
253
282
  for (const key of ['runner', 'actor', 'runUrl']) {
254
283
  if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
@@ -271,6 +300,12 @@ function validateV2(document) {
271
300
  }
272
301
  }
273
302
  if ('source' in subject) requireString(subject.source, 'subject.source')
303
+ if (subject.treeStable === false) {
304
+ errors.push('subject.treeStable is false: the package tree changed during collection')
305
+ }
306
+ if ('treeSha256After' in subject && subject.treeSha256After && subject.treeSha256After !== subject.treeSha256 && subject.treeStable !== false) {
307
+ errors.push('subject.treeSha256After differs from subject.treeSha256: the package tree changed during collection')
308
+ }
274
309
  }
275
310
 
276
311
  if (requireObject(document.host, 'host')) {
@@ -323,6 +358,8 @@ function validateV2(document) {
323
358
  if ('method' in claims) requireString(claims.method, 'claims.method')
324
359
  }
325
360
 
361
+ validateSandbox(document, errors)
362
+
326
363
  if (requireObject(document.chain, 'chain')) {
327
364
  requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
328
365
  for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
@@ -366,6 +403,74 @@ function validateV2(document) {
366
403
  host: isPlainObject(document.host) ? document.host : null,
367
404
  generatedAt: document.generatedAt ?? null,
368
405
  conclusion: document.conclusion ?? null,
406
+ claimLevel: isPlainObject(document.claims) ? document.claims.claimLevel ?? null : null,
407
+ producerKind: isPlainObject(document.producer) ? document.producer.kind ?? null : null,
408
+ sandbox: isPlainObject(document.sandbox)
409
+ ? {
410
+ mode: document.sandbox.mode ?? null,
411
+ degraded: Array.isArray(document.sandbox.degraded) ? [...document.sandbox.degraded] : [],
412
+ }
413
+ : null,
414
+ sandboxAttested: isSandboxAttested(document),
415
+ }
416
+
417
+ function validateSandbox(doc, errors) {
418
+ const kind = isPlainObject(doc.producer) ? doc.producer.kind ?? null : null
419
+ const declaredSandbox = 'sandbox' in doc
420
+ if (!declaredSandbox && kind !== 'sandbox' && kind !== 'sandbox-degraded') return
421
+ if (!declaredSandbox) {
422
+ errors.push('sandbox is required when producer.kind is sandbox or sandbox-degraded')
423
+ return
424
+ }
425
+ const sandbox = doc.sandbox
426
+ if (!isPlainObject(sandbox)) {
427
+ errors.push('sandbox must be an object')
428
+ return
429
+ }
430
+ if (!['sandbox', 'sandbox-degraded'].includes(sandbox.mode)) {
431
+ errors.push('sandbox.mode must be sandbox or sandbox-degraded')
432
+ }
433
+ const degraded = Array.isArray(sandbox.degraded) ? sandbox.degraded : null
434
+ if (!degraded || degraded.some(item => typeof item !== 'string' || item.length === 0)) {
435
+ errors.push('sandbox.degraded must be an array of non-empty strings')
436
+ }
437
+ const list = degraded ?? []
438
+ if (sandbox.mode === 'sandbox' && list.length > 0) errors.push('sandbox.mode is sandbox but sandbox.degraded is not empty')
439
+ if (sandbox.mode === 'sandbox-degraded' && list.length === 0) errors.push('sandbox.mode is sandbox-degraded but sandbox.degraded is empty')
440
+ if ('env' in sandbox) {
441
+ if (!isPlainObject(sandbox.env)) errors.push('sandbox.env must be an object')
442
+ else if (typeof sandbox.env.absent !== 'boolean') errors.push('sandbox.env.absent must be a boolean')
443
+ }
444
+ if ('network' in sandbox && (!isPlainObject(sandbox.network) || typeof sandbox.network.isolated !== 'boolean')) {
445
+ errors.push('sandbox.network.isolated must be a boolean')
446
+ }
447
+ if ('limits' in sandbox) {
448
+ if (!isPlainObject(sandbox.limits)) errors.push('sandbox.limits must be an object')
449
+ else {
450
+ if ('available' in sandbox.limits && typeof sandbox.limits.available !== 'boolean') errors.push('sandbox.limits.available must be a boolean')
451
+ if ('applied' in sandbox.limits && (!Array.isArray(sandbox.limits.applied) || sandbox.limits.applied.some(item => typeof item !== 'string'))) {
452
+ errors.push('sandbox.limits.applied must be an array of strings')
453
+ }
454
+ }
455
+ }
456
+ if ('home' in sandbox && (!isPlainObject(sandbox.home) || typeof sandbox.home.isolated !== 'boolean')) {
457
+ errors.push('sandbox.home.isolated must be a boolean')
458
+ }
459
+ if ('executed' in sandbox && typeof sandbox.executed !== 'boolean') errors.push('sandbox.executed must be a boolean')
460
+ if (kind === 'sandbox') {
461
+ if (sandbox.mode !== 'sandbox') errors.push('producer.kind=sandbox requires sandbox.mode=sandbox')
462
+ if (list.length > 0) errors.push('producer.kind=sandbox requires an empty sandbox.degraded list')
463
+ if (sandbox.executed !== true) errors.push('producer.kind=sandbox requires sandbox.executed=true')
464
+ if (sandbox.env?.absent !== true) errors.push('producer.kind=sandbox requires sandbox.env.absent=true')
465
+ if (sandbox.network?.isolated !== true) errors.push('producer.kind=sandbox requires sandbox.network.isolated=true')
466
+ if (sandbox.limits?.available !== true) errors.push('producer.kind=sandbox requires sandbox.limits.available=true')
467
+ if (sandbox.home?.isolated !== true) errors.push('producer.kind=sandbox requires sandbox.home.isolated=true')
468
+ }
469
+ if (kind === 'sandbox-degraded') {
470
+ if (sandbox.mode !== 'sandbox-degraded') errors.push('producer.kind=sandbox-degraded requires sandbox.mode=sandbox-degraded')
471
+ if (list.length === 0) errors.push('producer.kind=sandbox-degraded requires at least one sandbox.degraded reason')
472
+ if (doc.claims?.claimLevel === 'sandbox-attested') errors.push('producer.kind=sandbox-degraded cannot claim sandbox-attested')
473
+ }
369
474
  }
370
475
 
371
476
  function validateCheck(check, label) {
@@ -7,5 +7,5 @@ export { buildReport, formatText, formatSarif, writeReport } 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, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
10
+ export { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, evidenceHash, finalizeEvidence, journeyAggregate, isSandboxAttested, treeSha256, validateFlowEvidence } from './flow-evidence.mjs'
11
11
  export { sanitizedEnv, scrubProcessEnv, isSecretEnvName, isAllowedEnvName, envPolicy } from './sandbox-env.mjs'