dsh-plugin-inspector 0.2.0 → 0.3.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
@@ -13,15 +13,15 @@ $ dsh-inspect --from-npm some-dsh-plugin@1.4.0
13
13
 
14
14
  ## Install
15
15
 
16
- Node `^22.19.0 || >=24`. Once a release is published:
16
+ Node `^22.19.0 || >=24`.
17
17
 
18
18
  ```console
19
19
  npm install -g dsh-plugin-inspector
20
20
  dsh-inspect --help
21
21
  ```
22
22
 
23
- Until then the binary comes from a checkout — and `lib/` is generated, so a fresh clone has no
24
- `dsh-inspect` until it is built:
23
+ From a checkout instead — `lib/` is generated, so a fresh clone has no `dsh-inspect` until it is
24
+ built:
25
25
 
26
26
  ```console
27
27
  git clone https://github.com/CharlotteN7/dsh-plugin-inspector
@@ -89,14 +89,14 @@ every `B13`.
89
89
 
90
90
  ## What it reports on the real ecosystem
91
91
 
92
- Measured **2026-08-16** against the 40 most-starred GitHub repositories tagged `dsh-plugin` that
93
- publish a resolvable npm package, each pinned to the version current that day. Re-run it with
94
- `pnpm run sweep`; the corpus is `scripts/ecosystem-corpus.json` and the recorded measurement is
95
- `tests/ecosystem-baseline.json`.
92
+ Measured **2026-08-17** against the 40 most-starred GitHub repositories tagged `dsh-plugin` that
93
+ publish a resolvable npm package, each pinned to the version it resolved to on 2026-08-16. Re-run
94
+ it with `pnpm run sweep`; the corpus is `scripts/ecosystem-corpus.json` and the recorded
95
+ measurement is `tests/ecosystem-baseline.json`.
96
96
 
97
97
  Both columns come from the same corpus and the same pinned versions, so the difference is this
98
- tool's doing and not the ecosystem's. "0.1" is the published `dsh-plugin-inspector@0.1.0`; "0.2" is
99
- this tree, which still reports version `0.1.0` because the release is not published yet.
98
+ tool's doing and not the ecosystem's. "0.1" is `dsh-plugin-inspector@0.1.0`; "0.2" is this tree,
99
+ which reports itself as `0.2.1` because it reads the version out of its own manifest.
100
100
 
101
101
  | | 0.1 | 0.2 |
102
102
  |---|---|---|
@@ -126,7 +126,7 @@ Read the 0.2 column honestly:
126
126
  output and no source" — fires on 65 % of published packages, because that is what publishing a
127
127
  package is. It is `low`, it does not degrade the analysis, and it is not a defect.
128
128
 
129
- The gap between "readable report" and "installable gate" is what 0.3 is for.
129
+ A readable report is not yet an installable gate.
130
130
 
131
131
  ## Usage
132
132
 
@@ -208,30 +208,134 @@ a verdict.
208
208
 
209
209
  ## What it looks for
210
210
 
211
- Full catalogue with detection methods in [`PLAN.md`](./PLAN.md) §6. The short version:
212
-
213
- **Tier A** install lifecycle scripts; a patch row disabling, re-enabling, or rewriting a core row
214
- (with `approval`, `permission`, `sandbox`, `sandbox-policy`, `fs-sandbox`, `fs-observation-policy`,
215
- `subprocess`, `credentials` and friends called out by name); the `!!js` inventory, classified by
216
- what each expression reaches; `!!js` in a field the loader never interpolates; `!js`, which is a
217
- hard parse error and proves the layer has never loaded anywhere; inserted rows naming modules the
218
- manifest does not declare; rows that re-map a service for their subtree with `isolate` or
219
- `intercept`; MCP server rows; `dsh.bundle.patch` paths that climb out of the package;
220
- skill-root redirection; a `dsh.profile.bundles` list, which makes the package a profile that mounts
221
- other packages; commands installed on your PATH; non-registry dependency specifiers; and injection
222
- phrasing in shipped instruction markdown, which is Tier A because the shipped bytes *are* the
223
- prompt.
224
-
225
- **Tier B** — capability-seam replacement via `ctx.provide` / `ctx.set`; system-prompt mutation;
226
- credential reads; network egress; the two of those together; `node:child_process`,
227
- `node:worker_threads`, `node:vm`; filesystem access outside `ctx.fs`; code built at runtime **and
228
- called**; nested plugin mounting; injection phrasing in registered tool `description` strings,
229
- which code assembles and which is therefore evadable.
230
-
231
- **Tier C** minified source; computed member access, specifiers, and names; binaries and files
232
- over the read caps; a patch layer whose structure hit a walk ceiling; and build output with no
233
- source beside it, which is the one Tier C finding that does *not* degrade the analysis the bytes
234
- were read exactly as they will run, and only their provenance is unverifiable.
211
+ ### Facts no severity, always emitted
212
+
213
+ | Fact | Source |
214
+ |---|---|
215
+ | `package.name`, `package.version`, `license`, `private` | `package.json` |
216
+ | `mountsAsBundle` + patch file path | `dsh.bundle.patch` |
217
+ | `shipsClientBundle` | `dsh.client` and `exports["./client"]` |
218
+ | `insertedRows` ids and plugin names this layer adds | patch YAML `insert[]` |
219
+ | `targetedRows` ids of existing rows this layer modifies | patch YAML top-level rows with `id` |
220
+ | `dependencies`, `peerDependencies`, `optionalDependencies` counts and names | `package.json` |
221
+ | `modelVisibleFiles` shipped `SKILL.md` / skills / `AGENTS.md` / `CLAUDE.md` | file walk |
222
+ | `filesRead`, `bytesRead`, `sourceFilesParsed` | analysis run |
223
+
224
+ ### Tier A — decidable, structured declaration, a real verdict
225
+
226
+ Tier A reads declarations, not code. It is *much* harder to hide from than Tier B, because the
227
+ harness itself must be able to read these fields literally in order to act on them: an attacker
228
+ cannot obfuscate `disabled: true` and still have it disable anything. Every Tier A finding has
229
+ confidence `certain`.
230
+
231
+ | id | Check | Severity | Method |
232
+ |---|---|---|---|
233
+ | A1 | Install lifecycle script (`preinstall`, `install`, `postinstall`, `prepare`, `prepublish`, `preprepare`, `postprepare`) | medium; **high** when the command itself fetches, decodes, pipes to a shell, or evaluates inline code | `package.json.scripts` key set. `dsh plugin add` forwards to pnpm verbatim and adds no `--ignore-scripts`, but pnpm ≥ 10 blocks a dependency's lifecycle scripts by default until the package is listed under `allowBuilds`, and `apps/cli/src/plugin.ts` prints that instruction when a build is blocked. The script is one approval away from running, not already running — which is why the category alone is `medium`, and why the measured 5 of 40 legitimate packages that declare one (`tsdown`, `npm run build`, `husky`, `node scripts/prepare.mjs`) stay there. The escalation reads the command line itself, and is calibrated to fire on none of them |
234
+ | A2 | Patch row sets `disabled` **truthily** on a **security-relevant** core row (`approval`, `permission`, `sandbox`, `sandbox-policy`, `bash-sandbox`, `pwsh-sandbox`, `fs-sandbox`, `fs-observation-policy`, `subprocess`, `credentials`, `timeout-policy`, `spill-policy`, `session-persistence-jsonl`) | **critical** | patch YAML row with `id ∈ SECURITY_ROWS`. The loader coerces — `disabledOf` is `Boolean(options.disabled)` (`vendor/loader/src/config/entry.ts`) — so `null`, `0` and `""` leave the row **running** and are not this finding. A `!!js` node is an object and stays truthy, so an expression is judged by what it can evaluate to |
235
+ | A3 | Patch row disables any other known core row | high for a `@deepseek-ai/dsh-base` row, medium for one only a surface bundle inserts | same, `id ∈ CORE_ROWS`. The row inventory records which of the three shipped bundles inserts each row, because they are not one profile: a `ui-*` row exists only where the web bundle is mounted. Suppressed entirely when the package under analysis *is* one of the three bundles — `@deepseek-ai/dsh-web-app` disabling two dozen rows `@deepseek-ai/dsh-base` inserted is what composing a surface bundle is |
236
+ | A4 | Patch row carries a `name` that does not match the targeted row's `name` | medium | `applyEntryPatches` treats `name` on a non-insert patch as an **assertion guard**, not an override: on mismatch it warns and `continue`s, skipping the whole patch. So this row does nothing at all. Either the author is targeting a row that has been renamed, or the patch is stale — in both cases what the user reads and what mounts disagree |
237
+ | A5 | Patch row overrides `config` / `inject` / `isolate` / `intercept` / `group` / any other key of an existing core row | medium (high for a security row) | patch YAML. Override is a **shallow whole-value replacement** (`target[key] = value`), never a deep merge, so overriding `config` discards the core row's entire configuration. `PatchOptions` carries a `[key: string]: any` index signature, so *any* key that is not `id`/`insert`/`name` is copied onto the target verbatim |
238
+ | A6 | `!!js` expression inventory, with AST sub-classification (see the `!!js` table below) | low → critical by class | dialect parse + `new Function` parse-compile, never evaluated |
239
+ | A7 | `!!js` in a field where the loader never interpolates it (`id`, `name`, `group`, `inject`, `intercept`, `isolate`) | medium | mirrors `metadataExpressionErrors`. Signal: the author believes it is live when it is inert — the plugin was very likely never validated |
240
+ | A8 | `!js` (single bang) anywhere in the patch YAML | medium | `!js` is a **hard YAML parse error**, verified. Its presence proves the plugin has never been successfully loaded by any harness |
241
+ | A9 | `insert` row naming a module that is neither this package nor any of its declared dependencies | high | set difference against `dependencies` ∪ `peerDependencies` ∪ own name. The layer mounts code whose provenance the manifest does not admit to |
242
+ | A10 | MCP server row — an `insert`ed row whose `name` is `@deepseek-ai/dsh-mcp-client`. `transport: stdio` → **critical**; `transport: streamable-http` → high | **critical** / high | The stdio config is `{ command, args, env, cwd }` and it spawns that executable directly — **not** through `ctx.subprocess` or `ctx.sandbox`, with no approval and no tool gate. Every tool the server advertises is then registered as `mcp__<serverName>__<tool>` with model-visible descriptions this package does not control. `streamable-http` does not spawn but still imports an untrusted remote tool catalogue. Structured declaration, so Tier A |
243
+ | A11 | Non-registry dependency specifier (`git+`, `github:`, `http(s):`, `file:`, `link:`) | high | the referenced code can change under a fixed version string |
244
+ | A12 | Shipped model-visible instruction text (`SKILL.md`, `**/skills/*/SKILL.md`, `**/skills/*.md`, `AGENTS.md`, `CLAUDE.md`) | low as presence; escalated by B10 | file walk. See the reach note below |
245
+ | A13 | No `files` allowlist in `package.json` | low | the published tarball is whatever happened to be in the working tree |
246
+ | A14 | `dsh.bundle.patch` climbs out of the package directory — contains a `..` that escapes | **critical** | `loadProfile` computes the patch path as `join(packageDir, declared)` with **no sanitization** of `declared`, and `..` segments survive that join. An **absolute** path does not escape and is not this finding: `join('/…/pkg', '/etc/passwd')` is `/…/pkg/etc/passwd`, which is inside the package and simply does not exist — that is A16 |
247
+ | A15 | Patch row redirects skill discovery into this package — sets `customSkillDirs` or `bundledSkillDir` on the `skill-filesystem` row | high | this is the declaration that turns shipped markdown into model-visible instructions. `bundledSkillDir` additionally carries `trustedHost: true`, which reads through raw Node `fs` and **bypasses the `ctx.fs` sandbox** |
248
+ | A16 | `dsh.bundle.patch` names a file the package does not ship | medium | commonly a `files` allowlist that forgets it. Mounting the bundle fails the profile boot |
249
+ | A17 | The declared patch layer does not parse | medium | the layer cannot load, and nothing inside it could be analysed |
250
+ | A18 | `package.json` field of the wrong shape | low | the field was ignored. A manifest that npm and the harness read differently is worth knowing about |
251
+ | A19 | Patch row sets `disabled` **falsily** on a core row | medium | the inverse of A2 and A3, and the one the coercion rule makes visible. Bundle layers apply after the profile's own, so a row the user deliberately switched off is switched back on by this one while the user's file still reads `disabled: true` |
252
+ | A20 | `dsh.profile.bundles` names packages to mount as bundles | high | the launcher resolves each named package, reads its `dsh.bundle.patch`, and mounts that layer (`packages/boot/app-boot/src/profile.ts`). This package is then a profile, and everything those packages declare composes into it — none of which is in this analysis |
253
+ | A21 | Injection phrasing in shipped instruction markdown | high | **Tier A rather than Tier B, and exempt from the Tier C downgrade.** There is no syntax between a `SKILL.md` and the model: the shipped bytes *are* the prompt, so there is nothing to obfuscate and nothing for a degraded parse to have made unreliable. What is heuristic is the reading of the sentence, not the reading of the file. Tool `description` hits stay Tier B (B10), because code assembles those |
254
+ | A22 | `bin` installs a command on the user's PATH | low | linked into the profile's `node_modules/.bin` at install time. The harness never runs it; the user, a script, or an agent shell tool can |
255
+ | A23 | Inserted row carries `isolate` or `intercept` on a catalogued service | **critical** for a security seam, high otherwise | `vendor/loader/src/config/isolate.ts` re-maps the named service to a fresh symbol realm for the row and every row beneath it, so a descendant injecting that name receives this subtree's implementation instead of the profile's. The same substitution as replacing the service in code, declared in YAML |
256
+
257
+ **Reach note for A12, stated because getting this wrong would be dishonest.** Shipping a `SKILL.md`
258
+ inside an npm package does **not** by itself put it in front of the model. There is no
259
+ `dsh.skills` manifest field. The filesystem provider scans a fixed root set —
260
+ `<project>/.dsh/skills`, `<project>/.agents/skills`, `$DSH_HOME/skills`,
261
+ `$DSH_AGENTS_HOME/skills`, `bundledSkillDir` — at depth 1 only (`<root>/<name>/SKILL.md` or `<root>/<name>.md`), and a plugin's own `node_modules`
262
+ directory is none of those. The three ways shipped text actually reaches the model are: the plugin
263
+ calls `ctx.skills.register()` / `ctx.skills.registerProvider()` (→ B10 on the registered body), a
264
+ patch row redirects a skill root into the package (→ A15), or the file is copied into the user's
265
+ workspace by something else. `AGENTS.md` / `CLAUDE.md` are a separate subsystem again — discovered
266
+ by walking the *workspace*, not the profile. So A12 on its own is `low` and its text says
267
+ "shipped, reaches the model only if registered or redirected"; it escalates to `high` only when
268
+ A15 or a `ctx.skills.register*` call is also present, or when B10's injection heuristics fire.
269
+
270
+ ### Tier B — AST capability detection, "this plugin CAN do X"
271
+
272
+ Tier B parses shipped `.ts`/`.mts`/`.cts`/`.js`/`.mjs`/`.cjs` with the `typescript` compiler API —
273
+ `ts.createSourceFile`, syntax only, **no program, no type checker, no module resolution, no
274
+ transpilation, no execution**. Default confidence `high`, dropped to `moderate` when any Tier C
275
+ readability finding fires.
276
+
277
+ | id | Check | Severity | Method |
278
+ |---|---|---|---|
279
+ | B1 | Replaces a core capability seam — `ctx.provide(<seam>, …)` / `ctx.set(<seam>, …)` where `<seam>` is a key from `api-catalog.ts` | **critical** | call expression, literal first argument matched against the seam key set |
280
+ | B5 | System-prompt mutation — `system-prompt/assemble` listener, or `ctx.systemPrompt.{section,context,variable,tools,suppressRuntimeContext}` | high | call matching |
281
+ | B6 | Credential read — `process.env.*(TOKEN\|KEY\|SECRET\|PASSWORD\|CREDENTIAL)*`, `~/.dsh/credentials`, `~/.npmrc`, `~/.aws`, `~/.ssh`, `ctx.credentials.*` | medium alone | identifier + literal matching |
282
+ | B7 | Network egress — `fetch`, `node:http(s).request`, `node:net`, `WebSocket`, `undici` | medium alone | import + call matching |
283
+ | B8 | **Exfiltration pair** — B6 ∧ B7 in the same package | high | set intersection. Reported explicitly as *capability, not dataflow*: the tool cannot prove the credential value reaches the socket. `high` rather than critical because it fires on 18 % of published plugins |
284
+ | B9 | Direct `node:child_process` / `node:worker_threads` / `node:vm` | medium alone, high paired with B8's two halves | import specifier. Bypasses `ctx.subprocess` and `ctx.sandbox` entirely. `medium` alone because a bare import fires on half the published ecosystem |
285
+ | B10 | Prompt-injection heuristics on **model-visible text only** — registered tool `description` string literals, and shipped skill/instruction files | high | imperative-override phrasing, role reassignment, exfiltration instructions, hidden-text markers — zero-width and bidirectional controls, the tag block, and runs of four or more variation selectors, the encoding GlassWorm shipped executable JavaScript in. Run on *exactly* the text that reaches the model, never on ordinary source comments |
286
+ | B11 | Nested plugin mounting — `ctx.plugin(…)`, loader manipulation | high | call matching. A layer that mounts further layers moves the analysis target |
287
+ | B12 | Dynamic code construction — `eval`, `new Function`, `vm.runInNewContext`, `module._load` | high | call matching |
288
+ | B13 | Filesystem access outside `ctx.fs` — imports `node:fs` or `node:fs/promises` | medium | Reads and writes through the Node API are invisible to `fs/write-intent`, `fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and nothing appears in the session log |
289
+
290
+ **The framing B7, B9, and B13 share.** The harness's own dynamic-package sandbox
291
+ (`cordis-host-runner/src/sandbox.ts`) traps exactly `require`, `setTimeout`, `setInterval`,
292
+ `setImmediate`, `clearTimeout`, `clearInterval`, and `fetch`, redirecting each to a `ctx` service;
293
+ it leaves `process` `undefined` and exposes only the seven `HOST_BUILTIN_INSPECTION` globals.
294
+ **An installed npm bundle layer gets none of that** — it is a plain ESM import into the harness
295
+ process. So these three checks report a gap the harness itself defines: *the harness denies
296
+ untrusted code this capability, and this package uses it from a position where nothing denies it.*
297
+ That is the harness's reckoning, not a rule invented here.
298
+
299
+ ### Tier C — heuristic; "we cannot read this" is itself the finding
300
+
301
+ | id | Check | Severity | Effect |
302
+ |---|---|---|---|
303
+ | C1 | Minified or obfuscated source — long lines that are **most of the file**, or a dense file of under five lines. One long line is an embedded prompt or a base64 asset, not minification, and the harness's own web bundle has one | medium | **degrades** |
304
+ | C2 | Dynamic dispatch — computed member access on `ctx` (`ctx[expr]`), non-literal `import()`/`require()`, `atob`/`Buffer.from(…, 'base64')`, an assembled name passed to `.on`/`.set`/`.emit` **on a known context binding**. The receiver guard is the whole check: `.set` and `.get` are `Map`'s names too, and ``this.steps.set(`${turn}:${step}`, t)`` is a composite key, not evasion | high | **degrades** |
305
+ | C3 | Ships built output with no corresponding source (`lib/` without `src/`) | low | **does not degrade** — the bytes were read exactly as written and exactly as they will run; what cannot be checked is whether they match the repository. Treating that as an unreadable package marks every ordinary published tarball degraded, because shipping built output and no source is what publishing *is* |
306
+ | C4 | Unreadable payload — `.node`, `.wasm`, binaries, files over the size cap | medium | **degrades** |
307
+ | C5 | The mounted layer hit a walk ceiling — nesting depth or node count | high | **degrades**. Rows past the ceiling were not read |
308
+ | C6 | A `.min.js` artifact | low | **degrades** |
309
+
310
+ ### `!!js` sub-classification (A6)
311
+
312
+ Every `!!js` node is inventoried with its YAML path and text, then parse-compiled with
313
+ `new Function('return (' + expr + ')')` — compilation only; the constructor never executes the
314
+ body — and the resulting AST is classified.
315
+
316
+ Classification is by **reach**, not by syntactic form. `dshHomePath('sessions')` and `steal()` are
317
+ both `CallExpression`s; the first is a helper `dsh-app-boot` puts in scope with
318
+ `ctx.provide('dshHomePath', dshHomePath)` before any entry mounts, documented as such in that
319
+ package's README, and used by the base bundle's own `session-persistence-jsonl` row.
320
+
321
+ | Class | Example | Severity | Finding |
322
+ |---|---|---|---|
323
+ | `literal` | `true`, `3` | — | fact only |
324
+ | `inert-read` | `process.env.DSH_TOOLS_MODE`, `process.platform === 'win32'`, `ctx.webStartup.host` | — | fact only |
325
+ | `harness-call` | `dshHomePath('sessions')`, `process.cwd()` | low | A6 |
326
+ | `call` | a call this tool cannot resolve | medium | A6 |
327
+ | `mutation` | `process.env.X = …` | high | A6 |
328
+ | `module-access` | `require(…)`, `import(…)`, `globalThis[…]` | **critical** | A6 |
329
+ | `unparseable` | syntax error | medium — and it means the plugin cannot boot | A6 |
330
+
331
+ The two classes with no reach are counted in `facts.jsExpressions` and never raised: a constant, or
332
+ a read of a service the profile already handed the row, warrants no decision, and the shipped
333
+ bundles are mostly made of them.
334
+
335
+ The escalation of the rest is justified: the evaluator is
336
+ `new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')` — unrestricted eval, with `ctx`
337
+ in scope. And `disabled` re-evaluates at **every mount decision**, so a `!!js` there is not a
338
+ one-shot: it is a recurring execution point that user patch layers HMR-reload live.
235
339
 
236
340
  ## The ceiling
237
341
 
@@ -271,7 +375,7 @@ would have burned the idea.
271
375
  is why it is `high` and not `critical`.
272
376
  7. **Injection phrasing that is not spelled in ASCII.** The injection heuristics are Latin-alphabet
273
377
  regexes. Substituting Cyrillic homoglyphs — `о` U+043E for `o`, `е` U+0435 for `e` — defeats
274
- **every one of the ten rules**, including the zero-width-character rule, which looks for
378
+ **every one of the eleven rules**, including the two hidden-character rules, which look for
275
379
  invisible characters and not for visible ones that are the wrong letter. Verified against the
276
380
  rule table, not assumed. Normalisation is not in 0.2; do not read a clean `A21`/`B10` as
277
381
  evidence that shipped markdown carries no instructions.
@@ -334,8 +438,8 @@ expressions, and its module top level all write a sentinel file, and the test as
334
438
  does not exist after a full analysis. `node:child_process` and the write half of `node:fs` are
335
439
  mocked to throw for the whole suite, so a stray call fails the tests rather than passing quietly.
336
440
 
337
- Design decisions are in [`ADR.md`](./ADR.md); the scope, catalogue, and phasing are in
338
- [`PLAN.md`](./PLAN.md).
441
+ Design decisions are in [`ADR.md`](./ADR.md); the check catalogue is under
442
+ [What it looks for](#what-it-looks-for).
339
443
 
340
444
  ## Reporting a problem
341
445
 
@@ -11,7 +11,7 @@
11
11
  import { isJsExpr } from "../cordis-yaml.js";
12
12
  import { boundedJson, lineColumn, normalizePackagePath, snippet } from "../files.js";
13
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";
14
+ import { CORE_ROWS, HARNESS_BUNDLE_PACKAGES, INSTALL_LIFECYCLE_SCRIPTS, LIFECYCLE_SIGNALS, MCP_CLIENT_PACKAGE, SECURITY_ROW_IDS, SECURITY_SEAM_KEYS, SEAM_KEYS, SKILL_FILESYSTEM_ROW, SKILL_ROOT_CONFIG_KEYS, } from "../knowledge.js";
15
15
  import { declaredPackages } from "../manifest.js";
16
16
  /**
17
17
  * Checks that read a Cordis patch row. None of them may produce a finding
@@ -421,19 +421,26 @@ function checkManifest(input) {
421
421
  const { manifest, source } = input;
422
422
  const lifecycle = INSTALL_LIFECYCLE_SCRIPTS.filter(name => name in manifest.scripts);
423
423
  for (const name of lifecycle) {
424
+ const command = manifest.scripts[name] ?? '';
425
+ const signals = LIFECYCLE_SIGNALS.filter(signal => signal.pattern.test(command));
424
426
  findings.push(tierA({
425
427
  checkId: 'A1',
426
428
  name: 'install-lifecycle-script',
427
429
  subject: name,
428
- severity: 'medium',
430
+ severity: signals.length === 0 ? 'medium' : 'high',
429
431
  title: `Declares a \`${name}\` script, which runs at install time once allowed`,
430
432
  detail: 'This command would run at the user\'s uid as part of `dsh plugin add`, before the user has read a '
431
433
  + 'line of the package. Two things stand between it and execution, and neither is this package\'s doing: '
432
434
  + '`dsh plugin add` forwards its arguments to pnpm verbatim and adds no --ignore-scripts, but pnpm ≥10 '
433
435
  + 'blocks dependency lifecycle scripts by default until the exact package is listed under `allowBuilds` in '
434
436
  + 'the profile\'s pnpm-workspace.yaml — and the harness prints that instruction itself when a build is '
435
- + 'blocked (apps/cli/src/plugin.ts). Approving the prompt runs this command.',
436
- evidence: { file: 'package.json', path: `scripts.${name}`, snippet: snippet(manifest.scripts[name] ?? '') },
437
+ + 'blocked (apps/cli/src/plugin.ts). Approving the prompt runs this command.'
438
+ + (signals.length === 0
439
+ ? ''
440
+ : ` The command ${signals.map(signal => signal.meaning).join(', and ')}. A build hook runs something `
441
+ + 'this package shipped and this one does not, which is the shape 21.2 % of malicious npm packages '
442
+ + 'take: the whole attack inside `package.json`, with no module to read.'),
443
+ evidence: { file: 'package.json', path: `scripts.${name}`, snippet: snippet(command) },
437
444
  }));
438
445
  }
439
446
  for (const command of manifest.binNames) {
package/lib/files.js CHANGED
@@ -23,7 +23,7 @@ export function isSourceFile(path) {
23
23
  /**
24
24
  * Whether a path is markdown that can reach the model verbatim.
25
25
  *
26
- * The reach is conditional and PLAN.md §6.1 says so: a `SKILL.md` inside an npm
26
+ * The reach is conditional: a `SKILL.md` inside an npm
27
27
  * package is only discovered when the plugin registers it through
28
28
  * `ctx.skills`, when a patch row redirects a skill root into the package, or
29
29
  * when something copies it into the user's workspace. This predicate answers
package/lib/injection.js CHANGED
@@ -62,6 +62,22 @@ export const INJECTION_RULES = [
62
62
  pattern: /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]|[\u{E0000}-\u{E007F}]/u,
63
63
  meaning: 'contains zero-width or bidirectional-control characters, which change what a human reader sees but not what the model reads',
64
64
  },
65
+ {
66
+ // Keyed on a run of four, not on a single selector. One selector is
67
+ // ordinary: U+FE0F and U+FE0E pick the emoji or text presentation of the
68
+ // character before them, and the U+E01xx plane carries the Ideographic
69
+ // Variation Sequences that CJK text uses, so firing on one would fire on
70
+ // every document with an emoji in it. Nothing standardised puts four in a
71
+ // row: a variation selector modifies the single character it follows, so a
72
+ // second one has nothing to modify. GlassWorm's five waves encoded
73
+ // executable JavaScript one byte per selector, which makes any real payload
74
+ // an unbroken run of tens to thousands. Four is far below that and above
75
+ // the doubled selectors that copy-paste through an editor produces.
76
+ id: 'variation-selector-payload',
77
+ pattern: /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]{4,}/u,
78
+ meaning: 'contains a run of variation selectors, which occupy no width in any editor, terminal or diff '
79
+ + 'view and can carry an arbitrary encoded payload one byte per selector',
80
+ },
65
81
  {
66
82
  id: 'hidden-html-instruction',
67
83
  pattern: /<!--[^]{0,400}?\b(?:you (?:must|should|are)|instruction|assistant|ignore)\b[^]{0,400}?-->/i,
package/lib/inspect.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * `cordis-yaml.ts`, and its result is discarded without being called.
10
10
  * @module dsh-plugin-inspector/inspect
11
11
  */
