dsh-plugin-inspector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Tyshchenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # dsh-plugin-inspector
2
+
3
+ **Know what a plugin does before you install it.**
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.
8
+
9
+ ```console
10
+ $ npm pack some-dsh-plugin@1.4.0 --pack-destination /tmp
11
+ $ dsh-inspect /tmp/some-dsh-plugin-1.4.0.tgz
12
+ ```
13
+
14
+ ## Install
15
+
16
+ Node `^22.19.0 || >=24`. Once a release is published:
17
+
18
+ ```console
19
+ npm install -g dsh-plugin-inspector
20
+ dsh-inspect --help
21
+ ```
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:
25
+
26
+ ```console
27
+ git clone https://github.com/CharlotteN7/dsh-plugin-inspector
28
+ 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
+
50
+ ```
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 4,000 repos tagged `dsh-plugin` — 4,813 when this was last counted, in 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
+ 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
+ **Findings** — ranked, in three tiers:
81
+
82
+ | Tier | What it reads | What it can say |
83
+ |---|---|---|
84
+ | **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 |
85
+ | **B** | Shipped source, through the TypeScript parser | A capability report: "this plugin **can** do X" |
86
+ | **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 |
87
+
88
+ Every Tier B and Tier C finding carries a `bypass` field naming the one-line evasion for that
89
+ specific check. It is inside the finding, not in a footnote, so a report cannot be rendered
90
+ without its caveat.
91
+
92
+ ## Usage
93
+
94
+ ```
95
+ dsh-inspect <target> [options]
96
+
97
+ <target> A plugin directory, or an npm tarball (.tgz / .tar.gz).
98
+
99
+ Options
100
+ --json Emit the machine-readable JSON document on stdout.
101
+ --fail-on <severity> Exit 1 at or above this severity.
102
+ critical | high | medium | low | none (default: high)
103
+ --no-color Plain text, no ANSI.
104
+ --version, --help
105
+ ```
106
+
107
+ **Exit codes**, which are the CI contract:
108
+
109
+ | Code | Meaning |
110
+ |---|---|
111
+ | `0` | Analysis completed; nothing at or above `--fail-on` |
112
+ | `1` | Analysis completed; at least one finding at or above `--fail-on` |
113
+ | `2` | Analysis could not be performed |
114
+
115
+ `2` is deliberately distinct from `1`. A job that cannot tell "the analyzer broke" from "the
116
+ plugin is clean" is the failure this split exists to prevent.
117
+
118
+ ### Getting a package without installing it
119
+
120
+ Never `pnpm add` a package you have not read. Two safe ways to get the bytes:
121
+
122
+ ```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
127
+
128
+ # From git. Clone shallow and point the tool at the directory — do NOT use `npm pack` on a git
129
+ # spec, which runs the package's `prepare` script.
130
+ git clone --depth 1 https://github.com/… /tmp/plugin
131
+ dsh-inspect /tmp/plugin
132
+ ```
133
+
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.
138
+
139
+ ### Directory mode reads the working tree, not "the package"
140
+
141
+ The two targets are not the same thing and the report says which one you gave it.
142
+
143
+ A **tarball** is the published package: exactly the bytes a user installs. A **directory** is a
144
+ repository checkout, which holds far more — tests, fixtures, CI config, build scratch. None of that
145
+ is installed, none of it is mounted, and none of it can act on anybody, so the directory reader is
146
+ narrowed to the set `npm pack` would produce: the `files` allowlist when the manifest declares one,
147
+ otherwise `.npmignore` or `.gitignore` under npm's defaults. The facts section names which rule it
148
+ used and how many working-tree files it skipped.
149
+
150
+ This matters more than it sounds. Reading a checkout whole means a hostile *test fixture* — a file
151
+ that ships nowhere and mounts nothing — is reported at `critical` with `certain` confidence. That
152
+ is not a conservative error; it is the tool being confidently wrong about the one tier it treats as
153
+ a verdict.
154
+
155
+ ## What it looks for
156
+
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.
181
+
182
+ ## The ceiling
183
+
184
+ **This is triage. It is not containment.**
185
+
186
+ The tool does not run in the harness process, does not gate installation, and cannot stop
187
+ anything. It raises the cost of shipping a hostile plugin and gives you something to read where
188
+ today you see nothing. That is the whole claim.
189
+
190
+ ### What is not statically decidable
191
+
192
+ 1. **`!!js` semantics.** The loader evaluates these with
193
+ `new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')` — unrestricted eval, under
194
+ `with (ctx)` scoping. Which identifiers resolve, and to what, depends on the runtime context
195
+ object. This tool reports the expression text and its syntactic class. It cannot tell you what
196
+ the expression will do.
197
+ 2. **Transitive dependencies.** One package is read. A clean package with one hostile dependency
198
+ reads as clean. The dependency list is printed as a fact for exactly this reason.
199
+ 3. **Runtime-fetched code.** Anything downloaded and evaluated after mount is invisible.
200
+ 4. **Post-install mutation of `node_modules`.** The bytes analysed are not guaranteed to be the
201
+ bytes that run.
202
+ 5. **A later version acquiring `dsh.bundle`.** Reconciliation is by *installed state*, not by
203
+ dependency diff. A package installed today as a plain library that gains a `dsh.bundle`
204
+ declaration in a patch release is mounted automatically by the next `dsh plugin update`, with
205
+ no notice. This is the most likely real-world bypass, and it means **a verdict is about one
206
+ version and only that version.**
207
+ 6. **Intent.** Tier B's `B8` is the sharpest case: the tool proves a package *can* read a
208
+ credential and *can* open a socket. It has not shown that the value flows between them, and it
209
+ cannot — that needs value tracking this tool does not do. Any telemetry library or
210
+ authenticated API client trips `B8` legitimately.
211
+
212
+ ### Every Tier B check has a one-line bypass
213
+
214
+ `ctx['pro' + 'vide']('approval', …)` defeats seam detection. A computed specifier defeats every
215
+ import check. A base64 event name defeats every listener check. Splitting a credential read and a
216
+ network call across two packages defeats `B8`.
217
+
218
+ **Tier A is much harder to hide from, because it is structured declaration rather than code.**
219
+ The harness must read `disabled: true` literally in order to disable anything, so there is no
220
+ obfuscation that leaves it working. That asymmetry is why Tier A issues verdicts and Tier B
221
+ issues capability reports.
222
+
223
+ ### And when the tool cannot read the package
224
+
225
+ If any Tier C check that says something could not be *read* fires, every Tier B confidence drops to
226
+ `moderate`, `analysis.integrity` becomes `degraded`, `analysis.negativesReliable` becomes `false`,
227
+ and the human report is **forbidden from printing "no findings"**. A clean-looking report on a
228
+ minified bundle would be worse than no report, so the tool refuses to produce one.
229
+
230
+ The honest form of a clean result is: *nothing was found at or above the threshold, in the parts
231
+ that could be read.*
232
+
233
+ ## Development
234
+
235
+ Node `^22.19.0 || >=24` and pnpm are the only requirements; there is no network and no harness
236
+ checkout in any test.
237
+
238
+ ```console
239
+ pnpm install
240
+ pnpm run typecheck
241
+ pnpm run test # unit suite
242
+ pnpm run test:coverage # same suite, with the coverage ratchet
243
+ pnpm run test:e2e # builds, then runs the real binary as a subprocess
244
+ pnpm run inspect <target> # run from source without building
245
+ ```
246
+
247
+ Hostile fixtures live in `tests/fixtures/` and are authored here — a plugin that disables the
248
+ approval row, one whose `!!js` calls `child_process`, one with a `postinstall`, one pairing a
249
+ credential read with `fetch`, one shipping a `SKILL.md` full of injection text, one declaring an
250
+ MCP stdio server, a minified one, one using the `!js` tag, one whose bundle patch path escapes the
251
+ package, and a benign control that must produce **zero** findings. They are deliberately hostile
252
+ and structurally inert; [`tests/fixtures/README.md`](./tests/fixtures/README.md) says why, and
253
+ which of them is a live prompt-injection payload you should not copy anywhere.
254
+
255
+ `tests/fixtures/execution-canary/` is the proof that nothing runs: its install scripts, its `!!js`
256
+ expressions, and its module top level all write a sentinel file, and the test asserts the sentinel
257
+ does not exist after a full analysis. `node:child_process` and the write half of `node:fs` are
258
+ mocked to throw for the whole suite, so a stray call fails the tests rather than passing quietly.
259
+
260
+ Design decisions are in [`ADR.md`](./ADR.md); the scope, catalogue, and phasing are in
261
+ [`PLAN.md`](./PLAN.md).
262
+
263
+ ## Reporting a problem
264
+
265
+ Security reports go to the address in [`SECURITY.md`](./SECURITY.md), which also says what counts
266
+ as a vulnerability in a tool whose whole job is reading hostile input. A check that fires on
267
+ ordinary code is a real defect — please open a normal issue for it.
268
+
269
+ ## License
270
+
271
+ MIT — see [`LICENSE`](./LICENSE).
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The decoded package every check tier reads from.
3
+ *
4
+ * Parsing happens once, in `inspect.ts`, and each tier receives the result.
5
+ * That keeps the tiers pure functions of already-parsed data — which is what
6
+ * makes "no analysed code was executed" a property of one small module rather
7
+ * than something every check has to be trusted about.
8
+ * @module dsh-plugin-inspector/checks/input
9
+ */
10
+ export {};