跳转至

SOP:merge-guard 升级为「强制拦截」(2026-10-06/07)

1. 背景

merge-guard 于 2026-10-06 上线时是只提醒、不拦截——设计约束 1 写明「PreToolUse 一次误判就废掉本轮工具调用」,故当时选择"投递必然、但不阻断"。

上线首轮实测即证伪其充分性:本会话未合并块 4 组 / 17 步、已合并率 37.1%,较上行(46.8%)回落 9.7 pt;犯规条款(「同一文件多段一次读完」「改-验循环批量化」)全都早已写在规则里且我已读到。

用户决定:把高置信判据升级为强制拦截 + 指纹放行,先按最大可强制范围实施,使用后看效果,若阻断带来负面再放宽。

2. 目标

  1. 高置信「本可合并」序列被必然拦截,且拦截文本写明「怎么改」与「如何放行」;
  2. 误判只浪费一次往返,绝不瘫痪会话(三重兜底:指纹放行 + 每会话上限 + 异常静默 exit 0);
  3. 覆盖范围:A / B / D + 新增 E 强制;C(改-验循环)保持仅提醒——「改→看结果→再改」是真依赖链,Hook 拿不到"改动的确定性",强制会误伤合法串行。

3. 前置条件

  • merge-guard/ 目录可写;~/.trae-cn/hooks.json 已有 merge-guard 注册(无改动);
  • 已知改脚本即时生效(不需重启 Trae);
  • 回滚点:backup/merge-guard/merge-guard.py.bak.20261007-002707、backup/trae-cn/hooks.json.bak.20261007-002707。

4. 影响范围

  • 组件:merge-guard/merge-guard.py(唯一改动文件);hooks.json 未改;
  • 影响面:所有 Trae 工具调用都会经过该 Hook;触发拦截时该次调用被拒,模型需重新决策;
  • 是否停服:否;不涉及服务与在跑作业;
  • 窗口期:无。

5. 分步操作

步骤 1:备份

先备份实现体与 Hook 配置(高危:覆盖配置类)。