12
+ import { readFileSync } from 'node:fs';
12
13
  import { EXPRESSION_CLASSES, PatchParseError, parsePatchDocument, } from "./cordis-yaml.js";
13
14
  import { isCordisConfigFile, isModelVisibleText, isSourceFile, normalizePackagePath } from "./files.js";
14
15
  import { HARNESS_REFERENCE } from "./knowledge.js";
@@ -18,8 +19,18 @@ import { loadSource } from "./source.js";
18
19
  import { runTierA } from "./checks/tier-a.js";
19
20
  import { runTierB } from "./checks/tier-b.js";
20
21
  import { NON_DEGRADING_CHECKS, runTierC } from "./checks/tier-c.js";
21
- /** This tool's own version, reported in the JSON document. */
22
- export const TOOL_VERSION = '0.1.0';
22
+ /**
23
+ * This tool's own version, reported in the JSON document, by `--version`, and
24
+ * in the recorded ecosystem measurement.
25
+ *
26
+ * Read from this package's own `package.json` rather than written down a second
27
+ * time. A constant is a copy that only a release checklist keeps honest, and it
28
+ * stopped being honest for two releases: every report claimed `0.1.0` while the
29
+ * published package was `0.2.1`. The manifest sits one directory above this
30
+ * module in the source tree, in `lib/` after a build, and in the published
31
+ * tarball, so the same relative path resolves in all three.
32
+ */
33
+ export const TOOL_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
34
  /** This tool's package name, reported in the JSON document. */
