dsh-plugin-inspector 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,8 +84,8 @@ Findings are tiered by how much you should trust them:
84
84
  intent is not. Every Tier B check has a one-line bypass, and the tool says so per finding rather
85
85
  than implying a completeness it does not have. What it does guarantee is that it never runs the
86
86
  code it analyses — asserted from outside the unit suite by a CI canary whose fixture writes
87
- sentinel files from `preinstall`, `postinstall`, `prepare`, `!!js` config and module top level. Any
88
- sentinel on disk after a full analysis is a release blocker.
87
+ sentinel files from `preinstall`, `postinstall`, `prepare`, `!!js` config, `!!js` disabled, and
88
+ module top level. Any sentinel on disk after a full analysis is a release blocker.
89
89
 
90
90
  [What is not statically decidable →](https://charlotten7.github.io/dsh-plugin-inspector/ceiling.html) ·
91
91
  [What it reports on the real ecosystem →](https://charlotten7.github.io/dsh-plugin-inspector/ecosystem.html)
@@ -100,8 +100,13 @@ pnpm run test:coverage
100
100
  pnpm run test:e2e
101
101
  ```
102
102
 
103
- Severity calibration is pinned against a corpus of published packages, so a change that starts
104
- firing on ordinary code fails CI rather than shipping.
103
+ Severity calibration is pinned against a corpus of forty published packages, in
104
+ `tests/ecosystem-baseline.json`. **The sweep is not part of CI** every other workflow here runs
105
+ without a network, which is what lets the unit suite claim that analysing a package touches nothing
106
+ outside the process — so it runs as a weekly cron and on request, and a change that starts firing
107
+ on ordinary code does not fail the pull request that makes it. What catches it is the release: the
108
+ baseline records the build that measured it and a unit test fails unless that matches the version
109
+ in `package.json`, so a version bump is not finished until the sweep has been re-run against it.
105
110
 
106
111
  Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
107
112
  [SECURITY.md](SECURITY.md).
@@ -123,8 +123,12 @@ function checkDisabledRows(input) {
123
123
  // (vendor/loader/src/config/entry.ts). `null`, `0` and `""` therefore
124
124
  // leave the row running, and reporting them as a disabled row would be
125
125
  // confidently wrong about the one thing Tier A claims to be certain of.
126
- // An expression node is an object, so it stays truthy here and is judged
127
- // by what it can evaluate to rather than by its own shape.
126
+ // A `!!js` node is not that case. The loader evaluates the expression
127
+ // first and coerces its *result*, so `disabled: !!js false` also leaves
128
+ // the row running — which this tool cannot know without running the
129
+ // expression, and running it is the one thing it may never do. An
130
+ // expression is therefore read as what it could evaluate to, which
131
+ // raises the finding rather than dropping it.
128
132
  if (!override.disabled) {
129
133
  const enabled = coreRowSeverity(override.id);
130
134
  if (enabled === null)
@@ -643,7 +647,7 @@ function checkModelVisibleText(input) {
643
647
  detail: 'Skill and agent-instruction markdown reaches the model verbatim, unescaped and uncapped. Shipping it '
644
648
  + 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
645
649
  + 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
646
- + 'something copies it into the user\'s workspace. The text itself is scored separately by B10.',
650
+ + 'something copies it into the user\'s workspace. The text itself is scored separately by A21.',
647
651
  /* v8 ignore next -- the caller returns early on an empty list. */
648
652
  evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
649
653
  })];
@@ -53,6 +53,13 @@ export function matchesCredentialPath(text) {
53
53
  const DYNAMIC_CODE_CALLEES = new Set([
54
54
  'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
55
55
  ]);
56
+ /**
57
+ * The harness's own tool-definition helper, exported from
58
+ * `@deepseek-ai/dsh-tools`. Every registered tool in the harness is built by
59
+ * either calling it or handing `tools.register` a literal, so recognising the
60
+ * two shapes is what tells a tool `description` from every other kind.
61
+ */
62
+ const TOOL_DEFINITION_HELPER = 'defineTool';
56
63
  /** `ctx.systemPrompt` members that change what the model is told. */
57
64
  const SYSTEM_PROMPT_MEMBERS = new Set([
58
65
  'section', 'context', 'variable', 'tools', 'suppressRuntimeContext',
@@ -350,6 +357,70 @@ function checkCredentialRead(file, node, accumulator) {
350
357
  accumulator.findings.push(finding);
351
358
  accumulator.credentialRead ??= finding;
352
359
  }
360
+ /**
361
+ * Whether a call hands its arguments to the tool registry: the registry call
362
+ * itself, `<ctx>.tools.register(…)`, or the harness's `defineTool(…)` helper,
363
+ * whose argument is a tool definition and nothing else.
364
+ * @param node - the call expression.
365
+ * @returns true when its arguments are tool definitions.
366
+ */
367
+ function isToolRegistration(node) {
368
+ const callee = node.expression;
369
+ if (ts.isIdentifier(callee))
370
+ return callee.text === TOOL_DEFINITION_HELPER;
371
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'register'
372
+ && ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === 'tools';
373
+ }
374
+ /**
375
+ * Whether a name bound in this file is passed to a tool registration call, so
376
+ * a definition built as `const tool = {…}` and registered on a later line is
377
+ * still recognised as one.
378
+ * @param name - the bound identifier.
379
+ * @param file - the parsed file it was bound in.
380
+ * @returns true when a registration call in the same file receives it.
381
+ */
382
+ function isRegisteredName(name, file) {
383
+ let registered = false;
384
+ const visit = (node) => {
385
+ if (ts.isCallExpression(node) && isToolRegistration(node)
386
+ && node.arguments.some(argument => ts.isIdentifier(argument) && argument.text === name)) {
387
+ registered = true;
388
+ }
389
+ ts.forEachChild(node, visit);
390
+ };
391
+ ts.forEachChild(file.node, visit);
392
+ return registered;
393
+ }
394
+ /**
395
+ * Whether a `description` property belongs to a tool definition this package
396
+ * registers.
397
+ *
398
+ * The receiver guard is the whole check. `description` is one of the commonest
399
+ * property names in JavaScript — a JSON schema, an OpenAPI document, a
400
+ * changelog entry and a CLI option table all carry one — and none of that text
401
+ * reaches a model. Without the guard the injection heuristics run on release
402
+ * notes, and the finding's title then asserts something about a tool that the
403
+ * package does not have.
404
+ *
405
+ * Nested properties count, because the whole definition is model-visible: a
406
+ * parameter's `description` is rendered into the tool schema the model
407
+ * receives alongside the tool's own.
408
+ * @param node - the `description` property assignment.
409
+ * @param file - the parsed file it came from.
410
+ * @returns true when an enclosing object literal is a registered tool definition.
411
+ */
412
+ function isRegisteredToolDescription(node, file) {
413
+ let parent = node.parent;
414
+ for (;;) {
415
+ if (ts.isCallExpression(parent))
416
+ return isToolRegistration(parent);
417
+ if (ts.isVariableDeclaration(parent))
418
+ return ts.isIdentifier(parent.name) && isRegisteredName(parent.name.text, file);
419
+ if (!ts.isObjectLiteralExpression(parent) && !ts.isPropertyAssignment(parent))
420
+ return false;
421
+ parent = parent.parent;
422
+ }
423
+ }
353
424
  /** B10 — injection phrasing in a registered tool description. */
354
425
  function checkToolDescription(file, node, accumulator) {
355
426
  if (!ts.isPropertyAssignment(node))
@@ -359,6 +430,8 @@ function checkToolDescription(file, node, accumulator) {
359
430
  const text = literalText(node.initializer);
360
431
  if (text === null)
361
432
  return;
433
+ if (!isRegisteredToolDescription(node, file))
434
+ return;
362
435
  for (const match of scanInjection(text)) {
363
436
  accumulator.findings.push(tierB({
364
437
  checkId: 'B10',
@@ -366,11 +439,14 @@ function checkToolDescription(file, node, accumulator) {
366
439
  subject: match.ruleId,
367
440
  severity: 'high',
368
441
  title: `Tool description ${match.meaning}`,
369
- detail: `Heuristic \`${match.ruleId}\` matched a tool \`description\`, which is prompt text the model receives `
370
- + 'verbatim on every request that lists the tool. This is a natural-language heuristic: it will miss a '
371
- + 'rephrasing, and it can fire on a description that legitimately discusses the subject.',
442
+ detail: `Heuristic \`${match.ruleId}\` matched a \`description\` inside a registered tool definition, which is `
443
+ + 'prompt text the model receives verbatim on every request that lists the tool. This is a natural-language '
444
+ + 'heuristic: it will miss a rephrasing, and it can fire on a description that legitimately discusses the '
445
+ + 'subject.',
372
446
  evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
373
- bypass: 'any rephrasing the pattern does not cover, or building the description by concatenation',
447
+ bypass: 'any rephrasing the pattern does not cover, building the description by concatenation, or registering '
448
+ + 'the definition through a value this tool does not track — a definition exported from one file and passed '
449
+ + 'to `tools.register` in another is not matched',
374
450
  }));
