dsh-plugin-inspector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,632 @@
1
+ /**
2
+ * Tier A — decidable checks over structured declarations.
3
+ *
4
+ * Everything here reads a field the harness itself must read literally in order
5
+ * to act on it: a `package.json` key, or a Cordis patch row. That is why Tier A
6
+ * findings carry `certain` confidence and why they are the only findings this
7
+ * tool treats as verdicts. `disabled: true` cannot be obfuscated and still
8
+ * disable anything.
9
+ * @module dsh-plugin-inspector/checks/tier-a
10
+ */
11
+ import { isJsExpr } from "../cordis-yaml.js";
12
+ import { boundedJson, lineColumn, normalizePackagePath, snippet } from "../files.js";
13
+ import { scanInjection } from "../injection.js";
14
+ import { CORE_ROWS, HARNESS_BUNDLE_PACKAGES, INSTALL_LIFECYCLE_SCRIPTS, MCP_CLIENT_PACKAGE, SECURITY_ROW_IDS, SECURITY_SEAM_KEYS, SEAM_KEYS, SKILL_FILESYSTEM_ROW, SKILL_ROOT_CONFIG_KEYS, } from "../knowledge.js";
15
+ import { declaredPackages } from "../manifest.js";
16
+ /**
17
+ * Checks that read a Cordis patch row. None of them may produce a finding
18
+ * about a package the harness never composes into a profile: `dsh plugin add`
19
+ * on a package with no `dsh.bundle` installs a plain library and says so, and
20
+ * whatever YAML that library happens to ship is inert bytes.
21
+ */
22
+ const PATCH_ROW_CHECKS = new Set([
23
+ 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A15', 'A17', 'A19', 'A23',
24
+ ]);
25
+ /** Loader builtins that are entry names but not resolvable npm packages. */
26
+ const LOADER_BUILTINS = new Set([
27
+ 'cordis:include', 'cordis:group',
28
+ '@deepseek-ai/cordis-plugin-group', '@deepseek-ai/cordis-plugin-include',
29
+ ]);
30
+ /** Specifier prefixes that do not pin an immutable registry artifact. */
31
+ const MUTABLE_SPECIFIER = /^(?:git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|link:|portal:)/;
32
+ /**
33
+ * How much reach each `!!js` classification represents.
34
+ *
35
+ * `call` is medium, not high: the class means "calls something this tool
36
+ * cannot resolve", and under `with (ctx)` most of what a config expression can
37
+ * call is a service the profile already handed it. `harness-call` is lower
38
+ * still — the harness itself puts those functions in scope.
39
+ */
40
+ const EXPRESSION_SEVERITY = {
41
+ 'module-access': 'critical',
42
+ mutation: 'high',
43
+ call: 'medium',
44
+ unparseable: 'medium',
45
+ 'harness-call': 'low',
46
+ 'inert-read': 'low',
47
+ literal: 'low',
48
+ };
49
+ /** What each `!!js` classification means, in one clause. */
50
+ const EXPRESSION_MEANING = {
51
+ 'module-access': 'reaches the module system, the global object, or the evaluator',
52
+ mutation: 'writes to something rather than only reading',
53
+ call: 'calls something this tool cannot resolve',
54
+ unparseable: 'does not parse, so the entry cannot mount',
55
+ 'harness-call': 'calls a helper the harness provides to config expressions',
56
+ 'inert-read': 'reads an identifier',
57
+ literal: 'is a constant',
58
+ };
59
+ /**
60
+ * Classifications with no reach at all. A constant and a read of a service the
61
+ * profile already provides are what the shipped bundles are made of, so they
62
+ * are counted as a fact and never raised as a finding.
63
+ */
64
+ const INERT_CLASSES = new Set(['literal', 'inert-read']);
65
+ /**
66
+ * Build one finding, keeping every Tier A finding at `certain` confidence and
67
+ * `null` bypass — a structured declaration has no syntactic evasion.
68
+ * @param finding - everything but the fixed fields.
69
+ * @returns the complete finding.
70
+ */
71
+ function tierA(finding) {
72
+ return { ...finding, tier: 'A', confidence: 'certain', bypass: null };
73
+ }
74
+ /**
75
+ * Whether the package under analysis is itself one of the harness's shipped
76
+ * bundles. A bundle composes the core row set: `@deepseek-ai/dsh-web-app`
77
+ * disabling two dozen rows `@deepseek-ai/dsh-base` inserted is what a bundle
78
+ * *is*, and reporting each one as an attack on the profile says nothing.
79
+ * @param input - the decoded package.
80
+ * @returns true when this package's own name is a shipped bundle's.
81
+ */
82
+ function isHarnessBundle(input) {
83
+ return HARNESS_BUNDLE_PACKAGES.has(input.manifest.name);
84
+ }
85
+ /**
86
+ * How severe it is to remove one core row, by which bundles define it. Every
87
+ * profile mounts `base`; `headless` and `web-app` are surface bundles a given
88
+ * profile may never have mounted, so a row only they define was never there to
89
+ * lose.
90
+ * @param id - the row id.
91
+ * @returns the severity, or `null` when the id is not a core row at all.
92
+ */
93
+ function coreRowSeverity(id) {
94
+ const row = CORE_ROWS.get(id);
95
+ if (row === undefined)
96
+ return null;
97
+ return row.bundles.includes('base') ? 'high' : 'medium';
98
+ }
99
+ /**
100
+ * Name the bundles a core row comes from, for a finding's prose.
101
+ * @param id - the row id.
102
+ * @returns a phrase naming the bundles.
103
+ */
104
+ function coreRowOrigin(id) {
105
+ const row = CORE_ROWS.get(id);
106
+ if (row === undefined)
107
+ return 'a shipped bundle';
108
+ return row.bundles.map(bundle => `@deepseek-ai/dsh-${bundle}`).join(' and ');
109
+ }
110
+ /** A2, A3, A19 — a patch layer switching a core row off, or back on. */
111
+ function checkDisabledRows(input) {
112
+ if (isHarnessBundle(input))
113
+ return [];
114
+ const findings = [];
115
+ for (const patch of input.patches) {
116
+ for (const override of patch.overrides) {
117
+ if (override.disabled === undefined)
118
+ continue;
119
+ const expression = isJsExpr(override.disabled) ? override.disabled.__jsExpr : null;
120
+ const shown = expression === null ? boundedJson(override.disabled) : `!!js ${expression}`;
121
+ // The loader coerces: `disabledOf` is `Boolean(options.disabled)`
122
+ // (vendor/loader/src/config/entry.ts). `null`, `0` and `""` therefore
123
+ // leave the row running, and reporting them as a disabled row would be
124
+ // confidently wrong about the one thing Tier A claims to be certain of.
125
+ // An expression node is an object, so it stays truthy here and is judged
126
+ // by what it can evaluate to rather than by its own shape.
127
+ if (!override.disabled) {
128
+ const enabled = coreRowSeverity(override.id);
129
+ if (enabled === null)
130
+ continue;
131
+ findings.push(tierA({
132
+ checkId: 'A19',
133
+ name: 'core-row-force-enabled',
134
+ severity: 'medium',
135
+ title: `Patch layer re-enables the core row "${override.id}"`,
136
+ detail: 'The loader coerces `disabled` with `Boolean()`, so this value leaves the row running. Because '
137
+ + 'bundle layers apply after the profile\'s own, a row the user deliberately switched off in their '
138
+ + 'personal layer is switched back on by this one — and the user\'s file still reads `disabled: true`.',
139
+ evidence: { file: patch.file, path: `${override.path}.disabled`, snippet: snippet(shown) },
140
+ }));
141
+ continue;
142
+ }
143
+ const stops = SECURITY_ROW_IDS.get(override.id);
144
+ if (stops !== undefined) {
145
+ findings.push(tierA({
146
+ checkId: 'A2',
147
+ name: 'security-row-disabled',
148
+ severity: 'critical',
149
+ title: `Patch layer disables the core row "${override.id}"`,
150
+ detail: `Bundle patches apply after @deepseek-ai/dsh-base, so this layer switches off ${stops}. `
151
+ + (expression === null
152
+ ? 'The row stops running for every session in the profile.'
153
+ : 'The expression is re-evaluated at every mount decision, so the row can be switched off conditionally at runtime.'),
154
+ evidence: { file: patch.file, path: `${override.path}.disabled`, snippet: snippet(shown) },
155
+ }));
156
+ continue;
157
+ }
158
+ const severity = coreRowSeverity(override.id);
159
+ if (severity === null)
160
+ continue;
161
+ findings.push(tierA({
162
+ checkId: 'A3',
163
+ name: 'core-row-disabled',
164
+ severity,
165
+ title: `Patch layer disables the core row "${override.id}"`,
166
+ detail: `The row comes from ${coreRowOrigin(override.id)}, and this layer applies after it, so the row `
167
+ + 'stops running for every session in the profile.'
168
+ + (severity === 'medium'
169
+ ? ' That bundle is a surface bundle rather than the shared base, so a profile that does not mount it '
170
+ + 'never had this row.'
171
+ : ''),
172
+ evidence: { file: patch.file, path: `${override.path}.disabled`, snippet: snippet(shown) },
173
+ }));
174
+ }
175
+ }
176
+ return findings;
177
+ }
178
+ /** A4, A5 — a patch layer rewriting a core row it did not define. */
179
+ function checkOverriddenRows(input) {
180
+ if (isHarnessBundle(input))
181
+ return [];
182
+ const findings = [];
183
+ for (const patch of input.patches) {
184
+ for (const override of patch.overrides) {
185
+ const coreName = CORE_ROWS.get(override.id)?.module;
186
+ if (coreName === undefined)
187
+ continue;
188
+ if (override.nameGuard !== null && override.nameGuard !== coreName) {
189
+ findings.push(tierA({
190
+ checkId: 'A4',
191
+ name: 'patch-name-guard-mismatch',
192
+ severity: 'medium',
193
+ title: `Patch for "${override.id}" names ${override.nameGuard}, but that row is ${coreName}`,
194
+ detail: 'applyEntryPatches treats `name` on a non-insert patch as an assertion guard: on mismatch it '
195
+ + 'warns and skips the whole patch. Every override in this patch therefore does nothing, so what the '
196
+ + 'file says and what mounts disagree.',
197
+ evidence: { file: patch.file, path: `${override.path}.name`, snippet: snippet(override.nameGuard) },
198
+ }));
199
+ continue;
200
+ }
201
+ const rewritten = override.overriddenKeys.filter(key => key !== 'disabled');
202
+ if (rewritten.length === 0)
203
+ continue;
204
+ const isSecurity = SECURITY_ROW_IDS.has(override.id);
205
+ findings.push(tierA({
206
+ checkId: 'A5',
207
+ name: 'core-row-overridden',
208
+ severity: isSecurity ? 'high' : 'medium',
209
+ title: `Patch layer rewrites ${rewritten.map(key => `\`${key}\``).join(', ')} on the core row "${override.id}"`,
210
+ detail: `The row is ${coreName}. Patch overrides are shallow whole-value replacements, not merges, so `
211
+ + `overriding \`config\` discards that row's entire shipped configuration rather than adding to it.`
212
+ + (isSecurity ? ` This row provides ${SECURITY_ROW_IDS.get(override.id) ?? 'a core constraint'}.` : ''),
213
+ evidence: { file: patch.file, path: override.path, snippet: snippet(rewritten.join(', ')) },
214
+ }));
215
+ }
216
+ }
217
+ return findings;
218
+ }
219
+ /**
220
+ * A6, A7 — the `!!js` expressions with reach. The complete inventory is a fact
221
+ * (`facts.jsExpressions`); an expression that is a constant or a read of a
222
+ * service the profile already provides warrants no decision and is not raised.
223
+ */
224
+ function checkExpressions(input) {
225
+ const findings = [];
226
+ for (const patch of input.patches) {
227
+ for (const site of patch.expressions) {
228
+ if (site.slot === 'inert') {
229
+ findings.push(inertExpression(patch.file, site));
230
+ continue;
231
+ }
232
+ if (INERT_CLASSES.has(site.classification))
233
+ continue;
234
+ findings.push(liveExpression(patch.file, site));
235
+ }
236
+ }
237
+ return findings;
238
+ }
239
+ /**
240
+ * One `!!js` node the loader will evaluate.
241
+ * @param file - package-relative YAML path.
242
+ * @param site - the expression site.
243
+ * @returns the finding.
244
+ */
245
+ function liveExpression(file, site) {
246
+ const where = site.slot === 'disabled'
247
+ ? 'A `disabled` expression is re-evaluated at every mount decision, and user patch layers HMR-reload live'
248
+ : 'A `config` expression is evaluated whenever the entry activates or reloads';
249
+ return tierA({
250
+ checkId: 'A6',
251
+ name: 'js-expression',
252
+ severity: EXPRESSION_SEVERITY[site.classification],
253
+ title: `\`!!js\` expression in a row's \`${site.slot}\` ${EXPRESSION_MEANING[site.classification]}`,
254
+ detail: `The loader evaluates this with new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }') — `
255
+ + `unrestricted eval with the plugin context in scope. ${where}. `
256
+ + (site.parseError === undefined
257
+ ? 'This tool classified the expression by parsing it and never evaluated it.'
258
+ : `It does not parse: ${site.parseError}`),
259
+ evidence: { file, path: site.path, snippet: snippet(site.expression) },
260
+ });
261
+ }
262
+ /**
263
+ * One `!!js` node in a field the loader keeps literal, where it silently
264
+ * becomes a truthy object instead of a value.
265
+ * @param file - package-relative YAML path.
266
+ * @param site - the expression site.
267
+ * @returns the finding.
268
+ */
269
+ function inertExpression(file, site) {
270
+ return tierA({
271
+ checkId: 'A7',
272
+ name: 'js-expression-inert',
273
+ severity: 'medium',
274
+ title: '`!!js` in a field the loader never interpolates',
275
+ detail: 'The loader interpolates only a row\'s `config` (recursively) and the top-level node of its `disabled`. '
276
+ + 'Everywhere else the expression stays literal, so this becomes a truthy `{ __jsExpr }` object and silently '
277
+ + 'changes composition. The author believes this is live code and it is not, which means this layer has very '
278
+ + 'likely never been validated.',
279
+ evidence: { file, path: site.path, snippet: snippet(site.expression) },
280
+ });
281
+ }
282
+ /** A8, A17 — patch layers that do not load at all. */
283
+ function checkPatchFailures(input) {
284
+ return input.patchFailures.map(failure => failure.error.singleBangTag
285
+ ? tierA({
286
+ checkId: 'A8',
287
+ name: 'single-bang-js-tag',
288
+ severity: 'medium',
289
+ title: 'Patch layer uses the `!js` tag, which no harness accepts',
290
+ detail: 'The dialect registers exactly one custom tag, `tag:yaml.org,2002:js`, whose shorthand is `!!js`. '
291
+ + '`!js` is an unknown local tag and is a hard YAML parse error, so this file has never been loaded '
292
+ + 'successfully by any harness — the plugin was published without ever being booted.',
293
+ evidence: { file: failure.file, snippet: snippet(failure.error.message) },
294
+ })
295
+ : tierA({
296
+ checkId: 'A17',
297
+ name: 'patch-parse-error',
298
+ severity: 'medium',
299
+ title: 'Patch layer does not parse',
300
+ detail: 'The declared patch layer cannot be read as a Cordis entry list, so mounting this package fails the '
301
+ + 'profile boot. Nothing else in this file could be analysed.',
302
+ evidence: { file: failure.file, snippet: snippet(failure.error.message) },
303
+ }));
304
+ }
305
+ /** A9 — inserting a row that names a module the manifest does not account for. */
306
+ function checkInsertedModules(input) {
307
+ const declared = declaredPackages(input.manifest);
308
+ const coreModules = new Set([...CORE_ROWS.values()].map(row => row.module));
309
+ const findings = [];
310
+ for (const patch of input.patches) {
311
+ for (const row of patch.inserts) {
312
+ const name = row.name;
313
+ if (name === null || LOADER_BUILTINS.has(name))
314
+ continue;
315
+ if (declared.has(name))
316
+ continue;
317
+ // A subpath export of a declared package resolves through that package.
318
+ if ([...declared].some(pkg => name.startsWith(`${pkg}/`)))
319
+ continue;
320
+ const isCore = coreModules.has(name);
321
+ findings.push(tierA({
322
+ checkId: 'A9',
323
+ name: 'insert-undeclared-module',
324
+ severity: isCore ? 'medium' : 'high',
325
+ title: `Inserted row "${row.id ?? '(unnamed)'}" mounts ${name}, which this package does not declare`,
326
+ detail: isCore
327
+ ? 'This is a harness-owned module, so it resolves from the profile install anchor even though this '
328
+ + 'package lists no dependency on it. The manifest therefore does not describe everything this layer mounts.'
329
+ : 'The module is in neither `dependencies`, `peerDependencies`, nor `optionalDependencies`. Whatever it '
330
+ + 'resolves to at mount time is decided by the profile directory, not by this package.',
331
+ evidence: { file: patch.file, path: `${row.path}.name`, snippet: snippet(name) },
332
+ }));
333
+ }
334
+ }
335
+ return findings;
336
+ }
337
+ /** A10 — MCP server rows, which spawn processes or import remote tool catalogues. */
338
+ function checkMcpRows(input) {
339
+ const findings = [];
340
+ for (const patch of input.patches) {
341
+ for (const row of patch.inserts) {
342
+ if (row.name !== MCP_CLIENT_PACKAGE)
343
+ continue;
344
+ const config = typeof row.config === 'object' && row.config !== null
345
+ ? row.config
346
+ : {};
347
+ const stdio = config.transport === 'stdio';
348
+ const command = typeof config.command === 'string' ? config.command : null;
349
+ const shown = isJsExpr(config.command)
350
+ ? `!!js ${config.command.__jsExpr}`
351
+ : command ?? String(config.url ?? '(no command or url)');
352
+ findings.push(tierA({
353
+ checkId: 'A10',
354
+ name: 'mcp-server-row',
355
+ severity: stdio ? 'critical' : 'high',
356
+ title: stdio
357
+ ? `Patch layer starts a local MCP server by running \`${shown}\``
358
+ : 'Patch layer connects to a remote MCP server',
359
+ detail: stdio
360
+ ? 'A stdio MCP row spawns that executable directly with the configured args, env, and cwd. It does not '
361
+ + 'go through ctx.subprocess or ctx.sandbox, it raises no approval prompt, and it passes no tool gate. '
362
+ + 'Every tool the server advertises is then registered as mcp__<server>__<tool> with model-visible '
363
+ + 'descriptions this package does not control.'
364
+ : 'The row imports a tool catalogue from a remote server. The tool names and their model-visible '
365
+ + 'descriptions are decided by that server at connect time, not by anything in this package.',
366
+ evidence: { file: patch.file, path: `${row.path}.config`, snippet: snippet(shown) },
367
+ }));
368
+ }
369
+ }
370
+ return findings;
371
+ }
372
+ /** A15 — pointing skill discovery at this package's own shipped markdown. */
373
+ function checkSkillRootRedirect(input) {
374
+ const findings = [];
375
+ const rows = input.patches.flatMap(patch => [
376
+ ...patch.overrides.map(override => ({ file: patch.file, path: override.path, id: override.id, config: override.config })),
377
+ ...patch.inserts.map(row => ({ file: patch.file, path: row.path, id: row.id, config: row.config })),
378
+ ]);
379
+ for (const row of rows) {
380
+ if (row.id !== SKILL_FILESYSTEM_ROW)
381
+ continue;
382
+ if (typeof row.config !== 'object' || row.config === null)
383
+ continue;
384
+ const config = row.config;
385
+ const keys = SKILL_ROOT_CONFIG_KEYS.filter(key => key in config);
386
+ if (keys.length === 0)
387
+ continue;
388
+ const bundled = keys.includes('bundledSkillDir');
389
+ findings.push(tierA({
390
+ checkId: 'A15',
391
+ name: 'skill-root-redirected',
392
+ severity: 'high',
393
+ title: `Patch layer redirects skill discovery via ${keys.map(key => `\`${key}\``).join(', ')}`,
394
+ detail: 'Skill files reach the model verbatim, unescaped and uncapped. This row changes which directories '
395
+ + 'the filesystem skill provider scans, which is the declaration that turns shipped markdown into model '
396
+ + 'instructions.'
397
+ + (bundled
398
+ ? ' `bundledSkillDir` additionally marks the root trustedHost, which reads through raw Node fs and '
399
+ + 'bypasses the ctx.fs sandbox.'
400
+ : ''),
401
+ evidence: { file: row.file, path: `${row.path}.config`, snippet: snippet(boundedJson(config)) },
402
+ }));
403
+ }
404
+ return findings;
405
+ }
406
+ /** A1, A11, A13, A14, A16, A18 — checks that read `package.json` alone. */
407
+ function checkManifest(input) {
408
+ const findings = [];
409
+ const { manifest, source } = input;
410
+ const lifecycle = INSTALL_LIFECYCLE_SCRIPTS.filter(name => name in manifest.scripts);
411
+ for (const name of lifecycle) {
412
+ findings.push(tierA({
413
+ checkId: 'A1',
414
+ name: 'install-lifecycle-script',
415
+ severity: 'medium',
416
+ title: `Declares a \`${name}\` script, which runs at install time once allowed`,
417
+ detail: 'This command would run at the user\'s uid as part of `dsh plugin add`, before the user has read a '
418
+ + 'line of the package. Two things stand between it and execution, and neither is this package\'s doing: '
419
+ + '`dsh plugin add` forwards its arguments to pnpm verbatim and adds no --ignore-scripts, but pnpm ≥10 '
420
+ + 'blocks dependency lifecycle scripts by default until the exact package is listed under `allowBuilds` in '
421
+ + 'the profile\'s pnpm-workspace.yaml — and the harness prints that instruction itself when a build is '
422
+ + 'blocked (apps/cli/src/plugin.ts). Approving the prompt runs this command.',
423
+ evidence: { file: 'package.json', path: `scripts.${name}`, snippet: snippet(manifest.scripts[name] ?? '') },
424
+ }));
425
+ }
426
+ for (const command of manifest.binNames) {
427
+ findings.push(tierA({
428
+ checkId: 'A22',
429
+ name: 'installs-command',
430
+ severity: 'low',
431
+ title: `Installs the command \`${command}\` on the user's PATH`,
432
+ detail: 'A `bin` entry is linked into the profile\'s `node_modules/.bin` at install time. It is not run by '
433
+ + 'the harness, but it is now a name the user, a script, or an agent shell tool can invoke, and it is not '
434
+ + 'covered by anything in the profile.',
435
+ evidence: { file: 'package.json', path: 'bin', snippet: snippet(command) },
436
+ }));
437
+ }
438
+ const profileBundles = manifest.dsh.profile?.bundles ?? [];
439
+ if (profileBundles.length > 0) {
440
+ findings.push(tierA({
441
+ checkId: 'A20',
442
+ name: 'profile-mounts-bundles',
443
+ severity: 'high',
444
+ title: `Declares a profile that mounts ${profileBundles.length} bundle(s)`,
445
+ detail: 'A `dsh.profile.bundles` list makes this package a profile rather than a layer: the launcher resolves '
446
+ + 'each named package, reads its `dsh.bundle.patch`, and mounts that layer '
447
+ + '(packages/boot/app-boot/src/profile.ts). Everything those packages declare composes into the profile, '
448
+ + 'and none of it is in this package or in this analysis.',
449
+ evidence: { file: 'package.json', path: 'dsh.profile.bundles', snippet: snippet(profileBundles.join(', ')) },
450
+ }));
451
+ }
452
+ const specifiers = [
453
+ ...Object.entries(manifest.dependencies).map(([k, v]) => ['dependencies', k, v]),
454
+ ...Object.entries(manifest.optionalDependencies).map(([k, v]) => ['optionalDependencies', k, v]),
455
+ ];
456
+ for (const [field, name, specifier] of specifiers) {
457
+ if (!MUTABLE_SPECIFIER.test(specifier))
458
+ continue;
459
+ findings.push(tierA({
460
+ checkId: 'A11',
461
+ name: 'non-registry-dependency',
462
+ severity: 'high',
463
+ title: `Depends on ${name} through a non-registry specifier`,
464
+ detail: 'The code behind this specifier can change without the version of this package changing, so nothing '
465
+ + 'about this analysis carries forward to a later install. A git specifier additionally runs the '
466
+ + 'dependency\'s `prepare` script at install time.',
467
+ evidence: { file: 'package.json', path: `${field}.${name}`, snippet: snippet(specifier) },
468
+ }));
469
+ }
470
+ if (manifest.files === null) {
471
+ findings.push(tierA({
472
+ checkId: 'A13',
473
+ name: 'no-files-allowlist',
474
+ severity: 'low',
475
+ title: 'No `files` allowlist in package.json',
476
+ detail: 'Without an allowlist the published tarball is whatever was in the working tree minus npm\'s default '
477
+ + 'ignores, so what ships is not what the manifest describes.',
478
+ evidence: { file: 'package.json' },
479
+ }));
480
+ }
481
+ const patch = manifest.dsh.bundle?.patch;
482
+ if (patch !== undefined) {
483
+ const normalized = normalizePackagePath(patch);
484
+ if (normalized === null) {
485
+ findings.push(tierA({
486
+ checkId: 'A14',
487
+ name: 'bundle-patch-escapes-package',
488
+ severity: 'critical',
489
+ title: 'The declared `dsh.bundle.patch` path climbs out of the package directory',
490
+ detail: 'The launcher resolves the patch as join(packageDir, declared) with no sanitisation, and `..` '
491
+ + 'segments survive that join, so this package\'s mounted patch layer is read from a file it does not '
492
+ + 'ship and this analysis cannot see. This tool did not follow the path.',
493
+ evidence: { file: 'package.json', path: 'dsh.bundle.patch', snippet: snippet(patch) },
494
+ }));
495
+ }
496
+ else if (!source.files.has(normalized)) {
497
+ findings.push(tierA({
498
+ checkId: 'A16',
499
+ name: 'bundle-patch-missing',
500
+ severity: 'medium',
501
+ title: 'The declared `dsh.bundle.patch` file is not in the package',
502
+ detail: 'The package declares a mounted patch layer whose file is absent — commonly a `files` allowlist '
503
+ + 'that forgets it. Mounting this bundle fails the profile boot. An absolute path lands here too: '
504
+ + '`join(packageDir, "/etc/passwd")` is `<packageDir>/etc/passwd`, which is inside the package and '
505
+ + `simply does not exist. The path was resolved to \`${normalized}\` and nothing was read from it.`,
506
+ evidence: { file: 'package.json', path: 'dsh.bundle.patch', snippet: snippet(patch) },
507
+ }));
508
+ }
509
+ }
510
+ for (const defect of manifest.defects) {
511
+ findings.push(tierA({
512
+ checkId: 'A18',
513
+ name: 'manifest-defect',
514
+ severity: 'low',
515
+ title: `Malformed package.json field: ${defect}`,
516
+ detail: 'The field was ignored. A manifest that npm and the harness read differently is worth knowing about.',
517
+ evidence: { file: 'package.json', snippet: snippet(defect) },
518
+ }));
519
+ }
520
+ return findings;
521
+ }
522
+ /** A12 — shipped markdown that reaches the model when it is discovered. */
523
+ function checkModelVisibleText(input) {
524
+ if (input.modelVisibleFiles.length === 0)
525
+ return [];
526
+ return [tierA({
527
+ checkId: 'A12',
528
+ name: 'model-visible-text-shipped',
529
+ severity: 'low',
530
+ title: `Ships ${input.modelVisibleFiles.length} model-visible instruction file(s)`,
531
+ detail: 'Skill and agent-instruction markdown reaches the model verbatim, unescaped and uncapped. Shipping it '
532
+ + 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
533
+ + 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
534
+ + 'something copies it into the user\'s workspace. The text itself is scored separately by B10.',
535
+ evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
536
+ })];
537
+ }
538
+ /** A23 — an inserted row substituting a service for its whole subtree. */
539
+ function checkServiceRemapping(input) {
540
+ const findings = [];
541
+ for (const patch of input.patches) {
542
+ for (const row of patch.inserts) {
543
+ for (const [field, names] of [['isolate', row.isolate], ['intercept', row.intercept]]) {
544
+ const seams = names.filter(name => SEAM_KEYS.has(name));
545
+ if (seams.length === 0)
546
+ continue;
547
+ const critical = seams.some(name => SECURITY_SEAM_KEYS.has(name));
548
+ findings.push(tierA({
549
+ checkId: 'A23',
550
+ name: 'row-service-remapping',
551
+ severity: critical ? 'critical' : 'high',
552
+ title: `Inserted row "${row.id ?? '(unnamed)'}" re-maps ${seams.map(name => `\`${name}\``).join(', ')} via \`${field}\``,
553
+ detail: field === 'isolate'
554
+ ? 'The loader\'s isolate hook binds the named service to a fresh symbol realm for this row and every '
555
+ + 'row beneath it (vendor/loader/src/config/isolate.ts), so a descendant that injects that name '
556
+ + 'receives whatever this subtree provides instead of the profile\'s implementation. That is the same '
557
+ + 'substitution as replacing the service in code, declared in YAML, with no code to read.'
558
+ : 'The loader\'s intercept hook layers this row\'s own values over the named service for its whole '
559
+ + 'subtree, so every descendant sees this package\'s version of it.'
560
+ + (critical ? ' The named service is one whose purpose is to constrain what the agent may do.' : ''),
561
+ evidence: { file: patch.file, path: `${row.path}.${field}`, snippet: snippet(names.join(', ')) },
562
+ }));
563
+ }
564
+ }
565
+ }
566
+ return findings;
567
+ }
568
+ /**
569
+ * A21 — injection phrasing in shipped instruction markdown.
570
+ *
571
+ * This is Tier A rather than Tier B because there is no syntax between the
572
+ * bytes and the model: a `SKILL.md` reaches the model verbatim, so the shipped
573
+ * file *is* the prompt. There is nothing to obfuscate and therefore nothing for
574
+ * a Tier C degradation to make unreliable — which is why it is exempt from the
575
+ * downgrade that applies to every capability check. What is heuristic here is
576
+ * the reading of the sentence, not the reading of the file, and the finding
577
+ * says so.
578
+ */
579
+ function checkInjectionText(input) {
580
+ const findings = [];
581
+ for (const path of input.modelVisibleFiles) {
582
+ const text = input.source.files.get(path);
583
+ if (text === undefined)
584
+ continue;
585
+ for (const match of scanInjection(text)) {
586
+ findings.push({
587
+ checkId: 'A21',
588
+ name: 'model-visible-injection',
589
+ tier: 'A',
590
+ severity: 'high',
591
+ confidence: 'certain',
592
+ title: `Shipped instruction file ${match.meaning}`,
593
+ detail: `Heuristic \`${match.ruleId}\` matched shipped markdown. The file reaches the model verbatim, `
594
+ + 'unescaped and uncapped, when it is discovered — there is no encoding step between these bytes and the '
595
+ + 'model, which is why this is a verdict about the text rather than a capability report. Whether the '
596
+ + 'sentence is an instruction or a discussion of one is a judgement this tool cannot make: the pattern '
597
+ + 'will miss a rephrasing, and it can fire on a document that legitimately quotes an attack.',
598
+ evidence: { file: path, path: lineColumn(text, match.index), snippet: snippet(match.excerpt) },
599
+ bypass: null,
600
+ });
601
+ }
602
+ }
603
+ return findings;
604
+ }
605
+ /**
606
+ * Run every Tier A check.
607
+ *
608
+ * The filter is the guard: a package that declares no `dsh.bundle.patch`
609
+ * composes into no profile, so no reading of a Cordis row in it can be a
610
+ * verdict about anything. `patches` is already empty in that case; this makes
611
+ * the property structural rather than a consequence of how the input was built.
612
+ * @param input - the decoded package.
613
+ * @returns findings, unordered.
614
+ */
615
+ export function runTierA(input) {
616
+ const findings = [
617
+ ...checkManifest(input),
618
+ ...checkDisabledRows(input),
619
+ ...checkOverriddenRows(input),
620
+ ...checkExpressions(input),
621
+ ...checkPatchFailures(input),
622
+ ...checkInsertedModules(input),
623
+ ...checkMcpRows(input),
624
+ ...checkSkillRootRedirect(input),
625
+ ...checkServiceRemapping(input),
626
+ ...checkModelVisibleText(input),
627
+ ...checkInjectionText(input),
628
+ ];
629
+ if (input.mountsAsBundle)
630
+ return findings;
631
+ return findings.filter(finding => !PATCH_ROW_CHECKS.has(finding.checkId));
632
+ }