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
@@ -53,8 +53,13 @@ function uniqueEvidence(...lists) {
53
53
  *
54
54
  * One entry is produced per (check.id, detail.item) pair with FAIL/WARN — or,
55
55
  * for a FAIL/WARN check with no live details, one entry keyed by check.clause.
56
- * Duplicate details are merged: severity takes the worst value, evidence is
57
- * unioned, and the specific reasons are joined for diagnostics.
56
+ * `gate` is the check's final result (PASS/WARN/FAIL) after report adjustments
57
+ * such as the ENTRY static-claim downgrade; `severity` follows that gate, so a
58
+ * FAIL detail under a check downgraded to WARN yields a WARN advice entry. The
59
+ * detail's own reason stays in `reason` for diagnostics.
60
+ *
61
+ * Duplicate details are merged: evidence is unioned, occurrences counted, and
62
+ * the specific reasons are joined for diagnostics.
58
63
  *
59
64
  * @param {object} report dsh-conformance/1 report
60
65
  * @param {object} rules flat key→rule map (also accepts a { rules } or { items } wrapper)
@@ -66,15 +71,15 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
66
71
  const version = versionOf(rules)
67
72
  const merged = new Map()
68
73
 
69
- const add = (check, key, severity, reason, evidence) => {
70
- if (typeof key !== 'string' || key === '' || !LIVE.has(severity)) return false
74
+ const add = (check, key, gate, reason, evidence) => {
75
+ if (typeof key !== 'string' || key === '' || !LIVE.has(gate)) return false
71
76
  const id = `${check.id}|${key}`
72
77
  const current = merged.get(id)
73
78
  if (!current) {
74
79
  merged.set(id, {
75
80
  check,
76
81
  key,
77
- severity,
82
+ gate,
78
83
  reasons: typeof reason === 'string' && reason !== '' ? [reason] : [],
79
84
  evidence: uniqueEvidence(evidence),
80
85
  occurrences: 1,
@@ -83,7 +88,6 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
83
88
  }
84
89
  current.occurrences += 1
85
90
  if (typeof reason === 'string' && reason !== '' && !current.reasons.includes(reason)) current.reasons.push(reason)
86
- if (SEVERITY_RANK[severity] > SEVERITY_RANK[current.severity]) current.severity = severity
87
91
  current.evidence = uniqueEvidence(current.evidence, evidence)
88
92
  return true
89
93
  }
@@ -92,7 +96,7 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
92
96
  const liveDetails = (Array.isArray(check.details) ? check.details : [])
93
97
  .filter(detail => detail && LIVE.has(detail.result))
94
98
  let added = 0
95
- for (const detail of liveDetails) if (add(check, detail.item, detail.result, detail.reason, detail.evidence)) added += 1
99
+ for (const detail of liveDetails) if (add(check, detail.item, check.result, detail.reason, detail.evidence)) added += 1
96
100
  // A runtime FAIL (DSC-04 / DSC-14) has no details; its clause is the only
97
101
  // joinable key. Only use the fallback when no joinable detail was produced,
98
102
  // so a specific detail always wins over the check-level summary.
@@ -106,7 +110,8 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
106
110
  const rule = table[entry.key] ?? null
107
111
  const item = {
108
112
  key: entry.key,
109
- severity: entry.severity,
113
+ gate: entry.gate,
114
+ severity: entry.gate,
110
115
  title: rule?.title ?? entry.key,
111
116
  why: rule?.why ?? null,
112
117
  fix: rule?.fix ?? null,
@@ -124,7 +129,7 @@ export function joinAdvice(report, rules, docsBase = DOCS_BASE) {
124
129
 
125
130
  // FAIL before WARN, then key/source for deterministic output.
126
131
  advice.sort((a, b) => (
127
- (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)
132
+ (SEVERITY_RANK[b.gate] ?? 0) - (SEVERITY_RANK[a.gate] ?? 0)
128
133
  || a.key.localeCompare(b.key)
129
134
  || a.source.localeCompare(b.source)
130
135
  ))
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Sandbox execution for FLOW evidence collection.
3
+ *
4
+ * `flow collect --sandbox` runs the runtime probe (which executes
5
+ * `dsh plugin add` and boots the tree) behind a containment layer:
6
+ *
7
+ * env allowlist from `sandbox-env.mjs`; GH_TOKEN / NPM_TOKEN /
8
+ * GITHUB_TOKEN are asserted absent, and any secret-shaped name is
9
+ * reported rather than silently forwarded;
10
+ * home caller-supplied temporary HOME/DSH_HOME/TMPDIR (never the real
11
+ * profile);
12
+ * network `unshare -n` when permitted, falling back to
13
+ * `unshare -Urn` (user+network namespace); if neither works the run
14
+ * continues degraded and records `unshare-missing`;
15
+ * limits `prlimit --nproc --as` when util-linux prlimit is available; the
16
+ * probe reads `/proc/self/limits` so only limits that actually took
17
+ * effect are recorded;
18
+ * timeout the child is spawned as its own process-group leader and the
19
+ * whole group receives SIGKILL, so `dsh` grandchildren die too
20
+ * (Windows: `taskkill /T /F`).
21
+ *
22
+ * This is containment, not a security boundary: no seccomp, no cgroup memory/
23
+ * pids accounting, no OCI image. Evidence is only allowed to call itself
24
+ * `sandbox` when every item above is effective; any missing item switches the
25
+ * producer to `sandbox-degraded`, which `level-audit` never reports as runtime.
26
+ */
27
+ import fs from 'node:fs'
28
+ import { spawn, spawnSync } from 'node:child_process'
29
+ import { isSecretEnvName, sanitizedEnv } from './sandbox-env.mjs'
30
+
31
+ export const SANDBOX_REPORT_SCHEMA = 'dsh-sandbox-report/1'
32
+ export const SANDBOX_DEGRADED = Object.freeze({
33
+ unshare: 'unshare-missing',
34
+ prlimit: 'prlimit-missing',
35
+ env: 'env-token-present',
36
+ home: 'home-not-isolated',
37
+ treeKill: 'process-tree-kill-unavailable',
38
+ execution: 'sandbox-execution-failed',
39
+ })
40
+
41
+ /** Names that must never reach a child that may import plugin code. */
42
+ export const CREDENTIAL_ENV_NAMES = Object.freeze(['GH_TOKEN', 'NPM_TOKEN', 'GITHUB_TOKEN'])
43
+
44
+ /** Inherited or caller-supplied names that look like credentials. */
45
+ export function credentialEnvOffenders(env = process.env, names = CREDENTIAL_ENV_NAMES) {
46
+ const offenders = new Set()
47
+ for (const name of names) {
48
+ if (env && Object.prototype.hasOwnProperty.call(env, name) && env[name] !== undefined && env[name] !== null) {
49
+ offenders.add(name)
50
+ }
51
+ }
52
+ for (const name of Object.keys(env ?? {})) {
53
+ if (isSecretEnvName(name)) offenders.add(name)
54
+ }
55
+ return [...offenders].sort()
56
+ }
57
+
58
+ /** Hard gate: refuse to boot a plugin when a credential survived scrubbing. */
59
+ export function assertNoCredentialEnv(env = process.env, names = CREDENTIAL_ENV_NAMES) {
60
+ const offenders = credentialEnvOffenders(env, names)
61
+ if (offenders.length > 0) {
62
+ throw new Error(`sandbox env assertion failed: ${offenders.join(', ')} present in the child environment`)
63
+ }
64
+ return { policy: 'allowlist', checked: [...names], absent: true, offenders: [] }
65
+ }
66
+
67
+ /** Count tasks (processes + threads) owned by this uid, for a safe RLIMIT_NPROC. */
68
+ export function userTaskCount(uid = typeof process.getuid === 'function' ? process.getuid() : null) {
69
+ if (process.platform !== 'linux' || uid === null) return 0
70
+ let tasks = 0
71
+ try {
72
+ for (const entry of fs.readdirSync('/proc')) {
73
+ if (!/^\d+$/.test(entry)) continue
74
+ try {
75
+ if (fs.statSync(`/proc/${entry}`).uid !== uid) continue
76
+ const status = fs.readFileSync(`/proc/${entry}/status`, 'utf8')
77
+ const match = /^Threads:\s+(\d+)$/m.exec(status)
78
+ tasks += match ? Number(match[1]) : 1
79
+ } catch { /* process vanished */ }
80
+ }
81
+ } catch { return 0 }
82
+ return tasks
83
+ }
84
+
85
+ export function defaultNproc() {
86
+ // RLIMIT_NPROC counts tasks, so a fixed small value (e.g. 64) breaks Node on
87
+ // a busy desktop. Keep headroom above the current task count.
88
+ return Math.max(1024, userTaskCount() + 512)
89
+ }
90
+
91
+ function probeFailure(error) {
92
+ if (!error) return null
93
+ return String(error.message ?? error).slice(0, 300)
94
+ }
95
+
96
+ /**
97
+ * Try a real network namespace. `unshare -n` needs CAP_SYS_ADMIN; in an
98
+ * unprivileged container/WSL that fails, so `unshare -Urn` (user namespace +
99
+ * network namespace) is the fallback. `true` is the probe target so no plugin
100
+ * code runs while deciding.
101
+ */
102
+ export function probeNetworkIsolation({ platform = process.platform, unshare = 'unshare', timeoutMs = 5_000 } = {}) {
103
+ if (platform === 'win32') {
104
+ return {
105
+ isolated: false,
106
+ degraded: SANDBOX_DEGRADED.unshare,
107
+ reason: 'unshare is not available on win32',
108
+ attempts: [],
109
+ }
110
+ }
111
+ const attempts = []
112
+ for (const args of [['-n'], ['-Urn']]) {
113
+ const command = [unshare, ...args, 'true']
114
+ const result = spawnSync(unshare, [...args, 'true'], {
115
+ encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], timeout: timeoutMs, env: sanitizedEnv(),
116
+ })
117
+ const exitCode = Number.isInteger(result.status) ? result.status : null
118
+ const attempt = {
119
+ command,
120
+ exitCode,
121
+ signal: result.signal ?? null,
122
+ error: probeFailure(result.error),
123
+ stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
124
+ }
125
+ attempts.push(attempt)
126
+ if (!result.error && result.status === 0) {
127
+ return {
128
+ isolated: true,
129
+ mechanism: args[0] === '-n' ? 'unshare -n (network namespace)' : 'unshare -Urn (user+network namespace)',
130
+ bin: unshare,
131
+ args,
132
+ probe: attempt,
133
+ attempts,
134
+ }
135
+ }
136
+ }
137
+ return {
138
+ isolated: false,
139
+ degraded: SANDBOX_DEGRADED.unshare,
140
+ reason: 'unshare -n and unshare -Urn both failed; no network namespace',
141
+ attempts,
142
+ }
143
+ }
144
+
145
+ function readLimitsProbe() {
146
+ return [
147
+ "const t = require('fs').readFileSync('/proc/self/limits', 'utf8')",
148
+ "const pick = (label) => { const m = new RegExp('^' + label + '\\\\s+(\\\\d+)', 'm').exec(t); return m ? Number(m[1]) : null }",
149
+ "console.log(JSON.stringify({ nproc: pick('Max processes'), as: pick('Max address space') }))",
150
+ ].join(';')
151
+ }
152
+
153
+ /**
154
+ * Apply and verify `prlimit --nproc --as`. The probe prints the child's own
155
+ * `/proc/self/limits`, so `applied` lists effects, not intentions.
156
+ */
157
+ export function probeLimits({
158
+ platform = process.platform,
159
+ prlimit = 'prlimit',
160
+ nproc = null,
161
+ asBytes = 4 * 1024 ** 3,
162
+ node = process.execPath,
163
+ timeoutMs = 15_000,
164
+ } = {}) {
165
+ if (platform === 'win32') {
166
+ return { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'prlimit is a util-linux tool; win32 has no equivalent here', applied: [] }
167
+ }
168
+ const requestedNproc = Number.isFinite(nproc) && nproc > 0 ? Math.trunc(nproc) : defaultNproc()
169
+ const requestedAs = Number.isFinite(asBytes) && asBytes > 0 ? Math.trunc(asBytes) : 4 * 1024 ** 3
170
+ const command = [prlimit, `--nproc=${requestedNproc}`, `--as=${requestedAs}`, '--', node, '-e', readLimitsProbe()]
171
+ const result = spawnSync(prlimit, command.slice(1), {
172
+ encoding: 'utf8', timeout: timeoutMs, env: sanitizedEnv(),
173
+ })
174
+ const exitCode = Number.isInteger(result.status) ? result.status : null
175
+ let observed = null
176
+ try {
177
+ const line = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop()
178
+ observed = line ? JSON.parse(line) : null
179
+ } catch { observed = null }
180
+ const applied = []
181
+ if (observed?.nproc === requestedNproc) applied.push('nproc')
182
+ if (observed?.as === requestedAs) applied.push('as')
183
+ const available = !result.error && exitCode === 0 && applied.length === 2
184
+ return {
185
+ available,
186
+ ...(available ? {} : { degraded: SANDBOX_DEGRADED.prlimit }),
187
+ bin: prlimit,
188
+ nproc: requestedNproc,
189
+ asBytes: requestedAs,
190
+ applied,
191
+ observed,
192
+ probe: {
193
+ command,
194
+ exitCode,
195
+ signal: result.signal ?? null,
196
+ error: probeFailure(result.error),
197
+ stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
198
+ },
199
+ ...(available ? {} : { reason: 'prlimit did not apply and verify nproc+as' }),
200
+ }
201
+ }
202
+
203
+ export function processTreeKillCapability(platform = process.platform) {
204
+ if (platform === 'win32') {
205
+ const probe = spawnSync('taskkill', ['/?'], { stdio: 'ignore', timeout: 5_000 })
206
+ const available = !probe.error
207
+ return {
208
+ available,
209
+ mechanism: available ? 'taskkill /T /F' : 'taskkill-missing',
210
+ ...(available ? {} : { degraded: SANDBOX_DEGRADED.treeKill, reason: 'taskkill is unavailable' }),
211
+ }
212
+ }
213
+ return { available: true, mechanism: 'process-group SIGKILL (detached child, kill(-pgid))' }
214
+ }
215
+
216
+ /**
217
+ * Build the containment report for one collection. Degraded reasons are
218
+ * deduplicated; `mode` is `sandbox` only when the list is empty.
219
+ */
220
+ export function buildSandboxPlan({
221
+ platform = process.platform,
222
+ env = process.env,
223
+ unshare = 'unshare',
224
+ prlimit = 'prlimit',
225
+ nproc = null,
226
+ asBytes = 4 * 1024 ** 3,
227
+ homeIsolated = true,
228
+ force = {},
229
+ } = {}) {
230
+ const degraded = []
231
+ const envOffenders = credentialEnvOffenders(env)
232
+ if (envOffenders.length > 0) degraded.push(SANDBOX_DEGRADED.env)
233
+ if (!homeIsolated) degraded.push(SANDBOX_DEGRADED.home)
234
+
235
+ const network = force.network
236
+ ? { isolated: false, degraded: SANDBOX_DEGRADED.unshare, reason: 'network isolation disabled by caller', attempts: [] }
237
+ : probeNetworkIsolation({ platform, unshare })
238
+ if (!network.isolated) degraded.push(network.degraded ?? SANDBOX_DEGRADED.unshare)
239
+
240
+ const limits = force.limits
241
+ ? { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'rlimits disabled by caller', applied: [] }
242
+ : probeLimits({ platform, prlimit, nproc, asBytes })
243
+ if (!limits.available) degraded.push(limits.degraded ?? SANDBOX_DEGRADED.prlimit)
244
+
245
+ const processTreeKill = processTreeKillCapability(platform)
246
+ if (!processTreeKill.available) degraded.push(processTreeKill.degraded ?? SANDBOX_DEGRADED.treeKill)
247
+
248
+ const unique = [...new Set(degraded)]
249
+ return {
250
+ schema: SANDBOX_REPORT_SCHEMA,
251
+ mode: unique.length === 0 ? 'sandbox' : 'sandbox-degraded',
252
+ platform,
253
+ env: {
254
+ policy: 'allowlist',
255
+ checked: [...CREDENTIAL_ENV_NAMES],
256
+ absent: envOffenders.length === 0,
257
+ offenders: envOffenders,
258
+ },
259
+ home: { isolated: homeIsolated, mode: 'temporary HOME/DSH_HOME/TMPDIR' },
260
+ network,
261
+ limits,
262
+ processTreeKill,
263
+ degraded: unique,
264
+ note: 'containment only: no seccomp, no cgroup memory/pids accounting, no OCI image; read access is not restricted',
265
+ }
266
+ }
267
+
268
+ /** Wrap `command args...` in the effective prlimit + unshare prefixes. */
269
+ export function sandboxArgv(plan, command, args = []) {
270
+ let argv = [command, ...args]
271
+ if (plan?.limits?.available) {
272
+ argv = [plan.limits.bin, `--nproc=${plan.limits.nproc}`, `--as=${plan.limits.asBytes}`, '--', ...argv]
273
+ }
274
+ if (plan?.network?.isolated) {
275
+ argv = [plan.network.bin, ...plan.network.args, ...argv]
276
+ }
277
+ return argv
278
+ }
279
+
280
+ function groupAlive(pgid, platform) {
281
+ try {
282
+ process.kill(platform === 'win32' ? pgid : -pgid, 0)
283
+ return true
284
+ } catch (error) {
285
+ return error?.code === 'EPERM'
286
+ }
287
+ }
288
+
289
+ function waitForGroupExit(pgid, platform, timeoutMs) {
290
+ return new Promise((resolve) => {
291
+ const deadline = Date.now() + timeoutMs
292
+ const tick = () => {
293
+ if (!groupAlive(pgid, platform)) { resolve(true); return }
294
+ if (Date.now() >= deadline) { resolve(false); return }
295
+ setTimeout(tick, 50)
296
+ }
297
+ tick()
298
+ })
299
+ }
300
+
301
+ /** Terminate the child *and its whole process group* (never just the parent). */
302
+ export function killProcessTree(pid, { platform = process.platform, signal = 'SIGKILL' } = {}) {
303
+ if (!pid) return { mechanism: 'none', signalled: false, pid: null }
304
+ if (platform === 'win32') {
305
+ const result = spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 10_000 })
306
+ return { mechanism: 'taskkill /pid <pid> /T /F', signalled: !result.error && result.status === 0, pid, exitCode: result.status ?? null }
307
+ }
308
+ let signalled = false
309
+ try { process.kill(-pid, signal); signalled = true } catch { /* group already gone */ }
310
+ try { process.kill(pid, signal); signalled = true } catch { /* parent already gone */ }
311
+ return { mechanism: `process-group ${signal}`, signalled, pid, pgid: pid }
312
+ }
313
+
314
+ /**
315
+ * Spawn and supervise one command. On timeout the process group is SIGKILLed
316
+ * and `treeKill.cleared` is verified before resolving.
317
+ */
318
+ export function runInProcessTree({
319
+ command,
320
+ args = [],
321
+ cwd = process.cwd(),
322
+ env = sanitizedEnv(),
323
+ timeoutMs = 120_000,
324
+ platform = process.platform,
325
+ maxBufferBytes = 8 * 1024 * 1024,
326
+ killGraceMs = 2_000,
327
+ } = {}) {
328
+ return new Promise((resolve) => {
329
+ const startedAt = new Date().toISOString()
330
+ const t0 = Date.now()
331
+ let child
332
+ try {
333
+ child = spawn(command, args, {
334
+ cwd,
335
+ env,
336
+ stdio: ['ignore', 'pipe', 'pipe'],
337
+ detached: platform !== 'win32',
338
+ windowsHide: true,
339
+ })
340
+ } catch (error) {
341
+ resolve({
342
+ command: [command, ...args], exitCode: null, signal: null, killed: false, timedOut: false,
343
+ stdout: '', stderr: `spawn error: ${error.message}`, startedAt, endedAt: new Date().toISOString(),
344
+ durationMs: Date.now() - t0, treeKill: null, spawnError: error.message,
345
+ })
346
+ return
347
+ }
348
+ let stdout = ''
349
+ let stderr = ''
350
+ let stdoutBytes = 0
351
+ let stderrBytes = 0
352
+ let timedOut = false
353
+ let treeKill = null
354
+ let settled = false
355
+ let timer = null
356
+ const append = (chunk, stream) => {
357
+ const text = String(chunk)
358
+ if (stream === 'stdout') {
359
+ if (stdoutBytes < maxBufferBytes) stdout += text.slice(0, maxBufferBytes - stdoutBytes)
360
+ stdoutBytes += Buffer.byteLength(text)
361
+ } else {
362
+ if (stderrBytes < maxBufferBytes) stderr += text.slice(0, maxBufferBytes - stderrBytes)
363
+ stderrBytes += Buffer.byteLength(text)
364
+ }
365
+ }
366
+ const finalize = async (code, signal) => {
367
+ if (settled) return
368
+ settled = true
369
+ clearTimeout(timer)
370
+ if (treeKill) {
371
+ treeKill.cleared = await waitForGroupExit(child.pid, platform, killGraceMs)
372
+ }
373
+ resolve({
374
+ command: [command, ...args],
375
+ exitCode: Number.isInteger(code) ? code : timedOut ? 124 : null,
376
+ signal: signal ?? null,
377
+ killed: Boolean(treeKill),
378
+ timedOut,
379
+ stdout,
380
+ stderr,
381
+ startedAt,
382
+ endedAt: new Date().toISOString(),
383
+ durationMs: Date.now() - t0,
384
+ treeKill,
385
+ stdoutTruncated: stdoutBytes > maxBufferBytes,
386
+ stderrTruncated: stderrBytes > maxBufferBytes,
387
+ pid: child.pid ?? null,
388
+ })
389
+ }
390
+ child.stdout?.on('data', (chunk) => append(chunk, 'stdout'))
391
+ child.stderr?.on('data', (chunk) => append(chunk, 'stderr'))
392
+ child.on('error', (error) => {
393
+ stderr += `\n[spawn error] ${error.message}`
394
+ void finalize(null, null)
395
+ })
396
+ child.on('close', (code, signal) => { void finalize(code, signal) })
397
+ timer = setTimeout(() => {
398
+ timedOut = true
399
+ treeKill = killProcessTree(child.pid, { platform })
400
+ }, timeoutMs)
401
+ timer.unref?.()
402
+ })
403
+ }
package/CHANGELOG.md CHANGED
@@ -1,9 +1,16 @@
1
- ## 未发布
1
+ ## 1.2.2 — 2026-11-06
2
2
 
3
+ - FLOW 沙箱:`flow collect --sandbox` 在 `unshare -Urn` + `prlimit` 下运行,记录 `sandbox{}`;env 白名单硬断言 token 不在子进程;进程组超时击杀;`producer.kind=sandbox` 仅当隔离项全部生效,否则 `sandbox-degraded` 且 audit 永不 runtime。测试 152 → 159。
4
+ - 发布包:`npm test` 改为 `run-tests.mjs`,未随包分发的 test 目录不再导致安装后测试失败。
5
+
6
+ ## 1.2.1 — 2026-11-06
7
+
8
+ - **V2 核验修复**:`level-audit` 不再把结构自洽的 self-attested/ci-attested `dsh-flow-evidence/2` 判成 FLOW `runtime`。只有 `claims.claimLevel=sandbox-attested` 或 `producer.kind=sandbox` 才允许 `runtime`;self/ci 的 `passed` 计数保留但 state 记 `not-run`,reason 为 `<claimLevel> evidence; runtime requires sandbox-attested`。`spec/flow-evidence.schema.json` 的 `claims`/`producer.kind` 补充了该判定说明。`advice[]` 新增 `gate`(该 check 的最终结果 PASS/WARN/FAIL),`severity` 跟随 `gate`:ENTRY static claim 把 DSC-02 降为 WARN 时,C-2.1 的 FAIL detail 仍保留在 `reason`,但 advice severity 为 WARN。测试 150 → 152。
3
9
  - **FLOW 证据链**:新增 `spec/flow-evidence.schema.json`(`dsh-flow-evidence/2`)与 `40-checker/src/flow-evidence.mjs`。`level-audit --flow-evidence` 只接受 v2;v1(`dsh-flow-journey/1`、`dsh-flow-runtime/1`)与裸 `{Jx:{status}}` 标 `deprecated`,不计 pass。每个 J 须有 `checks[]` 且全部 `exitCode === 0`、断言成立、至少一条非 `static` 观测检查才计 pass(`warn`/`blocked`/`na`/`static` 不计 8/8)。v2 的 `chain.evidenceHash` 由 audit 重算,改动一个字节即 `invalid`。
4
10
  - **collector**:新增 `40-checker/bin/dsh-flow-collect.mjs`(根入口 `bin/dsh-flow-collect.mjs`,并支持 `dsh-levels flow collect`)。只读副本 + 前后 `treeSha256` 一致、`npm pack --ignore-scripts` 的 integrity/tarballSha256、`git rev-parse` commit、临时 HOME/DSH_HOME、失败五要素提示(子检查/命令/exitCode/stderr 末 20 行/日志绝对路径 + 条款锚);前置缺失记 `blocked` 且 exit 0,真失败 exit 1。
5
11
  - **安全**:新增 `40-checker/src/sandbox-env.mjs` 白名单 env。`src/cli.mjs` 的 runtime-child/bundle-smoke 子进程、`bundle-smoke.mjs`/`runtime-child.mjs` 进程内 boot、`m2-runner.mjs`、`journey-runtime.mjs`、`journey-config-observe.mjs`、`flow-sample-ci.mjs`、`host-farm.mjs`、`entry18-web.mjs` 不再把 `process.env` 透传给被检插件/宿主 boot。
6
12
  - 测试 137 → 149(新增 `40-checker/test/flow-evidence.test.mjs`:v1 不再 pass、v2 伪造 checks/exitCode 不 pass、篡改链路 invalid、blocked 不判 fail、collector 产物可被 audit 读取、失败五要素提示、env 白名单)。
13
+ - 发布包不再包含 `40-checker/test`:该目录依赖仓库内 fixtures,装到用户环境后无法自测,`npm test` 会在 tarball 内失败。`npm test` 152/152。
7
14
 
8
15
  ## 1.2.0 — 2026-11-06
9
16