DeepSeek Harness Plugin

wulun811/dsh-plugin-vet

Stars ★ 4 Downloads (30d) 2,475 Category Security & Permissions Added 2026-08-16 npm @jieai/dsh-plugin-vet

Plugin trust pipeline for DeepSeek Harness: deterministic static scan with verdicts, opt-in runtime guard with honeypot lures, agent audit-protocol skill, and a browser shield status light. Alarm-only, never an enforcer.

Install

# from npm (prebuilt)

dsh plugin --profile web add @jieai/dsh-plugin-vet

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:wulun811/dsh-plugin-vet

Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).

README

The security gate for DSH plugins — audit before install, guard at runtime.

[中文] · Architecture · Security boundaries · Failure ledger · Audit protocol · Contributing

🌐 Landing page: https://wulun811.github.io/dsh-plugin-vet/ — same story, with the dashboard, the architecture and the live numbers (bilingual, dark mode; source in site/).

🔗 The dsh.so plugin-submission and security-report pages run vet-led scanning — see it in action.

dsh plugin --profile <profile> add @jieai/dsh-plugin-vet

vet shield panel (light theme)

Screenshots date from before the 0.5 redesign (the self-check section and the fourth "no data" shield state are missing from them). The panel is described in text below; regenerate this image when it matters.


What a bad plugin actually does to you

Not "it violates a capability contract". This:

It encrypts your files. You install a plugin to get a nice sidebar. Somewhere in its code, it walks ~/Documents, ~/Pictures, ~/code — every file it can read — and rewrites each one with new bytes, renames it to report.pdf.a8f3c1, then deletes the original. Your thesis, your photos, three years of git history. There is no undo. It could also just rm -rf them, but renaming is better business: now there's a ransom note.

It takes your keys. ~/.ssh/id_rsa is your identity on every server you deploy to. ~/.dsh/.credentials.yaml is your model API key — real money. ~/.aws/credentials, ~/.npmrc, .netrc, .git-credentials. A plugin reads them (no permission prompt: same process, same user) and POSTs them to a webhook. You find out when the bill arrives or the servers are gone.

It puts a door in your machine. It appends a line to ~/.bashrc, drops a key into ~/.ssh/authorized_keys, or writes a cron entry — so it survives the uninstall. Or it edits node_modules of another package so the next update re-installs it.

Why nothing stops this today

In DSH everything is a plugin, and a plugin's permissions are your permissions. There is no permission model underneath and no sandbox: a mounted plugin runs in the host process with your full authority. Installing one hands over your SSH keys, your credentials, your project directories and the whole machine, to code you have not read and will probably never read.

That is not a bug in DSH — it is what makes the plugin ecosystem powerful. It is also why "install a plugin" and "run a stranger's script as root" are the same sentence, and why the ecosystem needs a trust layer between them.

Which of the three vet actually stops — and in which mode

Disaster Default (standard) hardened / guard on What stops it
Encrypts / mass-deletes your files sees it, alarms throws inside the plugin, from the 2nd destruction signature onward Family 1 — armed by the destruction-signature combination (mass delete + mass rename-to-ciphertext + read-then-overwrite-in-place + write amplification: any two within 10 s)
Deletes or overwrites your keys sees it, alarms threw before the first byte was written Family 2 — single-shot, exact file level, no confirmation needed
Plants persistence (~/.bashrc, authorized_keys, cron, systemd) sees it, alarms alarms (block only with confirmBlockFamily3: block) Family 3 — recoverable, so by default it does not gamble on a false block
Edits node_modules / config sees it, alarms alarms (block only with confirmBlockFamily4: block) Family 4 — reinstall recovers it
Steals keys and uploads them sees it, alarms alarms No interception. Read-then-send is observation, not destruction

Read the second row again. Delete ~/.ssh/id_rsa from a plugin and vet throws before the delete happens — not after, not on the next one. There is exactly one copy of your key; the design axis is irreversibility: recoverable → alarm, unrecoverable → intercept.

