Plugin Install Security Review (v1.8.0)
  
A security gate for DSH plugin installation. It statically reviews the source of dynamic plugins being installed or run via cordis_define / cordis_run, and blocks them under a security-first (fail-closed) policy. It also ships two review tools: plugin_security_review and plugin_security_audit.
Since v1.5.0 this is a static bundle plugin that loads automatically from the profile layer stack — no need to define/run it again after each DSH restart.
Repository Contents
| Path | Description |
|---|---|
package.json + cordis.patch.yml + lib/ | Static bundle (recommended): auto-starts after dsh plugin add |
manifest.json + package-source.js | Dynamic-plugin fallback form (v1.4.0): for profiles without bundle support, loaded per the recovery flow below |
tests/gate.test.mjs | Behavior tests for the dynamic form (node tests/gate.test.mjs) |
tests/static.test.mjs | Behavior tests for the static form (node tests/static.test.mjs) |
Installation (static bundle, recommended)
dsh plugin --profile web add dsh-plugin-security-review> Published on npm (registry.npmjs.org/dsh-plugin-security-review, latest v1.8.0). Use the command above to install from the registry in production; the file: local install is only for unpublished or offline debugging.
For a local, unpublished copy, point file: at this repository (the path must not contain spaces):
dsh plugin --profile web add file:/path/to/dsh-plugin-security-reviewAfter restarting dsh web the gate is active automatically: plugin_security_audit (no arguments) should show 「守卫: 运行中 (v1.8.0)」.
Installation (dynamic plugin, fallback)
Only for profiles without bundle support:
1. Have the agent read package-source.js and manifest.json. 2. cordis_define: plugin: { kind:"new", idPrefix:"secur" }, take name/purpose from manifest.json, and take code.host from the part of package-source.js after return { ... }. 3. cordis_run to activate; verify with plugin_security_audit.
> The dynamic form does not survive DSH process restarts and must be reloaded each time; the static bundle form has no such limitation.
What's New in v1.8.0 (vs. v1.7.0)
- New "Agree + Allowlist" option in the popup. The dialog is now [Reject] [Agree+Allowlist] [Agree]. Clicking Agree + Allowlist (outcome
approveTrusted) — in addition to writing the code fingerprint intoapproved— writes the plugin family intotrusted-local(adefineof a new plugin adds theidPrefixtoprefixes; anexisting/runadds thepluginIdtopluginIds). Install and development iterations of the same plugin (where each code change produces a new fingerprint) no longer re-prompt. Agree (outcomeapprove) still approves only that code fingerprint (invalidated on any code change). The allowlist write is best-effort: a failure (permissions/corrupt file) is logged and does not block the fingerprint approval; the read-side fail-closed semantics are unchanged. - Fixed: clicking Agree could retry in the wrong conversation. The old implementation kept a single browser-side
sessionIdcaptured from anysession/subscribedenvelope, but DSH's mux stream is all-session aggregated — with several conversations open the last subscriber overrode the value andsession.promptlanded the retry in another working conversation. The gate now records the origin session id (exec.agent.sessionId) into each pending entry'sagentIdat interception time, and the popup injects the retry to that origin session viaconnection.api.sessions.prompt; ifagentIdis missing or the target can't be prompted it silently falls back to manual retry. - Popup hint/button copy updated; new tests T19 (origin session id exposure) and T20 (agree+allowlist writes the allowlist and later family variants don't re-prompt).
- The dynamic fallback form is version-synced to v1.8.0 but does not provide the popup (a browser-GUI feature, static bundle only; its ASK still uses the seam).
What's New in v1.7.0 (vs. v1.6.0)
- Approval popup (agree/reject, new — static bundle): ASK verdicts no longer route through the official approval seam (which in approval-disabled deployments is auto-rejected into the
the user rejected tooldead end). Instead the gate parks the approval + denies + shows a browser popup. Clicking Agree persists the code-fingerprint approval, injects a session message so the agent auto-retries, and the install/run then succeeds; clicking Reject records and abandons it. The popup is driven by the gate's own HTTP routes (/security-gate/approvals/pending,/security-gate/approvals/decide) with polling, rendered in theshell.overlaylayer; the gate now ships aclient/client.jsclient half (dsh.clientdeclaration +exports["./client"]). askModeconfig:<DSH_HOME>/storages/plugin-security-gate/config.jsonwith{"askMode":"seam"}falls back to the official approval UI; the default (absent/corrupt) ispopup.- Popup styling & theme adaptation: a centered modal with a semi-transparent mask (replacing the earlier side floating card to avoid occlusion); every color uses real dsw design-system tokens (
--dsw-alias-bg-overlay/--dsw-alias-button-primary-fill/--dsw-alias-label-primary-foreground/--dsw-alias-bg-mask-3/--dsw-alias-state-error-primary/--dsw-alias-state-warn-label, etc.) so light/dark themes adapt automatically and text/panel readability is preserved. - Audit status: the overview now shows
askModeand pending-approval count;gateStatus()gains matching fields. - The dynamic fallback form is version-synced to v1.7.0 but does not provide the popup (a browser-GUI feature, static bundle only; its ASK still uses the seam) — documented in the README.
What's New in v1.6.0 (vs. v1.5.0)
- Operator allowlist
trusted-local(new): a file at<DSH_HOME>/storages/plugin-security-gate/trusted-local.jsondeclares locally trusted plugins (prefixes/pluginIds/fingerprints). A matchingcordis_define/cordis_runis allowed directly and recorded in the audit history as[trusted-local]. The trust anchor is the operator with file permissions, not agent self-attestation; when the file is absent or corrupt, behavior is unchanged (fail-closed). Thecordis_stop/cordis_undefineself-protection is not affected by the allowlist. - Fixed the audit lossless-JSON error:
plugin_security_auditwithoutincludeBundlesreturnedbundles: undefined, which violates the dsh-tools lossless-JSON output check and made the tool fail; it now defaults tonull. - BLOCK denial wording corrected: BLOCK has no human-approval channel, so the message no longer claims approval can unblock it — it now points to the
trusted-localallowlist. Composition rules (HOST_EXFIL_CRED/CLIENT_COOKIE_EXFIL, etc.) are marked[not declarable], i.e. they are not reduced byCAPABILITIES:declarations. - The dynamic fallback form (
package-source.js/manifest.json) is synced to v1.6.0.
What's New in v1.5.0 (vs. the v1.4.0 dynamic form)
- Static bundle: named exports
name/inject/apply; tools are registered viactx.tools.register(defineTool(...)); interception happens viactx.on('tools/pre-execute'); auto-starts on boot. - Self-upgrade exemption removed: the static form upgrades via
dsh plugin update;cordis_define/cordis_runnow review all dynamic plugins with nosecurprefix or lineage-token exemptions; unreviewable source always fails closed (ask). - The review engine, scoring model, capability declarations, cross-session approval persistence, and inventory audit remain unchanged from v1.4.0.
Decision Policy (security-first)
| Verdict | Condition | Behavior |
|---|---|---|
| BLOCK | critical>0 or high≥2 or score≥100 | Refuse install/run, with a full report (no popup) |
| ASK | high≥1 or score≥40 | askMode=popup (default): park the approval + show a Reject / Agree+Allowlist / Agree dialog; agree persists the fingerprint and auto-retries (to the origin session), agree+allowlist additionally writes trusted-local; askMode=seam: route to the official approval service (equivalent to reject when approvals are disabled) |
| WARN | score≥10 | Allow, with the review report attached to the card |
| ALLOW | otherwise | Allow |
Risk weights: critical=100, high=40, medium=15, low=4, with a total score cap of 300; declared capabilities score at half weight (minimum 1).
Review Coverage
- Host rules: unsafe process execution (exec/execSync/shell:true — critical), VM escape, Node internal APIs, constructor-chain escape, host process termination (critical); subprocess execution capability (spawn/fork — high), prototype pollution, dynamic code, credential access, approval tampering, dynamic module loading (high); static module loading, process signals, filesystem, network, global settings, sandbox, tool intervention, session reads (medium); ordinary env reads, timers (low).
- Credential-shaped env vars:
process.env.Xwhose name matches*_KEY/_TOKEN/_SECRET/_PASSWORD/_CREDENTIAL/_AUTH/_COOKIE/_PRIVATEis scored high on its own. - Client rules:
document.cookie,innerHTMLXSS, browsereval, dynamic module loading (high); import()/require, storage, network, navigation, postMessage, Service Worker (medium); host.call, timers (low). - 15 service capability surfaces: precise detection of
ctx.get('shell'/'subprocess'/'credentials'/'approval'/'dynamicCordisRunner'/...). - Composition rules (after reduction): credential-shaped data + network = critical; Cookie + network = critical; file reads + network = high; dynamic code + network = high; local storage + network = low (informational).
- Obfuscation detection: high-entropy long strings,
\x/\uescapes,atob.
Capability Declaration (optional)
Append to the end of purpose in cordis_define:
CAPABILITIES: spawn,network,env,fsKeywords: spawn exec module network env fs credentials approval shell subprocess runner settings sandbox sessions llm process eval vm proto storage cookie dom redirect postmessage serviceworker rpc timer obfs. Rules covered by a declaration score at half weight (marked [declared] in the report).
Cross-Session Approval Persistence
- After a human-approved ASK executes successfully, its code fingerprint (sha256 of host+client) is written to
<DSH_HOME>/storages/plugin-security-gate/state.json. - Subsequent define/run calls matching the same fingerprint are auto-approved; any code change invalidates it. The history tail is persisted as well.
Locally Trusted Development: the trusted-local Allowlist (v1.6.0)
Background: the gate reviews every cordis_define/cordis_run, so legitimate local development plugins can also be stuck by BLOCK/ASK — e.g. a plugin that reads DEEPSEEK_API_KEY and calls the official API triggers the HOST_EXFIL_CRED critical composition, and BLOCK has no human-approval channel. We deliberately do not add "locally created ⇒ auto-allow": the DSH tool contract carries no provenance field, so "locally authored" and "prompt-injection forgery" are indistinguishable at the call surface, and auto-allow would re-introduce the exact exemption-forgery class fixed in v1.4.0. Allow-listing must be anchored to something other than agent self-attestation — the operator file.
Format (created by the operator with file permissions; absent/corrupt file = no allowlist, behavior unchanged):
{
"prefixes": ["dev", "local"],
"pluginIds": ["devtool-1", "legit-llm-client"],
"fingerprints": ["<sha256 fingerprint of host+client source; use the sha field from plugin_security_review>"]
}Match rules (any match allows directly and is recorded in the audit history as [trusted-local]):
| Entry | Matches |
|---|---|
prefixes | prefix of the define idPrefix (kind:new) or pluginId (kind:existing) |
pluginIds | exact pluginId of define / run (does not depend on source retrievability) |
fingerprints | exact sha256 of host+client (code changes invalidate it) |
Boundary notes:
- The allowlist also lets through BLOCK-level plugins — that is the intent of explicit operator trust.
- Adding the gate's own id/prefix to the allowlist disables its self-protection (the operator could uninstall the gate with file access anyway; their risk).
cordis_stop/cordis_undefineself-protection is not affected by the allowlist.- In full-access deployments the agent can also write this file — consistent with the trust level the agent already has for all file access; the allowlist guards against high-risk installs the operator has not confirmed, not against the agent itself.
Approval Popup: Reject / Agree+Allowlist / Agree (v1.8.0, static bundle)
Why: when an ASK verdict routes through the official approval seam in an approval-disabled deployment (policy auto-rejects), it dies as the user rejected tool "cordis_define". Since v1.7.0 the static bundle ships its own popup, so ASK has a real human exit.
Flow:
1. cordis_define/cordis_run hits an ASK verdict → the gate parks an approval record (persisted in state.json, including the origin-session id agentId) and returns a deny whose reason says an approval dialog was raised. 2. The browser half (client/client.js, registered in the shell.overlay layer) polls GET /security-gate/approvals/pending and shows a card: plugin name, verdict/score, risk distribution, plus [Reject] [Agree+Allowlist] [Agree]. 3. Clicking Agree → POST /security-gate/approvals/decide {sha, outcome:'approve'} → the gate writes the code fingerprint into approved → it injects a retry message to the origin session via session.prompt → the agent auto-retries → the same fingerprint now passes. 4. Clicking Agree + Allowlist → POST .../decide {sha, outcome:'approveTrusted'} → as step 3, plus it writes the plugin family into trusted-local (prefixes/pluginIds), so later installs/dev iterations of the same family (even with changed code) pass without re-prompting. 5. Clicking Reject → records human rejected via popup history and clears the pending record; retrying the same code is still blocked.
Config askMode (<DSH_HOME>/storages/plugin-security-gate/config.json):
{ "askMode": "seam" }popup(default; also the fallback when absent/corrupt): gate-owned dialog; bypasses the official approval UI.seam: route through the officialapprovalservice (requires a deployment with approvals enabled, otherwise it degrades to rejection).
Boundaries:
- The popup is a browser-GUI feature: only the
webprofile's static bundle form provides it; CLI/headless sessions and the dynamic fallback form keep the seam for ASK (the deny text still points to thetrusted-localmanual path). - Agree approves only that code fingerprint (invalidated on any code change) and does not add the plugin to
trusted-local; only Agree + Allowlist writestrusted-local. - The retry is always routed to the origin session (
entry.agentId, recorded by the gate fromexec.agentat interception time), independent of which conversation is currently displayed — with several sessions open it no longer lands in the wrong one. - BLOCK remains a hard refusal and never opens the popup (security-first unchanged).
Profile Bundle Inventory Audit
plugin_security_audit includeBundles=true: scans dependencies under <DSH_HOME>/profiles/* that declare dsh (skipping official @deepseek-ai/* packages), reads their entry artifacts through the same static review, and outputs a per-package verdict. Informational audit: bundle installation (pnpm/npm) does not pass through cordis_define/cordis_run, so the gate cannot hard-block that path — it only provides pre/post-install visibility.
Tools
plugin_security_review: pre-install review of given host/client source; returns the verdict, score, per-item risks, and line numbers;purposesupportsCAPABILITIES:declarations.plugin_security_audit: with pluginId+packageId, emits a full report; with no arguments, emits a full overview + intercept/allow history + gate status;includeBundles=trueappends the profile inventory audit.
Maintenance and Boundaries
- Upgrade the gate (static):
dsh plugin --profile web update dsh-plugin-security-review; after updating a localfile:dependency, rundsh plugin addonce more. - Self-protection boundary: the static bundle gate cannot be disabled via
cordis_stop/cordis_undefine(it is not in the dynamic registry), but an operator with file permissions can uninstall it viadsh plugin remove— an inherent difference between the static and dynamic forms. - Known limitations: composition verdicts are static presence checks, not dataflow-confirmed, so false positives/negatives are possible; there are blind spots for deeply obfuscated code; this gate is an in-process interceptor (standard install path), not a security boundary against malicious actors who can disable it — real defense in depth still requires host-level isolation. The
trusted-localallowlist and the approval popup are operator/user decision surfaces, not security boundaries against the agent itself; the popup is provided only by the static bundle form.