@furongjun1999/dsh-memory 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +588 -552
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  8. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  9. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  10. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
  11. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  12. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
  13. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
  14. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
  15. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
  16. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
  17. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  18. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
  19. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  20. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  21. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
  23. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
  24. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v20.md +283 -0
  25. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v21.md +224 -0
  26. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v22.md +223 -0
  27. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v23.md +293 -0
  28. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v24.md +303 -0
  29. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v25.md +230 -0
  30. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
  31. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  32. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  33. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  34. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  35. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  36. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
  37. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
  38. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  39. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  40. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.js +24 -2
  43. package/lib/cli.d.ts +3 -0
  44. package/lib/cli.js +66 -0
  45. package/lib/hooks.d.ts +31 -2
  46. package/lib/hooks.js +219 -16
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +302 -0
  49. package/lib/lib/prompt_safety.d.ts +52 -1
  50. package/lib/lib/prompt_safety.js +76 -1
  51. package/md_cg/audit.py +652 -379
  52. package/md_cg/bench6_arms.py +1 -1
  53. package/md_cg/bench_p0.py +1 -1
  54. package/md_cg/branches.py +2 -2
  55. package/md_cg/ccgc.py +1071 -1006
  56. package/md_cg/chain.py +1 -1
  57. package/md_cg/consistency.py +97 -9
  58. package/md_cg/consolidate.py +34 -9
  59. package/md_cg/crypto.py +59 -26
  60. package/md_cg/docindex.py +16 -1
  61. package/md_cg/evidence.py +585 -582
  62. package/md_cg/evolution.py +17 -1
  63. package/md_cg/export.py +1 -1
  64. package/md_cg/forgetting.py +309 -13
  65. package/md_cg/fsutil.py +454 -8
  66. package/md_cg/identity.py +3 -3
  67. package/md_cg/insight.py +24 -3
  68. package/md_cg/linkref.py +1 -1
  69. package/md_cg/logref.py +327 -0
  70. package/md_cg/mcp_server.py +4152 -3818
  71. package/md_cg/mdcg.py +4208 -3462
  72. package/md_cg/mdcos.py +4491 -4137
  73. package/md_cg/mreview/pipeline.py +15 -1
  74. package/md_cg/nodefile.py +639 -575
  75. package/md_cg/protect.py +158 -11
  76. package/md_cg/protocol.py +41 -2
  77. package/md_cg/provenance.py +22 -4
  78. package/md_cg/reach.py +4 -4
  79. package/md_cg/readcache.py +76 -15
  80. package/md_cg/reconcile.py +1 -1
  81. package/md_cg/refindex.py +246 -37
  82. package/md_cg/refine.py +1 -1
  83. package/md_cg/review_cli.py +49 -4
  84. package/md_cg/routing.py +32 -5
  85. package/md_cg/run_tests.py +17 -0
  86. package/md_cg/scrub.py +30 -3
  87. package/md_cg/security.py +47 -1
  88. package/md_cg/self_state.py +5 -5
  89. package/md_cg/sources.py +23 -6
  90. package/md_cg/srcindex.py +352 -0
  91. package/md_cg/stg.py +47 -3
  92. package/md_cg/subgraph.py +2 -2
  93. package/md_cg/sustain.py +1299 -1168
  94. package/md_cg/tasks.py +469 -470
  95. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  96. package/md_cg/test_b1b2_write_face.py +417 -0
  97. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  98. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  99. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  100. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  101. package/md_cg/test_c3_transient_read_negative.py +793 -0
  102. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  103. package/md_cg/test_ccg_form_parity.py +188 -0
  104. package/md_cg/test_ccgc.py +28 -3
  105. package/md_cg/test_govern_directread.py +17 -4
  106. package/md_cg/test_h2_session_view_norm.py +233 -0
  107. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  108. package/md_cg/test_hive_ingest.py +285 -285
  109. package/md_cg/test_index_crossprocess_reload.py +301 -0
  110. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  111. package/md_cg/test_issue43_default_policy.py +865 -0
  112. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  113. package/md_cg/test_linkref.py +10 -3
  114. package/md_cg/test_lock.py +2 -2
  115. package/md_cg/test_logref.py +1109 -0
  116. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  117. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  118. package/md_cg/test_mr_m2.py +15 -3
  119. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  120. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  121. package/md_cg/test_n176_link_trust.py +221 -0
  122. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  123. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  124. package/md_cg/test_n195_writepath_reload.py +283 -0
  125. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  126. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  127. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  128. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  129. package/md_cg/test_n201_proposal_visibility.py +382 -0
  130. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  131. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  132. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  133. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  134. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  135. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  136. package/md_cg/test_n225_nonobject_load.py +1110 -0
  137. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  138. package/md_cg/test_neg_condition_hits.py +333 -0
  139. package/md_cg/test_neg_tail_honesty.py +663 -0
  140. package/md_cg/test_none_id_write_guard.py +166 -0
  141. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  142. package/md_cg/test_p1.py +5 -5
  143. package/md_cg/test_p11_consistency.py +161 -12
  144. package/md_cg/test_p26_refindex.py +2 -2
  145. package/md_cg/test_p27_docindex.py +777 -774
  146. package/md_cg/test_p28_refcheck.py +740 -13
  147. package/md_cg/test_p29_session_ingest_export.py +2 -2
  148. package/md_cg/test_p2_mcp.py +12 -1
  149. package/md_cg/test_p30_maintain.py +33 -2
  150. package/md_cg/test_p31_insight.py +1 -0
  151. package/md_cg/test_p9c_dedup_hints.py +276 -0
  152. package/md_cg/test_policy_required_ccg.py +426 -0
  153. package/md_cg/test_protocol.py +22 -0
  154. package/md_cg/test_rank_parity_score_mode.py +516 -0
  155. package/md_cg/test_read_face_input_gates.py +363 -0
  156. package/md_cg/test_read_face_semantics.py +489 -0
  157. package/md_cg/test_recall_face_guards.py +812 -0
  158. package/md_cg/test_rejected_credential_forms.py +188 -0
  159. package/md_cg/test_rejected_redact.py +146 -0
  160. package/md_cg/test_retr_s1b.py +2 -2
  161. package/md_cg/test_retr_s5.py +20 -7
  162. package/md_cg/test_review_cli_attribution.py +177 -0
  163. package/md_cg/test_review_cli_visibility.py +235 -0
  164. package/md_cg/test_review_conformance.py +21 -3
  165. package/md_cg/test_security_audit_v21.py +2 -2
  166. package/md_cg/test_server_version.py +67 -0
  167. package/md_cg/test_srcindex.py +171 -0
  168. package/md_cg/test_tenant_env_override_warn.py +63 -50
  169. package/md_cg/test_token_lowercase_form.py +315 -0
  170. package/md_cg/test_v21r1_package_version.py +310 -0
  171. package/md_cg/test_writepipe.py +10 -4
  172. package/md_cg/tokens.py +225 -95
  173. package/md_cg/tool_face.py +4 -4
  174. package/md_cg/trust.py +49 -5
  175. package/md_cg/units.py +204 -6
  176. package/md_cg/weights.py +4 -4
  177. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  178. package/md_cg/writelimit.py +42 -8
  179. package/md_cg/writepipe.py +651 -554
  180. package/package.json +8 -2
  181. package/skills/plugin.json +1 -1
  182. package/src/bridge.ts +25 -2
  183. package/src/cli.ts +65 -0
  184. package/src/hooks.ts +229 -16
  185. package/src/init.ts +361 -0
  186. package/src/lib/prompt_safety.ts +88 -1
  187. package/zcode/AGENTS.md +10 -10
