dsh-standards 0.0.3 → 1.0.1

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 (139) hide show
  1. package/40-checker/README.md +251 -0
  2. package/40-checker/bin/dsh-levels.mjs +15 -0
  3. package/40-checker/bin/dsh-plugin-verify.mjs +8 -0
  4. package/40-checker/fixtures/bad/index.mjs +12 -0
  5. package/40-checker/fixtures/bad/package.json +15 -0
  6. package/40-checker/fixtures/bad-manifest/index.js +1 -0
  7. package/40-checker/fixtures/bad-manifest/package.json +5 -0
  8. package/40-checker/fixtures/bundle/cordis.patch.yml +3 -0
  9. package/40-checker/fixtures/bundle/package.json +15 -0
  10. package/40-checker/fixtures/bundle/server.mjs +7 -0
  11. package/40-checker/fixtures/bundle-v0/cordis.patch.yml +3 -0
  12. package/40-checker/fixtures/bundle-v0/package.json +21 -0
  13. package/40-checker/fixtures/bundle-v0/server.mjs +7 -0
  14. package/40-checker/fixtures/client-missing/client.js +1 -0
  15. package/40-checker/fixtures/client-missing/index.js +1 -0
  16. package/40-checker/fixtures/client-missing/package.json +6 -0
  17. package/40-checker/fixtures/flow-config/index.js +9 -0
  18. package/40-checker/fixtures/flow-config/package.json +10 -0
  19. package/40-checker/fixtures/fs-write/index.mjs +7 -0
  20. package/40-checker/fixtures/fs-write/package.json +10 -0
  21. package/40-checker/fixtures/good/index.mjs +10 -0
  22. package/40-checker/fixtures/good/package.json +19 -0
  23. package/40-checker/fixtures/hang/index.mjs +5 -0
  24. package/40-checker/fixtures/hang/package.json +10 -0
  25. package/40-checker/fixtures/known-answer/expected.json +18 -0
  26. package/40-checker/fixtures/market-index-real.json +18 -0
  27. package/40-checker/fixtures/market-index.json +18 -0
  28. package/40-checker/fixtures/negative/bad-entry/package.json +1 -0
  29. package/40-checker/fixtures/negative/no-entry/package.json +1 -0
  30. package/40-checker/fixtures/negative/no-patch/package.json +1 -0
  31. package/40-checker/fixtures/negative/root-missing/cordis.patch.yml +2 -0
  32. package/40-checker/fixtures/negative/root-missing/package.json +1 -0
  33. package/40-checker/fixtures/negative/workspace/package.json +1 -0
  34. package/40-checker/package.json +22 -0
  35. package/40-checker/samples/README.md +8 -0
  36. package/40-checker/samples/entry18.json +139 -0
  37. package/40-checker/samples/frame.json +485 -0
  38. package/40-checker/scripts/apply-overlay.mjs +59 -0
  39. package/40-checker/scripts/attestation-check.mjs +151 -0
  40. package/40-checker/scripts/bundle-smoke.mjs +126 -0
  41. package/40-checker/scripts/claim-check.mjs +145 -0
  42. package/40-checker/scripts/clause-map.mjs +94 -0
  43. package/40-checker/scripts/collect-fixtures.mjs +44 -0
  44. package/40-checker/scripts/dashboard.mjs +85 -0
  45. package/40-checker/scripts/dco-check.mjs +147 -0
  46. package/40-checker/scripts/dependabot-check.mjs +40 -0
  47. package/40-checker/scripts/doc-commands-check.mjs +92 -0
  48. package/40-checker/scripts/entry-scan.mjs +190 -0
  49. package/40-checker/scripts/entry-validate.mjs +291 -0
  50. package/40-checker/scripts/entry18-attribution.mjs +66 -0
  51. package/40-checker/scripts/entry18-ci.mjs +685 -0
  52. package/40-checker/scripts/entry18-realweb-summary.mjs +71 -0
  53. package/40-checker/scripts/entry18-web.mjs +307 -0
  54. package/40-checker/scripts/evidence-chain.mjs +100 -0
  55. package/40-checker/scripts/flow-index.mjs +82 -0
  56. package/40-checker/scripts/flow-sample-ci.mjs +529 -0
  57. package/40-checker/scripts/governance-check.mjs +210 -0
  58. package/40-checker/scripts/host-farm.mjs +165 -0
  59. package/40-checker/scripts/journey-config-observe.mjs +109 -0
  60. package/40-checker/scripts/journey-disclosure-shape.mjs +83 -0
  61. package/40-checker/scripts/journey-runtime.mjs +178 -0
  62. package/40-checker/scripts/journey.mjs +76 -0
  63. package/40-checker/scripts/label-drift.mjs +74 -0
  64. package/40-checker/scripts/label-sync.mjs +87 -0
  65. package/40-checker/scripts/label-verify.mjs +90 -0
  66. package/40-checker/scripts/landlock-exec.c +101 -0
  67. package/40-checker/scripts/level-audit.mjs +336 -0
  68. package/40-checker/scripts/m1-sidecar.mjs +126 -0
  69. package/40-checker/scripts/m2-runner.mjs +213 -0
  70. package/40-checker/scripts/round-robin.mjs +88 -0
  71. package/40-checker/scripts/sample-frame.mjs +72 -0
  72. package/40-checker/scripts/sample20-ci.mjs +25 -0
  73. package/40-checker/scripts/sample60-ci.mjs +25 -0
  74. package/40-checker/scripts/stale-check.mjs +59 -0
  75. package/40-checker/scripts/surveillance.mjs +88 -0
  76. package/40-checker/scripts/web-probe.mjs +504 -0
  77. package/40-checker/src/cli.mjs +300 -0
  78. package/40-checker/src/client-shape.mjs +37 -0
  79. package/40-checker/src/declarations.mjs +242 -0
  80. package/40-checker/src/index.mjs +8 -0
  81. package/40-checker/src/install-spec.mjs +67 -0
  82. package/40-checker/src/label.mjs +130 -0
  83. package/40-checker/src/manifest.mjs +381 -0
  84. package/40-checker/src/permissions.mjs +60 -0
  85. package/40-checker/src/report.mjs +204 -0
  86. package/40-checker/src/result.mjs +60 -0
  87. package/40-checker/src/runtime.mjs +299 -0
  88. package/40-checker/src/semver.mjs +194 -0
  89. package/40-checker/src/util.mjs +148 -0
  90. package/40-checker/test/attestation.test.mjs +59 -0
  91. package/40-checker/test/checks.test.mjs +172 -0
  92. package/40-checker/test/claim.test.mjs +28 -0
  93. package/40-checker/test/client-shape.test.mjs +35 -0
  94. package/40-checker/test/dco.test.mjs +51 -0
  95. package/40-checker/test/entry-validate.test.mjs +172 -0
  96. package/40-checker/test/governance.test.mjs +76 -0
  97. package/40-checker/test/label.test.mjs +97 -0
  98. package/40-checker/test/level-audit.test.mjs +99 -0
  99. package/40-checker/test/levels.test.mjs +31 -0
  100. package/40-checker/test/ops.test.mjs +117 -0
  101. package/40-checker/test/permissions.test.mjs +40 -0
  102. package/40-checker/test/semver.test.mjs +32 -0
  103. package/40-checker/test/verify.test.mjs +57 -0
  104. package/CHANGELOG.md +24 -0
  105. package/LICENSE +28 -0
  106. package/LICENSE-APACHE +190 -0
  107. package/LICENSE-CC-BY +23 -0
  108. package/NOTICE +14 -0
  109. package/NOTICE.md +11 -0
  110. package/README.md +146 -3
  111. package/bin/dsh-levels.mjs +4 -0
  112. package/bin/dsh-standards.mjs +19 -0
  113. package/docs/BOOST-FIXTURES-2026-11-02.md +38 -0
  114. package/docs/DEVELOPER-JOURNEY.md +40 -0
  115. package/docs/HOST-J3-BUG-REPORT-DRAFT.md +27 -0
  116. package/docs/HOST-J3-CONFIG-REPRO-2026-11-02.md +36 -0
  117. package/docs/NAMING-FALLBACK.md +11 -0
  118. package/docs/fork-policy.md +12 -0
  119. package/docs/full-test-plan-2026-11-02.md +207 -0
  120. package/docs/incident.md +37 -0
  121. package/docs/onboarding.md +27 -0
  122. package/docs/privacy.md +12 -0
  123. package/docs/release.md +16 -0
  124. package/docs/scope-security.md +62 -0
  125. package/docs/status-taxonomy.md +54 -0
  126. package/docs/sunset.md +44 -0
  127. package/docs/translating.md +12 -0
  128. package/package.json +58 -12
  129. package/spec/README.md +27 -0
  130. package/spec/boost/README.md +8 -0
  131. package/spec/clauses.json +253 -0
  132. package/spec/entry/README.md +9 -0
  133. package/spec/flow/README.md +8 -0
  134. package/spec/levels.json +371 -0
  135. package/spec/levels.schema.json +45 -0
  136. package/spec/reference-suite.json +35 -0
  137. package/spec/schemas/attestation.schema.json +284 -0
  138. package/spec/schemas/evidence.schema.json +331 -0
  139. package/spec/schemas/report.schema.json +64 -0
