dsh-standards 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/40-checker/README.md +15 -0
  2. package/40-checker/bin/dsh-flow-collect.mjs +3 -0
  3. package/40-checker/bin/dsh-levels.mjs +13 -2
  4. package/40-checker/fixtures/bundle-patch-only/cordis.patch.yml +3 -0
  5. package/40-checker/fixtures/bundle-patch-only/package.json +12 -0
  6. package/40-checker/fixtures/c12-command-arg/index.mjs +7 -0
  7. package/40-checker/fixtures/c12-command-arg/package.json +10 -0
  8. package/40-checker/fixtures/c12-comment/index.mjs +3 -0
  9. package/40-checker/fixtures/c12-comment/package.json +10 -0
  10. package/40-checker/fixtures/c12-compare/index.mjs +3 -0
  11. package/40-checker/fixtures/c12-compare/package.json +10 -0
  12. package/40-checker/fixtures/c12-doc-string/index.mjs +13 -0
  13. package/40-checker/fixtures/c12-doc-string/package.json +10 -0
  14. package/40-checker/fixtures/c12-grant/index.mjs +3 -0
  15. package/40-checker/fixtures/c12-grant/package.json +10 -0
  16. package/40-checker/fixtures/c12-string/index.mjs +3 -0
  17. package/40-checker/fixtures/c12-string/package.json +10 -0
  18. package/40-checker/fixtures/declaration-file/index.d.ts +1 -0
  19. package/40-checker/fixtures/declaration-file/index.mjs +9 -0
  20. package/40-checker/fixtures/declaration-file/package.json +10 -0
  21. package/40-checker/fixtures/import-local-side-effect/index.mjs +11 -0
  22. package/40-checker/fixtures/import-local-side-effect/package.json +21 -0
  23. package/40-checker/fixtures/import-local-side-effect/side-effectful.mjs +5 -0
  24. package/40-checker/fixtures/import-open-readonly/index.mjs +14 -0
  25. package/40-checker/fixtures/import-open-readonly/package.json +19 -0
  26. package/40-checker/fixtures/import-package-only/index.mjs +11 -0
  27. package/40-checker/fixtures/import-package-only/package.json +21 -0
  28. package/40-checker/fixtures/import-parse-error/index.mjs +5 -0
  29. package/40-checker/fixtures/import-parse-error/package.json +10 -0
  30. package/40-checker/fixtures/import-side-effect/index.mjs +14 -0
  31. package/40-checker/fixtures/import-side-effect/package.json +19 -0
  32. package/40-checker/fixtures/import-side-effect-nonentry/index.mjs +9 -0
  33. package/40-checker/fixtures/import-side-effect-nonentry/package.json +19 -0
  34. package/40-checker/fixtures/import-side-effect-nonentry/selfcheck.mjs +8 -0
  35. package/40-checker/fixtures/import-void-main/index.mjs +16 -0
  36. package/40-checker/fixtures/import-void-main/package.json +19 -0
  37. package/40-checker/fixtures/no-source/package.json +19 -0
  38. package/40-checker/fixtures/parse-error-nonentry/index.mjs +2 -0
  39. package/40-checker/fixtures/parse-error-nonentry/package.json +10 -0
  40. package/40-checker/fixtures/parse-error-nonentry/references/broken.js +1 -0
  41. package/40-checker/scripts/bundle-smoke.mjs +3 -0
  42. package/40-checker/scripts/entry18-ci.mjs +4 -3
  43. package/40-checker/scripts/entry18-web.mjs +4 -1
  44. package/40-checker/scripts/flow-collect.mjs +494 -0
  45. package/40-checker/scripts/flow-sample-ci.mjs +4 -4
  46. package/40-checker/scripts/host-farm.mjs +2 -1
  47. package/40-checker/scripts/journey-config-observe.mjs +5 -3
  48. package/40-checker/scripts/journey-runtime.mjs +208 -83
  49. package/40-checker/scripts/level-audit.mjs +91 -19
  50. package/40-checker/scripts/m2-runner.mjs +4 -3
  51. package/40-checker/scripts/runtime-child.mjs +4 -0
  52. package/40-checker/src/cli.mjs +11 -3
  53. package/40-checker/src/declarations.mjs +514 -19
  54. package/40-checker/src/flow-evidence.mjs +431 -0
  55. package/40-checker/src/index.mjs +3 -0
  56. package/40-checker/src/manifest.mjs +5 -0
  57. package/40-checker/src/remediation.mjs +132 -0
  58. package/40-checker/src/report.mjs +19 -2
  59. package/40-checker/src/sandbox-env.mjs +78 -0
  60. package/40-checker/test/checks.test.mjs +243 -1
  61. package/40-checker/test/flow-evidence.test.mjs +295 -0
  62. package/40-checker/test/remediation.test.mjs +300 -0
  63. package/40-checker/vendor/babel/README.md +10 -0
  64. package/40-checker/vendor/babel/index.cjs +3 -0
  65. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/LICENSE +22 -0
  66. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/README.md +19 -0
  67. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.d.ts +41 -0
  68. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/lib/index.js +286 -0
  69. package/40-checker/vendor/babel/node_modules/@babel/helper-string-parser/package.json +31 -0
  70. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/LICENSE +22 -0
  71. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/README.md +19 -0
  72. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier-browser.js +62 -0
  73. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/identifier.js +60 -0
  74. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.d.ts +28 -0
  75. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/lib/index.js +27 -0
  76. package/40-checker/vendor/babel/node_modules/@babel/helper-validator-identifier/package.json +45 -0
  77. package/40-checker/vendor/babel/node_modules/@babel/parser/CHANGELOG.md +1073 -0
  78. package/40-checker/vendor/babel/node_modules/@babel/parser/LICENSE +19 -0
  79. package/40-checker/vendor/babel/node_modules/@babel/parser/README.md +19 -0
  80. package/40-checker/vendor/babel/node_modules/@babel/parser/bin/babel-parser.js +14 -0
  81. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.d.ts +1047 -0
  82. package/40-checker/vendor/babel/node_modules/@babel/parser/lib/index.js +14612 -0
  83. package/40-checker/vendor/babel/node_modules/@babel/parser/package.json +57 -0
  84. package/40-checker/vendor/babel/node_modules/@babel/parser/typings/babel-parser.d.ts +636 -0
  85. package/40-checker/vendor/babel/node_modules/@babel/types/LICENSE +22 -0
  86. package/40-checker/vendor/babel/node_modules/@babel/types/README.md +19 -0
  87. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index-legacy.d.ts +2765 -0
  88. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.d.ts +3668 -0
  89. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js +10758 -0
  90. package/40-checker/vendor/babel/node_modules/@babel/types/lib/index.js.flow +2618 -0
  91. package/40-checker/vendor/babel/node_modules/@babel/types/package.json +38 -0
  92. package/CHANGELOG.md +66 -0
  93. package/bin/dsh-flow-collect.mjs +4 -0
  94. package/package.json +2 -1
  95. package/spec/README.md +2 -0
  96. package/spec/flow/README.md +23 -2
  97. package/spec/flow-evidence.schema.json +217 -0
  98. package/spec/levels.json +2 -0
  99. package/spec/remediation.json +478 -0
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * flow-collect — produce a `dsh-flow-evidence/2` document for one plugin.
4
+ *
5
+ * What it does:
6
+ * 1. bind the subject: name@version from package.json, `npm pack
7
+ * --ignore-scripts` tarball sha256 + integrity, `git rev-parse HEAD`,
8
+ * and a deterministic tree sha256 of the package directory;
9
+ * 2. copy the package to a temp dir and run every journey against that copy
10
+ * only; the original directory is never written;
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`;
15
+ * 5. write the JSON, per-check logs, the chain hash, and SHA256SUMS.
16
+ *
17
+ * Missing prerequisites (no dsh, no --previous, host compatibility gate
18
+ * rejected the install) are recorded as `blocked` with a reason and exit 0.
19
+ * A failed check exits 1 with the five-element hint: sub-check, command,
20
+ * exitCode, last 20 stderr lines, absolute log path, plus the clause anchor.
21
+ *
22
+ * Usage:
23
+ * node 40-checker/bin/dsh-flow-collect.mjs --package <dir> [--out flow-evidence.json]
24
+ * [--logs <dir>] [--previous <dir>] [--dsh <bin>] [--timeout <ms>]
25
+ * [--static] [--ci] [--json]
26
+ */
27
+ import fs from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
30
+ import { spawnSync } from 'node:child_process'
31
+ import { fileURLToPath } from 'node:url'
32
+ import {
33
+ FLOW_IDS, FLOW_EVIDENCE_SCHEMA, clauseAnchor, finalizeEvidence, journeyAggregate,
34
+ sha256File, sha256Value, treeSha256, validateFlowEvidence,
35
+ } from '../src/flow-evidence.mjs'
36
+ import { sanitizedEnv } from '../src/sandbox-env.mjs'
37
+
38
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
39
+ const STD = path.resolve(HERE, '..', '..')
40
+ const JOURNEY_STATIC = path.join(HERE, 'journey.mjs')
41
+ const JOURNEY_RUNTIME = path.join(HERE, 'journey-runtime.mjs')
42
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
43
+ const GIT = process.platform === 'win32' ? 'git.exe' : 'git'
44
+
45
+ const STATIC_CHECK_IDS = {
46
+ J1: 'J1.install', J2: 'J2.toggle', J3: 'J3.config', J4: 'J4.first-run',
47
+ J5: 'J5.permissions', J6: 'J6.upgrade', J7: 'J7.observe', J8: 'J8.client',
48
+ }
49
+ const RUNTIME_JOURNEYS = ['J1', 'J2', 'J4', 'J6']
50
+ const STATIC_COMMAND = [process.execPath, path.relative(STD, JOURNEY_STATIC).split(path.sep).join('/'), '--package', '<pkg>', '--json']
51
+ const NOT_CLAIMED = [
52
+ 'certification', 'security audit', 'official endorsement', 'ranking',
53
+ 'real network/browser behavior', 'sandbox confinement (local collector)',
54
+ ]
55
+
56
+ function usage() {
57
+ console.log(`flow-collect — collect a ${FLOW_EVIDENCE_SCHEMA} document
58
+
59
+ Usage:
60
+ node 40-checker/bin/dsh-flow-collect.mjs --package <dir> [options]
61
+
62
+ Options:
63
+ --package <dir> plugin/bundle directory to inspect (required)
64
+ --out <file> evidence JSON (default: ./flow-evidence.json)
65
+ --logs <dir> log directory (default: <out>.logs)
66
+ --previous <dir> previous version for J6 upgrade/rollback
67
+ --dsh <bin> dsh executable (default: dsh / $DSH_BIN)
68
+ --timeout <ms> per-command budget (default: 120000)
69
+ --static skip the runtime probe (J1/J2/J4/J6 become static/blocked)
70
+ --ci mark producer.kind=ci, claims.claimLevel=ci-attested
71
+ --no-assert-unchanged skip the before/after tree hash comparison (not recommended)
72
+ --json print the evidence JSON instead of the summary
73
+ --help show this help
74
+
75
+ The inspected directory is copied to a temp dir and never written. No
76
+ allowBuilds/allow-version is granted. Missing prerequisites are recorded as
77
+ blocked and exit 0; a failed check exits 1 with a five-element hint.`)
78
+ }
79
+
80
+ function parse(argv) {
81
+ const out = {
82
+ package: null, out: path.resolve('flow-evidence.json'), logs: null, previous: null,
83
+ dsh: process.env.DSH_BIN || null, timeout: 120_000, static: false, ci: false,
84
+ assertUnchanged: true, json: false,
85
+ }
86
+ for (let i = 0; i < argv.length; i += 1) {
87
+ const next = () => {
88
+ const value = argv[i + 1]
89
+ if (value === undefined) throw new Error(`${argv[i]} needs a value`)
90
+ i += 1
91
+ return value
92
+ }
93
+ const arg = argv[i]
94
+ if (arg === '--package') out.package = next()
95
+ else if (arg === '--out') out.out = path.resolve(next())
96
+ else if (arg === '--logs') out.logs = path.resolve(next())
97
+ else if (arg === '--previous') out.previous = path.resolve(next())
98
+ else if (arg === '--dsh') out.dsh = next()
99
+ else if (arg === '--timeout') out.timeout = Number(next())
100
+ else if (arg === '--static' || arg === '--no-runtime') out.static = true
101
+ else if (arg === '--ci') out.ci = true
102
+ else if (arg === '--no-assert-unchanged') out.assertUnchanged = false
103
+ else if (arg === '--json') out.json = true
104
+ else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
105
+ else throw new Error(`unknown option: ${arg}`)
106
+ }
107
+ if (!out.package) throw new Error('--package is required')
108
+ if (!Number.isFinite(out.timeout) || out.timeout < 1000) throw new Error('--timeout must be >= 1000')
109
+ return out
110
+ }
111
+
112
+ function nowIso() { return new Date().toISOString() }
113
+ function tailLines(text, count = 20) {
114
+ const lines = String(text ?? '').trimEnd().split(/\r?\n/)
115
+ return lines.slice(-count).join('\n')
116
+ }
117
+ function quoteCommand(command) {
118
+ return command.map(part => (/^[A-Za-z0-9_./:@=+-]+$/.test(part) ? part : `'${String(part).replace(/'/g, "'\\''")}'`)).join(' ')
119
+ }
120
+
121
+ function run(command, args, { cwd, env, timeout = 120_000 } = {}) {
122
+ const startedAt = nowIso()
123
+ const t0 = Date.now()
124
+ const result = spawnSync(command, args, {
125
+ cwd, env, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024, killSignal: 'SIGKILL',
126
+ })
127
+ const endedAt = nowIso()
128
+ return {
129
+ command: [command, ...args],
130
+ exitCode: Number.isInteger(result.status) ? result.status : result.error?.code === 'ETIMEDOUT' ? 124 : 127,
131
+ startedAt,
132
+ endedAt,
133
+ durationMs: Date.now() - t0,
134
+ stdoutText: result.stdout ?? '',
135
+ stderrText: result.stderr ?? '',
136
+ timedOut: result.error?.code === 'ETIMEDOUT',
137
+ error: result.error ? result.error.message : null,
138
+ }
139
+ }
140
+
141
+ function writeLog(logDir, name, text) {
142
+ const file = path.join(logDir, name)
143
+ fs.writeFileSync(file, text ?? '')
144
+ return { file, sha256: sha256Value(text ?? '') }
145
+ }
146
+
147
+ function main() {
148
+ const options = parse(process.argv.slice(2))
149
+ const pkg = path.resolve(options.package)
150
+ if (!fs.existsSync(path.join(pkg, 'package.json'))) throw new Error(`not a package directory: ${pkg}`)
151
+ const manifest = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'), 'utf8'))
152
+ const generatedAt = nowIso()
153
+ const outFile = path.resolve(options.out)
154
+ const outDir = path.dirname(outFile)
155
+ const logDir = path.resolve(options.logs ?? `${outFile}.logs`)
156
+ fs.mkdirSync(outDir, { recursive: true })
157
+ fs.mkdirSync(logDir, { recursive: true })
158
+ const toEvidencePath = file => path.relative(outDir, file).split(path.sep).join('/')
159
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-flow-collect-'))
160
+ const tmpHome = path.join(tmpRoot, 'home')
161
+ const tmpCache = path.join(tmpRoot, 'npm-cache')
162
+ fs.mkdirSync(tmpHome, { recursive: true })
163
+ fs.mkdirSync(tmpCache, { recursive: true })
164
+ const childEnv = () => sanitizedEnv({
165
+ HOME: tmpHome, USERPROFILE: tmpHome, TMPDIR: path.join(tmpRoot, 'tmp'), TEMP: path.join(tmpRoot, 'tmp'), TMP: path.join(tmpRoot, 'tmp'),
166
+ npm_config_cache: tmpCache, npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false',
167
+ })
168
+
169
+ const warnings = []
170
+ try {
171
+ // 0. Never let our own output/log dirs count as a tree change.
172
+ const treeExclude = []
173
+ for (const candidate of [outFile, logDir]) {
174
+ const rel = path.relative(pkg, candidate)
175
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) treeExclude.push(rel.split(path.sep).join('/'))
176
+ }
177
+ const before = treeSha256(pkg, { exclude: treeExclude })
178
+
179
+ // 1. Read-only copy. Everything below runs against the copy.
180
+ const copy = path.join(tmpRoot, 'package')
181
+ fs.cpSync(pkg, copy, {
182
+ recursive: true,
183
+ dereference: false,
184
+ filter: source => path.basename(source) !== '.git',
185
+ })
186
+ let previousCopy = null
187
+ if (options.previous) {
188
+ const previousSource = path.resolve(options.previous)
189
+ if (!fs.existsSync(path.join(previousSource, 'package.json'))) throw new Error(`not a package directory: ${previousSource}`)
190
+ previousCopy = path.join(tmpRoot, 'previous')
191
+ fs.cpSync(previousSource, previousCopy, { recursive: true, dereference: false, filter: source => path.basename(source) !== '.git' })
192
+ }
193
+
194
+ // 2. Subject binding.
195
+ const packed = packSubject(copy, tmpRoot, childEnv, options.timeout)
196
+ const commit = gitCommit(pkg)
197
+ const subject = {
198
+ name: manifest.name,
199
+ version: manifest.version,
200
+ integrity: packed.integrity,
201
+ tarballSha256: packed.tarballSha256,
202
+ commit,
203
+ treeSha256: before.hash,
204
+ treeExclude,
205
+ source: options.ci ? 'ci-checkout' : 'local-directory',
206
+ }
207
+
208
+ // 3. Host.
209
+ const dshBin = options.dsh || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
210
+ const versionRun = run(dshBin, ['--version'], { env: childEnv(), timeout: 30_000 })
211
+ const dshVersion = versionRun.exitCode === 0 ? versionRun.stdoutText.trim() || null : null
212
+ const host = { dsh: dshVersion, node: process.version, platform: `${process.platform}-${process.arch}` }
213
+
214
+ // 4. Static preflight (never imports the plugin).
215
+ const staticChecks = {}
216
+ const staticRun = run(process.execPath, [JOURNEY_STATIC, '--package', copy, '--json'], { env: childEnv(), timeout: options.timeout })
217
+ const staticLog = writeLog(logDir, 'preflight-journey.json', staticRun.stdoutText)
218
+ let staticResult = null
219
+ try { staticResult = JSON.parse(staticRun.stdoutText) } catch { /* handled below */ }
220
+ for (const id of FLOW_IDS) {
221
+ const wanted = STATIC_CHECK_IDS[id]
222
+ const mapped = (staticResult?.checks ?? []).find(check => check.id === wanted) ?? null
223
+ const observed = mapped?.result === 'PASS'
224
+ staticChecks[id] = [{
225
+ id: `${wanted}.preflight`,
226
+ kind: 'static',
227
+ command: STATIC_COMMAND.map(part => (part === '<pkg>' ? pkg : part)),
228
+ exitCode: mapped?.result === 'FAIL' || !mapped ? 1 : 0,
229
+ startedAt: staticRun.startedAt,
230
+ endedAt: staticRun.endedAt,
231
+ stdout: { path: toEvidencePath(staticLog.file), sha256: staticLog.sha256 },
232
+ assertions: [{
233
+ claim: mapped ? `${wanted}: ${mapped.reason}` : `${wanted}: static preflight produced no row`,
234
+ observed,
235
+ }],
236
+ }]
237
+ }
238
+ if (!staticResult) warnings.push(`static preflight produced no JSON (exit ${staticRun.exitCode}); J1-J8 recorded as static preflight failure`)
239
+
240
+ // 5. Runtime probe (J1/J2/J4/J6), unless disabled or dsh is missing.
241
+ const runtimeChecks = Object.fromEntries(FLOW_IDS.map(id => [id, []]))
242
+ const runtimeResults = {}
243
+ const blockerReasons = {}
244
+ let runtimeSkipped = null
245
+ if (options.static) runtimeSkipped = 'runtime collection disabled (--static)'
246
+ else if (dshVersion === null) runtimeSkipped = `dsh executable not usable: ${dshBin} (exit ${versionRun.exitCode})`
247
+ let runtimeVerdict = null
248
+ if (runtimeSkipped === null) {
249
+ const args = [JOURNEY_RUNTIME, '--package', copy, '--dsh', dshBin, '--json', '--log-dir', logDir, '--timeout', String(options.timeout)]
250
+ 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)}` }
255
+ if (runtimeVerdict) {
256
+ for (const check of runtimeVerdict.checks ?? []) {
257
+ const mapped = toEvidenceCheck(check, logDir, toEvidencePath)
258
+ if (!mapped) continue
259
+ const journey = mapped.id.slice(0, 2)
260
+ if (!runtimeChecks[journey]) continue
261
+ runtimeChecks[journey].push(mapped)
262
+ runtimeResults[mapped.id] = check.result
263
+ }
264
+ for (const blocker of runtimeVerdict.blockers ?? []) {
265
+ blockerReasons[blocker.journey] = blockerReasons[blocker.journey] ?? []
266
+ blockerReasons[blocker.journey].push(blocker.reason)
267
+ }
268
+ }
269
+ }
270
+ if (runtimeSkipped !== null) warnings.push(`runtime probe skipped: ${runtimeSkipped}`)
271
+
272
+ // 6. Journey statuses + reasons (never upgrade a skip to a pass).
273
+ const journeys = {}
274
+ const hints = []
275
+ for (const id of FLOW_IDS) {
276
+ const checks = [...staticChecks[id], ...runtimeChecks[id]]
277
+ let status
278
+ let reason = null
279
+ if (RUNTIME_JOURNEYS.includes(id)) {
280
+ const results = checks.filter(check => check.kind !== 'static').map(check => runtimeResults[check.id]).filter(Boolean)
281
+ const staticDeclared = staticResult?.checks?.find(check => check.id === STATIC_CHECK_IDS[id])?.result ?? null
282
+ const upstream = id !== 'J1' && blockerReasons.J1?.length
283
+ ? `blocked by J1: ${blockerReasons.J1.join('; ')}`
284
+ : null
285
+ if (runtimeSkipped !== null) {
286
+ status = staticDeclared === 'NA' ? 'na' : staticDeclared === 'FAIL' ? 'fail' : checks.length > 0 ? 'static' : 'blocked'
287
+ if (staticDeclared === 'NA') reason = 'no dsh.client (static preflight NA)'
288
+ else if (staticDeclared === 'FAIL') reason = 'static preflight FAIL'
289
+ else reason = runtimeSkipped
290
+ } else if (blockerReasons[id]?.length) {
291
+ status = 'blocked'
292
+ reason = blockerReasons[id].join('; ')
293
+ } else if (id === 'J6' && !previousCopy) {
294
+ status = 'blocked'
295
+ reason = 'no --previous package supplied; upgrade/rollback not exercised'
296
+ } else if (results.includes('FAIL')) {
297
+ status = 'fail'
298
+ reason = `${results.filter(item => item === 'FAIL').length} runtime check(s) failed`
299
+ } else if (results.includes('BLOCKED')) {
300
+ status = 'blocked'
301
+ reason = 'runtime probe reported BLOCKED'
302
+ } else if (results.includes('WARN')) {
303
+ status = 'warn'
304
+ reason = 'runtime probe reported WARN (does not count as pass)'
305
+ } else if (upstream) {
306
+ status = 'blocked'
307
+ reason = upstream
308
+ } else if (results.length === 0 || results.every(item => item === 'not-run')) {
309
+ status = 'not-run'
310
+ reason = 'runtime probe did not cover this journey'
311
+ } else {
312
+ status = journeyAggregate(checks)
313
+ if (status !== 'pass' && status !== 'fail') reason = `checks aggregate to ${status}`
314
+ }
315
+ } else {
316
+ status = journeyAggregate(checks)
317
+ if (id === 'J8' && staticResult?.checks?.find(check => check.id === 'J8.client')?.result === 'NA') status = 'na'
318
+ if (status === 'static') reason = 'static preflight only; not a runtime pass'
319
+ }
320
+ const journey = { status, checks }
321
+ if (reason) journey.reason = reason
322
+ if (id === 'J8' && status === 'na') journey.limitations = ['no dsh.client: server-only shape is expected']
323
+ journeys[id] = journey
324
+ if (status === 'fail') collectHints(id, checks, hints, outDir, logDir, toEvidencePath)
325
+ else if (status === 'blocked') hints.push(`BLOCKED ${id} — 前置缺失: ${reason ?? 'unknown'}\n 条款锚: ${clauseAnchor(id)}`)
326
+ }
327
+
328
+ // 7. The inspected tree must be unchanged; the copy is disposed afterwards.
329
+ const after = options.assertUnchanged ? treeSha256(pkg, { exclude: treeExclude }) : null
330
+ const treeStable = after ? after.hash === before.hash : true
331
+ subject.treeStable = treeStable
332
+ if (after) subject.treeSha256After = after.hash
333
+ if (!treeStable) warnings.push(`package tree changed during collection (before ${before.hash}, after ${after.hash}); evidence is not trustworthy`)
334
+
335
+ // 8. Assemble, chain, validate.
336
+ const statuses = FLOW_IDS.map(id => journeys[id].status)
337
+ const counts = statuses.reduce((acc, status) => {
338
+ acc[status] = (acc[status] ?? 0) + 1
339
+ return acc
340
+ }, {})
341
+ const conclusion = statuses.includes('fail') || !treeStable
342
+ ? 'flow-fail'
343
+ : statuses.includes('blocked') ? 'flow-blocked' : statuses.every(status => status === 'pass') ? 'flow-pass' : 'flow-warn'
344
+ const producerCommand = [process.execPath, path.relative(STD, fileURLToPath(import.meta.url)).split(path.sep).join('/'), ...process.argv.slice(2)]
345
+ const document = finalizeEvidence({
346
+ schema: FLOW_EVIDENCE_SCHEMA,
347
+ generatedAt,
348
+ producer: { command: producerCommand, at: generatedAt, kind: options.ci ? 'ci' : 'local', runner: 'flow-collect/1' },
349
+ subject,
350
+ host,
351
+ 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
+ },
357
+ conclusion,
358
+ })
359
+ const verdict = validateFlowEvidence(document)
360
+ fs.writeFileSync(outFile, `${JSON.stringify(document, null, 2)}\n`)
361
+
362
+ // 9. SHA256SUMS for the logs (the evidence file itself is listed too).
363
+ const sums = []
364
+ for (const name of fs.readdirSync(logDir).sort()) {
365
+ const file = path.join(logDir, name)
366
+ if (!fs.statSync(file).isFile()) continue
367
+ sums.push(`${sha256File(file).slice('sha256:'.length)} ${toEvidencePath(file)}`)
368
+ }
369
+ sums.push(`${sha256File(outFile).slice('sha256:'.length)} ${path.basename(outFile)}`)
370
+ fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${sums.join('\n')}\n`)
371
+
372
+ // 10. Report.
373
+ if (options.json) {
374
+ process.stdout.write(`${JSON.stringify(document, null, 2)}\n`)
375
+ } else {
376
+ const lines = [
377
+ `FLOW evidence: ${outFile}`,
378
+ ` schema : ${document.schema}`,
379
+ ` subject : ${subject.name}@${subject.version}`,
380
+ ` integrity=${subject.integrity ?? 'null'} tarball=${subject.tarballSha256}`,
381
+ ` tree=${subject.treeSha256} stable=${treeStable}${after ? ` after=${after.hash}` : ''}`,
382
+ ` commit=${subject.commit ?? 'null'}`,
383
+ ` host : dsh=${host.dsh ?? 'null'} node=${host.node} platform=${host.platform}`,
384
+ ` journeys : ${FLOW_IDS.map(id => `${id}=${journeys[id].status}`).join(' ')}`,
385
+ ` totals : ${JSON.stringify(counts)}`,
386
+ ` conclusion: ${conclusion}`,
387
+ ` chain : ${document.chain.evidenceHash}`,
388
+ ` logs : ${logDir} (+ ${path.join(outDir, 'SHA256SUMS')})`,
389
+ 'Claims: self-attested structure + binding only; no certification, no security audit, not a sandbox.',
390
+ ]
391
+ if (warnings.length > 0) {
392
+ lines.push('Warnings:')
393
+ for (const warning of warnings) lines.push(` - ${warning}`)
394
+ }
395
+ if (hints.length > 0) {
396
+ lines.push('Journey hints (five elements + clause anchor):')
397
+ for (const hint of hints) lines.push(hint)
398
+ }
399
+ lines.push(`Next: node 40-checker/scripts/level-audit.mjs ${pkg} --flow-evidence ${outFile} --format json`)
400
+ process.stdout.write(`${lines.join('\n')}\n`)
401
+ }
402
+ if (!verdict.valid) {
403
+ for (const error of verdict.errors) console.error(`flow-collect: evidence invalid: ${error}`)
404
+ process.exitCode = 3
405
+ } else {
406
+ process.exitCode = conclusion === 'flow-fail' ? 1 : 0
407
+ }
408
+ } finally {
409
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
410
+ }
411
+ }
412
+
413
+ function packSubject(copy, tmpRoot, childEnv, timeout) {
414
+ const destination = fs.mkdtempSync(path.join(tmpRoot, 'pack-'))
415
+ const runResult = run(NPM, ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], { cwd: copy, env: childEnv(), timeout })
416
+ if (runResult.exitCode !== 0) {
417
+ throw new Error(`npm pack failed (exit ${runResult.exitCode}): ${tailLines(runResult.stderrText, 10)}`)
418
+ }
419
+ let parsed
420
+ try { parsed = JSON.parse(runResult.stdoutText) } catch {
421
+ throw new Error(`npm pack did not print JSON: ${tailLines(runResult.stdoutText, 5)}`)
422
+ }
423
+ const info = Array.isArray(parsed) ? parsed[0] : parsed
424
+ if (!info?.filename) throw new Error('npm pack did not report a tarball filename')
425
+ const tarball = path.join(destination, info.filename)
426
+ return {
427
+ integrity: typeof info.integrity === 'string' ? info.integrity : null,
428
+ tarballSha256: sha256File(tarball),
429
+ files: Array.isArray(info.files) ? info.files.length : null,
430
+ filename: info.filename,
431
+ }
432
+ }
433
+
434
+ function gitCommit(dir) {
435
+ const result = run(GIT, ['-C', dir, 'rev-parse', 'HEAD'], { env: sanitizedEnv(), timeout: 10_000 })
436
+ const commit = result.exitCode === 0 ? result.stdoutText.trim() : ''
437
+ return /^[0-9a-f]{40}$/.test(commit) ? commit : null
438
+ }
439
+
440
+ /** Map one journey-runtime check to the v2 check shape (null = not evidence). */
441
+ function toEvidenceCheck(check, logDir, toEvidencePath) {
442
+ if (!check || !Array.isArray(check.command) || check.command.length === 0) return null
443
+ if (!Number.isInteger(check.exitCode)) return null
444
+ if (!check.startedAt || !check.endedAt) return null
445
+ if (!check.stdout?.path) return null
446
+ const output = {
447
+ id: check.id,
448
+ kind: 'observed',
449
+ command: check.command,
450
+ exitCode: check.exitCode,
451
+ startedAt: check.startedAt,
452
+ endedAt: check.endedAt,
453
+ stdout: { path: toEvidencePath(path.join(logDir, check.stdout.path)), sha256: check.stdout.sha256 },
454
+ assertions: Array.isArray(check.assertions) && check.assertions.length > 0
455
+ ? check.assertions.map(assertion => ({ claim: String(assertion.claim), observed: assertion.observed === true }))
456
+ : [{ claim: String(check.reason ?? check.id), observed: check.result === 'PASS' }],
457
+ }
458
+ if (check.stderrLog?.path && check.stderrLog?.sha256) {
459
+ output.stderr = { path: toEvidencePath(path.join(logDir, check.stderrLog.path)), sha256: check.stderrLog.sha256 }
460
+ }
461
+ if (Number.isFinite(check.durationMs)) output.durationMs = check.durationMs
462
+ return output
463
+ }
464
+
465
+ /** The fixed five elements + clause anchor. */
466
+ function collectHints(journeyId, checks, hints, outDir, logDir, toEvidencePath) {
467
+ const failing = checks.filter(check => check.exitCode !== 0)
468
+ for (const check of failing) {
469
+ const logFiles = [check.stderr?.path, check.stdout?.path].filter(Boolean)
470
+ const absolute = logFiles.map(rel => path.resolve(outDir, rel))
471
+ const stderrAbs = check.stderr?.path ? path.resolve(outDir, check.stderr.path) : null
472
+ let stderrTail = ''
473
+ if (stderrAbs) {
474
+ try { stderrTail = tailLines(fs.readFileSync(stderrAbs, 'utf8'), 20) } catch { /* keep empty */ }
475
+ }
476
+ const lines = [
477
+ `FAIL ${check.id}`,
478
+ ` 1) 子检查: ${check.id}`,
479
+ ` 2) 命令: ${quoteCommand(check.command)}`,
480
+ ` 3) exitCode: ${check.exitCode}`,
481
+ ` 4) stderr(末20行): ${stderrTail ? `\n${stderrTail.split('\n').map(line => ` ${line}`).join('\n')}` : '(empty)'}`,
482
+ ` 5) 日志: ${absolute.join(', ') || '(no log written)'}`,
483
+ ` 条款锚: ${clauseAnchor(check.id) ?? journeyId}`,
484
+ ]
485
+ hints.push(lines.join('\n'))
486
+ }
487
+ }
488
+
489
+ try {
490
+ main()
491
+ } catch (error) {
492
+ console.error(`flow-collect: ${error.message}`)
493
+ process.exitCode = 3
494
+ }
@@ -24,6 +24,7 @@ import fs from 'node:fs'
24
24
  import os from 'node:os'
