dsh-standards 1.3.0 → 1.4.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.
@@ -10,13 +10,25 @@
10
10
  * lifecycle read an existing sidecar (`--prev`) and only recompute
11
11
  * `lifecycle` from `--today` and `publish/withdrawals.json`.
12
12
  *
13
+ * Entry fields are a superset of the original `dsh-market-sidecar/1` shape
14
+ * (subject/levels/evidence/validity/lifecycle/selfAssessed are unchanged):
15
+ * title/repo/license from the published package.json, null when absent
16
+ * scope {dsh, cordis, platform} from dsh.engines / npm engines
17
+ * / peerDependencies, falling back to the audit anchor
18
+ * checks checker results as {id,result,coverage} only; detailed
19
+ * reasons stay in the report, never in the sidecar
20
+ * evidence url/sha256 kept for compatibility; reportUrl/
21
+ * reportSha256 are the same values under the new names
22
+ * checkedBy {checker, command} that produced the audit
23
+ * notChecked short honest list of what the run did not exercise
24
+ *
13
25
  * Deliberately absent: advice (author view), issuer / recordHash / verifyUrl
14
- * (we are not an issuer). `--today` pins the clock so CI and tests are
15
- * reproducible.
26
+ * (we are not an issuer), score / rank (we do not grade). `--today` pins the
27
+ * clock so CI and tests are reproducible.
16
28
  *
17
29
  * Usage:
18
30
  * node 40-checker/scripts/build-sidecar.mjs --levels audit.json \
19
- * --name dsh-security-pack --version 3.0.1 --tarball dsh-security-pack-3.0.1.tgz \
31
+ * --report report.json --tarball dsh-security-pack-3.0.1.tgz \
20
32
  * --out publish/sidecar.json --today 2026-11-06
21
33
  *
22
34
  * node 40-checker/scripts/build-sidecar.mjs --prev publish/sidecar.json \
@@ -36,8 +48,12 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/
36
48
  const HEX40 = /^[0-9a-f]{40}$/
37
49
  const HEX64 = /^[0-9a-f]{64}$/
38
50
  const LIFECYCLE = new Set(['active', 'stale', 'under-review', 'withdrawn'])
39
- const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl'])
51
+ const FORBIDDEN = new Set(['advice', 'issuer', 'recordHash', 'verifyUrl', 'score', 'rank'])
40
52
  const NOT_CLAIMED = ['official', 'certification', 'security-audit', 'endorsement', 'ordering', 'comparison', 'warranty']
53
+ const CHECK_KEYS = new Set(['id', 'result', 'coverage'])
54
+ const SCOPE_KEYS = new Set(['dsh', 'cordis', 'platform'])
55
+ const CHECKED_BY_KEYS = new Set(['checker', 'command'])
56
+ const REPO_GIT_SHORTHAND = /^github:([^/]+\/[^/]+?)(?:\.git)?$/
41
57
 
