Saltar al contenido principal
H

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.

Instalar

dsh plugin --profile web add github:huajikano/dsh-gatekeeper

README

闸总 · 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 waterfallglobalThis.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 含任一片段才处理;留空 = 关闭
wireScanResponsestrue边收边判响应流
wireScanRequestsfalse扫描出站请求体(提示词)
wireBlockRequestsfalse命中就拒发;false 只记录
wireAbortUpstreamtrue命中后断开上游,省掉剩余 token
wireTerminatordata: [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 上验证时踩出来的,模拟测试发现不了:

  1. 判定必须针对解析后的文本,不是原始字节。 SSE 帧的 JSON 包装大约是正文的十倍,用原始字节比窗口,短回复会被判成"超出窗口"。

  2. detect() 内部按 scanChars 截断。 即使 wire 缓冲了整条回复,判定仍只看前 240 字。token 在 2200 字处就漏了。wire 判定必须显式放大窗口。

  3. 终止帧必须带内容。

    • 只有 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 怎么选

streamwire
收尾干净,空替换也行需要非空替换文本
流式体验缓冲到 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/stateGET配置 + 待审列表 + 计数
/api/dsh-gatekeeper/decidePOST{ id, action: 'release' | 'block' }
/api/dsh-gatekeeper/configPOST部分配置,校验后写入 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

怎么调才不会越调越乱

  1. 先 dryRun: true 跑一段,看面板「最近记录」里的 reason 和原文开头
  2. 误伤了 → 要么关掉对应扫描路径(scanReasoning/scanToolArgs),要么把那条说法从词库里去掉
  3. 漏拦了 → 用 blocklist 补具体说法,别急着加正则
  4. 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] 已拦截一段拒绝性回复,原文未进入会话。'
字段默认说明
enabledtrue总开关
onDetect'block'block 自动丢弃;ask 检查命中后等待人工决定
askTimeoutMs120000ask 模式下等你多久,超时按丢弃
dryRunfalse只记录不拦截,用来先调检测
scanReasoningfalse思考内容也参与判定(误伤高,按需打开)
scanToolArgsfalse工具参数也参与判定(写文件/发消息会误伤)
scanChars600检测多少字符
minChars8短于这个长度不判
openerWindowChars120拒绝语必须出现在开头多少字符内
replacementText''丢弃后写进会话的文本。空字符串 = 什么都不写
patterns[]自定义正则,命中即拦(大小写不敏感)
blocklist[]自定义词库:纯文本、一行一条、不区分大小写,命中任一条即拦
sessions[]只守这些 session id;空表示全部
sessionsExclude[]这些 session 永不拦截
purposes[]额外要守的 purpose;空表示只守主对话
blockToolCallsfalse连带工具调用的回复也拦
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-executellm/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 会话隔离与重连、卸载清理。测试计数以实际运行结果为准。

Plugins relacionados