A security scanner for DeepSeek Harness (dsh) plugins, built on the dsh plugin kernel. It detects risky third-party plugin packages — config row overrides, !!js expressions, capability escalation, prompt injection, and self-modification — and reports findings through a model-facing tool and a human-facing command.
Reference architecture is skill-scanner, but where skill-scanner has a privileged factory (analyzer_factory.py hardcodes the analyzer set), this project makes analyzer registration an effect:
ctx.pluginScan.registerAnalyzer(analyzer)returns a disposer, exactly like a Cordis registration.- The four built-in analyzers (
dsh-plugin-scan-rules) register through that same public method. - The external-engine bridge (
dsh-plugin-scan-bridge) adds a whole detector over a subprocess — also throughregisterAnalyzer, with no change to the core.
There is no privileged core to patch. The seam is the standard dsh three-role split:
| Package | Role | ctx key |
|---|---|---|
dsh-plugin-scan |
Service Definition: analyzer registry, package loader, policy | ctx.pluginScan |
dsh-plugin-scan-rules |
Service Provider: four built-in analyzers + YAML rule pack | registers on ctx.pluginScan |
dsh-plugin-scan-bridge |
Service Provider: external engine as a persistent subprocess | registers on ctx.pluginScan |
dsh-tool-plugin-scan |
Consumer: scan_plugin tool + /scan command + bundle patch |
registers on ctx.tools, ctx.commands |
| Analyzer | Detects |
|---|---|
config-analyzer |
patch overrides/disables a security-relevant row (sandbox, approval, credentials, llm, …); !!js expressions and !!js reaching fs/shell/credentials |
capability-analyzer |
eval/new Function/node:vm; raw node:fs/child_process/net imports; process.env + network (credential exfiltration); shadowing a built-in tool name; preinstall/postinstall/prepare scripts |
model-analyzer |
prompt-injection directives (ignore-override, jailbreak) in model-visible text |
runtime-analyzer |
dynamic Cordis packages (cordis_define/cordis_run/ctx.dynamic); writing cordis.yml/profile state |
coverage-analyzer |
the scan's own gaps: files the loader refused to read (oversized / unreadable / over the byte budget) and a walk that stopped at the file cap |
The last one detects nothing about the package; it reports on the scan. A package whose payload sits in a file the loader never read looks exactly like a clean one, so "no findings" is only meaningful next to "everything was read".
Rules live in packages/scan-rules/rules/core.yaml: the manifest (severity / category / description) plus per-rule case-insensitive matches lists. Structural checks (row overrides, AND-combinations, lifecycle scripts) stay in the analyzer code. Every ruleId an analyzer emits must have a manifest entry — finding() fails loud on a missing one.
dsh-plugin-scan-bridge runs any detector as a persistent subprocess over a JSON-lines protocol, pinned by version at a handshake and bounded per request (timeout + max line size) — the same versioned, bounded subprocess posture as skill-scanner's cel-go helper. The engine declares its own rules at handshake; they surface in ctx.pluginScan.ruleRegistry.
# enable a bridge engine in a patch
- insert:
- id: my-scan-engine
name: 'dsh-plugin-scan-bridge'
config:
command: node
args: ['./engines/my-engine.mjs']
engineName: my-engine
engineVersion: 1.0.0Protocol (JSON lines over stdio): init {version} → ready {version, rules}; scan {id, root} → findings {id, findings} | error {id, error}. A version mismatch fails plugin load.
{ kind: 'directory', path }— scan a plugin package directory.{ kind: 'profile', name }— scan an installed profile's own files ($DSH_HOME/profiles/<name>;DSH_HOMEdefaults to~/.dsh).{ kind: 'github', repo }— shallow-clone (git clone --depth 1) anowner/name(or a full URL / local path) to a temp dir, scan it, then remove the clone.scanRegistry(path)— batch: read a registry JSON file and scan every entry.
scanRegistry(path) (and the /scan-registry <path> command) reads a JSON file of entries and scans each, returning a ScanBatchReport:
[
{ "name": "my-plugin", "path": "/abs/path/to/my-plugin" },
{ "name": "someone/some-plugin", "repo": "someone/some-plugin" }
]Each entry carries either a path (local directory) or a repo (github shorthand / URL).
ctx.pluginScanconfig:disabledRules(drop rule ids) andseverityOverrides(per-rule severity).scan-rulesconfig:trustedRowIds,builtinToolNames.tool-scanconfig:failOn— a severity threshold; thescan_pluginresult carriesfailed: truewhenmaxSeverityreaches it (empty disables).tool-scanrenders at most 25 findings, and at most 25 flagged packages per batch; the rest are summarized by count. The rendered blocks are the model-facing tool content, so this caps what a package shipping hundreds of findings can push into the context.
This is a best-effort static scanner, not a security guarantee. "No findings" means no known pattern matched — it does not certify that a plugin is safe. Every scan reports what it did not read (SCAN_FILE_SKIPPED) and whether the walk stopped at a cap (SCAN_TRUNCATED); when either is present, the "no known threat patterns detected" line is a partial answer. Rules are heuristics (substring + structure, not full AST/dataflow); a determined attacker evades signatures, and the scanner will match a literal token like eval even in a comment. Pair results with manual review before installing a plugin you do not trust.
packages/
scan/ dsh-plugin-scan (Service Definition)
scan-rules/ dsh-plugin-scan-rules (Provider: 4 analyzers + YAML rules)
scan-bridge/ dsh-plugin-scan-bridge (Provider: external engine)
tool-scan/ dsh-tool-plugin-scan (Consumer: tool + command + bundle)
testdata/ malicious + clean fixtures, and a bridge engine fixture
This repo resolves the dsh framework packages from a sibling deepseek-harness-master checkout as workspace members (see pnpm-workspace.yaml), so @deepseek-ai/cordis keeps a single Context identity and the built lib/ is used for types.
pnpm install
pnpm run typecheck
pnpm testcd /path/to/deepseek-harness
pnpm dsh plugin --profile web add link:../dsh-plugin-scanner/packages/tool-scan
# or, once published: dsh plugin --profile web add dsh-tool-plugin-scanThen ask the model to scan_plugin a directory, or type /scan <path>.
- v1 is report-only. The tool returns a structured report; it never blocks the model on a finding. Guard/deny behavior and context injection are later milestones.
- v1 does not add a session event. Out-of-repo plugins cannot yet mark a new
SessionEventMapmemberignorable, so a new event type would make first-party readers refuse resume. Report output rides the existingtool/resultsurface. - Exit codes / a CLI are a later milestone; v1 surfaces the
failedflag andmaxSeverityfor a caller to gate on.