package/md_cg/audit.py CHANGED
@@ -1,380 +1,653 @@
1
- # -*- coding: utf-8 -*-
2
- """写入审核:按内容类型分派的验证体系。
3
-
4
- 架构约束:验证能力不在认知图内(跑测试/识图/验收都不是记忆基底的职责)。
5
- 认知图只做三件事:按内容类型选验证器 → 调用 → 记账。缺能力返回 DEFER,绝不假装通过。
6
-
7
- 内容类型 → 验证动作:
8
- code 代码内容 → 实测 image_desc 图像描述 → 识图确认
9
- text 文字内容 → 合规 + 纪律 permission 权限操作 → 是否具有权限
10
- work_done 工作完成 → 验收 work_wip 工作进行 → 完整成果 + 纪律
11
- ccg_marks CCG 六要素 → 编外验证方实测(生成方不得自证)
12
-
13
- 裁决四态复用 judge_qualification:ACCEPT / REJECT / DEFER / BLINDSPOT
14
- 验证器签名:fn(payload: dict, ctx: dict) -> {"state":..., "evidence":..., "detail":...}
15
- """
16
- from __future__ import annotations
17
-
18
- import json
19
- import os
20
- import re
21
-
22
- ACCEPT, REJECT, DEFER, BLINDSPOT = "ACCEPT", "REJECT", "DEFER", "BLINDSPOT"
23
- STATES = (ACCEPT, REJECT, DEFER, BLINDSPOT)
24
-
25
- CONTENT_KINDS = {
26
- "code": "代码内容 → 实测(能跑 / 测试通过)",
27
- "image_desc": "图像描述 → 识图确认(描述与图像一致)",
28
- "text": "文字内容 → 合规 + 纪律",
29
- "permission": "权限操作 → 是否具有权限",
30
- "work_done": "工作完成 → 工作项是否通过验收",
31
- "work_wip": "工作进行 → 是否已有完整成果 + 是否符合纪律",
32
- "ccg_marks": "CCG 六要素候选 → 编外验证方实测确认(生成方不得自证)",
33
- "hyperedge": "跨端验证超边 → 回放重建比对(回执逐字段一致)",
34
- }
35
-
36
- # content_kind → 建议的 verification_basis
37
- # (见 nodefile.VERIFICATION_BASIS:compiler|test|measurement|formal_proof|data|textbook|public_kb|other)
38
- KIND_BASIS = {"code": "test", "image_desc": "measurement", "text": "other",
39
- "permission": "data", "work_done": "test", "work_wip": "other",
40
- "ccg_marks": "test", "hyperedge": "test"}
41
-
42
- VERIFIERS = {}
43
-
44
-
45
- # 生效条件:kind 属于 CONTENT_KINDS 且(kind 不在 VERIFIERS 或 override 为真值)时把 fn 写入 VERIFIERS[kind];kind 未知、或 kind 已在 VERIFIERS 而 override 为假值时抛 ValueError。
46
- def register_verifier(kind, fn, override=False):
47
- """注入/替换某类内容的验证器(外部能力接入点)。"""
48
- if kind not in CONTENT_KINDS:
49
- raise ValueError(f"未知内容类型:{kind}(可选 {sorted(CONTENT_KINDS)})")
50
- if kind in VERIFIERS and not override:
51
- raise ValueError(f"验证器已存在:{kind}(需 override=True)")
52
- VERIFIERS[kind] = fn
53
-
54
-
55
- # 生效条件:传入 state、kind、evidence(detail 可缺省为 None)时,返回以 state/kind/evidence 为键、basis 取 KIND_BASIS.get(kind)(查不到即 None)的判定字典。
56
- def _verdict(state, kind, evidence, detail=None):
57
- return {"state": state, "kind": kind, "basis": KIND_BASIS.get(kind),
58
- "evidence": evidence, "detail": detail}
59
-
60
-
61
- # ---------- 规则库(合规 / 纪律) ----------
62
-
63
- # 生效条件:path 为假值(None 或空串)时回落到 os.environ 的 MDCG_POLICY_FILE,所得路径仍为假值或 os.path.exists 判定为假时返回 {};否则按 json.load 读取,抛 OSError/ValueError 或结果非 dict 时返回 {},是 dict 则返回该 dict。
64
- def load_rulebook(path=None):
65
- """{"forbidden": [正则], "required": [正则]};缺失返回空规则。"""
66
- path = path or os.environ.get("MDCG_POLICY_FILE")
67
- if not path or not os.path.exists(path):
68
- return {}
69
- try:
70
- with open(path, encoding="utf-8") as f:
71
- rules = json.load(f)
72
- except (OSError, ValueError):
73
- return {}
74
- return rules if isinstance(rules, dict) else {}
75
-
76
-
77
- # 生效条件:当 rules 的 forbidden 与 required 去空后非全空时,逐条对 text 做 re.search(非法正则跳过),无禁止命中且必需项全部命中才返回 ACCEPT,否则 REJECT;两类都为空时返回 DEFER。
78
- def _rule_check(text, rules):
79
- """规则为空 → DEFER(无规则不能假装合规)。"""
80
- forbidden = [r for r in (rules.get("forbidden") or []) if r]
81
- required = [r for r in (rules.get("required") or []) if r]
82
- if not forbidden and not required:
83
- return DEFER, "未配置合规/纪律规则(MDCG_POLICY_FILE),无法判定"
84
- for pat in forbidden:
85
- try:
86
- if re.search(pat, text):
87
- return REJECT, f"命中禁止规则:{pat}"
88
- except re.error:
89
- continue
90
- missing = []
91
- for pat in required:
92
- try:
93
- if not re.search(pat, text):
94
- missing.append(pat)
95
- except re.error:
96
- continue
97
- if missing:
98
- return REJECT, f"缺少必需要素:{missing[:3]}"
99
- return ACCEPT, f"通过 {len(forbidden)} 条禁止 + {len(required)} 条必需规则"
100
-
101
-
102
- # ---------- 内建验证器 ----------
103
-
104
- # 生效条件:以 payload['content'](为假值则回落 payload['text'],再为假值取空串)作为文本,用 ctx['rules'](为假值则回落 load_rulebook())做规则检查,返回 _verdict(检查状态, 'text', 证据)。
105
- def _verify_text(payload, ctx):
106
- text = str(payload.get("content") or payload.get("text") or "")
107
- state, ev = _rule_check(text, ctx.get("rules") or load_rulebook())
108
- return _verdict(state, "text", ev)
109
-
110
-
111
- # 生效条件:ctx['principal'] 缺失或为 None 时恒 DEFER;否则按 payload['action'](为假值取空串)是否属于 admin/forget/restore/review_decide 分别取 p.can_admin 或 p.can_write,且 p 具 allows 方法而 payload['sensitivity'] 为真值时再叠加 p.allows(sensitivity),按最终 ok 返回 ACCEPT/REJECT。
112
- def _verify_permission(payload, ctx):
113
- p = ctx.get("principal")
114
- if p is None:
115
- return _verdict(DEFER, "permission", "缺少 principal,无法判定权限")
116
- action = str(payload.get("action") or "")
117
- if action in ("admin", "forget", "restore", "review_decide"):
118
- ok = bool(getattr(p, "can_admin", False))
119
- else:
120
- ok = bool(getattr(p, "can_write", False))
121
- sens = payload.get("sensitivity")
122
- if ok and sens and hasattr(p, "allows"):
123
- ok = bool(p.allows(sens))
124
- return _verdict(ACCEPT if ok else REJECT, "permission",
125
- f"action={action or 'write'} can_write={getattr(p, 'can_write', None)} "
126
- f"can_admin={getattr(p, 'can_admin', None)}")
127
-
128
-
129
- # 生效条件:payload['content'](为假值取空串)经 ctx['rules'](为假值回落 load_rulebook())检查后,state==REJECT 即 REJECT;否则 ctx['cg'] 非 None 且 payload['topic'](为假值回落 payload['query'],再为假值取空串)非空且 cg.search 结果含 ACCEPT 状态节点时 DEFER(查询抛异常则跳过该路);再 state==DEFER 时 DEFER,否则 ACCEPT。
130
- def _verify_work_wip(payload, ctx):
131
- text = str(payload.get("content") or "")
132
- state, ev = _rule_check(text, ctx.get("rules") or load_rulebook())
133
- if state == REJECT:
134
- return _verdict(REJECT, "work_wip", f"纪律不合规:{ev}")
135
- cg = ctx.get("cg")
136
- topic = str(payload.get("topic") or payload.get("query") or "")
137
- if cg is not None and topic:
138
- try:
139
- res, _ = cg.search(topic, layer="knowledge", k=5, record=False)
140
- done = [n.get("id") for n, _s, q in res if q.get("state") == ACCEPT]
141
- if done:
142
- return _verdict(DEFER, "work_wip",
143
- f"已存在同主题成果节点 {done[:3]},应合并而非新增")
144
- except Exception: # noqa: BLE001 —— 查询失败不阻塞审核
145
- pass
146
- if state == DEFER:
147
- return _verdict(DEFER, "work_wip", ev)
148
- return _verdict(ACCEPT, "work_wip", f"纪律通过且未见重复成果({ev})")
149
-
150
-
151
- # 生效条件:payload['content'](为假值取空串)为空白即 REJECT '空内容';ast.parse 抛 SyntaxError 即 REJECT 语法错误;payload['test_cmd'] 与 os.environ 的 MDCG_CODE_TEST_CMD 均为假值时 ACCEPT(仅静态验证);否则 shlex.split 抛 ValueError 或 subprocess 抛 OSError/SubprocessError 即 DEFER,returncode 为 0 即 ACCEPT,非 0 即 REJECT 并附 returncode 与 stdout/stderr 尾部。
152
- def _verify_code(payload, ctx):
153
- """代码内容 → 实测:AST 可解析为最低门槛;给了 test_cmd 则真跑测试。
154
-
155
- test_cmd 只能由调用方显式提供(payload.test_cmd 或 MDCG_CODE_TEST_CMD),
156
- 不配置时只做静态验证并如实说明,绝不假装"已实测"。
157
- """
158
- import ast
159
- import shlex
160
- import subprocess
161
-
162
- src = str(payload.get("content") or "")
163
- if not src.strip():
164
- return _verdict(REJECT, "code", "空内容")
165
- try:
166
- tree = ast.parse(src)
167
- except SyntaxError as exc:
168
- return _verdict(REJECT, "code", f"语法错误 L{exc.lineno}: {exc.msg}")
169
- n_def = sum(isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
170
- for x in ast.walk(tree))
171
- # P2-18(批次 30,外部审查报告):test_cmd 只允许服务端 env 配置——
172
- # payload 是模型可控输入面,一旦接到工具参数顶层即为 RCE 注入点。
173
- cmd = os.environ.get("MDCG_CODE_TEST_CMD")
174
- if not cmd:
175
- return _verdict(ACCEPT, "code",
176
- f"AST 解析通过({n_def} 个定义);未配置 test_cmd,仅静态验证")
177
- try:
178
- argv = shlex.split(cmd)
179
- except ValueError as exc:
180
- return _verdict(DEFER, "code", f"test_cmd 解析失败:{exc}")
181
- try:
182
- # encoding 必须显式指定:`text=True` 会退回 locale 编码(Windows 常为 gbk),
183
- # 被测命令只要输出非 gbk 字节,读取线程就抛 UnicodeDecodeError →
184
- # p.stdout/p.stderr 可能为空 → 下一行的失败证据丢失,
185
- # 「实测失败」会退化成一句没有依据的 REJECT(对齐 whitebox.py 的写法)。
186
- p = subprocess.run(argv, cwd=ctx.get("cwd"), capture_output=True,
187
- text=True, encoding="utf-8", errors="replace",
188
- shell=False,
189
- timeout=int(os.environ.get("MDCG_CODE_TEST_TIMEOUT", "60")))
190
- except (OSError, subprocess.SubprocessError) as exc:
191
- return _verdict(DEFER, "code", f"测试无法执行:{type(exc).__name__}: {exc}")
192
- if p.returncode == 0:
193
- return _verdict(ACCEPT, "code", f"实测通过:{cmd}")
194
- tail = ((p.stdout or "")[-300:] + (p.stderr or "")[-300:]).strip()
195
- return _verdict(REJECT, "code", f"实测失败 rc={p.returncode}:{tail}")
196
-
197
-
198
- # 生效条件:调用 _pending 并传入 kind 与 why 后,其返回的内层闭包 _fn 对任意 payload/ctx 均求值为 _verdict(DEFER, kind, why),其中 kind 与 why 来自外层 _pending 的闭包,而非 _fn 的形参;。
199
- def _pending(kind, why):
200
- # 生效条件:调用 _pending 并传入 kind 与 why 后,其返回的内层闭包 _fn 对任意 payload/ctx 均求值为 _verdict(DEFER, kind, why),其中 kind 与 why 来自外层 _pending 的闭包,而非 _fn 的形参;。
201
- def _fn(payload, ctx):
202
- return _verdict(DEFER, kind, why)
203
- return _fn
204
-
205
-
206
- # 生效条件:payload['node_id'] 去空后为空即 DEFER;否则取 payload['unit_verdict'](为假值回落 payload['verdict'])去空转大写,其属于 STATES 时——verifier 为空即 DEFER、ctx['compiled_by'](为假值回落 payload['compiled_by'])非空且等于 verifier 即 REJECT E041、verdict==ACCEPT 而 evidence 为空即 DEFER、其余按该 verdict 裁定;其不属于 STATES 时经 units.probe 探测(探测抛异常亦恒 DEFER)后恒 DEFER 并给出通道与下一步提示。
207
- def _verify_ccg_marks(payload, ctx):
208
- """CCG 六要素候选的验证闸门:**只认认知图外的复核裁决**(裁定 A)。
209
-
210
- 本闸门**不在写入路径里阻塞跑 LLM**——写入是同步闸门,等待外部单元会卡住写路径。
211
- 正确的异步三段式:compile(ccgc)→ 外部单元复核(蜂巢 reflect/verify,或
212
- harness 端子代理)→ 带 `unit_verdict` 回到本闸门 → ACCEPT 才谈得上写入
213
- (`ccgc.link` 仍**独立**校验签章,本闸门不是唯一防线)。
214
-
215
- payload 键:
216
- node_id 目标节点(必填)
217
- unit_verdict 外部单元裁决 ACCEPT/REJECT/DEFER/BLINDSPOT
218
- (缺 → DEFER + 给出「下一步怎么拿到裁决」的通道提示)
219
- verifier 验证方标识(须 != compiled_by,E041)
220
- evidence 裁决依据(ACCEPT 而无依据 → DEFER:无依据不通过)
221
- model/allow_degrade/channel 可选,仅用于探测降级通道
222
- ctx 键:
223
- compiled_by 编译执行者(E041 比对)
224
- """
225
- node_id = str(payload.get("node_id") or "").strip()
226
- if not node_id:
227
- return _verdict(DEFER, "ccg_marks", "缺少 node_id:六要素候选须指明目标节点")
228
- verdict = str(payload.get("unit_verdict") or payload.get("verdict") or "").strip().upper()
229
- verifier = str(payload.get("verifier") or "").strip()
230
- evidence = str(payload.get("evidence") or "").strip()
231
- compiled_by = str(ctx.get("compiled_by") or payload.get("compiled_by") or "").strip()
232
-
233
- if verdict in STATES:
234
- if not verifier:
235
- return _verdict(DEFER, "ccg_marks",
236
- "有裁决但缺验证方标识(verifier)——无法证明是编外复核")
237
- if compiled_by and verifier == compiled_by:
238
- return _verdict(REJECT, "ccg_marks",
239
- "E041 自证拒绝:验证方标识 == 编译执行者(LLM 不得自己验证自己)")
240
- if verdict == ACCEPT and not evidence:
241
- return _verdict(DEFER, "ccg_marks",
242
- "ACCEPT 但未给出裁决依据(evidence 为空)——无依据不通过")
243
- return _verdict(verdict, "ccg_marks", evidence or "(未给依据)",
244
- detail={"node_id": node_id, "verifier": verifier,
245
- "job_id": payload.get("job_id") or ""})
246
-
247
- # 无裁决 → 探测复核通道,按三态给出「下一步怎么拿到裁决」(不阻塞、不假装通过)
248
- try:
249
- from . import units
250
- p = units.probe(model=payload.get("model") or "",
251
- allow_degrade=bool(payload.get("allow_degrade")),
252
- channel=payload.get("channel") or "")
253
- except Exception as exc: # noqa: BLE001 —— 探测失败亦不假装
254
- return _verdict(DEFER, "ccg_marks",
255
- "未获复核裁决,且复核通道探测失败:%s: %s"
256
- % (type(exc).__name__, exc))
257
- if p["state"] == units.HIVE:
258
- nxt = ("复核通道=蜂巢(model=%s):cg(op=ccg, action=review, node_id=%s, "
259
- "blocking=true) 取得裁决后带 unit_verdict/verifier 重入本闸门"
260
- % (p["model"], node_id))
261
- elif p["state"] == units.SUBAGENT:
262
- nxt = ("已降级 harness 端子代理(channel=%s):把 units.review 返回的 prompt 交给"
263
- "子代理执行,取回 JSON 裁决后带 unit_verdict/verifier 重入本闸门"
264
- % p["channel"])
265
- else:
266
- nxt = p["hint"]
267
- return _verdict(DEFER, "ccg_marks",
268
- "六要素候选尚未经编外复核(未获 unit_verdict)——缺复核恒不通过",
269
- detail={"channel_state": p["state"], "next": nxt,
270
- "jobs_dir": p["jobs_dir"], "model": p["model"],
271
- "node_id": node_id})
272
-
273
-
274
- # ---------- 分派入口 ----------
275
-
276
- # 生效条件:content_kind 去空后不在 CONTENT_KINDS 即 BLINDSPOT;否则取 VERIFIERS 中该 kind 的验证器,缺失即 DEFER,调用抛异常即 DEFER,成功则以返回值 state(不在 STATES 时降级为 DEFER)连同 evidence/detail 构造裁定。
277
- def audit(content_kind, payload=None, ctx=None):
278
- """按内容类型分派验证器。未知类型 → BLINDSPOT;缺验证器 → DEFER。"""
279
- kind = (content_kind or "").strip()
280
- payload, ctx = payload or {}, ctx or {}
281
- if kind not in CONTENT_KINDS:
282
- return _verdict(BLINDSPOT, kind, f"未知内容类型:{kind!r}(可选 {sorted(CONTENT_KINDS)})")
283
- fn = VERIFIERS.get(kind)
284
- if fn is None:
285
- return _verdict(DEFER, kind, f"未注入 {kind} 验证器")
286
- try:
287
- v = fn(payload, ctx) or {}
288
- except Exception as exc: # noqa: BLE001 —— 验证器异常不视为通过
289
- return _verdict(DEFER, kind, f"验证器异常:{type(exc).__name__}: {exc}")
290
- state = v.get("state") if v.get("state") in STATES else DEFER
291
- return _verdict(state, kind, str(v.get("evidence") or ""), v.get("detail"))
292
-
293
-
294
- # 生效条件:对模块级常量 CONTENT_KINDS 中的每个 k 返回 action=CONTENT_KINDS[k]、basis=KIND_BASIS[k]、verifier 为 'builtin'(k 在 VERIFIERS 中)否则 'missing'。
295
- def kinds():
296
- """内容类型清单 + 验证器可用性(供 service_info / health 自描述)。"""
297
- return {k: {"action": CONTENT_KINDS[k], "basis": KIND_BASIS[k],
298
- "verifier": "builtin" if k in VERIFIERS else "missing"}
299
- for k in CONTENT_KINDS}
300
-
301
-
302
- # ---------- 外部能力接入(**能力外置,认知图只留接口**) ----------
303
- #
304
- # 架构约束(见模块 docstring):识图/实测/验收等**能力不在认知图内**。
305
- # 认知图只做三件事:按内容类型选验证器 → 调用 → 记账。
306
- # 因此本模块**不内置任何具体能力实现**(不读图像、不跑测试、不连网络),
307
- # 只提供注入点:外部模块在被 import 时调用 register_verifier(...),
308
- # 或提供 register(audit_module) 函数由本函数回调。
309
- #
310
- # 能力模块通常位于**私有运行时仓**(如灵枢身体 AEIS),经 MDCG_VERIFIER_MODULES
311
- # 以 import 路径声明;公开的大脑仓不含这些能力。
312
- VERIFIER_MODULES_ENV = "MDCG_VERIFIER_MODULES"
313
-
314
-
315
- # 生效条件:modules 为 None 时按 os.environ 的 VERIFIER_MODULES_ENV(为假值取空串)取逗号分隔模块名,去空后列表为空即返回空报告;否则逐个 import_module 并调用其可调用的 register(本模块),单个导入或调用失败记入 failed 且 strict 为真值时立即抛出、为假值时继续,成功者记入 loaded,最后以 CONTENT_KINDS 生成 verifiers 可用性映射。
316
- def load_external_verifiers(modules=None, strict=False):
317
- """按 `MDCG_VERIFIER_MODULES`(逗号分隔 import 路径)加载外部验证器模块。
318
-
319
- 单个模块失败不影响其余(除非 strict=True);失败原因如实返回,不静默。
320
- 返回 {"loaded": [...], "failed": [{"module","error"}], "verifiers": {...}}。
321
- """
322
- import importlib
323
- import sys as _sys
324
- spec = modules
325
- if spec is None:
326
- spec = os.environ.get(VERIFIER_MODULES_ENV) or ""
327
- names = [x.strip() for x in str(spec).split(",") if x.strip()]
328
- # P2-19(批次 30):模块名格式白名单——拒绝空串外的异常形态(路径分隔/
329
- # 通配/扩展名等非 import 路径输入),加载动作本身写 stderr(可见性)。
330
- import re as _re
331
- _bad = [n for n in names if not _re.match(r"^[A-Za-z_][A-Za-z0-9_.]*$", n)]
332
- rep = {"loaded": [], "failed": [], "verifiers": {}}
333
- for n in _bad:
334
- names.remove(n)
335
- rep["failed"].append({"module": n,
336
- "error": "模块名不符合 import 路径格式(P2-19 白名单)"})
337
- if not names:
338
- return rep
339
- this = _sys.modules[__name__]
340
- for name in names:
341
- try:
342
- mod = importlib.import_module(name)
343
- except Exception as exc: # noqa: BLE001
344
- rep["failed"].append({"module": name,
345
- "error": "%s: %s" % (type(exc).__name__, exc)})
346
- if strict:
347
- raise
348
- continue
349
- _sys.stderr.write("[mdcg-audit] 已加载外部验证器模块: %s\n" % name)
350
- fn = getattr(mod, "register", None)
351
- if callable(fn):
352
- try:
353
- fn(this)
354
- except Exception as exc: # noqa: BLE001
355
- rep["failed"].append({"module": name,
356
- "error": "register(): %s: %s"
357
- % (type(exc).__name__, exc)})
358
- if strict:
359
- raise
360
- continue
361
- rep["loaded"].append(name)
362
- rep["verifiers"] = {k: ("builtin" if k in VERIFIERS else "missing")
363
- for k in CONTENT_KINDS}
364
- return rep
365
-
366
-
367
- # ---------- 内建注册(缺外部能力的用 DEFER 占位) ----------
368
-
369
- register_verifier("text", _verify_text)
370
- register_verifier("permission", _verify_permission)
371
- register_verifier("work_wip", _verify_work_wip)
372
- register_verifier("code", _verify_code)
373
- # image_desc / work_done 的**能力**由外部模块注入(见上)。未注入时 DEFER——
374
- # 诚实说明缺什么,绝不假装通过。
375
- register_verifier("image_desc", _pending("image_desc", "未注入识图验证器(需视觉能力)"))
376
- register_verifier("work_done", _pending("work_done", "未注入验收器(需验收标准)"))
377
- # ccg_marks:闸门本身不是「能力」而是**准入判据**——只认编外单元的裁决
378
- # (unit_verdict + verifier);无裁决时探测复核通道(蜂巢→配置→子代理)并给出
379
- # 下一步,恒不假装通过。真实复核由外部单元完成,认知图只负责记账。
1
+ # -*- coding: utf-8 -*-
2
+ """写入审核:按内容类型分派的验证体系。
3
+
4
+ 架构约束:验证能力不在认知图内(跑测试/识图/验收都不是记忆基底的职责)。
5
+ 认知图只做三件事:按内容类型选验证器 → 调用 → 记账。缺能力返回 DEFER,绝不假装通过。
6
+
7
+ 内容类型 → 验证动作:
8
+ code 代码内容 → 实测 image_desc 图像描述 → 识图确认
9
+ text 文字内容 → 合规 + 纪律 permission 权限操作 → 是否具有权限
10
+ work_done 工作完成 → 验收 work_wip 工作进行 → 完整成果 + 纪律
11
+ ccg_marks CCG 六要素 → 编外验证方实测(生成方不得自证)
12
+
13
+ 裁决四态复用 judge_qualification:ACCEPT / REJECT / DEFER / BLINDSPOT
14
+ 验证器签名:fn(payload: dict, ctx: dict) -> {"state":..., "evidence":..., "detail":...}
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+
22
+ ACCEPT, REJECT, DEFER, BLINDSPOT = "ACCEPT", "REJECT", "DEFER", "BLINDSPOT"
23
+ STATES = (ACCEPT, REJECT, DEFER, BLINDSPOT)
24
+
25
+ CONTENT_KINDS = {
26
+ "code": "代码内容 → 实测(能跑 / 测试通过)",
27
+ "image_desc": "图像描述 → 识图确认(描述与图像一致)",
28
+ "text": "文字内容 → 合规 + 纪律",
29
+ "permission": "权限操作 → 是否具有权限",
30
+ "work_done": "工作完成 → 工作项是否通过验收",
31
+ "work_wip": "工作进行 → 是否已有完整成果 + 是否符合纪律",
32
+ "ccg_marks": "CCG 六要素候选 → 编外验证方实测确认(生成方不得自证)",
33
+ "hyperedge": "跨端验证超边 → 回放重建比对(回执逐字段一致)",
34
+ }
35
+
36
+ # content_kind → 建议的 verification_basis
37
+ # (见 nodefile.VERIFICATION_BASIS:compiler|test|measurement|formal_proof|data|textbook|public_kb|other)
38
+ KIND_BASIS = {"code": "test", "image_desc": "measurement", "text": "other",
39
+ "permission": "data", "work_done": "test", "work_wip": "other",
40
+ "ccg_marks": "test", "hyperedge": "test"}
41
+
42
+ VERIFIERS = {}
43
+
44
+
45
+ # 生效条件:kind 属于 CONTENT_KINDS 且(kind 不在 VERIFIERS 或 override 为真值)时把 fn 写入 VERIFIERS[kind];kind 未知、或 kind 已在 VERIFIERS 而 override 为假值时抛 ValueError。
46
+ def register_verifier(kind, fn, override=False):
47
+ """注入/替换某类内容的验证器(外部能力接入点)。"""
48
+ if kind not in CONTENT_KINDS:
49
+ raise ValueError(f"未知内容类型:{kind}(可选 {sorted(CONTENT_KINDS)})")
50
+ if kind in VERIFIERS and not override:
51
+ raise ValueError(f"验证器已存在:{kind}(需 override=True)")
52
+ VERIFIERS[kind] = fn
53
+
54
+
55
+ # 生效条件:传入 state、kind、evidence(detail 可缺省为 None)时,返回以 state/kind/evidence 为键、basis 取 KIND_BASIS.get(kind)(查不到即 None)的判定字典。
56
+ def _verdict(state, kind, evidence, detail=None):
57
+ return {"state": state, "kind": kind, "basis": KIND_BASIS.get(kind),
58
+ "evidence": evidence, "detail": detail}
59
+
60
+
61
+ # ---------- 规则库(合规 / 纪律) ----------
62
+
63
+ POLICY_ENV = "MDCG_POLICY_FILE"
64
+ # 包内默认策略(issue #43 问题 1 修复,2026-09-29 使用者裁定方向 ①+③)。
65
+ # 根因(第4条取证):修前 `load_rulebook` 只认 MDCG_POLICY_FILE,未设即返回
66
+ # **空规则库**(无任何默认回落),而 npm 发布面不含 `data/policy.json`、
67
+ # `lingshu-init` 也不生成该 env——「README 说默认 data/policy.json」成为空话。
68
+ # 后果不是「少一条规则」而是**安全面翻转**:空规则让 text 恒判 DEFER,
69
+ # 而 writepipe._gate_audit 的非 ACCEPT/REJECT 出口是 `cg.propose` ⇒ 正文
70
+ # (含凭据)**明文**落 hippocampus/inbox.jsonl,且脱敏只在 REJECT 分支。
71
+ # 故:未设时回落到包内默认;包内默认也拿不到时 fail-closed——由写入闸门
72
+ # 在**提案入队之前**拦下(见 resolve_rulebook 与 writepipe._gate_audit)。
73
+ DEFAULT_POLICY_REL = ("data", "policy.json")
74
+
75
+ # fail-closed 时给调用方的**可执行**下一步(错误体 hint 单点,勿在多处各写一份)。
76
+ POLICY_HINT = ("设置 MDCG_POLICY_FILE 指向可解析的策略文件(JSON 对象,含 "
77
+ "forbidden/required 键),或重装本包——默认策略随包发布"
78
+ "(包内 data/policy.json);重装后无需再设 env。")
79
+
80
+ # 列表型策略键(**判据面**):这些键的语义就是「一组模式」,取值必须是列表。
81
+ # 白名单式(而非「所有键都须是列表」)是必须的:默认策略里 `_comment`/`_note`
82
+ # 本就是字符串,泛化类型闸会把**合法默认策略**判死。故形状闸只认这四键,
83
+ # 其余键(含未知键)一律不参与形状判定——未知键是「没读到」,不是「读坏了」。
84
+ POLICY_LIST_KEYS = ("forbidden", "required", "required_kinds", "required_labels")
85
+
86
+
87
+ # 生效条件:rules 为 dict 时取 key 的**列表型**安全取值——list/tuple 原样浅拷贝,其余(标量/对象/None/缺键)一律返回空列表;rules 非 dict 亦返回空列表;恒不抛异常。
88
+ def _policy_list(rules, key):
89
+ """列表型键的**安全**取值(纵深防御,不是可用性判据)。
90
+
91
+ 为什么容错到「空」而不是抛:形状闸(`_policy_shape_error`)已在**读取单点**
92
+ 把畸形策略判为不可用,写入闸门据此 fail-closed,正常路径下本函数只会碰到
93
+ 列表。但 `_rule_check` / `redact_forbidden` 还被**别的调用面**直接喂 rules
94
+ (`audit.audit` 的 `ctx["rules"]` 兜底、验证器模块、测试),那里若因
95
+ `TypeError` 被兜底 `except` 吞掉,就退化成「看起来正常」的 DEFER——正是
96
+ issue #43 的病态出口。故消费面只保证两件事:**不抛**、**不把一个字符串
97
+ 按字符拆成规则**(`'sk-…'` 迭代即 23 条单字符规则,计数与判定双双失真)。
98
+ 可用性判定不在这里,仍单点在策略读取面——「同一语义两处实现」必然漂移。
99
+ """
100
+ v = rules.get(key) if isinstance(rules, dict) else None
101
+ return list(v) if isinstance(v, (list, tuple)) else []
102
+
103
+
104
+ # 生效条件:rules 非 dict 时返回 None;否则逐键检查 POLICY_LIST_KEYS——任一键**存在**且取值非 list/tuple 即返回 ("policy_bad_shape", 含键名与实得类型的原因串);全部合规返回 None。恒不抛异常。
105
+ def _policy_shape_error(rules):
106
+ """策略**内容形状闸**(issue #43 键类型面补强,2026-09-29):→ (错误码, 原因) | None。
107
+
108
+ 前提与病态(复核 DEFER 的实据):策略是**合法 JSON 对象**,但列表型键取值
109
+ 是标量——`{"forbidden": 1}` / `{"required": true}` ⇒ `policy_report()` 的
110
+ 列表推导抛 `TypeError`,而启动期的策略来源行(`mcp_server.main()` →
111
+ `_policy_stderr_note(_audit.policy_report())`)**不在任何 try 内**
112
+ ⇒ **server rc=1**(启动即崩=记忆面整体不可用);`--show-config` 同 rc=1
113
+ 且 stdout 空,与其「恒退出 0」契约相反;写入侧 `_rule_check` 同抛,被
114
+ `audit.py` 验证器兜底 `except Exception → DEFER` 吞掉 ⇒ `moved_to=
115
+ review_queue`、正文(含凭据)**逐字**落 `hippocampus/inbox.jsonl`。
116
+ `{"forbidden": "sk-…"}` 更隐蔽:**不抛**但**按字符建规则**——`policy_report`
117
+ 报 `forbidden=23`(把长度当规则数),写入侧以单字符规则判定 ⇒ 静默错判。
118
+
119
+ 为什么**一律**判不可用(字符串「整串作单条规则」这条路也不走):①标量取值
120
+ 没有唯一合理读法——`"sk-…"` 既可能是「一条规则写成了标量」也可能是「一串
121
+ 正则被压成一行」,替调用方猜即是替它决定安全边界;②按字面迭代即按字符建
122
+ 规则(见上);③三种形态同一个根因(列表型键没有类型闸),故同一个出口:
123
+ 形状非法 ⇒ 策略不可用 ⇒ 在**提案入队之前** fail-closed,错误结构化 +
124
+ 可执行 hint(`_read_policy_file` 把它并进既有错误码面,故闸门与诊断面
125
+ 无需各自再判一次)。
126
+ """
127
+ if not isinstance(rules, dict):
128
+ return None
129
+ for key in POLICY_LIST_KEYS:
130
+ if key in rules and not isinstance(rules[key], (list, tuple)):
131
+ return ("policy_bad_shape",
132
+ "策略键 %s 须为列表(实得 %s)——数组之外的值没有唯一读法,"
133
+ "不猜、不按字符拆开" % (key, type(rules[key]).__name__))
134
+ return None
135
+
136
+
137
+ # 生效条件:始终返回本模块所在包的包根绝对路径(`md_cg/` 的父目录),由 __file__ 反推——与 os.getcwd() 无关,cwd 在任何目录下取值相同。
138
+ def package_root() -> str:
139
+ """包根(`md_cg/` 的父目录)——定位方式与 mcp_server._package_version 同族。"""
140
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
141
+
142
+
143
+ # 生效条件:始终返回包内默认策略文件的绝对路径(<包根>/data/policy.json);不判存在性、不读盘,路径不可得的情形由 resolve_rulebook 报 policy_unavailable。
144
+ def default_policy_path() -> str:
145
+ """包内随发默认策略路径(按包根解析,不依赖 cwd)。"""
146
+ return os.path.join(package_root(), *DEFAULT_POLICY_REL)
147
+
148
+
149
+ # 生效条件:path 指向的文件可读、json.load 得 dict 且通过形状闸(POLICY_LIST_KEYS 取值皆列表)时返回 (rules, None);文件不存在返回 (None, ("policy_not_found", 原因)),OSError 返回 ("policy_unreadable", 原因),JSON 非法返回 ("policy_invalid_json", 原因),顶层非对象返回 ("policy_not_object", 原因),列表型键取值非列表返回 ("policy_bad_shape", 原因);空 dict 是**可用**策略(内容为空,非可用性故障)。
150
+ def _read_policy_file(path):
151
+ """读单个策略文件 → (rules|None, (错误码, 原因)|None)。错误码集合是判据面。"""
152
+ if not os.path.exists(path):
153
+ return None, ("policy_not_found", "文件不存在:%s" % path)
154
+ try:
155
+ with open(path, encoding="utf-8") as f:
156
+ rules = json.load(f)
157
+ except OSError as exc:
158
+ return None, ("policy_unreadable", "读取失败:%s: %s"
159
+ % (type(exc).__name__, exc))
160
+ except ValueError as exc:
161
+ return None, ("policy_invalid_json", "JSON 解析失败:%s" % exc)
162
+ if not isinstance(rules, dict):
163
+ return None, ("policy_not_object",
164
+ "顶层须为 JSON 对象(实得 %s)" % type(rules).__name__)
165
+ shape = _policy_shape_error(rules) # 形状闸(键类型面):与不可用同码
166
+ if shape is not None:
167
+ return None, shape
168
+ return rules, None
169
+
170
+
171
+ # 生效条件:path 显式为真值、或 os.environ 的 POLICY_ENV(MDCG_POLICY_FILE)去空后非空时以之为来源(source="env",含显式传入的 path)——该文件不可用即返回 (None, "env", 结构化 error),**不回落到包内默认**(显式指定即用户的决定,坏路径必须可见);两者皆空时回落 default_policy_path()(source="package_default"),可用返回 (rules, "package_default", None),不可用返回 (None, "unavailable", 结构化 error);恒不抛异常,结构化 error 恒含 code/path/reason/hint 四键。
172
+ def resolve_rulebook(path=None):
173
+ """策略来源解析单点(issue #43):→ (rules|None, source, error|None)。
174
+
175
+ source ∈ {"env", "package_default", "unavailable"},供启动 stderr /
176
+ --show-config / op=info 报出来源;有 error 即**策略不可用**。
177
+
178
+ 为什么单点:来源判定(env vs 包内默认 vs 不可用)只有这一处,闸门、
179
+ 自描述面、守卫读同一个函数——「同一语义两处实现」必然随改动漂移。
180
+ 为什么**不抛异常**:策略不可用是可诊断的事实,抛异常会被 audit() 的
181
+ 兜底 except 吞成 DEFER(正是本 issue 的病态出口),异常不该在这里变成
182
+ 又一个「看起来正常」的裁决。
183
+ """
184
+ explicit = path if path is not None else (os.environ.get(POLICY_ENV) or "")
185
+ explicit = str(explicit).strip()
186
+ if explicit:
187
+ rules, why = _read_policy_file(explicit)
188
+ if rules is None:
189
+ return None, "env", {"code": why[0], "path": explicit,
190
+ "reason": why[1], "hint": POLICY_HINT}
191
+ return rules, "env", None
192
+ dflt = default_policy_path()
193
+ rules, why = _read_policy_file(dflt)
194
+ if rules is None:
195
+ return None, "unavailable", {"code": "policy_unavailable",
196
+ "path": dflt, "reason": why[1],
197
+ "hint": POLICY_HINT}
198
+ return rules, "package_default", None
199
+
200
+
201
+ # 生效条件:exc 为任意异常对象时返回策略自描述的**不可用形状**(六键字典:source="unavailable"、path=""、available=False、forbidden=0、required=0、error={code:"policy_report_failed", path:"", reason:"<类型>: <消息>", hint:POLICY_HINT}),恒不抛异常。
202
+ def unavailable_report(exc):
203
+ """策略自描述兜底的**同一形状**(`policy_report` 内部兜底与各调用面兜底共用)。
204
+
205
+ 为什么单点:调用面(启动 stderr / `--show-config` / `op=info`)只需要「拿到
206
+ 一个可渲染的自描述」,不需要知道怎么造它;兜底形状若各面各写一份,改一处
207
+ 必留另一处漂移——本仓「同一语义两处实现」的既有教训都在这。
208
+ """
209
+ return {"source": "unavailable", "path": "", "available": False,
210
+ "forbidden": 0, "required": 0,
211
+ "error": {"code": "policy_report_failed", "path": "",
212
+ "reason": "%s: %s" % (type(exc).__name__, exc),
213
+ "hint": POLICY_HINT}}
214
+
215
+
216
+ # 生效条件:调 resolve_rulebook() 后恒返回六键字典 {"source","path","available","forbidden","required","error"}——path 在可用时按来源取(env 取 env/显式路径、包内默认取 default_policy_path())、不可用时取 error.path;forbidden/required 为去空后的模式计数(策略不可用时均为 0,取值经 _policy_list 类型闸,标量键计数为 0 而非其长度);error 为 None 或结构化错误;**恒不抛异常**(形状闸是第一道,本函数的 except 是第二道,兜底形状由 unavailable_report 单点给出)。
217
+ def policy_report():
218
+ """策略自描述(启动 stderr / --show-config / op=info 共用,单点)。
219
+
220
+ 为什么单点:来源文案与计数若各面各写一份,改一处必留另一处漂移——
221
+ 本仓「同一语义两处实现」的既往教训(负条件判据、CCG 冒号形态)都在这。
222
+
223
+ 为什么**恒不抛**(issue #43 键类型面补强):本函数是**启动路径**上的调用
224
+ (`mcp_server.py:3907`,构造点在 main() 任何 try 之外——N225 的教训:启动期
225
+ 崩=记忆面整体不可用),也是 `--show-config` 的应答体(其契约是恒退出 0)。
226
+ 诊断面不得因为「策略畸形」这件事本身而死:畸形恰好是最需要被诊断出来的
227
+ 情形。两道防线:形状闸让本函数体在畸形下根本走不到列表推导;万一还有
228
+ 未预料的形态,except 收成 `unavailable_report`(available=false)而不是
229
+ traceback。
230
+ """
231
+ try:
232
+ rules, source, err = resolve_rulebook()
233
+ if err is not None:
234
+ path = err.get("path") or ""
235
+ elif source == "env":
236
+ path = os.environ.get(POLICY_ENV) or ""
237
+ else:
238
+ path = default_policy_path()
239
+ return {"source": source, "path": path, "available": err is None,
240
+ "forbidden": len([r for r in _policy_list(rules, "forbidden") if r]),
241
+ "required": len([r for r in _policy_list(rules, "required") if r]),
242
+ "error": err}
243
+ except Exception as exc: # noqa: BLE001 —— 自描述面恒不抛
244
+ return unavailable_report(exc)
245
+
246
+
247
+ # 生效条件:委托 resolve_rulebook(path)——可用时返回该规则 dict;不可用(来源缺失/不可读/非对象)时返回 {},**不抛异常**——fail-closed 的判定单点在 resolve_rulebook,落点在写入闸门(writepipe._gate_audit 在提案入队前拦下),本函数保持「空规则即空 dict」的既有契约以免调用点(redact_forbidden 等)因异常而连坐。
248
+ def load_rulebook(path=None):
249
+ """{"forbidden": [正则], "required": [正则]};不可用返回空规则 {}。
250
+
251
+ 来源口径(issue #43):path 参数 > MDCG_POLICY_FILE > 包内默认
252
+ `data/policy.json`;三者都拿不到时返回 {}(此时**不应**把 {} 当成
253
+ 「合规」——写入闸门据 resolve_rulebook 的 error fail-closed)。
254
+ """
255
+ rules, _source, _err = resolve_rulebook(path)
256
+ return rules if isinstance(rules, dict) else {}
257
+
258
+
259
+ # 生效条件:kind 属于 rules 的 required_kinds(该键缺失或去空后为空列表时视为全部 kind)时才检查 required;forbidden 对所有 kind 恒检查。两类去空后全空即返回 DEFER;有任一 forbidden 命中即返回 REJECT 与「命中禁止规则:<模式>」(非法正则跳过);required 适用且存在缺失项即返回 REJECT 与「缺少必需要素:<展示名、连接>(补齐后重写即可,本条未入库)」并附 detail={"missing": [...], "missing_patterns": [...]}(两数组按下标一一对应);全部通过即 ACCEPT(required 为空时措辞如实说明未配置必需规则,required 非空但被 kind 跳过时如实说明不适用于该 kind)。返回三元组 (state, evidence, detail)。
260
+ def _rule_check(text, rules, kind="text"):
261
+ """规则为空 → DEFER(无规则不能假装合规)。
262
+
263
+ 两类规则的生效面**不对称**:forbidden 是内容政策(机密外泄形态),对所有
264
+ content_kind 恒生效;required 是**成文格式**要求(CCG 六要素),只对
265
+ `required_kinds` 收窄内的 kind 生效——该键缺失或为空即「全部 kind」,
266
+ 与改动前(required 对所有 kind 生效)行为一致,向后兼容。
267
+
268
+ 缺失项**全列不截断**(原实现只列前 3 个,写入方拿不到完整补齐清单);
269
+ 展示名优先取 required_labels 的同下标中文名(policy 侧展示件,缺失或与
270
+ 去空后的 required 长度不一致即整体回落正则串)——labels 畸形不得抛错,
271
+ 也不得改变判定,它只影响证据文本。
272
+
273
+ 边界(如实):required 非空但被 kind 跳过、且 forbidden 为空时判 ACCEPT,
274
+ 证据里点明「不适用于该 kind」——「规则不适用于此类内容」与「未配置规则」
275
+ 是两件事(后者才 DEFER)。若部署方要求某 kind 也恒判 DEFER,应收窄
276
+ required_kinds(去掉该 kind)而不是靠本函数猜。
277
+
278
+ 键类型面(issue #43 补强):四类列表型键一律经 `_policy_list` 取值——取值
279
+ 非列表时**当空列表**而不是迭代它。畸形策略的正常路径在读取单点就被形状闸
280
+ 判为不可用(写入闸门 fail-closed),这里是纵深防御:本函数还会被直接喂
281
+ rules 的调用面用到,那里一个 `TypeError` 会被 `audit()` 的兜底 except 吞成
282
+ DEFER(病态出口),字符串取值则会被按字符建成规则。
283
+ """
284
+ forbidden = [r for r in _policy_list(rules, "forbidden") if r]
285
+ required = [r for r in _policy_list(rules, "required") if r]
286
+ if not forbidden and not required:
287
+ return DEFER, "未配置合规/纪律规则(MDCG_POLICY_FILE),无法判定", None
288
+ for pat in forbidden:
289
+ try:
290
+ if re.search(pat, text):
291
+ return REJECT, f"命中禁止规则:{pat}", None
292
+ except re.error:
293
+ continue
294
+ kinds = [str(k) for k in _policy_list(rules, "required_kinds") if str(k)]
295
+ req_active = bool(required) and (not kinds or str(kind) in kinds)
296
+ if req_active:
297
+ labels = _policy_list(rules, "required_labels")
298
+ if len(labels) != len(required):
299
+ labels = [] # 长度不匹配 → 整体回落正则串
300
+ missing, missing_patterns = [], []
301
+ for i, pat in enumerate(required):
302
+ try:
303
+ if not re.search(pat, text):
304
+ missing_patterns.append(pat)
305
+ missing.append(str(labels[i]) if labels else pat)
306
+ except re.error:
307
+ continue
308
+ if missing_patterns:
309
+ return (REJECT,
310
+ "缺少必需要素:%s(补齐后重写即可,本条未入库)"
311
+ % "、".join(missing),
312
+ {"missing": missing, "missing_patterns": missing_patterns})
313
+ if not required:
314
+ return (ACCEPT,
315
+ f"通过 {len(forbidden)} 条禁止规则(未配置必需规则)", None)
316
+ if not req_active:
317
+ return (ACCEPT,
318
+ f"通过 {len(forbidden)} 条禁止规则"
319
+ f"({len(required)} 条必需规则不适用于 content_kind={kind})", None)
320
+ return ACCEPT, f"通过 {len(forbidden)} 条禁止 + {len(required)} 条必需规则", None
321
+
322
+
323
+ # 生效条件:rules 为 None 时回落 load_rulebook();对 rules["forbidden"] 经 _policy_list 取值(非列表取值当空列表,不按字符建规则)去空后的每条模式求出全部命中跨度,按跨度合并(重叠/相邻者合并、占位符取其中**最先声明**那条规则的序号)后整段替换为「[已过滤:禁表#i]」,返回替换后的文本;非法正则跳过不抛错;无命中时原样返回 text。
324
+ def redact_forbidden(text, rules=None):
325
+ """把命中禁止规则的片段替换成占位符——负记忆(rejected)落盘前用。
326
+
327
+ REJECT 的内容仍记入负记忆(「这条被拒过」本身有价值),但命中禁表的
328
+ 片段正是**不该入库的东西**(凭据形态),原样写进 rejected 层等于拦截
329
+ 之后又把凭据存了一遍(issue #43)。占位符只带禁表序号,不带原文,也
330
+ 不带原文哈希;否决原因(evidence)里已有命中的模式,可审计。
331
+
332
+ 为什么按**跨度合并**而不是逐条 `re.sub` 串行替换(2026-09-28 PR#44 复核
333
+ 实测的反例):串行替换时先命中的窄规则会先把文字换成占位符,宽规则随后
334
+ 就再也匹配不上那段被替换过的文字——全形态令牌 `mdcg1.<role>.<id>.<secret>`
335
+ 里 id 段先被 `\\btk_...` 掩掉,宽规则 `mdcg1\\....` 便无法命中,
336
+ **密钥段原样留在负记忆里**(拦截了 id、漏了真正的凭据)。跨度合并与规则
337
+ 书写顺序无关:任一规则命中的字符一律被掩,重叠部分并为一段。
338
+
339
+ 键类型面(issue #43 补强):forbidden 经 `_policy_list` 取值——取值非列表
340
+ 当空列表,避免「字符串取值被按字符逐条建规则」时把正文掩成筛子(形状闸
341
+ 已在读取单点判其不可用,本条是消费面的纵深防御)。
342
+ """
343
+ if rules is None:
344
+ rules = load_rulebook()
345
+ pats = [r for r in _policy_list(rules, "forbidden") if r]
346
+ spans = [] # (起, 止, 规则序号) —— 序号为去空后 1 起的下标(占位符标签)
347
+ for i, pat in enumerate(pats, 1):
348
+ try:
349
+ for m in re.finditer(pat, text):
350
+ if m.end() > m.start(): # 零宽命中不掩(掩了等于插字符)
351
+ spans.append((m.start(), m.end(), i))
352
+ except re.error:
353
+ continue # 非法正则跳过,不因一条坏规则废掉整次脱敏
354
+ if not spans:
355
+ return text
356
+ spans.sort()
357
+ merged = [] # 重叠或相接的跨度并成一段,标签取最先声明的那条规则
358
+ for st, en, i in spans:
359
+ if merged and st <= merged[-1][1]:
360
+ pst, pen, pi = merged[-1]
361
+ merged[-1] = (pst, en if en > pen else pen, pi if pi < i else i)
362
+ else:
363
+ merged.append((st, en, i))
364
+ out, last = [], 0
365
+ for st, en, i in merged:
366
+ out.append(text[last:st])
367
+ out.append("[已过滤:禁表#%d]" % i)
368
+ last = en
369
+ out.append(text[last:])
370
+ return "".join(out)
371
+
372
+
373
+ # ---------- 内建验证器 ----------
374
+
375
+ # 生效条件:以 payload['content'](为假值则回落 payload['text'],再为假值取空串)作为文本,用 ctx['rules'](为假值则回落 load_rulebook())按 kind='text' 做规则检查,返回 _verdict(检查状态, 'text', 证据, 规则检查给出的 detail);缺要素 REJECT 时 detail 形如 {"missing": [中文名...], "missing_patterns": [正则...]}(其余状态为 None)。
376
+ def _verify_text(payload, ctx):
377
+ text = str(payload.get("content") or payload.get("text") or "")
378
+ state, ev, detail = _rule_check(text, ctx.get("rules") or load_rulebook(),
379
+ "text")
380
+ return _verdict(state, "text", ev, detail)
381
+
382
+
383
+ # 生效条件:ctx['principal'] 缺失或为 None 时恒 DEFER;否则按 payload['action'](为假值取空串)是否属于 admin/forget/restore/review_decide 分别取 p.can_admin 或 p.can_write,且 p 具 allows 方法而 payload['sensitivity'] 为真值时再叠加 p.allows(sensitivity),按最终 ok 返回 ACCEPT/REJECT。
384
+ def _verify_permission(payload, ctx):
385
+ p = ctx.get("principal")
386
+ if p is None:
387
+ return _verdict(DEFER, "permission", "缺少 principal,无法判定权限")
388
+ action = str(payload.get("action") or "")
389
+ if action in ("admin", "forget", "restore", "review_decide"):
390
+ ok = bool(getattr(p, "can_admin", False))
391
+ else:
392
+ ok = bool(getattr(p, "can_write", False))
393
+ sens = payload.get("sensitivity")
394
+ if ok and sens and hasattr(p, "allows"):
395
+ ok = bool(p.allows(sens))
396
+ return _verdict(ACCEPT if ok else REJECT, "permission",
397
+ f"action={action or 'write'} can_write={getattr(p, 'can_write', None)} "
398
+ f"can_admin={getattr(p, 'can_admin', None)}")
399
+
400
+
401
+ # 生效条件:payload['content'](为假值取空串)经 ctx['rules'](为假值回落 load_rulebook())按 kind='work_wip' 检查后,state==REJECT 即 REJECT(证据前缀「纪律不合规:」,缺要素时把规则检查的 detail 一并透传);否则 ctx['cg'] 非 None 且 payload['topic'](为假值回落 payload['query'],再为假值取空串)非空且 cg.search 结果含 ACCEPT 状态节点时 DEFER(查询抛异常则跳过该路);再 state==DEFER 时 DEFER,否则 ACCEPT。
402
+ def _verify_work_wip(payload, ctx):
403
+ text = str(payload.get("content") or "")
404
+ state, ev, detail = _rule_check(text, ctx.get("rules") or load_rulebook(),
405
+ "work_wip")
406
+ if state == REJECT:
407
+ return _verdict(REJECT, "work_wip", f"纪律不合规:{ev}", detail)
408
+ cg = ctx.get("cg")
409
+ topic = str(payload.get("topic") or payload.get("query") or "")
410
+ if cg is not None and topic:
411
+ try:
412
+ res, _ = cg.search(topic, layer="knowledge", k=5, record=False)
413
+ done = [n.get("id") for n, _s, q in res if q.get("state") == ACCEPT]
414
+ if done:
415
+ return _verdict(DEFER, "work_wip",
416
+ f"已存在同主题成果节点 {done[:3]},应合并而非新增")
417
+ except Exception: # noqa: BLE001 —— 查询失败不阻塞审核
418
+ pass
419
+ if state == DEFER:
420
+ return _verdict(DEFER, "work_wip", ev)
421
+ return _verdict(ACCEPT, "work_wip", f"纪律通过且未见重复成果({ev})")
422
+
423
+
424
+ # 生效条件:payload['content'](为假值取空串)为空白即 REJECT '空内容';ast.parse 抛 SyntaxError 即 REJECT 语法错误;payload['test_cmd'] 与 os.environ 的 MDCG_CODE_TEST_CMD 均为假值时 ACCEPT(仅静态验证);否则 shlex.split 抛 ValueError 或 subprocess 抛 OSError/SubprocessError 即 DEFER,returncode 为 0 即 ACCEPT,非 0 即 REJECT 并附 returncode 与 stdout/stderr 尾部。
425
+ def _verify_code(payload, ctx):
426
+ """代码内容 → 实测:AST 可解析为最低门槛;给了 test_cmd 则真跑测试。
427
+
428
+ test_cmd 只能由调用方显式提供(payload.test_cmd 或 MDCG_CODE_TEST_CMD),
429
+ 不配置时只做静态验证并如实说明,绝不假装"已实测"。
430
+ """
431
+ import ast
432
+ import shlex
433
+ import subprocess
434
+
435
+ src = str(payload.get("content") or "")
436
+ if not src.strip():
437
+ return _verdict(REJECT, "code", "空内容")
438
+ try:
439
+ tree = ast.parse(src)
440
+ except SyntaxError as exc:
441
+ return _verdict(REJECT, "code", f"语法错误 L{exc.lineno}: {exc.msg}")
442
+ n_def = sum(isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
443
+ for x in ast.walk(tree))
444
+ # P2-18(批次 30,外部审查报告):test_cmd 只允许服务端 env 配置——
445
+ # payload 是模型可控输入面,一旦接到工具参数顶层即为 RCE 注入点。
446
+ cmd = os.environ.get("MDCG_CODE_TEST_CMD")
447
+ if not cmd:
448
+ return _verdict(ACCEPT, "code",
449
+ f"AST 解析通过({n_def} 个定义);未配置 test_cmd,仅静态验证")
450
+ try:
451
+ argv = shlex.split(cmd)
452
+ except ValueError as exc:
453
+ return _verdict(DEFER, "code", f"test_cmd 解析失败:{exc}")
454
+ try:
455
+ # encoding 必须显式指定:`text=True` 会退回 locale 编码(Windows 常为 gbk),
456
+ # 被测命令只要输出非 gbk 字节,读取线程就抛 UnicodeDecodeError →
457
+ # p.stdout/p.stderr 可能为空 → 下一行的失败证据丢失,
458
+ # 「实测失败」会退化成一句没有依据的 REJECT(对齐 whitebox.py 的写法)。
459
+ p = subprocess.run(argv, cwd=ctx.get("cwd"), capture_output=True,
460
+ text=True, encoding="utf-8", errors="replace",
461
+ shell=False,
462
+ timeout=int(os.environ.get("MDCG_CODE_TEST_TIMEOUT", "60")))
463
+ except (OSError, subprocess.SubprocessError) as exc:
464
+ return _verdict(DEFER, "code", f"测试无法执行:{type(exc).__name__}: {exc}")
465
+ if p.returncode == 0:
466
+ return _verdict(ACCEPT, "code", f"实测通过:{cmd}")
467
+ tail = ((p.stdout or "")[-300:] + (p.stderr or "")[-300:]).strip()
468
+ return _verdict(REJECT, "code", f"实测失败 rc={p.returncode}:{tail}")
469
+
470
+
471
+ # 生效条件:调用 _pending 并传入 kind 与 why 后,其返回的内层闭包 _fn 对任意 payload/ctx 均求值为 _verdict(DEFER, kind, why),其中 kind 与 why 来自外层 _pending 的闭包,而非 _fn 的形参;。
472
+ def _pending(kind, why):
473
+ # 生效条件:调用 _pending 并传入 kind 与 why 后,其返回的内层闭包 _fn 对任意 payload/ctx 均求值为 _verdict(DEFER, kind, why),其中 kind 与 why 来自外层 _pending 的闭包,而非 _fn 的形参;。
474
+ def _fn(payload, ctx):
475
+ return _verdict(DEFER, kind, why)
476
+ return _fn
477
+
478
+
479
+ # 生效条件:payload['node_id'] 去空后为空即 DEFER;否则取 payload['unit_verdict'](为假值回落 payload['verdict'])去空转大写,其属于 STATES 时——verifier 为空即 DEFER、ctx['compiled_by'](为假值回落 payload['compiled_by'])非空且等于 verifier 即 REJECT E041、verdict==ACCEPT 而 evidence 为空即 DEFER、其余按该 verdict 裁定;其不属于 STATES 时经 units.probe 探测(探测抛异常亦恒 DEFER)后恒 DEFER 并给出通道与下一步提示。
480
+ def _verify_ccg_marks(payload, ctx):
481
+ """CCG 六要素候选的验证闸门:**只认认知图外的复核裁决**(裁定 A)。
482
+
483
+ 本闸门**不在写入路径里阻塞跑 LLM**——写入是同步闸门,等待外部单元会卡住写路径。
484
+ 正确的异步三段式:compile(ccgc)→ 外部单元复核(蜂巢 reflect/verify,或
485
+ harness 端子代理)→ 带 `unit_verdict` 回到本闸门 → ACCEPT 才谈得上写入
486
+ (`ccgc.link` 仍**独立**校验签章,本闸门不是唯一防线)。
487
+
488
+ payload 键:
489
+ node_id 目标节点(必填)
490
+ unit_verdict 外部单元裁决 ACCEPT/REJECT/DEFER/BLINDSPOT
491
+ (缺 → DEFER + 给出「下一步怎么拿到裁决」的通道提示)
492
+ verifier 验证方标识(须 != compiled_by,E041)
493
+ evidence 裁决依据(ACCEPT 而无依据 → DEFER:无依据不通过)
494
+ model/allow_degrade/channel 可选,仅用于探测降级通道
495
+ ctx 键:
496
+ compiled_by 编译执行者(E041 比对)
497
+ """
498
+ node_id = str(payload.get("node_id") or "").strip()
499
+ if not node_id:
500
+ return _verdict(DEFER, "ccg_marks", "缺少 node_id:六要素候选须指明目标节点")
501
+ verdict = str(payload.get("unit_verdict") or payload.get("verdict") or "").strip().upper()
502
+ verifier = str(payload.get("verifier") or "").strip()
503
+ evidence = str(payload.get("evidence") or "").strip()
504
+ compiled_by = str(ctx.get("compiled_by") or payload.get("compiled_by") or "").strip()
505
+
506
+ if verdict in STATES:
507
+ if not verifier:
508
+ return _verdict(DEFER, "ccg_marks",
509
+ "有裁决但缺验证方标识(verifier)——无法证明是编外复核")
510
+ if compiled_by and verifier == compiled_by:
511
+ return _verdict(REJECT, "ccg_marks",
512
+ "E041 自证拒绝:验证方标识 == 编译执行者(LLM 不得自己验证自己)")
513
+ if verdict == ACCEPT and not evidence:
514
+ return _verdict(DEFER, "ccg_marks",
515
+ "ACCEPT 但未给出裁决依据(evidence 为空)——无依据不通过")
516
+ return _verdict(verdict, "ccg_marks", evidence or "(未给依据)",
517
+ detail={"node_id": node_id, "verifier": verifier,
518
+ "job_id": payload.get("job_id") or ""})
519
+
520
+ # 无裁决 → 探测复核通道,按三态给出「下一步怎么拿到裁决」(不阻塞、不假装通过)
521
+ try:
522
+ from . import units
523
+ p = units.probe(model=payload.get("model") or "",
524
+ allow_degrade=bool(payload.get("allow_degrade")),
525
+ channel=payload.get("channel") or "")
526
+ except Exception as exc: # noqa: BLE001 —— 探测失败亦不假装
527
+ return _verdict(DEFER, "ccg_marks",
528
+ "未获复核裁决,且复核通道探测失败:%s: %s"
529
+ % (type(exc).__name__, exc))
530
+ if p["state"] == units.HIVE:
531
+ nxt = ("复核通道=蜂巢(model=%s):cg(op=ccg, action=review, node_id=%s, "
532
+ "blocking=true) 取得裁决后带 unit_verdict/verifier 重入本闸门"
533
+ % (p["model"], node_id))
534
+ elif p["state"] == units.SUBAGENT:
535
+ nxt = ("已降级 harness 端子代理(channel=%s):把 units.review 返回的 prompt 交给"
536
+ "子代理执行,取回 JSON 裁决后带 unit_verdict/verifier 重入本闸门"
537
+ % p["channel"])
538
+ else:
539
+ nxt = p["hint"]
540
+ return _verdict(DEFER, "ccg_marks",
541
+ "六要素候选尚未经编外复核(未获 unit_verdict)——缺复核恒不通过",
542
+ detail={"channel_state": p["state"], "next": nxt,
543
+ "jobs_dir": p["jobs_dir"], "model": p["model"],
544
+ "node_id": node_id})
545
+
546
+
547
+ # ---------- 分派入口 ----------
548
+
549
+ # 生效条件:content_kind 去空后不在 CONTENT_KINDS 即 BLINDSPOT;否则取 VERIFIERS 中该 kind 的验证器,缺失即 DEFER,调用抛异常即 DEFER,成功则以返回值 state(不在 STATES 时降级为 DEFER)连同 evidence/detail 构造裁定。
550
+ def audit(content_kind, payload=None, ctx=None):
551
+ """按内容类型分派验证器。未知类型 → BLINDSPOT;缺验证器 → DEFER。"""
552
+ kind = (content_kind or "").strip()
553
+ payload, ctx = payload or {}, ctx or {}
554
+ if kind not in CONTENT_KINDS:
555
+ return _verdict(BLINDSPOT, kind, f"未知内容类型:{kind!r}(可选 {sorted(CONTENT_KINDS)})")
556
+ fn = VERIFIERS.get(kind)
557
+ if fn is None:
558
+ return _verdict(DEFER, kind, f"未注入 {kind} 验证器")
559
+ try:
560
+ v = fn(payload, ctx) or {}
561
+ except Exception as exc: # noqa: BLE001 —— 验证器异常不视为通过
562
+ return _verdict(DEFER, kind, f"验证器异常:{type(exc).__name__}: {exc}")
563
+ state = v.get("state") if v.get("state") in STATES else DEFER
564
+ return _verdict(state, kind, str(v.get("evidence") or ""), v.get("detail"))
565
+
566
+
567
+ # 生效条件:对模块级常量 CONTENT_KINDS 中的每个 k 返回 action=CONTENT_KINDS[k]、basis=KIND_BASIS[k]、verifier 为 'builtin'(k 在 VERIFIERS 中)否则 'missing'。
568
+ def kinds():
569
+ """内容类型清单 + 验证器可用性(供 service_info / health 自描述)。"""
570
+ return {k: {"action": CONTENT_KINDS[k], "basis": KIND_BASIS[k],
571
+ "verifier": "builtin" if k in VERIFIERS else "missing"}
572
+ for k in CONTENT_KINDS}
573
+
574
+
575
+ # ---------- 外部能力接入(**能力外置,认知图只留接口**) ----------
576
+ #
577
+ # 架构约束(见模块 docstring):识图/实测/验收等**能力不在认知图内**。
578
+ # 认知图只做三件事:按内容类型选验证器 → 调用 → 记账。
579
+ # 因此本模块**不内置任何具体能力实现**(不读图像、不跑测试、不连网络),
580
+ # 只提供注入点:外部模块在被 import 时调用 register_verifier(...),
581
+ # 或提供 register(audit_module) 函数由本函数回调。
582
+ #
583
+ # 能力模块通常位于**私有运行时仓**(如灵枢身体 AEIS),经 MDCG_VERIFIER_MODULES
584
+ # 以 import 路径声明;公开的大脑仓不含这些能力。
585
+ VERIFIER_MODULES_ENV = "MDCG_VERIFIER_MODULES"
586
+
587
+
588
+ # 生效条件:modules 为 None 时按 os.environ 的 VERIFIER_MODULES_ENV(为假值取空串)取逗号分隔模块名,去空后列表为空即返回空报告;否则逐个 import_module 并调用其可调用的 register(本模块),单个导入或调用失败记入 failed 且 strict 为真值时立即抛出、为假值时继续,成功者记入 loaded,最后以 CONTENT_KINDS 生成 verifiers 可用性映射。
589
+ def load_external_verifiers(modules=None, strict=False):
590
+ """按 `MDCG_VERIFIER_MODULES`(逗号分隔 import 路径)加载外部验证器模块。
591
+
592
+ 单个模块失败不影响其余(除非 strict=True);失败原因如实返回,不静默。
593
+ 返回 {"loaded": [...], "failed": [{"module","error"}], "verifiers": {...}}。
594
+ """
595
+ import importlib
596
+ import sys as _sys
597
+ spec = modules
598
+ if spec is None:
599
+ spec = os.environ.get(VERIFIER_MODULES_ENV) or ""
600
+ names = [x.strip() for x in str(spec).split(",") if x.strip()]
601
+ # P2-19(批次 30):模块名格式白名单——拒绝空串外的异常形态(路径分隔/
602
+ # 通配/扩展名等非 import 路径输入),加载动作本身写 stderr(可见性)。
603
+ import re as _re
604
+ _bad = [n for n in names if not _re.match(r"^[A-Za-z_][A-Za-z0-9_.]*$", n)]
605
+ rep = {"loaded": [], "failed": [], "verifiers": {}}
606
+ for n in _bad:
607
+ names.remove(n)
608
+ rep["failed"].append({"module": n,
609
+ "error": "模块名不符合 import 路径格式(P2-19 白名单)"})
610
+ if not names:
611
+ return rep
612
+ this = _sys.modules[__name__]
613
+ for name in names:
614
+ try:
615
+ mod = importlib.import_module(name)
616
+ except Exception as exc: # noqa: BLE001
617
+ rep["failed"].append({"module": name,
618
+ "error": "%s: %s" % (type(exc).__name__, exc)})
619
+ if strict:
620
+ raise
621
+ continue
622
+ _sys.stderr.write("[mdcg-audit] 已加载外部验证器模块: %s\n" % name)
623
+ fn = getattr(mod, "register", None)
624
+ if callable(fn):
625
+ try:
626
+ fn(this)
627
+ except Exception as exc: # noqa: BLE001
628
+ rep["failed"].append({"module": name,
629
+ "error": "register(): %s: %s"
630
+ % (type(exc).__name__, exc)})
631
+ if strict:
632
+ raise
633
+ continue
634
+ rep["loaded"].append(name)
635
+ rep["verifiers"] = {k: ("builtin" if k in VERIFIERS else "missing")
636
+ for k in CONTENT_KINDS}
637
+ return rep
638
+
639
+
640
+ # ---------- 内建注册(缺外部能力的用 DEFER 占位) ----------
641
+
642
+ register_verifier("text", _verify_text)
643
+ register_verifier("permission", _verify_permission)
644
+ register_verifier("work_wip", _verify_work_wip)
645
+ register_verifier("code", _verify_code)
646
+ # image_desc / work_done 的**能力**由外部模块注入(见上)。未注入时 DEFER——
647
+ # 诚实说明缺什么,绝不假装通过。
648
+ register_verifier("image_desc", _pending("image_desc", "未注入识图验证器(需视觉能力)"))
649
+ register_verifier("work_done", _pending("work_done", "未注入验收器(需验收标准)"))
650
+ # ccg_marks:闸门本身不是「能力」而是**准入判据**——只认编外单元的裁决
651
+ # (unit_verdict + verifier);无裁决时探测复核通道(蜂巢→配置→子代理)并给出
652
+ # 下一步,恒不假装通过。真实复核由外部单元完成,认知图只负责记账。
380
653
  register_verifier("ccg_marks", _verify_ccg_marks)