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 包一层:
// 我们读到的是这个壳 —— 函数体只是一句"调用别人",静态永远看不透
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 的赋值
结论:插件写一个自定义事件名 = 使用者的会话打不开,且作者无法自救。
两种用法
1) 装进 profile(进程内体检)
把包加进 profile 依赖与 bundle 列表:
// 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)
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。
本工具的重点始终是运行中的注册表与会让会话打不开的契约,不是发布前的仓库检查。
它是按 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 条,一致。
局限(如实)
- 静态分析有盲区:运行时拼出来的返回值、深度混淆的代码 → 判为
unknown,不是"没问题"
- 不检测恶意:一个满足全部契约的插件照样能偷数据。这需要进程外的隔离与权限手段,不是本工具的职责
- 不拦截:发现违规只告警。要"拦住"需要
tools.guard() 注册执行前守卫——那会影响正常工作,
是另一个决策(见下方"设计取舍")
- 词汇表有版本性:内嵌表来自
@deepseek-ai/dsh-session@0.1.0-rc.6。
升级 DSH 后请跑 npm run verify:vocab,不一致就 npm run gen:vocab 重新生成并复核
- 实测 render(默认开启,可关):真调用一次
render() 能大幅提高判定率
(实机:静态 21/21 判不准 → 加了实测后 20 个能判定)。
代价是它会真的执行一次插件代码;假数据不合形状时一律归 unknown,绝不误报成违规。
要关:probeRender: false
- 未覆盖的契约:以下两类不在本工具能力范围——
- 打包白名单(
files 是否漏了 dsh.bundle.patch 指向的文件):那是「包在磁盘上的属性」,
用 npm pack --dry-run 核对,注册表里看不到
- 是否杀宿主进程 / 自杀式重启:那是运行时行为,不是工具定义的静态属性
- 实机状态(2026-09-19):已在真实 DSH 中挂载运行——web profile 以
link: 接入后,
会话头部出现状态按钮,实机读数 无耗 · 检查 21 个插件 · 违规 0 · 不可判定 1
(其中实测判定 20)。两点如实说明:
- 黄灯路径已实机走通(2026-09-20):临时装入一个故意违规的测试夹具后,
状态变为
无耗 · 检查 23 个插件 · 违规 2 · 不可判定 1,且点名准确。
夹具验证完即移除。顺带验证了实测探针的价值:一个静态判不准的 render,
被实测抓成了真实违规 —— 静态看不透的,跑一次就清楚。
- 首次体检的日志行未经核对(DSH 输出在启动它的控制台窗口,未落盘);
tools/change 的作用域是否到达本插件也未验证,因此插件额外做了兜底:
首次见到会话流量时补做一次体检。
设计取舍
- 为什么只告警不拦截:拦截意味着误报会直接阻断你的正常工作。本工具的首要目标是"零误报",
而零误报是动态目标;在两者冲突时选择不干预。要拦可以后续加一个显式开关。
- 为什么不做成"防投毒":进程内的检查器与恶意代码同权限,对抗不了;把它宣传成安全工具
会给出虚假的安心,比不做更糟。
- 为什么合并进这个包而不是并进 session-doctor:两者的信任域不同——doctor 处理"你自己的会话",
本工具审"别人写的包"。分开更清楚。
测试
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