# Plugin Install Security Review (v1.8.1)

[![npm version](https://img.shields.io/npm/v/dsh-plugin-security-review.svg)](https://www.npmjs.com/package/dsh-plugin-security-review)
[![npm downloads](https://img.shields.io/npm/dm/dsh-plugin-security-review.svg)](https://www.npmjs.com/package/dsh-plugin-security-review)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

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)

```sh
dsh plugin --profile web add dsh-plugin-security-review
```

> **Published on npm** (`registry.npmjs.org/dsh-plugin-security-review`, latest v1.8.1). 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):

```sh
dsh plugin --profile web add file:/path/to/dsh-plugin-security-review
```

After restarting `dsh web` the gate is active automatically: `plugin_security_audit` (no arguments) should show 「守卫: 运行中 (v1.8.1)」.

## 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.1 (vs. v1.8.0)

- **Fixed: static-bundle `peerDependencies` aligned to DSH 0.1.1-rc.2.** `@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-cordis-host-runner` were bumped from `^0.1.0-rc.6` to `^0.1.1-rc.2` to match the versions DSH currently ships. Because npm/pnpm apply strict prerelease matching (a prerelease satisfies a range only when it shares the same `major.minor.patch` tuple), the old range does not satisfy `0.1.1-rc.2`, which broke `dsh plugin --profile web add/update dsh-plugin-security-review` with a peer-dependency (ERESOLVE) failure. This release does not affect the dynamic fallback form (which does not read the package.json `peerDependencies`).

## 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 into `approved` — writes the plugin family into `trusted-local` (a `define` of a new plugin adds the `idPrefix` to `prefixes`; an `existing`/`run` adds the `pluginId` to `pluginIds`). Install and development iterations of the same plugin (where each code change produces a new fingerprint) no longer re-prompt. **Agree** (outcome `approve`) 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 `sessionId` captured from any `session/subscribed` envelope, but DSH's mux stream is **all-session aggregated** — with several conversations open the last subscriber overrode the value and `session.prompt` landed the retry in another working conversation. The gate now records the **origin session id** (`exec.agent.sessionId`) into each pending entry's `agentId` at interception time, and the popup injects the retry to that origin session via `connection.api.sessions.prompt`; if `agentId` is 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 tool` dead 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 the `shell.overlay` layer; the gate now ships a `client/client.js` client half (`dsh.client` declaration + `exports["./client"]`).
- **`askMode` config**: `<DSH_HOME>/storages/plugin-security-gate/config.json` with `{"askMode":"seam"}` falls back to the official approval UI; the default (absent/corrupt) is `popup`.
- **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 `askMode` and 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.json` declares locally trusted plugins (`prefixes` / `pluginIds` / `fingerprints`). A matching `cordis_define`/`cordis_run` is **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). The `cordis_stop`/`cordis_undefine` self-protection is not affected by the allowlist.
- **Fixed the audit lossless-JSON error**: `plugin_security_audit` without `includeBundles` returned `bundles: undefined`, which violates the dsh-tools lossless-JSON output check and made the tool fail; it now defaults to `null`.
- **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-local` allowlist. Composition rules (`HOST_EXFIL_CRED`/`CLIENT_COOKIE_EXFIL`, etc.) are marked `[not declarable]`, i.e. they are not reduced by `CAPABILITIES:` 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 via `ctx.tools.register(defineTool(...))`; interception happens via `ctx.on('tools/pre-execute')`; auto-starts on boot.
- **Self-upgrade exemption removed**: the static form upgrades via `dsh plugin update`; `cordis_define`/`cordis_run` now review **all** dynamic plugins with no `secur` prefix 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.X` whose name matches `*_KEY/_TOKEN/_SECRET/_PASSWORD/_CREDENTIAL/_AUTH/_COOKIE/_PRIVATE` is scored high on its own.
- **Client rules**: `document.cookie`, `innerHTML` XSS, browser `eval`, 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`/`\u` escapes, `atob`.

## Capability Declaration (optional)

Append to the end of `purpose` in `cordis_define`:

```
CAPABILITIES: spawn,network,env,fs
```

Keywords: `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):

```json
{
  "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_undefine` self-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`):

```json
{ "askMode": "seam" }
```

- `popup` (default; also the fallback when absent/corrupt): gate-owned dialog; bypasses the official approval UI.
- `seam`: route through the official `approval` service (requires a deployment with approvals enabled, otherwise it degrades to rejection).

**Boundaries**:

- The popup is a browser-GUI feature: only the `web` profile'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 the `trusted-local` manual path).
- **Agree** approves only **that code fingerprint** (invalidated on any code change) and does not add the plugin to `trusted-local`; only **Agree + Allowlist** writes `trusted-local`.
- The retry is always routed to the **origin session** (`entry.agentId`, recorded by the gate from `exec.agent` at 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; `purpose` supports `CAPABILITIES:` declarations.
- `plugin_security_audit`: with pluginId+packageId, emits a full report; with no arguments, emits a full overview + intercept/allow history + gate status; `includeBundles=true` appends the profile inventory audit.

## Maintenance and Boundaries

- **Upgrade the gate (static)**: `dsh plugin --profile web update dsh-plugin-security-review`; after updating a local `file:` dependency, run `dsh plugin add` once 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 via `dsh 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-local` allowlist 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.
