dsh-plugin-inspector 0.3.0 → 0.5.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
@@ -2,8 +2,8 @@
2
2
 
3
3
  **Know what a plugin does before you install it.**
4
4
 
5
- `dsh-inspect` reads a DeepSeek Harness plugin — a directory, an npm tarball, or a published
6
- package fetched by name and checked against the hash the registry published — and tells you what it
5
+ `dsh-inspect` reads a DeepSeek Harness plugin — a directory, an npm tarball, or a published package
6
+ fetched by name and checked against the hash the registry published — and tells you what it
7
7
  declares and what its code is capable of. It does not install it, build it, import it, spawn it, or
8
8
  evaluate any part of it.
9
9
 
@@ -11,6 +11,16 @@ evaluate any part of it.
11
11
  $ dsh-inspect --from-npm some-dsh-plugin@1.4.0
12
12
  ```
13
13
 
14
+ 📖 **[Full documentation](https://charlotten7.github.io/dsh-plugin-inspector/)**
15
+
16
+ ## Why
17
+
18
+ `dsh plugin add` is a thin pnpm forwarder: it passes your arguments to pnpm verbatim, and a plugin
19
+ is ordinary Node code that runs in the agent's process at the agent's uid. Nothing between the
20
+ registry and that process tells you what the code can reach.
21
+
22
+ [The full argument →](https://charlotten7.github.io/dsh-plugin-inspector/)
23
+
14
24
  ## Install
15
25
 
16
26
  Node `^22.19.0 || >=24`.
@@ -20,113 +30,14 @@ npm install -g dsh-plugin-inspector
20
30
  dsh-inspect --help
21
31
  ```
22
32
 
23
- From a checkout instead — `lib/` is generated, so a fresh clone has no `dsh-inspect` until it is
24
- built:
33
+ From a checkout instead — `lib/` is generated, so a fresh clone has no `dsh-inspect` until built:
25
34
 