And read the first row honestly. Ransomware is caught by shape: a plugin renaming files to ciphertext extensions, or overwriting files it just read, or deleting 20 files in 10 seconds. The signature needs a 10-second window and two classes to corroborate, so the first few files of the first burst may already be lost. What vet guarantees is that the loop dies — the plugin's next writeFile throws, and it takes the process that long to notice. That is not a sandbox. It is the difference between "everything is gone" and "three files are gone".

standard is the default and it blocks nothing — it only reports. Turn the guard on (one click, or profile: hardened) to get any interception at all. Why that trade is drawn this way ↓


How it works

   ┌ 1. BEFORE INSTALL — scan_plugin ───────────────────────────────────┐
   │ 20 static rules: AST + regex, no model in the loop                 │
   │ + capability manifest + literal decode + OSV advisories            │
   │ → verdict clean/suspicious/critical, plus staticScore              │
   └────────────────────────────────────────────────────────────────────┘
                               │
                               │  you (or the agent, via the
                               │  vet-audit-protocol skill) decide
                               ▼
   ┌ 2. DSH HOST — guards and observation ──────────────────────────────┐
   │ tools/execute guard ..... code the agent is about to run           │
   │ internal/plugin gate .... auto-scan on install; deny rolls back    │
   │ T1 sentinel (subprocess)  host /proc: RSS, children, fds           │
   │ T2 hooks (in-process) ... fs / spawn / net, stack-attributed       │
   │   ├─ N1 capability diff . declared vs observed → hidden red        │
   │   ├─ N3 ledger .......... byte counters, 10-second windows         │
   │   ├─ N4 canaries ........ outbound match = 100% certain            │
   │   ├─ integrity canaries . marker files under ~/.dsh                │
   │   └─ honeypot lures ..... fake keys; touch = someone hunting       │
   │                                                                    │
   │ ┌───────────────────────┐  ┌────────────────────────────┐          │
   │ │ N7 CONFIRMATION BLOCK │  │ webServer  /vet/*          │          │
   │ │ the only interceptor  │  │ status.json + POST control │          │
   │ └───────────────────────┘  └─────────────┬──────────────┘          │
   └────────────────────────────────────────────────────────────────────┘
                                                             │
   ┌ 3. BROWSER — shield in the DSH header ─────────────────────────────┐
   │ green / yellow / red / no-data                                     │
   │ (no data is not green: it has not heard from the guard)            │
   └────────────────────────────────────────────────────────────────────┘

   ┌ SCANNER — separate process, one per scan ──────────────────────────┐
   │ stdin JSON → AST parse (read-only) → rules → stdout JSON           │
   │ never eval'd, never required, never touches host state             │
   └────────────────────────────────────────────────────────────────────┘

Two facts worth pulling out of that picture:

  • The scanner is a different process. Auditing a package means parsing hostile JavaScript. vet never evals it and never requires it; it ships the bytes to a child process that reads an AST and exits. A scanner crash cannot touch your host. This is process isolation, not a sandbox — same user, same machine, no namespaces; a hostile input can still peg that core for the length of the timeout.
  • There is exactly one place that blocks. Everything else observes. If you ever wonder "did vet stop this or just watch it", the answer is in that one box, and the panel tells you which one it was.

Why it does not just block everything

Because a security plugin that blocks on suspicion gets uninstalled within a week, and an uninstalled plugin protects nobody. So the product is a monitoring alarm, not an enforcer: check → alarm → advise, and the final call stays with you. Interception exists in exactly two documented scopes — the N7 confirmation block above, and mode: deny (a static scan over your threshold rolls the mount back). Neither is on by default, both are visible in the panel, and neither is a sandbox.

The opposite failure is worse and more common: a tool that shows green because it was not looking. vet spends most of its engineering on that — see the fourth state and what we openly admit it cannot catch.


Sixty seconds to first use

# 1. install
dsh plugin --profile web add @jieai/dsh-plugin-vet

# 2. restart (the browser-side shield only loads when dsh web starts)
dsh web

# 3. to make it actually watch runtime behaviour (static-only by default):
#    click "Medium defense" on the panel, or write the config:
# ~/.dsh/profiles/web/cordis.patch.yml
- id: plugin-vet
  config:
    profile: hardened      # = runtimeGuard: watch + honeypot + third-party content baseline
    runtimeMemLimitMb: 4096
Tier Shield label Guard Blocks anything?
standard Light defense 轻度防御 off Nothing. Static scan + alarms only.
hardened Medium defense 中级防御 on (watch) Families 1/2 throw; 3/4 alarm. Honeypot + baseline wake up.
paranoid High defense 高级防御 on Above, plus families 3/4 throw.
+ mode: deny — — A scan over threshold rolls the mount back. No tier turns this on for you.

One sentence: a tier is not interception. paranoid only tightens the threshold to suspicious; while mode is still report, that half of the tier does nothing — and the panel marks it not in effect rather than letting you believe you are defended.


What it can actually see

Sees Does not see (written down as boundaries, not omitted)
Before install constructor-chain escape, dynamic execution, direct process access, prototype pollution, hardcoded secrets, resource bombs, install hooks, sensitive-path read/write, hardcoded exfil endpoints, download-and-exec in non-JS scripts, missing Cordis contract, dependency advisories (OSV) obfuscated payloads, targets assembled at runtime, the inside of a native binary, .wasm
After install sensitive-path read/write/delete, outbound requests, child processes, honeypot lure touches, memory/fork/fd anomalies, same-version byte swaps worker_threads realms, native addons, ESM named-import snapshots, low-and-slow exfiltration
On upgrade network targets / sensitive paths / child processes / dependencies that a new version added behaviour that changed without its declared surface changing
When it can't tell says "no data", says coverage unknown, says the scan timed out —

Two things that separate vet from a scanner

"I can't distinguish 'found nothing' from 'wasn't looking'"

That is the most common disease of tools in this category, and it is where vet spends most of its effort. Three concrete mechanisms:

  • Nine coverage surfaces, each of which can only be covered / partial / unknown / excluded / unsupported. A surface nobody reported on is unknown and never renders as covered. A surface you turned off is excluded, not "covered".
  • Effective-config derivation: combinations like hardened + runtimeGuard: off, or denyOn: suspicious + mode: report, are computed and marked not in effect on the panel — instead of letting you believe you are defended.
  • The status light has a fourth state: with no data, or data older than 15 seconds, the shield shows a dashed "no data" shield — not green. It would rather say nothing than pretend everything is fine.

"Do I have to live on the command line?"

No. There is a shield in the DSH Web header: four states (green / yellow / red / no-data). Click it for status, live metrics and an alarm list where every entry carries a suggested disposition and can be dismissed or restored — dismissal never deletes the record. Second-level panels hold the audit & honeypot centre, the alarm timeline, and a corridor of recently scanned plugins; the third level is one plugin's six-axis capability radar and rule-hit wall.


What we openly admit it cannot catch

This section matters more than everything above. A security tool's credibility equals the boundaries it is willing to admit.

  • Ransomware's first burst can land. The signature needs a 10-second window and two corroborating classes. Files touched before that are gone; everything after it throws.
  • ESM named imports are a definite blind spot — import { readFileSync } from 'node:fs' snapshots at module load and bypasses every runtime hook, including the blocking one. vet does not pretend to block it.
  • Static scanning is not a security boundary — it is a speed bump plus a forensics layer. Obfuscated, encoded and runtime-constructed code walks around AST rules.
  • T2 is a camera, not a vault — it catches obvious mischief but not worker threads, native addons, or an exfiltration that takes 4 KB a minute.
  • Source enumeration has limits — auto-scan recurses ≤6 levels and skips dot-directories; source deeper than that is silently unscanned. Use scan_plugin(target=package) for a full pass.
  • A real malicious package can hide its payload under test/ — path-based CI downgrading means main pointing into test/ scores suspicious (64) instead of critical (0). Documented, not fixed.
  • @deepseek-ai/* official packages are trusted more by default — via content hash plus catalog membership, not by name. If the official ecosystem is ever compromised, this needs tightening.
  • The /vet control surface carries no authentication of its own — authentication lives at the gateway above. GET status.json is anonymous by design; POST has same-origin validation, not authentication. Opt-in controlLocalOnly: true narrows it to a local peer.
  • Agent review can be prompt-injected — which is exactly why the verdict never comes from the review step.

The full list (19 items, each empirically verified) is under Known Limitations below; the complete trust-boundary text is in SECURITY.md.


Twelve invariants

The skeleton of the project. Each one is pinned by a test that goes red if it breaks. They are not marketing copy.

  1. The verdict comes only from the deterministic static layer — the model participates in review, never in judgment.
  2. No merged single score — staticScore and verdict are displayed separately; folding them into one number is forbidden.
  3. The scanner is a separate child process — it reads source, never evals, never requires the audited package. Process isolation, not a sandbox: same user, same machine, no namespaces; a hostile input can still peg its CPU.
  4. Fail-open by default — mode: report; deny is enabled explicitly by the deployer.
  5. Observation first, interception explicit — T1/T2 only watch; blocking is limited to documented scopes.
  6. A surface nobody looked at must show as unknown — unknown may never be painted as covered.
  7. A conclusion's identity = bytes + rule set + time — drop one and the other two impersonate it.
  8. Audit records are not a trust anchor — subject_sha256 is a declaration, not a signature; anyone who can write the record can write the digest.
  9. Three entry points (tool / pre-load / CLI gate) given the same frozen input must agree — any difference has to be explicit and pinned by a test.
  10. "Blocked" must be falsifiable — the receipt is computed server-side; the client never re-asserts it.
  11. Dismissing is not deleting — a dismissed alarm stays on record and reappears when the fact escalates.
  12. A switch that is set but inert must be labelled — a useless switch is worse than a missing one.

Full derivations in docs/ARCHITECTURE.md; the mapping from each invariant to the tests that hold it is in docs/CASES.md.


Numbers

Not "how many lines were added" — only numbers that can be interrogated:

  • 20 numbered rules (R1–R20) plus two OSV channels: 12 run per-file over the AST, 4 are regex/text-level, 7 are dispatched by file type
  • 1688 tests across 112 files, green in both the normal and the strict-shadow (VET_DOMAIN_SHADOW_STRICT=1) modes
  • 4474 expect() calls — roughly 88% assert externally observable behaviour, and zero snapshot tests
  • 34 malicious mutants + 8 benign controls: 26 decisive (the verdict really does escalate) and 8 advisory (rule hits but does not escalate — each one declares its reason in the manifest); zero false positives on the controls
  • 14 counter-example classes (C03, C12–C24), each pointing at a test that goes red; the ones without a test are marked "gap" with an explanation of why they cannot be closed yet
  • 16 recorded failures in docs/FAILURES.md, each with a concrete wrong artifact it produced

npm run check:self prints three numbers: findings (what self-scan hit), declared (what maps onto a declaration), and retained (what it cannot account for). The third number is the important one — it means "parts I cannot explain about myself", and the release gate blocks on the critical/high slice of it.


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). Since 0.5.19 it means one thing only: do the load-time tree walks. The static scan, the official-package content-hash baseline and the third-party baseline all stop when it is off — they all walk the package directory. It no longer turns off the requireAudit gate (F19): that gate reads one directory and verifies one digest, so a performance switch was silently reinterpreting "don't scan every install" as "don't require any human audit" — un-audited third-party plugins loaded straight through while the panel showed audit_gate as desired. The one cost left: with autoScan: false, a package that does have an audit record declaring subject_sha256 still gets hashed once, because otherwise "record says A, disk says B" cannot be detected
scannerTimeoutMs 15000 Static-scan subprocess timeout
controlLocalOnly false Control-plane writes (/vet/profile, /vet/runtime-guard, /vet/dismiss, /vet/restore) get the 0.5.12 behaviour by default: same-origin check only. Setting it to true adds a second condition — the TCP peer must be local (loopback ∪ this host's own interface addresses). The peer address is filled in by the kernel, while Origin/Host can be typed by any client, so it is the only half that cannot be forged. The cost is real: with it on, the panel no longer works from another machine, which is why it is off by default (0.5.13 turned it on, and the first user hit exactly that)
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. Independent of autoScan since 0.5.19 (see the autoScan row). Since 0.5.20 a record can also pin the rule set it was written under (rule_set_version: + scan_verdict:); when that engine is older than the one this build ships, the record is only invalidated if the current rule set judges the package worse — a rule-set change alone is not evidence about a plugin
rules {} (all on) Per-rule switches, e.g. {"R17": false}. Wired to the scanner since 0.5.15 — before that the key was inert: it existed in config, the panel marked it active, and nothing ever wrote it into a scan request, so every false you set did nothing. Now it reaches all three entry points (tool / pre-load / tool-execute). Note R5 is code-scenario only by design — it fires on tool-executed code strings, not on file scans; enabling {"R5": true} for a file scan produces a false-positive machine (99 hits on vet's own sources, all of them ordinary ctx parameter access)
scanSurface all on Static scan-surface switches (0.2.6, engine static-v14+, current static-v26): configFiles (cordis.yml/patch !!js detection, R17), instructionFiles (instruction/skill injection observation, R18); disabling only affects the new surfaces, the legacy surface keeps scanning
domainProjection active (changed from shadow in 0.5.18) Which path builds an alarm's shape (severity, merge key, pluginHint, source channel). active takes it from the registry + projector; shadow takes it from the legacy literal written at each detection site; legacy skips the projector entirely. All three compare — the comparison is what turns any divergence into a CI failure under VET_DOMAIN_SHADOW_STRICT=1, and since 0.5.18 it also runs on the active path (before that, switching the default would have silently disarmed CI). The panel reports this key as partial, not active: it genuinely takes effect, but the visible output does not change today because the projection is equivalent to the literals — the modes only diverge if some projection stops being equivalent. Switching the default was verified against a packed artifact booted twice (one mode each) with identical /vet/status.json rows
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 loopback-control observation (alarm-only, dismissible). Observation is not a fix — RPC auth is a dsh-side concern
telemetryDiff true Telemetry config sensitization (0.2.6): periodically hashes telemetry exporter url/mode fields in profile config; cold start records only; host change → yellow (requires restart verification, G-3 shape). Hashes only — config content never enters alarms/archive
thirdPartyBaseline false (on under hardened/paranoid) Third-party post-install integrity baseline (0.2.6): records first-install content hash for non-official packages; same-version content change → red (exempt via acknowledgedPackageHashes). Change-detection, not a trust anchor; the static scan still runs regardless
denyOn critical Blocking threshold in mode: deny
updateCheck true Automatic update-adaptation notice (0.4.1). Offline layer (always on, zero network): the running DSH version is compared against this release's package.json → dshCompat.tested; outside that range the shield's Version & Compatibility card warns that compatibility is unverified (an unreadable version yields unknown, never a false warning). Online layer (throttled to once per 24 h, fail-quiet): the latest VET's declared range is read from registry.npmjs.org and an upgrade is advertised only when a newer version exists and it declares coverage of the running DSH — if no adapted version has been published, it stays silent rather than claiming one exists. Any failure (offline, HTTP error, oversized/malformed body) is silent. Set false to disable both layers for a fully offline deployment. Egress destination is unchanged (registry.npmjs.org was already declared by registry-verify); deny mode suppresses the network call while keeping the offline layer. There is deliberately no one-click upgrade — the panel shows facts plus a copyable command
allowlist [] Package/plugin-id allowlist (skip scanning)
runtimeGuard off Runtime guard (performance/stability cost, opt-in): off = disabled; watch enables the T1 sentinel + T2 hooks (alarm-only) plus the N7 confirmation block (confirmBlock defaults to block — see below; wakes whenever watch is on, incl. via the hardened/paranoid tiers)
runtimeIntervalMs 2000 T1 sentinel /proc sampling interval
runtimeMemLimitMb 4096 T1 memory alarm threshold (host VmRSS, over limit → red). Was 2048 until 0.5.14: a DSH host with plugins loaded routinely sits above 2 GB, so the old default red-flagged ordinary operation
runtimeForkBurstN 5 T1 child-process burst alarm threshold (single-round delta → red)
runtimeFdLimit 512 T1 file-descriptor alarm threshold (→ yellow)
runtimeGrowthMb 2048 T1 sustained memory-growth alarm threshold, in MB per runtimeGrowthWindowMs window (net RSS growth over the full window → yellow, suspected leak; an early-window spike does not count as window-level sustained growth, so no false positive). The default was 256 until 0.5.21: a host that grows 260 MB while installing plugins and running tasks is ordinary, so the alarm was reporting normal work — and an alarm that fires on normal work teaches people to ignore alarms. 2 GB in 10 minutes is a leak; the absolute ceiling (runtimeMemLimitMb, 4096 MB) still covers the slow-and-steady case
runtimeGrowthWindowMs 600000 Growth-detection window (default 10 minutes)
runtimeWarmupMs 180000 T1 startup warm-up, in ms: no T1 alarm (RSS / child-process / fd) is raised during the first 3 minutes after the sentinel starts. A DSH host loads its bundle and builds the client bundle in-process at boot, so RSS climbs to a startup-only peak before settling — measured here: 4413 MB at boot, ~2.1 GB once steady. Evaluating the absolute ceiling during that ramp reports the boot waveform as a memory bomb. Sampling and the prev sample keep advancing, so judgement resumes on the first tick after the window; a host that is still over the ceiling then still reports. 0 disables the warm-up
runtimeMemSustainMs 30000 T1 memory ceiling now requires the breach to be continuous for this long (ms) before it alarms. One /proc sample above 4096 MB is not a memory bomb — the false alarm that prompted 0.5.22 held 4413 MB for 4 ticks (≈8 s) and then fell back. 0 restores "alarm on the first over-limit sample". The message names the sustain window, so the reader knows it is a sustained verdict and not a momentary reading
honeypot.enabled false Honeypot lures (needs runtimeGuard: watch): plants fake key lures in honeypot.dir; T2 reports touches (read/write/delete) of lure paths as a separate honeypot alarm class. Directory/file names and contents carry no honeypot keywords (anti-honeypot), default location ~/.dsh/.local, lure values are well-formed but invalid fake credentials
honeypot.dir '' Lure directory; empty = $HOME/.dsh/.local
osvCheck true Query Google OSV for known vulnerabilities when scanning package.json (exact-version queries only: ranges (*/>=/^/~) and version-less main packages are skipped, P3-1/P3-3 — avoids stale full-history false positives; since round-7 ranges are no longer stripped to query as exact lower bounds). Verified targets = the plugin itself + direct dependencies (cap 8, official @deepseek-ai/* packages skipped, P3-10); transitive trees exceed the OSV v1 scope and the scan budget. Default on sends package names to api.osv.dev; network failure degrades silently. Set false if privacy-sensitive
contentBaseline true Official-package content-hash baseline (P-5): computes a SHA-256 over each @deepseek-ai/* package's files and compares it against the recorded baseline — a same-name impostor (file:/tarball with no registry validation) is judged by the strictest plugin rules on hash mismatch. First-seen stores and trusts the baseline; baseline storage is multi-version by name@version (capped: 1000 files / 50MB / 10s)
networkEgress true Runtime network egress observation (P1): wraps http/https/net/http2/tls/dgram/fetch to observe plugin-originated outbound requests (alarm-only; needs runtimeGuard: watch)
transitiveDeps false Transitive dependency vulnerability audit (P1, opt-in, default off): shells out to a locally installed upstream-radar CLI (never npx-auto-installed); missing / timeout / unexpected output shape degrades silently to direct-dependency-only. Hits surface as OSV-T medium findings
contract enabled; dir ~/.dsh/vet/contracts Runtime contract snapshots (0.3, M1): a per-plugin contract file states the operation surface the plugin declares acceptable; vet reconciles observed runtime actions against it — out-of-surface alarms are recorded as info m1-contract-violation (aggregated per plugin + field), a rejected contract is noted once per plugin (yellow), and an N1 hidden-capability finding invalidates ("distrusts") the contract (yellow, once per plugin). Record-tier only: contracts never gate or block loading. Env override: DSH_PLUGIN_VET_CONTRACTS_DIR
confirmBlock block N7 confirmation block (0.1.14, needs runtimeGuard: watch): only irreversible destruction is intercepted. block (default) — families 1/2 intercept on certain confirmation; alarm — all families alarm-only; off — disabled. Every block throws with an actionable message and writes a red n7-block alarm; process-memory state (cleared on restart)
confirmBlockFamily3 alarm N7 family 3 override (persistence/privilege-surface writes: bashrc/cron/systemd/ld.so.preload/sudoers.d/profile.d/autostart/authorized_keys/hosts/ssl). Explicit block is user opt-in — interception risk is the user's choice; default alarms only
confirmBlockFamily4 alarm N7 family 4 override (supply-chain/install-state writes: node_modules package files, cordis.patch.yml / cordis.yml / plugin.json). Explicit block is user opt-in; default alarms only

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:

  1. 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.
  2. 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".
  3. 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)

Deployment postures: what is kept, what is not promised (0.5.12)

The table above is the profile preset. Below is the posture — a different question: "how protected am I right now?" Any claim of "protected / blocked / covered" has to land on one of these rows; if it cannot, it is not said.

Posture How to get it What it keeps What it does not promise
Scan-only runtimeGuard: off, mode: report Static rules + the vet-gate install gate + the audit protocol + content baseline; rules reproducible, input hashable No runtime observation at all; nothing is blocked; "scanned" only means these bytes were read by this rule set
Standard the above + confirmBlock: block (default) Adds the N7 family 1/2 confirmation block: after a confirmation signal, destructive fs ops and credential-body writes are intercepted (with a falsifiable receipt) Still no runtime observation; family 1's first destructive batch may already have landed; loading is never blocked
Hardened profile: hardened (= runtimeGuard: watch + third-party baseline + honeypot) Adds the T1 sentinel and T2 hooks: observation of memory/forks/fds/sensitive paths/egress, honeypot lures, same-version byte-change detection Watch only, never kill; ESM named imports, vm/worker_threads and native addons are known blind spots (recorded as partial/unknown in coverage); hot-path overhead
Strict profile: paranoid and explicitly mode: deny Adds load-time blocking: a scan above threshold rolls the mount back; the audit gate (requireAudit); explicit family 3/4 interception The preset never turns deny on for you — paranoid lowers the threshold to suspicious, but with mode: report that half of the tier does nothing (the panel's config section marks it not in use); interception is scoped, not a sandbox

In one line: scan-only sees "what this code says", standard blocks family 1/2's irreversible destruction (deleting your keys, a confirmed mass-destruction loop), hardened starts watching "what it is doing", and only adding mode: deny is allowed to say "it does not get installed" — none of the four means "it cannot hurt me".

Which of the four stops which disaster is tabulated once, above, in What a bad plugin actually does to you. This paragraph does not restate it — that is how the two versions drifted apart last time.

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. Since 0.3.15 every store path derives from a single root (DSH_PLUGIN_VET_STORE_DIR, default ~/.dsh/vet), and a test runtime (VITEST / NODE_ENV=test) defaults to a process-private temp directory — running vet's own test suite can no longer write your real store.

Variable Default Purpose
DSH_PLUGIN_VET_STORE_DIR ~/.dsh/vet Base root (0.3.15) for every store file below (capabilities / baseline / stats / scan-summaries / known-boundaries / official-catalog / forensics / contracts / audits / dismissed-alerts / update-check). Per-file variables below still win when set
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 + self-check (coverage / effective posture)
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):
      • node_modules package-directory exemption: package names/inner files are public artifacts — package names containing credential/secret words are normal ecosystem (@aws-sdk/credential-provider-*, @deepseek-ai/dsh-credentials-local, etc.), and both host module resolution (require.resolve's internal realpathSync/stat of inner package.json) and vet's own scan reads touch them at high frequency, so they no longer false-positive as fs-probe; path segments before node_modules still judged normally (~/.ssh/node_modules/x still hits .ssh), and write/delete of system roots (/usr etc.) still alarms.
      • Attribution excludes vet itself: the wrapper frame is always the top of the alarm stack, and the vet root never participates in attribution mapping — host/unowned alarms are no longer pinned on vet (the alarm still fires, attributed to the real caller).
      • Toolchain temp artifacts (tsc <src>.<pid>.<uuid>.tmpdir, *.tmp, *.temp, *.swp, etc.) are auto-exempt — the secrets/credentials in their names are just source filenames being compiled; deleting them is cleanup, not destruction; parent segments still judged normally (~/.ssh/config.bak still alarms).
      • OS temp roots and macOS /private (0.4.2, user report): on macOS /tmp, /var and /etc are symlinks into /private/*, so the canonical path is /private/tmp/… or /private/var/…. Previously /private/tmp/... (atomic-write staging) and /private/var/folders/<xx>/<yy>/T/... (os.tmpdir(), where DSH's atomic staging and workspace-changes' private git index live) were hit twice — by the private keyword and by the /var system-root prefix — producing a burst of 20 unattributed red fs-destroy alarms. Paths are now normalized (dot-segment folding + macOS /private folding) before judgement, and writes/deletes under /tmp, /var/tmp and os.tmpdir() pass; reads are not exempt (/tmp/id_rsa is still visible as a key copy). Boundaries hold: /private/etc, /private/var/db, /var/log, ~/.dsh/.credentials.yaml still alarm, and /tmp/../etc/hosts folds out of the temp root and still alarms. The roots are HookConfig.tempRoots (/tmp, /var/tmp, os.tmpdir(), materialized at load) and candidates are validated — TMPDIR=$HOME/.ssh cannot turn a credential directory into an exemption surface. Case is folded on darwin as well (macOS volumes are case-insensitive by default): /ETC/hosts is /etc/hosts, yet the root-prefix check compared case sensitively, so an all-caps spelling rewrote /etc/hosts with no alarm at all; Linux/Windows keep exact case (ext4 cannot resolve /ETC/hosts to /etc). Honeypot/canary matching (isHoneypotPath, isIntegrityPath) normalizes both sides too, so a lure spelled /private/tmp/... no longer misses. The N7 confirm-block uses the same normalization (/private/etc/hosts no longer bypasses family 3).
  • GUI shield: a browser half registers into conversation.session.header.actions and polls /vet/status.json to show a green/yellow/red light + alarm count. Activation requires a dsh web restart (client-modules only scans the dsh.client declaration at startup).
    • Interaction: clickable — clicking expands the alarm panel (live metrics: memory/CPU/I-O/ child-process/fd; guard status: when off, one click writes a `

…

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →

Community comments

Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.