dsh-plugin-content-policy

securitynpmMIT

Preventive content policy for tool results: rewrites matched text, and drops or truncates whole fields by path before a result is normalised, rendered or persisted, so an unreviewed payload never enters it. Ships inert with no built-in category or domain blocklist.

下载量 / Downloads
643

Install

dsh plugin --profile web add dsh-plugin-content-policy

Sandbox test

This plugin has not been tested yet. Submit a test →

README

dsh-plugin-content-policy

预防式内容策略:在 DSH 的工具结果被规范化、落盘、进入下一次 provider 请求之前改写它。

它是 dsh-plugin-redact(事后补救:原地脱敏已经写进 session.vN.jsonl.zstd 的日志)的事前对应物:

redact 擦掉已经留下的东西,本插件让不该留下的东西根本没机会留下。

两条互补的策略,挂在同一条接缝上(tools/execute),顺序固定为 先 strip、后正则:

策略 · 配置 · 认识内容吗? · 适用场景

内容改写 · rules[] · 认识:你给字面量/正则,命中即替换 · 你已经知道要挡什么(手机号、密钥、内部域名…)

结构最小化 · strip[](+ stripDefaults) · 不认识:只按字段路径整段丢弃/截断 · 事先无法枚举的载荷(公开检索回来的任意段落),对策是"让它根本不进来"


1. 为什么必须发生在 append 之前

一次 session.append('tool/result', …) 有两个后果,而且它们是同一份数据:

后果 · 证据

它成为持久日志里的一行 · dsh-agent-loop/lib/index.js:703-712(session.append("tool/result", { turn, step, message, …result.meta }))

它是将来每一次请求里那条工具结果消息 · dsh-session/lib/index.js:216:deriveEventMessage 对 tool/result 直接 return event.data.message;请求由 session.deriveMessages() 折叠同一批事件得到

所以"事后脱敏"永远慢一步:内容在写入的那一刻就已经同时进了日志和(下一次请求的)上下文。

本插件把自己插在 tools/execute 瀑布里 —— 那是结果被规范化之前唯一的改写点。

尤其要注意两条通道:web_search 把同一段 snippet 同时放进渲染文本(content)和

presentationMeta(持久化 meta)。只换 content 会漏掉 meta,见接缝表第 6、7 行。


2. 接缝表(每条都对着 shipped 源码核过)

# · 接缝 · 源码位置 · 本插件怎么用

1 · tools/execute 瀑布 · 声明:dsh-tools/lib/types/index.d.ts:49 · 主接缝:包装器返回值会被注册表重新规范化

2 · 返回值规范化 · dsh-tools/lib/index.js:3213-3214(waterfall('tools/execute', …) → normalizeDispatchResult) · 我们返回的对象就是下游 tools/post-execute 看到的 result

3 · 同一性快路径 · dsh-tools/lib/index.js:3448(canonicalResults.get(result) === exec.token ⇒ 原样返回) · 无改动时按引用返回原结果 ⇒ 零开销、不二次渲染

4 · 成功结果从 value 重新派生 content · dsh-tools/lib/index.js:3458 → :3422(createSuccessResult → tool.output.render(exec.arguments, value)) · 我们改写 value,content 由注册表自己重算

5 · 成功结果从 value 重新派生 meta · dsh-tools/lib/index.js:3428-3436(presentationMeta(exec.arguments, value)) · 同上:meta 也自动跟着 value 变

6 · 只换 content 会留下旧 meta · dsh-tools/lib/index.js:3401-3405({...result, content} 展开保留 meta) · 这是本插件改写 value 而不是 content 的原因

7 · meta 会被持久化 · dsh-agent-loop/lib/index.js:708(...result.meta !== void 0 ? { meta: result.meta } : {}) · 只换 content ⇒ 命中文本仍随 meta 落盘

8 · 同一段文本双通道的真实例子 · dsh-tool-web/lib/index.js:62-79(snippet 进 content)与 :103-124(同一 snippet 进 presentationMeta) · web_search 是最典型的"只擦 content 不够"案例

9 · 失败结果不能替换 value · dsh-tools/lib/index.js:3392(tools/post-execute cannot replace the value of a failed result) · 失败结果只能重建 content;strip 对它不生效

10 · 失败结果的 content/meta 逐字复制 · dsh-tools/lib/index.js:3449-3455 · 返回自己的 { isError: true, error, content } 并省略 meta ⇒ meta 被丢弃

11 · 失败结果持久化时的约束 · dsh-session/lib/index.js:241-248(error 存在时要求 message.content[0].isError === true) · 由 createToolResultMessage 依据 result.isError 生成,天然满足