375
451
  }
376
452
  }
@@ -330,7 +330,9 @@ function checkUnreadableFiles(input) {
330
330
  detail: reason === 'binary'
331
331
  ? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
332
332
  + 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
333
- : 'These files exceeded a size or count cap and were not read. Nothing is claimed about their contents.',
333
+ : 'These files were not read: each either passed a size or count cap, or is not a regular file the reader '
334
+ + 'can open — a symbolic link, a FIFO, a socket, or a directory it was refused. The subject names which. '
335
+ + 'Nothing is claimed about their contents.',
334
336
  /* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
335
337
  evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
336
338
  bypass: 'none — this finding is about the analysis, not about the plugin',
package/lib/injection.js CHANGED
@@ -39,7 +39,13 @@ export const INJECTION_RULES = [
39
39
  },
40
40
  {
41
41
  id: 'credential-exfiltration',
42
- pattern: /\b(?:send|post|upload|transmit|exfiltrate|forward|report)\b[^.\n]{0,60}\b(?:api[_ -]?key|access[_ -]?token|secret|credential|password|\.env|id_rsa|\.npmrc)\b/i,
42
+ // The dotted filenames carry their own boundary. A `\b` in front of the
43
+ // whole alternation cannot match at the start of `.env` or `.npmrc`: the
44
+ // preceding character is a space and the next is a `.`, so neither side of
45
+ // that position is a word character and the boundary does not exist there.
46
+ // Under a shared `\b` those two alternatives match nothing, while the
47
+ // word-initial ones beside them keep working and hide it.
48
+ pattern: /\b(?:send|post|upload|transmit|exfiltrate|forward|report)\b[^.\n]{0,60}(?:\b(?:api[_ -]?key|access[_ -]?token|secret|credential|password|id_rsa)\b|\.(?:env|npmrc)\b)/i,
43
49
  meaning: 'instructs the model to move a credential somewhere',
44
50
  },
45
51
  {
package/lib/report.js CHANGED
@@ -140,7 +140,11 @@ function renderFacts(report, paint) {
140
140
  ? []
141
141
  : [
142
142
  ['fetched from', `${provenance.tarball} (${provenance.tarballBytes} bytes, never written to disk)`],
143
- ['verified', `${provenance.digest} matched dist.integrity before anything parsed it`],
143
+ // Which field matched is not cosmetic: `dist.shasum` is SHA-1 and is
144
+ // only reached on packages published before npm 5, so naming
145
+ // `dist.integrity` there would report a stronger check than ran.
146
+ ['verified', `${provenance.digest} matched `
147
+ + `${provenance.algorithm === 'sha1' ? 'dist.shasum' : 'dist.integrity'} before anything parsed it`],
144
148
  ['install script', provenance.hasInstallScript
145
149
  ? 'yes — the registry marks this package as running one at install time'
146
150
  : 'no — the registry does not mark this package as running one'],
package/lib/source.js CHANGED
@@ -15,7 +15,9 @@
15
15
  * cap.
16
16
  *
17
17
  * Symbolic links are recorded and never followed, for the same reason: a link
18
- * pointing outside the package is not part of the package.
18
+ * pointing outside the package is not part of the package. Anything else that
19
+ * is not a regular file — a FIFO, a socket, a device node — is recorded the
20
+ * same way and never opened.
19
21
  * @module dsh-plugin-inspector/source
20
22
  */
21
23
  import { createReadStream, openSync, readdirSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
@@ -131,8 +133,15 @@ function walkDirectory(root, directory, collector, published) {
131
133
  walkDirectory(root, absolute, collector, published);
132
134
  continue;
133
135
  }
134
- if (!entry.isFile())
136
+ if (!entry.isFile()) {
137
+ // A FIFO, socket or device node the publish set includes is content the
138
+ // analyzer did not read, and "how much could be read" is the one number
139
+ // that may never be overstated. Reading one is also not an option: a
140
+ // `readFileSync` on a FIFO blocks until somebody writes to it.
141
+ if (published.includes(path))
142
+ collector.skipped.push({ path, reason: 'unreadable' });
135
143
  continue;
144
+ }
136
145
  if (!published.includes(path)) {
137
146
  collector.unpublished += 1;
138
147
  continue;
@@ -17,7 +17,7 @@ export type Severity = 'critical' | 'high' | 'medium' | 'low';
17
17
  * `certain`; Tier B recognises syntax and is downgraded when Tier C fires.
18
18
  */
19
19
  export type Confidence = 'certain' | 'high' | 'moderate' | 'low';
20
- /** Which analysis produced a finding. See PLAN.md §6. */
20
+ /** Which analysis produced a finding. See the check catalogue, `docs/checks.md`. */
21
21
  export type Tier = 'A' | 'B' | 'C';
22
22
  /** Severity ordering, ascending. Used for `--fail-on` comparison and ranking. */
23
23
  export declare const SEVERITY_RANK: Readonly<Record<Severity, number>>;
@@ -49,7 +49,7 @@ export declare const MAX_EXAMPLES = 3;
49
49
  * finding that does.
50
50
  */
51
51
  export interface Finding {
52
- /** Catalogue id from PLAN.md §6, e.g. `A2`. Stable across releases. */
52
+ /** Catalogue id from `docs/checks.md`, e.g. `A2`. Stable across releases. */
53
53
  readonly checkId: string;
54
54
  /** Machine-readable check name, e.g. `core-row-disabled`. Stable across releases. */
55
55
  readonly name: string;
@@ -116,7 +116,7 @@ export interface Facts {
116
116
  readonly unmountedPatchFiles: readonly string[];
117
117
  readonly dependencies: readonly string[];
118
118
  readonly peerDependencies: readonly string[];
119
- /** Shipped markdown that can reach the model. See PLAN.md §6.1 reach note. */
119
+ /** Shipped markdown that can reach the model. See the A12 reach note in `docs/checks.md`. */
120
120
  readonly modelVisibleFiles: readonly string[];
121
121
  readonly filesRead: number;
122
122
  readonly bytesRead: number;
@@ -15,7 +15,9 @@
15
15
  * cap.
16
16
  *
17
17
  * Symbolic links are recorded and never followed, for the same reason: a link
18
- * pointing outside the package is not part of the package.
18
+ * pointing outside the package is not part of the package. Anything else that
19
+ * is not a regular file — a FIFO, a socket, a device node — is recorded the
20
+ * same way and never opened.
19
21
  * @module dsh-plugin-inspector/source
20
22
  */
21
23
  import type { SkippedFile } from './model.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-inspector",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Know what a DeepSeek Harness plugin does before you install it — static pre-install analysis of a plugin directory or tarball",
5
5
  "license": "MIT",
6
6
  "author": "Ivan Tyshchenko",