26
35
  ```console
27
36
  git clone https://github.com/CharlotteN7/dsh-plugin-inspector
28
37
  cd dsh-plugin-inspector
29
- pnpm install
30
- pnpm run build # writes lib/, which .gitignore excludes and `files` ships
31
- node lib/cli.js --help # or `pnpm link --global` for a `dsh-inspect` on PATH
32
- ```
33
-
34
- To run it from source without building, `pnpm run inspect <target>`.
35
-
36
- ---
37
-
38
- ## Why
39
-
40
- `dsh plugin add` is a thin pnpm forwarder. It passes your arguments to pnpm verbatim — no spec
41
- parsing, no added flags, no subcommand allowlist, no confirmation prompt — and then reconciles
42
- the profile's layer list from the installed state. Any package whose `package.json` declares
43
- `dsh.bundle.patch` is promoted to a **mounted patch layer**: an ESM module imported into the
44
- harness process at the agent's uid, with ungated top-level side effects, and a YAML layer that
45
- applies *after* `@deepseek-ai/dsh-base` and can therefore override any field of any core row by
46
- id — or set `disabled: true` on it.
47
-
48
- The only thing `dsh plugin add` prints is a warning for the harmless case:
49
-
38
+ pnpm install && pnpm run build
39
+ node lib/cli.js --help
50
40
  ```
51
- dsh: warning: <pkg> declares no dsh.bundle — installed as a plain dependency, not a profile layer
52
- ```
53
-
54
- The dangerous case prints nothing.
55
-
56
- There are over 5,000 repos tagged `dsh-plugin` — 5,071 when this was last counted, on 16 August
57
- 2026 — and no registry, no review, and no signing between any of them and your process. This tool
58
- exists so that the moment before you install one is not a blank.
59
-
60
- ## What you get
61
-
62
- The report has two halves, and the first one is the point of the tool.
63
-
64
- **Facts** — no severity, always printed. Whether the package mounts as a patch layer and from
65
- which file, whether it ships a browser bundle, which rows it inserts and which existing rows it
66
- modifies, the `!!js` inventory of the mounted layer, cordis YAML it ships that nothing mounts,
67
- commands it puts on your PATH, its dependencies, what model-visible text it ships, and how much of
68
- it could be read. A well-behaved plugin has a full facts section and an empty findings section.
69
- That is a useful answer, not an empty one.
70
-
71
- **Findings** — ranked, in three tiers:
72
-
73
- | Tier | What it reads | What it can say |
74
- |---|---|---|
75
- | **A** | Structured declarations: `package.json` keys, Cordis patch rows, the `!!js` expression inventory | A verdict. Confidence is `certain`, because the harness reads the same bytes the same way |
76
- | **B** | Shipped source, through the TypeScript parser | A capability report: "this plugin **can** do X" |
77
- | **C** | Whether the package could be read at all — minification, computed names, sourceless builds, binaries | That the analysis is degraded, and that no Tier B negative can be trusted |
78
-
79
- Every Tier B and Tier C finding carries a `bypass` field naming the one-line evasion for that
80
- specific check. It is inside the finding, not in a footnote, so a report cannot be rendered
81
- without its caveat.
82
-
83
- A finding is **per package, not per syntax site**. A package importing `node:fs` from eleven files
84
- gets one finding with `occurrences: 11` and three example locations, because the eleventh import
85
- warrants no decision the first did not. Findings are grouped by check and `subject` — the module
86
- specifier, the row id, the seam name, the matched rule — so `node:child_process` and
87
- `node:worker_threads` stay two findings, and a gate can accept `B13`/`node:fs` without accepting
88
- every `B13`.
89
-
90
- ## What it reports on the real ecosystem
91
-
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
-
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 `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
-
101
- | | 0.1 | 0.2 |
102
- |---|---|---|
103
- | Findings | 1,420 | **295** |
104
- | Critical | 252 | **3** |
105
- | Median findings per package | 10.5 | **5.5** |
106
- | Packages with a high or critical | 27 of 40 (68 %) | **21 of 40 (53 %)** |
107
- | Packages failing `--fail-on critical` | 40 of 40 | **1 of 40** |
108
- | Clean packages | 0 of 40 | **0 of 40** |
109
-
110
- **The 0.1 README quoted "49 findings, 0 critical" and that number was worthless.** It was measured
111
- on twelve targets — the harness's own bundles and our own sibling plugins — which is a sample
112
- selected for being trusted already. Against published third-party plugins the same build produced
113
- 1,420 findings and 252 criticals, and no package came out clean.
114
-
115
- Read the 0.2 column honestly:
116
-
117
- - **`--fail-on critical` is now a usable gate.** It stops one package in forty. That package,
118
- `@struktoai/mirage-dsh`, ships a patch layer that switches off `fs-sandbox`, `bash-sandbox` and
119
- `pwsh-sandbox`, and its three findings lead the report. Under 0.1 the same three sat somewhere in
120
- a list of 252.
121
- - **The default `--fail-on high` still stops a majority of the ecosystem**, and that is not a
122
- finished job. The largest remaining driver is `C2` — the analyzer saying it could not read the
123
- package, on 33 % of the corpus. That is a true statement rather than a false positive, but a gate
124
- that fires on a third of npm for reasons about the *tool* is not yet a gate.
125
- - **No package is clean, and that is expected rather than alarming.** `C3` alone — "ships built
126
- output and no source" — fires on 65 % of published packages, because that is what publishing a
127
- package is. It is `low`, it does not degrade the analysis, and it is not a defect.
128
-
129
- A readable report is not yet an installable gate.
130
41
 
131
42
  ## Usage
132
43
 
@@ -134,21 +45,14 @@ A readable report is not yet an installable gate.
134
45
  dsh-inspect <target> [options]
135
46
  dsh-inspect --from-npm <name>[@<version>] [options]
136
47
 
137
- <target> A plugin directory, or an npm tarball (.tgz / .tar.gz).
138
-
139
- Options
140
- --from-npm <spec> Fetch a published package from the registry, verify its
141
- dist.integrity hash, and analyse it in memory.
48
+ --from-npm <spec> Fetch from the registry, verify dist.integrity, analyse in memory.
142
49
  --registry <url> Registry base URL for --from-npm.
143
- (default: https://registry.npmjs.org)
144
50
  --json Emit the machine-readable JSON document on stdout.
145
- --fail-on <severity> Exit 1 at or above this severity.
146
- critical | high | medium | low | none (default: high)
51
+ --fail-on <severity> Exit 1 at or above this severity. (default: high)
147
52
  --no-color Plain text, no ANSI.
148
- --version, --help
149
53
  ```
