Skip to content
dsh.fish
Bundle

dsh-contract-check

Contract check for DeepSeek Harness plugins: statically verifies that registered tools' output.render() returns ContentBlock[], and warns when a session event type falls outside the kernel vocabulary. Warn-only. 契约体检插件(不检测恶意)。

Source
imtokenxinluo
License
MIT
Updated
Updated 4 days ago

Readme

# dsh-contract-check

**它检查「契约合规」,不检测恶意,也不拦截执行。**

本工具回答一个问题:**当前装着的插件,有没有在违反 DSH 的内核契约**——
写坏会话历史的那个雷,现在是不是还埋着。

- ✅ 它能发现:`render()` 返回字符串(会让整条会话历史无法加载)、插件写入词汇表外的会话事件类型
- ❌ 它不能发现:蓄意投毒、数据窃取、后门。**进程内的检查器和恶意插件权限相同**,挡不住对方
- ❌ 它不阻止任何操作:只告警(这是刻意的设计选择)。发现违规后由**你**决定怎么办

如果你要的是"防投毒",这个工具帮不了你;如果答案是"是"的那类事故——它真能挡住。

---

## 与社区同类工具的差异(先说清楚,别重复造轮子)

DSH 社区已有成熟的插件审计工具,**本工具不是首创**。诚实的定位如下:

| | `omdsh-dev/dsh-plugin-check` | 本工具 |
|---|---|---|
| 扫描对象 | **插件仓库目录**(发布前)| **运行中的工具注册表**(安装后)|
| 检查内容 | 清单协议 / patch 格式 / 构建陷阱 / hub 收录 / 生态合规,**33 项** | 只两条:`render` 契约、会话事件类型词汇表 |
| 覆盖范围 | 更广(打包、命名、patch、hub 状态)| 更窄(但更深)|
| 压缩/无源码的插件 | 看不到(扫源码树)| **照样看得见**(读的是注册表里的定义)|
| GUI 状态徽章 | ❌ | ✅ |
| token 成本 | ⚠️ 注册了模型工具 `plugin_check`(每请求付 schema 税)| **0** |

**同组织还有**:`dsh-session-health`(会话文件帧级健康)、`dsh-security-audit`(凭据/暴露面)。

**因此**:

- **发布前查打包/清单/命名 → 用 `dsh-plugin-check`**(更全)。
  本仓库的 `package` 子命令只是**轻量版**(只查"声明要用的文件会不会被漏发"这一类),
  够用就好,不够请用他们那个。
- **装进来之后查"有没有在埋雷" → 用本工具**:它是唯一一个看**实时注册表**、
  盯**让会话打不开的那两个契约**、并把状态做成**零 token 的 GUI 角标**的。

两者互补而非竞争:他们管"发出去之前对不对",本工具管"装进来之后有没有在埋雷"。

---

## 硬约束:token 中立

**本插件不注册模型工具、不注入提示词、不产生模型可见输出。**

它唯一的对外输出是 `ctx.logger.warn(...)`——**只进控制台/日志,不进模型上下文**。
所以它对你每轮对话的 token 消耗**零影响**。

这条是硬约束,不是巧合:一个模型工具的定义(名字+描述+参数 schema)会进入**每一次请求**的
上下文,几十轮对话就是上万 token,**不管你用没用它**。任何"要让 agent 知道"的功能都要付这笔税,
因此**本插件永远不加模型工具**。

**GUI 状态页/徽章不受此约束**:它是浏览器侧代码,**不进模型上下文**,所以 token 成本仍然是零。

---

## GUI 状态标识(绿/黄/灰)

装进 profile 并重启后,**会话头部**会多出一个小按钮:**一个颜色圆点 + 「体检」二字**。
**鼠标移上去**展开完整状态条:

```
🟢 无耗 · 检查 21 个插件 · 违规 0 · 不可判定 1
```

- **无耗** 放在最前:这个检查不花一个 token(浏览器侧代码不进模型上下文)。
- **违规 0** 与 **不可判定 1** 即使为 0 也照常显示 —— 数字消失会被误读成"这一项没查"。

**界面上就只有这一个按钮**,没有 Tab、没有面板。理由很直接:
体检是后台该做的事,不该要求用户主动去翻页签。检查由插件自己跑
(加载时 + 工具注册表变化时),按钮只是"想立刻确认一下"的入口。

