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.
- package/40-checker/README.md +15 -0
- package/40-checker/bin/dsh-flow-collect.mjs +3 -0
- package/40-checker/bin/dsh-levels.mjs +13 -2
- package/40-checker/scripts/bundle-smoke.mjs +3 -0
- package/40-checker/scripts/entry18-ci.mjs +4 -3
- package/40-checker/scripts/entry18-web.mjs +4 -1
- package/40-checker/scripts/flow-collect.mjs +494 -0
- package/40-checker/scripts/flow-sample-ci.mjs +4 -4
- package/40-checker/scripts/host-farm.mjs +2 -1
- package/40-checker/scripts/journey-config-observe.mjs +5 -3
- package/40-checker/scripts/journey-runtime.mjs +208 -83
- package/40-checker/scripts/level-audit.mjs +91 -19
- package/40-checker/scripts/m2-runner.mjs +4 -3
- package/40-checker/scripts/runtime-child.mjs +4 -0
- package/40-checker/src/cli.mjs +10 -2
- package/40-checker/src/flow-evidence.mjs +431 -0
- package/40-checker/src/index.mjs +3 -0
- package/40-checker/src/remediation.mjs +132 -0
- package/40-checker/src/report.mjs +19 -2
- package/40-checker/src/sandbox-env.mjs +78 -0
- package/40-checker/test/flow-evidence.test.mjs +295 -0
- package/40-checker/test/remediation.test.mjs +300 -0
- package/CHANGELOG.md +20 -0
- package/bin/dsh-flow-collect.mjs +4 -0
- package/package.json +1 -1
- package/spec/README.md +2 -0
- package/spec/flow/README.md +23 -2
- package/spec/flow-evidence.schema.json +217 -0
- package/spec/levels.json +2 -0
- package/spec/remediation.json +478 -0
|
@@ -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
|
-
|
|
48
|
-
|
|
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 })
|
|
@@ -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>]
|
|
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 = {
|
|
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
|
|
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 = {
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
add('
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
159
|
-
|
|
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 =
|
|
165
|
-
const status = row?.status
|
|
213
|
+
const row = verdict.journeys[id]
|
|
166
214
|
journeys[id] = {
|
|
167
|
-
status:
|
|
168
|
-
|
|
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
|
|
172
|
-
const
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
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)
|