24
35
  export const TOOL_NAME = 'dsh-plugin-inspector';
25
36
  /**
package/lib/knowledge.js CHANGED
@@ -291,6 +291,45 @@ export const SKILL_ROOT_CONFIG_KEYS = ['customSkillDirs', 'bundledSkillDir'];
291
291
  export const INSTALL_LIFECYCLE_SCRIPTS = [
292
292
  'preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'preprepare', 'postprepare',
293
293
  ];
294
+ /**
295
+ * Command shapes that make an install lifecycle script the attack rather than
296
+ * the build.
297
+ *
298
+ * The head-to-head measurement on 6,420 malicious and 7,288 benign npm packages
299
+ * (ASE 2026) puts 72.21 % of malicious packages on a lifecycle hook and 21.2 %
300
+ * with the whole attack inside `package.json` scripts — no shipped module at
301
+ * all. That second number is what this table is for: it is the case where the
302
+ * command line itself fetches, decodes, or evaluates, and there is nothing else
303
+ * to read.
304
+ *
305
+ * Each pattern is chosen against the measured false-positive side rather than
306
+ * against the idea of a build script. The five packages in the pinned corpus
307
+ * that declare a hook run `tsdown`, `npm run build`, `husky`, and
308
+ * `node scripts/prepare.mjs`; running a shipped file is what a build hook is, so
309
+ * that shape is deliberately not a signal here.
310
+ */
311
+ export const LIFECYCLE_SIGNALS = [
312
+ {
313
+ id: 'fetches-remote',
314
+ pattern: /\b(?:curl|wget|Invoke-WebRequest|iwr)\b/i,
315
+ meaning: 'fetches a remote resource at install time, so what runs is not what was published',
316
+ },
317
+ {
318
+ id: 'pipes-to-shell',
319
+ pattern: /\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/,
320
+ meaning: 'pipes its input straight into a shell',
321
+ },
322
+ {
323
+ id: 'evaluates-inline-code',
324
+ pattern: /\b(?:node|deno|bun|ruby|perl)\s+(?:-\S+\s+)*--?e(?:val)?\b|\bpython3?\s+(?:-\S+\s+)*-c\b/,
325
+ meaning: 'evaluates code written on the command line, which no published file records',
326
+ },
327
+ {
328
+ id: 'decodes-payload',
329
+ pattern: /\bbase64\s+(?:-d|-D|--decode)\b|\batob\s*\(|\bBuffer\.from\([^)]*base64/,
330
+ meaning: 'decodes an encoded payload, which is how a command hides what it runs',
331
+ },
332
+ ];
294
333
  /** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
295
334
  export const STATIC_ENTRY_FIELDS = [
296
335
  'id', 'name', 'group', 'inject', 'intercept', 'isolate',
@@ -13,7 +13,7 @@ export declare function isSourceFile(path: string): boolean;
13
13
  /**
14
14
  * Whether a path is markdown that can reach the model verbatim.
15
15
  *
16
- * The reach is conditional and PLAN.md §6.1 says so: a `SKILL.md` inside an npm
16
+ * The reach is conditional: a `SKILL.md` inside an npm
17
17
  * package is only discovered when the plugin registers it through
18
18
  * `ctx.skills`, when a patch row redirects a skill root into the package, or
19
19
  * when something copies it into the user's workspace. This predicate answers
@@ -11,8 +11,18 @@
11
11
  */
12
12
  import { type RegistryProvenance, type Report, type Severity } from './model.ts';
13
13
  import { type PluginSource } from './source.ts';
14
- /** This tool's own version, reported in the JSON document. */
15
- export declare const TOOL_VERSION = "0.1.0";
14
+ /**
15
+ * This tool's own version, reported in the JSON document, by `--version`, and
16
+ * in the recorded ecosystem measurement.
17
+ *
18
+ * Read from this package's own `package.json` rather than written down a second
19
+ * time. A constant is a copy that only a release checklist keeps honest, and it
20
+ * stopped being honest for two releases: every report claimed `0.1.0` while the
21
+ * published package was `0.2.1`. The manifest sits one directory above this
22
+ * module in the source tree, in `lib/` after a build, and in the published
23
+ * tarball, so the same relative path resolves in all three.
24
+ */
25
+ export declare const TOOL_VERSION: string;
16
26
  /** This tool's package name, reported in the JSON document. */
17
27
  export declare const TOOL_NAME = "dsh-plugin-inspector";
18
28
  /**
@@ -110,6 +110,31 @@ export declare const SKILL_ROOT_CONFIG_KEYS: readonly string[];
110
110
  * only needs one of these to run code before the user has read a line of it.
111
111
  */
112
112
  export declare const INSTALL_LIFECYCLE_SCRIPTS: readonly string[];
113
+ /** One thing a lifecycle command can do that a build never needs to. */
114
+ export interface LifecycleSignal {
115
+ readonly id: string;
116
+ readonly pattern: RegExp;
117
+ /** What the match means, phrased for a report. */
118
+ readonly meaning: string;
119
+ }
120
+ /**
121
+ * Command shapes that make an install lifecycle script the attack rather than
122
+ * the build.
123
+ *
124
+ * The head-to-head measurement on 6,420 malicious and 7,288 benign npm packages
125
+ * (ASE 2026) puts 72.21 % of malicious packages on a lifecycle hook and 21.2 %
126
+ * with the whole attack inside `package.json` scripts — no shipped module at
127
+ * all. That second number is what this table is for: it is the case where the
128
+ * command line itself fetches, decodes, or evaluates, and there is nothing else
129
+ * to read.
130
+ *
131
+ * Each pattern is chosen against the measured false-positive side rather than
132
+ * against the idea of a build script. The five packages in the pinned corpus
133
+ * that declare a hook run `tsdown`, `npm run build`, `husky`, and
134
+ * `node scripts/prepare.mjs`; running a shipped file is what a build hook is, so
135
+ * that shape is deliberately not a signal here.
136
+ */
137
+ export declare const LIFECYCLE_SIGNALS: readonly LifecycleSignal[];
113
138
  /** Entry fields the loader never interpolates: a `!!js` node here is inert data. */
114
139
  export declare const STATIC_ENTRY_FIELDS: readonly string[];
115
140
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-inspector",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",