diff --git a/backend/services/knowledge_cards.py b/backend/services/knowledge_cards.py index dc48c54..9d7fe05 100644 --- a/backend/services/knowledge_cards.py +++ b/backend/services/knowledge_cards.py @@ -196,3 +196,40 @@ def effective(db, domain: str, project_id: str | None = None, at: str | None = N continue result.append(card) return result + + +# ---------------------------------------------------------------- AC-26 +# 运行时的"本次预审补充说明"只影响**本次运行**(run_instruction 落在运行对象上, +# 见 03 §9.3)。要把它变成持久知识,唯一途径是**显式提议为草稿**,且草稿走正常 +# 审核流程;指令本身不得改变工具权限。 +RUN_SCOPE_FIELDS = ("run_instruction", "tools", "allowed_tools", "permissions", "engine") + + +def draft_from_run_instruction(db, project_id: str, run_id: str, body: dict, actor: str): + """把一次运行的补充说明转成**待审草稿**(AC-26)。 + + 三条约束: + 1. 只产草稿,绝不因"运行中说过"而直接生效 + 2. 指令不得携带工具/权限/引擎字段——扩大工具授权必须走独立提议与审核 + 3. 保留来源运行,便于回溯"这条知识从哪次运行来" + """ + forbidden = sorted(field for field in RUN_SCOPE_FIELDS if field in body) + if forbidden: + raise ValueError( + "本次预审补充说明不能持久化工具权限或执行引擎;" + f"请移除这些字段并通过独立提议与审核:{forbidden}" + ) + if not body.get("name") or not body.get("kind"): + raise ValueError("转为持久知识需要明确的名称与类型") + + origin = body.get("origin", "manual") + if origin not in ORIGINS: + raise ValueError(f"来源取值须为 {' 或 '.join(ORIGINS)}") + # 运行指令天然没有历史来源,历史来源必须另行提供证据 + if origin == "history_derived": + raise ValueError("运行补充说明不能声明为历史提炼;历史提炼需提供来源事件或证据") + + enriched = dict(body, derived_from_run=run_id, proposed_via="run_instruction") + return propose(db, enriched, actor, project_id, origin=origin, + card_scope=body.get("card_scope", "project"), + team_id=body.get("team_id")) diff --git a/backend/test_knowledge_cards.py b/backend/test_knowledge_cards.py index deb001f..e32919b 100644 --- a/backend/test_knowledge_cards.py +++ b/backend/test_knowledge_cards.py @@ -220,5 +220,47 @@ class EffectiveFilterTests(KnowledgeCardTestCase): self.assertIn("不是项目知识卡片", str(ctx.exception)) +class RunInstructionScopeTests(KnowledgeCardTestCase): + """AC-26:运行补充说明只影响当前运行;持久化只能形成草稿,且不得扩大工具权限。""" + + def draft_from_run(self, body, actor="demo-manager"): + with store.transaction(self.db_path) as db: + return cards.draft_from_run_instruction(db, "DEMO-A", "run-1", body, actor) + + def test_instruction_becomes_a_draft_never_a_published_card(self): + draft = self.draft_from_run(rule_body(id="corporate.from_run")) + self.assertEqual(draft["status"], "draft") + with store.transaction(self.db_path) as db: + published = [row for row in store.listing(db, "component_version") + if row.get("component_id") == "corporate.from_run"] + self.assertEqual(published, [], "运行中说过的话不得直接成为生效知识") + + def test_instruction_cannot_widen_tool_permissions(self): + for field in ("allowed_tools", "tools", "permissions", "engine", "run_instruction"): + with self.subTest(field=field): + body = rule_body(id="corporate.tool_grab") + body[field] = ["terminal"] + with self.assertRaises(ValueError) as ctx: + self.draft_from_run(body) + self.assertIn("不能持久化工具权限", str(ctx.exception)) + + def test_instruction_cannot_claim_history_derived_origin(self): + with self.assertRaises(ValueError) as ctx: + self.draft_from_run(rule_body(id="corporate.fake_history", origin="history_derived")) + self.assertIn("不能声明为历史提炼", str(ctx.exception)) + + def test_draft_records_which_run_it_came_from(self): + mock = self.draft_from_run(rule_body(id="corporate.traced")) + with store.transaction(self.db_path) as db: + row = store.get(db, "component_draft", mock["id"]) + self.assertEqual(row["derived_from_run"], "run-1") + self.assertEqual(row["proposed_via"], "run_instruction") + + def test_hard_gate_still_applies_to_run_derived_cards(self): + with self.assertRaises(ValueError) as ctx: + self.draft_from_run(rule_body(id="corporate.run_hard", effect="hard")) + self.assertIn("正式制度或授权依据", str(ctx.exception)) + + if __name__ == "__main__": unittest.main()