点击后的反馈(0.5 秒):圆点变成**转圈**、文字变「体检中…」、按钮禁用防连点。
即使体检只花几十毫秒,动画也会转完这半秒——否则就是"点一下闪一下",比不动还难看。

另外**设置页**会多出一栏「无耗检查」,里面是自愿打赏(装了 `dsh-tip-jar` 才显示)。
打赏放在设置页而不是日常视野里,是因为它跟体检本身没关系,不该天天占地方。

| 颜色 | 含义 |
|---|---|
| 🟢 绿 | 体检过,**0 违规**,且**确实验证到了东西** —— 健康 |
| 🟡 黄 | 体检过,**有违规** —— 有插件在写坏会话的风险 |
| ⚪ 灰 | **尚未体检 / 无法枚举 / 全部判不准** —— 诚实显示"不知道",绝不假装健康 |

`不可判定(unknown)`**不影响颜色**:那是"判不准",不是"有问题"。
把它算成风险会让你天天看到黄色,然后就再也不看这个颜色了。

但有一个例外(实机教训):**如果 21 个插件全部判不准,那不是"健康",是"什么都没验"**
—— 这时显示灰,状态条会写明"全部判不准,等于没有验证"。
**绿色必须意味着"真验过了"。**

### 准确率靠什么:静态 + 实测

| 手段 | 做法 | 局限 |
|---|---|---|
| 静态 | 读 `render` 源码,看 `return` 的是不是数组 | **对 `defineTool` 写的工具永远失效**(见下) |
| **实测** | 按 `output.schema` 造假数据,真调一次 `render`,看返回值 | 假数据不合形状时会抛异常 → 归 unknown |

内核的 `defineTool` 会把作者的 render **包一层**:

```js
// 我们读到的是这个壳 —— 函数体只是一句"调用别人",静态永远看不透
render(args, value) { return userRender(args, value); }
```

所以静态检查对**绝大多数正经插件**都只能报 unknown(实机实测:21/21 全 unknown = 等于没检查)。
**实测是必需品,不是可选项**,因此默认开启(`probeRender: false` 可关)。

实测的边界(重要):**调用失败绝不等于违规** —— 假数据不合形状会让 `render` 抛异常,
那是**检查器自己的问题**,所以一律归 `unknown`,绝不误报成 violation。

实机效果:**21 个插件 → 实测判定 20,剩 1 个如实说"判不准"**。

实现方式(刻意从简):

- 宿主侧用 `ctx.webServer` 注册两条**同源 HTTP 路由**:
  - `GET /contract-check/status` —— 最近一次体检结果(JSON,含 `diag` 诊断)
  - `POST /contract-check/audit` —— 立刻体检一次
- 客户端(`src/client.js` → `lib/client.js`)只做两件事:`fetch` 上面两条路由 + 渲染
  - **不走 typert/remote RPC**:同源 fetch 就够,少一层协议
  - 体检进行中重复点击返回 `409`(枚举注册表是 CPU 活,不排队堆叠)
  - 体检失败**不清空**上一次结果(否则界面会从"有风险"跳回"未知",丢掉信息)
- 服务依赖一律用 `ctx.inject([...], cb)` **延迟注入**:
  `tools` 与 `webServer` 都可能在 `apply` 之后才就绪(实机踩到过两次:
  直接读 `ctx.tools` / `ctx.webServer` 会静默失败,界面只能是灰点)。
  `/status` 的 `diag` 字段会告诉你注入是否触发、用了哪个访问器、`view()` 返回什么形态。

> ⚠️ **客户端产物需要构建**:`npm run build`(用本机已有的 esbuild,不联网安装)。
> 构建产物 `lib/client.js` 必须存在,否则 DSH 启动会报 "client bundles not found"。
> `files` 白名单已含 `lib`,避免打包时漏发(这正是本工具自己检查的那类错误)。

---

## 背景:为什么这值得一个插件

DSH 的写路径宽松、读路径严格,而失败模式是**整条会话**而不是"那一条记录":

