feat(v2): T06-c 方案治理入口与 Tools 卡守卫,T06 收尾

T06 第三块,收尾。方案提案复用既有组件治理(registry.draft/release),
不新建发布路径;自批拒绝等既有保证有测试锁定。

- propose_plan():维度在提案时即去重与冲突拦截(T06-a 的解析前移),
  保证草稿阶段就挡住"同 ID 不同定义"
- **不得出现业务 Tools 卡**(ADR-11):方案自带 tools/tool_cards/
  allowed_tools/permissions 一律拒绝,否则业务侧可绕过工具白名单;
  工具只能由后台治理
- validate_critical_checks():关键核查项必须真由本方案的 Skill/规则产生,
  凭空声明的"关键项"会让 BR-06 的硬性阻断语义失去可追溯性

T06 三块完成,卡片验收对照:AC-28(装配与依赖拒绝)· AC-29(维度去重与刻度
冲突)· AC-44(0/2/6/9 维与异刻度的呈现判定)均有测试;"没有 Tools 卡" 有守卫。

测试:test_review_plan.py 增至 34 项。全量 349 项 OK(340 → 349);
verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:24:24 +08:00
co-authored by Claude Code
parent 8a9005036c
commit 1bc17bb9bc
2 changed files with 139 additions and 0 deletions
+47
View File
@@ -181,3 +181,50 @@ def assemble(db, plan_id: str, legacy_catalog=None) -> dict:
frozen["dimensions"] = dimensions
frozen["presentation"] = result
return frozen
# ---------------------------------------------------------------- 方案治理(T06-c)
# ADR-11:Tools 由后台批准,**不开放业务 Tools 卡**。方案只能引用已登记工具,
# 不得自带工具配置面——否则业务侧就能绕过工具白名单。
FORBIDDEN_PLAN_KEYS = ("tools", "tool_cards", "tool_config", "allowed_tools", "permissions")
def _plan_codes(frozen: dict) -> set[str]:
"""方案内产生的核查编码:Skill 的 code 与规则条件的 code。"""
codes = {skill.get("code") for skill in frozen.get("skills", []) if skill.get("code")}
for rule in frozen.get("rules", []):
codes |= {item.get("code") for item in rule.get("conditions", []) if item.get("code")}
return codes
def validate_critical_checks(frozen: dict, critical_check_ids) -> None:
"""关键核查项必须真由本方案的 Skill/规则产生,不能凭空声明。
否则"关键项"就成了无法追溯的标签,会绕过 BR-06 的硬性阻断语义。
"""
declared = list(critical_check_ids or ())
if not declared:
return
available = _plan_codes(frozen)
unknown = sorted(set(declared) - available)
if unknown:
raise ValueError(f"关键核查项不属于本方案的 Skill 或规则:{unknown}")
def propose_plan(db, body: dict, actor: str, project_id: str):
"""提议一份方案草稿。复用既有组件治理,不新建发布路径。"""
forbidden = sorted(key for key in FORBIDDEN_PLAN_KEYS if key in body)
if forbidden:
raise ValueError(
"方案不得自带工具配置面(不开放业务 Tools 卡);"
f"工具由后台治理,请移除:{forbidden}"
)
if body.get("kind") != "plan":
raise ValueError("提案类型必须是 plan")
spec_inputs = body.get("dimensions", [])
resolved = resolve_dimensions(spec_inputs) # 去重与刻度冲突在此拦截
result = presentation(resolved)
assert_nothing_dropped(result, resolved)
return registry.draft(db, dict(body, project_id=project_id, dimensions=resolved), actor)