@jieai/dsh-plugin-vet — The security gate for DSH plugins
English | 中文
🔗 dsh.so plugin submission & security-report pages run vet-led scanning — view
🌐 Landing site: https://wulun811.github.io/dsh-plugin-vet/ — features, architecture, live dashboard numbers, trust boundaries and known limitations (bilingual, dark mode). Source in site/.
Audit before install, guard at runtime. Run every DSH plugin through dsh-plugin-vet before mounting it:
static rules produce a verdict (deterministic, unforgeable), the agent investigates sensitive points and
quality issues following the vet-audit-protocol skill (no one can substitute for that), and a final
scorecard is handed to a human/model to decide.
Positioning: a monitoring alarm, not an enforcer. vet only does "check → alarm → advise": checks at
write time (static scan), watches at run time (runtime guard), and surfaces alarms (scorecard + GUI shield
status light). In the default configuration vet never acts on your behalf — it never auto-uninstalls,
never kills processes, never rewrites configs, and blocks nothing. Interception exists only in explicit,
documented scopes: the N7 confirmation block wakes together with the runtime guard (confirmBlock —
credential-file deletion/overwrite and post-confirmation destructive ops throw once runtimeGuard: watch
is on, incl. when the hardened tier or the shield toggle enables it), and deny mode / the paranoid
tier roll back plugin loads and block per threshold. Every interception scope is deployer-visible and
documented below; none is part of the default product identity. The final disposition is always decided by
the user on their own DSH.
@jieai/dsh-plugin-vet is the trust-layer plugin in the deepseek-harness ecosystem: it occupies the whole
download → scan → audit → score → decide → runtime watch trust pipeline. The runtime watch ships built-in
honeypot lures: anyone quietly rifling through key files gets caught red-handed (opt-in, honeypot.enabled).
It does not provide a plugin marketplace itself (catalog/distribution).
Screenshots
vet shield panel (light theme)
vet shield panel (dark theme)
Notable changes since 0.1.x
If you're upgrading from 0.1.12 or earlier, here's what changed:
- 0.1.13-0.1.15: Landed the NEXT-GEN-PLAN (N1-N6): hidden capability detection (N1), upgrade behavioral diff (N6), anti-obfuscation decoding, environment snapshot tamper-proofing.
- 0.1.16: Security hardening batch: bundle-ized entry (C1, closes the
require(absolute-path) attack surface), ESM blind-spot explicit coverage (C2), content-baseline integrity (M7).
- 0.1.17-0.1.19: Bug fixes and noise reduction: npm pack integrity check, rc.8 subpath entryName handling, session-log deletion silence, DSH install-tree exemption widened.
- 0.1.20: Defense statistics panel (see how many plugins you've protected), startup file existence check, esm-guard-coverage dedup, upgrade-cold linked to audit records, red upgrade-diff now tells you to re-run audit protocol.
- 0.1.21: Self-scan trust annotation — when vet itself is scanned (
scan_plugin target=package, self-dogfooding on dsh.so), the result now carries a selfScan Trusted card instead of raw radar-style criticals: declaration-bound capability downgrade (only declared capability tokens are exempted; any undeclared outbound host / env var / credential path / IPC primitive stays red), per-version artifact pin (vet-self-pins.json, publish-bound — upgrades don't false-flag and swapped bytes fail the pin), and a publish gate rejecting releases with used-but-undeclared capabilities. The raw scan (all findings) stays fully visible. Details: docs/ARCHITECTURE.md §5.12. round-16 additions: the pin now covers the shipped artifact (lib/** + root manifests + docs/**) so production installs (tarball = lib only) reach Trusted instead of being permanently dev-tree, and byte-matching any published pin counts as pinned-match — the upgrade window no longer makes two vet instances distrust each other; official @deepseek-ai/* packages are now statically scanned even on first-seen (only deny escalation is exempted — the hash baseline alone cannot stop name-spoofed tarballs).
If you were only using static scans before, enabling runtimeGuard: watch now gives you the full defense stack: T1 sentinel (memory/fd/child-process monitoring) + T2 hooks (fs/child_process/network interception) + N7 confirmation blocking.
Installation
dsh plugin --profile <profile> add @jieai/dsh-plugin-vet
Install-and-activate chain: pnpm install → reconcilePlugins reads dsh.bundle → on next start loadProfile
resolves the bundle and mounts the plugin. Default configuration is in the Config section below
(fail-open in the default configuration: reports only, blocks nothing — interception wakes only with
explicit config, see confirmBlock / mode / the hardened-and-above tiers below).
Local tarball install (offline or verify-before-release scenario):
dsh plugin --profile <profile> add ./jieai-dsh-plugin-vet-<version>.tgz
# or unpack directly into the profile's node_modules:
# tar -xzf jieai-dsh-plugin-vet-<version>.tgz -C ~/.dsh/profiles/<profile>/node_modules/@jieai/
// and add an insert mount entry in the profile's cordis.patch.yml:
// - insert:
// - id: plugin-vet
// name: '@jieai/dsh-plugin-vet'
// config:
// mode: report
// autoScan: true
Paths / relative paths / URLs all work (dsh plugin add falls back to pnpm's file: protocol; a local tgz is
resolved directly).
First-install time note: the first dsh plugin add into a large profile can take several minutes — during
that time pnpm does a full dependency resolution, updates the lockfile for 500+ packages and runs supply-chain
policy validation over the whole dependency tree (vet itself carries only 3 runtime dependencies; the bulk of
the time is parsing/validating the profile's existing tree, not vet). Subsequent installs/updates take seconds
(validation results are reused).
Compatibility: vet targets DSH 0.1.0-rc.6+ (peers: @deepseek-ai/cordis ^4.0.1, dsh-* ^0.1.1-rc.1;
verified against npm-public 0.1.1-rc.2 in round-15 and re-verified in the follow-up review). pnpm may warn about unmet peer
dependencies — this is expected: profile templates set autoInstallPeers: false, and at runtime the packages
resolve from the DSH install closure ($DSH_HOME/profiles/node_modules fallback layer); you neither need nor
should install another copy of the cordis family in the profile.
npm-public DSH (0.1.1-rc.2+): a profile loads plugins from dsh.profile.bundles in the profile's
package.json (boot composes bundle layers + cordis.patch.yml + $DSH_HOME/cordis.patch.yml). After
dsh plugin add, add the package name to that bundle list (or insert it with a patch - insert: row) —
otherwise the package is installed but not mounted. vet's config block uses the same row-id form
(- id: plugin-vet / config: …). Guarded paths/tests are unchanged.
Watch scope = the profile vet is installed into. vet's guards are in-process events
(internal/plugin) — whichever profile vet is installed into is the one whose loaded plugins it guards.
For multi-profile deployments, install vet into every profile you want guarded
(dsh plugin --profile <name> add @jieai/dsh-plugin-vet) and point requireAudit at the matching profile's
cordis.patch.yml.
Config (cordis.yml)
| Key | Default | Description |
|---|
profile | standard | Safety tier (0.3): standard = current defaults (lowest noise); hardened = wakes dormant capabilities (runtime guard, third-party baseline, honeypot; R17/R18/R19 observations surface as yellow); paranoid = hardened + strictest blocking (requireAudit, denyOn: suspicious, N7 family 3/4 block). Presets only override keys still at their default; explicit settings (incl. panel-toggle writes into the patch) always win; verdict semantics never change — see "Safety tiers" below |
mode | report | report reports only, never blocks; deny explicitly enables blocking |
autoScan | true | Automatically static-scan new plugins (internal/plugin) |
scannerTimeoutMs | 15000 | Static-scan subprocess timeout |
requireAudit | false | Audit gate (opt-in, third-party only — official @deepseek-ai/* packages are governed by content-hash baseline + static scan instead, round-17): when enabled, loading a third-party plugin checks ~/.dsh/vet/audits/ for a health record — report mode logs a yellow audit-required alarm, deny mode blocks. Records are written to disk by hand by the agent following the vet-audit-protocol skill |
rules | {} (all on) | Per-rule switches (R1-R20; e.g. {"R17": false} disables the !!js config surface) |
scanSurface | all on | Static scan-surface switches (0.2.6, engine static-v14+, current static-v20): configFiles (cordis.yml/patch !!js detection, R17), instructionFiles (instruction/skill injection observation, R18); disabling only affects the new surfaces, the legacy surface keeps scanning |
observeLoopback | true | Local-API loopback observation (0.2.6 default off; 0.3 default on — loopback + control-plane path + third-party attribution, official attribution exempt, yellow dismissible): when on, plugin requests to 127.0.0.1 enter the N3 ledger, and hits on DSH control-plane paths (/api/, session.*, /plugins/) attributed to third-party plugins raise a yellow observation (alarm-only, dismissible). Observation is not a fix — RPC auth is a dsh-side concern |
Official @deepseek-ai/* packages are exempt by default (built-in trust).
Safety tiers (0.3)
profile preset-expands into the existing per-knob config — a deployment-strategy layer, not a second parallel config
system. Three principles:
- Tiers never change verdict semantics — the verdict is produced only by the deterministic static layer
(trust boundaries 1/4); tiers only change observation depth, alarm surface, and block scope.
- Explicit beats preset — keys the user set to a non-default value survive; keys written into the vet entry of
the profile
cordis.patch.yml (e.g. the shield's runtime-guard toggle) count as explicit and are never overridden.
Known boundary: a key explicitly set to its default value via the plugin config section is indistinguishable from
unset and gets the preset applied — the patch is the true explicit channel for "off".
- False-positive cost scales with the tier — higher tiers trade noise for coverage (see cost column).
| Tier | Position | Preset expansion | Cost |
|---|
standard (shield label: Light defense / 轻度防御) (default) | General public, lowest noise | nothing — current defaults | no runtime layer (static + telemetryDiff + official-package baseline only); the shield shows a hint when the runtime guard is off |
hardened (shield label: Medium defense / 中级防御) | Wake the capabilities already written | runtimeGuard: watch, thirdPartyBaseline: true, honeypot.enabled: true; R17/R18/R19 info observations surface as yellow alarms (alarm-only, verdict unchanged) | ~10-20% hot-path overhead; more dismissible yellows |
paranoid (shield label: High defense / 高级防御) | High-sensitivity environments | hardened + requireAudit: true, denyOn: suspicious, confirmBlockFamily3/4: block | highest noise; interception expanded (blocks persistent/install-time writes after confirmation) |
observeLoopback is on for all tiers (0.3): its signal is specific enough (loopback + control-plane path +
third-party attribution; official attribution exempt) that the unattended P15/P16/P17/G-2/G-5 family is back on
the alarm surface at zero user action.
The shield panel has a one-tap tier selector (writes the patch via /vet/profile, preserving other config keys)
and a tier explainer inside the ? help panel — no manual config editing needed; the runtime guard flips
immediately and is persisted to the profile patch, the remaining tier-preset expansion keys apply after a DSH
restart/hot-reload.
0.3.1 binding (guard ↔ tier): the defense tier and the runtime guard are no longer two independent knobs —
Light defense ⇔ guard off; Medium/High defense ⇔ guard on. Pressing "enable guard" raises the tier to Medium
(an already-High setting is never downgraded); pressing "disable guard" returns to Light; selecting a tier
switches the guard immediately (the remaining preset-expansion keys apply on the DSH config reload — patch
writes trigger the watchUserPatches hot-reload).
Environment variables
All DSH_PLUGIN_VET_* paths are snapshotted at module load (vet loads before third-party
plugins — a plugin changing process.env afterwards cannot redirect vet's storage). Set them in
the host environment (i.e. in the DSH profile/weekly launch script), not from inside a plugin.
| Variable | Default | Purpose |
|---|
DSH_PLUGIN_VET_CACHE_DIR | <tmpdir>/dsh-plugin-vet-cache | Static-scanner report cache (sha-256 keyed, 0600 files) |
DSH_PLUGIN_VET_BASELINE_DIR | ~/.dsh/vet | Content-baseline store (baseline.json) + N6 capability history (capabilities.json) + version snapshots |
DSH_PLUGIN_VET_ARCHIVE_DIR | ~/.dsh/vet/audits | Audit health records — where requireAudit looks for <plugin>-<version>-<ts>.md |
DSH_PLUGIN_VET_FORENSICS_DIR | ~/.dsh/vet/forensics | Forensics journal root (post-confirmation per-plugin recording, 0700 dirs / 0600 files) |
DSH_PLUGIN_VET_CONTRACTS_DIR | ~/.dsh/vet/contracts | Runtime contract snapshots (state contracts + observation reconciliation) |
DSH_PLUGIN_VET_STATS_DIR | ~/.dsh/vet | Defense statistics (stats.json, atomic write, 0600) |
DSH_VET_SIDECAR_PID | (internal) | T1 sentinel PID registry that survives hot reloads — internal, do not set |
Tools
scan_plugin — deterministic static scan: target = dynamic-code (source string) / package (package
directory) / file (single file). Returns a scorecard (verdict + staticScore + findings). The verdict is
produced only by static rules. Optional scanBasis: npm (default — registry tarball artifact, R12 entry/
patch checked against the real release) / git (source-only repo, where lib/ etc. usually aren't committed —
R12 entry/patch-missing findings drop to info so git-only rescan doesn't false-positive). Since 0.1.21 the
scorecard's capability block also reports the R16 ghost/zombie dependency fields (declared vs imported vs
installed). When vet scans itself (realpath-verified, not name-matched), the scorecard adds a selfScan
trust annotation — declared-capability-token downgrade (only declared tokens are exempt; undeclared
outbound/env/credential/IPC stays red) plus the per-version artifact pin (vet-self-pins.json, round-16:
the pin covers the shipped lib/** artifacts so production installs reach Trusted; byte-matching any
published pin counts as pinned-match) — the raw findings stay fully visible.
vet_diff — read-only, purely local: prints the stored version history of a package and the behavior
diff between its last two recorded versions (N6). Outputs hosts/fsPaths/spawnCmds/imports added|removed and
network/exec capability flips. No scan, no network.
vet_label — read-only, purely local: prints the human-readable "capability nutrition label" (M2) for a
package — the files it touches, the hosts / subprocesses it references, its third-party imports (capability
unknown), and its network/exec capability flags, plus a summary of the last upgrade diff. Sources from the
same local N6 capability history; the label represents declared (static-side) capabilities — runtime
observed/dormant capabilities are the domain of the running shield. No scan, no network.
vet-audit-protocol (skill) — audit-process protocol (AUDIT_PROTOCOL.md): the agent audits a new plugin
in preset steps — scan_plugin static criteria (incl. R12 Cordis/DSH contract) → read manifest/source →
verify each finding → proactively dig deeper (network/files/processes/credentials/library semantics) →
contract & code-quality audit (step 4.5: entry/Config-schema consistency, error handling/synchronous
blocking/resource leaks/async correctness and other "badly written" issues — statically clean ≠ worth
installing) → hand-write a health record to ~/.dsh/vet/audits/<plugin>-<version>-<ts>.md using the system
write capability. vet ships no audit tooling and does not investigate for the agent — it only provides the
criteria and the on-disk convention.
Shield panel (0.3 revamp)
The GUI was reskinned per the OBSIDIAN MOSS GOLD design mock (dark recipe; a matching light
variant ships in the same token set) and rebuilt as a layer stack — secondary panels slide
out flush against the main panel's right edge (never the browser's right edge); the whole stack
shifts left when space runs out — panels never overlay one another — and Esc pops layers one at
a time:
| Layer | Panel | Contents |
|---|
| L1 | Main | 3 ring+trend composite cards (memory/CPU/fd: value and direction in one card), foldable memory/IO details, runtime guard + safety tier, defense stats, audit bar, upgrade-diff & honeypot floating cards |
| L2 | Alerts timeline / Recent plugins / Audit & Honeypot / About | timeline = rail+dot+card (dismiss/restore/copy); recent plugins = scan-record corridor, 20 per page with "Load more" paging (round-21); audit center = pending-audit backlog + honeypot touches |
| L3 | Plugin details (the only third level) | 6-axis capability radar, rule-hit wall, OSV/AI-review meta, upgrade diff, declared-side nutrition label |
Data additions (read-only, backward compatible): GET /vet/status.json gains metricsHistory
(64-point trend), audit (pending/new/plugin index/honeypot) and lastUpgradeDiff; new
GET /vet/plugin?name= detail endpoint; new local scan-summary store
~/.dsh/vet/scan-summaries.json written by both the auto-scan and vet-gate paths.
Honest scope: radar/nutrition reflect the declared static capability surface (same
discipline as vet_label); the "blocked" mark comes from the N7 family-1 list.
Automatic behavior
internal/plugin auto-scan (autoScan: true): newly installed third-party npm packages are
static-scanned on load; deny mode + verdict ≥ denyOn → load rolled back.
- Audit gate (
requireAudit: true): loading a third-party plugin without a health record — report mode
logs a yellow audit-required alarm (enters the /vet/status.json alarm list, plugin loads normally); deny
mode rolls back the load (references vet-audit-protocol as a prompt to audit first). Records match by
exact version (P-1): after a plugin upgrade the old version's record no longer authorizes the new version —
re-audit is required to clear the alarm/block. Third-party only (round-17): official @deepseek-ai/*
packages are governed by content-hash baseline + static scan (decision 1: first-seen/match still fully
scanned, only deny escalation exempt), so DSH-bundled official plugins never fire audit-required.
tools/execute interception: cordis_define / run_code / workflow are scanned before execution
(cordis_run's real schema carries no code payload, so the guard slot stays dormant as a tripwire — if a
future schema adds code/source/script payloads it is scanned immediately; zero false positives today);
report mode prefixes non-clean results with VET: (clean executions don't pollute machine-readable
output), deny mode blocks outright (isError).
- Runtime guard (
runtimeGuard: watch) — T1/T2 observation is alarm-only; interception lives in the
dedicated N7 layer below ("N7 confirmation block"):
- T1 sentinel: a sidecar subprocess reads the host /proc every
runtimeIntervalMs
(VmRSS / child-process count / fd count) and streams alarm JSON lines back to the host → shield turns
yellow/red.
- T2 hooks: in-process wrappers around fs / child_process (incl. fs.promises); dangerous operations
(sensitive-path writes/deletes, key-file reads, subprocesses with shell/download/exfiltration keywords,
honeypot-lure touches,
~/.dsh config-root reconnaissance) are attributed via the stack to the plugin
package name before alarming; official packages get full-class noise reduction via attribution (capability
grant — official packages are the platform itself; their high-frequency ~/.dsh session/config/storage
reads don't spam; third parties can't forge attribution). Never blocks a call. Self-harm exemptions
(fixed after real-world false positives):
Static rule table (R1-R20)
| ID | Name | Default level | Scope | Determinism |
|---|
| R1 | constructor-chain escape | critical | code + files | certain/likely |
| R2 | Dynamic execution (eval/Function/import/require) | high (files) / medium (code; bin entries drop to medium) | both | certain/likely |
| R3 | Direct process access (runtime-graded; read-only members/generic/bin entries/app-type packages → info) | critical (host) / high (sandbox) | both | certain |
| R4 | Host closure capture (agent/TextEncoder…) + host-global prototype pollution | critical (code) / high (files, independent of targetKind) | both | certain/likely |
| R5 | ctx-escape attempt signal (withheld members/undeclared services; ctx.logger and other officially injected services are allowlisted) | medium | code only | likely |
| R6 | String coarse-scan fallback (obfuscation signals need combined evidence with dynamic execution) | info | both | heuristic |
| R7 | Hardcoded secrets | high | both | likely |
| R9 | Resource safety (unbounded allocation / exit-less synchronous loops / spawn-in-loop / ReDoS / non-terminating recursion / growth patterns in loops) | high (allocation/dead-loop/fork) / medium (ReDoS/recursion/Map.set) / info (resident loops/+=/Promise.all) | both | certain/likely/heuristic |
| R10 | Supply chain (package.json install hooks incl. prepare/preuninstall; dependency manifest → info; OSV exact-version vulnerability query (default-on osvCheck, configurable; network fail-open) | high (install hooks) / info (dependency manifest; OSV advisory) | files | likely/heuristic |
| R11 | Destructive file operations (fs deletes / sensitive-path reads-writes) | high (sensitive paths) / medium (deletes) | both | likely |
| R12 | Cordis/DSH contract (entry file / bundle-patch declaration / name / engines.node) | high (missing patch / missing entry) / medium (no entry / missing name) / info (low node version) | files | certain/likely |
Engine pipeline additions (0.1.13): besides the rule set, the scanner now produces a per-package
capability manifest (N1) — hosts/fsPaths/spawnCmds/imports/hasNetwork/hasExec extracted from source
(plus R16 ghostDeps/zombieDeps dependency-consistency fields from the package.json vs node_modules audit)
(declaration-side facts, never verdicts, conservative over-collection) — and runs a literal decode
preprocessor (N2) that statically decodes base64 / hex / Buffer.from / String.fromCharCode / constant
concatenation / template literals (all-literal arguments only, ≤4KB, ≤2 nesting layers, never executes
code) and feeds the decoded text back into R13/R7/R11/R20 matching (findings carry decodedFrom and the
original line for audit). Capabilities enable the cross-layer diff (see Runtime monitoring below).
Scoring model
staticScore = max(0, 100 - Σ(severity weight × hits × confidence coefficient))
verdict (the single authoritative judgment; heuristics never upgrade): critical ≥ 1 → critical; otherwise
high ≥ 1 → suspicious; otherwise → clean. The verdict is produced only by the static layer: staticScore
and verdict are shown separately and never merged into a single total.
Capability boundary (honest list)
Static scanning is a "speed bump + forensics layer", not a security boundary. The following is split by
impact on the verdict, and the forms it explicitly does not detect are listed truthfully (all
empirically verified).
Detected — verdict-level (changes the verdict)
| Rule | Problem class | Hit → verdict | Verified |
|---|
| R1 | Constructor-chain escape: x.constructor("return process") / x["constructor"]("return " + "process") / new (globalThis.constructor.constructor)("return process")() (dot/bracket-access + new forms; string args statically evaluable: literals/templates/concatenation/const bindings; new supports const-alias tracking) | critical | matrix + multi-file ✓ |
| R2 | Dynamic execution: eval() / Function() / new Function / new AsyncFunction (incl. parenthesized new (Function)(...); escape-string args → critical) / (async)=>{}.constructor capture (round-7.2: new X.constructor reported only when the base is a function literal — new n.constructor(n.type, n) object-clone no longer false-positives) / vm.runInContext/runInNewContext / dynamic import() / require() | high (files) / medium (code, escape-string → critical); bin entries judged as generic code, drop to medium | matrix + round-7/7.2 regression ✓ |
| R3 | Direct process access: getBuiltinModule/mainModule/module/exit (incl. reallyExit) → critical; side-effect members (kill/abort/chdir/umask/setuid/dlopen/binding, etc.) and unknown members → high; read-only members (round-7.1): env/cwd/platform/pid/argv/execPath/stdin/stdout/stderr/nextTick/on, etc. → info capability surface (reading cwd/env/pid isn't an escape channel; no-bin MCP/tool plugins like bridges no longer get hurt); runtime='sandbox' caps at high; shape degradation: generic packages / bin entry files / app-type packages → info | critical / high / info | matrix + round-7.1 regression ✓ |
| R4 | Host-closure capture: reading .constructor of agent/parallel/pipeline/phase/log/TextEncoder/TextDecoder/btoa/atob or feeding (code scenario); host-global prototype pollution: override assignments and (Object/Array/String/Function/TextEncoder/URL/Buffer and 40+ builtins, round-7) |
Detected — advisory level (downgrades score only, never changes the verdict)
| Rule | Problem class | Note |
|---|
| R5 | ctx-escape attempt signal: accessing sandbox-withheld framework members / undeclared services (ctx.plugin, etc.) | code scenario only; medium |
| R6 | String coarse scan: concatenated escape features, getBuiltinModule/child_process/dangerous-require module references, obfuscation features (String.fromCharCode/Buffer.from(base64)/atob(/charCodeAt — since round-7 reported only when combined with an in-file dynamic-execution signal (eval/new Function/vm etc.); routine byte handling for terminal protocols/encoding no longer false-positives) | info/heuristic |
| R8 | Scan timeout / file-too-large skip | info meta-rule |
Runtime monitoring (when runtimeGuard: watch) — observation alarm-only; the N7 confirmation block (row below) is the only interception layer
| Layer | Mechanism | Catches | Limits |
|---|
| T1 sentinel | Subprocess polling host /proc | Memory bomb (>memLimit), sustained memory growth (leak; net window growth alarms by multiple), fork bomb (child-process burst), fd surge | Granularity = host-global (plugins share the process; can't attribute to a plugin) |
| T2 hooks | In-process wrapping of fs/child_process (incl. fs.promises) | Sensitive-path writes/deletes (/etc, ~/.ssh, .env…), key-file reads, spawn with shell/download-exfiltration keywords | Stack attribution best-effort; per-call wrapper overhead (I/O-heavy <5%, hot paths 10-20% range) |
| N1 capability diff (0.1.13) | Declared capability manifest (scanner, registered at plugin load) vs observed runtime actions (T2) | Hidden capability executed (observed sensitive action with zero static footprint incl. imports) → red n1-hidden; imports non-empty ⇒ 「capability unknown」 conservatively covers any action; only sensitive actions participate | Requires a prior scan of the plugin (auto-scan registers it); statically-visible-but-unused capabilities are recorded as dormant, shown in the nutrition label (M2, 0.1.16) |
| N3 exfil/destruction ledger (0.1.14) | Per-plugin byte counters (sensitive-read / net-write, lifecycle cumulative) + 10s destruction signature windows + sequence signatures (READ_SECRET → SPAWN curl/wget/nc, READ_SECRET → NET_WRITE) | Read-secret-then-send-data: yellow n3-exfil (both counters > 0), red n3-exfil-match (magnitudes match — whole-package exfil), red sequence signatures (30s window); destruction family: mass delete / mass rename-to-encrypted-marker / read-then-overwrite-in-place / write amplification → yellow, two+ signatures together → red n3-ransom; honeypot/canary-confirmed (N4) plugins get lowest thresholds | No session/content inspection (bytes + operation-shape only); cross-session/ultra-slow exfil, native-binary internals, fd-level reads, fetch bodies not counted (documented boundary); per-plugin attribution best-effort |
| N4 canary watermark (0.1.14) | High-entropy canaries embedded in honeypot lure values (in-memory set); network URL/body (write/end), dgram messages, fetch URLs/bodies and spawn args scanned for them | Canary found outbound → red canary-leak (100% exfil confirmation; direct / URL-decode / one base64-decode variants; offending plugin marked suspected in the N3 ledger) | Only confirms exfiltration of honeypot material; canary sharding/reassembly not countered (documented); needs honeypot lures (idempotent lures keep their canary) |
|
Explicitly not detected (empirically verified)
| Form | Empirical result |
|---|
Indirect references: alias function const f = Function; f(...), process["getBuiltinModule"], globalThis.process, indirect eval (0, eval) | The alias-to-Function form (const f = Function; f(...)) remains undetected — R6 info or zero findings, verdict=clean (no variable-alias tracking; R1 alias tracking covers only .constructor); round-9 (0.1.16) / F4: process["getBuiltinModule"] (bracket access) → critical, globalThis.process.* → member-graded (critical/high/info), (0, eval)/globalThis.eval/window.eval/globalThis['eval'] → R2 high — all now caught |
| Runtime/externally constructed payloads: base64 strings, hex/charCode assembly, reading code from network/env/args, self-modifying code | 0.1.13 (N2): statically decodable base64/hex/charCode/constant-concat payloads are decoded and fed back to R13/R7/R11 (exfil/secret/destructive-path shapes now caught); direct Function(atob(...))/eval(atob(...)) calls are flagged by R2 regardless of arguments; the empirically-tested zero-finding floor is now only the alias/dynamic-base constructor form (x.constructor with a runtime-constructed argument) plus payloads sourced from network/env/args/self-modification; 0.1.15 (N5/R15): such network sinks are flagged info ("刻意遮蔽" — runtime target not auditable from source) |
Non-source files: .jsx/.tsx/.vue/binaries/wasm, arbitrary .md/.yml, and .json outside package.json | Not in the general scan surface; shell/PowerShell/batch scripts (.sh/.bash/.ps1/.cmd/.bat/.psm1/.zsh) are covered by R14 (download-and-exec); hardcoded download-and-exec in JS/TS exec/spawn-family arguments is covered by R20 (0.3.2); package.json itself is always parsed (R10 install hooks/dependency manifest, R12 contract, R16 dep consistency); 0.2.6 (R17/R18): root-level configs cordis.yml/cordis.patch.yml (!!js) and instruction/skill files AGENTS.md/SKILL.md gained narrow surface-gated extras; README/docs still not scanned |
| Dependency chain/supply chain (partial — the rest is R10's actual scan surface): full import/require graph resolution, licenses, author reputation, transitive-vulnerability trees (opt-in, off by default) | Not parsed beyond the checks below; : package.json install hooks (R10, incl. prepare/preuninstall → high), dependency manifest (R10 → info), (default-on , configurable off; network, fail-open; exact versions only, ranges skipped), optional transitive tree via a CLI (, default off — missing CLI degrades silently to direct-only), import/node_modules consistency (R16 ghost/zombie deps → info) |
Trust boundaries
- The verdict is produced only by the deterministic static layer — rules are regex/AST judgments, not
spoofable by prompt injection.
- The static layer is physically isolated from plugin code — the scanner is a separate process; the AST is
read-only and never eval'd.
- Review goes through the agent protocol — the agent follows the vet-audit-protocol skill steps (static
criteria first, sensitive points dug into one by one); the verdict is unaffected by the review step.
- No single merged score — merging the verdict with subjective assessment is forbidden, to avoid polluting
the verdict boundary.
- This product is not a security boundary — positioned as a "speed bump + forensics layer" (bypassable
forms in Known Limitations 1 below, aligned with DSH's official stance).
- Fail-open by default — default
mode: report; deny is explicitly enabled by the deployer.
- Observation-first, interception explicit — the runtime guard's T1/T2 layers only watch and never kill.
Interception is limited to documented scopes: the N7 confirmation-block of irreversible destruction
(default
confirmBlock: block families 1/2; active whenever the runtime guard is on — incl. when woken
by the hardened tier or the shield toggle), and the explicitly enabled opt-in deny mode / paranoid
tier (load rollback + per-threshold block). Alarms only carry advice; disposition is always left to the
user on their DSH.
0.1.16 hardening batch (security review)
Fixes from the full code review (C1–C4 critical, M5–M9 major/minor, rule patches; ENGINE static-v12):
- C1 — host side is published as a single bundle (
lib/index.bundle.js); guard internals
(setRootIndexing/withVetSelfIo/confirmBlock/canaryStore/capabilityDiff) are closure-private and no
longer ship as importable modules — an in-process plugin can no longer disable T2/N7/N4/N1 by absolute-path
require. Internal state is also no longer exported by the bundle. Companion fix: package-internal
resources (AUDIT_PROTOCOL.md / scanner-bin entry / T1 sidecar / self root) are now resolved
form-agnostically by src/pkg-root.ts (upward package.json search + candidate existence) — the old
fixed two-level .. locators broke under the bundle main and made a plain restart ENOENT on
AUDIT_PROTOCOL.md.
- C2 — ESM named imports (
import { readFileSync } from 'node:fs') bypass all T2 hooks by Node's
interop snapshot (verified). The N1 manifest now flags this (esmNamedBuiltins) and, under
runtimeGuard: watch, a yellow esm-guard-coverage notice is recorded so the coverage gap is explicit, not
silent.
- C3 —
DSH_PLUGIN_VET_CACHE_DIR / DSH_PLUGIN_VET_BASELINE_DIR are snapshotted at module load;
in-process process.env tampering can no longer redirect stores/cache. The scanner cache verifies a
host-injected random nonce on read — forged clean cache entries (verified attack) are now ignored.
- C4 —
Error.prepareStackTrace/stackTraceLimit tampering is detected: attribution becomes
untrustworthy → red attribution-tampered alarm + N7 family-2 credential blocks still apply via a sentinel
identity.
- M5 — T2 now wraps
symlink/link/chmod/chown/mkdir/mkdtemp/utimes/lutimes (+Sync, write surface) and
lstat/lstatSync (probe surface).
- M6/M7/M8/M9 — R9 fork-bomb covers sync spawn variants · capability/baseline stores self-check for
external overwrite (
vet-store-tamper yellow) · isSensitiveFsPath matches path segments instead of
substrings · sidecar kill verifies /proc/<pid>/cmdline before SIGTERM (PID-reuse protection).
- Rule patches — R2 global/indirect eval forms + require-concat folding, R3
globalThis.process.* member
policy, R4 Reflect.defineProperty, R9 escaped-paren ReDoS counting, R10 prepare hook, R14 python/ruby/perl
download-exec, R15 undici sinks (see Static rule table).
Platform Support
The one-glance matrix — what actually runs where. T1 = out-of-process sentinel (samples memory /
child-process count / fd every tick and alarms); T2 = in-process hooks (interception, honeypot, GUI
shield); panel = live host-metrics display (metrics.js).
| Capability | Linux | macOS 11+ | Windows / other |
|---|
| Static scan (scan_plugin, R1–R20, OSV) | ✅ | ✅ | ✅ |
| T2 runtime hooks + honeypot + shield | ✅ full | ✅ full | ✅ (system-root prefix check is POSIX-shaped; segment-name/keyword checks still hit) |
| T1 sentinel (out-of-process resource sampling) | ✅ /proc, every tick | ✅ ps + lsof, fd ~every 6s (since round-19) | ⛔ skipped via explicit platform gate (zero noise) |
Live metrics panel (metrics.js) | ✅ full | ✅ async ps/lsof sampling (since round-20); disk-I/O shows — | ⚠️ V8-side numbers only (rss/heap); OS counters show —/0 fallbacks (by design; since round-21 even childCount is honest —, never a fake 0) |
macOS floor and CI: GitHub Actions retired the older hosted macOS images (12 fully gone, 13/14 on
the deprecation path; macos-latest = macOS 15 Sequoia), so only modern macOS is ever CI-tested —
consistent with the Node 22 floor (macOS 11+). Older macOS never crashes the guard: unparsable ps
output degrades to "tick skipped", the same contract as a restricted-/proc container. Windows stays
sentinel-less by design: no stock ps/lsof equivalent at comparable cost; T2 + static still guard it.
Panel sampling on macOS is deliberately async snapshot-cache (TTL 4s, lsof 15s): the panel polls
readHostMetrics every 5s from inside the host process, and a sync execFile there would freeze the
host event loop — so reads never block, the first poll shows — and self-heals at the next. Disk-I/O
(read_bytes/write_bytes) is Linux-only (no stock per-process byte counter on macOS/Windows):
elsewhere the panel shows — (−1), never a fake 0. The panel also refuses to render parseable
non-snapshot JSON (SEC-6 cross-origin 403 envelope, host error envelopes): poll only replaces the
live snapshot when the payload matches the wire shape (level string + alarms array), so an error
envelope can never paint a fake all-green shield (round-21; shape predicate shared single-source
between server and client bundle).
Known Limitations
- Static scanning is not a security boundary: obfuscated/encoded/dynamically generated code can bypass the
AST rules; R6 only provides a "suspicious" signal.
1b. Source enumeration limits:
internal/plugin auto-scan only recursively collects ≤6 levels deep,
non-hidden (non-dot-prefixed) .js/.ts/.mjs/.cjs files — deep or hidden directories are silently unscanned
(no warning); use scan_plugin(target=package) manually for a full directory scan.
- Agent review can be prompt-injected: the verdict never comes from the review step, but the agent may
miss things — the confidence field lets users know.
internal/plugin guard doesn't cover runtime dynamic-mount escapes: the vm path is intercepted at the
call layer by the tools/execute guard.
- R5 is code-only: ctx access in files scenarios isn't reported by default (high false-positive rate).
- Scan duration: large plugin packages may time out and skip (R8 info); agent review proceeds per the
vet-audit-protocol steps.
- The verdict is the static layer's deterministic judgment; the agent's subjective assessment is recorded
in the health record and doesn't constitute a security guarantee.
- /vet/status.json has no auth: the shield's polling needs anonymous GET, and the route itself isn't
authenticated — if dsh web binds to a non-loopback address, LAN clients can read scan conclusions/alarm
targets. vet is an alarm-only observer and won't overreach into access control; if you care, keep loopback
binding or trust your network (the POST guard toggle already has same-origin validation; no-Origin requests
are rejected).
@deepseek-ai/* is trusted by default: if the official ecosystem is ever compromised, tighten this
(v1 keeps the switch). scan_plugin judges official packages as generic (capability-surface downgrade) —
an official-package supply-chain attack would let static downgrade mask process access (recorded, P-5;
official packages are the platform itself, same policy as the internal/plugin official exemption). vet's own
exemption is likewise narrowed (P-3): it now matches by name AND verifies via realpath that the target is
the current vet instance — a same-name impostor package (file: install has no registry validation) is judged
by the strictest plugin rules.
- R10's known-vulnerability check depends on an OSV network query: on by default, sending "package
name + exact version" to api.osv.dev (disclosed in the README config section; set
osvCheck: false if you
care); network failure/timeout degrades silently to skip (never false-blocks); only exact versions are
queried — */>=// ranges and version-less main packages are skipped (P3-1/P3-3; round-7 fix:
/ no longer strip their prefix to query as exact lower bounds — the lower bound being affected while
the actually installed version is already fixed would false-positive). Transitive-dependency vulnerability
scanning is opt-in (, default off) — it shells out to a locally installed
(never auto-installed); when it isn't installed, times out, or its output shape is
unexpected it degrades silently to direct-dependency-only. Independent of OSV, R16 (0.1.21, see the Static
rule table) audits the local declaration↔import↔installation consistency (ghost/zombie deps) with no network.
Development
npm run build # scanner-bin + src compiled to lib/ + client bundle
npm run typecheck # full tsc --noEmit (scanner / src / client)
npx vitest run # full suite: 74 files / 1095 tests
node scripts/count-assertions.mjs # assertion census: 2864 standalone expect() calls (+18 chain-matcher helpers; lexical scan, comments/strings excluded)
npx vitest run --coverage # coverage report (v8 over lib/; thresholds: lines/functions/statements >= 85%, branches >= 80%; measured 89.5/93.5/89.5/84.7)
npm run check:mutants # mutation gate (34 mutants must all be killed, 8 benign controls stay clean; per-rule kill matrix enforced)
node scripts/gen-self-pin.mjs && node scripts/check-self-contract.mjs # artifact self-pin + release-pin match
node scripts/check-pack-integrity.mjs # shipped-file whitelist integrity
Release gate: prepublishOnly runs build + pack-integrity + self-contract + mutant gate automatically. The
full pre-release chain is: build → typecheck → vitest → check:mutants → gen-self-pin → check-self-contract →
check-pack-integrity (all must be green; the pin is regenerated whenever the shipped artifacts change).
Layout: scanner-bin/ static engine (separate process); src/ plugin body (tools/guards/audit/report/guard);
src/client/ GUI shield; test/ fixtures + unit tests + adversarial matrix. Architecture in
docs/ARCHITECTURE.md.
License
MIT.