@@ -0,0 +1,300 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { runManifestChecks, DEFAULT_RUNTIME_VERSION } from './manifest.mjs'
5
+ import { checkCapabilities, checkCredentials, checkImportTimeAdvisory, checkLabels } from './declarations.mjs'
6
+ import { runRuntimeChecks } from './runtime.mjs'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { readSources } from './util.mjs'
9
+ import { buildReport, writeReport } from './report.mjs'
10
+
11
+ const VERSION = '0.1.0'
12
+ const DEFAULT_COMMIT = 'ce7073d51254c26cd63b22a60835b8f948deb447'
13
+ const DEFAULT_CORDIS = '4.0.6-alpha.1'
14
+
15
+ function usage() {
16
+ return `${process.env.DSH_CHECKER_BRAND || 'dsh-plugin-verify ' + VERSION} — DS-C conformance checker (MVP)
17
+
18
+ Usage:
19
+ dsh-plugin-verify <package-dir> [options]
20
+
21
+ Options:
22
+ --format text|json|sarif output format (default: text)
23
+ --scope mvp|ds-c|entry mvp = 8 MVP ids; ds-c = full claim (default); entry = compatibility gate
24
+ --profile package|listing|both evaluation profile (default: package; listing if --market-index is set)
25
+ --runtime <path> path to a built @deepseek-ai/cordis lib/index.js; enables L1-L3
26
+ --runtime-version <v> dsh runtime version used for C-2 (default: ${DEFAULT_RUNTIME_VERSION})
27
+ --cordis-version <v> cordis version recorded in the anchor (default: ${DEFAULT_CORDIS})
28
+ --commit <sha> harness commit recorded in the anchor (default: ${DEFAULT_COMMIT.slice(0, 12)})
29
+ --install-spec <spec> recommended install spec for C-3 (listing/install check)
30
+ --market-index <file> format-1 market index JSON for C-3 listing mode
31
+ --plugin-name <name> plugin name inside the market index
32
+ --timeout <ms> L1-L3 timeout (default: 10000)
33
+ --no-exec skip L1-L3 entirely (reported as not-run/NA, never PASS)
34
+ --static-only M1 mode: listing profile, only C-1/C-2/C-3/C-11/C-12/C-13; C-4..C-9 are out of scope
35
+ --treat-as-plugin run L1-L3 on a bundle's server entry (lib/index.js, index.js, lib/public-entry.js, src/index.ts)
36
+ --entry-runtime require|server-entry entry scope: require full G0-G3 (default) or accept server-entry proxy (not bundle)
37
+ --entry-claim runtime|static entry scope: runtime (default, needs DSC-14) or static recognized (no runtime claim)
38
+ --host <file> app-boot lib/index.js; enables bundle G0-G3 smoke
39
+ --base <file> optional base cordis.patch.yml for bundle smoke
40
+ --web <file> optional web-app cordis.patch.yml for bundle smoke
41
+ --bare <dir> optional bare-module base for bundle smoke (host farm)
42
+ --out <file> write the report to a file (stdout otherwise)
43
+ --help show this help
44
+ --version show the checker version
45
+
46
+ Exit codes:
47
+ 0 completed, no FAIL (PASS or WARN; WARN does not claim a clean PASS)
48
+ 1 completed, at least one FAIL
49
+ 2 incomplete: a mandatory check did not run, or the checker itself failed
50
+ 3 usage error
51
+ `
52
+ }
53
+
54
+ function parseArgs(argv) {
55
+ const options = {
56
+ target: null,
57
+ format: 'text',
58
+ scope: 'ds-c',
59
+ profile: null,
60
+ runtime: null,
61
+ runtimeVersion: DEFAULT_RUNTIME_VERSION,
62
+ cordisVersion: DEFAULT_CORDIS,
63
+ commit: DEFAULT_COMMIT,
64
+ installSpec: null,
65
+ marketIndex: null,
66
+ pluginName: null,
67
+ timeoutMs: 10_000,
68
+ noExec: false,
69
+ staticOnly: false,
70
+ treatAsPlugin: false,
71
+ entryRuntime: 'require',
72
+ entryClaim: 'runtime',
73
+ host: null,
74
+ base: null,
75
+ web: null,
76
+ bare: null,
77
+ out: null,
78
+ help: false,
79
+ version: false,
80
+ }
81
+ for (let index = 0; index < argv.length; index += 1) {
82
+ const arg = argv[index]
83
+ if (arg === '--help' || arg === '-h') { options.help = true; continue }
84
+ if (arg === '--version' || arg === '-v') { options.version = true; continue }
85
+ if (!arg.startsWith('--')) {
86
+ if (options.target !== null) throw new Error(`unexpected positional argument: ${arg}`)
87
+ options.target = arg
88
+ continue
89
+ }
90
+ const [key, inline] = arg.split('=', 2)
91
+ const value = () => {
92
+ if (inline !== undefined) return inline
93
+ const next = argv[index + 1]
94
+ if (next === undefined || next.startsWith('--')) throw new Error(`missing value for ${key}`)
95
+ index += 1
96
+ return next
97
+ }
98
+ switch (key) {
99
+ case '--format': options.format = value(); break
100
+ case '--scope': options.scope = value(); break
101
+ case '--profile': options.profile = value(); break
102
+ case '--runtime': options.runtime = value(); break
103
+ case '--runtime-version': options.runtimeVersion = value(); break
104
+ case '--cordis-version': options.cordisVersion = value(); break
105
+ case '--commit': options.commit = value(); break
106
+ case '--install-spec': options.installSpec = value(); break
107
+ case '--market-index': options.marketIndex = value(); break
108
+ case '--plugin-name': options.pluginName = value(); break
109
+ case '--timeout': options.timeoutMs = Number(value()); break
110
+ case '--out': options.out = value(); break
111
+ case '--no-exec': options.noExec = true; break
112
+ case '--static-only': options.staticOnly = true; break
113
+ case '--treat-as-plugin': options.treatAsPlugin = true; break
114
+ case '--entry-runtime': options.entryRuntime = value(); break
115
+ case '--entry-claim': options.entryClaim = value(); break
116
+ case '--host': options.host = value(); break
117
+ case '--base': options.base = value(); break
118
+ case '--web': options.web = value(); break
119
+ case '--bare': options.bare = value(); break
120
+ default: throw new Error(`unknown option: ${key}`)
121
+ }
122
+ }
123
+ if (!['text', 'json', 'sarif'].includes(options.format)) throw new Error(`unsupported --format: ${options.format}`)
124
+ if (!['mvp', 'ds-c', 'entry'].includes(options.scope)) throw new Error(`unsupported --scope: ${options.scope}`)
125
+ if (!['require', 'server-entry'].includes(options.entryRuntime)) throw new Error('--entry-runtime must be require or server-entry')
126
+ if (!['runtime', 'static'].includes(options.entryClaim)) throw new Error('--entry-claim must be runtime or static')
127
+ if (options.staticOnly) { options.scope = 'ds-c'; options.profile = 'listing' }
128
+ if (!options.profile) options.profile = options.marketIndex ? 'listing' : 'package'
129
+ if (!['package', 'listing', 'both'].includes(options.profile)) throw new Error(`unsupported --profile: ${options.profile}`)
130
+ if (options.staticOnly && options.treatAsPlugin) throw new Error('--static-only and --treat-as-plugin are mutually exclusive')
131
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 120_000) throw new Error('--timeout must be between 100 and 120000 ms')
132
+ if (options.target === null) options.target = '.'
133
+ return options
134
+ }
135
+
136
+ async function entryRunCheck(kind, runtimeChecks, options, dir) {
137
+ const byId = new Map(runtimeChecks.map(item => [item.id, item]))
138
+ const c4 = byId.get('DSC-04')
139
+ const base = {
140
+ id: 'DSC-14', clause: 'C-14', layer: 'L1', method: 'M', advisory: false,
141
+ details: [], evidence: c4?.evidence ?? [],
142
+ }
143
+ if (options.entryClaim === 'static') {
144
+ return { ...base, result: 'NA', coverage: 'not-run', reason: 'entry static claim: no runtime evidence' }
145
+ }
146
+ if (['client', 'preset', 'skill', 'theme', 'mcp-config', 'unknown'].includes(kind)) {
147
+ return { ...base, result: 'NA', coverage: 'not-run', reason: `kind=${kind}; no runtime claim` }
148
+ }
149
+ if (kind === 'bundle' && options.entryRuntime !== 'server-entry') {
150
+ if (!options.host) return { ...base, result: 'ERROR', coverage: 'not-run', reason: 'bundle runtime requires --host <app-boot/lib/index.js> for G0-G3' }
151
+ const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'bundle-smoke.mjs')
152
+ const args = [script, '--package', dir, '--host', options.host, '--json', '--timeout', String(options.timeoutMs)]
153
+ if (options.base) args.push('--base', options.base)
154
+ if (options.web) args.push('--web', options.web)
155
+ if (options.bare) args.push('--bare', options.bare)
156
+ const run = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: options.timeoutMs + 5000, maxBuffer: 8 * 1024 * 1024 })
157
+ if (run.error) return { ...base, result: 'ERROR', coverage: 'not-run', reason: `bundle smoke failed to start: ${run.error.message}` }
158
+ let smoke
159
+ try { smoke = JSON.parse(run.stdout) } catch { return { ...base, result: 'ERROR', coverage: 'not-run', reason: `bundle smoke produced no JSON: ${(run.stderr || '').slice(0, 300)}` } }
160
+ const state = smoke.g3?.state
161
+ if (smoke.g2?.compatibility === 'reject') return { ...base, result: 'FAIL', coverage: 'full', reason: 'G2 gate rejected: host would skip this bundle', details: [{ item: 'G2', result: 'FAIL', reason: JSON.stringify(smoke.g2) }] }
162
+ if (state === 'verified' && smoke.g4?.ok === false) return { ...base, result: 'FAIL', coverage: 'full', reason: 'G4 failed: dsh.client has no package-root row or missing client export', details: [{ item: 'G4', result: 'FAIL', reason: JSON.stringify(smoke.g4) }] }
163
+ if (state === 'verified') return { ...base, result: 'PASS', coverage: 'full', reason: `G0-G3 verified; rows=${smoke.g3.active}/${smoke.g3.rows.length}${smoke.g4?.hasClient ? '; G4 static ok' : ''}`, details: [{ item: 'G0', result: 'PASS', reason: 'patch parsed' }, { item: 'G1', result: 'PASS', reason: `rows ${smoke.g1.targetRows}` }, { item: 'G3', result: 'PASS', reason: `rows ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE` }] }
164
+ if (state === 'partial') return { ...base, result: 'ERROR', coverage: 'not-run', reason: `G3 partial: ${smoke.g3.active}/${smoke.g3.rows.length} ACTIVE; target profile does not provide all services`, details: [{ item: 'G3', result: 'WARN', reason: JSON.stringify(smoke.g3.rows) }] }
165
+ return { ...base, result: 'FAIL', coverage: 'full', reason: `bundle smoke failed: ${JSON.stringify(smoke.g3?.rows ?? [])}` }
166
+ }
167
+ if (c4?.result === 'PASS') {
168
+ return { ...base, result: 'PASS', coverage: 'full', reason: kind === 'bundle' ? 'server-entry ACTIVE only; bundle G0-G4 not run' : '' }
169
+ }
170
+ if (c4?.result === 'FAIL') return { ...base, result: 'FAIL', coverage: 'full', reason: c4.reason || 'runtime failed' }
171
+ return { ...base, result: 'ERROR', coverage: 'not-run', reason: c4?.reason || 'no runtime evidence' }
172
+ }
173
+
174
+ function anchorOf(options, runtime) {
175
+ return {
176
+ repo: 'deepseek-ai/deepseek-harness',
177
+ commit: options.commit,
178
+ scanner: `dsh-plugin-verify@${VERSION}`,
179
+ dsh: options.runtimeVersion,
180
+ cordis: options.cordisVersion,
181
+ runtime: runtime ? path.resolve(runtime) : null,
182
+ }
183
+ }
184
+
185
+ export async function main(argv = process.argv.slice(2)) {
186
+ let options
187
+ try {
188
+ options = parseArgs(argv)
189
+ } catch (error) {
190
+ process.stderr.write(`dsh-plugin-verify: ${error.message}\n`)
191
+ process.stderr.write(usage())
192
+ return 3
193
+ }
194
+ if (options.help) { process.stdout.write(usage()); return 0 }
195
+ if (options.version) { process.stdout.write(`${VERSION}\n`); return 0 }
196
+
197
+ const dir = path.resolve(options.target)
198
+ let stat
199
+ try { stat = fs.statSync(dir) } catch {
200
+ process.stderr.write(`dsh-plugin-verify: target not found: ${dir}\n`)
201
+ return 3
202
+ }
203
+ if (!stat.isDirectory()) {
204
+ process.stderr.write(`dsh-plugin-verify: target must be a package directory (tarball support is not in the MVP): ${dir}\n`)
205
+ return 3
206
+ }
207
+
208
+ const { manifest, kind, kindInferred, entry, patch, g4, checks: manifestChecks } = runManifestChecks(dir, {
209
+ runtimeVersion: options.runtimeVersion,
210
+ installSpec: options.installSpec,
211
+ marketIndex: options.marketIndex,
212
+ pluginName: options.pluginName,
213
+ })
214
+
215
+ let sources = []
216
+ try { sources = readSources(dir) } catch { sources = [] }
217
+ const declarationChecks = manifest ? [
218
+ checkCapabilities(manifest, sources, patch),
219
+ checkCredentials(manifest, sources),
220
+ checkLabels(manifest, sources),
221
+ ] : [
222
+ { id: 'DSC-11', clause: 'C-11', layer: 'L0', result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [], method: 'M', advisory: false },
223
+ { id: 'DSC-12', clause: 'C-12', layer: 'L0', result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [], method: 'M', advisory: false },
224
+ { id: 'DSC-13', clause: 'C-13', layer: 'L0', result: 'ERROR', coverage: 'not-run', reason: 'manifest unavailable', details: [], evidence: [], method: 'M', advisory: false },
225
+ ]
226
+ const advisoryChecks = [checkImportTimeAdvisory(sources)]
227
+
228
+ if (options.scope === 'entry' && options.entryRuntime === 'server-entry' && kind !== 'plugin') options.treatAsPlugin = true
229
+ let runtimeKind = kind
230
+ let runtimeEntry = entry
231
+ if (options.treatAsPlugin && manifest) {
232
+ runtimeKind = 'plugin'
233
+ if (!runtimeEntry?.exists) {
234
+ const candidates = ['lib/index.js', 'index.js', 'lib/public-entry.js', 'src/index.ts', 'src/index.js', 'src/index.mjs']
235
+ for (const relative of candidates) {
236
+ const absolute = path.resolve(dir, relative)
237
+ if (fs.existsSync(absolute)) { runtimeEntry = { exists: true, absolute, relative, source: '--treat-as-plugin' }; break }
238
+ }
239
+ }
240
+ }
241
+
242
+ let runtimeChecks
243
+ if (options.noExec || !manifest) {
244
+ const reason = options.noExec ? '--no-exec' : 'manifest unavailable'
245
+ runtimeChecks = ['DSC-04', 'DSC-05', 'DSC-06', 'DSC-07', 'DSC-08', 'DSC-09'].map(id => ({
246
+ id, clause: { 'DSC-04': 'C-4', 'DSC-05': 'C-5', 'DSC-06': 'C-6', 'DSC-07': 'C-7', 'DSC-08': 'C-8', 'DSC-09': 'C-9' }[id],
247
+ layer: { 'DSC-04': 'L1', 'DSC-05': 'L1', 'DSC-06': 'L1+L2', 'DSC-07': 'L2', 'DSC-08': 'L3', 'DSC-09': 'L1' }[id],
248
+ result: 'ERROR', coverage: 'not-run', reason, details: [], evidence: [], method: 'M', advisory: false,
249
+ }))
250
+ } else {
251
+ runtimeChecks = await runRuntimeChecks({
252
+ dir, manifest, kind: runtimeKind, entry: runtimeEntry,
253
+ runtimePath: options.runtime, timeoutMs: options.timeoutMs, sources,
254
+ })
255
+ }
256
+
257
+ const runCheck = await entryRunCheck(kind, runtimeChecks, options, dir)
258
+ const checks = [
259
+ manifestChecks[0], manifestChecks[1], manifestChecks[2],
260
+ ...runtimeChecks,
261
+ runCheck,
262
+ advisoryChecks[0],
263
+ ...declarationChecks,
264
+ ]
265
+
266
+ const subject = {
267
+ mode: options.profile === 'listing' || options.profile === 'both' ? 'listing' : 'package',
268
+ profile: options.profile,
269
+ kind,
270
+ kindInferred,
271
+ kindEvaluated: runtimeKind,
272
+ name: manifest?.name ?? null,
273
+ version: manifest?.version ?? null,
274
+ path: dir,
275
+ }
276
+ const report = buildReport({
277
+ subject,
278
+ anchor: anchorOf(options, options.runtime),
279
+ checks,
280
+ g4,
281
+ scope: options.scope,
282
+ profile: options.profile,
283
+ kind,
284
+ staticOnly: options.staticOnly,
285
+ entryRuntime: options.entryRuntime,
286
+ entryClaim: options.entryClaim,
287
+ })
288
+ const output = writeReport(report, options.format)
289
+ if (options.out) {
290
+ const outPath = path.resolve(options.out)
291
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
292
+ fs.writeFileSync(outPath, output)
293
+ process.stderr.write(`dsh-plugin-verify: wrote ${outPath}\n`)
294
+ } else {
295
+ process.stdout.write(output)
296
+ }
297
+ return report.conclusion.exitCode
298
+ }
299
+
300
+ export { parseArgs, usage, VERSION }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Static G4 check: when `dsh.client` exists, the package must expose
3
+ * `exports["./client"]` and declare the client at the package root.
4
+ *
5
+ * This is a static shape check. It does not prove the client module loads or
6
+ * renders; that is a runtime/UI probe and is reported separately.
7
+ */
8
+ export function checkClientShape(manifest) {
9
+ const hasClient = manifest?.dsh?.client !== undefined
10
+ if (!hasClient) {
11
+ return {
12
+ hasClient: false,
13
+ ok: true,
14
+ result: 'NA',
15
+ reason: 'no dsh.client declaration',
16
+ }
17
+ }
18
+ const client = manifest.dsh.client
19
+ if (client === null || client === undefined || client === '') {
20
+ return { hasClient: true, ok: false, result: 'FAIL', reason: 'dsh.client is empty' }
21
+ }
22
+ const exported = manifest.exports?.['./client']
23
+ if (!exported) {
24
+ return {
25
+ hasClient: true,
26
+ ok: false,
27
+ result: 'FAIL',
28
+ reason: 'dsh.client exists but exports["./client"] is missing; a package-root row cannot resolve it',
29
+ }
30
+ }
31
+ return {
32
+ hasClient: true,
33
+ ok: true,
34
+ result: 'PASS',
35
+ reason: 'dsh.client + exports["./client"] present (static only)',
36
+ }
37
+ }
@@ -0,0 +1,242 @@
1
+ import { isObject, regexHits } from './util.mjs'
2
+ import { check, detail, worst } from './result.mjs'
3
+ import { validateCommitments, validateDisclosure } from './permissions.mjs'
4
+
5
+ export const CAPABILITIES = new Set([
6
+ 'fs:read', 'fs:write', 'shell', 'subprocess', 'network:egress', 'network:ingress',
7
+ 'credentials:read', 'credentials:list', 'credentials:provider', 'llm:adapter',
8
+ 'sessions:read', 'system-prompt:write', 'tools:register', 'remote:expose', 'plugin:install',
9
+ ])
10
+
11
+ export const LABELS = new Set([
12
+ 'prompt-modification', 'red-team', 'third-party-model-provider', 'remote-access', 'mirror',
13
+ ])
14
+
15
+ export const FORBIDDEN_LABELS = new Set(['official', 'verified', 'certified', 'secure', 'audited'])
16
+
17
+ const TRIGGERS = [
18
+ { cap: 'network:ingress', pattern: /ctx\.(?:webServer|webhookRuntime)\b/g },
19
+ { cap: 'plugin:install', pattern: /\b(?:installBundle|removeBundle)\s*\(/g },
20
+ { cap: 'llm:adapter', pattern: /registerAdapter\s*\(|ctx\.llm\.register/g },
21
+ { cap: 'system-prompt:write', pattern: /ctx\.systemPrompt\.(?:section|context|variable)\s*\(/g },
22
+ { cap: 'tools:register', pattern: /ctx\.tools\.register\s*\(/g },
23
+ { cap: 'remote:expose', pattern: /@Remote(?:Scope)?\s*\(/g },
24
+ { cap: 'credentials:list', pattern: /\.listRecords\s*\(/g },
25
+ { cap: 'sessions:read', pattern: /ctx\.(?:sessions|sessionQuery)\b/g },
26
+ { cap: 'shell', pattern: /ctx\.(?:shell|bash)\b/g },
27
+ { cap: 'subprocess', pattern: /ctx\.subprocess\b/g },
28
+ { cap: 'fs:read', pattern: /ctx\.fs\b/g },
29
+ { cap: 'network:egress', pattern: /ctx\.web\b|\bfetch\s*\(/g },
30
+ ]
31
+
32
+ function capabilityList(manifest) {
33
+ const value = manifest?.dsh?.capabilities
34
+ if (value === undefined) return { defined: false, list: [], error: null }
35
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
36
+ return { defined: true, list: [], error: 'dsh.capabilities must be a string array' }
37
+ }
38
+ return { defined: true, list: value, error: null }
39
+ }
40
+
41
+ function labelList(manifest) {
42
+ const value = manifest?.dsh?.labels
43
+ if (value === undefined) return { defined: false, list: [], error: null }
44
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
45
+ return { defined: true, list: [], error: 'dsh.labels must be a string array' }
46
+ }
47
+ return { defined: true, list: value, error: null }
48
+ }
49
+
50
+ function hitEvidence(hits, limit = 3) {
51
+ return hits.slice(0, limit).map(hit => `${hit.file}:${hit.line}`)
52
+ }
53
+
54
+ /** DSC-10: advisory only; never FAIL. */
55
+ export function checkImportTimeAdvisory(sources) {
56
+ const patterns = [
57
+ { name: 'top-level await', pattern: /^await\s/m },
58
+ { name: 'top-level setInterval', pattern: /^setInterval\s*\(/m },
59
+ { name: 'top-level process.on', pattern: /^process\.on\s*\(/m },
60
+ { name: 'top-level process.env assignment', pattern: /^process\.env\.[A-Za-z_][A-Za-z0-9_]*\s*=/m },
61
+ { name: 'top-level globalThis assignment', pattern: /^globalThis\.[A-Za-z_][A-Za-z0-9_]*\s*=/m },
62
+ ]
63
+ const hits = []
64
+ for (const item of patterns) {
65
+ for (const hit of regexHits(sources, item.pattern)) hits.push({ ...hit, pattern: item.name })
66
+ }
67
+ const result = hits.length > 0 ? 'WARN' : sources.length === 0 ? 'WARN' : 'PASS'
68
+ return check({
69
+ id: 'DSC-10',
70
+ clause: 'C-10',
71
+ layer: 'L0',
72
+ result,
73
+ advisory: true,
74
+ reason: hits.length > 0 ? `advisory: ${hits.length} import-time side-effect clue(s)` : sources.length === 0 ? 'no source files scanned' : '',
75
+ details: hits.slice(0, 20).map(hit => detail('C-10.1/2', 'WARN', `${hit.pattern} at ${hit.file}:${hit.line}`, [`${hit.file}:${hit.line}`])),
76
+ evidence: hitEvidence(hits),
77
+ })
78
+ }
79
+
80
+ /** DSC-11: dsh.capabilities declaration + high-signal static triggers. */
81
+ export function checkCapabilities(manifest, sources, patch) {
82
+ const caps = capabilityList(manifest)
83
+ const details = []
84
+ if (caps.error) details.push(detail('C-11.1', 'FAIL', caps.error, ['package.json#dsh.capabilities']))
85
+ if (!caps.defined) {
86
+ details.push(detail('C-11.1', 'WARN', 'dsh.capabilities is not declared (transitional WARN; host does not read it yet)', ['package.json#dsh.capabilities']))
87
+ } else if (!caps.error) {
88
+ for (const id of caps.list) {
89
+ if (!CAPABILITIES.has(id)) details.push(detail('C-11.1', 'FAIL', `unknown capability id: ${id}`, ['package.json#dsh.capabilities']))
90
+ }
91
+ if (caps.list.length === 0) details.push(detail('C-11.1', 'WARN', 'dsh.capabilities is empty', ['package.json#dsh.capabilities']))
92
+ }
93
+
94
+ const triggered = new Map()
95
+ for (const trigger of TRIGGERS) {
96
+ const hits = regexHits(sources, trigger.pattern)
97
+ if (hits.length > 0) triggered.set(trigger.cap, hits)
98
+ }
99
+ if (!caps.defined) {
100
+ for (const [cap, hits] of triggered) {
101
+ details.push(detail('C-11.2', 'WARN', `trigger for ${cap} observed but dsh.capabilities is absent`, hitEvidence(hits)))
102
+ }
103
+ } else if (!caps.error) {
104
+ for (const [cap, hits] of triggered) {
105
+ const satisfied = caps.list.includes(cap) || (cap === 'fs:read' && caps.list.includes('fs:write'))
106
+ if (!satisfied) {
107
+ details.push(detail('C-11.2', 'FAIL', `static trigger ${cap} is not declared in dsh.capabilities`, hitEvidence(hits)))
108
+ }
109
+ }
110
+ for (const cap of caps.list) {
111
+ const observed = triggered.has(cap) || (cap === 'fs:write' && triggered.has('fs:read'))
112
+ if (!observed) details.push(detail('C-11.5', 'WARN', `declared capability ${cap} was not observed statically`, ['package.json#dsh.capabilities']))
113
+ }
114
+ }
115
+
116
+ if (patch?.enabledPluginManagerTools) {
117
+ details.push(detail('C-11.3', 'FAIL', 'bundle patch enables @deepseek-ai/dsh-plugin-manager/tools', ['dsh.bundle.patch']))
118
+ }
119
+ const dangerHits = regexHits(sources, /danger-full-access/g)
120
+ if (dangerHits.length > 0) details.push(detail('C-11.4', 'WARN', 'danger-full-access literal found', hitEvidence(dangerHits)))
121
+ if (details.length === 0) details.push(detail('C-11.1', 'PASS', 'dsh.capabilities is declared and consistent with static triggers'))
122
+
123
+ const result = worst(details.map(item => item.result))
124
+ return check({
125
+ id: 'DSC-11', clause: 'C-11', layer: 'L0', result,
126
+ reason: details.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
127
+ details,
128
+ evidence: ['package.json#dsh.capabilities', 'dsh.bundle.patch'],
129
+ })
130
+ }
131
+
132
+ /** DSC-12: direct credential backing-file access / listRecords. */
133
+ export function checkCredentials(manifest, sources) {
134
+ const caps = capabilityList(manifest)
135
+ const details = []
136
+ const backingPatterns = [
137
+ /\.credentials\.yaml/g,
138
+ /\bCREDENTIALS_FILENAME\b/g,
139
+ /@deepseek-ai\/dsh-credentials-local/g,
140
+ /\$DSH_HOME[^\n]{0,80}credentials/gi,
141
+ ]
142
+ const backingHits = []
143
+ for (const pattern of backingPatterns) backingHits.push(...regexHits(sources, pattern))
144
+ const provider = caps.defined && caps.list.includes('credentials:provider')
145
+ const hardHits = backingHits.filter(hit => {
146
+ if (/credentials-local/.test(hit.text)) return true
147
+ const source = sources.find(item => item.relative === hit.file)
148
+ if (!source) return false
149
+ const lines = source.text.split('\n')
150
+ const window = lines.slice(Math.max(0, hit.line - 6), hit.line + 6).join('\n')
151
+ return /readFile|readFileSync|createReadStream|openSync|\.load\s*\(|\.parse\s*\(/.test(window)
152
+ })
153
+ if (backingHits.length === 0) {
154
+ details.push(detail('C-12.1', 'PASS', 'no credential backing-file reference found'))
155
+ } else if (provider) {
156
+ details.push(detail('C-12.1', 'PASS', 'credential backing-file reference is declared as credentials:provider', hitEvidence(backingHits)))
157
+ } else if (hardHits.length > 0) {
158
+ details.push(detail('C-12.1', 'FAIL', 'credential backing-file access near a file-read API without credentials:provider', hitEvidence(hardHits)))
159
+ } else {
160
+ details.push(detail('C-12.1', 'PASS', 'credential backing-file path appears in text/UI only; no file-read API found nearby', hitEvidence(backingHits)))
161
+ }
162
+
163
+ const listHits = regexHits(sources, /\.listRecords\s*\(/g)
164
+ if (listHits.length > 0) {
165
+ if (!caps.defined) details.push(detail('C-12.2', 'WARN', 'listRecords() used but dsh.capabilities is absent (transitional WARN)', hitEvidence(listHits)))
166
+ else if (!caps.list.includes('credentials:list')) details.push(detail('C-12.2', 'FAIL', 'listRecords() used without credentials:list', hitEvidence(listHits)))
167
+ else details.push(detail('C-12.2', 'PASS', 'listRecords() is declared as credentials:list'))
168
+ } else {
169
+ details.push(detail('C-12.2', 'PASS', 'listRecords() not used'))
170
+ }
171
+
172
+ const providerHits = regexHits(sources, /ctx\.provide\(\s*['"](?:shell|credentials|sandboxPolicy|sandbox|deepseekAccount)['"]/g)
173
+ if (providerHits.length > 0) details.push(detail('C-12.3', 'WARN', 'global seam provider registration detected; declaration ownership needs review', hitEvidence(providerHits)))
174
+ const dangerHits = regexHits(sources, /danger-full-access|--add-dir/g)
175
+ if (dangerHits.length > 0) details.push(detail('C-12.4', 'WARN', 'danger-full-access/--add-dir literal found', hitEvidence(dangerHits)))
176
+ details.push(...validateCommitments(manifest), ...validateDisclosure(manifest))
177
+
178
+ const result = worst(details.map(item => item.result))
179
+ return check({
180
+ id: 'DSC-12', clause: 'C-12', layer: 'L0', result,
181
+ reason: details.filter(item => item.result !== 'PASS' && item.result !== 'NA').map(item => `${item.item}: ${item.reason}`).join(' | '),
182
+ details,
183
+ evidence: ['package.json#dsh.capabilities'],
184
+ })
185
+ }
186
+
187
+ /** DSC-13: high-risk labels + mirror/upstream. */
188
+ export function checkLabels(manifest, sources) {
189
+ const caps = capabilityList(manifest)
190
+ const labels = labelList(manifest)
191
+ const details = []
192
+ if (labels.error) details.push(detail('C-13.1', 'FAIL', labels.error, ['package.json#dsh.labels']))
193
+ if (!labels.defined) {
194
+ details.push(detail('C-13.1', 'WARN', 'dsh.labels is not declared', ['package.json#dsh.labels']))
195
+ } else if (!labels.error) {
196
+ for (const label of labels.list) {
197
+ if (!LABELS.has(label)) details.push(detail('C-13.1', 'FAIL', `unknown label: ${label}`, ['package.json#dsh.labels']))
198
+ }
199
+ }
200
+
201
+ for (const label of labels.list) {
202
+ if (FORBIDDEN_LABELS.has(label)) details.push(detail('C-13.4', 'FAIL', `forbidden endorsement label: ${label}`, ['package.json#dsh.labels']))
203
+ }
204
+
205
+ const upstream = manifest?.dsh?.upstream
206
+ if (upstream !== undefined) {
207
+ let url = null
208
+ try { url = new URL(String(upstream)) } catch { /* handled below */ }
209
+ if (!url || url.protocol !== 'https:') {
210
+ details.push(detail('C-13.3', 'FAIL', 'dsh.upstream must be an https URL', ['package.json#dsh.upstream']))
211
+ } else if (isObject(manifest.repository) && typeof manifest.repository.url === 'string' && manifest.repository.url.includes(url.hostname + url.pathname)) {
212
+ details.push(detail('C-13.3', 'FAIL', 'dsh.upstream points at the package itself', ['package.json#dsh.upstream']))
213
+ } else if (!labels.list.includes('mirror')) {
214
+ details.push(detail('C-13.2', 'FAIL', 'dsh.upstream is declared but the mirror label is missing', ['package.json#dsh.labels']))
215
+ }
216
+ }
217
+
218
+ if (!caps.defined) {
219
+ details.push(detail('C-13.2', 'WARN', 'dsh.capabilities is absent; trigger-to-label mapping cannot be evaluated', ['package.json#dsh.capabilities']))
220
+ } else if (!caps.error) {
221
+ const required = new Set()
222
+ if (caps.list.includes('system-prompt:write')) required.add('prompt-modification')
223
+ if (caps.list.includes('llm:adapter') && caps.list.includes('network:egress')) required.add('third-party-model-provider')
224
+ if (caps.list.includes('network:ingress')) required.add('remote-access')
225
+ for (const label of required) {
226
+ if (!labels.list.includes(label)) details.push(detail('C-13.2', 'FAIL', `capability trigger requires label ${label}`, ['package.json#dsh.labels']))
227
+ }
228
+ }
229
+
230
+ if (labels.list.includes('red-team') && manifest?.dsh?.usageScope === undefined) {
231
+ details.push(detail('C-13.5', 'WARN', 'red-team label without dsh.usageScope', ['package.json#dsh.labels']))
232
+ }
233
+ if (details.length === 0) details.push(detail('C-13.1', 'PASS', 'dsh.labels is declared and consistent with capability triggers'))
234
+
235
+ const result = worst(details.map(item => item.result))
236
+ return check({
237
+ id: 'DSC-13', clause: 'C-13', layer: 'L0', result,
238
+ reason: details.filter(item => item.result !== 'PASS').map(item => `${item.item}: ${item.reason}`).join(' | '),
239
+ details,
240
+ evidence: ['package.json#dsh.labels', 'package.json#dsh.upstream'],
241
+ })
242
+ }
@@ -0,0 +1,8 @@
1
+ /** Programmatic API for dsh-plugin-verify. */
2
+ export { main, parseArgs, usage, VERSION } from './cli.mjs'
3
+ export { runManifestChecks, checkMarketEntry, DEFAULT_RUNTIME_VERSION, inferKind, resolveEntry } from './manifest.mjs'
4
+ export { checkCapabilities, checkCredentials, checkLabels, checkImportTimeAdvisory, CAPABILITIES, LABELS } from './declarations.mjs'
5
+ export { runRuntimeChecks } from './runtime.mjs'
6
+ export { buildReport, formatText, formatSarif, writeReport } from './report.mjs'
7
+ export { satisfies, validRange, parseVersion, compareVersions } from './semver.mjs'
8
+ export { parseInstallSpec } from './install-spec.mjs'
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Minimal port of plugin-manager/src/install-spec.ts:69 (parseInstallSpec).
3
+ * The checker must not import the host TypeScript source (it is not built in
4
+ * a plugin checkout), so this file reimplements the accepted forms and
5
+ * refusal rules. It is deliberately narrower: it also requires a pinned
6
+ * reference for git specs, which DS-C C-3 requires.
7
+ */
8
+
9
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/
10
+ const PACKAGE_NAME_MAX_LENGTH = 214
11
+ const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i
12
+ const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i
13
+ const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i
14
+ const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i
15
+ const COMMIT = /^[0-9a-f]{40}$/
16
+
17
+ export class InvalidInstallSpec extends Error {
18
+ constructor(spec, reason) {
19
+ super(`invalid install spec: ${reason}: ${spec}`)
20
+ this.name = 'InvalidInstallSpec'
21
+ this.spec = spec
22
+ this.reason = reason
23
+ }
24
+ }
25
+
26
+ function fail(spec, reason) {
27
+ throw new InvalidInstallSpec(spec, reason)
28
+ }
29
+
30
+ /**
31
+ * @param {string} raw
32
+ * @returns {{kind:'registry'|'path'|'tarball'|'git', spec:string, name?:string, range?:string, path?:string, host?:string, commit?:string, pinned:boolean}}
33
+ */
34
+ export function parseInstallSpec(raw) {
35
+ if (typeof raw !== 'string') fail(String(raw), 'the spec must be a string')
36
+ const spec = raw.trim()
37
+ if (spec === '') fail(spec, 'the package spec must not be empty')
38
+
39
+ const path = spec.replace(/^(?:file|link):/, '')
40
+ if (path !== spec || /^(?:\/|[A-Za-z]:[\\/])/.test(spec)) {
41
+ if (!/^(?:\/|[A-Za-z]:[\\/])/.test(path)) fail(spec, 'a local path must be absolute')
42
+ if (TARBALL_SPEC.test(path)) return { kind: 'tarball', spec, path, pinned: true }
43
+ return { kind: 'path', spec, path, pinned: true }
44
+ }
45
+ if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) fail(spec, 'a local path must be absolute')
46
+
47
+ const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)
48
+ if (git && !TARBALL_SPEC.test(spec)) {
49
+ if (!/#[0-9a-f]{40}$/.test(spec)) fail(spec, 'a git spec must pin a 40-hex-digit commit (no HEAD/branch/tag)')
50
+ return { kind: 'git', spec, host: 'git', commit: spec.slice(-40), pinned: true }
51
+ }
52
+ if (/^https?:\/\//i.test(spec)) {
53
+ if (TARBALL_SPEC.test(spec)) return { kind: 'tarball', spec, host: new URL(spec).host, pinned: true }
54
+ fail(spec, 'a URL must point at a git repository or a tarball')
55
+ }
56
+
57
+ const at = spec.indexOf('@', 1)
58
+ const name = at === -1 ? spec : spec.slice(0, at)
59
+ const range = at === -1 ? undefined : spec.slice(at + 1)
60
+ if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) {
61
+ fail(spec, 'not a package name the registry accepts')
62
+ }
63
+ if (range === '') fail(spec, 'a version after @ must not be empty')
64
+ return range === undefined
65
+ ? { kind: 'registry', spec, name, pinned: false }
66
+ : { kind: 'registry', spec, name, range, pinned: !/^(?:latest|next|beta|canary|\*|x)$/i.test(range) }
67
+ }