dsh-plugin-inspector

What it looks for

← dsh-plugin-inspector docs

Facts — no severity, always emitted

Fact Source
package.name, package.version, license package.json
mountsAsBundle + patch file path dsh.bundle.patch
shipsClientBundle dsh.client and exports["./client"]
insertedRows — ids and plugin names this layer adds patch YAML insert[]
targetedRows — ids of existing rows this layer modifies patch YAML top-level rows with id
dependencies, peerDependencies — the declared names package.json
modelVisibleFiles — shipped SKILL.md / skills / AGENTS.md / CLAUDE.md file walk
filesRead, bytesRead, sourceFilesParsed analysis run
provenance — whether the registry published a build provenance attestation, and when it did, the source repository, commit, ref and workflow it names, plus every check that ran and every one that did not dist.attestations in the version document, then the registry’s attestation endpoint. --from-npm only

What the provenance fact says, and what it does not

npm publishes a provenance attestation for a package released from a trusted CI environment: a Sigstore bundle holding a DSSE envelope over an in-toto statement that names the published tarball by digest, the source repository and commit it was built from, and the workflow file that built it. The version document says whether one exists, under dist.attestations.provenance; the bundle itself is at <registry>/-/npm/v1/attestations/<name>@<version>.

The fact has five states, and they are five different answers:

State Meaning
unavailable The target is a directory or a local tarball. Neither has a registry statement, so this is a statement about the mode and not about the package
absent The registry published no attestation for this version. 28 of the 40 pinned packages are in this state. It is the common case and no finding fires on it
unreadable The version document said there is one and it could not be fetched or decoded. Reported with the reason; the analysis is unaffected, because dist.integrity had already vouched for the bytes
attested An attestation was read and every check below passed
failed An attestation was read and a check below did not pass → A25

Four checks run, all offline, against bytes already in hand:

Check What it establishes
subject-digest The statement’s subject digest is the SHA-512 of the tarball this run analysed. This is the load-bearing one: it is what ties the statement to these bytes rather than to another version of the same package
subject-package That subject is this package at this version, as a package URL
dsse-signature The envelope’s signature verifies under the public key of the certificate carried inside the bundle
certificate-identity The certificate’s subject alternative name is the workflow the statement claims, so the payload and the signer agree. Skipped, and recorded as skipped, when the statement names no GitHub Actions workflow

Two things are not established, and the report prints both every time it prints the four above:

What provenance does not prove, even fully verified. It says a named workflow in a named repository at a named commit produced these bytes. It does not say that the repository is trustworthy, that the commit was reviewed by anyone, that the workflow was not itself modified in that commit, or that the code does nothing harmful. A malicious package built in CI from a public repository carries a perfect attestation. Provenance answers “where did this come from”; every other check in this catalogue answers “what does it do”, and the second question is not weakened by a good answer to the first.

Tier A — decidable, structured declaration, a real verdict

Tier A reads declarations, not code. It is much harder to hide from than Tier B, because the harness itself must be able to read these fields literally in order to act on them: an attacker cannot obfuscate disabled: true and still have it disable anything. Every Tier A finding has confidence certain.