| 契约 | 违反的后果 | 真实事故 |
|---|---|---|
| `output.render()` 必须返回 `ContentBlock[]` | 加载器报 `SessionPersistenceCorruptionError`,**整条历史打不开** | 2026-09:`dsh-ssh-ops` 的 `sftp_*`/`tunnel_*` 返回裸字符串,多个会话失联 |
| 会话事件类型必须在 `KNOWN_SESSION_EVENT_TYPES` 内 | 加载器**拒绝解释整条日志**("unknown to this harness and not marked ignorable") | 社区多次报告(`dsh-agent-teams` 等) |

第二条有个**容易忽略的硬事实**(本工具核验过内核源码):

- 加载器判定只有一行:`if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue;`
- 词汇表在 0.1.0-rc.6 上共 **44** 类
- **`Session.append(type, data, ...opts)` 的 opts 只接受 `sourceEventSeqs` / `surfaceOp`
  —— 插件没有设置 `ignorable` 的入口**;而且整个内核里**不存在**任何 `ignorable: true` 的赋值

**结论:插件写一个自定义事件名,默认就会让使用者的会话打不开。**

### ⚠️ 但"作者无法自救"这句话是错的(2026-09-20 修正)

本文档早前写过"且作者无法自救"。**这句话不成立**,证据来自生态里的真实代码
(`@flowingspring/dsh-voco`):

```js
function registerVoiceSessionEventTypes(eventTypes) {
  const writable = eventTypes
  for (const type of VOICE_SESSION_EVENT_TYPES) writable.add(type)
}
registerVoiceSessionEventTypes(KNOWN_SESSION_EVENT_TYPES)         // 自己 import 的那份
// 还要额外改「宿主」那一份,否则改的是另一个模块实例、不起作用:
const hostSession = createRequire(resolve(process.argv[1]))('@deepseek-ai/dsh-session')
hostSession.KNOWN_SESSION_EVENT_TYPES && registerVoiceSessionEventTypes(hostSession.KNOWN_SESSION_EVENT_TYPES)
```

**`KNOWN_SESSION_EVENT_TYPES` 是一个导出的可变 `Set`。** 插件在运行时往里 `.add()`,
而加载器 `KNOWN_SESSION_EVENT_TYPES.has(event.type)` 用的是同一个对象引用 —— **注册真的生效。**

所以准确的表述是:

| | |
|---|---|
| `ignorable` | 插件**确实**设不了(opts 只认那两个字段) |
| 词汇表 | 插件**可以**用改 Set 的方式把自己的类型注册进去 —— 脆弱(依赖拿到宿主那份模块实例)、但可行 |
| 因此 | **写自定义事件类型不是绝对禁忌**,前提是**必须同时完成注册**;只 append 不注册,才会让使用者的会话打不开 |

本工具运行时只能看到"这个类型不在内核初始词汇表内",**看不出该插件有没有注册过它** ——
所以这类告警的措辞是"**看起来**不在词汇表内,请确认你是否已注册",而不是断言违规。

---

## 两种用法

### 1) 装进 profile(进程内体检)

把包加进 profile 依赖与 bundle 列表:

```jsonc
// profiles/<profile>/package.json
{
  "dependencies": { "dsh-contract-check": "^0.1.0" },
  "dsh": { "profile": { "bundles": ["...", "dsh-contract-check"] } }
}
```

> 本地开发可以写成 `"dsh-contract-check": "link:D:/tool/claude_code/dsh-contract-check"`
> ——改代码即时生效,但**改完客户端要重跑 `npm run build`** 再重启 DSH。

它会做三件事:

- **加载时**:枚举 `ctx.tools.view()` 里可见的工具定义,逐个静态检查 `output.render`
- **运行期**:订阅 `session/event`,发现表外事件类型立刻告警
- **GUI**:注册上面那两条状态路由,供头部那个「体检」按钮读取(界面只有它一个元素)

> 插件注册顺序不可控,所以本插件也会监听 `tools/change`:**先加载时注册表还是空的就保持安静**,
> 等注册表变化后再体检(同一问题只告警一次,不刷屏)。

### 2) 离线检查(不需要 DSH)

```bash
dsh-contract-check scan <文件或目录>     # 递归扫源码里的 render 定义
dsh-contract-check package <包目录>      # 检查 npm 打包会不会漏发声明要用的文件
dsh-contract-check vocab                 # 打印内嵌词汇表版本与条目数
```

