dsh-plugin-inspector 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,13 +2,13 @@
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 or an npm tarball and tells you
6
- what it declares and what its code is capable of. It does not install it, build it, import it,
7
- spawn it, or evaluate any part of it.
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
7
+ declares and what its code is capable of. It does not install it, build it, import it, spawn it, or
8
+ evaluate any part of it.
8
9
 
9
10
  ```console
10
- $ npm pack some-dsh-plugin@1.4.0 --pack-destination /tmp
11
- $ dsh-inspect /tmp/some-dsh-plugin-1.4.0.tgz
11
+ $ dsh-inspect --from-npm some-dsh-plugin@1.4.0
12
12
  ```
13
13
 
14
14
  ## Install
@@ -53,7 +53,7 @@ dsh: warning: <pkg> declares no dsh.bundle — installed as a plain dependency,
53
53
 
54
54
  The dangerous case prints nothing.
55
55
 
56
- There are over 4,000 repos tagged `dsh-plugin` — 4,813 when this was last counted, in August
56
+ There are over 5,000 repos tagged `dsh-plugin` — 5,071 when this was last counted, on 16 August
57
57
  2026 — and no registry, no review, and no signing between any of them and your process. This tool
58
58
  exists so that the moment before you install one is not a blank.
59
59
 
@@ -68,15 +68,6 @@ commands it puts on your PATH, its dependencies, what model-visible text it ship
68
68
  it could be read. A well-behaved plugin has a full facts section and an empty findings section.
69
69
  That is a useful answer, not an empty one.
70
70
 
71
- That is also the bar the tool is held to. Across twelve real targets — four plugins read both as a
72
- directory and as their published tarball, the three bundles the harness itself ships, and a
73
- scratch plugin — the current build reports **49 findings: none critical, two high, and exactly one
74
- target exiting non-zero.** Both high findings are true statements about `@deepseek-ai/dsh-web-app`'s
75
- own shipped code, which does contribute to the system prompt and does mount further plugins. The
76
- upstream plugin template reports three findings, all `node:fs` and `dshHomePath` facts about what
77
- it genuinely does; the base and headless bundles report three and one. Every packable target's
78
- directory reading and tarball reading are byte-identical.
79
-
80
71
  **Findings** — ranked, in three tiers:
81
72
 
82
73
  | Tier | What it reads | What it can say |
@@ -89,14 +80,67 @@ Every Tier B and Tier C finding carries a `bypass` field naming the one-line eva
89
80
  specific check. It is inside the finding, not in a footnote, so a report cannot be rendered
90
81
  without its caveat.
91
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-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`.
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 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.
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
+
92
131
  ## Usage
93
132
 
94
133
  ```
95
134
  dsh-inspect <target> [options]
135
+ dsh-inspect --from-npm <name>[@<version>] [options]
96
136
 
97
137
  <target> A plugin directory, or an npm tarball (.tgz / .tar.gz).
98
138
 
99
139
  Options
