- Главная
- Плагины
- Сеансы и сообщения
- dsh-gatekeeper
dsh-gatekeeper
huajikano/dsh-gatekeeper
Inspects the model's streamed reply before it is committed and either discards it or holds it for review when it reads as a refusal, so that text does not stay in the session context. Detection combines built-in phrase rules (an opening refusal, a capability denial, policy wording, first-person intent, refusals quoted in reasoning) with a plain-text word list and optional regular expressions. Settings cover the detection range, the word list and a record list. Interception can sit at the llm/stream waterfall, at the HTTP fetch layer before encryption (not TLS decryption, and it cannot tell which session a call belongs to), at both, or in a preview mode that shows a transient preview while the original chunks stay in memory until the whole reply has been checked. The rules are heuristic and can miss phrasings, and a transient preview can briefly display the refused text.
Установка
dsh plugin --profile web add github:huajikano/dsh-gatekeeperREADME
闸总 · Gatekeeper
把模型说出口的"抱歉,我不能协助…"挡在会话之外。
一个 DeepSeek Harness 插件:在流式 chunk 落进会话日志之前判定并拦截拒绝性回复,避免它污染后续每一轮上下文。
包名:dsh-gatekeeper 插件行 id:gatekeeper
它解决什么
模型偶尔会回一段"抱歉,我不能协助…"。这段话一旦进入会话日志,后面每一轮请求都会重新读到它,越滚越长,把上下文带偏。
这个插件挂在 llm/stream 上——包住每次流式模型调用的 waterfall——在 agent loop 把 chunk 折叠成 assistant/message 并提交之前做判定。命中就丢弃内容,usage 元数据照常透传,token 计量不受影响。因为替换发生在提交之前,会话日志里根本不会出现那段拒绝原文。
安装
# 在本机 clone 后,用 DSH 的插件管理器安装这个目录
# 包类型是 bundle,patch 会插入 id 为 gatekeeper 的一行
装好后在 设置 → 拒绝拦截 里能看到开关与记录面板。
拦截点
llm/stream 是包住每次流式模型调用的 waterfall:
'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
Agent loop 从这条流里折叠出 assistant message 并提交。插件包住 next(),边收边判,命中就丢弃内容 chunk。
实时预览与正式提交分离(新增 preview 模式)
在「设置 → 闸总 → 拦截位置」选择 实时预览,完整检查后提交。原有 stream / wire / both 保留,默认仍为 stream;安装升级不会主动切换正在使用的会话。
- id: gatekeeper
name: dsh-gatekeeper
config:
interceptAt: preview
onDetect: ask # 命中后等待人工放行/丢弃;block 则自动丢弃
replacementText: '' # preview 丢弃分支始终不写标记
scanReasoning: false
scanToolArgs: false
- 预览通道:模型原始内容只留在插件内存,通过同源、已认证、按会话隔离的 SSE 推送到输入框上方。预览不是正式 assistant 消息,明确标注“尚未提交”。视图更新最多每 32ms 合并一次;仍有本地检测、序列化与传输开销,不承诺与原生逐毫秒相同。
- 正式提交:生成成功结束后检查选定内容的全文,不使用
scanChars/ 快速提前放行的捷径。通过后原样回放保留的 chunk、工具调用与 replay 信息。检测器仍是启发式,不保证识别所有自然语言拒绝。 - 命中:
ask保持当前流未提交,复用审核面板;人工放行才回放原文,丢弃/超时只产生无文本的正常结束,不保留原始流或 replayState,也不写替换标记。 - 取消与失败:接收或待审时取消,不转发原始前缀;提供方错误保留失败状态与 usage,让 DSH 按原策略处理。Host 退出、重启、保存配置或禁用插件会丢失未提交的内存内容,不从磁盘恢复,也不自动放行。
- 工具:预览只显示工具名称,不执行;执行仍由 DSH 在正式提交后调度。正常工具通常与原生一样须等本次模型输出结束,另外增加本地检查和快速回放开销。
- 范围:保护的是经此插件流入口进入 DSH 的内容,不覆盖提供方日志、其他插件日志、外部写入或已发生的工具副作用。关闭思考/工具参数检测时,这些部分不参与拦截。
预览默认最多保留单条 8MiB、全局 64MiB 的 chunk JSON 字节预算(实际运行内存还包括字符串、对象、序列化和网络缓冲,会高于该预算)。超限明确报错,不偷偷降级为放行。客户端预览显示最近 65536 字符,完整暂存仍参与检查。最多 16 个预览连接,慢连接跳过中间快照,重连只恢复当前内存状态。
previewMaxBytes、previewGlobalMaxBytes 可通过配置调整。预览模式不默认把原文写入审计记录;审核全文只在当前进程内存和已认证界面中存在。
本地可控流测试验证了“第一段预览在上游结束前可见,正式流此时收到零个原始 chunk”、末尾/跨 chunk 命中、回放一致、取消、错误、审核、并发隔离、预算及断线。另已用临时隔离插件在真实 DSH 子会话验证:被拦标记没有出现在生成的消息、原始流或工具调用记录中,回合 completed、零重试;正常输出先产生预览事件,随后原样提交。正常探针曾遇到提供方错误重试,不能据此宣称所有上游都无重试。真实浏览器显示与点击尚未人工验收,不以插槽注册代替视觉验证。
提交边界是完整检查通过(或人工放行)后开始回放。回放不是不可中断的原子操作:此时取消可能留下已审核通过的前缀,但接收和待审阶段不会提交未放行原文。插件取消能停止消费,不保证立即停止忽略 AbortSignal 的提供方网络连接或计费。
既有的两种拦截位置
interceptAt 决定守卫站在哪里。两者可分别启用,也能同时开。
provider ──▶ [TLS 之前 / globalThis.fetch] ──▶ 适配器 ──▶ [llm/stream] ──▶ agent loop ──▶ 会话日志
wire stream
stream(默认) | wire | |
|---|---|---|
| 挂载点 | llm/stream waterfall | globalThis.fetch |
| 看到的内容 | 结构化的 StreamChunk | 加密前的原始字节(SSE 帧 + JSON) |
| 能看到出站请求体 | ✗ | ✓ |
| 知道会话 / purpose | ✓ | ✗ |
| 命中后的收尾 | 干净的替换文本 | 补协议终止帧(默认 data: [DONE]) |
| 协议依赖 | 无 | 终止帧需与协议匹配 |
| 稳定性 | 高 | 较低(改了进程级 fetch) |
关于「TLS 层」
插件跑在 Host 进程里,没有密钥,无法解密别人的 TLS 流量。但它也不需要——Host 就是客户端,明文在加密前一步就存在。钩住 globalThis.fetch 就站在了整条链最上游:比 SSE 解析早,比适配器早,比 llm/stream 早。
这也是唯一能看到出站请求体的位置,所以请求侧拦截只能在这里做。
为什么钩 globalThis.fetch 能生效:OpenAI SDK 在构造客户端时绑定 fetch(options.fetch ?? getDefaultFetch()),而 pi-ai 适配器每次流式调用都新建客户端,所以下一次调用会取到被替换后的全局函数。
开启 wire 模式
- id: gatekeeper
config:
interceptAt: wire # stream | wire | both
wire 模式的可调项:
| 字段 | 默认 | 说明 |
|---|---|---|
wireUrlPatterns | /chat/completions 等 | URL 含任一片段才处理;留空 = 关闭 |
wireScanResponses | true | 边收边判响应流 |
wireScanRequests | false | 扫描出站请求体(提示词) |
wireBlockRequests | false | 命中就拒发;false 只记录 |
wireAbortUpstream | true | 命中后断开上游,省掉剩余 token |
wireTerminator | data: [DONE]\n\n | 中止后补的帧;留空则直接关闭 |
wireSkipBodyPatterns | 三条系统提示词片段 | 请求体含任一片段即视为 DSH 内部调用,完整放过 |
为什么要 wireSkipBodyPatterns:wire 层没有 purpose 字段,分不清主对话和 DSH 自己的辅助调用。压缩摘要、会话标题、auto-review 评审都会经过这里,改坏它们是之前已经修过的回归。所以用请求体里的系统提示词片段来识别——命中就原样放行。
both 会同时开两层:wire 先拦,stream 作为兜底。
实测
wire 模式下,拒绝文本在上游发出后即被判定、上游被断开、下游只收到终止帧。真实 Harness 里跑过(不是模拟):
settle raw=1215 reply=8 BLOCK blocklist
ASSISTANT: [{"type":"text","text":"[已拦截]"}]
TURN END: {"kind":"completed"}
原文没进会话,替换文本进了,这一轮正常完成(无重试、无报错)。
wire 模式的三个坑(都踩过了)
在真实 Harness 上验证时踩出来的,模拟测试发现不了:
-
判定必须针对解析后的文本,不是原始字节。 SSE 帧的 JSON 包装大约是正文的十倍,用原始字节比窗口,短回复会被判成"超出窗口"。
-
detect()内部按scanChars截断。 即使 wire 缓冲了整条回复,判定仍只看前 240 字。token 在 2200 字处就漏了。wire 判定必须显式放大窗口。 -
终止帧必须带内容。
- 只有
data: [DONE]→Stream ended without finish_reason(TRANSPORT 错误) - 加了
finish_reason: stop但内容为空 →EMPTY_RESPONSE,pi-ai 会重试五次再失败 - 带内容 +
finish_reason: stop+[DONE]→ 干净结束
所以 wire 模式下
replacementText为空无法干净收尾,这一轮会以EMPTY_RESPONSE结束。stream模式没有这个限制。 - 只有
wire vs stream 怎么选
stream | wire | |
|---|---|---|
| 收尾 | 干净,空替换也行 | 需要非空替换文本 |
| 流式体验 | 缓冲到 scanChars | 缓冲到 wireReleaseChars(默认 4000,延迟明显更大) |
| 能看到出站请求 | ✗ | ✓ |
| 稳定性 | 高 | 低(改了进程级 fetch) |
日常用 stream。 wire 留给"必须看请求体"或"怀疑有调用绕过了 llm/stream"的场景。
拦截之后怎么办
拦截有两种处理方式,由 onDetect 决定:
| 模式 | 行为 |
|---|---|
block(默认) | 直接丢内容,replacementText 为空时什么都不写。确定性生效。 |
ask | 命中后等待人工放行或丢弃;推荐结合 preview 模式,检查完成前不提交原文。 |
关于
ask的更正:旧版曾把子 agent 先通过send_message发出文本误判为“暂停生成器被绕过”,该结论缺乏证据。新preview模式在每次输出结束、所有选定内容检查后才请求人工决定,放行前不给 agent loop 原始 chunk;路由往返和取消行为有独立测试。关闭工具参数检测时,文本仍可能通过合法工具参数输出,这是检查范围的选择,不能等同于最终回复的审核被绕过。
丢弃时默认什么都不写(replacementText: '')。会话里留下的是一个空 assistant 消息——没有文本、没有标记、没有 token 消耗。这条空消息是安全的:agent loop 每步无条件追加 assistant/message,所以"什么都不写"之后同一会话的下一轮请求实测仍然成功(provider 接受空 content)。
放行没有走 DSH 的 approval 服务:那个服务在会话审批策略为 never 时会直接返回 rejected 而不弹窗,而本 profile 的默认策略正是 never——用它做拒绝提示会永远弹不出来。插件自带一套待审区和路由。
界面
插件带一个客户端面板:
- 输入框下方的黄色横幅:有待审回复时出现。点它弹出审核面板,显示这一条回复的完整原文(不是 200 字预览)、命中原因、字符数。面板里放行或丢弃,处理完自动关闭,回到会话。Esc 或点遮罩也能关。
- 设置 → 拒绝拦截:开关、命中后行为、检测长度、超时、替换文本等配置;待审列表(处理后自动关闭设置面板);以及最近 50 条记录——丢掉的回复看不到了,但记录还在。
审核面板用 react-dom 的 createPortal 挂到 document.body:dock 槽里的固定定位会被输入框卡片裁剪,必须 portal 出去。模块表没提供 react-dom 时退回内联渲染,不会整块崩掉。
客户端通过三条同源路由跟 Host 通信:
| 路由 | 方法 | 作用 |
|---|---|---|
/api/dsh-gatekeeper/state | GET | 配置 + 待审列表 + 计数 |
/api/dsh-gatekeeper/decide | POST | { id, action: 'release' | 'block' } |
/api/dsh-gatekeeper/config | POST | 部分配置,校验后写入 profile |
自定义词库
内置规则抓不到的说法,用 blocklist 补。它是纯文本匹配,不是正则,写进去就能用:
- id: gatekeeper
config:
blocklist:
- 帮不了
- 无法配合
- not able to help
- 一行一条,自动 trim、转小写、去空行
- 对原始文本和归一化文本各匹配一次(所以带下划线/星号的也能中)
- 命中任一条即拦,recent 记录里 reason 显示
blocklist - 设置页有文本框,还有「填入推荐词库」一键补一批常见说法
正则(patterns)和词库(blocklist)可以同时用:词库管「这个说法我见过」,正则管「这类模式」。
排查「为什么没拦住」
设置页顶部有一行:本插件已检查 N 次模型调用。
这个数字是判断问题出在哪的唯一可靠依据:
| 现象 | 结论 |
|---|---|
| 模型回了拒绝话术,但 N 没涨 | 插件当时没被加载(重装窗口期、插件被禁用、Host 未重启) |
| N 涨了,但没拦 | 规则没覆盖这种说法 → 去 recent 记录里看 reason,然后补 blocklist |
N 涨了、recent 里有 guard-error | 插件自身出错了;错误会写进 recent,回复按放行处理(fail open) |
为什么专门加这个:我排查 session-d27aa8ac 那次漏拦时,日志里那条回复用 detect() 单测跑是能拦的(policy+denial),但会话里没拦住——说明问题不在规则,而在插件当时没在跑。光看会话是分不出这两者的。
为什么它会拦到"讨论拒绝"的对话
插件分不清在说拒绝和在讨论拒绝。当你在一段对话里反复引用拒绝样本(写测试、写文档、解释这个插件本身),三条路径都会命中:
| 路径 | 触发场景 | 默认 |
|---|---|---|
| 可见正文 | 回复开头就是拒绝语 | 安全,误伤极少 |
| 思考内容 | 思考里引用/分析拒绝样本 | 已默认关闭 |
| 工具参数 | 写文件时 fixture 里含拒绝原句 | 已默认关闭 |
所以默认配置现在只守可见正文——这是唯一"模型在说话"的路径。另外加了两条抑制规则:
- 示例上下文抑制:引号前 40 字符内出现
例如/比如/示例/样例/引用/测试/样本/for example/sample/example/fixture时,该引号判定为"在讨论",不拦。 - 会话排除:
sessionsExclude里的会话完全不管。
推荐配置
日常使用(只拦真正说出口的拒绝):
- id: gatekeeper
config:
onDetect: block
scanReasoning: false
scanToolArgs: false
要连思考一起管(误伤明显升高,配合 dryRun 先看):
- id: gatekeeper
config:
onDetect: block
scanReasoning: true
dryRun: true
auditPath: C:\temp\refusal-audit.jsonl
开发/调试这个插件本身:把当前会话 id 加进排除,否则它会拦你自己的测试输出。
sessionsExclude:
- session-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
怎么调才不会越调越乱
- 先
dryRun: true跑一段,看面板「最近记录」里的reason和原文开头 - 误伤了 → 要么关掉对应扫描路径(
scanReasoning/scanToolArgs),要么把那条说法从词库里去掉 - 漏拦了 → 用
blocklist补具体说法,别急着加正则 patterns(正则)留给"这类模式",别用它做词库
排查「保存不了」
设置页保存走 POST /api/dsh-gatekeeper/config,失败时页面会直接显示服务端返回的原文。
unknown config key "..."—— 提交体里混进了非配置字段。已修:服务端现在会丢弃未知键,只把认识的设置写进 profile。this profile cannot persist configuration—— 当前 profile 没有 config editor,或插件行不是由 patch 挂载的;这种情况直接改cordis.patch.yml。- 其他校验错误 —— 比如
scanChars must be an integer >= 1,按提示改。
保存成功后插件会重启生效,计数器与待审列表会清空。
判定规则
单看"不能""无法"会误杀——正常的工程回复里满屏都是这些词("这个函数不能返回空指针")。所以判定要求拒绝的形态,不是关键词:
| 条件 | 含义 |
|---|---|
| 开头附近有拒绝语 + 一处能力否认 | 抱歉,我不能… |
| 两处独立的能力否认 | 我无法协助…,也不能提供… |
| 开头附近有拒绝语 + 政策框架 | Sorry, but this is against my guidelines. |
| 政策框架 + 能力否认 | 我不能提供,因为违反了我的使用准则 |
开头窗口默认 120 字符,只看前 600 字符。低于 8 字符的回复不判。
另外:回复里带工具调用的一律放行——它在干活,不是在拒绝。要连工具调用一起拦,把 blockToolCalls 打开。
思考内容也检查
scanReasoning(默认开)让 reasoning-delta 也进入判定。思考里常常引用它准备说的话,比如:
The user is asking me to reply with exactly one sentence: "抱歉,我无法协助完成这个请求。"
普通规则只看开头,抓不到这种。所以思考额外走一层引号判定:抽取 「」『』“”《》"" 里的整段内容,对每段重新跑一遍普通规则——引号内正好是拒绝句开头的位置。
BLOCK quoted:opener+denial | The user is asking me to reply with exactly one sentence: "抱歉,我无法协助完成这个请求。"
BLOCK quoted:opener+denial | 用户让我回复「抱歉,我无法协助完成这个请求。」这是拒绝句。
pass | 引用一句普通文本「请把偏移量填好」不算拒绝。
这个开关是有代价的:思考里只要引用了一句拒绝样本(哪怕是在解释、在写文档)就会命中。觉得误伤多就关掉,只检查最终回复。
配置
在 profile 的 cordis.patch.yml 里按 id 覆盖:
- id: gatekeeper
config:
scanChars: 600
dryRun: false
replacementText: '[gatekeeper] 已拦截一段拒绝性回复,原文未进入会话。'
| 字段 | 默认 | 说明 |
|---|---|---|
enabled | true | 总开关 |
onDetect | 'block' | block 自动丢弃;ask 检查命中后等待人工决定 |
askTimeoutMs | 120000 | ask 模式下等你多久,超时按丢弃 |
dryRun | false | 只记录不拦截,用来先调检测 |
scanReasoning | false | 思考内容也参与判定(误伤高,按需打开) |
scanToolArgs | false | 工具参数也参与判定(写文件/发消息会误伤) |
scanChars | 600 | 检测多少字符 |
minChars | 8 | 短于这个长度不判 |
openerWindowChars | 120 | 拒绝语必须出现在开头多少字符内 |
replacementText | '' | 丢弃后写进会话的文本。空字符串 = 什么都不写 |
patterns | [] | 自定义正则,命中即拦(大小写不敏感) |
blocklist | [] | 自定义词库:纯文本、一行一条、不区分大小写,命中任一条即拦 |
sessions | [] | 只守这些 session id;空表示全部 |
sessionsExclude | [] | 这些 session 永不拦截 |
purposes | [] | 额外要守的 purpose;空表示只守主对话 |
blockToolCalls | false | 连带工具调用的回复也拦 |
auditPath | '' | 非空则把每次命中追加成 JSONL |
建议先开 dryRun: true 跑一段时间,看 auditPath 里的记录确认没有误杀,再关掉。
只守主对话流(重要)
llm/stream 是所有模型调用的唯一出口——LlmRuntime.streamWithRegistration() 是单一漏斗,主对话和 DSH 自己的辅助调用都从这里过:
streamWithRegistration(options, prepared) {
return this.ctx.waterfall(this, "llm/stream", options, () => this.adapterStream(options, prepared));
}
所以插件不能见文本就拦。DSH 有三个内部 purpose,拦了会出事:
| purpose | 谁在用 | 拦了会怎样 |
|---|---|---|
compaction | 压缩摘要 | 摘要被替换 → 上下文丢失 |
session-title | 会话标题 | 替换文本变成标题 |
auto-review | 工具授权评审 | 评审 JSON 被替换 → 解析抛错 → 工具调用被拒 |
判定规则就一条:主对话请求没有 purpose 字段(agent loop 用 markAgentLoopRequest() 构造,只有 messages/toolHistory/sessionId/signal),辅助调用都带 purpose。所以默认只守 purpose === undefined 的调用;带 purpose 的一律放过,除非你把它加进 purposes。
未知 purpose 也放过——将来 DSH 加了新的内部调用,不会被这个插件搞坏。
和 @deepseek-ai/dsh-experimental-auto-review 的关系
功能不重复,是两件事。
| auto-review | 本插件 | |
|---|---|---|
| 挂在哪 | tools/pre-execute | llm/stream |
| 管什么 | 工具调用执行前 | 回复文本进入会话前 |
| 谁决定 | 另起一次 LLM 调用判风险等级 | 本地正则判拒绝形态 |
| 输出 | allow / deny / ask 用户 | 丢弃原文,换一句标记 |
| 失败方向 | fail closed(拒了工具调用) | fail open(原样放行) |
一个是执行前的闸门,一个是发言后的过滤。auto-review 不碰回复文本,本插件不碰工具调用。
唯一交叉点是它们共用 llm/stream:auto-review 自己的评审调用(purpose: 'auto-review')也走这条流。改动前本插件会拦它——现在默认放过,不会互相干扰。
关于 cc-switch
farion1231/cc-switch 是评估过的对照实现,不是代码来源。
借用的只有定位判断:它证明了"在 HTTP 层拦模型回复"这件事是成立的,这给 wire 模式选层提供了依据。
没有借用任何实现:检测规则、正则、流处理、头部处理全部是本项目自己写的。它 src-tauri/src/proxy/ 下的模块与本插件没有对应关系:
| cc-switch 的模块 | 它在做什么 | 与本插件的关系 |
|---|---|---|
classifier_guard.rs | 连续三轮拿到"分类器不可用"的 tool_result 就停,靠数消息历史 | 无。那是事后止损,本插件是事前拦截 |
body_filter.rs | 递归剥掉 _ 前缀的私有字段 | 无。本插件不剥字段 |
upstream_guard.rs | 拒绝把请求转发回自己的监听端口(SSRF 防护) | 无。本插件没有监听端口可以配错 |
media_sanitizer.rs / cache_injector.rs | 媒体与缓存注入 | 无对应 |
两者的部署模型正好相反:
| cc-switch | 本插件 | |
|---|---|---|
| 形态 | Rust 桌面程序,进程外 HTTP 代理 | Node 插件,进程内 |
| 生效方式 | 改 provider 的 baseURL 指向代理 | 装进 Host,不动 provider 配置 |
| 看到的数据 | 网络字节(需自行解析 SSE) | wire 模式是原始帧;stream 模式是结构化 chunk |
能知道会话/purpose | 不能 | stream 模式能 |
| 适用范围 | 任何走 HTTP 的 AI 客户端 | 仅 DeepSeek Harness |
结论:要跨客户端通用,用 cc-switch;要按会话/用途精确控制,用本插件。 两者可以并存。
已知边界
- 检测是启发式,不是分类器。
patterns用来补漏。 - 拦截后模型那一轮就"什么都没说"。如果你希望它换个说法重试,那是另一个插件的事。
- 流式显示会有
scanChars那么多字符的延迟——判定前必须缓冲。
测试
node --test test/*.test.mjs
覆盖旧模式、中英规则、预览先于提交的时序、全文和跨块命中、审核原始证据、取消、失败、内存预算、SSE 会话隔离与重连、卸载清理。测试计数以实际运行结果为准。
Похожие плагины
billion-context
ranxianglei/billion-context
dsh-web-ui (dsh-chat-recovery)
zhu1090093659/dsh-web-ui
dsh-synapse
liangmianya/dsh-synapse
dsh-archive-manager
michengai/dsh-archive-manager