25
25
  import path from 'node:path'
26
26
  import { spawnSync } from 'node:child_process'
27
+ import { sanitizedEnv } from '../src/sandbox-env.mjs'
27
28
  import { fileURLToPath } from 'node:url'
28
29
 
29
30
  const HERE = path.dirname(fileURLToPath(import.meta.url))
@@ -127,11 +128,10 @@ function removeDir(dir) {
127
128
  fs.rmSync(assertUnderSampleBase(dir), { recursive: true, force: true })
128
129
  }
129
130
 
131
+ // The journey probe boots the plugin; forward an allowlisted env, never the
132
+ // caller's tokens/keys (the probe also rebuilds this list for the plugin child).
130
133
  function childEnv() {
131
- const env = { ...process.env }
132
- delete env.DEEPSEEK_API_KEY
133
- delete env.OPENAI_API_KEY
134
- return env
134
+ return sanitizedEnv()
135
135
  }
136
136
 
137
137
  function manifestSummary(dir) {
@@ -20,6 +20,7 @@
20
20
  import fs from 'node:fs'
21
21
  import path from 'node:path'
22
22
  import { spawnSync } from 'node:child_process'
23
+ import { sanitizedEnv } from '../src/sandbox-env.mjs'
23
24
 
24
25
  function parse(argv) {
25
26
  const options = { plugin: null, farm: null, host: '<dsh-checkout>', install: false, build: false, link: false, quiet: false }
@@ -124,7 +125,7 @@ function copyPlugin(plugin, farm, asLink) {
124
125
  }
125
126
 
126
127
  function run(command, args, cwd) {
127
- const result = spawnSync(command, args, { cwd, stdio: 'inherit', env: { ...process.env, npm_config_audit: 'false', npm_config_fund: 'false' } })
128
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit', env: sanitizedEnv({ npm_config_audit: 'false', npm_config_fund: 'false' }) })
128
129
  if (result.error) return { ok: false, error: String(result.error) }
129
130
  return { ok: result.status === 0, status: result.status }
130
131
  }
@@ -19,6 +19,7 @@ import fs from 'node:fs'
19
19
  import os from 'node:os'
20
20
  import path from 'node:path'
21
21
  import { spawnSync } from 'node:child_process'
22
+ import { sanitizedEnv } from '../src/sandbox-env.mjs'
22
23
 
23
24
  function parse(argv) {
24
25
  const out = { package: null, dsh: process.env.DSH_BIN || 'dsh', json: false, timeout: 120_000 }
@@ -43,10 +44,11 @@ function main() {
43
44
  const pkg = path.resolve(options.package)
44
45
  const manifest = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'), 'utf8'))
45
46
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-config-'))
47
+ const tmp = path.join(home, 'tmp')
48
+ fs.mkdirSync(tmp, { recursive: true })
46
49
  const profile = 'flow-config'
47
- const env = { ...process.env, DSH_HOME: home }
48
- delete env.DEEPSEEK_API_KEY
49
- delete env.OPENAI_API_KEY
50
+ // Allowlisted env only: a plugin boots on import and must not see tokens/keys.
51
+ const env = sanitizedEnv({ DSH_HOME: home, HOME: home, USERPROFILE: home, TMPDIR: tmp, TEMP: tmp, TMP: tmp })
50
52
  const checks = []
51
53
  let dshVersion = null
52
54
  const add = (id, result, reason) => checks.push({ id, result, reason })