修复: 阻断目标事实改写造成的回放泄露

This commit is contained in:
zizi 2026-07-20 21:23:04 +08:00
parent 843d5d77d3
commit 96ebbc1d4c
4 changed files with 159 additions and 15 deletions

View File

@ -36,7 +36,7 @@ disable-model-invocation: true
授权、来源版本、目标章禁读或内容级泄露检查任一失败,都必须 fail-closed,返回明确的阻断状态,不靠重试绕过。 授权、来源版本、目标章禁读或内容级泄露检查任一失败,都必须 fail-closed,返回明确的阻断状态,不靠重试绕过。
`run_replay.py` 会同时审计公共快照和三臂卡注入区。没有 `leakageAudit` 配置、发现未来章记录或目标事实内容匹配时,不生成 `snapshot_manifest`,也不进入 planner。 `run_replay.py` 会同时审计公共快照和三臂卡注入区。没有 `leakageAudit` 配置、发现未来章记录、目标事实完整匹配,或原子事件子句达到多强锚点与覆盖率门时,不生成 `snapshot_manifest`,也不进入 planner。
## 产物边界 ## 产物边界
@ -55,7 +55,7 @@ disable-model-invocation: true
- planner、detector、judge 子进程统一受 `--timeout-seconds` 限制,默认 300 秒;任一超时分别落盘 `planner_timeout`、`detector_timeout`、`judge_timeout`,不得继续进入后续阶段或标记 `completed`。 - planner、detector、judge 子进程统一受 `--timeout-seconds` 限制,默认 300 秒;任一超时分别落盘 `planner_timeout`、`detector_timeout`、`judge_timeout`,不得继续进入后续阶段或标记 `completed`。
- `scripts/write_report.py`:从 `run_result.json` 生成独立严格 schema 的安全摘要,只接受受限标识符、枚举、数字、短安全摘要和 SHA-256;不会读取候选正文,也不会把候选路径以外的原始响应写入报告。 - `scripts/write_report.py`:从 `run_result.json` 生成独立严格 schema 的安全摘要,只接受受限标识符、枚举、数字、短安全摘要和 SHA-256;不会读取候选正文,也不会把候选路径以外的原始响应写入报告。
真实作品运行前必须先从权威来源取得不可变授权快照。当前状态:DDL 已实现待 apply,真实记录未写,实跑未开始。用户授权只能登记为 `research_only` 且 `allowedPurpose=["offline_evaluation"]`,不得伪造为 `licensed`;缺记录继续保持 `blocked_authorization`。 真实作品运行前必须先从权威来源取得不可变授权快照。当前 work=8 已有 `research_only` 且 `allowedPurpose=["offline_evaluation"]` 的有效记录;真实 489 dry-run 因安若雪卡含错标目标事件被 `invalid_snapshot` 阻断,尚未进入 planner。其他作品缺记录时继续保持 `blocked_authorization`,不得伪造为 `licensed`。
## 正文 A/B/C 回放 ## 正文 A/B/C 回放

View File