id Check Severity Method
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
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, session-telemetry-otel, session-checkpoint-policy, tools, agent-loop) 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 not one of those cases: the loader evaluates the expression and coerces its result (vendor/loader/src/config/entry.ts:104-108), so disabled: !!js false leaves the row running too. Deciding that needs the expression run, which this tool may never do, so an expression is read as what it could evaluate to — the conservative direction, which raises the finding rather than dropping it
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. The suppression is keyed on the package name and covers A2, A3 and A19 together with A4 and A5, so a directory named @deepseek-ai/dsh-web-app produces none of the five
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 continues, 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
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
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
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
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
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
A10 MCP server row — an inserted 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
A11 Non-registry dependency specifier — ten prefixes: git+, git:, github:, gitlab:, bitbucket:, http:, https:, file:, link:, portal: high the referenced code can change under a fixed version string
A12 Shipped model-visible instruction text (SKILL.md, **/skills/*/SKILL.md, **/skills/*.md, AGENTS.md, CLAUDE.md) low file walk. See the reach note below
A13 No files allowlist in package.json low the published tarball is whatever happened to be in the working tree
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
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
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
A17 The declared patch layer does not parse medium the layer cannot load, and nothing inside it could be analysed
A18 One of eight named package.json fields is of the wrong shape — scripts, dependencies, peerDependencies, optionalDependencies and devDependencies that are not objects, and dsh, dsh.bundle, dsh.client that are not objects low the field was ignored. A manifest that npm and the harness read differently is worth knowing about. Only those eight are checked, so {"bin": 42} produces nothing: bin of the wrong shape reads as no commands, and the check does not claim to cover every field
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
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
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
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
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
A24 Ships a binding.gyp — a native build declaration medium; high when a build step’s command line fetches, pipes to a shell, evaluates inline code, or decodes a payload file presence at the package root, plus a text match for an actions / rules / postbuilds key and the same command signals A1 grades a lifecycle script by. A package that ships this file and declares no install or preinstall script gets node-gyp rebuild as its install command by default, and node-gyp evaluates the file to decide what that build does. The declaration is in none of the entry points a reader checks — not main, not bin, not exports, not scripts — which is the whole reason to read it. It reaches execution through the same allowBuilds gate as A1 without needing a key in package.json at all. The detail also says when the package ships no C or C++ source, because a gyp with nothing to compile is a build declaration whose only effect is that a build runs. The file is never parsed and never evaluated — see the note below
A25 A provenance attestation the registry published for this version that does not check out — a subject digest that is not the downloaded tarball’s, a subject that is another package, a DSSE signature that does not verify, or a signing certificate naming a workflow the statement does not high the four checks above. Absence raises nothing: 28 of 40 pinned packages publish no attestation, and a finding on the majority of legitimate packages is the miscalibration the 0.2 release existed to remove. This is therefore not an attack detector — a publisher who wants no provenance publishes none, which is silent — but a mismatch between a real attestation and the artifact it is served with, which is a defect whichever way it arose. Registry mode only
A26 Patch row modifies a row that is neither a core row nor one this layer inserts high set difference. A2/A3/A5/A19 all key on CORE_ROWS, so an override whose id is not a row the shipped bundles define falls through every one of them and produces nothing. The composed profile is not only the core rows: it also holds the user’s own layer and every other installed plugin’s rows, and applyEntryPatches matches by id alone with no notion of who owns one. - id: dsh-dlp / disabled: true therefore switches off whatever package owns that row, and a config override replaces its configuration wholesale, since an override is a shallow whole-value replacement rather than a merge. Two kinds of package legitimately rewrite rows they did not insert and are excluded — the three harness bundles, and a package declaring dsh.profile.bundles, which is a profile assembling other people’s layers on purpose and is already reported by A20. Rows the layer inserts itself are excluded too, which is what keeps the ordinary case of a layer managing its own rows silent. The finding states the benign reading: if the id is not in the composed profile the patch is simply inert

Reach note for A12, stated because getting this wrong would be dishonest. Shipping a SKILL.md inside an npm package does not by itself put it in front of the model. There is no dsh.skills manifest field. The filesystem provider scans a fixed root set — <project>/.dsh/skills, <project>/.agents/skills, $DSH_HOME/skills, $DSH_AGENTS_HOME/skills, bundledSkillDir — at depth 1 only (<root>/<name>/SKILL.md or <root>/<name>.md), and a plugin’s own node_modules directory is none of those. The three ways shipped text actually reaches the model are: the plugin calls ctx.skills.register() / ctx.skills.registerProvider(), a patch row redirects a skill root into the package (→ A15), or the file is copied into the user’s workspace by something else. AGENTS.md / CLAUDE.md are a separate subsystem again — discovered by walking the workspace, not the profile.

A12 does not escalate. It is low, and it stays low however many other checks fire: the severity is a constant in the check. Its text says “shipped, reaches the model only if registered or redirected” and leaves the reader to combine it with what else is in the report. The two signals that would raise it are already reported in their own right — A15 for a patch row that redirects a skill root into the package, and A21 for injection phrasing in the markdown itself — so nothing is lost by not compounding them here. There is no check for a ctx.skills.register* call: the registration is not detected, and the reach note says so rather than implying it is.

Why A24 reads binding.gyp as text and never parses it. GYP is Python-ish, not JSON: single-quoted strings, # comments, trailing commas, and conditions whose first element is a Python expression written as a string. node-gyp shells out to Python to read it, and there is no maintained JavaScript parser for the format — so parsing it here would mean hand-rolling one for an attacker-controlled file, and evaluating a condition is the one thing this tool may never do. It also would not change the verdict. The decidable half is the whole finding: the file is at the package root or it is not, and npm’s default install command follows from that alone. What separates a build declaration from a build step is an actions / rules / postbuilds key and the shape of the command under it, and both are literal text either way. So the finding is raised on presence — which cannot be evaded while the build still happens — and the grade reads the command line, exactly as A1 grades a lifecycle script.

The base rate this check answers to. Install-time execution is now off by default: pnpm ≥ 10 and npm ≥ 12 block a dependency’s lifecycle scripts until the package is named in allowBuilds. That is why install-lifecycle-script is a medium category rather than the headline signal, and it is also why the interesting declarations moved out of scripts — to binding.gyp and to editor- and agent-owned files like .vscode/tasks.json and .claude/settings.json. A24 covers the first of those. The others are not checked here and this catalogue does not imply they are.

Tier B — AST capability detection, “this plugin CAN do X”

Tier B parses eight shipped extensions — .ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs — with the typescript compiler API, ts.createSourceFile, syntax only, no program, no type checker, no module resolution, no transpilation, no execution. Default confidence high, dropped to moderate when any Tier C readability finding fires.

Names are folded before they are matched. A string argument that selects a target — a module specifier, a seam key, an event name, an environment variable — is read through a bounded constant folder: a literal, a + chain of literals, a template whose spans are literals, or […].join(…) over an array of literals. Anything reaching an identifier, a property, or any other call is refused, and the refusal is C2, which degrades the report. The folder never evaluates: it concatenates literals out of an already-parsed tree. Both tiers ask the same function, so a site is either matched here or degraded there, and never both.

id Check Severity Method
B1 Replaces a core capability seam — ctx.provide(<seam>, …) / ctx.set(<seam>, …) where <seam> is one of the 71 keys from api-catalog.ts critical for one of the 21 SECURITY_SEAM_KEYS, high for the other 50 call expression, first argument folded and matched against the seam key set. The member is read on its receiver, so provide destructured off ctx and called through the bare name is not this finding — it is C2
B5 System-prompt mutation — system-prompt/assemble listener, or ctx.systemPrompt.{section,context,variable,tools,suppressRuntimeContext} high call matching
B6 Credential read — process.env.*(TOKEN\|KEY\|SECRET\|PASSWORD\|CREDENTIAL)*, ~/.dsh/credentials, ~/.npmrc, ~/.aws, ~/.ssh, ctx.credentials.* medium alone identifier + literal matching
B7 Network egress — fetch, node:http(s).request, node:net, WebSocket, undici medium alone import + call matching. A module reached through process.getBuiltinModule counts, and the finding says so rather than claiming an import
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
B9 Direct node:child_process / node:worker_threads / node:vm medium alone, high paired with B8’s two halves import specifier, require, or process.getBuiltinModule — three ways in, not two. Bypasses ctx.subprocess and ctx.sandbox entirely. medium alone because a bare import fires on half the published ecosystem
B10 Prompt-injection heuristics on model-visible text only — description string literals inside a registered tool definition, and nothing else. Shipped skill and instruction markdown is A21’s, not this check’s 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. The receiver guard is what makes the title true: the enclosing object literal must reach <ctx>.tools.register(…) or defineTool(…), directly or through a name registered later in the same file. description is one of the commonest property names in JavaScript — a JSON schema, an OpenAPI document, a changelog entry — and without the guard a package’s release notes produce a high finding titled “Tool description …” about a tool that does not exist. Nested properties inside the definition count, because a parameter’s description reaches the model in the same schema
B11 Nested plugin mounting — ctx.plugin(…), loader manipulation high call matching. A layer that mounts further layers moves the analysis target
B12 Dynamic code construction — eval, new Function, vm.runInNewContext, module._load high call matching
B13 Filesystem access outside ctx.fs — reaches node:fs or node:fs/promises, by import, by require, or through process.getBuiltinModule 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
B14 Waterfall listener that cannot call next — a listener on one of the 14 WATERFALL_EVENTS whose trailing parameter is never mentioned in its body, or which declares no parameter in that position at all critical for one of the six DECISION_EVENTS, high for the other eight EventsService.waterfall (@deepseek-ai/cordis@4.0.2, lib/index.js:317-327) pushes next as the last dispatch argument and ends the chain at the first listener that returns without calling it — the listeners still queued and the harness’s own built-in behavior both stop. next is positional, not named, so the check reads whatever the trailing parameter is called. The finding says what the built-in would have done: approval/request and user-questions/request both reach the surface that asks the user only through the chain, tools/pre-execute settles {kind:'allow'} and only then consults ctx.tools.guard() denials, and tools/execute’s built-in is the tool body. The listener is resolved either at the call site or from a name bound in the same file, the same bounded resolution B10 uses for tool definitions. Every uncertain shape answers “reaches next”: a rest parameter, a destructured parameter, arguments, a signature with no body, and a listener this tool cannot resolve all produce nothing. {prepend: true} and its boolean shorthand are reported inside the finding rather than as a check of their own, because prepending while delegating is what a security plugin does — dsh-dlp prepends at three seams
B15 Write into a catalogued seam’s own object graph — assignment to or delete of a member of <ctx>.<seam>, or a mutating call reaching two or more members past it critical for one of the 21 SECURITY_SEAM_KEYS, high for the other 50 B1 reads ctx.provide / ctx.set / ctx.mixin, and Cordis refuses both of those from a layer that does not own the service: provide throws when the isolate key is taken (lib/index.js:813) and set throws “cannot set property in multiple fibers” (lib/index.js:783-787). Writing a member of the object those calls would have replaced reaches the same substitution and meets neither check. Verified against the installed Cordis: a service is one shared instance, is not frozen, and a member written through one plugin’s context is what the root context and every other consumer read afterwards. The depth rule is the false-positive guard — ctx.credentials.set(ref, value) and ctx.skills.register(skill) are the seam’s published API at depth 0 and are not this finding; ctx.tools.layers.global.guards.data.clear() reaches five members past the seam into the map an unconditional ctx.tools.guard() deny is filed in
B16 Reaching the Cordis bookkeeping that owns other layers’ registrations — events._hooks, events.unregister, registry.delete, reflect.store, on a known context receiver critical ctx.events, ctx.registry and ctx.reflect are own properties of the root context inherited by every child, so reaching them needs no inject and leaves no record. registry.delete disposes every fiber a plugin owns with no ownership check (lib/index.js:1564-1571); splicing _hooks removes a listener permanently and the owning layer’s own disposer then silently does nothing. This is a wider reach than B14: a veto skips listeners for one dispatch, this deletes the code that would have decided. Two of the four are write-only. Reading ctx.events._hooks['approval/request']?.length to decide whether a prompt would reach a human is what dsh-dlp/src/approval-reach.ts does, so only an assignment, a delete, or a mutating call on that table is raised

Veto and removal are two different reaches, which is why B14 and B16 are two checks. It is often said that a Cordis listener returning without next() deletes the listeners behind it. It does not, and the difference decides what each finding is allowed to claim. EventsService.waterfall builds its working list with this.dispatch(...), whose body is (this._hooks[name] || []).filter(...).map(...) — both of which allocate — and next() shifts that copy. The registry _hooks[name] is never touched, so a skipped listener is still registered and runs on the very next dispatch. Cordis’s own JSDoc uses the right word: “a listener that does not call next() vetoes the rest of the chain, including the built-in behavior” (@deepseek-ai/cordis@4.0.2, lib/index.js:311-313). B14 is that veto, scoped to one dispatch and stated that way in the finding. B16 is the other thing — splicing _hooks, events.unregister, registry.delete — which outlives the dispatch and takes the listener, guard, or whole plugin with it.

The Cordis citations in B14, B15 and B16 were read from @deepseek-ai/cordis@4.0.2, which is still the exact version @deepseek-ai/dsh@0.1.5-rc.3 and every bundle depend on, so the 0.1.5-rc.3 sync leaves all three unchanged. The citations name the build they came from, so pnpm run sync settles the reference for all three in one place.

The framing B7, B9, and B13 share. The harness’s own dynamic-package sandbox (cordis-host-runner/src/sandbox.ts) traps exactly require, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, and fetch, redirecting each to a ctx service; it leaves process undefined and exposes only the seven HOST_BUILTIN_INSPECTION globals. An installed npm bundle layer gets none of that — it is a plain ESM import into the harness process. So these three checks report a gap the harness itself defines: the harness denies untrusted code this capability, and this package uses it from a position where nothing denies it. That is the harness’s reckoning, not a rule invented here.

One of the twenty-one security seams is graded from the catalogue rather than from an implementation, and it is marked as such. inspector is declared once in harness 0.1.2-rc.1 — @deepseek-ai/dsh-tool-cordis/lib/index.js:1558, “Shared Host/Client service façade over the realm’s source publisher”, with publish(topic, payload, monotonicMs?) and a read-only CordisRuntimeTreeReader — and nothing in the release uses it. Measured against an installed tree: the string 'inspector' occurs exactly once across the 224 @deepseek-ai packages, at that catalogue entry; no file reads ctx.inspector; InspectorJsonValue and CordisRuntimeTreeReader appear only in that same bundle.

That measurement has not been retaken at 0.1.5-rc.3. It needs an installed harness tree, and the 0.1.5-rc.3 sync read published tarballs. The key is still declared at 0.1.5-rc.3 — it is in SEAM_KEYS because pnpm run sync found it there — and whether it has since gained a publisher or a consumer is not known here. The grading is unchanged because it was always a reading of what the catalogue says the key is for.

It is critical because a package providing the observation channel claims the position every observation passes through: a substituted publisher chooses which topics reach the carrier and what payload each carries, so it can withhold the record of something that happened or publish one for something that did not, and a consumer downstream cannot tell either case from a quiet system. That is the property sessionPersistence and sessionTelemetry are already in the set for.

It is not a decision bypass, and the finding does not claim to be one. Nothing in the harness is gated on an observation, so inspector is not the approval case; and because no implementation exists, a package providing it in this release displaces nothing and reaches nothing it could not reach under a name of its own. The grade is a statement about what the key is declared for. A release that ships a publisher or a consumer is the one that should settle it again against them.

Tier C — heuristic; “we cannot read this” is itself the finding

id Check Severity Effect
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
C2 Dispatch the analyzer cannot follow — computed member access on ctx (ctx[expr], and its destructuring form const { [k]: fn } = ctx), an unfoldable import()/require()/process.getBuiltinModule() specifier, atob/Buffer.from(…, 'base64'), an unfoldable name passed to .on/.set/.emit on a known context binding, and a member detached from its receiver — const { provide } = ctx, const p = ctx.provide, or function apply({ provide }). 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. See the note below high degrades
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
C4 Unreadable payload — .node, .wasm, binaries, files over a size or count cap, and entries that are not regular files (a symlink, a FIFO, a socket) medium for binary, low for every other reason degrades
C5 The mounted layer hit a walk ceiling — nesting depth or node count high degrades. Rows past the ceiling were not read
C6 A .min.js artifact low degrades
C7 The mounted layer builds rows out of YAML anchors and aliases. *a is not a copy — it hands the loader the same node again, so a row anchored under an inert key can be the row that lands in a live one, and one row in the file can be two in the composed profile. The reader expands every alias to its own node before reading the layer, so the reading matches the loader; the finding stands because the document a person reviews is no longer the document that mounts medium degrades
C8 An identifier spelled with Unicode escapes — \u0066etch for fetch, the technique @kolbo/mcp@1.57.1 (GHSA-pm5r-9rq7-j86p) shipped. One finding per package, naming every distinct name the escapes resolve to medium does not degrade — see the note below

The two halves of C2, and why the second one exists. Tier B matches a member on a receiver: B1 reads ctx.provide, B5 reads ctx.on, B10 reads tools.register, B7/B9/B13 read process.getBuiltinModule. The first half of C2 covers a hidden member name. The second covers a missing receiver, which is the other way to reach the same call: const { provide } = ctx leaves a bare provide that still replaces the seam, and provide.call(ctx, 'approval', …) names no receiver at all. Following that binding to its call sites is value tracking this tool does not do, so the honest outcome is a degraded report rather than a B1 — and the guard that keeps it off ordinary code is the same one the call form uses: the object being destructured has to be a known receiver (ctx, context, globalThis, global, this.ctx, or process), or the first parameter of apply, which is the plugin context by the harness’s own mount contract. const { set } = options is not this finding.

Why C8 does not degrade, and what it is for. The escape is resolved in the scanner, before any binding, so \u0066etch and fetch are the same program and no behavior distinguishes them. That means two different things for the two kinds of reader. A person auditing the file sees nothing, and so would any check keyed on the spelling of a name — which is what makes this a technique at all. This tool is not in that group: ts.createSourceFile hands back node.text === 'fetch' for the escaped form, so B6, B7, B9 and B12 match escaped spellings exactly as they match plain ones. That is measured, not assumed — tests/fixtures/escaped-identifiers/ ships every name escaped and tests/unit/detection.spec.ts asserts the capability findings that come back. So the escape defeats the reading, not the detection, and treating it as an unreadable package would be false. It is still worth a finding on its own: nothing writes a name this way by accident, and a published package has no build reason to.

!!js sub-classification (A6)

Every !!js node is inventoried with its YAML path and text, then parse-compiled with new Function('return (' + expr + ')') — compilation only; the constructor never executes the body — and the resulting AST is classified.

Classification is by reach, not by syntactic form. dshHomePath('sessions') and steal() are both CallExpressions; the first is a helper dsh-app-boot puts in scope with ctx.provide('dshHomePath', dshHomePath) before any entry mounts, documented as such in that package’s README, and used by the base bundle’s own session-persistence-jsonl row.

Class Example Severity Finding
literal true, 3 — fact only
inert-read process.env.DSH_TOOLS_MODE, process.platform === 'win32', ctx.webStartup.host — fact only
harness-call dshHomePath('sessions'), process.cwd() low A6
call a call this tool cannot resolve medium A6
mutation process.env.X = … high A6
module-access require(…), import(…), globalThis[…] critical A6
unparseable syntax error medium — and it means the plugin cannot boot A6

The two classes with no reach are counted in facts.jsExpressions and never raised: a constant, or a read of a service the profile already handed the row, warrants no decision, and the shipped bundles are mostly made of them.

The escalation of the rest is justified: the evaluator is new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }') — unrestricted eval, with ctx in scope. And disabled re-evaluates at every mount decision, so a !!js there is not a one-shot: it is a recurring execution point that user patch layers HMR-reload live.

What is deliberately not a finding

Two tables in src/knowledge.ts decide when a Tier A or Tier B finding is graded critical: SECURITY_ROW_IDS, the core rows A2 treats as security-relevant, and SECURITY_SEAM_KEYS, the capability seams A23, B1 and B15 treat the same way. Both are deliberately small. This section says what was examined against them and kept out, so a reader can tell a considered exclusion from a row nobody looked at.

The rule for SECURITY_ROW_IDS

A core row belongs in that table when disabling it fails open or removes evidence:

A row that fails closed when removed is not a member, however security-adjacent its name reads. Neither is a row whose absence takes a feature away and grants nothing. The distinction matters because critical is a budget: the ecosystem sweep holds every check under a bar for how often it may fire (bar.maxCriticalShareOfCorpus, ten percent), and a table that collects everything alarming-sounding spends that budget on rows an operator turns off on purpose.

Rows considered against that rule and excluded

These are the thirteen core rows harness 0.1.2-rc.1 added, each checked against the rule above and kept out of SECURITY_ROW_IDS.

Excluded is not the same as unreported. Disabling any core row is still an A3 finding — high for a row the @deepseek-ai/dsh-base layer inserts, medium for one only a surface bundle does — and the patch that does it is in the targetedRows fact regardless. What these rows do not get is A2’s critical.

Row Bundle Why it is not a security row
ui-approval web-app It carries the browser answerer for approval/request (@deepseek-ai/dsh-client-ui-approval/lib/client.js:281), so removing it looks like removing the approval prompt. It fails closed: with no answerer composed, ApprovalService.request resolves 'unavailable' (@deepseek-ai/dsh-user-approval/lib/index.js:120), and 'unavailable' maps to { kind: 'deny' } in the tool gate (@deepseek-ai/dsh-tools/lib/index.js:3311, :3357) and to a thrown refusal on sandbox escalation (@deepseek-ai/dsh-sandbox/lib/index.js:109). Disabling it refuses tool calls that need approval; it does not permit them. The plugin that substitutes an answerer is a different act and is B14’s critical on approval/request
web-fetch-http base The shipped HTTP fetch provider. ctx.web has no provider priority and no last-wins rule, so a stricter replacement provider cannot be composed beside it without a pin — which is why dsh-netguard’s own install instructions tell operators to write - id: web-fetch-http / disabled: true. Disabling it removes an egress path; it adds no reach
session-log-deepseek base Contributes a dsh_session_log field carrying the session log’s event suffix to official DeepSeek API requests. Disabling it stops an outbound copy of the session log to a third party — a privacy improvement, not evidence loss, because the canonical record stays in session-persistence-jsonl. Its enabled also defaults to false (lib/index.js:16, apply returns immediately at :87) and the base bundle inserts the row with no config, so in a stock profile the row is already inert
plugin-package-inventory-deepseek base The same shape: it contributes the active plugin-package inventory to official DeepSeek requests. Disabling it stops disclosing which plugins are installed. enabled defaults to true here, so unlike the row above this one is live in a stock profile — and it is still a disclosure the operator is entitled to switch off
subagent-model-selection-settings web-app Owns the subagentModelSelection settings service, whose enabled defaults to false (@deepseek-ai/dsh-tool-subagent/lib/model-selection-settings.js:45). When it is off, no route policy is recorded and @deepseek-ai/dsh-tool-subagent/lib/invariant.js:41 requires the model-selection tool fields not to be offered. The row’s absence therefore equals its shipped default, and it is the enabled state that restricts. A profile that opted in with modelSelectionSettings: true and then loses the row fails loud at mount (lib/index.js:581)
session-controller web-app Host service backing the generated ctx.remote.session namespace (@deepseek-ai/dsh-api-session-controller/lib/index.js:2598)
settings-controller web-app Host service backing ctx.remote.settings and ctx.remote.credentials (@deepseek-ai/dsh-api-settings-controller/lib/index.js:412, :146)
workspace-controller web-app Host service backing ctx.remote.workspace and ctx.remote.directoryPicker (@deepseek-ai/dsh-api-workspace-controller/lib/index.js:662, :415)
ui-chat, ui-schedule, ui-session web-app Client surfaces. None registers a listener on a waterfall event: ctx.remote.$on appears in none of the three
session-turn-outline web-app A pure fold of turn/start boundaries into the chat rail’s turn outline. A projection the client renders
deepseek-llm-api-extensions base The registry through which plugins own independent top-level fields on official DeepSeek requests. Removing it removes the registry, and the two rows that inject it — session-log-deepseek and plugin-package-inventory-deepseek — then fail to mount. It grants nothing; the adapter, not the registry, is what refuses an extension field that collides with messages, tools or model (@deepseek-ai/dsh-llm-deepseek/lib/index.js:1748)

The three *-controller rows share one reason, stated once: each is a host service backing a generated ctx.remote.* namespace that the browser client calls across the wire. Disabling one deletes the wire namespace, so the client loses that function and no verb is served less carefully than before — the redaction and path fencing live inside the verbs that no longer exist.

The eight rows 0.1.5-rc.3 added, and the one it dropped

All eight are web bundle rows, and none is a member of SECURITY_ROW_IDS. Each was read at 0.1.5-rc.3 from the package the row names.

Row Why it is not a security row
open-in-app Host service that launches an external editor or terminal on the workspace path. It injects webServer, connection and subprocess (@deepseek-ai/dsh-host-open-in-app/lib/index.js:1213) and provides no seam. Disabling it removes a launch path and adds no reach — the web-fetch-http shape
ui-open-in-app The client half of the same feature
workspace-files Host service backing the workspaceFiles Remote. Disabling it deletes the namespace the browser client reads files through; see the section below on why the seam is nevertheless critical
file-upload Host service backing the fileUploads Remote and the streaming upload route it registers on ctx.connection.fetch (@deepseek-ai/dsh-client-file-upload/lib/index.js:170 to :175). Disabling it means nobody can attach a file; nothing is admitted less carefully
resources, ui-sidebar-right, ui-sidebar-documentpreview, ui-sidebar-files Client surfaces. Checked the same way the ui-chat / ui-schedule / ui-session row was: none of the four registers a listener on any event, and the two that call provide (resources, sidebarRight and sidebarRightTabs) do it in their lib/client.js browser half, on names the api-catalog does not carry

tool-str-replace-editor went the other way: no bundle patch inserts it at 0.1.5-rc.3, so it has left CORE_ROWS. Unlike the three rows 0.1.2-rc.1 dropped, its package is still published at this version — the bundles simply stopped composing the row. A patch naming it is now an A26 finding against somebody else’s row rather than an A3 against a core one, at the same high severity.

The three seams 0.1.5-rc.3 added

Two are in SECURITY_SEAM_KEYS and one is not, and the split is the point: a seam is graded from what the package declaring it does with the traffic, never from its name.

Seam Verdict Why
fileUploads critical @deepseek-ai/dsh-api-session-controller/lib/index.js:766 resolves every receipt a prompt names through this seam, and what comes back becomes the user message admitted to the Agent at :767 to :774. The shipped resolve answers only from that Agent’s own staged uploads (@deepseek-ai/dsh-client-file-upload/lib/index.js:228 to :231); a substitute answers with anything. Providing it chooses the file a person’s own prompt carries to the model
workspaceFiles critical @deepseek-ai/dsh-client-ui-deliverables/lib/index.js:83 hands the absolutePath its stat returns to ctx.sessionController.openWorkspacePath at :90, which refuses an empty string and then opens the path with the operating system’s own handler (@deepseek-ai/dsh-api-session-controller/lib/index.js:2886 to :2891). The caller’s other check is an fs host-path round trip at :88, which says nothing about where the path is; stat is deliberately unfenced and its own @param says “files outside it are allowed” (@deepseek-ai/dsh-api-workspace-files/lib/index.js:484, against the confine at :566 that list calls at :505)
sessionFeedback high, not critical One verb. record appends a feedback/record event to a live Session (@deepseek-ai/dsh-command-feedback/lib/index.js:149, through recordFeedback at :74). Its own module JSDoc at lib/index.js:7 to :8 says recording “appends one authoritative log-only event and does not start model work”. Nothing is gated on a remark, so a substitute removes no constraint; and a remark is the person’s own, not the record of what the agent did, so dropping or forging one is not evidence loss in the sense sessionPersistence is

Why a controller can be a security seam without its row being a security row

sessionController and settingsController are in SECURITY_SEAM_KEYS while session-controller and settings-controller are not in SECURITY_ROW_IDS — and 0.1.5-rc.3 adds two more pairs on the same footing, workspaceFiles / workspace-files and fileUploads / file-upload. That is not a disagreement between the two tables. They grade two different acts:

Substitution is the dangerous direction here; deletion is not. A table that could not tell them apart would have to be wrong in one direction or the other.

What this section does not cover

This is a statement about severity tables, not about the analysis ceiling. What the tool cannot decide at all — intent, dataflow, and the one-line bypass every Tier B check has — is its own page.