42
58
  function usage() {
43
59
  console.log(`build-sidecar — publish/sidecar.json (${SCHEMA})
@@ -58,9 +74,12 @@ Options:
58
74
  --package <dir> package.json directory for name/version
59
75
  --tarball <file> package tarball; sha256 is recorded in subject
60
76
  --tarball-sha256 <hex> explicit tarball sha256 (checked against --tarball)
61
- --evidence-url <url> optional https evidence URL
62
- --evidence-file <file> hash this file into evidence.sha256
63
- --evidence-sha256 <hex> explicit evidence sha256
77
+ --report <file> dsh-conformance/1 checker report; its checks are
78
+ reduced to {id,result,coverage} (no details)
79
+ --checked-command <s> command recorded in checkedBy.command
80
+ --evidence-url <url> optional https evidence URL (url + reportUrl)
81
+ --evidence-file <file> hash this file into evidence.sha256/reportSha256
82
+ --evidence-sha256 <hex> explicit evidence sha256 (sha256 + reportSha256)
64
83
  --checker-version <v> default dsh-standards package.json version
65
84
  --checker-sha <40hex> default git HEAD when available
66
85
  --index-url <url> default https://awesome-dsh-plugin.com/index.json
@@ -110,6 +129,7 @@ function parseArgs(argv) {
110
129
  today: null, todayProvided: false, ttlDays: 90,
111
130
  name: null, version: null, packageDir: null,
112
131
  tarball: null, tarballSha256: null,
132
+ report: null, checkedCommand: null,
113
133
  evidenceUrl: null, evidenceFile: null, evidenceSha256: null,
114
134
  checkerVersion: null, checkerSha: null,
115
135
  indexUrl: 'https://awesome-dsh-plugin.com/index.json',
@@ -134,6 +154,8 @@ function parseArgs(argv) {
134
154
  else if (arg === '--package') options.packageDir = next()
135
155
  else if (arg === '--tarball') options.tarball = next()
136
156
  else if (arg === '--tarball-sha256') options.tarballSha256 = next().toLowerCase()
157
+ else if (arg === '--report') options.report = next()
158
+ else if (arg === '--checked-command') options.checkedCommand = next()
137
159
  else if (arg === '--evidence-url') options.evidenceUrl = next()
138
160
  else if (arg === '--evidence-file') options.evidenceFile = next()
139
161
  else if (arg === '--evidence-sha256') options.evidenceSha256 = next().toLowerCase()
@@ -162,6 +184,123 @@ function readPackageMeta(directory) {
162
184
  }
163
185
  }
164
186
 
187
+ function firstLine(value) {
188
+ if (typeof value !== 'string') return null
189
+ const line = value.split(/\r?\n/)[0].trim()
190
+ return line === '' ? null : line
191
+ }
192
+
193
+ function httpsUrl(value) {
194
+ if (typeof value !== 'string' || value.trim() === '') return null
195
+ let url = value.trim()
196
+ const shorthand = url.match(REPO_GIT_SHORTHAND)
197
+ if (shorthand) url = `https://github.com/${shorthand[1]}`
198
+ if (url.startsWith('git+')) url = url.slice(4)
199
+ if (url.startsWith('ssh://git@')) url = `https://${url.slice('ssh://git@'.length)}`
200
+ if (url.startsWith('git@')) {
201
+ const match = url.match(/^git@([^:]+):(.+)$/)
202
+ if (match) url = `https://${match[1]}/${match[2]}`
203
+ }
204
+ if (url.startsWith('git://')) url = `https://${url.slice('git://'.length)}`
205
+ if (url.startsWith('http://')) url = `https://${url.slice('http://'.length)}`
206
+ if (!url.startsWith('https://')) return null
207
+ url = url.replace(/\.git$/i, '').replace(/\/+$/, '')
208
+ try {
209
+ const parsed = new URL(url)
210
+ if (parsed.protocol !== 'https:') return null
211
+ } catch {
212
+ return null
213
+ }
214
+ return url
215
+ }
216
+
217
+ function normalizeRepo(repository) {
218
+ if (typeof repository === 'string') return httpsUrl(repository)
219
+ if (!repository || typeof repository !== 'object') return null
220
+ return httpsUrl(repository.url)
221
+ }
222
+
223
+ function normalizeLicense(license) {
224
+ let value = license
225
+ if (value && typeof value === 'object') value = value.type
226
+ if (typeof value !== 'string') return null
227
+ const text = value.trim()
228
+ if (text === '' || /^SEE LICENSE/i.test(text) || text === 'UNLICENSED') return null
229
+ // Accept plain SPDX ids and simple AND/OR/WITH expressions; anything else is
230
+ // left null rather than guessed.
231
+ if (!/^[A-Za-z0-9.+-]+(?: \([^()]+\))?(?: (?:AND|OR|WITH) [A-Za-z0-9.+-]+(?: \([^()]+\))?)*$/.test(text)) return null
232
+ return text
233
+ }
234
+
235
+ function firstString(...values) {
236
+ for (const value of values) {
237
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
238
+ }
239
+ return null
240
+ }
241
+
242
+ function platformFromPackage(meta) {
243
+ const declared = firstString(meta?.dsh?.client?.platform, meta?.dsh?.platform, meta?.engines?.platform)
244
+ if (declared) return declared
245
+ const os = Array.isArray(meta?.os) ? meta.os.filter(item => typeof item === 'string' && item !== '') : []
246
+ const cpu = Array.isArray(meta?.cpu) ? meta.cpu.filter(item => typeof item === 'string' && item !== '') : []
247
+ if (os.length === 0 && cpu.length === 0) return null
248
+ return [os.join('/'), cpu.join('/')].filter(Boolean).join('-')
249
+ }
250
+
251
+ function inferScope(meta, audit) {
252
+ const anchor = audit?.checker?.anchor && typeof audit.checker.anchor === 'object' ? audit.checker.anchor : {}
253
+ const dshEngines = meta?.dsh?.engines && typeof meta.dsh.engines === 'object' ? meta.dsh.engines : {}
254
+ const engines = meta?.engines && typeof meta.engines === 'object' ? meta.engines : {}
255
+ const peer = meta?.peerDependencies && typeof meta.peerDependencies === 'object' ? meta.peerDependencies : {}
256
+ const deps = meta?.dependencies && typeof meta.dependencies === 'object' ? meta.dependencies : {}
257
+ return {
258
+ dsh: firstString(dshEngines.dsh, engines.dsh, anchor.dsh),
259
+ cordis: firstString(dshEngines.cordis, engines.cordis, peer['@deepseek-ai/cordis'], deps['@deepseek-ai/cordis'], anchor.cordis),
260
+ platform: platformFromPackage(meta),
261
+ }
262
+ }
263
+
264
+ function extractChecks(report, name, version) {
265
+ if (!report || !Array.isArray(report.checks)) return []
266
+ if (report.subject && typeof report.subject === 'object') {
267
+ const subjectName = firstString(report.subject.name)
268
+ const subjectVersion = firstString(report.subject.version)
269
+ if (subjectName !== null && name !== null && subjectName !== name) {
270
+ throw new Error(`--report subject.name ${subjectName} does not match ${name}`)
271
+ }
272
+ if (subjectVersion !== null && version !== null && subjectVersion !== version) {
273
+ throw new Error(`--report subject.version ${subjectVersion} does not match ${version}`)
274
+ }
275
+ }
276
+ return report.checks.map(check => {
277
+ if (!check || typeof check !== 'object') throw new Error('--report checks must be objects')
278
+ const id = firstString(check.id)
279
+ const result = firstString(check.result)
280
+ const coverage = firstString(check.coverage)
281
+ if (id === null || result === null || coverage === null) {
282
+ throw new Error('--report checks need id/result/coverage')
283
+ }
284
+ return { id, result, coverage }
285
+ })
286
+ }
287
+
288
+ function summarizeNotChecked(audit, checks, hasReport) {
289
+ const items = []
290
+ const entry = audit?.levels?.entry && typeof audit.levels.entry === 'object' ? audit.levels.entry : {}
291
+ if (entry.claim !== 'runtime') items.push(`ENTRY:runtime (claim=${firstString(entry.claim) ?? 'unknown'}; not exercised)`)
292
+ if (!hasReport) items.push('ENTRY:checks (no --report; checker results unavailable)')
293
+ const notRun = checks.filter(check => check.coverage === 'not-run').map(check => check.id)
294
+ if (notRun.length > 0) items.push(`ENTRY:${notRun.join(', ')} (coverage=not-run)`)
295
+ const flow = audit?.levels?.flow && typeof audit.levels.flow === 'object' ? audit.levels.flow : {}
296
+ if (flow.state !== 'runtime') {
297
+ items.push(`FLOW:J1-J8 (${firstString(flow.state) ?? 'not-run'}; ${Number.isInteger(flow.passed) ? flow.passed : 0}/8 journeys pass)`)
298
+ }
299
+ const boost = audit?.levels?.boost && typeof audit.levels.boost === 'object' ? audit.levels.boost : {}
300
+ if (boost.state !== 'measured') items.push(`BOOST:LEV-3..LEV-4 (${firstString(boost.state) ?? 'not-measured'})`)
301
+ return items
302
+ }
303
+
165
304
  function pickLevel(row, keys, fallback) {
166
305
  const level = {}
167
306
  if (row && typeof row === 'object') {
@@ -245,6 +384,9 @@ function entryFromAudit(audit, options, levelFile) {
245
384
  if (typeof name !== 'string' || name === '') throw new Error('cannot determine subject.name; pass --name or --package')
246
385
  if (typeof version !== 'string' || version === '') throw new Error('cannot determine subject.version; pass --version or --package')
247
386
 
387
+ const report = options.report ? readJson(path.resolve(options.report), '--report') : null
388
+ const checks = extractChecks(report, name, version)
389
+
248
390
  const auditLevels = audit.levels && typeof audit.levels === 'object' ? audit.levels : {}
249
391
  const levels = {
250
392
  entry: pickLevel(auditLevels.entry, ['state', 'claim'], 'not-run'),
@@ -286,16 +428,38 @@ function entryFromAudit(audit, options, levelFile) {
286
428
  let parsed
287
429
  try { parsed = new URL(options.evidenceUrl) } catch { throw new Error(`--evidence-url is not a URL: ${options.evidenceUrl}`) }
288
430
  if (parsed.protocol !== 'https:') throw new Error('--evidence-url must be https')
431
+ // url/sha256 are the original names; reportUrl/reportSha256 are the same
432
+ // values under the newer, explicit names. Both stay so old readers work.
289
433
  evidence.url = options.evidenceUrl
434
+ evidence.reportUrl = options.evidenceUrl
435
+ }
436
+ if (evidenceSha256 !== null) {
437
+ evidence.sha256 = evidenceSha256
438
+ evidence.reportSha256 = evidenceSha256
290
439
  }
291
- if (evidenceSha256 !== null) evidence.sha256 = evidenceSha256
440
+
441
+ const scanner = firstString(audit.checker?.anchor?.scanner)
442
+ const checkerVersion = firstString(options.checkerVersion, readPackageMeta(ROOT).version) ?? 'unknown'
443
+ const checker = scanner === null
444
+ ? `dsh-standards@${checkerVersion}`
445
+ : `dsh-standards@${checkerVersion} (${scanner})`
446
+ const command = firstString(options.checkedCommand) ?? (levels.entry.claim === 'runtime'
447
+ ? 'dsh-levels <package-dir> --format json'
448
+ : 'dsh-levels <package-dir> --no-runtime --format json')
292
449
 
293
450
  return {
294
451
  key: `${name}@${version}`,
295
452
  entry: {
296
453
  subject: { name, version, tarballSha256 },
454
+ title: firstLine(meta.description),
455
+ repo: normalizeRepo(meta.repository),
456
+ license: normalizeLicense(meta.license),
457
+ scope: inferScope(meta, audit),
297
458
  levels,
459
+ checks,
298
460
  evidence,
461
+ checkedBy: { checker, command },
462
+ notChecked: summarizeNotChecked(audit, checks, report !== null),
299
463
  validity: { checkedAt, expiresAt, policy: `${options.ttlDays}d` },
300
464
  lifecycle: 'active',
301
465
  selfAssessed: options.selfAssessed,
@@ -400,6 +564,72 @@ function validateSidecar(sidecar) {
400
564
  if (entry.evidence.sha256 !== undefined && !HEX64.test(String(entry.evidence.sha256))) {
401
565
  throw new Error(`${key}: evidence.sha256 must be 64 hex chars`)
402
566
  }
567
+ if (entry.evidence.reportUrl !== undefined) {
568
+ let url
569
+ try { url = new URL(entry.evidence.reportUrl) } catch { throw new Error(`${key}: evidence.reportUrl is not a URL`) }
570
+ if (url.protocol !== 'https:') throw new Error(`${key}: evidence.reportUrl must be https`)
571
+ }
572
+ if (entry.evidence.reportSha256 !== undefined && !HEX64.test(String(entry.evidence.reportSha256))) {
573
+ throw new Error(`${key}: evidence.reportSha256 must be 64 hex chars`)
574
+ }
575
+ if (entry.title !== undefined && entry.title !== null && (typeof entry.title !== 'string' || entry.title === '')) {
576
+ throw new Error(`${key}: title must be a non-empty string or null`)
577
+ }
578
+ if (entry.repo !== undefined && entry.repo !== null) {
579
+ let url
580
+ try { url = new URL(entry.repo) } catch { throw new Error(`${key}: repo is not a URL`) }
581
+ if (url.protocol !== 'https:') throw new Error(`${key}: repo must be https or null`)
582
+ }
583
+ if (entry.license !== undefined && entry.license !== null && (typeof entry.license !== 'string' || entry.license === '')) {
584
+ throw new Error(`${key}: license must be a non-empty string or null`)
585
+ }
586
+ if (entry.scope !== undefined) {
587
+ if (!entry.scope || typeof entry.scope !== 'object' || Array.isArray(entry.scope)) throw new Error(`${key}: scope must be an object`)
588
+ for (const field of Object.keys(entry.scope)) {
589
+ if (!SCOPE_KEYS.has(field)) throw new Error(`${key}: scope.${field} is not part of the sidecar contract`)
590
+ }
591
+ for (const field of ['dsh', 'cordis', 'platform']) {
592
+ const value = entry.scope[field]
593
+ if (field === 'platform' && value === undefined) continue
594
+ if (value !== null && (typeof value !== 'string' || value === '')) {
595
+ throw new Error(`${key}: scope.${field} must be a non-empty string or null`)
596
+ }
597
+ }
598
+ }
599
+ if (entry.checks !== undefined) {
600
+ if (!Array.isArray(entry.checks)) throw new Error(`${key}: checks must be an array`)
601
+ const seen = new Set()
602
+ for (const check of entry.checks) {
603
+ if (!check || typeof check !== 'object' || Array.isArray(check)) throw new Error(`${key}: every check must be an object`)
604
+ for (const field of Object.keys(check)) {
605
+ if (!CHECK_KEYS.has(field)) throw new Error(`${key}: checks must not carry ${field}; only id/result/coverage`)
606
+ }
607
+ for (const field of ['id', 'result', 'coverage']) {
608
+ if (typeof check[field] !== 'string' || check[field] === '') throw new Error(`${key}: checks[].${field} is required`)
609
+ }
610
+ if (seen.has(check.id)) throw new Error(`${key}: duplicate check id ${check.id}`)
611
+ seen.add(check.id)
612
+ }
613
+ }
614
+ if (entry.checkedBy !== undefined) {
615
+ if (!entry.checkedBy || typeof entry.checkedBy !== 'object' || Array.isArray(entry.checkedBy)) {
616
+ throw new Error(`${key}: checkedBy must be an object`)
617
+ }
618
+ for (const field of Object.keys(entry.checkedBy)) {
619
+ if (!CHECKED_BY_KEYS.has(field)) throw new Error(`${key}: checkedBy.${field} is not part of the sidecar contract`)
620
+ }
621
+ for (const field of ['checker', 'command']) {
622
+ if (typeof entry.checkedBy[field] !== 'string' || entry.checkedBy[field] === '') {
623
+ throw new Error(`${key}: checkedBy.${field} is required`)
624
+ }
625
+ }
626
+ }
627
+ if (entry.notChecked !== undefined) {
628
+ if (!Array.isArray(entry.notChecked)) throw new Error(`${key}: notChecked must be an array`)
629
+ for (const item of entry.notChecked) {
630
+ if (typeof item !== 'string' || item === '') throw new Error(`${key}: notChecked entries must be non-empty strings`)
631
+ }
632
+ }
403
633
  }
404
634
  walkKeys(sidecar, 'sidecar', (key, where) => {
405
635
  if (FORBIDDEN.has(key)) throw new Error(`${where} is forbidden in a sidecar (${[...FORBIDDEN].join(', ')})`)
@@ -5,22 +5,40 @@
5
5
  * same report is presented: Markdown by default, `--json` for the raw
6
6
  * `dsh-conformance/1` document, `--html <file>` for a single self-contained
7
7
  * file. Exit codes are the checker's, never rewritten here.
8
+ *
9
+ * Safety defaults live here, not in the checker:
10
+ * - unless the caller explicitly opts in, `--entry-claim static --no-exec` is
11
+ * forwarded, so `check` never imports the inspected plugin;
12
+ * - the PATH `dsh` is never executed unconditionally. The anchor comes from
13
+ * `--host-version`, then `$DSH_HOST_VERSION`, then an opt-in
14
+ * `--allow-host-probe` (which resolves the `dsh` JS entry and runs it with
15
+ * `node ... --version`). With no source the report says the host version is
16
+ * unknown and records `anchor.runtimeSource=default`, `suspect=true`.
8
17
  */
9
18
  import fs from 'node:fs'
19
+ import os from 'node:os'
10
20
  import path from 'node:path'
11
21
  import { spawnSync } from 'node:child_process'
22
+ import { createRequire } from 'node:module'
12
23
  import { fileURLToPath } from 'node:url'
13
24
  import { renderHtml, renderMarkdown } from './author-report.mjs'
25
+ import { sanitizedEnv } from './sandbox-env.mjs'
26
+ import { parseVersion } from './semver.mjs'
14
27
 
15
28
  const HERE = path.dirname(fileURLToPath(import.meta.url))
16
29
  const CHECKER = path.resolve(HERE, '..', 'bin', 'dsh-plugin-verify.mjs')
17
30
  const MAX_BUFFER = 64 * 1024 * 1024
31
+ const HOST_PROBE_TIMEOUT_MS = 5000
32
+ const RUNTIME_IDS = new Set(['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'])
33
+ // The checker falls back to `dsh --version` when DSH_BIN is unset. `check` must
34
+ // never do that implicitly, so the child always gets a path that cannot exist.
35
+ const NO_PROBE_BIN = path.join(os.tmpdir(), `dsh-standards-no-host-probe-${process.pid}`)
18
36
 
19
37
  /** Checker flags that take a value, so `check --scope entry` is not read as a directory. */
20
38
  const VALUE_FLAGS = new Set([
21
39
  '--scope', '--profile', '--runtime', '--runtime-version', '--cordis-version', '--commit',
22
40
  '--install-spec', '--market-index', '--plugin-name', '--timeout', '--host', '--base',
23
- '--web', '--bare', '--entry-runtime', '--entry-claim',
41
+ '--web', '--bare', '--entry-runtime', '--entry-claim', '--max-files', '--max-bytes',
24
42
  ])
25
43
 
26
44
  export function checkUsage() {
@@ -34,8 +52,17 @@ Usage:
34
52
  Options:
35
53
  --json print the raw dsh-conformance/1 report
36
54
  --html <file> write a single-file HTML report (inline CSS, no assets)
55
+ --host-version <v> host dsh version for the anchor (default: $DSH_HOST_VERSION)
56
+ --allow-host-probe resolve the \`dsh\` JS entry and run \`node <entry> --version\`
37
57
  --help show this help
38
58
 
59
+ Safety:
60
+ check defaults to --entry-claim static --no-exec: the inspected plugin code
61
+ is NOT executed. Runtime checks need an explicit --runtime <path>, --host <file>,
62
+ or --entry-claim runtime. PATH \`dsh\` is never executed unless --allow-host-probe
63
+ is passed; with no host version the report says "host 版本未知" and marks the
64
+ anchor source=default, suspect=true.
65
+
39
66
  Any other flag is forwarded to the checker, e.g.:
40
67
  --scope entry --entry-claim static --no-exec
41
68
  --runtime-version 0.2.2-rc.1
@@ -49,12 +76,162 @@ Exit codes (same as the checker):
49
76
  `
50
77
  }
51
78
 
79
+ function hasFlag(args, name) {
80
+ return args.some(arg => arg === name || arg.startsWith(`${name}=`))
81
+ }
82
+
83
+ function flagValue(args, name) {
84
+ for (let index = 0; index < args.length; index += 1) {
85
+ const arg = args[index]
86
+ if (arg === name) return args[index + 1]
87
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
88
+ }
89
+ return undefined
90
+ }
91
+
92
+ /**
93
+ * The one place that decides whether a run may execute plugin code.
94
+ *
95
+ * `--runtime`, `--host` and `--entry-claim runtime` are explicit opt-ins. Every
96
+ * other invocation gets `--no-exec`; a missing entry claim also gets the static
97
+ * claim so DSC-14 cannot pretend runtime evidence exists.
98
+ */
99
+ export function safetyDefaultsFor(passthrough = []) {
100
+ const entryClaim = flagValue(passthrough, '--entry-claim')
101
+ const claimProvided = hasFlag(passthrough, '--entry-claim')
102
+ const runtimeRequested = hasFlag(passthrough, '--runtime')
103
+ || hasFlag(passthrough, '--host')
104
+ || entryClaim === 'runtime'
105
+ const noExecRequested = hasFlag(passthrough, '--no-exec')
106
+ const args = []
107
+ if (!runtimeRequested) {
108
+ if (!claimProvided) args.push('--entry-claim', 'static')
109
+ if (!noExecRequested) args.push('--no-exec')
110
+ }
111
+ return {
112
+ args,
113
+ defaulted: !runtimeRequested,
114
+ runtimeRequested,
115
+ entryClaim: entryClaim ?? (runtimeRequested ? 'runtime' : 'static'),
116
+ noExec: runtimeRequested ? noExecRequested : true,
117
+ }
118
+ }
119
+
120
+ function normalizeVersion(value, label) {
121
+ const normalized = String(value ?? '').trim().replace(/^v/, '')
122
+ if (normalized === '' || parseVersion(normalized) === null) {
123
+ throw new Error(`${label} must be an exact semver version`)
124
+ }
125
+ return normalized
126
+ }
127
+
128
+ function realpathOrNull(value) {
129
+ try { return fs.realpathSync(value) } catch { return null }
130
+ }
131
+
132
+ /**
133
+ * Turn a `dsh` PATH entry into the JS file that `node` can run. npm installs a
134
+ * shell/cmd shim, so running the shim itself with node would fail; the shim's
135
+ * text points at `node_modules/@deepseek-ai/dsh/lib/bin.js`.
136
+ */
137
+ function nodeEntryOf(candidate) {
138
+ const resolved = realpathOrNull(candidate)
139
+ if (!resolved) return null
140
+ if (/\.(?:mjs|cjs|js)$/i.test(resolved)) return resolved
141
+ let head = ''
142
+ try { head = fs.readFileSync(resolved, 'utf8').slice(0, 8192) } catch { return null }
143
+ if (/^#![^\n]*\bnode\b/m.test(head)) return resolved
144
+ const shimRelative = head.match(/node_modules[\\/]@deepseek-ai[\\/]dsh[\\/][^\s"']+\.js/)
145
+ if (shimRelative) {
146
+ const fromShim = path.join(path.dirname(resolved), shimRelative[0].replace(/\\/g, '/'))
147
+ if (fs.existsSync(fromShim)) return fromShim
148
+ }
149
+ const fallback = path.join(path.dirname(resolved), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
150
+ return fs.existsSync(fallback) ? fallback : null
151
+ }
152
+
153
+ function resolveDshEntry(env = process.env) {
154
+ const candidates = []
155
+ if (typeof env.DSH_BIN === 'string' && env.DSH_BIN.trim() !== '') candidates.push(env.DSH_BIN.trim())
156
+ try {
157
+ const require = createRequire(path.join(process.cwd(), 'noop.cjs'))
158
+ candidates.push(require.resolve('@deepseek-ai/dsh/lib/bin.js'))
159
+ } catch { /* not installed next to the caller */ }
160
+ const names = process.platform === 'win32' ? ['dsh.cmd', 'dsh.exe', 'dsh'] : ['dsh']
161
+ for (const dir of String(env.PATH ?? env.Path ?? '').split(path.delimiter)) {
162
+ if (dir.trim() === '') continue
163
+ for (const name of names) candidates.push(path.join(dir, name))
164
+ }
165
+ for (const candidate of candidates) {
166
+ const entry = nodeEntryOf(candidate)
167
+ if (entry) return entry
168
+ }
169
+ return null
170
+ }
171
+
172
+ /** Opt-in only: resolve `dsh` and run its JS entry as `node <entry> --version`. */
173
+ export function probeHostVersion(env = process.env) {
174
+ const entry = resolveDshEntry(env)
175
+ if (!entry) return { version: null, source: 'none', error: 'no resolvable dsh JS entry' }
176
+ const run = spawnSync(process.execPath, [entry, '--version'], {
177
+ encoding: 'utf8',
178
+ timeout: HOST_PROBE_TIMEOUT_MS,
179
+ env: sanitizedEnv(),
180
+ })
181
+ if (run.error || run.status !== 0) {
182
+ return { version: null, source: 'none', error: run.error?.message ?? `dsh --version exited ${run.status}` }
183
+ }
184
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/.exec(`${run.stdout ?? ''}\n${run.stderr ?? ''}`)
185
+ if (!match) return { version: null, source: 'none', error: 'no semver in the probed dsh output' }
186
+ return { version: match[1], source: 'probe', entry, error: null }
187
+ }
188
+
189
+ /**
190
+ * Priority: explicit flag, environment, opt-in probe, then unknown. An invalid
191
+ * environment value is reported as unknown rather than failing the whole run.
192
+ */
193
+ export function resolveHostVersion({ hostVersion = null, allowHostProbe = false, env = process.env } = {}) {
194
+ if (hostVersion !== null && hostVersion !== undefined) {
195
+ return { version: normalizeVersion(hostVersion, '--host-version'), source: 'flag', error: null }
196
+ }
197
+ const fromEnv = typeof env.DSH_HOST_VERSION === 'string' ? env.DSH_HOST_VERSION.trim() : ''
198
+ if (fromEnv !== '') {
199
+ try {
200
+ return { version: normalizeVersion(fromEnv, 'DSH_HOST_VERSION'), source: 'env', error: null }
201
+ } catch {
202
+ return { version: null, source: 'none', error: 'DSH_HOST_VERSION is not an exact semver; ignored' }
203
+ }
204
+ }
205
+ if (allowHostProbe) return probeHostVersion(env)
206
+ return { version: null, source: 'none', error: 'host version unknown' }
207
+ }
208
+
52
209
  export function parseCheckArgs(argv = []) {
53
- const options = { dir: null, json: false, html: null, help: false, passthrough: [] }
210
+ const options = {
211
+ dir: null, json: false, html: null, help: false, hostVersion: null, allowHostProbe: false, passthrough: [],
212
+ }
54
213
  for (let index = 0; index < argv.length; index += 1) {
55
214
  const arg = argv[index]
56
215
  if (arg === '--help' || arg === '-h') { options.help = true; continue }
57
216
  if (arg === '--json') { options.json = true; continue }
217
+ if (arg === '--allow-host-probe' || arg.startsWith('--allow-host-probe=')) {
218
+ const inline = arg.startsWith('--allow-host-probe=') ? arg.slice('--allow-host-probe='.length) : undefined
219
+ options.allowHostProbe = inline === undefined ? true : inline !== 'false'
220
+ continue
221
+ }
222
+ if (arg === '--host-version' || arg.startsWith('--host-version=')) {
223
+ const inline = arg.startsWith('--host-version=') ? arg.slice('--host-version='.length) : undefined
224
+ if (inline !== undefined) {
225
+ if (inline.trim() === '') throw new Error('--host-version needs a value')
226
+ options.hostVersion = inline
227
+ } else {
228
+ const next = argv[index + 1]
229
+ if (next === undefined || next.startsWith('--')) throw new Error('--host-version needs a value')
230
+ options.hostVersion = next
231
+ index += 1
232
+ }
233
+ continue
234
+ }
58
235
  if (arg === '--html' || arg.startsWith('--html=')) {
59
236
  const inline = arg.startsWith('--html=') ? arg.slice('--html='.length) : undefined
60
237
  if (inline !== undefined) {
@@ -87,12 +264,20 @@ export function parseCheckArgs(argv = []) {
87
264
  if (options.json && options.html) throw new Error('--json and --html are mutually exclusive')
88
265
  if (options.dir === null) options.dir = '.'
89
266
  options.dir = path.resolve(options.dir)
267
+ if (options.hostVersion !== null) options.hostVersion = normalizeVersion(options.hostVersion, '--host-version')
90
268
  return options
91
269
  }
92
270
 
93
- function invokeChecker(options) {
94
- const args = [CHECKER, options.dir, '--format', 'json', ...options.passthrough]
95
- const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 600_000, maxBuffer: MAX_BUFFER })
271
+ function invokeChecker(options, forwarded) {
272
+ const args = [CHECKER, options.dir, '--format', 'json', ...forwarded]
273
+ const run = spawnSync(process.execPath, args, {
274
+ encoding: 'utf8',
275
+ timeout: 600_000,
276
+ maxBuffer: MAX_BUFFER,
277
+ // Never let the checker fall back to the PATH `dsh`: the anchor is resolved
278
+ // here, under --host-version / DSH_HOST_VERSION / --allow-host-probe.
279
+ env: sanitizedEnv({ DSH_BIN: NO_PROBE_BIN }),
280
+ })
96
281
  if (run.error) throw new Error(`could not run the checker: ${run.error.message}`)
97
282
  let report
98
283
  try {
@@ -103,6 +288,79 @@ function invokeChecker(options) {
103
288
  return { report, status: run.status }
104
289
  }
105
290
 
291
+ function applyHostAnchor(report, options, hostInfo) {
292
+ const anchor = report.anchor ?? {}
293
+ const runtimeVersionExplicit = hasFlag(options.passthrough, '--runtime-version')
294
+ if (hostInfo.version) {
295
+ if (!runtimeVersionExplicit) {
296
+ anchor.dsh = hostInfo.version
297
+ anchor.runtimeSource = 'host'
298
+ anchor.suspect = false
299
+ } else {
300
+ anchor.suspect = anchor.dsh !== hostInfo.version
301
+ }
302
+ anchor.hostVersion = hostInfo.version
303
+ anchor.hostVersionKnown = true
304
+ anchor.hostVersionSource = hostInfo.source
305
+ } else {
306
+ if (!runtimeVersionExplicit) {
307
+ anchor.runtimeSource = 'default'
308
+ anchor.suspect = true
309
+ }
310
+ anchor.hostVersion = null
311
+ anchor.hostVersionKnown = false
312
+ anchor.hostVersionSource = 'none'
313
+ }
314
+ report.anchor = anchor
315
+ return runtimeVersionExplicit
316
+ }
317
+
318
+ function applySafety(report, options, safety, hostInfo, runtimeVersionExplicit) {
319
+ applyHostAnchor(report, options, hostInfo)
320
+ const runtimeRan = (report.checks ?? []).some(check => RUNTIME_IDS.has(check.id)
321
+ && ['PASS', 'WARN', 'FAIL'].includes(check.result)
322
+ && check.coverage !== 'not-run')
323
+ const hostSmoke = hasFlag(options.passthrough, '--host') && !hasFlag(options.passthrough, '--no-exec')
324
+ const executed = !safety.noExec && (runtimeRan || hostSmoke)
325
+ report.safety = {
326
+ executed,
327
+ defaultSafety: safety.defaulted,
328
+ entryClaim: safety.entryClaim,
329
+ noExec: !executed,
330
+ hostVersionKnown: Boolean(hostInfo.version),
331
+ hostVersionSource: hostInfo.source,
332
+ hostProbeAllowed: options.allowHostProbe,
333
+ runtimeVersionExplicit,
334
+ }
335
+ if (hostInfo.error) report.safety.hostVersionError = hostInfo.error
336
+ return report
337
+ }
338
+
339
+ function safetyBanner(report) {
340
+ const safety = report.safety ?? {}
341
+ const anchor = report.anchor ?? {}
342
+ const lines = []
343
+ if (safety.executed) {
344
+ lines.push('> ⚠️ 本次已执行插件代码(显式开启运行时;L1–L3 在隔离子进程中运行,超时硬杀)。')
345
+ } else if (safety.defaultSafety) {
346
+ lines.push('> ⚠️ 本次未执行插件代码(默认安全模式:`--entry-claim static --no-exec`;运行时检查记为 not-run,不是 PASS)。')
347
+ } else {
348
+ lines.push('> ⚠️ 本次未执行插件代码(运行时检查没有产生完整装载证据;not-run 不是 PASS)。')
349
+ }
350
+ if (!safety.hostVersionKnown) {
351
+ lines.push(`> host 版本未知:anchor.dsh=\`${anchor.dsh ?? '?'}\` 是默认值(source=${anchor.runtimeSource ?? 'default'}, suspect=${anchor.suspect ?? true})。用 \`--host-version\`、\`DSH_HOST_VERSION\` 或 \`--allow-host-probe\` 指定宿主版本。`)
352
+ } else {
353
+ lines.push(`> host 版本 \`${anchor.dsh}\`(来源:${anchor.hostVersionSource ?? 'host'});anchor.source=${anchor.runtimeSource ?? 'host'}。`)
354
+ }
355
+ return lines.join('\n')
356
+ }
357
+
358
+ function insertBanner(markdown, banner) {
359
+ const newline = markdown.indexOf('\n')
360
+ if (newline < 0) return `${markdown}\n\n${banner}\n`
361
+ return `${markdown.slice(0, newline + 1)}\n${banner}\n${markdown.slice(newline + 1)}`
362
+ }
363
+
106
364
  export function runCheck(argv = process.argv.slice(2)) {
107
365
  let options
108
366
  try {
@@ -117,13 +375,22 @@ export function runCheck(argv = process.argv.slice(2)) {
117
375
  return 0
118
376
  }
119
377
  let result
378
+ let hostInfo
379
+ let safety
120
380
  try {
121
- result = invokeChecker(options)
381
+ safety = safetyDefaultsFor(options.passthrough)
382
+ hostInfo = resolveHostVersion({ hostVersion: options.hostVersion, allowHostProbe: options.allowHostProbe })
383
+ const forwarded = [...safety.args, ...options.passthrough]
384
+ if (hostInfo.version && !hasFlag(options.passthrough, '--runtime-version')) {
385
+ forwarded.push('--runtime-version', hostInfo.version)
386
+ }
387
+ result = invokeChecker(options, forwarded)
122
388
  } catch (error) {
123
389
  process.stderr.write(`dsh-standards check: ${error.message}\n`)
124
390
  return 3
125
391
  }
126
392
  const { report, status } = result
393
+ applySafety(report, options, safety, hostInfo, hasFlag(options.passthrough, '--runtime-version'))
127
394
  const exitCode = Number.isInteger(report?.conclusion?.exitCode) ? report.conclusion.exitCode : (status ?? 3)
128
395
  if (options.html) {
129
396
  const out = path.resolve(options.html)
@@ -133,7 +400,7 @@ export function runCheck(argv = process.argv.slice(2)) {
133
400
  } else if (options.json) {
134
401
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
135
402
  } else {
136
- process.stdout.write(renderMarkdown(report))
403
+ process.stdout.write(insertBanner(renderMarkdown(report), safetyBanner(report)))
137
404
  }
138
405
  return exitCode
139
406
  }