dsh-sensitive-replace
Manifest validSensitive-character replacement for DeepSeek Harness: matches messages and tool results against a YAML blocklist you maintain (global, per provider id, or per model) before the model reads them, and s
dsh-sensitive-replace
敏感字符自动替换插件(v0.7.2):
按用户自行维护的屏蔽表文档,在内容进入会话上下文之前、以及请求出网之前两个关口把命中字符替换为占位符,
从源头规避 DeepSeek 官方 API 的 Content Exists Risk 400(根因:特定政治敏感字符会命中官方审核)。
若会话已经触发 400 ,插件也能自动加硬清洗并重试,防止因上下文中的敏感内容进入请求造成再次触发。
替换是单向且不可逆的(详见能力与边界);屏蔽表格式不正确会在加载期明确告警。
本包是一个 DSH Profile Bundle(package.json 里声明 dsh.bundle.patch),装进 profile 即自动挂载。
代码、版本与来源
- 最新代码在 GitHub 仓库
Short-Arm-Ape/dsh-sensitive-replace的main上,可能含尚未发布的改动; - 正式版本参见同仓库的 Releases(tag
vX.Y.Z)或 npmdsh-sensitive-replace——这两处同号,由 CI 从 tag 发布;引用版本号以它们为准; - Gitee
Short_Arm_Ape/dsh-sensitive-replace是 GitHub 的镜像,可能滞后。
同类项目:生态里已有 dsh-text-guard 等同类插件,差异见与同类插件的关系。
安装
前置条件:需确保 dsh 命令可用,本说明提供的命令所指的 profile 名默认 web,请根据实际情况修改。
方式一:从 npm 安装(推荐)
dsh plugin --profile web add dsh-sensitive-replace
dsh plugin 本质是在 profile 目录里跑 pnpm,并在装完后把「声明了 dsh.bundle 的依赖」自动并入 dsh.profile.bundles 层栈。
本包声明了 dsh.bundle.patch,所以它自带的 cordis.patch.yml 会在下次启动 profile 时被组合进去,无需手动编辑 cordis.patch.yml 。
装完重启该 profile(dsh web 的 profile 若开着 patchReload: live,改 patch 层可热生效;新增依赖仍建议重启一次)。
方式二:通过 git 从仓库安装
本包 lib/ 是入库的,没有构建步骤:
# GitHub(可添加 #<tag> 或 #<commit> 参数指定版本,如下方所示的 #v0.7.2 )
dsh plugin --profile web add github:Short-Arm-Ape/dsh-sensitive-replace#v0.7.2
# Gitee(国内镜像)
dsh plugin --profile web add git+https://gitee.com/Short_Arm_Ape/dsh-sensitive-replace.git
注:
- 生产环境建议下加
#<tag>参数指定到最新正式版本。 - 安装 git 依赖要求本机装有 git,并且能连接到对应站点。
- 中国大陆地区用户若下载缓慢或不稳定可以从 Gitee 安装,但 Gitee 上的版本可能落后。
- pnpm 首次安装 git 依赖可能提示「允许构建」(
allowBuilds);本包没有prepare/postinstall脚本,按提示批准即可。
方式三:本地 tarball
dsh plugin --profile web add /abs/path/dsh-sensitive-replace-0.7.2.tgz
方式四:作为普通 npm 依赖安装 + 手动编辑 cordis.patch.yml
npm install /abs/path/dsh-sensitive-replace-0.7.2.tgz # 或放进 profile 的依赖里
然后在 profile 的 cordis.patch.yml(用户 patch 层)里追加以下内容:
- insert:
- id: sensitive-replace
name: 'dsh-sensitive-replace'
[必读]准备屏蔽表
屏蔽表默认路径位于 $DSH_HOME/sensitive-blocklist.yaml($DSH_HOME 默认 ~/.dsh;Windows 上默认 %USERPROFILE%\.dsh;
可用 config blocklistPath 修改)。
本仓库不含具体的屏蔽词内容,需要你自己维护屏蔽表:把要屏蔽的字符按格式填进去,保存即生效(按 mtime 热重载,无需重启)。
屏蔽表的填写可参考 samples/sensitive-blocklist.example.yaml ——里面有全局 / 按 Provider ID / 按模型三种写法和 re: 正则。
注:如果启动时这个文件不存在,插件会写一张空表模板(只有格式说明,不含任何屏蔽词)并记一条日志。
这样"忘了建表"至少不会变成静默空转;但空表 = 一条规则都没在生效。已存在的文件永远不会被覆盖(wx 独占创建);
无法建表(权限/路径问题)只在日志里告警,插件照常运行。若需取消该行为请设置 createBlocklistIfMissing: false。
配置覆盖
按行 id 覆盖(用户 patch 层在 bundle 层之后,后者生效):
- id: sensitive-replace
config:
enabled: true
autoReplace: true
detectUnfiltered: true
blocklistPath: /abs/path/to/sensitive-blocklist.yaml
replaceWith: '[BLOCKED]'
替换内容的时机(三道关口)
DSH 里发往模型的请求是深冻结的:dsh-llm 的消息工厂 createMessage → freezeMessage → deepFreeze(structuredClone(...)),
dsh-agent-loop 构请求时还会对每条消息 deepFreeze() 并 Object.freeze(boundaryMessages)。
所以在 llm/stream 瀑布里原地改写 options.messages 会抛 TypeError(严格模式)——v0.4.0 正是踩在这里:
异常被 catch 掉后照常出网,自动面静默空转。但改成"拷贝一份再发"就完全可行,这正是 v0.5.0 的第一道关口。
| # | 关口 | 钩子 | 覆盖 |
|---|---|---|---|
| 1 | 进上下文(消息) | agent/pre-step | 用户输入、steering、插件形成的进入步骤的消息 → 返回 { kind:'enter', messages: 替换后的消息 }(保留消息 id,不原地改写) |
| 2 | 进上下文(工具结果) | tools/post-execute | 网页抓取/搜索、读文件、命令输出等外部文本(含 tool-result 嵌套块)→ 返回 { kind:'accept', content: 替换后的块 } |
| 3 | 出网前(整份请求) | llm/stream | 历史消息 + 模型自己写过的内容 + tool-call 参数 + system:深拷贝整份请求、替换命中内容后直接续到适配器边界 |
前两道关口发生在写入会话日志之前,所以替换结果持久生效。但"已经躺在日志里"的内容(本次会话早先读进来的、 模型自己写出来的、tool-call 参数)前两道关口碰不到——请求是从日志派生的,它们照样出网。第三道关口补上这个洞:
llm/stream(options, next)
├─ 命中规则 → scrubRequest(options) 深拷贝清洗
│ → llm.adapterStream(clean, undefined) // 直接续链,不重进瀑布
└─ 未命中 → next() // 原样放行
不重进瀑布是有意的:其它 llm/stream 监听器(会话标题观察、检查点 flush)不会被同一次调用重复触发。
若 adapterStream 不存在则退回公开的 llm.stream(clean),并用 WeakSet 防止递归。
400 自动恢复(recoverOn400)
若不慎触发 400错误(例如内容里有你尚未写进屏蔽表的字符),插件将在 agent/request-error 里接管:
- 判定
failure.status === 400(或code === 'INVALID_REQUEST',DeepSeek 适配器就是这么映射的); - 开启加硬清洗:在规则之外额外剥离 emoji / 旗帜 / 杂项符号一类码位(
[\u{1F000}-\u{1FAFF}]等),持续aggressiveMs; - 返回
{ kind:'retry' }—— 循环重试时会重新从会话日志派生请求,于是这次请求就能发出去,对话与上下文保持连续,不用新建会话。
窗口内重试次数由 maxRecoveries 封顶(默认 2 次 / 10 分钟),避免 400 循环;到顶后照常失败并提示"把触发内容加进屏蔽表"。
加硬清洗也可以在没出事时常开:
aggressive: true(偏执模式)。
路由(provider/model)的判定
agent/pre-step / tools/post-execute 的载荷里没有 provider/model,而规则是分 Provider ID / 模型的。
插件监听 agent/request 瀑布,按 agent 记住最近一次解析出的 provider/model,供清洗时套用专属规则;
会话退场(agent/disposed)时清掉缓存。
边界:会话第一次请求之前路由还没解析出来,此时只套 global 规则;第一次请求之后,该会话的专属规则就都能用上。
出网前那一道直接用请求自带(且最准)的 options.provider / options.model,不受这个限制。
[必读]能力与边界
替换内容(命中屏蔽表即换,三道关口合计覆盖):
- 用户/插件发来的新消息(
agent/pre-step,进日志前); - 所有工具结果文本(
tools/post-execute,进日志前); - 出网前的整份请求(
llm/stream深拷贝清洗):历史消息、模型自己写过的 text/reasoning、tool-call参数、system —— 包括装插件之前就已经躺在日志里的内容。
不会替换的内容:
tool-call参数里的替换是文本级的:它保持 JSON 合法,但如果某个规则正好匹配到 JSON 结构字符(比如规则里写了"command"),替换后语义仍合法、只是工具可能收到不同参数 —— 规则尽量写"内容词",别写结构词;- 图片/文件附件(二进制,不参与文本替换);
- 命中内容如果不在屏蔽表里,就不会被替换 —— 这是 400 的来源。此时
recoverOn400会兜底(加硬清洗 + 重试),但它只剥离 emoji/符号一类码位;治本还是把词加进屏蔽表。
替换是单向的,插件不提供还原(挂之前先想清楚):
- 关口 1/2 的替换发生在内容写入会话日志之前,所以日志里存下来的就是占位符;原文只存在于你的原始输入或外部数据源里,插件不会再写回去。
- 关口 3 只替换发往 provider 的那一份副本,会话日志保持原文(这是有意的,见安全约定)。因此同一个会话里,你看到的可能是原文、模型看到的是占位符 —— 两边故意不一致。
- 平台目前没有给插件「执行前改写 tool-call 参数」或「展示前改写助手文本」的合法挂点(
PreToolDecision只有allow/deny/cancel/ask,不含改参;会话事件 append-only 且深冻结),所以占位符一旦落进日志或工具参数,就会原样执行、原样展示。 - 需要事后对回原文时,用
replaceWith: '[BLOCKED:{n}]'+ 工具返回的hits明细,按屏蔽表当前文件人工对照;插件不会自动还原。
诊断:sensitive_replace_status 工具(只读)给出规则数、屏蔽表是否存在、各关口清洗次数、最近一次出网前替换处数、是否处于加硬清洗、400 自动恢复次数,
以及每条规则的命中次数(只给编号 / 位置 / 次数 / 距今秒数,不含规则文本:编号规则见形态)和屏蔽表形态警告明细。
与同类插件的关系
解决同类问题的插件不止一个,最接近的是 dsh-text-guard(作者 7771sd,MIT)。 两者思路一致(都在内容进日志前中和),差别在覆盖面与运维方式:
| 能力 | 本插件 | dsh-text-guard |
|---|---|---|
进日志前中和(agent/pre-step / tools/post-execute) | ✅ | ✅ |
run_code 子分发日志副本(tools/ptc-dispatch-log) | 不需单独挂(子分发结果写该日志前已经过 tools/post-execute,见下) | ✅ 另挂一道 |
出网前整份请求清洗(llm/stream):历史消息、模型自己写过的内容、tool-call 参数、system prompt | ✅ | ❌(其 README 把"system prompt 里的文本"列为已知盲区) |
| 400 自动恢复(加硬清洗 + 同会话 retry) | ✅ | ❌ |
| 目标形态 | 用户维护的 YAML 屏蔽表(字形 / \u{XXXX} / re: 正则),global → Provider ID → 模型三级链 | 配置里的码位序列(regional-pairs 或 exact) |
| 改配置后 | 保存即生效(按 mtime 热重载) | 需重新挂载 bundle |
| 诊断面 | sensitive_replace_status(计数 + 每条规则命中 + 形态警告)、replace_sensitive(手动清洗任意文本) | 无 |
关于 tools/ptc-dispatch-log:本插件没有单独挂它,因为 run_code 的子分发结果是先过 tools/post-execute、再写日志的 ——
dsh-tools 的 commit() 先 scheduler.finalize()(= 跑 post-execute)拿到结果,之后 settle() 才用这份结果写 tool/ptc-dispatch 事件,
所以子分发内容不会绕过第二道关口。要自行核对,看 dsh-tools/lib/index.js 里的 finalizeScheduledExecution 与 shapeDispatchLog 两条路径即可。
建议:只要码位级、零配置的方案(专治 emoji/旗帜一类码位)用 dsh-text-guard 更轻; 要出网前兜底(含历史内容与 system prompt)+ 400 自动恢复 + 可热改的屏蔽表就用本插件。 两者可同时挂载(规则各自独立),但没必要 —— 本插件覆盖了它除那道冗余挂点之外的全部能力。
生态里还有一批目标不同的插件:PII / 凭据脱敏(dsh-privmask、dsh-privacy-protector)、
屏蔽文件读取(Yazzyk/dsh-file-shield)、隐私掩码(Huauauaa/dsh-privacy-mask)等 ——
它们的目标"不想让模型看到什么",本插件的目标"避免官方 API 400造成会话中断"。
形态
本插件不内置具体屏蔽词,而是需要用户维护一份 YAML 屏蔽表,按「全局 / Provider ID / 模型」分类,手动填入需要屏蔽的字符或短语(字形、\u{XXXX} 码位、正则均可)。
- 默认文档:
$DSH_HOME/sensitive-blocklist.yaml(可用 configblocklistPath覆盖) - 结构:
global:—— 对所有 Provider ID 生效的字符列表(最通用)providers:下按 Provider ID 分键 —— 键 = DSH 的 provider 路由 id(即请求options.provider,如deepseek-official)
- 保存即生效:按文件 mtime 热重载,增删屏蔽项无需重启
- 文件不存在时写空表模板(v0.7.1,兜底):启动时写一份带说明的空表模板,不覆盖已有文件;建不出来只告警不报错。空表等于没有规则,这不是开箱即用,是怕你忘了建表变成静默空转
# 敏感字符屏蔽表(支持注释)
global:
- '\u{1F600}' # 对任何 Provider ID 生效(emoji 码位举例)
providers:
deepseek-official: [] # 仅对 DeepSeek 官方生效(Provider ID 精确匹配)
匹配模式(按 Provider ID / 模型,三级链)
providers.<键> 的键对应当前请求的 options.provider —— 这是 DSH 的 provider 路由 id(设置页 / settings.yaml 里每个 provider 配置的 id),不是显示名、也不是厂商名。
按「来源名」猜键名(deepseek 对不上真实 id deepseek-official)会导致专属规则静默空转。
三级匹配链(从宽到窄,同时生效叠加):
global—— 对任何 Provider ID 生效;providers.<id>—— Provider ID 匹配:精确键优先,键尾带*为前缀模式(一族 id 共用,多命中取最长前缀);值可为数组(简单)或对象{ '_': 基础, models: 按模型 };providers.<id>.models.<model>—— 再按模型细分(options.model),模型键精确优先,键尾*为前缀模式。
providers:
deepseek-official:
_:
- '示例词A' # 该 Provider 所有模型的基础规则(对象形态的基础规则放 `_`)
models:
'deepseek-flash': [ ... ] # 精确键:本机真实 model id,只对该模型生效
'deepseek-*': [ ... ] # 前缀模式:一族模型共用(多命中取最长前缀)
'deepseek*': [ ... ] # 前缀模式:一族 Provider 共用(此处为举例)
⚠ 这页最容易写错的三种写法(都真实踩过,注意对照):
- 数组形态里再缩进写
models:—— YAML 直接报错bad indentation(序列里不能放映射键)。只有基础规则时用数组deepseek-official: [ '词A' ];要按模型细分就必须改成对象形态、基础规则放_下(如上例)。models:与deepseek-official:同级 —— 会被解析成另一个 provider(id 就叫models),里面的条目永不命中。v0.7.0 起这种形态会在加载期明确告警(旧版是静默失效)。- 模型键写成显示名/厂商名/猜的版本号 —— 必须写本机真实的
options.model(如deepseek-flash);用deepseek-v4-flash、deepseek-ai/*这类猜的键不会命中(v0.7.0 起,若该 provider 因此一条规则都没解析出来,也会告警)。另外记住:精确键命中后不再回落到前缀键(设计行为)。所以同一个 model 上,
models里写了精确键时,同族的前缀键不会再生效——想验证前缀键,换一个没有精确键的 model 试。
写错形态会告警(v0.7.0)
屏蔽表的语法错误(YAML 解析不了)一直是显式的:记 parseError、自动面空转、日志与工具面都提示。
但语法合法、语义写错的形态过去是静默失效的 —— 用户以为规则在防抖,实际一条都没生效。v0.7.0 起这类形态会变成警告:
- 在加载/热重载时打进运行日志(
屏蔽表警告: …,每条一行,只在屏蔽表版本变化时打,不刷屏); - 同时出现在
sensitive_replace_status的warningList与replace_sensitive的note里。
覆盖的形态:顶层出现 global / providers 之外的键(models: 写错位置会被点名)、providers.models 被当成 provider id、
provider 下直接写模型名、global / providers / models 类型不对、条目不是字符串(数字/布尔没加引号)、re: 正则无效、
以及兜底的"这个 provider 一条规则都没解析出来"。警告只报位置与形态,不复制规则内容。
规则编号(sensitive_replace_status 里的 ruleHits.n)
去重后按出现顺序编号:先是 global 的条目,再按文件顺序遍历 providers(每个 provider 先 _ 基础规则、再 models 下的各模型键)。
同一段规则文本在文件里出现多次时只占一个编号,计数合并到首次出现的位置。
有了它就能回答"我新加的那条规则到底有没有在真实流量里命中",而不用发测试消息:
ruleHits: [ { n: 3, scope: 'providers.deepseek-official/models.deepseek-flash', count: 12, lastAgoS: 40 } ]
n = 编号(0 表示该规则已从屏蔽表里删掉,或来自 400 加硬清洗的内置规则),scope = 它在文件里的位置,count = 累计命中次数,lastAgoS = 距今多少秒。
不含规则文本,可安全贴给别人看。
条目写法
- 普通字符串 = 字面量(精确匹配,
\u{XXXX}码位会自动解码); re:...= 正则表达式(u标志、大小写敏感;无效正则跳过并记警告,不中断其它规则)global: - 're:机密[0-9]{2,}'- 占位符支持
{n}模板:replaceWith: '[BLOCKED:{n}]'⇒ 每次命中替换为带规则序号的占位符(1 起:global在前、当前 Provider/模型规则在后), 配合工具返回的hits明细(n×count),按屏蔽表当前文件对应条目即可还原原文;不写{n}时保持单一[BLOCKED](默认,向后兼容)。
值写法:单引号
'...'(内容原样)或裸写;不要用双引号包\u{XXXX}(YAML 会把它当转义解析而报错 —— 若文件写坏,插件自动面空转并在日志/工具结果提示「YAML 解析失败」,不影响其它功能)。
工具面
replace_sensitive—— 手动清洗任意文本;provider/model缺省只套全局规则,指定时叠加对应专属规则;返回清洗后文本、替换计数与命中明细(hits)。sensitive_replace_status—— 只读诊断:规则数、屏蔽表是否存在、各关口清洗次数、最近一次出网前替换处数、是否处于加硬清洗、400 自动恢复次数、 每条规则的命中次数(ruleHits:编号 / 位置 / 次数 / 距今秒数)与屏蔽表形态警告明细(warningList)。 不返回任何屏蔽词内容(规则只以编号出现),可安全用于确认"出网前清洗是否真的在工作"、以及"新加的规则有没有真的命中"。
配置(profile cordis.patch.yml 里按行 id 覆盖)
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
| enabled | boolean | true | 总开关(工具面 + 自动面) |
| autoReplace | boolean | true | 进上下文前自动替换(agent/pre-step + tools/post-execute) |
| dispatchScrub | boolean | true | 出网前清洗整份请求(深拷贝后替换,含历史 / 模型输出 / tool-call 参数 / system) |
| detectUnfiltered | boolean | true | 未开启 dispatchScrub 时的只读扫描:仍有命中就记警告 |
| recoverOn400 | boolean | true | 400 时开启加硬清洗并 retry(对话保持连续) |
| aggressiveMs | number | 600000 | 加硬清洗持续时长(ms) |
| aggressive | boolean | false | 常开加硬清洗(不等 400 也额外剥离 emoji / 符号) |
| maxRecoveries | number | 2 | 10 分钟窗口内最多自动重试几次(防 400 循环) |
| blocklistPath | string | $DSH_HOME/sensitive-blocklist.yaml | 屏蔽表文档路径 |
| createBlocklistIfMissing | boolean | true | 文件不存在时兜底创建空表模板(已存在绝不覆盖;失败只告警;空表=无规则) |
| replaceWith | string | [BLOCKED] | 替换占位符——含 {n} 时按规则序号编号(便于还原),明示内容被屏蔽而非静默删除 |
replaceWith 用进 tool-call 参数时会自动做 JSON 转义,不会把参数弄成非法 JSON。
测试用例
安装/构建后所有断言都可直接跑,判断链路真通(不是只看退出码)。
自动单测(在包目录执行)
npm install # 装运行依赖 js-yaml 与 peer(cordis / schemastery / dsh-tools / dsh-llm)
npm test # 等价于裸 `node --test`
裸 node --test(自动发现 tests/ 下的用例;别用 node --test tests/ 或 node --test tests——Node 会把目录当模块入口,直接报 MODULE_NOT_FOUND;也别用 node --test test/*.test.mjs 这类通配写法——不通配展开的 shell 会跑 0 个用例假绿)。五个文件:
仓库内所有用例的 provider / model 键与规则条目都是编造的占位符,不含真实屏蔽词。
tests/match.test.mjs(8 条):三级匹配链——global兜底、Provider ID 精确键、键尾*前缀模式(多命中取最长)、未命中时只套全局、显式空键阻止回落。tests/features-v040.test.mjs(8 条):模型精确 / 模型前缀 / 未命中只基础、显式空模型键防回落、Provider 前缀族内按模型、正则命中 + 无效正则记警告、{n}编号占位符 +hits明细、默认无hits、global在前、旧数组形态等价。tests/auto-v041.test.mjs(14 条):用假 ctx 捕获真实注册的监听器来驱动 ——agent/pre-step替换且保留消息 id / 不原地改写、无命中与reject原样透传、空表不介入;tools/post-execute替换 content 与tool-result嵌套块、value / block 形态不动;路由追踪(agent/request之后套用专属规则、agent/disposed后回到只 global);开关组合、坏屏蔽表不抛;以及根因回归(对dsh-llm工厂产出的真实深冻结消息同样能清洗)。tests/outbound-v050.test.mjs(8 条):scrubRequest覆盖 text/reasoning/tool-result/tool-call 参数/system 且不动原请求、保留消息 id;tool-call 参数的 JSON 安全性(占位符含引号也不破 JSON);llm/stream命中时不调用 next、直接续到adapterStream、无命中时放行、dispatchScrub=false退回只读扫描;agent/request-error的 400→retry、非 400 放行、超上限放弃;400 之后规则外的 emoji 也被加硬清洗剥离。tests/v070.test.mjs(8 条):形态警告(models:写错位置、顶层杂键、global类型错、provider 下直接写模型名、非字符串条目只报一次、合法文档零警告);规则编号顺序;replaceText的sink回调与向后兼容;三道关口的命中落到对应规则编号且状态输出里不含规则文本;热重载后警告进运行日志。
期望结果:tests 46 / pass 46 / fail 0(EXIT=0)。
30 秒手测(链路是否真通)
示例屏蔽表(samples/sensitive-blocklist.example.yaml)在 global 里放了一个随机低碰撞占位符 blocklist-check-9f3a8c21e7(不含任何真实敏感词)。把它复制为 $DSH_HOME/sensitive-blocklist.yaml 后:
- 工具面:调工具
replace_sensitive,text含blocklist-check-9f3a8c21e7,provider不填; 期望返回removed=1、cleaned里出现[BLOCKED]。 - 进上下文关口:让 agent 跑一条命令
echo blocklist-check-9f3a8c21e7; 期望你看到的工具结果里就是[BLOCKED](说明结果在写入日志前已被替换),日志同时出现工具结果进入上下文前替换 1 处命中字符。 - 再直接给 agent 发一句含该占位符的话;模型看到的内容里该串应已被换成
[BLOCKED]。 - 出网前关口:把该串放进 agent 的命令行参数里(tool-call 参数不会被关口 1/2 洗,会原样留在日志),
然后调
sensitive_replace_status:dispatchScrubs/dispatchLast应该 > 0 —— 证明请求在出网前被拷贝清洗过。 - 验证完删掉该占位符,换成你自己的屏蔽词。
配套工具:找出"到底哪个字符触发 400"
仓库根目录带两个等价的探测脚本,用于找出到底是哪些字符让官方 API 返回 400(本插件最初的屏蔽表就是这么定位出来的)。
它们直接打 chat/completions(max_tokens: 1),三种模式:
| 模式 | 用途 |
|---|---|
single | 输入一个词、回车即发 —— 快速验证"这个词是不是触发词" |
batch | 一行按空格分词逐个发 —— 一次过筛一批候选 |
binary | 多行输入、单独一行 END 提交 —— 命中后自动二分 + 边界重定位,把长文收敛到最小触发片段 |
| 平台 | 脚本 | 依赖 |
|---|---|---|
| Windows | content-risk-interactive.ps1 | PowerShell(Windows 自带 curl.exe) |
| Linux / macOS | content-risk-interactive.sh | bash 4+ 与 curl(不需要 jq / python) |
Windows:
$env:DSK_PROBE_KEY = '你的 API key' # 也可不设,脚本会提示输入
pwsh -File .\content-risk-interactive.ps1
Linux / macOS(脚本没带执行位也可以直接 bash 跑):
export DSK_PROBE_KEY=sk-...
bash content-risk-interactive.sh # 交互模式
bash content-risk-interactive.sh -m deepseek-flash -t '待测文本' # 单发
bash content-risk-interactive.sh -m deepseek-flash -b -t '长文本…' # 二分(非交互,可直接进脚本/CI)
bash content-risk-interactive.sh -m deepseek-flash -f ./可疑摘录.txt
bash content-risk-interactive.sh -e https://your-gateway/v1/chat/completions -m deepseek-flash -t hi
交互模式两版通用:mode single|batch|binary 切模式、model <名> 切模型、file <路径> [起始] [长度] 发送文件切片、h 帮助、q 退出。
Linux 版另有非交互开关 -t(单发)/-T(批量)/-f(文件,配 -s 起始、-n 长度)/-b(二分),退出码:
0 = 未触发(HTTP 200)、2 = 触发 400、3 = 网络或其它失败、4 = 用法 / key 缺失。
两版的判定是一致的(v0.7.0 起 PS1 也补上了):二分收尾都会用长度 n-1 的前缀/后缀确认"不存在更短的连续触发片段",
再删掉中间一个字符看是否仍然触发,从而把 最小连续片段(整段字符缺一不可) 与 上下文组合触发(两个分离片段共同作用) 分开报。
平台差异只有两处:Linux 版带非交互开关与退出码(便于脚本化),并把切片对齐到 UTF-8 字符边界
(locale 不支持多字节时先尝试用 UTF-8 locale 重跑自己,否则用 iconv 校验边界),
避免切出半个汉字/emoji 导致请求本身非法、从而产生假 400。
⚠ 用 v0.7.0 之前的 PS1 探测过的话,结论值得复查一遍:旧版用
Set-Content -Encoding utf8写请求体, 在 PowerShell 5.1 上会带上 BOM,而带 BOM 的 JSON 在严格的网关/后端看来是非法请求体 —— 它回的 400 与Content Exists Risk的 400 长得一样,会被当成"命中触发词",把二分引向不存在的片段。 现在请求体写成无 BOM 的 UTF-8(与 Linux 版行为一致)。
两个脚本都不内置任何屏蔽词、不内置 key、不写任何配置文件:key 只从 $DSK_PROBE_KEY 或现场输入取(Linux 版还支持 -k,但会警告它留在 shell 历史里),
命中的片段只打印在你的终端里(别把含命中片段的输出贴到公开处)。
它们是独立工具,不参与插件运行;不需要就删掉(package.json 的 files 里没有它们,npm 包不会带上)。
故障排查
dsh: failed to read overlay …/dsh-sensitive-replace/cordis.patch.yml: ENOENT(启动就挂)
这条不是屏蔽表的问题,而是装进 profile 的那份包不完整(cordis.patch.yml 没了):
DSH 读到 package.json 的 dsh.bundle.patch 指向 ./cordis.patch.yml,去包目录里找却找不到,于是启动失败。
本包的 files 里含该文件,npm pack 出来的包里一定有它 —— 所以出现这个错,说明那份包本身缺文件
(常见于手工组装过包目录、或用旧产物解包后只拷了一部分)。
# ① 看那份包里到底有没有这个文件
ls "$DSH_HOME"/profiles/<profile>/node_modules/dsh-sensitive-replace/cordis.patch.yml
# ② 有就用当前版本覆盖重装;急用可以先卸掉让 DSH 起来
dsh plugin --profile <profile> remove dsh-sensitive-replace
dsh plugin --profile <profile> add /abs/path/dsh-sensitive-replace-0.7.2.tgz
自己打的包也可以先 npm pack --dry-run 看一眼:文件列表里应当有 cordis.patch.yml。
插件装上了、屏蔽表却是空的:先确认表在哪(sensitive_replace_status 里 blocklistExists 为真、且 规则 全局0/来源0 组 就是"表在但没写规则")。
如果文件压根不存在,v0.7.1 起会兜底写一张空表模板,日志里有
屏蔽表不存在 → 已创建空表模板: <路径>;照那个路径填规则即可。若文件确实存在但一条规则都没加载,
看 sensitive_replace_status 的 warningList(形态写错会明确告警)与 blocklistExists。
安全约定
源码 / 日志 / 输出不携带屏蔽字符字形,只以码位、计数与规则序号出现;替换位为纯 ASCII 占位符(默认 [BLOCKED]),明示内容被屏蔽而非静默删除。本仓库任何文件(含 tests/、samples/、README、两个 content-risk-interactive.* 探测脚本与类型注释里的示例)都不含真实屏蔽词 —— 示例一律用随机低碰撞占位符(blocklist-check-…)或编造的 provider / model 键;真实屏蔽表只存在于使用者本机 $DSH_HOME/sensitive-blocklist.yaml,发布包可放心分发。
仓库内的规则条目、provider / model 键与码位示例都可以逐条读;
sensitive_replace_status的ruleHits/warningList也只给编号、位置与次数,不含条目文本。
也就是说:出网前那道清洗不写日志、也不回填日志 —— 日志里保留原文,只有发往 provider 的那一份被替换。 这是有意的:不改写会话日志的"可重建"语义,人类看到的对话记录也保持完整。
版本变更
0.7.2
- 状态行能看出屏蔽表在不在:
sensitive_replace_status的那行中文里补了|屏蔽表 存在/|屏蔽表 缺失(未建表)(紧跟规则数)。0.7.1 只把blocklistExists放进了结构化输出, 看工具结果时仍然分不清"表是空的"和"表没读到"。 - 工具 description 同步写清这一项。
- 新增
tests/v072.test.mjs(4 条),全量 59 条。
0.7.1
- 屏蔽表缺失不再是一片空白:启动时若
blocklistPath指向的文件不存在,就写一张空表模板 (只有结构说明与global: []/providers: {},不含任何屏蔽词),并在日志里写明路径 —— 用户不用猜文件该放哪、该长什么样。约束:wx独占创建,绝不覆盖已有文件;父目录不存在会一并创建; 创建失败(权限/路径非法)只告警、不抛异常,插件照常运行(规则为空)。开关createBlocklistIfMissing(默认 true)。 sensitive_replace_status增加blocklistExists(0.7.2 起也显示在那行中文里),用来区分"表是空的"与"表没读到"。- 新增
ensureBlocklist()/BLOCKLIST_TEMPLATE两个导出,便于脚本侧复用同一份模板。 - 新增
tests/v071.test.mjs(8 条),全量 55 条。
0.7.0
- 屏蔽表写错的形态不再静默失效:
parseBlocklist现在会记录「语法合法但语义写错」的形态(顶层杂键、providers.models被当成 provider id、provider 下直接写模型名、global/providers/models类型不对、数字/布尔条目没加引号、 以及"这个 provider 一条规则都没解析出来"),并在加载/热重载时逐条打进运行日志;sensitive_replace_status新增warningList,replace_sensitive的note也会带上。警告只报位置与形态,不复制规则内容。 - 规则级命中计数:
sensitive_replace_status新增ruleHits—— 每条规则(按文件出现顺序编号)的累计命中次数与 距今秒数,scope指明它在文件里的位置。可用来确认"新加的规则到底有没有在真实流量里命中",而不必发测试消息。 仍然不返回任何屏蔽词内容(只给编号)。 replaceText/replaceBlocksCopy/replaceMessageCopy/scrubRequest新增可选的sink(rule, count)回调 (不传则行为与 v0.6.2 完全一致),自动面用它做上述计数。- 无效
re:正则的警告文案改为只报位置(global 第 N 条/providers.<id>.models.<model> 第 N 条), 不再把正则模式文本复制进警告 —— 避免警告本身成为泄露面。 - 文档:补「替换不可逆」的明确说明、与同类插件(dsh-text-guard 等)的差异对比、探测脚本用法。
- 新增 Linux / macOS 版探测脚本
content-risk-interactive.sh:与 PowerShell 版功能对齐(single / batch / binary), 另有非交互开关与退出码(便于脚本化),二分收尾多两条判定以区分「整段缺一不可」与「两个分离片段共同作用」, 并把切片对齐到 UTF-8 字符边界以免产生假 400。 - 新增
tests/v070.test.mjs(8 条),全量 46 条。
0.6.2
- 仅打包与文档,无行为变化:仓库公开前的清理 —— 测试与文档示例里的真实屏蔽表内容
与本机标识(provider / model 键、码位示例)一律换成编造的占位符;修好
npm test; 去掉指向不存在的tsconfig.json的build/typecheck脚本与无用的 TS devDependencies; 补齐 LICENSE / .gitignore / 包元数据(repository / keywords / engines 等)。
0.6.1
- 对齐 DSH 0.2.0-rc.x,去掉跨版本豁免:
peerDependencies里dsh-tools/dsh-llm放宽为^0.1.0-rc.6 || ^0.2.0-rc.1(DSH 校验时带includePrerelease,因此覆盖 0.1.5-rc.x / 0.2.0-rc.x / 0.2.0 / 0.2.1-alpha.x / 0.2.1),不再需要dsh plugin allow-version的精确版本豁免。 - 适配器边界改为显式
continueToAdapter():rc2 起llm.adapterStream被标为 private (运行时仍在),公开入口只剩会重进瀑布的llm.stream();现在优先直连边界,缺失时退回llm.stream()并只告警一次。 - 新增
scrubDurableRequests(默认 true):rc2 的 log-reconstruction 不变量声明带sessionId的请求"只读不改写";设为false即严格守约(只留进上下文清洗 + 只读告警)。 sensitive_replace_status增加version/scrubDurableRequests,重启后一次工具调用 即可确认运行时加载的是哪一份构建。
0.5.0
- 新增出网前清洗
dispatchScrub(默认开):在llm/stream里深拷贝整份请求(历史消息、模型自己写过的 text/reasoning、tool-call参数、system)替换后直接续到适配器边界。这是唯一能清掉"已经进过日志"的内容的关口; v0.4.1 对此只能告警(请求深冻结、原地改写必然失败)。 - 新增 400 自动恢复
recoverOn400(默认开):捕获 400(INVALID_REQUEST)后开启加硬清洗 (额外剥离 emoji/旗帜/杂项符号码位)并返回{kind:'retry'};循环重试会重新从日志派生请求, 于是能发出去、对话与上下文保持连续,不需新建会话。窗口内重试次数由maxRecoveries封顶。 - 新增
aggressive(常开加硬清洗)、aggressiveMs、maxRecoveries三个配置。 - 新增只读诊断工具
sensitive_replace_status。 replaceBlocksCopy/replaceMessageCopy支持tool-call参数(自动 JSON 转义,不破 JSON); 新增导出scrubRequest。detectUnfiltered语义收窄为"未开启dispatchScrub时的只读扫描"。
0.4.1
- 修正自动面落点:由
llm/stream原地改写(在深冻结消息上必然抛错、被 catch 后静默空转)改为agent/pre-step+tools/post-execute,在内容写入会话日志前替换,替换结果持久生效。 - 新增
detectUnfiltered(默认开):出网前只读扫描,命中未替换内容时告警。 - 新增路由追踪(
agent/request):进上下文阶段也能套用 Provider ID / 模型专属规则。 - 新增辅助导出:
replaceBlocksCopy/replaceMessageCopy/countText/scanBlocks/scanOptions; 移除原地改写的replaceBlocks/replaceMessage/sanitizeOptions(它们在本平台上永远失败)。 - 包改为 DSH Profile Bundle(
package.json的dsh.bundle.patch+ 自带cordis.patch.yml),dsh plugin --profile <p> add后自动入层栈,不再需要手动编辑cordis.patch.yml。 - README 去掉平台专属的安装方式与硬编码路径,改为通用写法(
$DSH_HOME/%DSH_HOME%)。 - 修正
samples/与 README 里会静默失效的 YAML 示例(数组形态里嵌models:、以及猜的模型键),并补上「三种写法坑」警示 —— 原示例照抄会漏掉模型级规则。
0.4.0
- 三级匹配链(global → Provider ID → 模型)、
{n}编号占位符与hits明细、re:正则条目。
Comments
Loading…
Similar plugins
by gythialy
Sensitive-data guard for DeepSeek Harness: blocks credential reads/outbound secrets and redacts secrets, tokens, and PII before they reach the model, logs, or spill files.
★ 0
Apache-2.0
TypeScript
Oct 20, 2026
dsh plugin --profile web add @r0cky20/dsh-sensitive-guardby ax2
Deterministic, offline, bounded tools for DeepSeek Harness: 29 JSON/YAML/CSV, text, encoding and inspection tools, each returning a byte receipt.
★ 0
MIT
TypeScript
Oct 13, 2026
dsh plugin --profile web add dsh-deterministic-toolsby hg1048596-pixel
DeepSeek Harness (DSH) 插件:在模型读取前撤回已发送的文字消息,支持单条与全部撤回。A DeepSeek Harness plugin that recalls sent-but-unread text messages before the model reads them — one-by-one or all at once.
★ 127
↓ 237/wk
MIT
JavaScript
Oct 3, 2026
dsh plugin --profile web add dsh-recall-unreadDeepSeek Harness approval guardian: a reviewer model decides permission requests before they reach you, with a System One decision-API backend (TypeSafe Jev, SiliconFlow, self-hosted Laya), a configur
★ 0
dsh plugin --profile web add dsh-auto-review-jevby Tim-ReJet
Durable subagent execution for DeepSeek Harness: a `durable` provider and a model-facing tool that drive pi-durable-subagents, so a crashed delegation converges on its first outcome instead of running twice.
★ 0
MIT
TypeScript
Oct 14, 2026
dsh plugin --profile web add dsh-plugin-durable-subagentby tianyaojiudi-prog
DSH(DeepSeek Harness) 插件:把一段可在设置页随时改写的文字,作为全局系统提示词段注入到所有会话,含子代理与工作流内部子代理;文本与开关即时生效,无需重启。
★ 9
MIT
JavaScript
Oct 4, 2026
dsh plugin --profile web add dsh-djy-xttsc