**`package` 检查的是"整树起不来"那类事故**:`package.json` 声明要用的文件
(`dsh.bundle.patch` / `exports` 目标 / `types`)有没有被 `files` 白名单漏在包外。

> ⚠️ **这是轻量版**:只覆盖"声明要用的文件会不会被漏发"这一小类。
> 完整的插件仓库体检(清单协议 / patch 格式 / 构建陷阱 / 命名 / hub 收录,共 33 项)
> 请用社区成熟的 [`omdsh-dev/dsh-plugin-check`](https://github.com/omdsh-dev/dsh-plugin-check)。
> 本工具的重点始终是**运行中的注册表**与**会让会话打不开的契约**,不是发布前的仓库检查。

它是按 npm 的**真实语义**判定的(用一次性实验实测,不是猜):

| npm 行为 | 文件 |
|---|---|
| **总是包含**(不会漏) | `package.json`、`README*`、`LICENSE*`、`CHANGELOG*`、`main` 指向的文件、`bin` 指向的文件 |
| **不自动包含**(必须在 `files` 里) | `exports` 的目标、`types`、**任何自定义字段**——含 `dsh.bundle.patch` |
| `files` 无此字段时 | 默认全打包,无遗漏风险 |

> 它**不运行 npm**、**不执行被检查包的脚本**——`npm pack` 会跑 `prepack`/`prepare`,
> 对不受信任的包是危险的,所以白名单判定是本工具自己实现的。

发现 `violation` 时退出码为 `1`,便于串进脚本或 CI。

---

## 三态判定:为什么会出现 `unknown`

| 判定 | 含义 |
|---|---|
| `ok` | 返回值形态正确(静态看出是数组,或实测返回数组)|
| `violation` | 返回字符串 / 非数组 / `undefined`(静态命中,或实测证实)|
| `unknown` | **判不准**(静态看不透 + 实测也失败)——如实说不知道 |

**宁 unknown,不 violation** 是这个工具的硬原则:误报会让人卸载好插件、并从此不信任检查器。
所以:**实测调用抛异常一律归 unknown**(那是假数据不合形状,是检查器自己的问题);
**全不可判定时状态显示灰而不是绿**(什么都没验 ≠ 健康)。

几个由此得来的实现细节(都有测试锁定):

- 链式表达式**由链尾决定类型**,但**只在括号深度 0 处**认标记:
  `[{ text: lines.join("\n") }]` 是数组(`.join` 在数组**内部**),而 `[...].join("")` 是字符串
- **剔除嵌套函数体**后再收集 `return`:`render` 内部的箭头函数/局部函数里的 `return "x"`
  不属于 render 的返回值
- 只认**属于函数自己**的箭头:`render(a, v) { ...map((i) => ...) }` 是方法简写,
  不能因为体内有 `=>` 就当成箭头函数
- 实测按 `output.schema` **造形状合法的假数据**(含 `required` 字段、数组、嵌套对象,有深度上限);
  自引用的病态 schema 不会卡死

---

## 实测证据(零误报)

`npm run verify:real` 对**真实已装代码**跑一遍:

```
真实代码合计:render=52,误报 violation=0
合成样本:violation=2(应 > 0,证明检测有效)
✓ 通过:真实代码零误报,且对已知违规有效
```

- 覆盖 **19 个内核工具包**(内核自己的工具必然合规 → 任何 violation 都是本工具的误报)
- 覆盖真实第三方插件:`dsh-ssh-ops@0.2.1`(**15 个 render,0 violation**——该版本已修好)。
  作为对照:修复前的同款代码会返回裸字符串,正是本项目要防的那类事故

### 打包检查的验证:抓出真实历史 bug

把本仓库要防的那次事故**原样复现**(`dsh-session-doctor` 修复前的提交 `896f6da^`):

```
$ dsh-contract-check package <修复前的包>
VIOLATION  ...(1 项会被漏发)
  · cordis.patch.yml
      在 files 白名单之外,npm 不会打包它:dsh.bundle.patch(漏发 = 插件树拒启)
```

——**这正是当初让整个插件树拒绝启动的那个 bug**。8 个真实已装包中 7 个判 `ok`
(零误报),唯一命中的 `@opendsh/dsh-plugin-scheduled-tasks` 是**真实瑕疵**:
它用 `"./src/*": "./src/*"` 导出子路径,但 `src` 不在 `files` 里 → 安装后该路径 import 会失败。
- 另配合成"旧式违规"样本,确认检测**确实有效**(不是什么都判 ok)

词汇表漂移检查:`npm run verify:vocab` → 内嵌 44 条 vs 内核 44 条,一致。

---

## 局限(如实)

1. **静态分析有盲区**:运行时拼出来的返回值、深度混淆的代码 → 判为 `unknown`,不是"没问题"
2. **不检测恶意**:一个满足全部契约的插件照样能偷数据。这需要进程外的隔离与权限手段,不是本工具的职责
3. **不拦截**:发现违规只告警。要"拦住"需要 `tools.guard()` 注册执行前守卫——那会影响正常工作,
   是另一个决策(见下方"设计取舍")
4. **词汇表有版本性**:内嵌表来自 `@deepseek-ai/dsh-session@0.1.0-rc.6`。
   升级 DSH 后请跑 `npm run verify:vocab`,不一致就 `npm run gen:vocab` 重新生成并复核
5. **实测 render(默认开启,可关)**:真调用一次 `render()` 能大幅提高判定率
   (实机:静态 21/21 判不准 → 加了实测后 20 个能判定)。
   代价是它会真的执行一次插件代码;假数据不合形状时**一律归 unknown,绝不误报成违规**。
   要关:`probeRender: false`
6. **未覆盖的契约**:以下两类**不在本工具能力范围**——
   - **打包白名单**(`files` 是否漏了 `dsh.bundle.patch` 指向的文件):那是「包在磁盘上的属性」,
     用 `npm pack --dry-run` 核对,注册表里看不到
   - **是否杀宿主进程 / 自杀式重启**:那是运行时行为,不是工具定义的静态属性
7. **实机状态(2026-09-19)**:已在真实 DSH 中挂载运行——web profile 以 `link:` 接入后,
   会话头部出现状态按钮,实机读数 `无耗 · 检查 21 个插件 · 违规 0 · 不可判定 1`
   (其中实测判定 20)。两点如实说明:
   - **黄灯路径已实机走通**(2026-09-20):临时装入一个故意违规的测试夹具后,
     状态变为 `无耗 · 检查 23 个插件 · 违规 2 · 不可判定 1`,且**点名准确**。
     夹具验证完即移除。顺带验证了实测探针的价值:一个**静态判不准**的 `render`,
     被实测抓成了真实违规 —— 静态看不透的,跑一次就清楚。
   - 首次体检的日志行未经核对(DSH 输出在启动它的控制台窗口,未落盘);
     `tools/change` 的作用域是否到达本插件也未验证,因此插件额外做了兜底:
     **首次见到会话流量时补做一次体检**。

---

## 设计取舍

- **为什么只告警不拦截**:拦截意味着误报会直接**阻断你的正常工作**。本工具的首要目标是"零误报",
  而零误报是动态目标;在两者冲突时选择不干预。要拦可以后续加一个显式开关。
- **为什么不做成"防投毒"**:进程内的检查器与恶意代码同权限,对抗不了;把它宣传成安全工具
  会给出**虚假的安心**,比不做更糟。
- **为什么合并进这个包而不是并进 session-doctor**:两者的信任域不同——doctor 处理"你自己的会话",
  本工具审"别人写的包"。分开更清楚。

---

## 测试

```bash
npm run build         # 构建客户端产物 lib/client.js(改了 src/client.js 后必须先跑)
npm test              # 168 个用例:判定三态 / 实测 / 审计 / 词汇表 / 插件壳 / 源码扫描 / 打包检查 / 状态与路由 / 客户端产物 / CLI
npm run verify:real   # 真实代码零误报验收
npm run verify:vocab  # 词汇表漂移检查
```

> `npm test` 会校验产物里盖的**源码指纹**:改了 `src/client.js` 却忘了 `npm run build`
> 会直接测试失败(否则用户拿到的是旧界面)。

## License

MIT

Install

dsh plugin --profile web add github:imtokenxinluo/dsh-contract-check

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source