cd /home/rain/Documents/services/token_proxy
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p merge-guard-enforce-20261006/backup/{merge-guard,trae-cn}
cp -p merge-guard/merge-guard.py  merge-guard-enforce-20261006/backup/merge-guard/merge-guard.py.bak.$TS
cp -p ~/.trae-cn/hooks.json       merge-guard-enforce-20261006/backup/trae-cn/hooks.json.bak.$TS
chmod 600 merge-guard-enforce-20261006/backup/*/*.bak.*

预期:两份 .bak 生成,权限 600。失败判断:cp 非 0 → 停止,不继续。

步骤 2:改判据与新增强制逻辑

改动集中在 merge-guard/merge-guard.py,共四处:

改动 内容
新增判据 E 连续 ≥3 次「只读」RunCommand(ro=True),且分属不同步
白名单 RUN_READONLY_CMDS(ls/cat/grep/jq/stat/pwd/echo/ip…)+ python3 -c / python3 -
新增判据 F Write 整篇覆盖已存在且 > F_OVERWRITE_MIN(2048 B)的文件 → 该走增量编辑
新增判据 I 文档类「改-验循环」:Edit/Write 命中 DOC_EXTS(.md/.txt/.json/.yaml/.yml/.csv)与只读 RunCommand 交替 ≥3 次
工具名归一化 TOOL_ALIAS = {"SearchReplace": "Edit"} —— 见坑 4
强制集 ENFORCE_MARKS = {"A","B","D","E","F","I"};C 不在其中(代码类改-验是真依赖)
deny 通道 emit_deny() 以 JSON 协议返回 permissionDecision: "deny" + permissionDecisionReason

坑 1(关键):hooks.json 的注册命令行结尾是 || true; exit 0,会吞掉脚本退出码。首版用「退出码 2 = deny」实现,脚本内部确实 deny 了(state 里 denies=1),但调用仍被放行。必须走 JSON 协议,不能依赖退出码。

坑 2:判据 E 的连续段起初写成「连续 N 次 RunCommand 且全部 ro」,导致前面一条非只读命令(如 cd x && python3 - <<PY,首词是 cd)把整段判为不满足。正确写法是只累计"连续且只读"的部分,非只读命令即截断。

坑 3:DENY_PROMPTS["B"] 沿用提醒模板的双 %d、而强制分支只传一个参数,% 格式化抛异常被兜底吞掉 → 静默不拦截。强制模板统一为单 %d。

坑 4(最隐蔽):Trae 传给 Hook 的 tool_name 与请求体里的名称不一致——请求体 SearchReplace(日志里 1835 次)在 Hook 侧是 Edit。判据 C 因此自上线起从未在真机触发过。修复:加 TOOL_ALIAS 在 main() 入口归一化。连带教训:新增判据 F/I 时必须同步加入 ENFORCE_MARKS,否则会落到提醒分支、而 PROMPTS 无对应键 → 异常被兜底吞掉、完全静默(首轮回归即如此失败 4 项)。

等价 shell(仅示意改动的关键行):

# 判据 E(只累计连续只读段)
python3 - <<'PY'
import re
src = open('merge-guard/merge-guard.py').read()
assert 'if last["tool"] == "RunCommand" and last.get("ro"):' in src
print('判据 E 已就位')
PY

实现体:merge-guard/merge-guard.py(_is_readonly_cmd / _detect / emit_deny / main)。

步骤 3:离线回归

喂假 JSON 到 stdin,断言「是否能拦截 / 是否误拦 / 是否放行 / 上限 / 静默兜底」。

cd /home/rain/Documents/services/token_proxy
python3 merge-guard/merge-guard.py <<'JSON'
{"tool_name":"RunCommand","tool_input":{"command":"ls"},"session_id":"probe"}
JSON
# 预期:无输出(单条不触发)

预期:12 项用例全通过(见第 6 章)。失败判断:任一用例失败 → 回到步骤 2 修正。

步骤 4:真机自触发

脚本改动即时生效。连发 3 条纯只读命令(每步一条消息,间隔 >1.5s)。

ls -1 <dir>          # 第 1 条
wc -l <file>         # 第 2 条
date '+%F %T'        # 第 3 条 —— 应被 hooks 拦截

预期:第 3 条返回 This RunCommand execution was denied by hooks. 并附 【强制合并·已拦截】连续 3 次只读取数命令;请聚合为一条命令再重发。确需分步则原样重发(将放行)。

失败判断:第 3 条正常执行 → 见第 8 章风险表逐项排查。

6. 验证

# 用例 判据 结果
U1 连续 3 次只读 RunCommand 第 3 次 stdout 含 "permissionDecision": "deny",且退出码仍为 0 通过
U2 连续 3 次非只读(python3 x.py) 不拦截 通过
U3 3 次只读但同一步(间隔 <1.5s) 不拦截 通过
U4 deny 后原样重发 放行 通过
U5 连续 6 条不同只读命令 拦截次数恰为 3(上限) 通过
U6 同一文件连续 2 次 Read(判据 B) 拦截 通过
U7 同一文件连续 2 次 SearchReplace(判据 C) 仅提醒、不拦截 通过
U8 缺 session_id 静默 exit 0 通过
U9 非法 JSON 静默 exit 0 通过
U10 连续 3 次 python3 - <<PY 取数 拦截 通过
U11 连续 3 次不同文件 Read(判据 A) 拦截 通过
U12 非只读命令打断后再连续 3 次只读 拦截 通过

离线合计 12/12 通过。

真机自触发:3 条纯只读命令的第 3 条被拦截,理由文本完整送达,会话未中断——deny 语义验证通过。

幂等:Hook 脚本无状态副作用(除自身 state/ 追踪文件);重复运行同一序列结果一致。

7. 回滚

备份位置:merge-guard-enforce-20261006/backup/(台账见其 README.md)。

cd /home/rain/Documents/services/token_proxy
cp merge-guard-enforce-20261006/backup/merge-guard/merge-guard.py.bak.20261007-002707 merge-guard/merge-guard.py
# hooks.json 本次未改,无需还原

回滚后验证:连发 3 条只读命令,第 3 条不应被拦截(回到提醒版行为)。 回滚触发判据:真机出现「必须串行的步骤被拦且无法放行」、或拦截导致任务失败。

8. 风险说明

风险 缓解
误拦合法串行 放行机制(原样重发即过)+ 每会话上限 3 次;C 判据不强制
deny 后模型不重发也不合并 拦截文本明确写「确需分步则原样重发(将放行)」
提示文本每轮重放耗 token 文本 ≤80 字;每会话拦截上限 3 次
退出码方式失效(已实测) 改用 JSON 协议;不依赖 hooks.json 的退出码传递
Trae 侧附加「avoid retrying」提示 与放行机制语义冲突:真依赖时需主动重发才能放行,届时以任务需要为准
白名单漏项(如 pwd 曾漏) 判据 E 只对该白名单生效;漏项只导致漏报、不误报,按需增补

附录:交付物

文件 说明
merge-guard/merge-guard.py 被改实现体(判据 E + 强制 + 放行)
backup/ 回滚点(2 份,600 权限),台账见 backup/README.md
docs/BASELINE.md §2 新增机制事实:PreToolUse deny 走 JSON 协议、hooks.json exit 0 吞退出码
docs/HANDOFF.md §3 强制已上线 + 待验证项

来源:/home/rain/Documents/services/token_proxy/merge-guard-enforce-20261006/SOP.md | 最后更新:2026-10-07 00:52(由 build/sync.py 同步,请勿在站点侧修改)