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
@@ -6,34 +6,46 @@
6
6
  * J3 (config validation), J5 (permissions), J7 (observability) and J8 (client
7
7
  * shapes) are reported `not-run`: this probe does not pretend to cover them.
8
8
  *
9
+ * Evidence mode (`--log-dir`): every command records its argv, exit code,
10
+ * ISO start/end time, and stdout/stderr log path + sha256. `flow-collect.mjs`
11
+ * wraps those checks into `dsh-flow-evidence/2`. Without `--log-dir` the older
12
+ * summary shape is preserved for existing consumers.
13
+ *
14
+ * Safety: the child environment is built from an allowlist (no tokens, keys,
15
+ * credentials), HOME/DSH_HOME/TMPDIR point at a fresh temp tree, and the probe
16
+ * never grants an exact-version exemption unless the caller explicitly asks.
17
+ *
9
18
  * Usage:
10
- * node scripts/journey-runtime.mjs --package <dir> [--dsh <bin>] [--previous <dir>] [--json]
19
+ * node scripts/journey-runtime.mjs --package <dir> [--dsh <bin>] [--previous <dir>]
20
+ * [--log-dir <dir>] [--allow-exemption] [--json]
11
21
  */
12
22
  import fs from 'node:fs'
13
23
  import os from 'node:os'
14
24
  import path from 'node:path'
15
25
  import { spawnSync } from 'node:child_process'
26
+ import { sanitizedEnv, envPolicy } from '../src/sandbox-env.mjs'
27
+ import { sha256Value } from '../src/flow-evidence.mjs'
16
28
 
17
29
  function parse(argv) {
18
- const out = { package: null, previous: null, dsh: process.env.DSH_BIN || null, allowExemption: false, json: false, timeout: 120_000 }
30
+ const out = {
31
+ package: null, previous: null, dsh: process.env.DSH_BIN || null, allowExemption: false,
32
+ json: false, timeout: 120_000, logDir: null,
33
+ }
19
34
  for (let i = 0; i < argv.length; i += 1) {
20
35
  if (argv[i] === '--package') out.package = argv[++i]
21
36
  else if (argv[i] === '--previous') out.previous = argv[++i]
22
37
  else if (argv[i] === '--dsh') out.dsh = argv[++i]
38
+ else if (argv[i] === '--log-dir') out.logDir = argv[++i]
23
39
  else if (argv[i] === '--allow-exemption') out.allowExemption = true
24
40
  else if (argv[i] === '--json') out.json = true
25
41
  else if (argv[i] === '--timeout') out.timeout = Number(argv[++i])
26
- else if (argv[i] === '--help' || argv[i] === '-h') { console.log('usage: node scripts/journey-runtime.mjs --package <dir> [--previous <dir>] [--dsh <bin>] [--allow-exemption] [--json]'); process.exit(0) }
42
+ else if (argv[i] === '--help' || argv[i] === '-h') { console.log('usage: node scripts/journey-runtime.mjs --package <dir> [--previous <dir>] [--dsh <bin>] [--log-dir <dir>] [--allow-exemption] [--json]'); process.exit(0) }
27
43
  else throw new Error(`unknown option: ${argv[i]}`)
28
44
  }
29
45
  if (!out.package) throw new Error('--package is required')
30
46
  return out
31
47
  }
32
48
 
33
- function sh(cmd, args, env, timeout) {
34
- return spawnSync(cmd, args, { encoding: 'utf8', timeout, env, maxBuffer: 8 * 1024 * 1024 })
35
- }
36
-
37
49
  /** Find the row id whose `name:` is the package name or a relative server entry. */
