dsh-standards 1.2.1 → 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.
@@ -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`:结构自洽 + 绑定此包 + 未过期,**不证明真跑过**,也不是沙箱、认证或安全审计。`level-audit` 只有见到 `claims.claimLevel=sandbox-attested` 或 `producer.kind=sandbox` 才判 `runtime`;self/ci 证据保留 `passed` 计数但记 `not-run`。
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
+ })
@@ -167,6 +167,8 @@ function classifyFlow(evidence) {
167
167
  schema: null,
168
168
  claimLevel: null,
169
169
  producerKind: null,
170
+ sandboxMode: null,
171
+ sandboxDegraded: [],
170
172
  sandboxAttested: false,
171
173
  deprecated: false,
172
174
  invalid: false,
@@ -186,6 +188,8 @@ function classifyFlow(evidence) {
186
188
  schema: verdict.schema,
187
189
  claimLevel: verdict.claimLevel ?? null,
188
190
  producerKind: verdict.producerKind ?? null,
191
+ sandboxMode: verdict.sandbox?.mode ?? null,
192
+ sandboxDegraded: verdict.sandbox?.degraded ?? [],
189
193
  sandboxAttested: verdict.sandboxAttested === true,
190
194
  deprecated: verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy',
191
195
  invalid: false,
@@ -245,7 +249,11 @@ function classifyFlow(evidence) {
245
249
  ? 'runtime'
246
250
  : 'not-run'
247
251
  const reasons = []
248
- if (!runtimeEligible && failed.length === 0) {
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) {
249
257
  reasons.push(`${base.claimLevel ?? 'non-sandbox'} evidence; runtime requires sandbox-attested`)
250
258
  }
251
259
  if (failed.length > 0) reasons.push(`failed journeys: ${failed.join(', ')}`)
@@ -362,7 +370,9 @@ function nextActions(entry, flow, boost) {
362
370
  if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
363
371
  for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
364
372
  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) {
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) {
366
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)`)
367
377
  } else if (flow.state === 'not-run') {
368
378
  actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
@@ -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)
@@ -19,6 +19,9 @@
19
19
  * `producer.kind === 'sandbox'`) may be reported as `flow.state = 'runtime'`.
20
20
  * `self-attested`/`ci-attested` documents keep their `passed` count but stay
21
21
  * `not-run`, because structural validation cannot prove where commands ran.
22
+ * A `sandbox-degraded` producer (any isolation item missing) is never runtime,
23
+ * and a document whose `subject.treeStable` is false is structurally invalid:
24
+ * a collector that changed the inspected tree has no trustworthy evidence.
22
25
  */
23
26
  import crypto from 'node:crypto'
24
27
  import fs from 'node:fs'
@@ -178,6 +181,11 @@ export function journeyCountsAsPass(journey) {
178
181
  */
179
182
  export function isSandboxAttested(document) {
180
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
181
189
  return document.claims?.claimLevel === 'sandbox-attested' || document.producer?.kind === 'sandbox'
182
190
  }
183
191
 
@@ -268,8 +276,8 @@ function validateV2(document) {
268
276
  if (!Array.isArray(command) || command.length === 0 || command.some(item => typeof item !== 'string')) {
269
277
  errors.push('producer.command must be a non-empty array of strings')
270
278
  }
271
- if ('kind' in document.producer && document.producer.kind !== null && !['local', 'ci', 'sandbox'].includes(document.producer.kind)) {
272
- errors.push('producer.kind must be local, ci, or sandbox')
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')
273
281
  }
274
282
  for (const key of ['runner', 'actor', 'runUrl']) {
275
283
  if (key in document.producer && document.producer[key] !== null) requireString(document.producer[key], `producer.${key}`)
@@ -292,6 +300,12 @@ function validateV2(document) {
292
300
  }
293
301
  }
294
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
+ }
295
309
  }
296
310
 
297
311
  if (requireObject(document.host, 'host')) {
@@ -344,6 +358,8 @@ function validateV2(document) {
344
358
  if ('method' in claims) requireString(claims.method, 'claims.method')
345
359
  }
346
360
 
361
+ validateSandbox(document, errors)
362
+
347
363
  if (requireObject(document.chain, 'chain')) {
348
364
  requireHash(document.chain.evidenceHash, 'chain.evidenceHash')
349
365
  for (const key of Object.keys(document.chain)) if (key !== 'evidenceHash') errors.push(`chain.${key} is not allowed`)
@@ -389,9 +405,74 @@ function validateV2(document) {
389
405
  conclusion: document.conclusion ?? null,
390
406
  claimLevel: isPlainObject(document.claims) ? document.claims.claimLevel ?? null : null,
391
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,
392
414
  sandboxAttested: isSandboxAttested(document),
393
415
  }
394
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
+ }
474
+ }
475
+
395
476
  function validateCheck(check, label) {
396
477
  if (!isPlainObject(check)) {
397
478
  errors.push(`${label} must be an object`)