dsh-standards 1.1.2 → 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.
@@ -0,0 +1,300 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import remediationSpec from '../../spec/remediation.json' with { type: 'json' }
7
+ import packageJson from '../../package.json' with { type: 'json' }
8
+ import { DOCS_BASE, joinAdvice } from '../src/remediation.mjs'
9
+ import { buildReport } from '../src/report.mjs'
10
+ import { runManifestChecks } from '../src/manifest.mjs'
11
+ import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from '../src/declarations.mjs'
12
+ import { readSources } from '../src/util.mjs'
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url))
15
+ const srcDir = path.join(here, '..', 'src')
16
+ const fixturesDir = path.join(here, '..', 'fixtures')
17
+ const guideDocs = process.env.DSH_GUIDE_DOCS
18
+ ? path.resolve(process.env.DSH_GUIDE_DOCS)
19
+ : path.resolve(here, '..', '..', '..', 'dsh-plugin-guide', 'docs')
20
+ const RULES = remediationSpec
21
+
22
+ function slugify(text) {
23
+ return String(text)
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
27
+ .replace(/\s+/g, '-')
28
+ }
29
+
30
+ function headingSlugs(text) {
31
+ const slugs = new Set()
32
+ let fence = null
33
+ for (const line of text.split(/\r?\n/)) {
34
+ const fenceMatch = /^\s*(```+|~~~+)/.exec(line)
35
+ if (fenceMatch) {
36
+ if (fence === null) fence = fenceMatch[1][0]
37
+ else if (fence === fenceMatch[1][0]) fence = null
38
+ continue
39
+ }
40
+ if (fence !== null) continue
41
+ // Explicit HTML anchors are valid targets too (older docs predate heading-only slugs).
42
+ const explicit = /<a\s+id="([^"]+)"/.exec(line)
43
+ if (explicit) slugs.add(explicit[1])
44
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line)
45
+ if (match) slugs.add(slugify(match[2]))
46
+ }
47
+ return slugs
48
+ }
49
+
50
+ /** The key set the checker can emit: C-x[.y] literals, clause values, G0-G4. */
51
+ function checkerUniverse() {
52
+ const keys = new Set()
53
+ for (const file of fs.readdirSync(srcDir).filter(name => name.endsWith('.mjs'))) {
54
+ const text = fs.readFileSync(path.join(srcDir, file), 'utf8')
55
+ for (const match of text.matchAll(/'((?:C-\d+)(?:\.\d+)*)'/g)) keys.add(match[1])
56
+ for (const match of text.matchAll(/item:\s*'(G[0-4])'/g)) keys.add(match[1])
57
+ }
58
+ for (const gate of ['G0', 'G1', 'G2', 'G3', 'G4']) keys.add(gate)
59
+ return keys
60
+ }
61
+
62
+ function runtimeNotRun() {
63
+ const meta = {
64
+ 'DSC-04': ['C-4', 'L1'], 'DSC-05': ['C-5', 'L1'], 'DSC-06': ['C-6', 'L1+L2'],
65
+ 'DSC-07': ['C-7', 'L2'], 'DSC-08': ['C-8', 'L3'], 'DSC-09': ['C-9', 'L1'],
66
+ }
67
+ return Object.entries(meta).map(([id, [clause, layer]]) => ({
68
+ id, clause, layer, method: 'M', advisory: false,
69
+ result: 'ERROR', coverage: 'not-run', reason: 'test: runtime not run', details: [], evidence: [],
70
+ }))
71
+ }
72
+
73
+ function staticReportFor(dir) {
74
+ const result = runManifestChecks(dir)
75
+ const sources = readSources(dir)
76
+ const declaration = result.manifest
77
+ ? [
78
+ checkCapabilities(result.manifest, sources, result.patch),
79
+ checkCredentials(result.manifest, sources),
80
+ checkLabels(result.manifest, sources),
81
+ ].map(item => (item.result === 'WARN' && ['DSC-11', 'DSC-13'].includes(item.id) ? { ...item, advisory: true } : item))
82
+ : [
83
+ { id: 'DSC-11', clause: 'C-11', layer: 'L0', method: 'M', advisory: false, result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [] },
84
+ { id: 'DSC-12', clause: 'C-12', layer: 'L0', method: 'M', advisory: false, result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [] },
85
+ { id: 'DSC-13', clause: 'C-13', layer: 'L0', method: 'M', advisory: false, result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [] },
86
+ ]
87
+ const checks = [
88
+ ...result.checks,
89
+ ...runtimeNotRun(),
90
+ { id: 'DSC-14', clause: 'C-14', layer: 'L1', method: 'M', advisory: false, result: 'ERROR', coverage: 'not-run', reason: 'test: runtime not run', details: [], evidence: [] },
91
+ checkImportTimeAdvisory(sources, result.entry?.relative),
92
+ ...declaration,
93
+ ]
94
+ if (result.g4?.result === 'FAIL') {
95
+ const dsc01 = checks.find(item => item.id === 'DSC-01')
96
+ if (dsc01 && dsc01.result !== 'FAIL') {
97
+ dsc01.result = 'FAIL'
98
+ dsc01.reason = dsc01.reason ? `${dsc01.reason}; ${result.g4.reason}` : result.g4.reason
99
+ dsc01.details = [...(dsc01.details ?? []), { item: 'G4', result: 'FAIL', reason: result.g4.reason }]
100
+ }
101
+ }
102
+ return buildReport({
103
+ subject: {
104
+ mode: 'package', profile: 'package', kind: result.kind, kindInferred: true,
105
+ name: result.manifest?.name ?? null, version: result.manifest?.version ?? null, path: dir,
106
+ },
107
+ anchor: { repo: 'test', commit: '0'.repeat(40), scanner: 'test', dsh: '0.2.2-alpha.2', cordis: '4.0.6-alpha.1', runtime: null },
108
+ checks,
109
+ g4: result.g4,
110
+ scope: 'ds-c',
111
+ profile: 'package',
112
+ kind: result.kind,
113
+ })
114
+ }
115
+
116
+ test('remediation table covers every checker key exactly', () => {
117
+ const universe = checkerUniverse()
118
+ const keys = new Set(Object.keys(RULES))
119
+ const missing = [...universe].filter(key => !keys.has(key)).sort()
120
+ const extra = [...keys].filter(key => !universe.has(key)).sort()
121
+ assert.deepEqual(missing, [], `missing remediation rules: ${missing.join(', ')}`)
122
+ assert.deepEqual(extra, [], `stale remediation rules: ${extra.join(', ')}`)
123
+ assert.ok(universe.size > 0)
124
+ })
125
+
126
+ test('remediation rules have the required shape', () => {
127
+ assert.equal(DOCS_BASE, 'https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.4/')
128
+ assert.equal(packageJson.version, '1.2.0')
129
+ assert.ok(Object.keys(RULES).length > 0)
130
+ for (const [key, rule] of Object.entries(RULES)) {
131
+ assert.match(key, /^(?:C-\d+(?:\.\d+)*|G[0-4])$/)
132
+ for (const field of ['title', 'why', 'fix']) {
133
+ assert.equal(typeof rule[field], 'string', `${key}.${field}`)
134
+ assert.ok(rule[field].trim().length > 0, `${key}.${field} is empty`)
135
+ }
136
+ assert.match(rule.docs, /^docs\/[a-z]+\.md#[a-z0-9-]+$/, `${key}.docs`)
137
+ assert.ok(['fail', 'warn', 'advisory'].includes(rule.severityHint), `${key}.severityHint`)
138
+ }
139
+ })
140
+
141
+ test('advice on every fixture has a rule (no ruleMissing)', () => {
142
+ const dirs = fs.readdirSync(fixturesDir, { withFileTypes: true })
143
+ .filter(entry => entry.isDirectory())
144
+ .map(entry => path.join(fixturesDir, entry.name))
145
+ .filter(dir => fs.existsSync(path.join(dir, 'package.json')))
146
+ assert.ok(dirs.length >= 20, `expected fixture packages, got ${dirs.length}`)
147
+ let total = 0
148
+ for (const dir of dirs) {
149
+ const report = staticReportFor(dir)
150
+ assert.ok(Array.isArray(report.advice), `${path.basename(dir)}: advice is not an array`)
151
+ const missing = report.advice.filter(item => item.ruleMissing)
152
+ assert.deepEqual(missing.map(item => `${item.source}:${item.key}`), [], `${path.basename(dir)}: ruleMissing`)
153
+ for (const item of report.advice) {
154
+ assert.ok(item.docsUrl, `${path.basename(dir)}: ${item.key} has no docsUrl`)
155
+ assert.equal(item.docsUrl, `${DOCS_BASE}${RULES[item.key].docs}`, `${path.basename(dir)}: ${item.key} docsUrl`)
156
+ assert.equal(item.version, packageJson.version)
157
+ assert.ok(['FAIL', 'WARN'].includes(item.severity))
158
+ }
159
+ total += report.advice.length
160
+ }
161
+ assert.ok(total > 0, 'fixtures produced no advice; the test would be vacuous')
162
+ })
163
+
164
+ test('runtime FAIL without details falls back to check.clause', () => {
165
+ const checks = [
166
+ {
167
+ id: 'DSC-04', clause: 'C-4', layer: 'L1', result: 'FAIL', coverage: 'full',
168
+ reason: 'load failed: boom', details: [], evidence: ['index.mjs:1'], method: 'M', advisory: false,
169
+ },
170
+ {
171
+ id: 'DSC-14', clause: 'C-14', layer: 'L1', result: 'FAIL', coverage: 'full',
172
+ reason: 'runtime failed', details: [], evidence: ['index.mjs:1'], method: 'M', advisory: false,
173
+ },
174
+ ]
175
+ const report = buildReport({
176
+ subject: { mode: 'package', profile: 'package', kind: 'plugin', kindInferred: false, name: 'x', version: '1.0.0', path: here },
177
+ anchor: { repo: 'x', commit: '0'.repeat(40), scanner: 'x', dsh: '0.2.2-alpha.2', cordis: '4.0.6-alpha.1', runtime: null },
178
+ checks,
179
+ scope: 'ds-c',
180
+ profile: 'package',
181
+ kind: 'plugin',
182
+ })
183
+ const c4 = report.advice.find(item => item.source === 'DSC-04')
184
+ const c14 = report.advice.find(item => item.source === 'DSC-14')
185
+ assert.equal(c4.key, 'C-4')
186
+ assert.equal(c4.severity, 'FAIL')
187
+ assert.equal(c4.version, '1.2.0')
188
+ assert.deepEqual(c4.evidence, ['index.mjs:1'])
189
+ assert.equal(c4.docsUrl, `${DOCS_BASE}docs/levels.md#entry`)
190
+ assert.equal(c14.key, 'C-14')
191
+ assert.equal(c14.docsUrl, `${DOCS_BASE}docs/entry.md#the-two-claims`)
192
+ })
193
+
194
+ test('advice uses detail severity even when the check was adjusted to WARN', () => {
195
+ const checks = [
196
+ { id: 'DSC-01', clause: 'C-1', layer: 'L0', result: 'PASS', coverage: 'full', reason: '', details: [], evidence: [] },
197
+ {
198
+ id: 'DSC-02', clause: 'C-2', layer: 'L0', result: 'FAIL', coverage: 'full', reason: '',
199
+ details: [{ item: 'C-2.1', result: 'FAIL', reason: 'no engines', evidence: ['package.json#engines'] }],
200
+ evidence: [], advisory: false,
201
+ },
202
+ { id: 'DSC-11', clause: 'C-11', layer: 'L0', result: 'PASS', coverage: 'full', reason: '', details: [], evidence: [] },
203
+ { id: 'DSC-12', clause: 'C-12', layer: 'L0', result: 'PASS', coverage: 'full', reason: '', details: [], evidence: [] },
204
+ { id: 'DSC-13', clause: 'C-13', layer: 'L0', result: 'PASS', coverage: 'full', reason: '', details: [], evidence: [] },
205
+ { id: 'DSC-14', clause: 'C-14', layer: 'L1', result: 'PASS', coverage: 'full', reason: '', details: [], evidence: [] },
206
+ ]
207
+ const report = buildReport({
208
+ subject: { mode: 'package', profile: 'package', kind: 'plugin', kindInferred: false, name: 'x', version: '1.0.0', path: here },
209
+ anchor: { repo: 'x', commit: '0'.repeat(40), scanner: 'x', dsh: '0.2.2-alpha.2', cordis: '4.0.6-alpha.1', runtime: null },
210
+ checks,
211
+ scope: 'entry',
212
+ profile: 'package',
213
+ kind: 'plugin',
214
+ })
215
+ assert.equal(report.checks.find(item => item.id === 'DSC-02').result, 'WARN')
216
+ const item = report.advice.find(entry => entry.key === 'C-2.1')
217
+ assert.equal(item.severity, 'FAIL')
218
+ assert.equal(item.source, 'DSC-02')
219
+ assert.match(item.reason, /no engines/)
220
+ })
221
+
222
+ test('duplicate details merge evidence and count once per check and key', () => {
223
+ const checks = [{
224
+ id: 'DSC-11', clause: 'C-11', layer: 'L0', result: 'WARN', coverage: 'full', reason: '',
225
+ details: [
226
+ { item: 'C-11.2', result: 'WARN', reason: 'trigger tools', evidence: ['a.mjs:1'] },
227
+ { item: 'C-11.1', result: 'WARN', reason: 'missing capabilities', evidence: ['package.json#dsh.capabilities'] },
228
+ { item: 'C-11.2', result: 'WARN', reason: 'trigger web', evidence: ['b.mjs:2'] },
229
+ ],
230
+ evidence: [], advisory: true,
231
+ }]
232
+ const report = buildReport({
233
+ subject: { mode: 'package', profile: 'package', kind: 'plugin', kindInferred: false, name: 'x', version: '1.0.0', path: here },
234
+ anchor: { repo: 'x', commit: '0'.repeat(40), scanner: 'x', dsh: '0.2.2-alpha.2', cordis: '4.0.6-alpha.1', runtime: null },
235
+ checks,
236
+ scope: 'ds-c',
237
+ profile: 'package',
238
+ kind: 'plugin',
239
+ })
240
+ const merged = report.advice.find(item => item.key === 'C-11.2')
241
+ assert.ok(merged)
242
+ assert.equal(merged.occurrences, 2)
243
+ assert.deepEqual(merged.evidence, ['a.mjs:1', 'b.mjs:2'])
244
+ assert.equal(merged.advisory, true)
245
+ assert.equal(report.advice.filter(item => item.source === 'DSC-11').length, 2)
246
+ })
247
+
248
+ test('buildReport includes advice by default and omits it on advice:false', () => {
249
+ const base = {
250
+ subject: { mode: 'package', profile: 'package', kind: 'plugin', kindInferred: false, name: 'x', version: '1.0.0', path: here },
251
+ anchor: { repo: 'x', commit: '0'.repeat(40), scanner: 'x', dsh: '0.2.2-alpha.2', cordis: '4.0.6-alpha.1', runtime: null },
252
+ checks: [],
253
+ scope: 'ds-c',
254
+ profile: 'package',
255
+ kind: 'plugin',
256
+ }
257
+ const on = buildReport(base)
258
+ const off = buildReport({ ...base, advice: false })
259
+ assert.ok(Array.isArray(on.advice))
260
+ assert.equal(on.advice.length, 0)
261
+ assert.equal(Object.hasOwn(off, 'advice'), false)
262
+ })
263
+
264
+ test('every rule docs anchor exists in the local guide repository', t => {
265
+ if (!fs.existsSync(guideDocs)) {
266
+ t.skip(`guide docs not checked out at ${guideDocs}`)
267
+ return
268
+ }
269
+ const cache = new Map()
270
+ for (const [key, rule] of Object.entries(RULES)) {
271
+ const hash = rule.docs.indexOf('#')
272
+ const file = rule.docs.slice(0, hash)
273
+ const anchor = rule.docs.slice(hash + 1)
274
+ assert.ok(hash > 0 && !anchor.includes('#'), `${key}: docs must be <file>#<anchor>`)
275
+ const relative = file.replace(/^docs\//, '')
276
+ const absolute = path.join(guideDocs, relative)
277
+ assert.ok(fs.existsSync(absolute), `${key}: missing guide file ${file}`)
278
+ if (!cache.has(file)) cache.set(file, headingSlugs(fs.readFileSync(absolute, 'utf8')))
279
+ assert.ok(cache.get(file).has(anchor), `${key}: missing anchor ${rule.docs}`)
280
+ }
281
+ })
282
+
283
+ test('joinAdvice is pure and leaves the report untouched', () => {
284
+ const checks = [{
285
+ id: 'DSC-04', clause: 'C-4', layer: 'L1', result: 'FAIL', coverage: 'full',
286
+ reason: 'load failed', details: [], evidence: ['index.mjs'], method: 'M', advisory: false,
287
+ }]
288
+ const report = {
289
+ checks,
290
+ standard: { id: 'DS-C', version: '1.0.0' },
291
+ }
292
+ const snapshot = JSON.stringify(report)
293
+ const advice = joinAdvice(report, remediationSpec, DOCS_BASE)
294
+ assert.equal(JSON.stringify(report), snapshot)
295
+ assert.equal(advice.length, 1)
296
+ assert.equal(advice[0].key, 'C-4')
297
+ // A flat rule map (no version metadata) still gets the package version.
298
+ const bare = joinAdvice(report, RULES, DOCS_BASE)
299
+ assert.equal(bare[0].version, packageJson.version)
300
+ })
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 未发布
2
+
3
+ - **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
+ - **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
+ - **安全**:新增 `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
+ - 测试 137 → 149(新增 `40-checker/test/flow-evidence.test.mjs`:v1 不再 pass、v2 伪造 checks/exitCode 不 pass、篡改链路 invalid、blocked 不判 fail、collector 产物可被 audit 读取、失败五要素提示、env 白名单)。
7
+
8
+ ## 1.2.0 — 2026-11-06
9
+
10
+ - 新增**建议管道**:`spec/remediation.json` 提供 68 条规则(`details[].item` ∪ `checks[].clause` ∪ G0–G4),`40-checker/src/remediation.mjs` 的纯函数 `joinAdvice(report, rules, docsBase)` 把 FAIL/WARN detail 连接成 `advice[]`;无 detail 的运行时 FAIL 用 `check.clause` 兜底(C-4/C-14)。
11
+ - `buildReport()` 输出新增**加性** `advice[]`(key/severity/title/why/fix/docsUrl/source/version/evidence),不改任何 check 构造、不改判定与 exit code;CLI 新增 `--advice`(默认开)与 `--no-advice`。
12
+ - 建议文档统一指向 `dsh-plugin-guide` 的 v0.4.3:`docsUrl = https://github.com/PerryLink/dsh-plugin-guide/blob/v0.4.3/ + docs`;规则表 `docs` 只存 `文件#锚点`。
13
+ - 新增 `40-checker/test/remediation.test.mjs`:规则表覆盖全集、fixture 无 `ruleMissing`、本地 guide 锚点存在、severity/merge/兜底/纯函数回归。测试 128 → 137。
14
+
1
15
  ## 1.1.2 — 2026-11-04
2
16
 
3
17
  - 修复 1.1.1 的发布失败:vendored `@babel/parser` 的 `node_modules/` 被根 `.gitignore` 忽略,CI checkout 缺依赖导致 `npm test` 失败。现在 force-add 四个 vendored 包(约 2 MB),并加 1.1.2 的回归说明。
@@ -65,6 +79,12 @@
65
79
 
66
80
  # Changelog
67
81
 
82
+ ## 2026-11-04 — 0.2.2-alpha.3 baseline in the three-level docs
83
+
84
+ - Add the 0.2.2-alpha.3 baseline table and six PR-review evidence gates to `35`.
85
+ - Carry the baseline into `31` (ENTRY), `32` (FLOW R6) and the `33` BOOST card.
86
+ - Fix the ENTRY checklist: Node `>=24.9.0`, `engines.dsh` precedence from 0.2.1-alpha.4, frozen lockfile / release-age evidence.
87
+
68
88
  ## 2026-11-03 — Node 24.9 baseline
69
89
  - Require Node >=24.9.0 to match DeepSeek Harness 0.2.2-alpha.3.
70
90
 
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Public umbrella entry point for the FLOW evidence collector.
3
+ // The implementation lives in 40-checker/.
4
+ import '../40-checker/bin/dsh-flow-collect.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-standards",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "DSH plugin standards and conformance checks: ENTRY/FLOW checks plus BOOST guidance. Non-official; no certification or ranking.",
5
5
  "type": "module",
6
6
  "main": "40-checker/src/index.mjs",
package/spec/README.md CHANGED
@@ -14,7 +14,9 @@ Machine-readable:
14
14
  - [levels.json](levels.json) — levels, gates, evidence states, crosswalk.
15
15
  - [clauses.json](clauses.json) — stable clause IDs mapped to gates (`clause-map.mjs`).
16
16
  - [reference-suite.json](reference-suite.json) — versioned samples, anchors, change rules.
17
+ - [remediation.json](remediation.json) — 68-key advice table for the additive `advice[]` report field (docs anchors + severity hints).
17
18
  - [schemas/evidence.schema.json](schemas/evidence.schema.json) — `dsh-levels/1` record.
19
+ - [flow-evidence.schema.json](flow-evidence.schema.json) — `dsh-flow-evidence/2` FLOW chain (J1–J8 checks, subject/host binding, `evidenceHash`). v1 and bare `{Jx:{status}}` rows are deprecated and never count as a pass.
18
20
  - [schemas/attestation.schema.json](schemas/attestation.schema.json) — `dsh-attestation/1` quality record.
19
21
  - [schemas/report.schema.json](schemas/report.schema.json) — `dsh-report/1` inspection report (no grade).
20
22
  - [../40-checker/scripts/level-audit.mjs](../40-checker/scripts/level-audit.mjs) — one-command audit.
@@ -4,5 +4,26 @@ Usability surface: install, configure, first success, permissions, upgrade,
4
4
  uninstall, diagnostics, client/server states.
5
5
 
6
6
  - Normative text: [../../32-DSH-FLOW顺畅规范.md](../../32-DSH-FLOW顺畅规范.md)
7
- - Journeys: J1–J8; only J1 has a runtime harness (`40-checker/scripts/journey-runtime.mjs`).
8
- - Current: J1 6 PASS + 1 WARN; J2–J8 not-run.
7
+ - Journeys: J1–J8 (R1–R8 in the normative text).
8
+ - Runtime probe: `40-checker/scripts/journey-runtime.mjs` (J1/J2/J4/J6); J3/J5/J7/J8
9
+ are static preflight or manual and never count as a runtime pass.
10
+ - Evidence: `dsh-flow-evidence/2` ([../flow-evidence.schema.json](../flow-evidence.schema.json)).
11
+ Collect it with `node 40-checker/bin/dsh-flow-collect.mjs --package <dir> --out flow-evidence.json`.
12
+
13
+ ## Evidence chain rules
14
+
15
+ - `level-audit.mjs --flow-evidence` accepts only `dsh-flow-evidence/2`. v1
16
+ (`dsh-flow-journey/1`, `dsh-flow-runtime/1`) and bare `{ "J1": { "status": "pass" } }`
17
+ rows are reported `deprecated` and never count as a pass.
18
+ - A journey counts as pass only when it has at least one `checks[]` element,
19
+ every check has `exitCode === 0`, every assertion holds, and at least one
20
+ check is an observed (non-`static`) command. `warn`, `blocked`, `na`,
21
+ `static` and `not-run` never count toward 8/8.
22
+ - `static` preflight is not runtime: a static PASS is a preflight result only.
23
+ - The collector copies the package to a temp dir, runs against the copy, and
24
+ records `subject.treeSha256` before/after; a changed tree fails the run.
25
+ - Child environments are built from an allowlist; tokens/keys/secrets never
26
+ reach the inspected plugin.
27
+ - `claims.claimLevel` is `self-attested` for a local collector run,
28
+ `ci-attested` with `--ci`. Neither is `verified`, a certification, or a
29
+ security audit; only a sandbox run may be described as runtime-attested.
@@ -0,0 +1,217 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/PerryLink/dsh-standards/spec/flow-evidence.schema.json",
4
+ "title": "DSH FLOW evidence chain",
5
+ "description": "One collected FLOW result (J1-J8) bound to a package, a host, a timestamp, and a chain hash. Structural validation only: it does not prove the commands really ran. v1 (dsh-flow-journey/1, dsh-flow-runtime/1) and bare {Jx:{status}} documents are deprecated and never count as a pass. `level-audit.mjs --flow-evidence` accepts this document only.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema",
10
+ "generatedAt",
11
+ "producer",
12
+ "subject",
13
+ "host",
14
+ "journeys",
15
+ "claims",
16
+ "chain"
17
+ ],
18
+ "properties": {
19
+ "schema": {
20
+ "const": "dsh-flow-evidence/2"
21
+ },
22
+ "evidenceId": {
23
+ "type": "string",
24
+ "minLength": 1
25
+ },
26
+ "generatedAt": {
27
+ "type": "string",
28
+ "format": "date-time"
29
+ },
30
+ "producer": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "required": ["command", "at"],
34
+ "properties": {
35
+ "command": {
36
+ "type": "array",
37
+ "minItems": 1,
38
+ "items": { "type": "string" }
39
+ },
40
+ "at": {
41
+ "type": "string",
42
+ "format": "date-time"
43
+ },
44
+ "kind": {
45
+ "enum": ["local", "ci", "sandbox"]
46
+ },
47
+ "runner": { "type": "string" },
48
+ "actor": { "type": "string" },
49
+ "runUrl": { "type": "string" }
50
+ }
51
+ },
52
+ "subject": {
53
+ "type": "object",
54
+ "additionalProperties": false,
55
+ "required": ["name", "version", "integrity", "tarballSha256", "commit", "treeSha256"],
56
+ "properties": {
57
+ "name": { "type": "string", "minLength": 1 },
58
+ "version": { "type": "string", "minLength": 1 },
59
+ "integrity": {
60
+ "oneOf": [
61
+ { "type": "string", "pattern": "^(sha512|sha256)-[A-Za-z0-9+/=]+$" },
62
+ { "type": "null" }
63
+ ]
64
+ },
65
+ "tarballSha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
66
+ "commit": {
67
+ "oneOf": [
68
+ { "type": "string", "pattern": "^[0-9a-f]{40}$" },
69
+ { "type": "null" }
70
+ ]
71
+ },
72
+ "treeSha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
73
+ "treeSha256After": {
74
+ "oneOf": [
75
+ { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
76
+ { "type": "null" }
77
+ ]
78
+ },
79
+ "treeStable": { "type": "boolean" },
80
+ "treeExclude": {
81
+ "type": "array",
82
+ "items": { "type": "string" }
83
+ },
84
+ "source": { "type": "string" }
85
+ }
86
+ },
87
+ "host": {
88
+ "type": "object",
89
+ "additionalProperties": false,
90
+ "required": ["dsh", "node", "platform"],
91
+ "properties": {
92
+ "dsh": {
93
+ "oneOf": [
94
+ { "type": "string", "minLength": 1 },
95
+ { "type": "null" }
96
+ ]
97
+ },
98
+ "dshCommit": {
99
+ "oneOf": [
100
+ { "type": "string", "pattern": "^[0-9a-f]{7,40}$" },
101
+ { "type": "null" }
102
+ ]
103
+ },
104
+ "cordis": { "type": "string" },
105
+ "node": { "type": "string", "minLength": 1 },
106
+ "platform": { "type": "string", "minLength": 1 }
107
+ }
108
+ },
109
+ "journeys": {
110
+ "type": "object",
111
+ "additionalProperties": false,
112
+ "required": ["J1", "J2", "J3", "J4", "J5", "J6", "J7", "J8"],
113
+ "patternProperties": {
114
+ "^J[1-8]$": { "$ref": "#/$defs/journey" }
115
+ }
116
+ },
117
+ "claims": {
118
+ "type": "object",
119
+ "additionalProperties": false,
120
+ "required": ["claimLevel"],
121
+ "properties": {
122
+ "claimLevel": {
123
+ "enum": ["self-attested", "ci-attested", "sandbox-attested"]
124
+ },
125
+ "notClaimed": {
126
+ "type": "array",
127
+ "items": { "type": "string" }
128
+ },
129
+ "method": { "type": "string" }
130
+ }
131
+ },
132
+ "chain": {
133
+ "type": "object",
134
+ "additionalProperties": false,
135
+ "required": ["evidenceHash"],
136
+ "properties": {
137
+ "evidenceHash": { "$ref": "#/$defs/sha256" }
138
+ }
139
+ },
140
+ "conclusion": {
141
+ "enum": ["flow-pass", "flow-warn", "flow-blocked", "flow-fail"]
142
+ },
143
+ "signature": {
144
+ "oneOf": [{ "type": "object" }, { "type": "null" }]
145
+ }
146
+ },
147
+ "$defs": {
148
+ "sha256": {
149
+ "type": "string",
150
+ "pattern": "^sha256:[0-9a-f]{64}$"
151
+ },
152
+ "log": {
153
+ "type": "object",
154
+ "additionalProperties": false,
155
+ "required": ["path", "sha256"],
156
+ "properties": {
157
+ "path": { "type": "string", "minLength": 1 },
158
+ "sha256": { "$ref": "#/$defs/sha256" }
159
+ }
160
+ },
161
+ "assertion": {
162
+ "type": "object",
163
+ "additionalProperties": false,
164
+ "required": ["claim", "observed"],
165
+ "properties": {
166
+ "claim": { "type": "string", "minLength": 1 },
167
+ "observed": { "type": "boolean" },
168
+ "detail": { "type": "string" }
169
+ }
170
+ },
171
+ "check": {
172
+ "type": "object",
173
+ "additionalProperties": false,
174
+ "required": ["id", "command", "exitCode", "startedAt", "endedAt", "stdout", "assertions"],
175
+ "properties": {
176
+ "id": { "type": "string", "pattern": "^J[1-8](?:\\.[A-Za-z0-9._-]+)?$" },
177
+ "kind": { "enum": ["observed", "static", "manual"] },
178
+ "command": {
179
+ "type": "array",
180
+ "minItems": 1,
181
+ "items": { "type": "string" }
182
+ },
183
+ "exitCode": { "type": "integer", "minimum": 0, "maximum": 255 },
184
+ "startedAt": { "type": "string", "format": "date-time" },
185
+ "endedAt": { "type": "string", "format": "date-time" },
186
+ "stdout": { "$ref": "#/$defs/log" },
187
+ "stderr": { "$ref": "#/$defs/log" },
188
+ "assertions": {
189
+ "type": "array",
190
+ "minItems": 1,
191
+ "items": { "$ref": "#/$defs/assertion" }
192
+ },
193
+ "durationMs": { "type": "number", "minimum": 0 }
194
+ }
195
+ },
196
+ "journey": {
197
+ "type": "object",
198
+ "additionalProperties": false,
199
+ "required": ["checks"],
200
+ "properties": {
201
+ "status": {
202
+ "enum": ["pass", "fail", "warn", "blocked", "static", "na", "not-run"]
203
+ },
204
+ "reason": { "type": "string" },
205
+ "kind": { "type": "string" },
206
+ "limitations": {
207
+ "type": "array",
208
+ "items": { "type": "string" }
209
+ },
210
+ "checks": {
211
+ "type": "array",
212
+ "items": { "$ref": "#/$defs/check" }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
package/spec/levels.json CHANGED
@@ -156,6 +156,8 @@
156
156
  "machine": {
157
157
  "journey": "40-checker/scripts/journey.mjs",
158
158
  "journeyRuntime": "40-checker/scripts/journey-runtime.mjs",
159
+ "collector": "40-checker/bin/dsh-flow-collect.mjs",
160
+ "flowEvidence": "spec/flow-evidence.schema.json",
159
161
  "audit": "40-checker/scripts/level-audit.mjs"
160
162
  }
161
163
  },