@ -8,7 +8,10 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import difflib
import json import json
import re
import unicodedata
from typing import Any, Iterator, Mapping from typing import Any, Iterator, Mapping
from build_snapshot import normalize_chapter, normalize_chapter_range, sha256_value from build_snapshot import normalize_chapter, normalize_chapter_range, sha256_value
@ -21,6 +24,9 @@ STATUS_INVALID_INPUT = "invalid_audit_input"
_CHAPTER_KEYS = frozenset( _CHAPTER_KEYS = frozenset(
{"chapter", "chapter_no", "order_no", "章", "章号", "from_order", "to_order"} {"chapter", "chapter_no", "order_no", "章", "章号", "from_order", "to_order"}
) )
_FACT_SEGMENT_SPLIT = re.compile(r"[,,;;。!?!?、\n]+")
_FACT_LABEL_PREFIX = re.compile(r"^(?:章目标|关键事件|出场角色|伏笔动作|章末钩子)[::]")
_NON_FACT_CHARACTER = re.compile(r"[^0-9a-z\u3400-\u9fff]+")
def _chapter_bounds(record: Mapping[str, Any]) -> tuple[int, int] | None: def _chapter_bounds(record: Mapping[str, Any]) -> tuple[int, int] | None:
@ -113,6 +119,54 @@ def _finding(
return result return result
def _normalize_fact_text(value: str) -> str:
"""统一宽窄字符并移除标点,只保留可稳定比较的中文、字母和数字。"""
normalized = unicodedata.normalize("NFKC", value).lower()
return _NON_FACT_CHARACTER.sub("", normalized)
def _target_fact_segments(text: str) -> list[str]:
"""把复合目标细纲拆成原子事件子句,避免只能逐字匹配整段长文本。"""
segments: list[str] = []
for raw_segment in _FACT_SEGMENT_SPLIT.split(text):
stripped = _FACT_LABEL_PREFIX.sub("", raw_segment.strip())
normalized = _normalize_fact_text(stripped)
# 太短的角色名或栏目词缺乏事件区分度,不能单独触发失败关闭。
if len(normalized) >= 6:
segments.append(normalized)
return segments
def _has_partial_target_fact(value: str, fact_text: str) -> bool:
"""识别目标事件的局部改写,同时用多锚点阈值控制历史事实误报。"""
normalized_value = _normalize_fact_text(value)
if len(normalized_value) < 6:
return False
for segment in _target_fact_segments(fact_text):
if segment in normalized_value:
return True
blocks = [
block
for block in difflib.SequenceMatcher(
None, segment, normalized_value, autojunk=False
).get_matching_blocks()
if block.size >= 3
]
# 三个互不重叠的强锚点可表达“实体 + 能力/动作 + 数值/结果”的同一事件;
# 两个常见词(例如角色名 + 同步率)不足以判定泄露。
matched_characters = sum(block.size for block in blocks)
if (
len(blocks) >= 3
and matched_characters >= 9
and matched_characters / len(segment) >= 0.5
):
return True
return False
def audit_snapshot( def audit_snapshot(
snapshot: Mapping[str, Any], snapshot: Mapping[str, Any],
target_facts: Mapping[str, Any], target_facts: Mapping[str, Any],
@ -172,6 +226,15 @@ def audit_snapshot(
findings.append( findings.append(
_finding(reason="target_fact_match", path=path, value=value, fact=fact) _finding(reason="target_fact_match", path=path, value=value, fact=fact)
) )
elif _has_partial_target_fact(value, fact["text"]):
findings.append(
_finding(
reason="target_fact_partial_match",
path=path,
value=value,
fact=fact,
)
)
return { return {
"status": STATUS_INVALID_SNAPSHOT if findings else STATUS_READY, "status": STATUS_INVALID_SNAPSHOT if findings else STATUS_READY,

View File

@ -65,6 +65,87 @@ class LeakageAuditTest(unittest.TestCase):
self.assertIn("factHash", result["findings"][0]) self.assertIn("factHash", result["findings"][0])
self.assertIn("valueHash", result["findings"][0]) self.assertIn("valueHash", result["findings"][0])
def test_paraphrased_real_target_event_is_blocked(self):
"""真实错标卡不会逐字复制目标细纲,仍须识别同一事件的多个强锚点。"""
facts = {
"targetChapter": 489,
"forbiddenFacts": [
{
"id": "key-events-489",
"firstChapter": 489,
"text": "关键事件:苏铭投两枚核雷撕开缺口、安若雪同步率100%强攻加特朗、铎叔反水贯杀加特朗",
}
],
}
result = audit_snapshot(
{
"cards": [
{
"chapter": 488,
"fact": "与加特朗激战中深陷压力,转交主攻位置后用生命进发让同步率飙至100%,击碎加特朗能量屏障",
}
]
},
facts,
as_of=488,
target=489,
)
encoded = json.dumps(result, ensure_ascii=False)
self.assertFalse(result["ok"])
self.assertEqual(result["findings"][0]["reason"], "target_fact_partial_match")
self.assertNotIn("加特朗", encoded)
self.assertNotIn("同步率", encoded)
def test_related_but_distinct_history_does_not_false_positive(self):
"""只共享旧角色和体系术语、但缺少目标事件关键锚点时不得误报。"""
facts = {
"targetChapter": 489,
"forbiddenFacts": [
{
"id": "key-events-489",
"firstChapter": 489,
"text": "安若雪同步率100%强攻加特朗",
}
],
}
result = audit_snapshot(
{"events": [{"chapter": 387, "fact": "安若雪开启拘束解除,同步率突破95%重创死亡收割者"}]},
facts,
as_of=488,
target=489,
)
self.assertTrue(result["ok"])
self.assertEqual(result["findingCount"], 0)
def test_entities_scattered_across_long_history_do_not_form_one_event(self):
"""多个实体分散在旧细纲的不同事件里,不能仅凭名字凑成目标事件。"""
facts = {
"targetChapter": 489,
"forbiddenFacts": [
{
"id": "network-attack-489",
"firstChapter": 489,
"text": "赛莉丝入虚拟城外圈夺控防御闸门并坑杀凯多萨康里弗两方",
}
],
}
historical_outline = (
"佛罗科密授康里弗追求安若雪以图谋王位;"
"赛莉丝入侵要塞惊见囚禁的高等级灵械后退出;"
"另一场景中凯多萨与康里弗争执,搅乱苏铭和安若雪的会面。"
)
result = audit_snapshot(
{"chapters": [{"chapter": 484, "outline": historical_outline}]},
facts,
as_of=488,
target=489,
)
self.assertTrue(result["ok"])
self.assertEqual(result["findingCount"], 0)
def test_target_and_as_of_must_form_next_chapter_pair(self): def test_target_and_as_of_must_form_next_chapter_pair(self):
result = audit_snapshot({}, target_facts(), as_of=487, target=489) result = audit_snapshot({}, target_facts(), as_of=487, target=489)
self.assertFalse(result["ok"]) self.assertFalse(result["ok"])

View File

@ -8,17 +8,17 @@
**技术栈:** `agent-example` 文件版实验台、PostgreSQL `muse-example` 只读查询、现有 `planning`/`read-context`/`detect`/`quality-gate` skill、Opus 规划与评审子代理;确定性冻结和校验脚本使用仓库 `.venv` 的 Python。 **技术栈:** `agent-example` 文件版实验台、PostgreSQL `muse-example` 只读查询、现有 `planning`/`read-context`/`detect`/`quality-gate` skill、Opus 规划与评审子代理;确定性冻结和校验脚本使用仓库 `.venv` 的 Python。
## 执行状态(2026-07-19) ## 执行状态(2026-07-20)
- 已落地并同步到 `agent-example/main`:`fbb262c`(冻结/细纲/评分合同)、`a54a3a4`(审查反馈收紧与回放编排)、`1243d9b`(外部 planner execute 链路测试)。 - 已合入 `agent-example/main`:冻结/细纲/评分合同、三臂 planner、逐候选 detector、双 judge 反序盲评、稳定性门、原文件授权链和安全报告;集成提交为 `843d5d7`。
- 已验证:replay-eval 70 个离线测试、fine-outline 合同 3 个测试全部通过;fake runner 已跑通三臂 planner、真正不识别臂卡内容的逐候选 detector、双 judge 反序盲评、子进程超时失败关闭、稳定性门和去盲矩阵。测试只使用合成 fixture,不代表参考作品评测结果。 - 已验证:replay-eval 127 个离线测试全部通过;fake runner 已跑通三臂 planner、真正不识别臂卡内容的逐候选 detector、双 judge 反序盲评、子进程超时失败关闭、稳定性门和去盲矩阵。合成 fixture 不代表参考作品评测结果。
- 已新增并验证:`audit_leakage.py` 对公共快照和三臂卡注入区执行内容级事实审计;`load_reference_work.py` 通过 PostgreSQL 只读事务组装仓库外临时配置,候选卡标记为 `eval_draft`,不进入生产检索。 - 已新增并验证:`audit_leakage.py` 对公共快照和三臂卡注入区执行内容级事实审计;`load_reference_work.py` 通过 PostgreSQL 只读事务组装仓库外临时配置,候选卡标记为 `eval_draft`,不进入生产检索。
- 已执行真实适配 smoke:work=8、冻结 488、目标 489,组装 31 个完整历史大纲窗、6 个近章细纲摘要、正确/错配卡各 2 张;送入回放仍为 `blocked_authorization`(`example_reference_work` 没有授权快照),未生成 `snapshot_manifest`,未调用模型。 - 授权表已应用,work=8 已有一条有效的 `research_only + offline_evaluation` 不可变快照;原文件、document SHA-256 和 `raw-file-v1` 来源版本三方一致,重验时间为 2026-08-18。
- 已执行真实数据库前置 smoke:深空之影 `work_id=8` 的 `example_reference_work` 表没有不可变授权快照/版权用途字段,结果为 `blocked_authorization`,三臂均未调用模型。 - 已执行真实 work=8 / as_of=488 / target=489 dry-run:正确卡选择为安若雪与圣蒂曼行星,placebo 为同型近体量的伊蕾莉雅与鹦鹉螺号;未调用模型。
- 任务 A 已实现原文件版本与 append-only 授权快照 DDL:work=8 的权威原文件 `document_id=7`、SHA-256=`02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4`,来源版本固定为 `raw-file-v1:sha256:<hash>`,不再使用作品 revision+导入章数。 - 首次真实 dry-run 暴露内容审计假阴性:安若雪卡把第 489 章“加特朗 / 同步率 100%”事件错标为第 488 章,旧审计只做完整字符串包含而错误放行。现已增加原子事件子句、多强锚点和目标子句覆盖率门,真实样本被正确阻断为 `invalid_snapshot`,不生成 manifest、不进入 planner。
- 当前状态:**DDL 已实现待 apply,真实记录未写,实跑未开始**。用户授权只能登记为 `research_only + offline_evaluation`,不得伪造 `licensed`;apply 与 INSERT 均不在本任务执行范围。 - 当前状态:**授权与真实装配已通,目标样本因上游卡章号错标被内容门阻断,模型实跑未开始**。必须先修正或隔离泄露来源,再执行三臂模型回放。
- `.claude/skills/llm/scripts/test_quota.py` 已统一到当前 `$24/6000` 契约:预算场景使用 `$24.5`,调用上限场景使用 `6000`,并增加策略常量断言。 - `.claude/skills/llm/scripts/test_quota.py` 已统一到当前 `$24/6000` 契约:预算场景使用 `$24.5`,调用上限场景使用 `6000`,并增加策略常量断言。
- 当前允许的结论是“冻结、变量控制、内容级泄露审计、候选结构门、detector/judge 双评编排、安全摘要机制和授权快照代码合同已通”;不能说数据库表已应用、真实授权已写入、细纲智能体或知识卡已通过。真实授权接入和 430/489/550 实样仍待完成。 - 当前允许的结论是“授权、真实数据装配、冻结、变量控制、内容级泄露阻断、候选结构门、detector/judge 双评编排和安全摘要机制已通”;不能说细纲智能体或知识卡已通过。489 样本需先清除泄露来源,430/550 样本仍待执行。
--- ---
@ -301,7 +301,7 @@ arm ∈ {outline_only, outline_plus_cards, outline_plus_placebo_cards}
- [x] 将卡生成 `as_of=N` 规划视图,剔除终态字段、未来里程碑、无绝对上界条目和无法溯源字段。 - [x] 将卡生成 `as_of=N` 规划视图,剔除终态字段、未来里程碑、无绝对上界条目和无法溯源字段。
- [x] 预检授权双闸、来源状态、目标章禁读、候选卡选择来源和原书全文不落最终报告。 - [x] 预检授权双闸、来源状态、目标章禁读、候选卡选择来源和原书全文不落最终报告。
- [x] 用离线 fixture 覆盖未来里程碑、跨 N 窗口、章号归一化、嵌套章记录、终态裁剪、缺授权失败关闭和三臂公共区哈希一致。 - [x] 用离线 fixture 覆盖未来里程碑、跨 N 窗口、章号归一化、嵌套章记录、终态裁剪、缺授权失败关闭和三臂公共区哈希一致。
- [x] 增加目标章内容级事实泄露审计,发现错位事件或臂内卡未来记录时整例作废;审计结果只保留路径和哈希。 - [x] 增加目标章内容级事实泄露审计;完整匹配外,还按原子事件子句执行多强锚点与覆盖率判断,能阻断真实错标改写并避免长历史细纲实体散落造成的假阳性。审计结果只保留路径和哈希。
- [x] 增加 PostgreSQL 只读参考作品适配器;只读取结构化大纲/细纲摘要和预注册卡,目标章仅进入临时审计 proxy,缺授权不放行。 - [x] 增加 PostgreSQL 只读参考作品适配器;只读取结构化大纲/细纲摘要和预注册卡,目标章仅进入临时审计 proxy,缺授权不放行。
### Task 2:细纲智能体功能合同 ### Task 2:细纲智能体功能合同
@ -329,7 +329,7 @@ arm ∈ {outline_only, outline_plus_cards, outline_plus_placebo_cards}
- [x] 给 detector 增加细纲候选的检查对象、严重度和证据格式。 - [x] 给 detector 增加细纲候选的检查对象、严重度和证据格式。
- [x] 给 judge 增加 `fine_outline_replay` rubric profile,明确不评正文文风和文笔。 - [x] 给 judge 增加 `fine_outline_replay` rubric profile,明确不评正文文风和文笔。
- [x] 在回放编排中固定两个独立 judge、匿名 arm 和第二评委候选顺序反转;fake runner 已覆盖双评、rubric 合同和稳定性失败关闭,真实样本尚未越过授权门。 - [x] 在回放编排中固定两个独立 judge、匿名 arm 和第二评委候选顺序反转;fake runner 已覆盖双评、rubric 合同和稳定性失败关闭,真实样本当前被内容泄露门阻断。
- [x] 机械测试确保 rubric 不包含正文质量维度,且每个分数必须有证据字段。 - [x] 机械测试确保 rubric 不包含正文质量维度,且每个分数必须有证据字段。
### Task 4:回放编排与最小首跑 ### Task 4:回放编排与最小首跑
@ -341,11 +341,11 @@ arm ∈ {outline_only, outline_plus_cards, outline_plus_placebo_cards}
- Test: `.claude/skills/replay-eval/scripts/test_run_replay.py` - Test: `.claude/skills/replay-eval/scripts/test_run_replay.py`
- Create: `docs/replay/<run-id>-summary.md` - Create: `docs/replay/<run-id>-summary.md`
- [x] 先用合成 fixture 完成一个机制 dry-run,并用 fake planner 验证 execute 链路;真实样本尚未越过授权门。 - [x] 先用合成 fixture 完成机制 dry-run,并用 fake planner 验证 execute 链路;真实 489 样本已完成确定性 dry-run 并因内容泄露正确阻断。
- [x] 编排器支持每个目标章 A/B/C 三臂,planner 使用相同身份段、功能段、预算和输出合同;尚未运行参考作品目标章。 - [x] 编排器支持每个目标章 A/B/C 三臂,planner 使用相同身份段、功能段、预算和输出合同;参考作品目标章尚未启动模型。
- [x] 两个独立 judge 对每章候选做顺序互换盲评;detector 阻断样本不送 judge。当前由 fake runner 离线测试验证,尚无真实样本结果。 - [x] 两个独立 judge 对每章候选做顺序互换盲评;detector 阻断样本不送 judge。当前由 fake runner 离线测试验证,尚无真实样本结果。
- [x] 运行时原始候选和标准事实摘要只放仓库外临时目录;安全报告只写摘要、定位、评分入口、哈希和失败类别。 - [x] 运行时原始候选和标准事实摘要只放仓库外临时目录;安全报告只写摘要、定位、评分入口、哈希和失败类别。
- [x] 接入真实数据库只读适配 smoke;授权缺失时维持 `blocked_authorization`,不启动 planner。 - [x] 接入真实数据库只读适配与授权快照;授权、来源或内容审计任一失败都不启动 planner。
- [ ] 产出真实卡增量矩阵和样本级假阴/假阳说明;不能用机制 smoke 的合成结果代替。 - [ ] 产出真实卡增量矩阵和样本级假阴/假阳说明;不能用机制 smoke 的合成结果代替。
### Task 5:滚动评估门 ### Task 5:滚动评估门