@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
@@ -0,0 +1,188 @@
1
+ # -*- coding: utf-8 -*-
2
+ """负记忆里**凭据形态**的覆盖守卫(PR#44 复核补,2026-09-28)。
3
+
4
+ 运行:python -X utf8 -m md_cg.test_rejected_credential_forms
5
+
6
+ 为什么另立一套(与 `test_rejected_redact.py` 的分工):那一套守的是
7
+ 「命中禁表 → 原文不落盘」的**主路径**;本套守的是 PR#44 复核时实测出来的
8
+ 三个**未覆盖形态**——主路径绿了、凭据照样能落盘:
9
+
10
+ F1 全形态令牌 `mdcg1.<role>.<id>.<secret>`:只有 id 段命中禁表,负记忆脱敏
11
+ 按命中片段替换 → 掩掉 id 却把 **role 与密钥段**原样写进 rejected。
12
+ 令牌的可用性靠 role+id+secret 三段凑齐(`tokens.parse_token`),而 id 早已
13
+ 可公开查得(仓库/npm 曾发出),故密钥段落盘等于凭据可被拼回。
14
+ F2 脱敏必须是**跨度合并**而非逐条串行 `re.sub`:串行时窄规则先把 id 换成
15
+ 占位符,宽规则再也匹配不上那段文字,密钥段漏掩(实测反例即 F1 的成因)。
16
+ F3 `tags`:正文命中禁表而 tags 夹带凭据时,tags 原先原样进负记忆。
17
+ F4 防误伤:泛形写法 `mdcg1.<role>.<id>.<secret>`(文档用)不得被拒。
18
+ F5 跨闸口径:写入闸门禁表与发布门禁 `check_publish_artifact.R1_CONTENT_RULES`
19
+ 对同一合成令牌必须同判——两闸分叉正是本缺陷的根因。
20
+ F6 判别力自证:无凭据的正文经脱敏后与原文逐字相同。
21
+
22
+ 实验纪律:临时目录 + 临时 policy(其 forbidden 拷贝**真源** data/policy.json,
23
+ 故本套同时钉住真源不丢这两条令牌口径);哑值 `tk_0123456789abcdef` 与
24
+ `SYNTHFRAG...` 均为合成串,**非真凭据**。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import io
29
+ import json
30
+ import os
31
+ import re
32
+ import shutil
33
+ import sys
34
+ import tempfile
35
+ import traceback
36
+
37
+ from . import audit, writepipe
38
+ from .mdcos import MdCGSecure
39
+ from .security import Principal
40
+
41
+ ID = "tk_0123456789abcdef" # 哑令牌 id(与禁表同形)
42
+ FRAG = "SYNTHFRAG_do_not_use_0123456789" # 合成密钥段,非真凭据
43
+ # 变量名刻意不叫 FRAG_*:发布门禁 APIKEY_LITERAL 规则会命中
44
+ # `secret = "<16+ 字符>"` 这种**形似密钥赋值**的行(本条即被它抓到过一次)。
45
+ FULL = "mdcg1.designer." + ID + "." + FRAG # 全形态令牌(合成)
46
+ GENERIC = "mdcg1.<role>.<id>.<secret>" # 泛形写法(文档用)
47
+
48
+ _ok = 0
49
+ _bad = []
50
+
51
+
52
+ def ok(name, cond, detail=""):
53
+ global _ok
54
+ if cond:
55
+ _ok += 1
56
+ print(" ok %s" % name)
57
+ else:
58
+ _bad.append("%s %s" % (name, detail))
59
+ print(" FAIL %s %s" % (name, detail))
60
+
61
+
62
+ def _repo_root() -> str:
63
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
64
+
65
+
66
+ def _policy() -> dict:
67
+ with io.open(os.path.join(_repo_root(), "data", "policy.json"), encoding="utf-8") as fh:
68
+ return json.load(fh)
69
+
70
+
71
+ def _files_with(root, needle):
72
+ hits = []
73
+ for dp, _dn, fs in os.walk(root):
74
+ for f in fs:
75
+ fp = os.path.join(dp, f)
76
+ try:
77
+ with io.open(fp, encoding="utf-8", errors="ignore") as fh:
78
+ if needle in fh.read():
79
+ hits.append(os.path.relpath(fp, root))
80
+ except OSError:
81
+ continue
82
+ return hits
83
+
84
+
85
+ def _mk_cg(tmp, name):
86
+ p = Principal(tenant="default", actor="t_forms", role="designer",
87
+ can_write=True, can_admin=True)
88
+ return MdCGSecure(os.path.join(tmp, name), principal=p)
89
+
90
+
91
+ def _masked_regions(text):
92
+ """把占位符折叠成统一记号——用于比较「哪些字被掩」,与规则序号无关。"""
93
+ return re.sub(r"\[已过滤:禁表#\d+\]", "<MASK>", text)
94
+
95
+
96
+ def main() -> int:
97
+ tmp = tempfile.mkdtemp(prefix="cred_forms_")
98
+ old_policy = os.environ.get("MDCG_POLICY_FILE")
99
+ try:
100
+ _run(tmp)
101
+ except Exception:
102
+ traceback.print_exc()
103
+ _bad.append("未捕获异常")
104
+ finally:
105
+ shutil.rmtree(tmp, ignore_errors=True)
106
+ if old_policy is None:
107
+ os.environ.pop("MDCG_POLICY_FILE", None)
108
+ else:
109
+ os.environ["MDCG_POLICY_FILE"] = old_policy
110
+ print("\n凭据形态覆盖守卫:%d 通过%s" % (
111
+ _ok, (",%d 失败:%s" % (len(_bad), "; ".join(_bad))) if _bad else ""))
112
+ return 1 if _bad else 0
113
+
114
+
115
+ def _run(tmp):
116
+ src = _policy()
117
+ policy = os.path.join(tmp, "policy.json")
118
+ # 只搬 forbidden:保持与真源同口径(required 用不着,本套不走缺要素路径)
119
+ with io.open(policy, "w", encoding="utf-8") as f:
120
+ json.dump({"forbidden": src["forbidden"], "required": ["PASSED"]}, f)
121
+ os.environ["MDCG_POLICY_FILE"] = policy
122
+ rules = {"forbidden": src["forbidden"]}
123
+ pipe = writepipe.install_default_gates(writepipe.WritePipeline())
124
+
125
+ print("== F5 跨闸口径:写入闸门与发布门禁对同一合成令牌同判 ==")
126
+ import importlib.util
127
+ _sp = os.path.join(_repo_root(), "scripts", "check_publish_artifact.py")
128
+ spec = importlib.util.spec_from_file_location("_cpa_forms", _sp)
129
+ cpa = importlib.util.module_from_spec(spec)
130
+ spec.loader.exec_module(cpa)
131
+ pub = [rx for _n, rx, *_ in [tuple(x) + (None,) if len(x) == 2 else x
132
+ for x in cpa.R1_CONTENT_RULES]
133
+ if hasattr(rx, "search") and rx.search(FULL)]
134
+ write_hit = [p for p in src["forbidden"] if re.search(p, FULL)]
135
+ ok(bool(pub) and bool(write_hit),
136
+ "F5 两闸同判命中(发布 %d 条 / 写入 %d 条)" % (len(pub), len(write_hit)), repr((pub, write_hit)))
137
+
138
+ print("== F1 全形态令牌经写入闸门 → 凭据不落盘 ==")
139
+ cg = _mk_cg(tmp, "root_f1")
140
+ out = pipe.execute(cg, {"content_kind": "text", "layer": "knowledge",
141
+ "content": "PASSED 用 %s 调发布接口" % FULL})
142
+ ok("F1 命中 → rejected", out.get("moved_to") == "rejected"
143
+ and out.get("committed") is False, repr(out.get("verdict", {}).get("state")))
144
+ ok("F1a 库内无密钥段明文", not _files_with(cg.root, FRAG),
145
+ repr(_files_with(cg.root, FRAG)))
146
+ ok("F1b 库内无 id 明文", not _files_with(cg.root, ID),
147
+ repr(_files_with(cg.root, ID)))
148
+ rid = out.get("id")
149
+ body = ""
150
+ if rid in cg.index["nodes"]:
151
+ _fm, body = cg._read(cg.index["nodes"][rid])
152
+ ok("F1c 负记忆整段被掩", "[已过滤:禁表#" in (body or "") and FRAG not in (body or ""),
153
+ repr((body or "")[:120]))
154
+
155
+ print("== F2 跨度合并:与规则顺序无关、密钥段零残留 ==")
156
+ one = audit.redact_forbidden("PASSED 用 %s 调接口" % FULL, rules)
157
+ ok("F2 密钥段与 id 均被掩", FRAG not in one and ID not in one, repr(one))
158
+ flip = {"forbidden": list(reversed(src["forbidden"]))}
159
+ two = audit.redact_forbidden("PASSED 用 %s 调接口" % FULL, flip)
160
+ ok("F2a 规则顺序颠倒 → 掩码区域相同", _masked_regions(one) == _masked_regions(two),
161
+ repr((_masked_regions(one), _masked_regions(two))))
162
+
163
+ print("== F3 tags 夹带凭据 → 负记忆里的 tags 同样被掩 ==")
164
+ cg3 = _mk_cg(tmp, "root_f3")
165
+ out3 = pipe.execute(cg3, {"content_kind": "text", "layer": "knowledge",
166
+ "content": "PASSED 正文含 %s" % ID, "tags": [FULL, "正常标签"]})
167
+ rid3 = out3.get("id")
168
+ fm3 = {}
169
+ if rid3 in cg3.index["nodes"]:
170
+ fm3, _b3 = cg3._read(cg3.index["nodes"][rid3])
171
+ ok("F3 负记忆 tags 无密钥段", FRAG not in json.dumps(fm3.get("tags"), ensure_ascii=False),
172
+ repr(fm3.get("tags")))
173
+ ok("F3a 库内任何文件无密钥段明文", not _files_with(cg3.root, FRAG),
174
+ repr(_files_with(cg3.root, FRAG)))
175
+
176
+ print("== F4 防误伤:泛形写法不被拒、正常正文不受影响 ==")
177
+ cg4 = _mk_cg(tmp, "root_f4")
178
+ out4 = pipe.execute(cg4, {"content_kind": "text", "layer": "knowledge",
179
+ "content": "PASSED 令牌形如 %s ,照此书写" % GENERIC})
180
+ ok("F4 泛形写法过闸(不被拒)", out4.get("moved_to") != "rejected",
181
+ repr(out4.get("moved_to")))
182
+ text = "PASSED 一条与凭据无关的正文,含 mdcg1 一词与 tk_zzzz 哑值"
183
+ ok("F6 无凭据正文脱敏后逐字不变", audit.redact_forbidden(text, rules) == text,
184
+ repr(audit.redact_forbidden(text, rules)))
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())
@@ -0,0 +1,146 @@
1
+ # -*- coding: utf-8 -*-
2
+ """负记忆脱敏守卫(issue #43):命中禁表被 REJECT 的写入,凭据原文不得随 rejected 落盘。
3
+
4
+ 背景:writepipe._gate_audit 的 REJECT 分支把正文前 200 字交给 cg.add_rejected,
5
+ 命中 forbidden(凭据形态)的内容虽被拦下、committed=false,原文却写进
6
+ `rejected/rej_*.md`,按返回的 id 可原样读回——拦截之后又把凭据存了一遍。
7
+
8
+ 断言:
9
+ R1 命中禁表 → moved_to=rejected;库目录下**任何文件**都不含哑值明文;
10
+ rejected 节点正文带占位符「[已过滤:禁表#」;evidence 仍给出命中模式(可审计)
11
+ R2 凭据跨 200 字截断边界 → 残片(哑值前 8 字符)同样不落盘(先脱敏后截断)
12
+ R3 缺必需要素的 REJECT(可修正,无禁表命中)→ 负记忆保留原文(行为不变)
13
+ R4 redact_forbidden 单元:多处命中全替换、非法正则跳过不抛错、无规则原样返回
14
+
15
+ 红基线:在改动前的 main 上跑本文件,R1a/R1b/R2 为红(其余两态皆绿)。
16
+ 实验纪律:全程临时目录 + 临时 policy(MDCG_POLICY_FILE 只指向临时文件);
17
+ 哑值 `tk_0123456789abcdef` 仅匹配禁表形态,**非真凭据**。
18
+ 运行:python -X utf8 -m md_cg.test_rejected_redact(退出码 0 = 全绿)
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import shutil
24
+ import sys
25
+ import tempfile
26
+ import traceback
27
+
28
+ from . import audit, writepipe
29
+ from .mdcos import MdCGSecure
30
+ from .security import Principal
31
+
32
+ FAKE = "tk_0123456789abcdef" # 哑值,非真凭据
33
+ TK = r"\btk_[0-9a-f]{8,}\b" # 与 data/policy.json 批次71新增模式同形
34
+
35
+ _ok = 0
36
+ _bad = []
37
+
38
+
39
+ def _check(name, cond, detail=""):
40
+ global _ok
41
+ if cond:
42
+ _ok += 1
43
+ print(" ok %s" % name)
44
+ else:
45
+ _bad.append("%s %s" % (name, detail))
46
+ print(" FAIL %s %s" % (name, detail))
47
+
48
+
49
+ def _files_containing(root, needle):
50
+ hits = []
51
+ for dp, _dn, fs in os.walk(root):
52
+ for f in fs:
53
+ fp = os.path.join(dp, f)
54
+ try:
55
+ with open(fp, encoding="utf-8", errors="ignore") as fh:
56
+ if needle in fh.read():
57
+ hits.append(os.path.relpath(fp, root))
58
+ except OSError:
59
+ continue
60
+ return hits
61
+
62
+
63
+ def _mk_cg(tmp, name):
64
+ p = Principal(tenant="default", actor="t_redact", role="designer",
65
+ can_write=True, can_admin=True)
66
+ return MdCGSecure(os.path.join(tmp, name), principal=p)
67
+
68
+
69
+ def _rejected_text(cg, rid):
70
+ fm, content = cg._read(cg.index["nodes"][rid])
71
+ return content or ""
72
+
73
+
74
+ def main():
75
+ tmp = tempfile.mkdtemp(prefix="rejected_redact_")
76
+ old_policy = os.environ.pop("MDCG_POLICY_FILE", None)
77
+ try:
78
+ _run(tmp)
79
+ except Exception:
80
+ traceback.print_exc()
81
+ _bad.append("未捕获异常")
82
+ finally:
83
+ shutil.rmtree(tmp, ignore_errors=True)
84
+ if old_policy is not None:
85
+ os.environ["MDCG_POLICY_FILE"] = old_policy
86
+ else:
87
+ os.environ.pop("MDCG_POLICY_FILE", None)
88
+ print("\n负记忆脱敏守卫:%d 通过%s" % (
89
+ _ok, (",%d 失败:%s" % (len(_bad), "; ".join(_bad))) if _bad else ""))
90
+ return 1 if _bad else 0
91
+
92
+
93
+ def _run(tmp):
94
+ policy = os.path.join(tmp, "policy.json")
95
+ with open(policy, "w", encoding="utf-8") as f:
96
+ json.dump({"forbidden": [TK], "required": ["PASSED"]}, f)
97
+ os.environ["MDCG_POLICY_FILE"] = policy
98
+ pipe = writepipe.install_default_gates(writepipe.WritePipeline())
99
+
100
+ # ---------- R1 命中禁表:凭据不落盘 ----------
101
+ cg = _mk_cg(tmp, "root_r1")
102
+ out = pipe.execute(cg, {"content_kind": "text", "layer": "knowledge",
103
+ "content": "PASSED 用令牌 %s 调发布接口" % FAKE})
104
+ _check("R1 命中禁表 → rejected", out.get("moved_to") == "rejected"
105
+ and out.get("committed") is False, repr(out))
106
+ hits = _files_containing(cg.root, FAKE)
107
+ _check("R1a 库目录无哑值明文", not hits, repr(hits))
108
+ rid = out.get("id")
109
+ body = _rejected_text(cg, rid) if rid in cg.index["nodes"] else ""
110
+ _check("R1b rejected 正文带占位符", "[已过滤:禁表#1]" in body, repr(body))
111
+ ev = (out.get("verdict") or {}).get("evidence") or ""
112
+ _check("R1c evidence 仍给出命中模式", TK in ev and FAKE not in ev, repr(ev))
113
+
114
+ # ---------- R2 跨 200 字截断边界 ----------
115
+ cg2 = _mk_cg(tmp, "root_r2")
116
+ # 哑值起点在第 190 字:截前 200 字剩「tk_0123456」(仅 7 位 hex,已不匹配
117
+ # 禁表模式)——若先截断后脱敏,这段残片会漏过脱敏直接落盘。
118
+ content = "PASSED " + "x" * 182 + " " + FAKE
119
+ out = pipe.execute(cg2, {"content_kind": "text", "layer": "knowledge",
120
+ "content": content})
121
+ frag = FAKE[:8]
122
+ hits = _files_containing(cg2.root, frag)
123
+ _check("R2 截断边界残片不落盘", out.get("moved_to") == "rejected" and not hits,
124
+ repr((out.get("moved_to"), hits)))
125
+
126
+ # ---------- R3 缺必需要素:原文保留(行为不变) ----------
127
+ cg3 = _mk_cg(tmp, "root_r3")
128
+ out = pipe.execute(cg3, {"content_kind": "text", "layer": "knowledge",
129
+ "content": "缺要素的正文,补齐后可重写"})
130
+ rid = out.get("id")
131
+ body = _rejected_text(cg3, rid) if rid in cg3.index["nodes"] else ""
132
+ _check("R3 缺要素负记忆保留原文", out.get("moved_to") == "rejected"
133
+ and "缺要素的正文,补齐后可重写" in body, repr((out.get("moved_to"), body)))
134
+
135
+ # ---------- R4 redact_forbidden 单元 ----------
136
+ rules = {"forbidden": ["(unclosed", TK, "", r"\bsk-[A-Za-z0-9]{20,}"]}
137
+ s = "a %s b %s c sk-%s" % (FAKE, FAKE, "A" * 24)
138
+ r = audit.redact_forbidden(s, rules)
139
+ _check("R4a 多处命中全替换", FAKE not in r and "sk-AAAA" not in r
140
+ and r.count("[已过滤:禁表#2]") == 2 and "[已过滤:禁表#3]" in r, repr(r))
141
+ _check("R4b 无规则原样返回", audit.redact_forbidden(s, {}) == s)
142
+ _check("R4c 非法正则跳过不抛错", "[已过滤:禁表#1]" not in r)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -83,7 +83,7 @@ def main():
83
83
 
84
84
  root = tempfile.mkdtemp(prefix="retr_s1b_")
85
85
  cg = _build(root)
86
- b_sense = [e.get("bucket") for e in cg.index["nodes"].values()
86
+ b_sense = [e.get("bucket") for e in list(cg.index["nodes"].values())
87
87
  if e.get("path", "").endswith("s1.md")][0]
88
88
  check("写入侧确实落了桶", bool(b_sense) and b_sense != "orphan", str(b_sense))
89
89
 
@@ -142,7 +142,7 @@ def main():
142
142
  cgi.add("a1", "感知系统 记忆", "knowledge", tags=["domain:感知系统"])
143
143
  cgi.add("b1", "图数据库 邻接", "knowledge", tags=["domain:图数据库"])
144
144
  cgi.flush()
145
- bi = [e.get("bucket") for e in cgi.index["nodes"].values()
145
+ bi = [e.get("bucket") for e in list(cgi.index["nodes"].values())
146
146
  if e.get("path", "").endswith("a1.md")][0]
147
147
  # 对照组必须用**同一个 min_results**(否则比的是两次不同检索,不是回退等价)
148
148
  _setenv(MDCG_GATE_S1B_BUCKET=None)
@@ -9,7 +9,8 @@
9
9
  - 相邻抑制:与 rejected 节点有边相连(文本不重叠)的正候选同样被抑制
10
10
  - 无关候选不被抑制;λ 可配(0=不抑制,1=压到 0 且不低于 0)
11
11
  - 阈值真的在过滤:部分重叠候选在 thr=0.6 下不抑制、thr=0.4 下被抑制
12
- - 负记忆条目在 S5 开时不再与正候选同权(1.0 → 1-λ)
12
+ - 负覆盖条目不参与分数竞争:分数恒哨兵 0.0(负性走独立字段),S5 的 λ 只作用于
13
+ 正候选——H10① 2026-09-30(改前该条目被赋 1.0,高过任何真实候选)
13
14
  - 无负记忆命中时不落 s5 审计;幂等(重复检索 / 索引重建)
14
15
  """
15
16
  import os
@@ -61,6 +62,12 @@ def _scores(results):
61
62
  return {r[0]["id"]: round(float(r[1]), 6) for r in results}
62
63
 
63
64
 
65
+ def _neg_card(results):
66
+ """结果里的负覆盖条目卡(判据:独立字段 negative_coverage)。"""
67
+ return next((r[0] for r in results
68
+ if r[0].get("negative_coverage")), None)
69
+
70
+
64
71
  def _build(root):
65
72
  cg = MdCG(root)
66
73
  # 三个正候选都含查询词(都会成为 T2 命中),差别只在「与负记忆的关系」:
@@ -90,8 +97,13 @@ def main():
90
97
  base = _scores(r_off)
91
98
  check("默认关:meta 不含 gates 键", "gates" not in m_off,
92
99
  str(sorted(m_off.keys())))
93
- check("默认关:负记忆条目与正候选同权(1.0)",
94
- base.get("rejected/h_rej.md") == 1.0, str(base))
100
+ check("默认关:负覆盖条目分数=哨兵 0.0(不再冒充 1.0 候选,且不高过任何"
101
+ "正候选;负性走独立字段 negative_coverage)",
102
+ base.get("rejected/h_rej.md") == 0.0
103
+ and (_neg_card(r_off) or {}).get("negative_coverage") is True
104
+ and max(v for k, v in base.items()
105
+ if k != "rejected/h_rej.md") >= base["rejected/h_rej.md"],
106
+ str(base))
95
107
 
96
108
  # ---- 2) 子开关未设(总开关开、S1/S2 显式关)→ S5 不生效 ----
97
109
  _setenv(MDCG_RETRIEVAL_PIPELINE="1", MDCG_GATE_S5_NEG=None,
@@ -122,8 +134,9 @@ def main():
122
134
  sc5.get("pos_clean") == base.get("pos_clean")
123
135
  and "pos_clean" not in _sup,
124
136
  "%s vs %s sup=%s" % (sc5.get("pos_clean"), base.get("pos_clean"), sorted(_sup)))
125
- check("负记忆条目不再同权(1.0 → 0.5)",
126
- sc5.get("rejected/h_rej.md") == 0.5, str(sc5.get("rejected/h_rej.md")))
137
+ check("负覆盖条目不再参与分数竞争(S5 只降正候选;条目恒哨兵 0.0)",
138
+ sc5.get("rejected/h_rej.md") == 0.0,
139
+ str(sc5.get("rejected/h_rej.md")))
127
140
 
128
141
  # ---- 4) λ 可配:0=不抑制;1=压到 0 且 ≥0 ----
129
142
  _setenv(MDCG_NEG_LAMBDA="0")
@@ -159,8 +172,8 @@ def main():
159
172
  r4, m4 = cg.search(QUERY, k=10, judge=False, record=False)
160
173
  sc4 = _scores(r4)
161
174
  g4 = (m4.get("gates") or {}).get("s5") or {}
162
- check("λ=0.25:负记忆条目 0.75 且审计记 0.25",
163
- sc4.get("rejected/h_rej.md") == 0.75 and g4.get("lambda") == 0.25,
175
+ check("λ=0.25:审计记 0.25、负覆盖条目仍非分数(哨兵 0.0,不随 λ 变)",
176
+ sc4.get("rejected/h_rej.md") == 0.0 and g4.get("lambda") == 0.25,
164
177
  "%s %s" % (sc4.get("rejected/h_rej.md"), g4))
165
178
 
166
179
  # ---- 6) 无负记忆命中:不落 s5 审计 ----
@@ -0,0 +1,177 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · review_cli 归因守卫(P1 裁决面身份归因退化,DSH 端在役实测)
3
+
4
+ 缺陷(修复前):`review_cli._cg` 构造 `Principal(actor="designer-cli", ...)`
5
+ 不传 session → `security.Principal.__init__` 每次随机生成 `sess_<uuid12>`;
6
+ accept 落盘经 `MdCGSecure._attribution` 的 setdefault,writer 缺省成裁决者
7
+ designer-cli——于是 DSH 端实测:5 个裁决节点 5 个互不相同的随机会话 id、
8
+ writer 全被 designer-cli 覆盖,原始写入者(dsh-memory,提案记录 actor 有)
9
+ 不可追溯。
10
+
11
+ 守卫(修复前红):
12
+ ① review_cli accept --session <s> → 落盘节点 frontmatter.session == <s>
13
+ ② 落盘节点 frontmatter.writer == 提案记录的原始 actor(dsh-memory)
14
+ ③ 裁决者身份不丢失:frontmatter.reviewer == "designer-cli"
15
+ ④ 稳定性:同一 --session 两次裁决 → 两节点 session 一致
16
+ ⑤ 兜底:未传 --session 时环境变量 MDCG_SESSION 生效
17
+ ⑥ 仍无(两者皆缺省)→ stderr 告警一行 + 维持随机会话(现状兼容)
18
+
19
+ 运行:python -m md_cg.test_review_cli_attribution
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+
29
+ from . import nodefile
30
+ from .mdcos import MdCGOS, MdCGSecure
31
+ from .security import Principal
32
+
33
+ PASS = FAIL = 0
34
+ FAILS = []
35
+
36
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37
+
38
+ DUMMY_KEY = "ab" * 32 # 哑主密钥:子进程绝不触真实 ~/.mdcg/master.key
39
+
40
+
41
+ def check(name, cond, detail=""):
42
+ global PASS, FAIL
43
+ if cond:
44
+ PASS += 1
45
+ print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
46
+ else:
47
+ FAIL += 1
48
+ FAILS.append(name)
49
+ print(f" [FAIL] {name} · {detail}")
50
+
51
+
52
+ def _child_env(extra=None, remove=("MDCG_SESSION", "DSH_SESSION_ID",
53
+ "MDCG_REDTEAM_REQUIRED")):
54
+ """子进程环境:哑密钥 + UTF-8 + PYTHONPATH 指向本仓(纪律 15)。
55
+
56
+ remove 里的会话/红队环境变量先摘除——守卫 ①~④⑥ 必须证明 --session
57
+ 是唯一来源,不能被外层环境偷跑。
58
+ """
59
+ env = dict(os.environ)
60
+ for k in remove:
61
+ env.pop(k, None)
62
+ env["MDCG_MASTER_KEY"] = DUMMY_KEY
63
+ env["PYTHONUTF8"] = "1"
64
+ env["PYTHONIOENCODING"] = "utf-8"
65
+ env["PYTHONPATH"] = REPO + os.pathsep + env.get("PYTHONPATH", "")
66
+ env.update(extra or {})
67
+ return env
68
+
69
+
70
+ def _run_cli(root, argv, extra=None):
71
+ """跑一次 review_cli 子命令(包内入口 python -m md_cg.review_cli)。"""
72
+ return subprocess.run(
73
+ [sys.executable, "-m", "md_cg.review_cli"] + argv,
74
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
75
+ env=_child_env(extra), cwd=REPO)
76
+
77
+
78
+ def _propose(root, nid, content):
79
+ """以原始写入者身份(dsh-memory,DSH 端 agent 形态)入队一条提案。"""
80
+ cg = MdCGSecure(root, principal=Principal(
81
+ actor="dsh-memory", clearance="internal", can_write=True,
82
+ can_admin=False, role="recorder", session="sess_writer_orig"))
83
+ try:
84
+ return cg.propose(nid, content, layer="knowledge", tags=["attr"])
85
+ finally:
86
+ cg.close()
87
+
88
+
89
+ def _fm(root, nid):
90
+ """直读节点文件 frontmatter(不经密级读闸——守卫只看归因字段)。"""
91
+ probe = MdCGOS(root, actor="probe")
92
+ try:
93
+ e = (probe.index.get("nodes") or {}).get(nid)
94
+ if not e:
95
+ return None
96
+ with open(os.path.join(root, e["path"]), encoding="utf-8") as f:
97
+ fm, _c = nodefile.loads(f.read())
98
+ return fm
99
+ finally:
100
+ probe.close()
101
+
102
+
103
+ def main():
104
+ roots = []
105
+ try:
106
+ print("\n【①~③】accept --session → 落盘归因(session/writer/reviewer)")
107
+ root_a = tempfile.mkdtemp(prefix="cliattrib_a_")
108
+ roots.append(root_a)
109
+ pid = _propose(root_a, "attr-node-1", "P1 归因守卫:原始写入者应可追溯。")
110
+ r = _run_cli(root_a, ["accept", pid, "--session", "sess_decide_01",
111
+ "--root", root_a, "--reason", "归因守卫"])
112
+ check("review_cli accept --session 退出码 0", r.returncode == 0,
113
+ (r.stdout or r.stderr or "")[:200])
114
+ fm = _fm(root_a, "attr-node-1")
115
+ check("frontmatter.session == 传入 --session",
116
+ bool(fm) and fm.get("session") == "sess_decide_01",
117
+ str(fm and fm.get("session")))
118
+ check("frontmatter.writer == 提案原始 actor(dsh-memory)",
119
+ bool(fm) and fm.get("writer") == "dsh-memory",
120
+ str(fm and fm.get("writer")))
121
+ check("裁决者身份保留于 frontmatter.reviewer == designer-cli",
122
+ bool(fm) and fm.get("reviewer") == "designer-cli",
123
+ str(fm and fm.get("reviewer")))
124
+
125
+ print("\n【④】同一 --session 两次裁决 → 落盘归属一致")
126
+ pid2 = _propose(root_a, "attr-node-2",
127
+ "P1 归因守卫第二提案:同批裁决同会话。")
128
+ r2 = _run_cli(root_a, ["accept", pid2, "--session", "sess_decide_01",
129
+ "--root", root_a, "--reason", "归因守卫"])
130
+ fm2 = _fm(root_a, "attr-node-2")
131
+ check("第二节点 session 与第一节点一致",
132
+ r2.returncode == 0 and bool(fm) and bool(fm2)
133
+ and fm2.get("session") == fm.get("session") == "sess_decide_01",
134
+ "%r vs %r" % (fm and fm.get("session"), fm2 and fm2.get("session")))
135
+
136
+ print("\n【⑤】MDCG_SESSION 环境变量兜底")
137
+ pid3 = _propose(root_a, "attr-node-3",
138
+ "P1 归因守卫第三提案:环境变量会话。")
139
+ r3 = _run_cli(root_a, ["accept", pid3, "--root", root_a,
140
+ "--reason", "归因守卫"],
141
+ extra={"MDCG_SESSION": "sess_env_03"})
142
+ fm3 = _fm(root_a, "attr-node-3")
143
+ check("无 --session 时 MDCG_SESSION 生效",
144
+ r3.returncode == 0 and bool(fm3)
145
+ and fm3.get("session") == "sess_env_03",
146
+ str(fm3 and fm3.get("session")))
147
+
148
+ print("\n【⑥】两者皆缺省 → stderr 告警一行 + 维持随机会话")
149
+ pid4 = _propose(root_a, "attr-node-4",
150
+ "P1 归因守卫第四提案:随机会话告警。")
151
+ r4 = _run_cli(root_a, ["accept", pid4, "--root", root_a,
152
+ "--reason", "归因守卫"])
153
+ fm4 = _fm(root_a, "attr-node-4")
154
+ check("退出码 0(告警不阻断裁决)", r4.returncode == 0,
155
+ (r4.stdout or r4.stderr or "")[:200])
156
+ warn = [ln for ln in (r4.stderr or "").splitlines() if ln.strip()]
157
+ check("stderr 恰一行随机会话告警",
158
+ len(warn) == 1 and "随机会话" in warn[0], str(warn))
159
+ check("缺省仍落随机会话 id(sess_ 前缀,现状兼容)",
160
+ bool(fm4) and str(fm4.get("session", "")).startswith("sess_"),
161
+ str(fm4 and fm4.get("session")))
162
+ check("无 --session 时原始 writer 归因同样在位",
163
+ bool(fm4) and fm4.get("writer") == "dsh-memory",
164
+ str(fm4 and fm4.get("writer")))
165
+ finally:
166
+ for r_ in roots:
167
+ shutil.rmtree(r_, ignore_errors=True)
168
+
169
+ print("\n" + "=" * 64)
170
+ print(f"PASS={PASS} FAIL={FAIL}")
171
+ if FAILS:
172
+ print("失败项:" + ";".join(FAILS))
173
+ return 0 if FAIL == 0 else 1
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())