38
50
  function rowIdFromDump(dump, pkgName) {
39
51
  for (const block of dump.split(/\n(?=- )/)) {
@@ -60,90 +72,200 @@ function main() {
60
72
  const manifest = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'), 'utf8'))
61
73
  const previous = options.previous ? path.resolve(options.previous) : null
62
74
  const previousManifest = previous ? JSON.parse(fs.readFileSync(path.join(previous, 'package.json'), 'utf8')) : null
63
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-journey-'))
75
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-journey-'))
76
+ const home = path.join(root, 'home')
77
+ const tmp = path.join(root, 'tmp')
78
+ fs.mkdirSync(home, { recursive: true })
79
+ fs.mkdirSync(tmp, { recursive: true })
80
+ const logDir = options.logDir ? path.resolve(options.logDir) : null
81
+ if (logDir) fs.mkdirSync(logDir, { recursive: true })
64
82
  const profile = 'flow-journey'
65
- const env = { ...process.env, DSH_HOME: home }
66
- delete env.DEEPSEEK_API_KEY
67
- delete env.OPENAI_API_KEY
83
+ const env = sanitizedEnv({
84
+ DSH_HOME: home,
85
+ HOME: home,
86
+ USERPROFILE: home,
87
+ TMPDIR: tmp,
88
+ TEMP: tmp,
89
+ TMP: tmp,
90
+ NPM_CONFIG_USERCONFIG: path.join(home, '.npmrc'),
91
+ })
68
92
  const dsh = options.dsh || (process.platform === 'win32' ? 'dsh.cmd' : 'dsh')
69
93
  const checks = []
94
+ const blockers = []
70
95
  let dshVersion = null
71
- const add = (id, result, reason, extra = {}) => checks.push({ id, result, reason, ...extra })
72
- try {
73
- const version = sh(dsh, ['--version'], env, 30_000)
74
- dshVersion = (version.stdout || '').trim()
75
- const create = sh(dsh, ['--profile', profile, '--from-default-profile', 'headless', '--dump-config'], env, options.timeout)
76
- add('J1.profile', create.status === 0 ? 'PASS' : 'FAIL', `create profile exit=${create.status}`, { stderr: create.stderr.slice(0, 500) })
77
- let addResult = sh(dsh, ['plugin', '--profile', profile, 'add', pkg], env, options.timeout)
78
- let addText = `${addResult.stdout}${addResult.stderr}`
79
- const rejected = addResult.status !== 0 && /installation rejected/.test(addText)
80
- // Never grant an exact-version exemption by default: a gate rejection is a
81
- // measured BLOCKED result, not something the harness may silently accept.
82
- if (rejected && (options.allowExemption || process.env.DSH_JOURNEY_ALLOW_EXEMPTION === '1')) {
83
- const allow = sh(dsh, ['plugin', '--profile', profile, 'allow-version', `${manifest.name}@${manifest.version}`, '--dsh-version', dshVersion, '--accept-risk'], env, options.timeout)
84
- add('J1.exemption', allow.status === 0 ? 'WARN' : 'FAIL', `allow-version exit=${allow.status}`, { stderr: allow.stderr.slice(0, 500) })
85
- addResult = sh(dsh, ['plugin', '--profile', profile, 'add', pkg], env, options.timeout)
86
- addText = `${addResult.stdout}${addResult.stderr}`
87
- }
88
- add('J1.install', rejected && !options.allowExemption && process.env.DSH_JOURNEY_ALLOW_EXEMPTION !== '1' ? 'BLOCKED' : addResult.status === 0 ? 'PASS' : 'FAIL', rejected ? 'host compatibility gate rejected the install (no exemption granted)' : `add exit=${addResult.status}`, { stderr: addText.slice(0, 800) })
89
- const dump1 = sh(dsh, ['--profile', profile, '--dump-config'], env, options.timeout)
90
- add('J1.dump', dump1.status === 0 ? 'PASS' : 'FAIL', `dump exit=${dump1.status}`, { stderr: dump1.stderr.slice(0, 500) })
91
- const appears = dump1.stdout.includes(manifest.name) || /fixture-bundle-server/.test(dump1.stdout)
92
- add('J1.layer', appears ? 'PASS' : 'FAIL', appears ? 'layer/entry appears in dump' : 'plugin/bundle not visible in dump')
93
-
94
- // J2: disable/enable through a profile overlay. A missing row id is a real
95
- // failure of the probe (not a pass): report it and skip the toggle checks.
96
- const rowId = rowIdFromDump(dump1.stdout, manifest.name)
97
- if (!rowId) {
98
- add('J2.toggle', 'not-run', 'could not locate the inserted row id in dump-config')
99
- } else {
100
- const overlay = path.join(home, 'j2-overlay.yml')
101
- fs.writeFileSync(overlay, `- id: ${rowId}\n disabled: true\n`)
102
- const disabled = sh(dsh, ['--profile', profile, '--patch', overlay, '--dump-config'], env, options.timeout)
103
- const disabledOK = disabled.status === 0 && /disabled:\s*true/.test(disabled.stdout)
104
- add('J2.disable', disabledOK ? 'PASS' : 'FAIL', disabledOK ? `row ${rowId} disabled` : `disable exit=${disabled.status}`, { stderr: disabled.stderr.slice(0, 500) })
105
- fs.writeFileSync(overlay, `- id: ${rowId}\n disabled: false\n`)
106
- const enabled = sh(dsh, ['--profile', profile, '--patch', overlay, '--dump-config'], env, options.timeout)
107
- const enabledOK = enabled.status === 0 && !new RegExp(`id:\\s*${rowId}[\\s\\S]{0,80}disabled:\\s*true`).test(enabled.stdout)
108
- add('J2.enable', enabledOK ? 'PASS' : 'FAIL', enabledOK ? `row ${rowId} enabled again` : `enable exit=${enabled.status}`, { stderr: enabled.stderr.slice(0, 500) })
96
+
97
+ const writeLog = (name, text) => {
98
+ if (!logDir) return null
99
+ const file = path.join(logDir, name)
100
+ fs.writeFileSync(file, text ?? '')
101
+ return { path: name.split(path.sep).join('/'), sha256: sha256Value(text ?? '') }
102
+ }
103
+
104
+ /** Run one command and record argv/exit/times/logs (never process.env). */
105
+ const exec = (label, cmd, args, timeout) => {
106
+ const startedAt = new Date().toISOString()
107
+ const t0 = Date.now()
108
+ const result = spawnSync(cmd, args, {
109
+ encoding: 'utf8', timeout, env, maxBuffer: 8 * 1024 * 1024, killSignal: 'SIGKILL',
110
+ })
111
+ const endedAt = new Date().toISOString()
112
+ const stdoutText = result.stdout ?? ''
113
+ const stderrText = result.stderr ?? ''
114
+ const timedOut = result.error?.code === 'ETIMEDOUT'
115
+ return {
116
+ command: [cmd, ...args],
117
+ exitCode: Number.isInteger(result.status) ? result.status : timedOut ? 124 : 127,
118
+ startedAt,
119
+ endedAt,
120
+ durationMs: Date.now() - t0,
121
+ stdoutText,
122
+ stderrText,
123
+ stdout: writeLog(`${label}.out.log`, stdoutText),
124
+ stderrLog: writeLog(`${label}.err.log`, stderrText),
125
+ timedOut,
126
+ error: result.error ? result.error.message : null,
109
127
  }
128
+ }
110
129
 
111
- const remove = sh(dsh, ['plugin', '--profile', profile, 'remove', manifest.name], env, options.timeout)
112
- add('J1.remove', remove.status === 0 ? 'PASS' : 'FAIL', `remove exit=${remove.status}`, { stderr: (remove.stderr || remove.stdout).slice(0, 800) })
113
- const dump2 = sh(dsh, ['--profile', profile, '--dump-config'], env, options.timeout)
114
- const gone = !dump2.stdout.includes(manifest.name)
115
- add('J1.clean', remove.status === 0 && gone ? 'PASS' : 'FAIL', gone ? 'layer/dependency gone after remove' : 'still present after remove')
130
+ const add = (id, result, reason, record = null, assertions = []) => {
131
+ checks.push({
132
+ id,
133
+ result,
134
+ reason,
135
+ command: record ? record.command : null,
136
+ exitCode: record ? record.exitCode : null,
137
+ startedAt: record ? record.startedAt : null,
138
+ endedAt: record ? record.endedAt : null,
139
+ durationMs: record ? record.durationMs : null,
140
+ stdout: record ? record.stdout : null,
141
+ stderrLog: record ? record.stderrLog : null,
142
+ stderr: record ? String(record.stderrText).slice(0, 800) : null,
143
+ assertions,
144
+ })
145
+ }
146
+ const claim = (text, observed) => ({ claim: text, observed })
116
147
 
117
- // J4: with no API key, the tree must load far enough to reach the model
118
- // boundary. This is a tree-load proof, not a successful first task.
119
- if (process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY) {
120
- add('J4.tree-load', 'not-run', 'an API key is present in the launching environment')
148
+ try {
149
+ const version = exec('J1.version', dsh, ['--version'], 30_000)
150
+ dshVersion = (version.stdoutText || '').trim() || null
151
+ if (version.exitCode !== 0) {
152
+ blockers.push({ journey: 'J1', check: 'J1.version', reason: `dsh executable not usable: ${dsh} (exit ${version.exitCode})` })
153
+ add('J1.version', 'BLOCKED', `dsh --version failed (exit ${version.exitCode})`, version, [claim('dsh --version succeeded', false)])
154
+ add('J2.toggle', 'not-run', 'skipped: no usable dsh executable')
155
+ add('J4.tree-load', 'not-run', 'skipped: no usable dsh executable')
156
+ add('J6.upgrade', 'not-run', 'skipped: no usable dsh executable')
157
+ add('J6.rollback', 'not-run', 'skipped: no usable dsh executable')
121
158
  } else {
122
- const boot = sh(dsh, ['plugin', '--profile', profile, 'add', pkg], env, options.timeout)
123
- if (boot.status !== 0) add('J4.tree-load', 'FAIL', `reinstall exit=${boot.status}`, { stderr: boot.stderr.slice(0, 500) })
124
- else {
125
- const run = sh(dsh, ['--profile', profile, 'Reply with one short sentence.'], env, 60_000)
126
- const text = `${run.stdout}${run.stderr}`
127
- const reached = run.status !== 0 && /MISSING_CREDENTIAL|no API key|AUTH|credential/i.test(text)
128
- add('J4.tree-load', reached ? 'PASS' : 'FAIL', reached ? 'keyless boot reached the credential boundary' : `boot exit=${run.status}; no credential-boundary message`, { stderr: text.slice(0, 500) })
159
+ const create = exec('J1.profile', dsh, ['--profile', profile, '--from-default-profile', 'headless', '--dump-config'], options.timeout)
160
+ add('J1.profile', create.exitCode === 0 ? 'PASS' : 'FAIL', `create profile exit=${create.exitCode}`, create,
161
+ [claim('create profile from the shipped headless template', create.exitCode === 0)])
162
+
163
+ if (create.exitCode !== 0) {
164
+ blockers.push({ journey: 'J1', check: 'J1.profile', reason: `profile creation failed (exit ${create.exitCode})` })
165
+ add('J2.toggle', 'not-run', 'skipped: profile could not be created')
166
+ add('J4.tree-load', 'not-run', 'skipped: profile could not be created')
167
+ add('J6.upgrade', 'not-run', 'skipped: profile could not be created')
168
+ add('J6.rollback', 'not-run', 'skipped: profile could not be created')
169
+ } else {
170
+ runJourneys()
129
171
  }
130
172
  }
131
173
 
132
- // J6: upgrade then rollback. Only meaningful when a previous package is
133
- // supplied; otherwise it is honestly not-run.
134
- if (!previous || !previousManifest) {
135
- add('J6.upgrade', 'not-run', 'no --previous package supplied')
136
- add('J6.rollback', 'not-run', 'no --previous package supplied')
137
- } else {
138
- const u1 = sh(dsh, ['plugin', '--profile', profile, 'add', previous], env, options.timeout)
139
- const v1 = installedVersion(home, profile, manifest.name)
140
- add('J6.upgrade', u1.status === 0 && v1 === previousManifest.version ? 'PASS' : 'FAIL', `upgrade ${previousManifest.version}->${manifest.version} exit=${u1.status}, installed=${v1}`, { stderr: (u1.stderr || u1.stdout).slice(0, 500) })
141
- const u2 = sh(dsh, ['plugin', '--profile', profile, 'add', pkg], env, options.timeout)
142
- const v2 = installedVersion(home, profile, manifest.name)
143
- add('J6.upgrade-current', u2.status === 0 && v2 === manifest.version ? 'PASS' : 'FAIL', `current install exit=${u2.status}, installed=${v2}`, { stderr: (u2.stderr || u2.stdout).slice(0, 500) })
144
- const u3 = sh(dsh, ['plugin', '--profile', profile, 'add', previous], env, options.timeout)
145
- const v3 = installedVersion(home, profile, manifest.name)
146
- add('J6.rollback', u3.status === 0 && v3 === previousManifest.version ? 'PASS' : 'FAIL', `rollback exit=${u3.status}, installed=${v3}`, { stderr: (u3.stderr || u3.stdout).slice(0, 500) })
174
+ function runJourneys() {
175
+ const addResult = exec('J1.install', dsh, ['plugin', '--profile', profile, 'add', pkg], options.timeout)
176
+ const addText = `${addResult.stdoutText}${addResult.stderrText}`
177
+ const rejected = addResult.exitCode !== 0 && /installation rejected/.test(addText)
178
+ const exempt = options.allowExemption || process.env.DSH_JOURNEY_ALLOW_EXEMPTION === '1'
179
+ // Never grant an exact-version exemption by default: a gate rejection is a
180
+ // measured BLOCKED result, not something the harness may silently accept.
181
+ if (rejected && exempt) {
182
+ const allow = exec('J1.exemption', dsh, ['plugin', '--profile', profile, 'allow-version', `${manifest.name}@${manifest.version}`, '--dsh-version', dshVersion, '--accept-risk'], options.timeout)
183
+ add('J1.exemption', allow.exitCode === 0 ? 'WARN' : 'FAIL', `allow-version exit=${allow.exitCode}`, allow,
184
+ [claim('exact-version exemption granted for this host only', allow.exitCode === 0)])
185
+ const retry = exec('J1.install-retry', dsh, ['plugin', '--profile', profile, 'add', pkg], options.timeout)
186
+ add('J1.install', retry.exitCode === 0 ? 'PASS' : 'FAIL', `install retry exit=${retry.exitCode}`, retry,
187
+ [claim('plugin add succeeds after the explicit exemption', retry.exitCode === 0)])
188
+ } else if (rejected) {
189
+ blockers.push({ journey: 'J1', check: 'J1.install', reason: 'host compatibility gate rejected the install (no exemption granted)' })
190
+ add('J1.install', 'BLOCKED', 'host compatibility gate rejected the install (no exemption granted)', addResult,
191
+ [claim('host compatibility gate admits the plugin', false)])
192
+ add('J2.toggle', 'not-run', 'skipped: install was rejected by the host compatibility gate')
193
+ add('J4.tree-load', 'not-run', 'skipped: install was rejected by the host compatibility gate')
194
+ add('J6.upgrade', 'not-run', 'skipped: install was rejected by the host compatibility gate')
195
+ add('J6.rollback', 'not-run', 'skipped: install was rejected by the host compatibility gate')
196
+ return
197
+ } else {
198
+ add('J1.install', addResult.exitCode === 0 ? 'PASS' : 'FAIL', `add exit=${addResult.exitCode}`, addResult,
199
+ [claim('plugin add exits 0', addResult.exitCode === 0)])
200
+ }
201
+
202
+ const dump1 = exec('J1.dump', dsh, ['--profile', profile, '--dump-config'], options.timeout)
203
+ const appears = dump1.stdoutText.includes(manifest.name) || /fixture-bundle-server/.test(dump1.stdoutText)
204
+ add('J1.dump', dump1.exitCode === 0 ? 'PASS' : 'FAIL', `dump exit=${dump1.exitCode}`, dump1,
205
+ [claim('dump-config exits 0', dump1.exitCode === 0), claim('plugin/bundle layer appears in dump', appears)])
206
+ add('J1.layer', appears ? 'PASS' : 'FAIL', appears ? 'layer/entry appears in dump' : 'plugin/bundle not visible in dump', dump1,
207
+ [claim('layer/entry appears in dump', appears)])
208
+
209
+ // J2: disable/enable through a profile overlay. A missing row id is a real
210
+ // non-result of the probe (not a pass): report it and skip the toggle checks.
211
+ const rowId = rowIdFromDump(dump1.stdoutText, manifest.name)
212
+ if (!rowId) {
213
+ add('J2.toggle', 'not-run', 'could not locate the inserted row id in dump-config')
214
+ } else {
215
+ const overlay = path.join(root, 'j2-overlay.yml')
216
+ fs.writeFileSync(overlay, `- id: ${rowId}\n disabled: true\n`)
217
+ const disabled = exec('J2.disable', dsh, ['--profile', profile, '--patch', overlay, '--dump-config'], options.timeout)
218
+ const disabledOK = disabled.exitCode === 0 && /disabled:\s*true/.test(disabled.stdoutText)
219
+ add('J2.disable', disabledOK ? 'PASS' : 'FAIL', disabledOK ? `row ${rowId} disabled` : `disable exit=${disabled.exitCode}`, disabled,
220
+ [claim(`row ${rowId} shows disabled: true after the overlay`, disabledOK)])
221
+ fs.writeFileSync(overlay, `- id: ${rowId}\n disabled: false\n`)
222
+ const enabled = exec('J2.enable', dsh, ['--profile', profile, '--patch', overlay, '--dump-config'], options.timeout)
223
+ const enabledOK = enabled.exitCode === 0 && !new RegExp(`id:\\s*${rowId}[\\s\\S]{0,80}disabled:\\s*true`).test(enabled.stdoutText)
224
+ add('J2.enable', enabledOK ? 'PASS' : 'FAIL', enabledOK ? `row ${rowId} enabled again` : `enable exit=${enabled.exitCode}`, enabled,
225
+ [claim(`row ${rowId} is no longer disabled after the overlay`, enabledOK)])
226
+ }
227
+
228
+ const remove = exec('J1.remove', dsh, ['plugin', '--profile', profile, 'remove', manifest.name], options.timeout)
229
+ add('J1.remove', remove.exitCode === 0 ? 'PASS' : 'FAIL', `remove exit=${remove.exitCode}`, remove,
230
+ [claim('plugin remove exits 0', remove.exitCode === 0)])
231
+ const dump2 = exec('J1.clean', dsh, ['--profile', profile, '--dump-config'], options.timeout)
232
+ const gone = !dump2.stdoutText.includes(manifest.name)
233
+ add('J1.clean', remove.exitCode === 0 && gone ? 'PASS' : 'FAIL', gone ? 'layer/dependency gone after remove' : 'still present after remove', dump2,
234
+ [claim('plugin remove exits 0', remove.exitCode === 0), claim('layer/dependency gone after remove', gone)])
235
+
236
+ // J4: with no API key in the child environment, the tree must load far
237
+ // enough to reach the model boundary. This is a tree-load proof, not a
238
+ // successful first task. The allowlisted env never carries a provider key.
239
+ const boot = exec('J4.reinstall', dsh, ['plugin', '--profile', profile, 'add', pkg], options.timeout)
240
+ if (boot.exitCode !== 0) {
241
+ add('J4.tree-load', 'FAIL', `reinstall exit=${boot.exitCode}`, boot, [claim('clean reinstall exits 0', false)])
242
+ } else {
243
+ const run = exec('J4.tree-load', dsh, ['--profile', profile, 'Reply with one short sentence.'], 60_000)
244
+ const text = `${run.stdoutText}${run.stderrText}`
245
+ const reached = run.exitCode !== 0 && /MISSING_CREDENTIAL|no API key|AUTH|credential/i.test(text)
246
+ add('J4.tree-load', reached ? 'PASS' : 'FAIL', reached ? 'keyless boot reached the credential boundary' : `boot exit=${run.exitCode}; no credential-boundary message`, run,
247
+ [claim('keyless boot reaches the credential boundary', reached)])
248
+ }
249
+
250
+ // J6: upgrade then rollback. Only meaningful when a previous package is
251
+ // supplied; otherwise it is honestly not-run.
252
+ if (!previous || !previousManifest) {
253
+ add('J6.upgrade', 'not-run', 'no --previous package supplied')
254
+ add('J6.rollback', 'not-run', 'no --previous package supplied')
255
+ } else {
256
+ const u1 = exec('J6.upgrade', dsh, ['plugin', '--profile', profile, 'add', previous], options.timeout)
257
+ const v1 = installedVersion(home, profile, manifest.name)
258
+ add('J6.upgrade', u1.exitCode === 0 && v1 === previousManifest.version ? 'PASS' : 'FAIL', `upgrade ${previousManifest.version}->${manifest.version} exit=${u1.exitCode}, installed=${v1}`, u1,
259
+ [claim(`installed version equals previous ${previousManifest.version}`, u1.exitCode === 0 && v1 === previousManifest.version)])
260
+ const u2 = exec('J6.upgrade-current', dsh, ['plugin', '--profile', profile, 'add', pkg], options.timeout)
261
+ const v2 = installedVersion(home, profile, manifest.name)
262
+ add('J6.upgrade-current', u2.exitCode === 0 && v2 === manifest.version ? 'PASS' : 'FAIL', `current install exit=${u2.exitCode}, installed=${v2}`, u2,
263
+ [claim(`installed version equals current ${manifest.version}`, u2.exitCode === 0 && v2 === manifest.version)])
264
+ const u3 = exec('J6.rollback', dsh, ['plugin', '--profile', profile, 'add', previous], options.timeout)
265
+ const v3 = installedVersion(home, profile, manifest.name)
266
+ add('J6.rollback', u3.exitCode === 0 && v3 === previousManifest.version ? 'PASS' : 'FAIL', `rollback exit=${u3.exitCode}, installed=${v3}`, u3,
267
+ [claim(`rollback installs ${previousManifest.version} again`, u3.exitCode === 0 && v3 === previousManifest.version)])
268
+ }
147
269
  }
148
270
 
149
271
  for (const [id, reason] of [
@@ -153,16 +275,19 @@ function main() {
153
275
  ['J8.client', 'client-shape probe not implemented'],
154
276
  ]) add(id, 'not-run', reason)
155
277
  } finally {
156
- try { fs.rmSync(home, { recursive: true, force: true }) } catch { /* ignore */ }
278
+ try { fs.rmSync(root, { recursive: true, force: true }) } catch { /* ignore */ }
157
279
  }
158
280
  const result = {
159
281
  schema: 'dsh-flow-runtime/1',
160
- environment: { dsh: dshVersion, node: process.version, platform: process.platform },
282
+ environment: { dsh: dshVersion, node: process.version, platform: `${process.platform}-${process.arch}` },
161
283
  package: manifest.name,
162
284
  version: manifest.version,
163
285
  previous: previousManifest ? previousManifest.version : null,
164
286
  mode: 'j1-j2-j4-j6-runtime',
287
+ evidenceMode: logDir !== null,
288
+ environmentPolicy: envPolicy(),
165
289
  checks,
290
+ blockers,
166
291
  summary: {
167
292
  pass: checks.filter(c => c.result === 'PASS').length,
168
293
  warn: checks.filter(c => c.result === 'WARN').length,
@@ -170,7 +295,7 @@ function main() {
170
295
  blocked: checks.filter(c => c.result === 'BLOCKED').length,
171
296
  notRun: checks.filter(c => c.result === 'not-run').length,
172
297
  },
173
- conclusion: checks.some(c => c.result === 'FAIL') ? 'flow-fail' : checks.some(c => c.result === 'BLOCKED') ? 'flow-blocked' : 'flow-warn',
298
+ conclusion: checks.some(c => c.result === 'FAIL') ? 'flow-fail' : checks.some(c => c.result === 'BLOCKED') || blockers.length > 0 ? 'flow-blocked' : 'flow-warn',
174
299
  }
175
300
  if (options.json || !process.stdout.isTTY) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
176
301
  process.exitCode = result.conclusion === 'flow-fail' ? 1 : 0
@@ -5,8 +5,9 @@
5
5
  * What it does:
6
6
  * ENTRY runs the existing ENTRY checker (static by default; real G0-G3 boot
7
7
  * when --host is supplied) and maps its conclusion to an evidence state.
8
- * FLOW reads an optional J1-J8 evidence file; with no file it reports the
9
- * checklist as `not-run` and never invents a pass.
8
+ * FLOW reads an optional `dsh-flow-evidence/2` file; with no file it reports
9
+ * the checklist as `not-run` and never invents a pass. v1 schemas and
10
+ * bare {Jx:{status}} rows are `deprecated` and never count as a pass.
10
11
  * BOOST reads an optional LEV evidence file; with no file it reports
11
12
  * `not-measured`. It does not run benchmarks for you.
12
13
  *
@@ -26,6 +27,7 @@ import fs from 'node:fs'
26
27
  import path from 'node:path'
27
28
  import { spawnSync } from 'node:child_process'
28
29
  import { fileURLToPath } from 'node:url'
30
+ import { FLOW_IDS, FLOW_EVIDENCE_SCHEMA, validateFlowEvidence } from '../src/flow-evidence.mjs'
29
31
 
30
32
  const HERE = path.dirname(fileURLToPath(import.meta.url))
31
33
  const STD = path.resolve(HERE, '..', '..')
@@ -43,7 +45,9 @@ Options:
43
45
  --base <file> dsh-base cordis.patch.yml
44
46
  --bare <dir> bare-module farm for the bundle smoke
45
47
  --no-runtime force static ENTRY even when --host is given
46
- --flow-evidence <file> JSON: { "J1": { "status": "pass|fail|not-run", "evidence": "..." }, ... }
48
+ --flow-evidence <file> dsh-flow-evidence/2 JSON from dsh-flow-collect.mjs (J1-J8 checks
49
+ with command/exitCode/times/stdout sha256/assertions). v1 and bare
50
+ {Jx:{status}} files are reported deprecated and never count as pass.
47
51
  --boost-evidence <file> JSON: { "LEV-3": { "status": "measured|not-measured|failed", "evidenceCard": { ... } }, ... }
48
52
  measured requires a valid evidence card; a status-only row is downgraded to not-measured
49
53
  --timeout <ms> checker timeout, default 150000
@@ -151,32 +155,96 @@ function classifyEntry(report, invoked) {
151
155
  }
152
156
 
153
157
  function classifyFlow(evidence) {
154
- const ids = ['J1', 'J2', 'J3', 'J4', 'J5', 'J6', 'J7', 'J8']
158
+ const ids = FLOW_IDS
159
+ const emptyJourneys = () => Object.fromEntries(ids.map(id => [id, { status: 'not-run', declared: null, checks: 0, passes: false }]))
155
160
  if (!evidence) {
156
161
  return {
157
162
  state: 'not-run',
158
- reason: 'no --flow-evidence file; run the J1-J8 journeys and record pass/fail/not-run',
159
- journeys: Object.fromEntries(ids.map(id => [id, { status: 'not-run' }])),
163
+ schema: null,
164
+ deprecated: false,
165
+ invalid: false,
166
+ evidenceHash: null,
167
+ passed: 0,
168
+ errors: [],
169
+ reason: 'no --flow-evidence file; run dsh-flow-collect.mjs and re-run the audit',
170
+ journeys: emptyJourneys(),
171
+ }
172
+ }
173
+ const verdict = validateFlowEvidence(evidence)
174
+ const declaredOf = id => {
175
+ const row = evidence[id] ?? evidence[id.toLowerCase()]
176
+ return row?.status ?? null
177
+ }
178
+ const base = {
179
+ schema: verdict.schema,
180
+ deprecated: verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy',
181
+ invalid: false,
182
+ evidenceHash: null,
183
+ passed: 0,
184
+ errors: verdict.errors,
185
+ }
186
+ if (verdict.kind === 'v1-deprecated' || verdict.kind === 'legacy') {
187
+ return {
188
+ ...base,
189
+ state: 'deprecated',
190
+ reason: `${verdict.errors[0]}. Run node 40-checker/bin/dsh-flow-collect.mjs --package <dir> to produce ${FLOW_EVIDENCE_SCHEMA}`,
191
+ journeys: Object.fromEntries(ids.map(id => [id, { status: 'deprecated', declared: declaredOf(id), checks: 0, passes: false }])),
192
+ }
193
+ }
194
+ if (!verdict.valid) {
195
+ return {
196
+ ...base,
197
+ state: 'invalid',
198
+ invalid: true,
199
+ reason: `${FLOW_EVIDENCE_SCHEMA} failed structural validation: ${verdict.errors.slice(0, 5).join('; ')}${verdict.errors.length > 5 ? ` (+${verdict.errors.length - 5} more)` : ''}`,
200
+ evidenceHash: {
201
+ declared: verdict.declaredHash,
202
+ computed: verdict.computedHash,
203
+ ok: verdict.declaredHash !== null && verdict.declaredHash === verdict.computedHash,
204
+ },
205
+ journeys: Object.fromEntries(ids.map(id => {
206
+ const row = verdict.journeys[id] ?? { declared: null, derived: 'not-run', checks: 0, passes: false }
207
+ return [id, { status: row.declared ?? row.derived, declared: row.declared, checks: row.checks, passes: false }]
208
+ })),
160
209
  }
161
210
  }
162
211
  const journeys = {}
163
212
  for (const id of ids) {
164
- const row = evidence[id] ?? evidence[id.toLowerCase()]
165
- const status = row?.status
213
+ const row = verdict.journeys[id]
166
214
  journeys[id] = {
167
- status: ['pass', 'fail', 'not-run', 'manual'].includes(status) ? status : 'not-run',
168
- evidence: row?.evidence ?? null,
215
+ status: row.derived,
216
+ declared: row.declared,
217
+ checks: row.checks,
218
+ passes: row.passes,
219
+ reason: evidence.journeys?.[id]?.reason ?? null,
169
220
  }
170
221
  }
171
- const failed = ids.filter(id => journeys[id].status === 'fail')
172
- const passed = ids.filter(id => journeys[id].status === 'pass')
222
+ const passed = ids.filter(id => journeys[id].passes)
223
+ const failed = ids.filter(id => journeys[id].status === 'fail' && journeys[id].declared !== 'blocked')
224
+ const blocked = ids.filter(id => journeys[id].declared === 'blocked')
225
+ const state = failed.length > 0
226
+ ? 'failed'
227
+ : passed.length === ids.length
228
+ ? 'runtime'
229
+ : blocked.length > 0 ? 'blocked' : 'not-run'
230
+ const reasons = []
231
+ if (failed.length > 0) reasons.push(`failed journeys: ${failed.join(', ')}`)
232
+ if (blocked.length > 0) reasons.push(`blocked journeys (never a pass): ${blocked.join(', ')}`)
233
+ if (failed.length === 0 && passed.length !== ids.length) {
234
+ const rest = ids.filter(id => !passed.includes(id) && !blocked.includes(id))
235
+ if (rest.length > 0) reasons.push(`${passed.length}/8 journeys pass; ${rest.length} static/warn/not-run`)
236
+ }
237
+ if (passed.length === ids.length) reasons.push('all J1-J8 journey checks pass (exitCode 0, assertions held)')
173
238
  return {
174
- state: failed.length > 0 ? 'failed' : passed.length === ids.length ? 'runtime' : 'not-run',
175
- reason: failed.length > 0
176
- ? `failed journeys: ${failed.join(', ')}`
177
- : passed.length === ids.length
178
- ? 'all J1-J8 have pass evidence'
179
- : `${passed.length}/8 journeys have pass evidence`,
239
+ ...base,
240
+ state,
241
+ reason: reasons.join('; '),
242
+ passed: passed.length,
243
+ evidenceHash: {
244
+ declared: verdict.declaredHash,
245
+ computed: verdict.computedHash,
246
+ ok: verdict.declaredHash === verdict.computedHash,
247
+ },
180
248
  journeys,
181
249
  }
182
250
  }
@@ -270,7 +338,11 @@ function nextActions(entry, flow, boost) {
270
338
  if (entry.g4 && entry.g4.hasClient && entry.g4.ok === false) actions.push(`ENTRY: fix G4 — ${entry.g4.reason}`)
271
339
  for (const warning of entry.warnings) actions.push(`ENTRY: consider ${warning.id} — ${warning.reason}`)
272
340
  if (entry.state === 'recognized') actions.push('ENTRY: run with --host to replace static recognition with a real G0-G3 boot')
273
- if (flow.state === 'not-run') actions.push('FLOW: record J1-J8 in a flow-evidence JSON; do not claim FLOW until they run')
341
+ if (flow.state === 'not-run') actions.push('FLOW: record J1-J8 with node 40-checker/bin/dsh-flow-collect.mjs; do not claim FLOW until they run')
342
+ if (flow.state === 'deprecated') actions.push('FLOW: v1/bare evidence is deprecated; collect a dsh-flow-evidence/2 file (spec/flow-evidence.schema.json)')
343
+ if (flow.state === 'invalid') actions.push('FLOW: flow evidence failed structural validation; fix the listed fields or recollect')
344
+ if (flow.state === 'failed') actions.push(`FLOW: fix failed journeys: ${Object.entries(flow.journeys).filter(([, row]) => row.status === 'fail').map(([id]) => id).join(', ')}`)
345
+ if (flow.state === 'blocked') actions.push('FLOW: blocked journeys need a prerequisite (usable dsh, --previous, or a compatible host); blocked is not a pass')
274
346
  if (boost.state === 'not-measured') actions.push('BOOST: attach LEV evidence cards (subject/host/method/scope/command/result/negativeControl/generatedAt/expiresAt/recordHash/claimLevel/issuer/selfAssessed); a status alone is not measured')
275
347
  if (boost.state === 'stale') actions.push('BOOST: refresh stale evidence cards before claiming measured')
276
348
  return actions
@@ -18,6 +18,7 @@ import fs from 'node:fs'
18
18
  import os from 'node:os'
19
19
  import path from 'node:path'
20
20
  import { spawn, spawnSync } from 'node:child_process'
21
+ import { sanitizedEnv } from '../src/sandbox-env.mjs'
21
22
 
22
23
  function parse(argv) {
23
24
  const options = {
@@ -129,15 +130,15 @@ async function main() {
129
130
  const reportPath = path.join(work, 'report.json')
130
131
  const cli = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'dsh-plugin-verify.mjs')
131
132
  const args = [cli, pkg, '--format', 'json', '--treat-as-plugin', '--runtime', path.resolve(options.runtime), '--runtime-version', options.runtimeVersion, '--cordis-version', options.cordisVersion, '--out', reportPath]
132
- const env = {
133
- ...process.env,
133
+ // The child imports the inspected package: never forward the caller's env.
134
+ const env = sanitizedEnv({
134
135
  DSH_HOME: home,
135
136
  HOME: home,
136
137
  USERPROFILE: home,
137
138
  TMPDIR: path.join(root, 'tmp'),
138
139
  TEMP: path.join(root, 'tmp'),
139
140
  TMP: path.join(root, 'tmp'),
140
- }
141
+ })
141
142
  fs.mkdirSync(env.TMPDIR, { recursive: true })
142
143
  const started = Date.now()
143
144
  const needsLandlock = options.backend === 'landlock' || options.backend === 'unshare-landlock'
@@ -3,6 +3,10 @@
3
3
  // the checker. Input: JSON on stdin. Output: JSON { checks } on stdout.
4
4
  import { runRuntimeChecks } from '../src/runtime.mjs'
5
5
  import { readSources } from '../src/util.mjs'
6
+ import { scrubProcessEnv } from '../src/sandbox-env.mjs'
7
+
8
+ // This process imports the inspected package; keep only allowlisted env keys.
9
+ scrubProcessEnv()
6
10
 
7
11
  const chunks = []
8
12
  for await (const chunk of process.stdin) chunks.push(chunk)