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 +21 -0
- package/README.md +271 -0
- package/lib/checks/input.js +10 -0
- package/lib/checks/tier-a.js +632 -0
- package/lib/checks/tier-b.js +411 -0
- package/lib/checks/tier-c.js +288 -0
- package/lib/cli.js +154 -0
- package/lib/cordis-yaml.js +393 -0
- package/lib/files.js +131 -0
- package/lib/index.js +22 -0
- package/lib/injection.js +90 -0
- package/lib/inspect.js +168 -0
- package/lib/knowledge.js +321 -0
- package/lib/manifest.js +143 -0
- package/lib/model.js +55 -0
- package/lib/publish.js +208 -0
- package/lib/report.js +182 -0
- package/lib/source.js +410 -0
- package/lib/types/checks/input.d.ts +42 -0
- package/lib/types/checks/tier-a.d.ts +24 -0
- package/lib/types/checks/tier-b.d.ts +23 -0
- package/lib/types/checks/tier-c.d.ts +37 -0
- package/lib/types/cli.d.ts +56 -0
- package/lib/types/cordis-yaml.d.ts +133 -0
- package/lib/types/files.d.ts +71 -0
- package/lib/types/index.d.ts +23 -0
- package/lib/types/injection.d.ts +41 -0
- package/lib/types/inspect.d.ts +31 -0
- package/lib/types/knowledge.d.ts +137 -0
- package/lib/types/manifest.d.ts +62 -0
- package/lib/types/model.d.ts +160 -0
- package/lib/types/publish.d.ts +55 -0
- package/lib/types/report.d.ts +27 -0
- package/lib/types/source.d.ts +71 -0
- package/package.json +61 -0
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 {};
|