12 · 参数在工具体运行前就已落盘 · dsh-agent-loop/lib/index.js:586(appendToolCall)早于 :588(prepare/dispatch) · 所以"阻断"不能阻止参数入库,只能阻止执行(见第 6 节)

13 · tools/pre-execute 的 {kind:'deny'} · dsh-tools/lib/index.js:3116-3139 · block 规则:命中参数即拒绝,工具体不运行

14 · ctx.tools.guard(fn) · dsh-tools/lib/types/index.d.ts:620(单调守卫) · 未使用:瀑布的 deny 已经终止派发;理由见第 7 节

15 · tools/ptc-dispatch-log 瀑布 · 声明:dsh-tools/lib/types/index.d.ts:75;消费:dsh-tools/lib/index.js:1235-1251 · 兜底改写 run_code 子调用的日志副本 content

16 · 输出 schema 的受支持子集 · dsh-tools/lib/index.js:33-47(type/oneOf/properties/required/additionalProperties/items/enum/const + 注解)、:195-206 · 只有 enum/const 能固定一个字符串;也没有 minLength/maxLength(这是 maxChars 截断安全的前提)

17 · Cordis 的 Config 契约 · cordis/lib/index.js:955-960(runtime.Config["~standard"].validate(config))、:1630(Config: plugin.Config) · 导出的 Config 必须实现 Standard Schema

18 · patch 覆盖是整体替换 · dsh-app-boot/lib/index.js:70-105(target[key] = value) · 覆盖 config 时要把字段写全(第 4 节)

19 · 真空操作默认的约定 · dsh-spill-policy/lib/index.js:104(if (maxInlineBytes === void 0) return) · 未配置 rules 且未配置 strip ⇒ 一个监听器都不注册

20 · 工具输出 schema 是编译后的 · dsh-tools/lib/index.js:846-847(defineTool 调 valueSchemaSpecToJsonSchema)→ :594-603(属性级 required: true 变成对象级 required: [...] 数组) · 所以"哪个键是必需的"可以从 ctx.tools.get(name).output.schema.required 直接判定

21 · 缺 required 字段 ⇒ 值非法 · dsh-tools/lib/index.js:454-455(missing required property "…")、:466-467(additionalProperties: false 的多余键) · strip 拒绝删除 required 键的依据

22 · 非法 value ⇒ 整条调用变成终态错误 · dsh-tools/lib/index.js:3417-3418(validateJsonSchemaValue → throw new ToolOutputError)、:3226-3231(catch → toolErrorResult)、:2454-2458(INVALID_TOOL_OUTPUT) · 这就是"宁可响亮失败、也不静默产出非法值"的代价说明

23 · web_search 的 required 只有 3 个 · dsh-tool-web/lib/index.js:270-298:required 在编译后是 ['sources','truncated'],sources[].url 必需;content/title/snippet/publishedAt 可选;additionalProperties: false · stripDefaults 的两条规则据此选定(丢 sources.*.snippet 与 content)

24 · web_search 的两条投影都是纯函数 · dsh-tool-web/lib/index.js:62-79(formatSearchOutput,逐处 !== void 0 判断)、:103-124(projectSource + searchMetaFromValue) · 删掉可选字段后它们自然"看不见"该字段 ⇒ content 与 meta 同时干净

25 · ptc 日志副本只有 content 块 · dsh-tools/lib/index.js:1235-1242(shapeDispatchLog({… content: result.content})) · strip 在这条接缝上无从下手(没有 value)

26 · spill 策略只管纯文本结果 · dsh-spill-policy/lib/index.js:75-83(flattenPlainText:任何非 text 块 ⇒ undefined)、:156-172(tools/post-execute,prepend: true) · 它完全不碰 web_search 这类结构化结果;这正是 strip 要补的缺口

27 · spill 策略的落盘预览 · dsh-spill-policy/lib/index.js:88-101(头尾预览)、:114-150(存 spill 文件) · 全文仍以文件形式留在本地(ctx.spillStore),只是不进上下文

28 · 配置校验失败 = 加载期错误,不是运行期 · cordis/lib/index.js:955-961(resolveConfig 调 Config["~standard"].validate 并 throw new ValidationError)、:939-944(文案 = invalid config: + 每条 message (at path)) · 数值约束(maxScannedBytes / maxChars)因此放在 Config 里,报错点紧贴配置本身、且带字段路径

2.1 Config 的两条轨道:谁在生效,以及它们真正的差异

先说结论:用户实际拿到的是"兜底轨道"那一套。 插

…(truncated)

Rendered from the README published on npm. Formatted only, not edited.

Versions

Published on npm::2,最后更新 2026-09-12

Links

GitHub · Homepage · npm

Same category