session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」

1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
This commit is contained in:
admin committed 2026-10-06 22:27:03 +08:00
1 parent 19101acd65
commit 64dd82073b
21 files changed
+8444 -4523

No files matched your search

@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""**UserPromptSubmit 多守卫合并入口**(2026-10-06 用户报「三个 hook 同时 10s 超时」)。
## 为什么要合并(实测根因,⛔ 不是"脚本慢")
单一 `UserPromptSubmit` 上串行挂了 **7 条 hook**,每条都是**一个独立的 Python 进程**
(`python.exe <某 hook>.py`)⇒ 宿主要**冷启 7 次解释器**,每次 0.3–1 s
(Windows 上首次 import 开销尤其大,`datetime`/`json`/`re` 全都要重新加载)。
实测(本机,同一份真实 190 MB transcript 负载):
| 跑法 | 耗时 |
|---|---|
| 任一条 guard 单跑 | 0.23–0.36 s |
| 7 条**并发**(多进程) | 0.84 s(墙钟) |
| ⚠️ 宿主里**串行** 7 条 | 累计轻松顶破 10 s ⇒ **4 条 `timeout=10` 的一起报超时** |
⇒ 结论:**单条都不慢,死在"7 次冷启动 + 串行排队"**。
## 本入口做什么
把**同一事件、同一 stdin 契约**的 4 个 guard 合进**一个进程**:
`reply-style-guard` / `stop-dialog-guard` / `skill-load-guard` / `session-log-guard`
一次读 stdin ⇒ 依次在**本进程内**调各自的 `main()` ⇒ 合并 stdout ⇒ 一次输出。
**省掉 3 次 Python 冷启动**(4 条 → 1 条)。
## 契约(必须与各 guard 保持一致)
- 各自都从 `sys.stdin.buffer.read()` 读**同一份** payload(事件名都是 `UserPromptSubmit`)
- 各自都往 `sys.stdout` 写**一个 JSON**(可能空 ⇒ 表示放行)
- 合并规则:把各自的 JSON 合并 ——
· `hookSpecificOutput.additionalContext` **字符串拼接**(多个守卫都要注入时不能互相顶掉)
· `decision` / `permissionDecision` 等**谁拦谁说了算**(先出现的非空值优先,并记来源)
⚠️ ⛔ 不吞任何一条的"阻止"决定 —— 那是安全语义,合并只能加信息、不能减判定。
## 硬纪律(沿用本包同族)
1. 🔴 **输出走 `sys.stdout.buffer.write(bytes)`**(cp936 下文本模式会炸,见 `_env`/`pitfalls`)。
2. 🔴 **fail-open**:本入口自己不判任何业务规则;任一 guard 抛异常 ⇒ 只记 stderr,
其余 guard 照跑(⛔ 不许一个坏 guard 把别的都拖没)。
3. 🔴 **不改各 guard 源码**:用 `runpy` 按路径加载、临时接管 stdout 取回它们的 JSON
⇒ 单跑与合并跑**判据同源**(⛔ 不复制一份规则,避免"一条规则两份实现必漂")。
4. ⚠️ 保持**单跑入口不变**(各 `*-guard.py` 仍可直接被宿主调用)——本文件是**可选的加速入口**。
"""
from __future__ import annotations
import io
import json
import os
import runpy
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# 🔴 合并的守卫清单(⛔ 顺序=执行顺序;都是 fail-open 的"补充信息/提醒"类)
# ⚠️ **不含** `supervise-ensure-hook` / `wb-result-hook` / `decision_bridge` ——
# 它们有副作用(投递/常驻确保)或属别处接线,**合并会改变语义**,一律不碰。
GUARDS = (
"reply-style-guard.py",
"stop-dialog-guard.py",
"skill-load-guard.py",
"session-log-guard.py",
)
def _log(msg: str) -> None:
"""⛔ 绝不写 stdout(那是协议通道);失败只上 stderr 且吞异常。"""
try:
sys.stderr.write("[prompt-guards] %s\n" % msg)
sys.stderr.flush()
except Exception:
pass
class _Tee(io.TextIOBase):
"""临时接管 `sys.stdout`:把 guard 写出的 JSON 收下来(⛔ 不落终端)。
⚠️🔴 **踩过的坑(2026-10-06 实测)**:多数 guard 走 `sys.stdout.buffer.write(bytes)`。
最初把 `self.buffer = self` ⇒ 那次 `write` **撞上 `TextIOBase.write`**,
于是 bytes 被 `str()` 成 `b'{"hookSpecificOutput": ...}'` ⇒ **JSON 解析必失败**
(现象:合并入口报「输出了非 JSON(6228 字节)」)。
✅ 正解:`buffer` 用一个**独立的 `io.BytesIO`**;文本写与字节写**各收各的**,
`text()` 时按"谁有内容取谁"取回(正常只会有一路有内容)。
"""
def __init__(self):
self._raw = io.BytesIO() # 给 `sys.stdout.buffer.write(bytes)` 用
self.buffer = self._raw
self._txt = [] # 给 `sys.stdout.write(str)` 用(少数 guard 会走)
# TextIOBase 接口
def write(self, s): # noqa: D102
try:
self._txt.append(str(s))
except Exception:
pass
return len(s or "")
def flush(self): # noqa: D102
pass
def isatty(self): # noqa: D102
return False
def writable(self): # noqa: D102
return True
def text(self) -> str:
"""取回收集到的内容(字节路优先;两路都空 ⇒ 空串)。"""
b = self._raw.getvalue()
if b:
return b.decode("utf-8", "replace")
return "".join(self._txt)
def _run_one(name: str, payload_bytes: bytes):
"""在同进程内跑一个 guard,返回 (json_or_None, seconds)。
⚠️ guard 读的是 **stdin**;本入口已把 stdin 读过一次 ⇒ 这里给它一个"假 stdin",
内容是**同一份 payload 字节**(保证各 guard 看到的输入完全一致)。
"""
path = os.path.join(HERE, name)
if not os.path.isfile(path):
_log("跳过(不存在):%s" % name)
return None, 0.0
old_out, old_in = sys.stdout, sys.stdin
tee = _Tee()
# ⚠️ `TextIOWrapper.buffer` 是**只读属性**(实测 `AttributeError: readonly attribute`)
# ⇒ 不能后赋值;正解=**直接构造一个带 BytesIO 的 TextIOWrapper**,
# 它的 `.buffer` 天然就是那个 BytesIO(guard 走 `sys.stdin.buffer.read()`)。
fake_in = io.TextIOWrapper(io.BytesIO(payload_bytes), encoding="utf-8")
t0 = time.time()
try:
sys.stdout = tee
sys.stdin = fake_in
runpy.run_path(path, run_name="__main__")
except SystemExit:
pass # guard 自己 exit ⇒ 正常
except Exception as e: # noqa: BLE001
_log("%s 抛异常(已忽略,fail-open):%r" % (name, e))
finally:
sys.stdout, sys.stdin = old_out, old_in
el = time.time() - t0
txt = tee.text().strip()
if not txt:
return None, el
try:
return json.loads(txt), el
except Exception:
# 非 JSON(不合契约)⇒ 留痕但不当成阻止
_log("%s 输出了非 JSON(%d 字节,已忽略)" % (name, len(txt)))
return None, el
def _merge(dst: dict, src: dict, who: str) -> None:
"""把 `src` 合并进 `dst`(⛔ 只加不减;冲突时保留先到者并记来源)。"""
if not isinstance(src, dict):
return
for k, v in src.items():
if k == "hookSpecificOutput" and isinstance(v, dict):
cur = dst.setdefault("hookSpecificOutput", {})
if not isinstance(cur, dict):
continue
for k2, v2 in v.items():
if k2 == "additionalContext" and isinstance(v2, str):
# 🔴 多守卫都要注入 ⇒ **拼接**(⛔ 别互相顶掉)
old = cur.get("additionalContext") or ""
cur[k2] = (old + ("\n\n" if old else "") + v2) if old else v2
else:
if k2 not in cur:
cur[k2] = v2
elif cur[k2] != v2:
_log("字段冲突 %s.%s:保留先到者,丢弃 %s 的值" % (who, k2, who))
else:
if k not in dst:
dst[k] = v
# ⚠️ 同名字段(如 `decision`)冲突 ⇒ **保留先到者**;因为均为 fail-open 类,
# 先到者通常来自更"硬"的守卫(格式/收尾);真要强制多判定应拆开接线。
def main() -> None:
try:
raw = sys.stdin.buffer.read()
except Exception:
raw = b""
if not raw:
return # 空 payload ⇒ 静默放行(同各 guard)
out = {}
timings = []
for name in GUARDS:
obj, el = _run_one(name, raw)
timings.append("%s=%.3fs" % (name.split("-")[0], el))
if obj:
_merge(out, obj, name)
if not out:
return # 全都没话说 ⇒ 零输出(⛔ 不写空 JSON)
# 🔴 出口:必须走 buffer bytes(cp936 下文本模式炸)
try:
out.setdefault("hookSpecificOutput", {})["hookEventName"] = "UserPromptSubmit"
except Exception:
pass
try:
sys.stdout.buffer.write(json.dumps(out, ensure_ascii=False).encode("utf-8"))
sys.stdout.buffer.flush()
except Exception as e: # noqa: BLE001
_log("写 stdout 失败:%r" % (e,))
_log("合并完成:%s" % " ".join(timings))
if __name__ == "__main__":
try:
main()
except Exception as e: # noqa: BLE001
_log("FATAL %r" % (e,)) # fail-open:异常也不阻断提交