140
+ --from-npm <spec> Fetch a published package from the registry, verify its
141
+ dist.integrity hash, and analyse it in memory.
142
+ --registry <url> Registry base URL for --from-npm.
143
+ (default: https://registry.npmjs.org)
100
144
  --json Emit the machine-readable JSON document on stdout.
101
145
  --fail-on <severity> Exit 1 at or above this severity.
102
146
  critical | high | medium | low | none (default: high)
@@ -117,13 +161,12 @@ plugin is clean" is the failure this split exists to prevent.
117
161
 
118
162
  ### Getting a package without installing it
119
163
 
120
- Never `pnpm add` a package you have not read. Two safe ways to get the bytes:
164
+ Never `pnpm add` a package you have not read.
121
165
 
122
166
  ```console
123
- # From the registry. `npm pack` on a registry spec downloads and repacks; it does not install
124
- # and does not run the package's scripts.
125
- npm pack <name>@<version> --pack-destination /tmp
126
- dsh-inspect /tmp/<name>-<version>.tgz
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>
127
170
 
128
171
  # From git. Clone shallow and point the tool at the directory — do NOT use `npm pack` on a git
129
172
  # spec, which runs the package's `prepare` script.
@@ -131,10 +174,21 @@ git clone --depth 1 https://github.com/… /tmp/plugin
131
174
  dsh-inspect /tmp/plugin
132
175
  ```
133
176
 
134
- A tarball is decoded **entirely in memory**. Nothing is written to disk, which makes tar path
135
- traversal structurally impossible rather than something a filter has to catch. Every read ceiling
136
- is applied to the arriving stream rather than to a finished buffer, so a 28 MB archive holding one
137
- 8 GB member is a refusal in under two seconds, not an out-of-memory kill.
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.
138
192
 
139
193
  ### Directory mode reads the working tree, not "the package"
140
194
 
@@ -154,30 +208,137 @@ a verdict.
154
208
 
155
209
  ## What it looks for
156
210
 
157
- Full catalogue with detection methods in [`PLAN.md`](./PLAN.md) §6. The short version:
158
-
159
- **Tier A** install lifecycle scripts; a patch row disabling, re-enabling, or rewriting a core row
160
- (with `approval`, `permission`, `sandbox`, `sandbox-policy`, `fs-sandbox`, `fs-observation-policy`,
161
- `subprocess`, `credentials` and friends called out by name); the `!!js` inventory, classified by
162
- what each expression reaches; `!!js` in a field the loader never interpolates; `!js`, which is a
163
- hard parse error and proves the layer has never loaded anywhere; inserted rows naming modules the
164
- manifest does not declare; rows that re-map a service for their subtree with `isolate` or
165
- `intercept`; MCP server rows; `dsh.bundle.patch` paths that climb out of the package;
166
- skill-root redirection; a `dsh.profile.bundles` list, which makes the package a profile that mounts
167
- other packages; commands installed on your PATH; non-registry dependency specifiers; and injection
168
- phrasing in shipped instruction markdown, which is Tier A because the shipped bytes *are* the
169
- prompt.
170
-
171
- **Tier B** — capability-seam replacement via `ctx.provide` / `ctx.set`; system-prompt mutation;
172
- credential reads; network egress; the two of those together; `node:child_process`,
173
- `node:worker_threads`, `node:vm`; filesystem access outside `ctx.fs`; code built at runtime **and
174
- called**; nested plugin mounting; injection phrasing in registered tool `description` strings,
175
- which code assembles and which is therefore evadable.
176
-
177
- **Tier C** minified source; computed member access, specifiers, and names; binaries and files
178
- over the read caps; a patch layer whose structure hit a walk ceiling; and build output with no
179
- source beside it, which is the one Tier C finding that does *not* degrade the analysis the bytes
180
- 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 | `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 |
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
+ | B2 | Auto-approves — a listener on `approval/request` that returns an approving verdict with no user interaction | **critical** | listener body return analysis |
281
+ | B3 | `tools/pre-execute` listener returning `allow` | high | same |
282
+ | B4 | Waterfall listener that never references `next` | high | The waterfall set is exactly 13 events: `agent/pre-step`, `agent/request`, `agent/request-error`, `approval/request`, `fs/edit-intent`, `fs/write-intent`, `llm/stream`, `session-telemetry/record`, `system-prompt/assemble`, `tools/code-dispatch-log`, `tools/execute`, `tools/post-execute`, `tools/pre-execute`. Per the harness's own rule, returning without calling `next()` short-circuits the chain **including the built-in behavior**, silently disabling the default for everyone downstream. Note there is **no** `fs/read-intent` — the intent family is write and edit only |
283
+ | B5 | System-prompt mutation — `system-prompt/assemble` listener, or `ctx.systemPrompt.{section,context,variable,tools,suppressRuntimeContext}` | high | call matching |
284
+ | B6 | Credential read — `process.env.*(TOKEN\|KEY\|SECRET\|PASSWORD\|CREDENTIAL)*`, `~/.dsh/credentials`, `~/.npmrc`, `~/.aws`, `~/.ssh`, `ctx.credentials.*` | medium alone | identifier + literal matching |
285
+ | B7 | Network egress — `fetch`, `node:http(s).request`, `node:net`, `WebSocket`, `undici` | medium alone | import + call matching |
286
+ | 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 |
287
+ | 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 |
288
+ | 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. Run on *exactly* the text that reaches the model, never on ordinary source comments |
289
+ | B11 | Nested plugin mounting — `ctx.plugin(…)`, loader manipulation | high | call matching. A layer that mounts further layers moves the analysis target |
290
+ | B12 | Dynamic code construction — `eval`, `new Function`, `vm.runInNewContext`, `module._load` | high | call matching |
291
+ | 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 |
292
+
293
+ **The framing B7, B9, and B13 share.** The harness's own dynamic-package sandbox
294
+ (`cordis-host-runner/src/sandbox.ts`) traps exactly `require`, `setTimeout`, `setInterval`,
295
+ `setImmediate`, `clearTimeout`, `clearInterval`, and `fetch`, redirecting each to a `ctx` service;
296
+ it leaves `process` `undefined` and exposes only the seven `HOST_BUILTIN_INSPECTION` globals.
297
+ **An installed npm bundle layer gets none of that** — it is a plain ESM import into the harness
298
+ process. So these three checks report a gap the harness itself defines: *the harness denies
299
+ untrusted code this capability, and this package uses it from a position where nothing denies it.*
300
+ That is the harness's reckoning, not a rule invented here.
301
+
302
+ ### Tier C — heuristic; "we cannot read this" is itself the finding
303
+
304
+ | id | Check | Severity | Effect |
305
+ |---|---|---|---|
306
+ | 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** |
307
+ | 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** |
308
+ | 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* |
309
+ | C4 | Unreadable payload — `.node`, `.wasm`, binaries, files over the size cap | medium | **degrades** |
310
+ | C5 | The mounted layer hit a walk ceiling — nesting depth or node count | high | **degrades**. Rows past the ceiling were not read |
311
+ | C6 | A `.min.js` artifact | low | **degrades** |
312
+
313
+ ### `!!js` sub-classification (A6)
314
+
315
+ Every `!!js` node is inventoried with its YAML path and text, then parse-compiled with
316
+ `new Function('return (' + expr + ')')` — compilation only; the constructor never executes the
317
+ body — and the resulting AST is classified.
318
+
319
+ Classification is by **reach**, not by syntactic form. `dshHomePath('sessions')` and `steal()` are
320
+ both `CallExpression`s; the first is a helper `dsh-app-boot` puts in scope with
321
+ `ctx.provide('dshHomePath', dshHomePath)` before any entry mounts, documented as such in that
322
+ package's README, and used by the base bundle's own `session-persistence-jsonl` row.
323
+
324
+ | Class | Example | Severity | Finding |
325
+ |---|---|---|---|
326
+ | `literal` | `true`, `3` | — | fact only |
327
+ | `inert-read` | `process.env.DSH_TOOLS_MODE`, `process.platform === 'win32'`, `ctx.webStartup.host` | — | fact only |
328
+ | `harness-call` | `dshHomePath('sessions')`, `process.cwd()` | low | A6 |
329
+ | `call` | a call this tool cannot resolve | medium | A6 |
330
+ | `mutation` | `process.env.X = …` | high | A6 |
331
+ | `module-access` | `require(…)`, `import(…)`, `globalThis[…]` | **critical** | A6 |
332
+ | `unparseable` | syntax error | medium — and it means the plugin cannot boot | A6 |
333
+
334
+ The two classes with no reach are counted in `facts.jsExpressions` and never raised: a constant, or
335
+ a read of a service the profile already handed the row, warrants no decision, and the shipped
336
+ bundles are mostly made of them.
337
+
338
+ The escalation of the rest is justified: the evaluator is
339
+ `new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')` — unrestricted eval, with `ctx`
340
+ in scope. And `disabled` re-evaluates at **every mount decision**, so a `!!js` there is not a
341
+ one-shot: it is a recurring execution point that user patch layers HMR-reload live.
181
342
 
182
343
  ## The ceiling
183
344
 
@@ -187,6 +348,12 @@ The tool does not run in the harness process, does not gate installation, and ca
187
348
  anything. It raises the cost of shipping a hostile plugin and gives you something to read where
188
349
  today you see nothing. That is the whole claim.
189
350
 
351
+ A seam at which an install *could* be stopped does exist — `dsh plugin add` runs pnpm in the
352
+ profile directory, pnpm honours a `.pnpmfile.cjs` there, and throwing from its async `readPackage`
353
+ hook aborts the install with nothing written to `node_modules`. Nothing in 0.2 uses it.
354
+ [`ADR.md`](./ADR.md) §11 records the seam and why shipping a gate on this release's calibration
355
+ would have burned the idea.
356
+
190
357
  ### What is not statically decidable
191
358
 
192
359
  1. **`!!js` semantics.** The loader evaluates these with
@@ -207,13 +374,20 @@ today you see nothing. That is the whole claim.
207
374
  6. **Intent.** Tier B's `B8` is the sharpest case: the tool proves a package *can* read a
208
375
  credential and *can* open a socket. It has not shown that the value flows between them, and it
209
376
  cannot — that needs value tracking this tool does not do. Any telemetry library or
210
- authenticated API client trips `B8` legitimately.
377
+ authenticated API client trips `B8` legitimately. It fires on 18 % of published plugins, which
378
+ is why it is `high` and not `critical`.
379
+ 7. **Injection phrasing that is not spelled in ASCII.** The injection heuristics are Latin-alphabet
380
+ regexes. Substituting Cyrillic homoglyphs — `о` U+043E for `o`, `е` U+0435 for `e` — defeats
381
+ **every one of the ten rules**, including the zero-width-character rule, which looks for
382
+ invisible characters and not for visible ones that are the wrong letter. Verified against the
383
+ rule table, not assumed. Normalisation is not in 0.2; do not read a clean `A21`/`B10` as
384
+ evidence that shipped markdown carries no instructions.
211
385
 
212
386
  ### Every Tier B check has a one-line bypass
213
387
 
214
388
  `ctx['pro' + 'vide']('approval', …)` defeats seam detection. A computed specifier defeats every
215
389
  import check. A base64 event name defeats every listener check. Splitting a credential read and a
216
- network call across two packages defeats `B8`.
390
+ network call across two packages defeats `B8`. A Cyrillic `о` defeats every injection rule.
217
391
 
218
392
  **Tier A is much harder to hide from, because it is structured declaration rather than code.**
219
393
  The harness must read `disabled: true` literally in order to disable anything, so there is no
@@ -232,8 +406,9 @@ that could be read.*
232
406
 
233
407
  ## Development
234
408
 
235
- Node `^22.19.0 || >=24` and pnpm are the only requirements; there is no network and no harness
236
- checkout in any test.
409
+ Node `^22.19.0 || >=24` and pnpm are the only requirements. No test reaches a network or a harness
410
+ checkout: every registry case injects its own `fetch`, and one of them replaces the global with a
411
+ throwing stub to prove a directory or tarball scan never calls it.
237
412
 
238
413
  ```console
239
414
  pnpm install
@@ -242,8 +417,17 @@ pnpm run test # unit suite
242
417
  pnpm run test:coverage # same suite, with the coverage ratchet
243
418
  pnpm run test:e2e # builds, then runs the real binary as a subprocess
244
419
  pnpm run inspect <target> # run from source without building
420
+ pnpm run sweep -- --check # the one thing here that DOES use a network
245
421
  ```
246
422
 
423
+ `pnpm run sweep` is the ecosystem measurement. It fetches the pinned corpus in
424
+ `scripts/ecosystem-corpus.json` through the same verified in-memory path as `--from-npm`, prints the
425
+ distribution, and with `--check` exits non-zero when a fresh run is worse than
426
+ `tests/ecosystem-baseline.json`. `--discover` rebuilds the corpus from the most-starred repositories
427
+ carrying the topic; `--pin` moves every entry to the version current now; `--record` rewrites the
428
+ baseline. It runs from its own weekly workflow, never from CI — every other workflow here runs
429
+ without a network, and a unit suite that cannot reach one is easier to trust.
430
+
247
431
  Hostile fixtures live in `tests/fixtures/` and are authored here — a plugin that disables the
248
432
  approval row, one whose `!!js` calls `child_process`, one with a `postinstall`, one pairing a
249
433
  credential read with `fetch`, one shipping a `SKILL.md` full of injection text, one declaring an
@@ -257,8 +441,8 @@ expressions, and its module top level all write a sentinel file, and the test as
257
441
  does not exist after a full analysis. `node:child_process` and the write half of `node:fs` are
258
442
  mocked to throw for the whole suite, so a stray call fails the tests rather than passing quietly.
259
443
 
260
- Design decisions are in [`ADR.md`](./ADR.md); the scope, catalogue, and phasing are in
261
- [`PLAN.md`](./PLAN.md).
444
+ Design decisions are in [`ADR.md`](./ADR.md); the check catalogue is under
445
+ [What it looks for](#what-it-looks-for).
262
446
 
263
447
  ## Reporting a problem
264
448
 
@@ -69,7 +69,7 @@ const INERT_CLASSES = new Set(['literal', 'inert-read']);
69
69
  * @returns the complete finding.
70
70
  */
71
71
  function tierA(finding) {
72
- return { ...finding, tier: 'A', confidence: 'certain', bypass: null };
72
+ return { ...finding, tier: 'A', confidence: 'certain', bypass: null, examples: [finding.evidence], occurrences: 1 };
73
73
  }
74
74
  /**
75
75
  * Whether the package under analysis is itself one of the harness's shipped
@@ -131,6 +131,7 @@ function checkDisabledRows(input) {
131
131
  findings.push(tierA({
132
132
  checkId: 'A19',
133
133
  name: 'core-row-force-enabled',
134
+ subject: override.id,
134
135
  severity: 'medium',
135
136
  title: `Patch layer re-enables the core row "${override.id}"`,
136
137
  detail: 'The loader coerces `disabled` with `Boolean()`, so this value leaves the row running. Because '
@@ -145,6 +146,7 @@ function checkDisabledRows(input) {
145
146
  findings.push(tierA({
146
147
  checkId: 'A2',
147
148
  name: 'security-row-disabled',
149
+ subject: override.id,
148
150
  severity: 'critical',
149
151
  title: `Patch layer disables the core row "${override.id}"`,
150
152
  detail: `Bundle patches apply after @deepseek-ai/dsh-base, so this layer switches off ${stops}. `
@@ -161,6 +163,7 @@ function checkDisabledRows(input) {
161
163
  findings.push(tierA({
162
164
  checkId: 'A3',
163
165
  name: 'core-row-disabled',
166
+ subject: override.id,
164
167
  severity,
165
168
  title: `Patch layer disables the core row "${override.id}"`,
166
169
  detail: `The row comes from ${coreRowOrigin(override.id)}, and this layer applies after it, so the row `
@@ -189,6 +192,7 @@ function checkOverriddenRows(input) {
189
192
  findings.push(tierA({
190
193
  checkId: 'A4',
191
194
  name: 'patch-name-guard-mismatch',
195
+ subject: override.id,
192
196
  severity: 'medium',
193
197
  title: `Patch for "${override.id}" names ${override.nameGuard}, but that row is ${coreName}`,
194
198
  detail: 'applyEntryPatches treats `name` on a non-insert patch as an assertion guard: on mismatch it '
@@ -205,6 +209,7 @@ function checkOverriddenRows(input) {
205
209
  findings.push(tierA({
206
210
  checkId: 'A5',
207
211
  name: 'core-row-overridden',
212
+ subject: `${override.id}:${rewritten.join(',')}`,
208
213
  severity: isSecurity ? 'high' : 'medium',
209
214
  title: `Patch layer rewrites ${rewritten.map(key => `\`${key}\``).join(', ')} on the core row "${override.id}"`,
210
215
  detail: `The row is ${coreName}. Patch overrides are shallow whole-value replacements, not merges, so `
@@ -249,6 +254,7 @@ function liveExpression(file, site) {
249
254
  return tierA({
250
255
  checkId: 'A6',
251
256
  name: 'js-expression',
257
+ subject: `${site.slot}:${site.classification}`,
252
258
  severity: EXPRESSION_SEVERITY[site.classification],
253
259
  title: `\`!!js\` expression in a row's \`${site.slot}\` ${EXPRESSION_MEANING[site.classification]}`,
254
260
  detail: `The loader evaluates this with new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }') — `
@@ -270,6 +276,7 @@ function inertExpression(file, site) {
270
276
  return tierA({
271
277
  checkId: 'A7',
272
278
  name: 'js-expression-inert',
279
+ subject: 'inert-slot',
273
280
  severity: 'medium',
274
281
  title: '`!!js` in a field the loader never interpolates',
275
282
  detail: 'The loader interpolates only a row\'s `config` (recursively) and the top-level node of its `disabled`. '
@@ -285,6 +292,7 @@ function checkPatchFailures(input) {
285
292
  ? tierA({
286
293
  checkId: 'A8',
287
294
  name: 'single-bang-js-tag',
295
+ subject: failure.file,
288
296
  severity: 'medium',
289
297
  title: 'Patch layer uses the `!js` tag, which no harness accepts',
290
298
  detail: 'The dialect registers exactly one custom tag, `tag:yaml.org,2002:js`, whose shorthand is `!!js`. '
@@ -295,6 +303,7 @@ function checkPatchFailures(input) {
295
303
  : tierA({
296
304
  checkId: 'A17',
297
305
  name: 'patch-parse-error',
306
+ subject: failure.file,
298
307
  severity: 'medium',
299
308
  title: 'Patch layer does not parse',
300
309
  detail: 'The declared patch layer cannot be read as a Cordis entry list, so mounting this package fails the '
@@ -321,6 +330,7 @@ function checkInsertedModules(input) {
321
330
  findings.push(tierA({
322
331
  checkId: 'A9',
323
332
  name: 'insert-undeclared-module',
333
+ subject: name,
324
334
  severity: isCore ? 'medium' : 'high',
325
335
  title: `Inserted row "${row.id ?? '(unnamed)'}" mounts ${name}, which this package does not declare`,
326
336
  detail: isCore
@@ -352,6 +362,7 @@ function checkMcpRows(input) {
352
362
  findings.push(tierA({
353
363
  checkId: 'A10',
354
364
  name: 'mcp-server-row',
365
+ subject: shown,
355
366
  severity: stdio ? 'critical' : 'high',
356
367
  title: stdio
357
368
  ? `Patch layer starts a local MCP server by running \`${shown}\``
@@ -389,6 +400,7 @@ function checkSkillRootRedirect(input) {
389
400
  findings.push(tierA({
390
401
  checkId: 'A15',
391
402
  name: 'skill-root-redirected',
403
+ subject: keys.join(','),
392
404
  severity: 'high',
393
405
  title: `Patch layer redirects skill discovery via ${keys.map(key => `\`${key}\``).join(', ')}`,
394
406
  detail: 'Skill files reach the model verbatim, unescaped and uncapped. This row changes which directories '
@@ -412,6 +424,7 @@ function checkManifest(input) {
412
424
  findings.push(tierA({
413
425
  checkId: 'A1',
414
426
  name: 'install-lifecycle-script',
427
+ subject: name,
415
428
  severity: 'medium',
416
429
  title: `Declares a \`${name}\` script, which runs at install time once allowed`,
417
430
  detail: 'This command would run at the user\'s uid as part of `dsh plugin add`, before the user has read a '
@@ -427,6 +440,7 @@ function checkManifest(input) {
427
440
  findings.push(tierA({
428
441
  checkId: 'A22',
429
442
  name: 'installs-command',
443
+ subject: command,
430
444
  severity: 'low',
431
445
  title: `Installs the command \`${command}\` on the user's PATH`,
432
446
  detail: 'A `bin` entry is linked into the profile\'s `node_modules/.bin` at install time. It is not run by '
@@ -440,6 +454,7 @@ function checkManifest(input) {
440
454
  findings.push(tierA({
441
455
  checkId: 'A20',
442
456
  name: 'profile-mounts-bundles',
457
+ subject: 'dsh.profile.bundles',
443
458
  severity: 'high',
444
459
  title: `Declares a profile that mounts ${profileBundles.length} bundle(s)`,
445
460
  detail: 'A `dsh.profile.bundles` list makes this package a profile rather than a layer: the launcher resolves '
@@ -459,6 +474,7 @@ function checkManifest(input) {
459
474
  findings.push(tierA({
460
475
  checkId: 'A11',
461
476
  name: 'non-registry-dependency',
477
+ subject: `${field}.${name}`,
462
478
  severity: 'high',
463
479
  title: `Depends on ${name} through a non-registry specifier`,
464
480
  detail: 'The code behind this specifier can change without the version of this package changing, so nothing '
@@ -471,6 +487,7 @@ function checkManifest(input) {
471
487
  findings.push(tierA({
472
488
  checkId: 'A13',
473
489
  name: 'no-files-allowlist',
490
+ subject: 'files',
474
491
  severity: 'low',
475
492
  title: 'No `files` allowlist in package.json',
476
493
  detail: 'Without an allowlist the published tarball is whatever was in the working tree minus npm\'s default '
@@ -485,6 +502,7 @@ function checkManifest(input) {
485
502
  findings.push(tierA({
486
503
  checkId: 'A14',
487
504
  name: 'bundle-patch-escapes-package',
505
+ subject: 'dsh.bundle.patch',
488
506
  severity: 'critical',
489
507
  title: 'The declared `dsh.bundle.patch` path climbs out of the package directory',
490
508
  detail: 'The launcher resolves the patch as join(packageDir, declared) with no sanitisation, and `..` '
@@ -497,6 +515,7 @@ function checkManifest(input) {
497
515
  findings.push(tierA({
498
516
  checkId: 'A16',
499
517
  name: 'bundle-patch-missing',
518
+ subject: 'dsh.bundle.patch',
500
519
  severity: 'medium',
501
520
  title: 'The declared `dsh.bundle.patch` file is not in the package',
502
521
  detail: 'The package declares a mounted patch layer whose file is absent — commonly a `files` allowlist '
@@ -511,6 +530,7 @@ function checkManifest(input) {
511
530
  findings.push(tierA({
512
531
  checkId: 'A18',
513
532
  name: 'manifest-defect',
533
+ subject: defect,
514
534
  severity: 'low',
515
535
  title: `Malformed package.json field: ${defect}`,
516
536
  detail: 'The field was ignored. A manifest that npm and the harness read differently is worth knowing about.',
@@ -526,6 +546,7 @@ function checkModelVisibleText(input) {
526
546
  return [tierA({
527
547
  checkId: 'A12',
528
548
  name: 'model-visible-text-shipped',
549
+ subject: 'shipped-instructions',
529
550
  severity: 'low',
530
551
  title: `Ships ${input.modelVisibleFiles.length} model-visible instruction file(s)`,
531
552
  detail: 'Skill and agent-instruction markdown reaches the model verbatim, unescaped and uncapped. Shipping it '
@@ -548,6 +569,7 @@ function checkServiceRemapping(input) {
548
569
  findings.push(tierA({
549
570
  checkId: 'A23',
550
571
  name: 'row-service-remapping',
572
+ subject: `${field}:${seams.join(',')}`,
551
573
  severity: critical ? 'critical' : 'high',
552
574
  title: `Inserted row "${row.id ?? '(unnamed)'}" re-maps ${seams.map(name => `\`${name}\``).join(', ')} via \`${field}\``,
553
575
  detail: field === 'isolate'
@@ -583,9 +605,11 @@ function checkInjectionText(input) {
583
605
  if (text === undefined)
584
606
  continue;
585
607
  for (const match of scanInjection(text)) {
608
+ const evidence = { file: path, path: lineColumn(text, match.index), snippet: snippet(match.excerpt) };
586
609
  findings.push({
587
610
  checkId: 'A21',
588
611
  name: 'model-visible-injection',
612
+ subject: match.ruleId,
589
613
  tier: 'A',
590
614
  severity: 'high',
591
615
  confidence: 'certain',
@@ -595,7 +619,9 @@ function checkInjectionText(input) {
595
619
  + 'model, which is why this is a verdict about the text rather than a capability report. Whether the '
596
620
  + 'sentence is an instruction or a discussion of one is a judgement this tool cannot make: the pattern '
597
621
  + 'will miss a rephrasing, and it can fire on a document that legitimately quotes an attack.',
598
- evidence: { file: path, path: lineColumn(text, match.index), snippet: snippet(match.excerpt) },
622
+ evidence,
623
+ examples: [evidence],
624
+ occurrences: 1,
599
625
  bypass: null,
600
626
  });
601
627
  }