Bundle
dsh-llm-injection-filter
DSH LLM response-stream injection/pollution filter: hard-block rare Unicode scripts (Track A) and score-based disposal of control chars / protocol markers / script mixing / spam keywords (Track B) on the llm/stream waterfall.
- Source
- XiaoYuOvO
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 5 days ago
Readme
# dsh-llm-injection-filter
DeepSeek Harness 的 **LLM 响应流注入过滤器**:在模型流式响应进入 agent 之前,实时检测疑似提示词注入 / 污染内容并处置。挂载于 `llm/stream` waterfall,对所有适配器生效,不改动任何运行时源码。
## 背景动机(真实安全事件)
一个 coding agent 会话(`gpt-5.6-terra` / `openai` / OpenAI Responses API)中,模型在生成 `edit` 工具的 `new_string` 参数时,于正常代码之后无缝接续输出了一段注入式载荷并被逐字写入源码、进入 Git 历史。污染样本:
```
//ฤศจassistant to=functions.edit 彩神争霸网站՞ւjson /*<<<ി՞նչ սպասի 微信天天中彩票】【。json
//
```
特征:协议控制标记(`assistant to=`、`/*<<<`、`json` 碎片)、垃圾广告语义(博彩)、多语种混淆(泰文/亚美尼亚文/马拉雅拉姆文)。
**关键教训**:主载荷是普通 ASCII(`assistant to=`),纯"特殊字符"检测不够;且模型输出是 token 粒度分片(`"ฤศจ"` `"assistant"` `" to"` …),单个 delta 往往太短无法综合判断。因此:
- **轨道 A**(罕见脚本)恰好可以**逐 delta 立即命中**(`"ฤศจ"` 单独一个 delta 就含泰文)→ 硬性中断;
- **轨道 B**(评分制)必须**累积到完整 tool-call arguments(block-end)再评分**处置。
## 两条轨道
### 轨道 A:罕见脚本硬性阻断(默认开启,最高优先级)
- 模型输出中出现**任何一个不属于允许脚本集合**的 Unicode 字符 → **立即中断**(不等 block-end、不参与评分、无视 `mode` 取值)。
- 允许脚本集合(默认):`Latin`、`Han`、`Common`、`Inherited`。
- 检测含性能快筛:纯 ASCII(`/[^\x00-\x7F]/`)直接放行,零逐字符开销。
- 触发即中断:丢弃未发布 chunk,yield 一个终止性 error `finish` chunk(`code=RESPONSE_FILTERED`),关闭底层 adapter 流,结束包装流。
- **为什么能中断 agent turn(已从源码确认)**:
1. agent loop 消费完流后检查 `assembler.finish`,`finish.kind === 'error'` 分支先于消息组装执行(不落 assistant/message);
2. 进入 `agent/request-error` waterfall(`dsh-llm-retry` 检查 `failure.code`):`RESPONSE_FILTERED` 不在默认可重试集合(`EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT`)→ 不自动重试;
3. agent loop `throw new LlmError(message, code, failure)` → `turn/end { reason: { kind: 'error', ... } }` → 该 turn 立即终止,agent 回到 idle;
4. 前端 `dsh-client-ui-conversation` 专门渲染 `turn/end reason.kind==='error'` 错误横幅——`failure.message` 即用户可见错误文本(含 provider/model/被阻断字符/样本/原因)。
### 轨道 B:评分制规则(audit / strip / reject)
| 规则 | 判定 | 权重 | 应用层 |
|---|---|---|---|
| CTRL 控制字符 | `[\x00-\x08\x0b-\x1f\x7f]` | 100 | 逐 delta(text/args) |
| PROTO 协议标记 | `assistant to=`、`/*<<<`、`<system-reminder>`、`<compacted-checkpoint>`、`<!--`、`[system]`/`[instruction]` | 100 | 逐 delta + block-end |
| SCRIPT 混合脚本 | arguments 中非 Latin/Han/Common 脚本 **≥3 种且非基础字符 ≥8** | 80 | 仅 block-end 完整 arguments |
| KW 垃圾关键词 | 彩神/争霸/天天中彩票/博彩/casino/gambling/betting | 40 | block-end |
| 组合加成 | PROTO 命中且发生在 **tool-call arguments**(可执行面) | +30 | block-end |
- 总分 ≥ `minScore`(100) 触发。KW 永不单独触发(仅加分);SCRIPT 必须与 ≥1 个 PROTO/CTRL 同现才计入总分。
- 处置模式:
- `audit`:只记录一行结构化日志,不改动输出;
- `strip`:命中子串替换为 `[FILTERED:<n>]`(只替换字符串值内部,保持 JSON 引号/结构不变;逐 delta 与 block-end 权威内容同步改写);
- `reject`:block-end 把命中 tool-call 的 `block.arguments` 改写为 `{"$rejected":"dsh-injection-filter","reason":"<摘要>"}`(缺必需字段 → 工具 schema 校验失败 → `INVALID_ARGS` 返回模型,模型可自我纠正);text 命中替换为 `[FILTERED:<n>]`;reasoning 只 audit。
- 轨道 A 与 B 独立、同时生效;A 命中时不做 strip/reject,直接中断。
## 配置
```js
const CFG = {
enabled: true, // 总开关
hardBlockRareScripts: true, // 轨道 A:罕见脚本 → 直接中断(最高优先级,无视 mode)
allowedScripts: ['Latin', 'Han', 'Common', 'Inherited', 'Greek'], // 允许脚本白名单(含 Greek 放行数学希腊字母);可追加 'Thai'/'Cyrillic' 等放行
checkReasoning: false, // 是否对 reasoning-delta 也做轨道 A 检测(默认关)
mode: 'audit', // 轨道 B:'audit' | 'strip' | 'reject'
minScore: 100, // 轨道 B 触发阈值
applyToProviders: [], // 空 = 全部 provider;可填 ['openai']
applyToModels: [], // 空 = 全部模型;非空 = 仅检查这些模型(子串/前缀匹配,如 ['gpt-5'])
skipProviders: [], // 提供商跳过名单:命中即不检查(子串匹配)
skipModels: [], // 模型跳过名单:命中即不检查
maxRecords: 100, // 拦截记录环形缓冲上限(设置页可查)
extraKeywords: [], // 额外关键词
maxScanLen: 16384, // 轨道 B 单段扫描长度上限
maxSampleLen: 120, // 审计样本截断长度
recordMaxLen: 8192, // 中断记录中载荷详情(delta / 已累积 arguments)的截断长度
}
```
## 审计日志
每次命中输出一行结构化日志(`console.error`):
```
[llm-injection-filter] {"provider":"openai","model":"gpt-5.6-terra","sessionId":"...","callId":"call_1","toolName":"edit","rule":"HARD_BLOCK","mode":"audit","score":210,"detail":{...},"sample":"..."}
```
`rule` ∈ `HARD_BLOCK | CTRL | PROTO | SCRIPT | KW`;只提取标量,`sample` 截断 ≤ `maxSampleLen`。
## 中断时详细记录(RECORD)
**轨道 A 命中、需要中断 agent loop 时**,除上述紧凑审计行外,额外输出一条结构化 RECORD 行,包含**出现问题的请求身份**与**相应载荷的详细信息**(取证用):
```
[llm-injection-filter][RECORD] {"event":"llm-stream-filtered","code":"RESPONSE_FILTERED","time":"<ISO8601>","request":{"provider":"openai","model":"gpt-5.6-terra","sessionId":"...","purpose":"agent-loop"},"block":{"kind":"tool-call-arguments","index":0,"callId":"call_1","toolName":"edit","deltaText":"<命中 delta 原文,截断>","deltaLength":<完整长度>,"accumulatedArguments":"<拦截瞬间已累积的完整 arguments,截断>","accumulatedLength":<完整长度>},"char":"<被阻断字符>","charCode":<U+ 码点>,"script":"Thai","sample":"<样本截断>"}
```
- `block.kind` ∈ `text | tool-call-arguments | reasoning`;tool-call 场景额外携带 `index/callId/toolName/accumulatedArguments`(拦截瞬间的完整载荷,含命中 delta 本身)。
- 载荷详情按 `recordMaxLen` 截断(`deltaLength` / `accumulatedLength` 保留完整长度);只提取标量/有界文本,不触碰 `options.messages` 等大型 live 对象;不落盘,随插件 fiber 输出到结构化日志通道。
## WebUI 设置页(运行时配置)
v1.1.0 起,Web 客户端「设置 → LLM 注入过滤器」整页可实时调整(**改动即时生效,无需重启**):
- **快捷全局开关**:页面顶部总开关(等同 `CFG.enabled`),可一键暂停全部检测;
- **拦截哪些字符类别**:允许脚本白名单 chip 编辑(点击 chip 移除、点击下方候选项/输入框添加,如 `Cyrillic`)——即 `allowedScripts`;
- **轨道 A / reasoning / 轨道 B 模式**开关与下拉;
- **检查范围**:跳过的提供商 / 跳过的模型 / 仅检查这些提供商 / 仅检查这些模型——即 `skipProviders`、`skipModels`、`applyToProviders`、`applyToModels`(支持子串/前缀);
- **拦截记录查询**:列表展示最近 N 条(时间 / provider/model / 工具名 / 脚本 U+ 码点或规则+得分 / 样本 / 原因),可刷新、清空。
架构(三路径中的 **Path C + B 混合**,免改 harness 白名单):
- 客户端 = `settings.section` 顶级设置页(`lib/client.js`,`dsh.client` 声明 web 平台);状态持久化在 **localStorage**(`dsh-llm-injection-filter.v1`);
- Host 侧每次 `llm/stream` 分派读**运行时状态**(启动时复制自 `CFG` 默认值),设置页通过插件私有 fenced 路由读写:`GET/POST /api-llm-injection-filter/{state,set,records,clear-records}`(`webServer.register`,首次流触达惰性补挂);记录也存进程内环形缓冲(`maxRecords`,硬阻断与 reject 都写入)。
- 打开设置页时会自动把 localStorage 里保存的配置应用到 Host;Host 重启后配置回到默认,需在设置页重新保存(localStorage 仍在,页面打开即自动应用)。
- **默认允许集合含 Latin/Han/Common/Inherited/Greek**。含 `Greek` 是为了放行数学表达式中常用的希腊字母(如 U+03B8 角 theta 等),避免合法数学输出被硬阻断(用户实测反馈后加入)。**仍不含日文(假名)/韩文(谚文)/俄文(西里尔)等**,含这些脚本的合法输出会被硬阻断——这是刻意设计的极端严格语义(宁可误杀,不可放过);逃生门是 `allowedScripts` 配置(可追加如 `'Thai'`、`'Cyrillic'` 放行相应脚本)。
- `maxScanLen` 只约束轨道 B 的逐段扫描/替换(超出部分原样保留、不处理);轨道 A 不设上限(快筛优先,宁可慢,不可放过)。
- `reject` 模式依赖 block-end 携带完整 arguments 做整块改写;极少数不发 block-end 的 delta-only 流中 tool-call 参数无法整块改写(原样透传,仅审计)。
- 轨道 A 检测自身绝不抛异常(try/catch 包裹,异常时放行该 delta);规则编译失败在 apply 时即报错并停用该规则。
## 交付形态
- **动态插件**:`code.host` 即本源码(纯 JS、无 import、顶层常量配置),`return { apply, CFG }` 收尾;`ctx.on('llm/stream', ...)` 注册的监听器随插件 fiber 自动清理,停止/卸载零残留(无定时器/全局订阅)。
- **npm 包**:`lib/index.js`(本文件)+ `package.json`,`module.exports = { apply, CFG, ... }`;Node ≥18。
## 正式安装(web profile,官方 CLI)
包声明了 `dsh.bundle.patch`(`cordis.patch.yml`),`dsh plugin` 的 bundle 协调会自动把它加进
`dsh.profile.bundles`,下次启动即随 host 组合挂载(对所有会话的 `llm/stream` 生效)。
```powershell
# 路径含空格需用 8.3 短路径(cmd 批处理转发会吞掉空格段)
dsh plugin --profile web add "link:G:/DEEPSE~1/data/dsh/plugins/LLM-IN~1"
# 验证:组合树中应出现 id: llm-injection-filter / name: dsh-llm-injection-filter
dsh --profile web --dump-config
```
安装后:
- `profiles\web\package.json` → `dependencies` 增加 `"dsh-llm-injection-filter": "link:G:/DEEPSE~1/data/dsh/plugins/LLM-IN~1"`;
`dsh.profile.bundles` 末尾自动追加 `"dsh-llm-injection-filter"`。
- `profiles\web\node_modules\dsh-llm-injection-filter` → Junction 指向源码目录(改源码即生效,下次重启加载)。
- **重启 DSH 后生效**;重启前由会话内动态插件(`injf-1`)继续覆盖。
## 从 GitHub 一键安装(推上 GitHub 后)
仓库根目录 = **包根目录**(`package.json` 必须在仓库根)。包无构建脚本、无运行时依赖 →
安装无 `allowBuilds` 拦截、无 peer 警告。推上公开 GitHub 后,一条命令完成安装 + 挂载:
```powershell
# 默认分支
dsh plugin --profile web add github:XiaoYuOvO/dsh-llm-injection-filter
# 指定分支/提交/tag(可选)
dsh plugin --profile web add github:XiaoYuOvO/dsh-llm-injection-filter#main
# 按 tag 安装(推荐,锁定已验证版本)
dsh plugin --profile web add github:XiaoYuOvO/dsh-llm-injection-filter#v1.1.0
```
原理:`dsh plugin` 把参数转发给 pnpm(原生支持 `github:` 规格,走 codeload tarball),
安装后 CLI 的 bundle 协调读取已装包 manifest 的 `dsh.bundle.patch` 并自动追加到
`dsh.profile.bundles`——与本地 `link:` 安装走完全相同的挂载路径(已实证)。
**打包约定(重要,勿改回;均经实测验证)**:
- **不要加回 `package.json` 的 `files` 字段**——pnpm 的 packlist 语义在 tarball 安装路径下
会因 `files`(或包内 `.npmignore`)使 packlist 输出与 tarball 条目数不一致,走重加路径,
可能触发其 `parseTarball` 的"剥首个路径分量"行为(专为 registry `package/...` 包裹格式
设计),把未包裹 tarball 里的 `lib/index.js` 误剥成根 `index.js`、`lib/` 丢失。已实测。
- **根入口 `index.js` = 双保险**:`main` 指向包根的一行转发(`module.exports = require('./lib/index.js')`),
即便遇到未包裹 tarball 的路径剥离,真实实现 `lib/index.js` 也不受影响。勿删除该文件。
- **`github:` 安装本身安全**:codeload tarball 天然带 `<repo>-<commit>/` 顶层包裹,pnpm 剥掉
包裹层后 `lib/` 结构完好——已用 codeload 同构(包裹式)tarball 实测:`lib/index.js`、
`cordis.patch.yml`、`README.md`、`LICENSE`、`package.json` 全部就位,require 正常。
- 发布内容用两层控制:`.gitattributes export-ignore`(codeload 瘦身,排除 `test/`、
`plugin-body.js`、`scripts/`)+ `.npmignore`(npm publish 瘦身)。两者都
**不得排除 `cordis.patch.yml`**(bundle 挂载声明)。
- 验证脚本:`scripts/git-install-test.ps1`(git resolver + codeload 等价 tarball +
带 `.gitignore` 场景)、`scripts/codeload-sim-test.ps1`(包裹式 tarball)。
前置条件 / 注意:
- 仓库公开,或本机已配置 GitHub 凭据;本机需能访问 github.com(本部署 git 代理
`http://127.0.0.1:7800`,必要时先配置)。
- 已用 `link:` 本地装过的机器,再次 `add github:...` 会替换 dependency spec
(bundles 条目保持不变,不会双挂载)。
- 若之后选择 `npm publish`(内容由 `.npmignore` 控制:只含 `lib/`、`cordis.patch.yml`、`README.md`、`LICENSE`、`package.json`),
等价命令为 `dsh plugin --profile web add dsh-llm-injection-filter@1.1.1`。
## 测试
```bash
npm test # ① test/run-tests.js(33 项,核心过滤逻辑)② test/client-smoke.js(bundle materialize 契约)③ test/client-render-smoke.js(设置页 mini-render + 挂载 effect 执行,防导出变量覆盖同名函数类回归)
```
端到端(清单 17):挂载后真实模型调用应零延迟零中断;构造含泰文的 delta 时 turn 以 `RESPONSE_FILTERED` 错误结束、前端会话视图出现错误横幅、`llm/retry` 事件不产生(以上路径已通过源码核实:`dsh-agent-loop` step() → `agent/request-error` → LlmError → `turn/end`;`dsh-llm-retry` 默认可重试码不含 `RESPONSE_FILTERED`)。
> **客户端加载契约(v1.1.1 修复,勿回退)**:浏览器 loader(`@deepseek-ai/dsh-client-modules`)materialize 时
> `exports = factory(require)` 的**返回值**,factory 作用域内**没有 `module`/`exports` 全局**——lib/client.js 必须
> 自包含并在结尾 `return { inject, apply }`,不得引用未声明的 `module`(v1.1.0 因此 ReferenceError 静默加载失败、
> 设置页不出现;已由 `test/client-smoke.js` 锁定该契约)。
Install
dsh plugin --profile web add github:XiaoYuOvO/dsh-llm-injection-filter
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-llm-injection-filter from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.