# dsh-trust-check

[![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
[![npm version](https://img.shields.io/npm/v/dsh-trust-check)](https://www.npmjs.com/package/dsh-trust-check)
[![npm downloads](https://img.shields.io/npm/dm/dsh-trust-check)](https://www.npmjs.com/package/dsh-trust-check)

[中文](README.md)

Static **capability disclosure** for DeepSeek Harness plugins: permissions, injections, source, and install scripts — code-judged, reproducible, zero tokens.

> Surfaces **what a plugin touches, what it injects, and whether its source can be checked**, each with evidence (file + line + snippet) you can verify. It reports detected facts only — **not a safety endorsement, and it makes no safety inference**. Whether to block or continue is up to integrators and users. See [Positioning and boundaries](docs/POSITIONING.md) (Chinese).

## Install

**The settings UI needs dsh ≥ 0.1.0-rc.8 (recommend 0.1.1-rc.2).** The Web UI depends on `@deepseek-ai/dsh-client-store` in the host module table. The market will not block a mismatched install; on an older host the settings page fails to load (`dsh-client-store` missed the module table).

**The CLI does not need a DSH host** — `npx dsh-trust-check` still works on older dsh.

Check the host first (settings UI only):

```sh
dsh --version
# if too old: npm i -g @deepseek-ai/dsh@latest
```

```sh
dsh plugin --profile web add dsh-trust-check
```

Restart `dsh web`, then open **Settings → Plugin Trust**. Already installed: update from Plugin Market, or `dsh plugin --profile web add dsh-trust-check@latest`, then restart.

A standalone CLI is also provided, independent of the DSH host:

```sh
npx dsh-trust-check                 # audit default profile `web` (compact; Chinese unless LANG=en*)
npx dsh-trust-check --profile work  # audit another profile
npx dsh-trust-check --verbose       # expand every plugin
npx dsh-trust-check --lang en       # English human report
npx dsh-trust-check --no-color      # disable ANSI (off when piped; --color to force on a TTY)
npx dsh-trust-check --json          # machine-readable output

# Audit any extracted package directory (no profile, no DSH)
npx dsh-trust-check --dir ./path/to/plugin
npx dsh-trust-check --dir ./pkg --spec npm:foo@1.0.0 --json

# Gate a script / CI step on the exit code (optional)
npx dsh-trust-check --dir ./pkg --exit-code
```

`--dir` and `--profile` are mutually exclusive. Both modes emit the same `AuditResponse` shape for `--json`: `{ schemaVersion, profile, dir?, generatedAt, plugins, errors }`; in single-directory mode `profile` is an empty string and `dir` is the absolute path. See [docs/audit-schema.md](docs/audit-schema.md) and [docs/INTEGRATION.md](docs/INTEGRATION.md).

The exit code is always 0 by default. With `--exit-code`: `0` nothing detected in this static pass (in profile mode, also acknowledged), `1` review, `2` red line, `3` scan failed; profile mode reports the worst plugin. **A miss is not a safety endorsement.** Usage errors (unknown flag, missing value for `--dir` etc.) print to stderr and exit `1`.

| Settings → Plugin Trust | Expanded review |
| --- | --- |
| ![Settings overview](docs/overview.png) | ![Expanded review and evidence](docs/review.png) |

| Detection feedback | AI reading |
| --- | --- |
| ![Detection feedback](docs/feedback.png) | ![AI reading](docs/AI_read.png) |

| CLI `--dir --json` |
| --- |
| ![CLI JSON output](docs/cli.png) |

### Troubleshooting

| If | Then |
| --- | --- |
| Settings: `dsh-client-store` missed the module table | Host too old: upgrade **dsh ≥ 0.1.0-rc.8**, restart Web; use the CLI above for audits in the meantime |
| No “Plugin Trust” in Settings | Confirm `add` + restart; or the client failed to load on an old host |

## How to read the report

Settings use a single column of cards, with one plugin open at a time.

1. **Header**: one-line positioning plus an overview (e.g. “10 plugins · 3 review”). Colored overview items jump to the first card in that bucket.
2. **Collapsed card**: name, version, status badge, and source. Capability detail appears after you expand.
3. **Expanded card**: findings and action copy → AI reading (if generated) → capabilities / destinations / paths / secrets / injections / source → evidence → scan limits → acknowledgment bar at the bottom.
4. **When the last acknowledgment no longer matches**: the badge shows **reconfirm**; capability gains and losses appear as `+cap` / `−cap` pills under the title.
5. **Acknowledgment**: at the bottom of the card; writes the current fingerprint to the local acknowledgment store for the next scan.

The CLI prints the same facts in its own layout. Compact by default: attention items expand as rounded cards (soft-wrap, width follows the terminal), with evidence under each concern; one-liners for `expected` / `clear`. Color on TTY; `--no-color` / `NO_COLOR` disable it. Pass `--verbose` for the full dump. Machine integrations (catalog, CI) must use `--json`, not the human report.

| Verdict | Meaning | When |
|---|---|---|
| **Red line(s)** | A hard red line was detected. Review the specific facts; you can confirm the risk to keep using it | `redLines` non-empty, current fingerprint not acknowledged |
| **Risk accepted** | You confirmed the current red-line risk | Has red lines, `trust-ack.json` matches this scan |
| **Review** | No hard red lines, but privileged capabilities or patch changes | Has capabilities or override/disable, no red lines |
| **Reconfirm** | You acknowledged before, but this scan no longer matches | An acknowledgment exists and the fingerprint changed (upgrade, findings, or scan-rule updates) |
| **As expected** | You acknowledged the current capability/shape fingerprint | `trust-ack.json` matches this scan (no red lines) |
| **Nothing detected** | No red lines or privileged capabilities | Everything else |

`score` / `summary` stay in JSON for sorting and integrators; the settings UI omits them. Injected token estimates appear only as a cost hint on the Injections fold summary (when non-zero).

### Shape layer (code-judged)

Besides capability chips, the report extracts three kinds of literal facts from source:

- **Literal destinations**: URL / host / IP. Same-origin HTTP routes (e.g. `/dsh-market/check`) do not count as destinations. Each row is labelled with how that line uses it (request argument, comparison only, XML namespace, link, assigned, usage unknown) and a `file:line`. The label is for reading; it does not change capabilities, red lines, or the score.
- **Workspace path escapes**: absolute paths, home directory, traversal, …
- **Secret touches**: paths, sensitive env names.

They mean the literal appears in source; runtime-built URLs are invisible (a template URL still reports its host when `${…}` comes after the host or directly follows a complete dotted host, as in `` `https://api.github.com${path}` ``; `${…}` before the host is complete hides it). The literal itself carries no safety inference.

**Allowlist**: common host / registry domains (GitHub, npm, npmmirror, Tencent Cloud mirrors, …), the curated DSH catalog and GitHub proxies, and common model-vendor APIs (DeepSeek, OpenAI, Anthropic, Google Gemini). Allowlisted entries are collapsed by default with a short note; plaintext HTTP is never downgraded by the allowlist.

**Noise reduction and truncation**:

- Skips **network-aligned** CIDR table rows (e.g. `["10.0.0.0", 8]`, `inRange(a, "10.0.0.0", "10.255.255.255")`); host routes like `["8.8.8.8", 32]` still count as literal IPs. A `PRIVATE_RANGES` / `CIDR` token on the line, or two IPs on one call, does **not** wipe the line.
- Skips RFC 5737 documentation ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), and `0.0.0.0` / `255.255.255.255` (bind / broadcast, not unicast outbound).
- Skips placeholder bases like `http://local` / `http://dsh.invalid`, RFC 2606 `.example` / `.invalid` / `.test`, and shell switches (`/c`).
- Comments are blanked before the scan, so an example URL in a JSDoc block is not a destination (bundlers usually keep those comments).
- Namespace identifiers such as `xmlns="http://www.w3.org/2000/svg"` and pdf.js's `http://www.xfa.org/schema/…` / `http://ns.adobe.com/xdp/` are excluded by exact host — an attacker cannot register those domains, so the exemption cannot be borrowed. Only literals with a path qualify: a bare `"http://www.w3.org"` becomes another host once anything is appended, so it is still a destination.
- A few whole URLs that bundled format libraries use as identifiers, not requests (the Apple plist DTD `http://www.apple.com/DTDs/PropertyList-1.0.dtd`, the ID3 owner `http://musicbrainz.org`), are excluded by **exact literal**. Any other path or subdomain on those hosts, or the literal followed by `+` / `.concat(`, is still a destination. The bare `http://musicbrainz.org` is only exempt as an `==` / `===` / `!=` / `!==` operand.
- When findings exceed the cap, the riskiest are kept: plaintext HTTP and literal IPs cannot be crowded out by harmless addresses.
- Secret paths require a **path-only quoted string** (no whitespace): `"~/.ssh/config"`, `"/Users/x/.ssh/config"`, `'.ssh/config'`, `"~/.aws/credentials"`, or path forms like `/id_rsa` / `~/.netrc`. UI prose (`"Uses … ~/.ssh/config when empty"`), deny-list regexes, `startsWith('id_rsa')`, and a bare `'.ssh'` are not credential access.

### Mark as expected

After you confirm capabilities match why you installed the plugin, the fingerprint is stored in `trust-ack.json` in the active profile directory (usually `~/.dsh/profiles/<profile>/`; under DSH Desktop the host decides where it lives). An upgrade that changes capabilities, destinations, path escapes, secrets, red lines, or skill text (by content hash, not byte length) must be confirmed again. The confirmation request sends the report's `ackFingerprint`; a mismatch returns 409 and asks you to review the refreshed report. Older acknowledgements without a `digest` no longer match. Accepting a red line still requires its own "confirm risk" action, and only after the fingerprint matches.

**AI reading**: a reading aid, also reachable from the acknowledgment bar. Uses your DSH-configured model to explain the report summary; unavailable when no model is configured. The text is saved on this device; regenerate after the plugin version, findings, or UI language change.

**Report a detection issue**: each evidence row can build a draft, and the plugin detail has a separate entry for a miss or an unclear explanation. The draft stays in page memory. Inaccurate detections and unclear explanations open the detection-feedback Markdown issue editor with the title and body you edited; you submit it there. A miss opens the private report page from SECURITY.md. A public issue appears when you mark it as an ordinary rule gap with no exploitable bypass.

### Red lines (default block)

1. declares install / postinstall / preinstall scripts (**not** `prepare`: `prepare` runs on pack/git install only — a score deduction, not a red line);
2. `cordis.patch.yml` overrides/disables an `@deepseek-ai/*` core bundle (matched by `id` **or** `name`);
3. **reads** credential/secret *values* (`credentials.resolve` / `read` / `readRecord` / `get*`, including seam aliases and renamed destructures; keytar/keychain `getPassword` etc. including `import * as` / `default as`; or a secret path on the same line as a file read) **and** has network access — holding a handle only (`const c = ctx.credentials`, `ctx.get('credentials')`) or metadata (`describe`) is a capability chip, not a red line;
4. plaintext `http://` to a literal host that is neither localhost nor an RFC 1918 private IP **and** has network;
5. literal IP outbound that is not loopback, documentation, bind/broadcast, or RFC 1918 private (`10/8`, `172.16/12`, `192.168/16`) **and** has network. Private IPs are still listed as destinations and tagged private — a remote attacker cannot receive traffic sent to them, so they do not make a red line. `169.254/16` (including the cloud metadata endpoint) is not exempt and still red-lines.

Red lines cap the numeric score at 49 (avoiding "100 + high risk"). **The verdict follows `redLines`, not the score**: a low score (e.g. 9) can come from shell + network + unpinned spec stacking and should show **review**, not red line(s). JSON `band` may still be `red` (score below 50), but UI/CLI use `verdict()` — do not mix them.

## What it audits

- **Capabilities** — Reads: `package.json` dependency scopes + static scan of `lib/` / `dist/` (falls back to the package root when neither exists), `bin/`, `scripts/`, `skills/` / `prompts/`, and `main` / `exports` / `bin` / string `browser` entry files; when `files` parses exactly, dev-only files npm would not publish are not counted. Judges: shell / file read / file write / network / credentials / env / sub-agents / host-runtime / LLM / dynamic-code
- **Injections** — Reads: `cordis.patch.yml` + `systemPrompt` / `ctx.skills.register` / `system-prompt/assemble` + skill text. Judges: who it overrides/disables (`id` or `name`), what it injects; skill and system-prompt literals also yield an injected-token estimate (bytes / 4), shown only as a cost hint on the Injections fold summary when non-zero
- **Source** — Reads: `package.json` `repository` (falls back to the git install source) + install spec. Judges: pinned version / pinned commit
- **Update risk** — Reads: install scripts (install/postinstall/preinstall; `prepare` is deduction-only). Judges: whether scripts are declared to run at install time

## For integrators (plugin catalogs, markets, CI)

How to wire a pre-install gate: **[docs/INTEGRATION.md](docs/INTEGRATION.md)**.  
JSON field contract: **[docs/audit-schema.md](docs/audit-schema.md)** (`schemaVersion`, stable gate fields, the wording contract for `capabilities` values and red-line templates with suggested Chinese, volatile fields).  
Release changes: **[CHANGELOG.md](CHANGELOG.md)** lists "Affects catalog results" first on every release — the section to read before bumping a pin.  
Real-world integration: awesome-dsh-plugin runs this tool at catalog build time; its adapter is [`scripts/lib/capabilities.mjs`](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/blob/main/scripts/lib/capabilities.mjs) (third-party code, maintained there).

Stable exports for pre-install confirmation or CI gates:

```ts
import { auditPlugin, collectPlugin, verdict } from 'dsh-trust-check'

const report = auditPlugin(collectPlugin(extractedDir, spec))

// What the three states mean (whether to block or prompt is the integrator's call):
// verdict(report) === 'red'    → high-confidence risk signal; suggest blocking by default, user may confirm
// verdict(report) === 'review' → privileged capabilities; suggest showing the capability list
// verdict(report) === 'clear'  → nothing detected in this static pass (not a safety endorsement)
```

CLI equivalent (market can spawn without DSH):

```sh
npx dsh-trust-check --dir "$EXTRACTED_DIR" --spec "$INSTALL_SPEC" --json
```

Parse `--json` uniformly: `plugins[0]` for single `--dir`, or the full `plugins` array for profile mode; non-empty `errors` means the directory could not be read — **treat as scan failure, not `clear`**. An empty / corrupt extract (no readable `package.json` and no scannable sources) lands in `errors` (fail closed). Top-level **`schemaVersion`** is currently `2` — it bumps on a breaking **shape** change or when a field becomes a stable contract, not when detection rules change. Schema 2 adds `facts[]`, whose `id` is a stable filter key; no schema 1 field is removed or renamed, and the capability values and red-line templates are unchanged. See the two docs above; in-repo Path A smoke demo: `scripts/market-gate-demo.mjs`.

**Out of scope for this release**: remote tarball download (market's job). Workflow: extract to a temp dir, then `--dir`.

## Principles

- **Code-judged and reproducible**: judgements come from the rule table, zero tokens.
- **Reports presence**: claims "detected X"; a miss is not a safety inference or endorsement.
- **Evidence you can re-check**: every capability hit carries `file:line` and a snippet.
- **Hot-swappable seam table**: detection rules are a data table (`src/core/seams.ts`); DSH API changes mean editing the table, not the engine.

## Known limits

- **Post-install checkup**: profile mode audits plugins **already installed**; install/postinstall/prepare may have run before your first scan. `--dir` can scan an extracted tree before install (but install scripts may still run during market extract/install).
- Static scans miss/false-positive (runtime-loaded capabilities are invisible; dynamic `import('node:' + …)`, string concatenation, and obfuscated `eval`/`Function` can still bypass the rule table).
- **Does not expand the dependency tree**: the directory walk skips `node_modules`, and a bare `import 'lodash'` is not followed. A relative path that lands inside this package's `node_modules` is scanned.
- **Credential-value reads cover**: seam service (`ctx.get('credentials')`, including `await` / optional `get?.`; receivers `ctx` / `this.ctx` / `*Ctx`), property access (`ctx.credentials`), destructuring off context (including `credentials: creds`), rebinds of those aliases (including `b = a` and `provider = credCtx.credentials ?? null`), and keytar/keychain password reads including default / `import * as` / `import { default as … }` / `require` renames. Bare `readRecord(` / `this.readRecord()` do not count (local helpers); chained / optional-chained forms such as `ctx.get('credentials').readRecord(…)` and `credentials?.resolve(…)` still count. **Still missed on purpose**: destructuring down to a bare function name (`const { resolve } = ctx.credentials` then `resolve(…)`) — same shape as Promise executors and would red-line plugins like `dsh-pocket` / `agent-teams` without scope tracking; a secret path held in a variable then `readFileSync(p)`; receivers named neither `ctx` nor `*Ctx` (e.g. `context` / `Context`).
- **`new URL` base args are not destinations**, so `const u = new URL('/x', 'http://evil'); fetch(u.href)` has no plaintext-http red line — the known cost of that exemption; do not widen it.
- A complete relative-path literal (`fetch('/api')`, `fetch('./x')`) and a `blob:` / `data:` literal are not `network` — the former is same-origin, the latter is scheme fetch and never enters HTTP. A quoted `${` is literal path text. In a template, an interpolation after `/` plus a non-slash character, or after `./` / `../`, is same-origin too (`` fetch(`/dsh-xu/${name}`) ``). `` fetch(`/${name}`) ``, `` fetch(`${url}`) ``, variable arguments, and concatenation still count, as does a literal that becomes `//host` after `\` / `\n` / `\u` / octal (`\057`) folding. The chip is labelled same-origin or outbound, but a missing outbound literal is not a proof of no outbound access, and the score is unchanged.
- A **`link:` or source-directory** scan drops files npm would not publish when `package.json` `files` parses exactly (files imported by shipped code, named by an install script, or declared as an entry are still scanned). `*` means every path, and `dir/*` means that whole directory; `src*` does not include files under `src/`. Braces, character classes, extglob, and backslashes are not filtered. "Browser / server / command line / suspected dependency" on a row comes from which entry reaches the file and from the sourcemap; it is for reading and does not change the score. An extracted registry tarball already contains only published files.
- Injected tokens are a byte / 4 estimate, not exact billing.
- `link:` / `file:` installs can't infer source from the spec; if the package.json lacks `repository`, it shows "no repository declared".
- The `repository` field is self-declared; it is not cross-checked against the npm package name, and a non-`http(s)` scheme is never rendered as a clickable link.

## Development

```sh
pnpm install
pnpm build       # tsdown: node half → lib/index.js, client half → lib/client.js
pnpm test        # vitest, covers the core engine
pnpm typecheck   # tsc --noEmit
```

Rule-table, skip-rule, and allowlist contributions: [CONTRIBUTING.md](CONTRIBUTING.md). **Widening a skip or allowlist is reviewed harder than adding a rule** — both can weaken detection; plaintext HTTP is never downgraded by the allowlist. False-positive fixes must include a fail-open probe. The attack classes rules are held against, and the three limits of static analysis, are in [THREAT-MODEL.md](THREAT-MODEL.md).

## Roadmap

- Now: installed-plugin audit + CLI `--dir` + Web dimension-first report; ongoing scan-coverage hardening
- Integration: awesome-dsh-plugin scans at catalog build time; dsh-market and the catalog site only display the facts (maintained there, replaceable)
- v2: structured `facts[]` (`schemaVersion: 2`) since 0.2.0; next are a version-to-version drift diff and caller-supplied registry metadata
- Later: keep up with catalog pin bumps and false-positive reports; drift assertions in CI
- Conditional: named community human review — separate repo, bound to a version, revocable

Full write-up: [docs/POSITIONING.md](docs/POSITIONING.md) (Chinese).

## License

MIT