150
54
 
151
- **Exit codes**, which are the CI contract:
55
+ **Exit codes are the CI contract:**
152
56
 
153
57
  | Code | Meaning |
154
58
  |---|---|
@@ -156,297 +60,52 @@ Options
156
60
  | `1` | Analysis completed; at least one finding at or above `--fail-on` |
157
61
  | `2` | Analysis could not be performed |
158
62
 
159
- `2` is deliberately distinct from `1`. A job that cannot tell "the analyzer broke" from "the
160
- plugin is clean" is the failure this split exists to prevent.
161
-
162
- ### Getting a package without installing it
63
+ `2` is deliberately distinct from `1`. A job that cannot tell "the analyzer broke" from "the plugin
64
+ is clean" is the failure this split exists to prevent.
163
65
 
164
- Never `pnpm add` a package you have not read.
165
-
166
- ```console
167
- # From the registry, in one step. Reads the ~3 KB version document, downloads the tarball into
168
- # memory, verifies dist.integrity BEFORE anything parses it, and analyses it there.
169
- dsh-inspect --from-npm <name>@<version>
170
-
171
- # From git. Clone shallow and point the tool at the directory — do NOT use `npm pack` on a git
172
- # spec, which runs the package's `prepare` script.
173
- git clone --depth 1 https://github.com/… /tmp/plugin
174
- dsh-inspect /tmp/plugin
175
- ```
176
-
177
- `--from-npm` is the only mode that opens a socket, and it is one flag per invocation: it cannot be
178
- combined with a local target, and a directory or tarball scan can never reach it — the fetch lives
179
- in a module the analysis path does not import. **A network fetch is not execution.** No subprocess,
180
- no disk write, no lifecycle script, and no `npm pack`. The report records the tarball URL, the
181
- digest that matched, and the registry's own `hasInstallScript` flag under `target.registry`.
182
-
183
- If the hash does not match what the registry published, the tool refuses and parses nothing. If the
184
- package predates `dist.integrity` entirely, the weaker `dist.shasum` is used and the report says
185
- `sha1` rather than claiming more. If neither is published, that is a refusal too.
186
-
187
- A tarball is decoded **entirely in memory**, from a file or from a fetch alike. Nothing is written
188
- to disk, which makes tar path traversal structurally impossible rather than something a filter has
189
- to catch. Every read ceiling is applied to the arriving stream rather than to a finished buffer, so
190
- a 28 MB archive holding one 8 GB member is a refusal in under two seconds, not an out-of-memory
191
- kill.
192
-
193
- ### Directory mode reads the working tree, not "the package"
194
-
195
- The two targets are not the same thing and the report says which one you gave it.
196
-
197
- A **tarball** is the published package: exactly the bytes a user installs. A **directory** is a
198
- repository checkout, which holds far more — tests, fixtures, CI config, build scratch. None of that
199
- is installed, none of it is mounted, and none of it can act on anybody, so the directory reader is
200
- narrowed to the set `npm pack` would produce: the `files` allowlist when the manifest declares one,
201
- otherwise `.npmignore` or `.gitignore` under npm's defaults. The facts section names which rule it
202
- used and how many working-tree files it skipped.
203
-
204
- This matters more than it sounds. Reading a checkout whole means a hostile *test fixture* — a file
205
- that ships nowhere and mounts nothing — is reported at `critical` with `certain` confidence. That
206
- is not a conservative error; it is the tool being confidently wrong about the one tier it treats as
207
- a verdict.
66
+ [Full usage, and getting a package without installing it →](https://charlotten7.github.io/dsh-plugin-inspector/usage.html)
208
67
 
209
68
  ## What it looks for
210
69
 
211
- ### Facts no severity, always emitted
70
+ Findings are tiered by how much you should trust them:
212
71
 
213
- | Fact | Source |
72
+ | Tier | What it means |
214
73
  |---|---|
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.
74
+ | **Facts** | No severity, always emitted — what the package declares about itself |
75
+ | **Tier A** | Decidable from a structured declaration. A real verdict. |
76
+ | **Tier B** | AST capability detection — "this plugin *can* do X" |
77
+ | **Tier C** | Heuristic; "we cannot read this" is itself the finding |
78
+
79
+ [Every check, by tier →](https://charlotten7.github.io/dsh-plugin-inspector/checks.html)
339
80
 
340
81
  ## The ceiling
341
82
 
342
- **This is triage. It is not containment.**
343
-
344
- The tool does not run in the harness process, does not gate installation, and cannot stop
345
- anything. It raises the cost of shipping a hostile plugin and gives you something to read where
346
- today you see nothing. That is the whole claim.
347
-
348
- A seam at which an install *could* be stopped does exist — `dsh plugin add` runs pnpm in the
349
- profile directory, pnpm honours a `.pnpmfile.cjs` there, and throwing from its async `readPackage`
350
- hook aborts the install with nothing written to `node_modules`. Nothing in 0.2 uses it.
351
- [`ADR.md`](./ADR.md) §11 records the seam and why shipping a gate on this release's calibration
352
- would have burned the idea.
353
-
354
- ### What is not statically decidable
355
-
356
- 1. **`!!js` semantics.** The loader evaluates these with
357
- `new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')` — unrestricted eval, under
358
- `with (ctx)` scoping. Which identifiers resolve, and to what, depends on the runtime context
359
- object. This tool reports the expression text and its syntactic class. It cannot tell you what
360
- the expression will do.
361
- 2. **Transitive dependencies.** One package is read. A clean package with one hostile dependency
362
- reads as clean. The dependency list is printed as a fact for exactly this reason.
363
- 3. **Runtime-fetched code.** Anything downloaded and evaluated after mount is invisible.
364
- 4. **Post-install mutation of `node_modules`.** The bytes analysed are not guaranteed to be the
365
- bytes that run.
366
- 5. **A later version acquiring `dsh.bundle`.** Reconciliation is by *installed state*, not by
367
- dependency diff. A package installed today as a plain library that gains a `dsh.bundle`
368
- declaration in a patch release is mounted automatically by the next `dsh plugin update`, with
369
- no notice. This is the most likely real-world bypass, and it means **a verdict is about one
370
- version and only that version.**
371
- 6. **Intent.** Tier B's `B8` is the sharpest case: the tool proves a package *can* read a
372
- credential and *can* open a socket. It has not shown that the value flows between them, and it
373
- cannot — that needs value tracking this tool does not do. Any telemetry library or
374
- authenticated API client trips `B8` legitimately. It fires on 18 % of published plugins, which
375
- is why it is `high` and not `critical`.
376
- 7. **Injection phrasing that is not spelled in ASCII.** The injection heuristics are Latin-alphabet
377
- regexes. Substituting Cyrillic homoglyphs — `о` U+043E for `o`, `е` U+0435 for `e` — defeats
378
- **every one of the eleven rules**, including the two hidden-character rules, which look for
379
- invisible characters and not for visible ones that are the wrong letter. Verified against the
380
- rule table, not assumed. Normalisation is not in 0.2; do not read a clean `A21`/`B10` as
381
- evidence that shipped markdown carries no instructions.
382
-
383
- ### Every Tier B check has a one-line bypass
384
-
385
- `ctx['pro' + 'vide']('approval', …)` defeats seam detection. A computed specifier defeats every
386
- import check. A base64 event name defeats every listener check. Splitting a credential read and a
387
- network call across two packages defeats `B8`. A Cyrillic `о` defeats every injection rule.
388
-
389
- **Tier A is much harder to hide from, because it is structured declaration rather than code.**
390
- The harness must read `disabled: true` literally in order to disable anything, so there is no
391
- obfuscation that leaves it working. That asymmetry is why Tier A issues verdicts and Tier B
392
- issues capability reports.
393
-
394
- ### And when the tool cannot read the package
395
-
396
- If any Tier C check that says something could not be *read* fires, every Tier B confidence drops to
397
- `moderate`, `analysis.integrity` becomes `degraded`, `analysis.negativesReliable` becomes `false`,
398
- and the human report is **forbidden from printing "no findings"**. A clean-looking report on a
399
- minified bundle would be worse than no report, so the tool refuses to produce one.
400
-
401
- The honest form of a clean result is: *nothing was found at or above the threshold, in the parts
402
- that could be read.*
83
+ **This is not a malware scanner and it cannot be one.** Capability is decidable from source;
84
+ intent is not. Every Tier B check has a one-line bypass, and the tool says so per finding rather
85
+ than implying a completeness it does not have. What it does guarantee is that it never runs the
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.
403
89
 
404
- ## Development
90
+ [What is not statically decidable →](https://charlotten7.github.io/dsh-plugin-inspector/ceiling.html) ·
91
+ [What it reports on the real ecosystem →](https://charlotten7.github.io/dsh-plugin-inspector/ecosystem.html)
405
92
 
406
- Node `^22.19.0 || >=24` and pnpm are the only requirements. No test reaches a network or a harness
407
- checkout: every registry case injects its own `fetch`, and one of them replaces the global with a
408
- throwing stub to prove a directory or tarball scan never calls it.
93
+ ## Development
409
94
 
410
- ```console
95
+ ```sh
96
+ nvm use 22 # Node ^22.19.0 || >=24, and pnpm 11
411
97
  pnpm install
412
98
  pnpm run typecheck
413
- pnpm run test # unit suite
414
- pnpm run test:coverage # same suite, with the coverage ratchet
415
- pnpm run test:e2e # builds, then runs the real binary as a subprocess
416
- pnpm run inspect <target> # run from source without building
417
- pnpm run sweep -- --check # the one thing here that DOES use a network
99
+ pnpm run test:coverage
100
+ pnpm run test:e2e
418
101
  ```
419
102
 
420
- `pnpm run sweep` is the ecosystem measurement. It fetches the pinned corpus in
421
- `scripts/ecosystem-corpus.json` through the same verified in-memory path as `--from-npm`, prints the
422
- distribution, and with `--check` exits non-zero when a fresh run is worse than
423
- `tests/ecosystem-baseline.json`. `--discover` rebuilds the corpus from the most-starred repositories
424
- carrying the topic; `--pin` moves every entry to the version current now; `--record` rewrites the
425
- baseline. It runs from its own weekly workflow, never from CI — every other workflow here runs
426
- without a network, and a unit suite that cannot reach one is easier to trust.
427
-
428
- Hostile fixtures live in `tests/fixtures/` and are authored here — a plugin that disables the
429
- approval row, one whose `!!js` calls `child_process`, one with a `postinstall`, one pairing a
430
- credential read with `fetch`, one shipping a `SKILL.md` full of injection text, one declaring an
431
- MCP stdio server, a minified one, one using the `!js` tag, one whose bundle patch path escapes the
432
- package, and a benign control that must produce **zero** findings. They are deliberately hostile
433
- and structurally inert; [`tests/fixtures/README.md`](./tests/fixtures/README.md) says why, and
434
- which of them is a live prompt-injection payload you should not copy anywhere.
435
-
436
- `tests/fixtures/execution-canary/` is the proof that nothing runs: its install scripts, its `!!js`
437
- expressions, and its module top level all write a sentinel file, and the test asserts the sentinel
438
- does not exist after a full analysis. `node:child_process` and the write half of `node:fs` are
439
- mocked to throw for the whole suite, so a stray call fails the tests rather than passing quietly.
440
-
441
- Design decisions are in [`ADR.md`](./ADR.md); the check catalogue is under
442
- [What it looks for](#what-it-looks-for).
443
-
444
- ## Reporting a problem
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.
445
105
 
446
- Security reports go to the address in [`SECURITY.md`](./SECURITY.md), which also says what counts
447
- as a vulnerability in a tool whose whole job is reading hostile input. A check that fires on
448
- ordinary code is a real defect — please open a normal issue for it.
106
+ Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
107
+ [SECURITY.md](SECURITY.md).
449
108
 
450
109
  ## License
451
110
 
452
- MIT — see [`LICENSE`](./LICENSE).
111
+ MIT