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. 目标¶
- 高置信「本可合并」序列被必然拦截,且拦截文本写明「怎么改」与「如何放行」;
- 误判只浪费一次往返,绝不瘫痪会话(三重兜底:指纹放行 + 每会话上限 + 异常静默 exit 0);
- 覆盖范围: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同步,请勿在站点侧修改)