@furongjun1999/dsh-memory 0.6.0 → 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 (170) hide show
  1. package/README.md +588 -572
  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/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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  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_v25.md +230 -0
  23. 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
  24. 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
  25. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  26. 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
  27. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. package/lib/bridge.js +24 -2
  35. package/lib/hooks.d.ts +31 -2
  36. package/lib/hooks.js +219 -16
  37. package/lib/init.js +16 -1
  38. package/lib/lib/prompt_safety.d.ts +52 -1
  39. package/lib/lib/prompt_safety.js +76 -1
  40. package/md_cg/audit.py +652 -379
  41. package/md_cg/bench6_arms.py +1 -1
  42. package/md_cg/bench_p0.py +1 -1
  43. package/md_cg/branches.py +2 -2
  44. package/md_cg/ccgc.py +1071 -1006
  45. package/md_cg/chain.py +1 -1
  46. package/md_cg/consistency.py +97 -9
  47. package/md_cg/consolidate.py +34 -9
  48. package/md_cg/crypto.py +59 -26
  49. package/md_cg/docindex.py +16 -1
  50. package/md_cg/evolution.py +17 -1
  51. package/md_cg/export.py +1 -1
  52. package/md_cg/forgetting.py +309 -13
  53. package/md_cg/fsutil.py +454 -8
  54. package/md_cg/identity.py +3 -3
  55. package/md_cg/insight.py +24 -3
  56. package/md_cg/linkref.py +1 -1
  57. package/md_cg/logref.py +327 -0
  58. package/md_cg/mcp_server.py +4152 -3818
  59. package/md_cg/mdcg.py +4208 -3547
  60. package/md_cg/mdcos.py +4491 -4159
  61. package/md_cg/mreview/pipeline.py +15 -1
  62. package/md_cg/nodefile.py +639 -575
  63. package/md_cg/protect.py +158 -11
  64. package/md_cg/protocol.py +41 -2
  65. package/md_cg/provenance.py +22 -4
  66. package/md_cg/reach.py +4 -4
  67. package/md_cg/readcache.py +76 -15
  68. package/md_cg/reconcile.py +1 -1
  69. package/md_cg/refindex.py +246 -37
  70. package/md_cg/refine.py +1 -1
  71. package/md_cg/routing.py +32 -5
  72. package/md_cg/run_tests.py +17 -0
  73. package/md_cg/scrub.py +30 -3
  74. package/md_cg/security.py +47 -1
  75. package/md_cg/self_state.py +5 -5
  76. package/md_cg/sources.py +23 -6
  77. package/md_cg/srcindex.py +352 -0
  78. package/md_cg/stg.py +47 -3
  79. package/md_cg/subgraph.py +2 -2
  80. package/md_cg/sustain.py +1299 -1168
  81. package/md_cg/tasks.py +469 -470
  82. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  83. package/md_cg/test_b1b2_write_face.py +417 -0
  84. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  85. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  86. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  87. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  88. package/md_cg/test_c3_transient_read_negative.py +793 -0
  89. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  90. package/md_cg/test_ccg_form_parity.py +188 -0
  91. package/md_cg/test_ccgc.py +28 -3
  92. package/md_cg/test_govern_directread.py +17 -4
  93. package/md_cg/test_h2_session_view_norm.py +233 -0
  94. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  95. package/md_cg/test_hive_ingest.py +285 -285
  96. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  97. package/md_cg/test_issue43_default_policy.py +865 -0
  98. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  99. package/md_cg/test_linkref.py +10 -3
  100. package/md_cg/test_lock.py +2 -2
  101. package/md_cg/test_logref.py +1109 -0
  102. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  103. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  104. package/md_cg/test_mr_m2.py +15 -3
  105. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  106. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  107. package/md_cg/test_n176_link_trust.py +221 -0
  108. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  109. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  110. package/md_cg/test_n195_writepath_reload.py +283 -0
  111. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  112. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  113. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  114. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  115. package/md_cg/test_n201_proposal_visibility.py +382 -0
  116. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  117. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  118. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  119. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  120. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  121. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  122. package/md_cg/test_n225_nonobject_load.py +1110 -0
  123. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  124. package/md_cg/test_neg_condition_hits.py +333 -0
  125. package/md_cg/test_neg_tail_honesty.py +663 -0
  126. package/md_cg/test_none_id_write_guard.py +4 -3
  127. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  128. package/md_cg/test_p1.py +5 -5
  129. package/md_cg/test_p11_consistency.py +161 -12
  130. package/md_cg/test_p26_refindex.py +2 -2
  131. package/md_cg/test_p27_docindex.py +777 -774
  132. package/md_cg/test_p28_refcheck.py +740 -13
  133. package/md_cg/test_p29_session_ingest_export.py +2 -2
  134. package/md_cg/test_p2_mcp.py +12 -1
  135. package/md_cg/test_p30_maintain.py +33 -2
  136. package/md_cg/test_p31_insight.py +1 -0
  137. package/md_cg/test_p9c_dedup_hints.py +276 -0
  138. package/md_cg/test_policy_required_ccg.py +426 -0
  139. package/md_cg/test_protocol.py +22 -0
  140. package/md_cg/test_rank_parity_score_mode.py +516 -0
  141. package/md_cg/test_read_face_input_gates.py +363 -0
  142. package/md_cg/test_read_face_semantics.py +489 -0
  143. package/md_cg/test_recall_face_guards.py +812 -0
  144. package/md_cg/test_rejected_credential_forms.py +188 -0
  145. package/md_cg/test_rejected_redact.py +146 -0
  146. package/md_cg/test_retr_s1b.py +2 -2
  147. package/md_cg/test_retr_s5.py +20 -7
  148. package/md_cg/test_review_conformance.py +21 -3
  149. package/md_cg/test_security_audit_v21.py +2 -2
  150. package/md_cg/test_server_version.py +67 -0
  151. package/md_cg/test_srcindex.py +171 -0
  152. package/md_cg/test_tenant_env_override_warn.py +63 -50
  153. package/md_cg/test_token_lowercase_form.py +315 -0
  154. package/md_cg/test_v21r1_package_version.py +310 -0
  155. package/md_cg/test_writepipe.py +10 -4
  156. package/md_cg/tokens.py +225 -95
  157. package/md_cg/tool_face.py +4 -4
  158. package/md_cg/trust.py +49 -5
  159. package/md_cg/units.py +204 -6
  160. package/md_cg/weights.py +4 -4
  161. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  162. package/md_cg/writelimit.py +42 -8
  163. package/md_cg/writepipe.py +651 -554
  164. package/package.json +3 -2
  165. package/skills/plugin.json +1 -1
  166. package/src/bridge.ts +25 -2
  167. package/src/hooks.ts +229 -16
  168. package/src/init.ts +15 -1
  169. package/src/lib/prompt_safety.ts +88 -1
  170. package/zcode/AGENTS.md +10 -10
@@ -0,0 +1,171 @@
1
+ # -*- coding: utf-8 -*-
2
+ """真源索引契约层守卫(P0):通用层覆盖任意文件 / 细化层仅可读文本 / 伴随文本挂通用层 / 回读逐字节。
3
+
4
+ 判据姿态(P0 两次踩坑的产物,勿简化回「猜粒度」):
5
+ · **不猜提取器粒度**:细化层单位数与 `docindex.extract` 条数**平权**(units == extract + 1),
6
+ H1 节合并、过短节并入等语义归提取器,本层只保证「它切几节我就几条」;
7
+ · **哈希单一实现**:行区间哈希委托 `codeindex.region_hash`(sha1 前 12 位);本模块自写第二份
8
+ 的行为已被 5b/5c 抓出并删除——两处算法会让漂移检测永远 hash_match=True;
9
+ · **伴随文本挂通用层**:媒体不进可读文本分支,挂错层等于废掉多模态语义通道(6b 抓出)。
10
+
11
+ 实验纪律:全程 `tempfile` 临时目录 + 合成件(含**假魔数**的合成二进制,绝不用真实媒体/私有数据);
12
+ 不写任何库面、不碰在役库、不出网。运行:退出码 0 = 全绿。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import os
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ from md_cg import codeindex # noqa: E402
25
+ from md_cg import docindex # noqa: E402
26
+ from md_cg import srcindex as S # noqa: E402
27
+
28
+ PASS = FAIL = 0
29
+ FAILS = []
30
+
31
+
32
+ def ck(cid: str, ok: bool, detail: str = "") -> None:
33
+ global PASS, FAIL
34
+ if ok:
35
+ PASS += 1
36
+ else:
37
+ FAIL += 1
38
+ FAILS.append("%s %s" % (cid, detail))
39
+ print(" %s %s%s" % ("OK " if ok else "FAIL", cid, "" if ok else " " + detail))
40
+
41
+
42
+ def w(path: str, data) -> str:
43
+ if isinstance(data, bytes):
44
+ with open(path, "wb") as f:
45
+ f.write(data)
46
+ else:
47
+ with open(path, "w", encoding="utf-8") as f:
48
+ f.write(data)
49
+ return path
50
+
51
+
52
+ DOC = "\n".join([
53
+ "# 合成文档标题", "", "这是一段引言。", "",
54
+ "## 小节一", "", "小节一的正文,足够长以通过提取器的最小长度判断。", "",
55
+ "## 小节二", "", "小节二的正文,同样足够长,并带一行说明。", "",
56
+ "## 小节三", "", "小节三的正文。",
57
+ ])
58
+ CCG = "\n".join([
59
+ "# 功能名:合成样例", "# 生效条件:仅用于 P0 契约层守卫;合成件、非真实数据",
60
+ "## 小节一", "正文一",
61
+ ])
62
+
63
+
64
+ def main() -> int:
65
+ tmp = tempfile.mkdtemp(prefix="p0_srcindex_")
66
+ try:
67
+ md = w(os.path.join(tmp, "说明.md"), DOC)
68
+ ccg = w(os.path.join(tmp, "带条件.md"), CCG)
69
+ png = w(os.path.join(tmp, "pic.png"), b"\x89PNG\r\n\x1a\n" + b"\x00\x01\x02" * 40)
70
+ w(os.path.join(tmp, "pic.md"), "# pic 简介\n\n这是伴随文本(sidecar)")
71
+ mp4 = w(os.path.join(tmp, "clip.mp4"), b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 64)
72
+ raw = w(os.path.join(tmp, "blob.dat"), bytes(range(256)) * 4)
73
+ weird = w(os.path.join(tmp, "t.txt"), b"abc\x00def") # NUL ⇒ 不可读
74
+ fake = w(os.path.join(tmp, "disguised.txt"), b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
75
+ empty = w(os.path.join(tmp, "empty.md"), "")
76
+
77
+ # ① 通用层:任意文件都有 ≥1 条 whole 单位(含空文件/不可读二进制)
78
+ for label, p in (("md", md), ("png", png), ("mp4", mp4), ("raw", raw),
79
+ ("nul", weird), ("empty", empty)):
80
+ us = S.units(p)
81
+ ck("1a[%s] 通用层恒在" % label,
82
+ bool(us) and us[0]["span"]["unit"] == "whole" and bool(us[0]["span_hash"]),
83
+ "units=%d" % len(us))
84
+
85
+ # ② 细化层:与 docindex.extract 平权(不猜粒度)
86
+ for label, p in (("md", md), ("ccg", ccg)):
87
+ text = io.open(p, encoding="utf-8").read()
88
+ want = len(docindex.extract(text, path=p)) + 1
89
+ got = len(S.units(p))
90
+ ck("2a[%s] 单位数 == extract+1" % label, got == want, "units=%d want=%d" % (got, want))
91
+ ck("2b[png] 无细化层", all(u["span"]["unit"] == "whole" for u in S.units(png)))
92
+ ck("2c[NUL 文本] 判不可读、不细化",
93
+ all(u["span"]["unit"] == "whole" for u in S.units(weird)))
94
+ ck("2d[空文件] 不细化且不抛", len(S.units(empty)) == 1)
95
+
96
+ # ③ 类型判据:魔数优先于扩展名;未知类型不拒
97
+ ck("3a[魔数优先] .txt 内容为 PNG ⇒ image/png",
98
+ S.sniff_type(fake) == "image/png", S.sniff_type(fake))
99
+ ck("3b[扩展名兜底] .md ⇒ text/markdown", S.sniff_type(md) == "text/markdown")
100
+ ck("3c[未知不拒] .dat ⇒ octet-stream",
101
+ S.sniff_type(raw) == "application/octet-stream", S.sniff_type(raw))
102
+
103
+ # ④ 哈希判别力:改 1 字节 ⇒ file_hash 变;旧单位回读 hash_match=False
104
+ u0 = S.units(md)[0]
105
+ h0 = u0["src"]["file_hash"]
106
+ w(md, DOC.replace("这是一段引言。", "这是一段引言!"))
107
+ u1 = S.units(md)[0]
108
+ ck("4a[1 字节改判] file_hash 变", h0 != u1["src"]["file_hash"])
109
+ ck("4b[陈旧检出] 旧单位回读 hash_match=False",
110
+ S.read_unit(u0).get("hash_match") is False)
111
+ ck("4c[新鲜一致] 新单位回读 hash_match=True",
112
+ S.read_unit(u1).get("hash_match") is True)
113
+
114
+ # ⑤ 行区间回读逐字节 + 与 codeindex.region_hash 单一实现
115
+ lu = [u for u in S.units(md) if u["span"]["unit"] == "line"][0]
116
+ got = S.read_unit(lu)
117
+ lines = io.open(md, encoding="utf-8").read().split("\n")
118
+ want_text = "\n".join(lines[lu["span"]["start"] - 1:lu["span"]["end"]])
119
+ ck("5a[区间逐字节]", got.get("text") == want_text, repr(got.get("text"))[:60])
120
+ ck("5b[区间哈希一致]", got.get("hash_match") is True)
121
+ ck("5c[哈希单一实现] 委托 codeindex.region_hash",
122
+ codeindex.region_hash(lines, lu["span"]["start"], lu["span"]["end"])
123
+ == S.region_hash("\n".join(lines), lu["span"]["start"], lu["span"]["end"])
124
+ == got["hash"])
125
+
126
+ # ⑥ 伴随文本挂**通用层**(媒体条目的语义通道)
127
+ comps = S.companions(png)
128
+ u_png = S.units(png)[0]
129
+ ck("6a[sidecar 发现]", any(c["kind"] == "sidecar" for c in comps),
130
+ str([c["name"] for c in comps]))
131
+ ck("6b[挂进通用层] media 条目 text_view 含伴随文本且带 companions",
132
+ "伴随文本" in u_png["text_view"] and bool(u_png.get("companions")))
133
+
134
+ # ⑦ 未实现 locator 必须显式不静默
135
+ bad = S.read_unit({"src": {"path": md}, "span": {"unit": "time", "t0": 0, "t1": 1}})
136
+ ck("7a[time 显式不支持]", bad.get("ok") is False and "time" in bad.get("error", ""),
137
+ str(bad)[:80])
138
+
139
+ # ⑧ CCG 条件面进入细化层(且与该文件的 CCG 行相关)
140
+ ccg_units = S.units(ccg)
141
+ ck("8a[条件面非空]", any(u.get("condition_space") for u in ccg_units),
142
+ str([bool(u.get("condition_space")) for u in ccg_units]))
143
+ ck("8b[CCG 行落在细化层]",
144
+ any("生效条件" in (u.get("text_view") or "") or
145
+ "生效条件" in str(u.get("condition_space"))
146
+ for u in ccg_units if u["span"]["unit"] == "line"),
147
+ str([u.get("text_view") for u in ccg_units if u["span"]["unit"] == "line"])[:90])
148
+
149
+ # ⑨ 能力自陈与实现一致(契约漂移守卫)
150
+ cap = S.capability()
151
+ ck("9a[units 自陈]", tuple(cap["units"]) == S.UNITS == ("whole", "line"))
152
+ ck("9b[未实现项自陈]", set(cap["not_implemented"]) == {"time", "region", "byte"})
153
+ ck("9c[零依赖/不写状态]", cap["deps"] == [] and cap["writes_state"] is False)
154
+
155
+ # ⑩ 不存在路径:fail-closed 抛,而不是静默造条目
156
+ try:
157
+ S.units(os.path.join(tmp, "不存在.bin"))
158
+ ck("10a[不存在 fail-closed]", False, "未抛")
159
+ except OSError:
160
+ ck("10a[不存在 fail-closed]", True)
161
+ finally:
162
+ shutil.rmtree(tmp, ignore_errors=True)
163
+
164
+ print("\n结果:PASS %d / FAIL %d" % (PASS, FAIL))
165
+ if FAILS:
166
+ print("失败项:" + ";".join(FAILS))
167
+ return 0 if FAIL == 0 else 1
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main())
@@ -1,15 +1,17 @@
1
1
  # -*- coding: utf-8 -*-
2
- """租户绑定 env 覆盖告警守卫(v8 N62 / v9 / 第15轮三次成立,止血验收)。
2
+ """租户绑定 env 覆盖守卫(v8 N62 止血告警 → 本轮 N62 第 5 轮 fail-closed)。
3
3
 
4
4
  攻击面:控制 MCP 进程 env 的部署面(宿主配置 / 启动器 / 同机多会话)——
5
5
  tenantA 签发的 designer(secret, can_admin) 令牌在 MDCG_TENANT=tenantB 下经
6
- mcp_server._build_principal → verify_token(tok, tenant=env) 静默以 tenantB
7
- 身份运行(tenant=tenant or rec.get("tenant") 形参优先),全程零告警。
6
+ mcp_server._build_principal → verify_token(tok, tenant=env) 以 tenantB 身份
7
+ 运行(tenant=tenant or rec.get("tenant") 形参优先)。
8
8
 
9
- 止血口径(本轮):仅加 stderr 告警,零闸变——冲突时 Principal 判定结果与
10
- 修复前完全一致(形参/env 值仍优先),但必须开口。对照 MDCG_CLEARANCE 先例
11
- (mcp_server._build_principal 密级冲突告警)。clearance_cap 夹紧接线与租户
12
- 绑定强校验不在本项范围(另行 deferred)。
9
+ 契约演进(N62 五轮留档):
10
+ v8/v9/第15轮:仅 stderr 告警零闸变(止血口径——先开口,强校验 deferred);
11
+ 本轮(2026-09-27 第 5 次,medium 维持):告警改 fail-closed 拒绝
12
+ (tokens.verify_token 抛 TokenError,消息含两侧租户与校正指引),并接线
13
+ 登记租户 clearance_cap 夹紧(MDCG_TENANT_REGISTRY,与 mcp_server.
14
+ _resolve_root 同源;未登记租户零闸变)。
13
15
 
14
16
  运行:python -m md_cg.test_tenant_env_override_warn
15
17
  """
@@ -26,7 +28,7 @@ from . import tokens
26
28
 
27
29
  PASS = FAIL = 0
28
30
  FAILS = []
29
- MARK = "租户绑定被入参覆盖" # 告警稳定锚点(tokens.verify_token)
31
+ MARK = "租户绑定被入参覆盖" # fail-closed 拒绝消息稳定锚点
30
32
 
31
33
 
32
34
  def check(name, cond, detail=""):
@@ -41,82 +43,89 @@ def check(name, cond, detail=""):
41
43
 
42
44
 
43
45
  def _verify_capture(token, tenant, path):
44
- """verify_token 并捕获 stderr,返回 (principal, stderr_text)。"""
46
+ """verify_token 捕获异常与 stderr,返回 (principal|None, stderr_text, exc|None)。"""
45
47
  buf = io.StringIO()
46
- with contextlib.redirect_stderr(buf):
47
- p = tokens.verify_token(token, tenant=tenant, path=path)
48
- return p, buf.getvalue()
48
+ p = exc = None
49
+ try:
50
+ with contextlib.redirect_stderr(buf):
51
+ p = tokens.verify_token(token, tenant=tenant, path=path)
52
+ except tokens.TokenError as e:
53
+ exc = e
54
+ return p, buf.getvalue(), exc
49
55
 
50
56
 
51
57
  def main():
52
58
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
53
59
  root = tempfile.mkdtemp(prefix="mdcg_tenant_warn_")
54
60
  tf = os.path.join(root, "_tokens.json")
61
+ # 登记表钉在临时不存在路径:守卫全程不触真实 ~/.mdcg/_tenants.json,
62
+ # 且「未登记 → 零夹紧」确定性(冲突拒绝发生在登记表访问之前)。
63
+ reg_path = os.path.join(root, "_tenants_absent.json")
64
+ old_reg = os.environ.get("MDCG_TENANT_REGISTRY")
65
+ os.environ["MDCG_TENANT_REGISTRY"] = reg_path
55
66
  try:
56
- # ---------------- ① 攻击复现(单元级) ----------------
57
- print("\n[1] 攻击复现:tenantA 令牌被 tenantB 形参覆盖必须告警")
67
+ # ---------------- ① 攻击复现(单元级,fail-closed) ----------------
68
+ print("\n[1] 攻击复现:tenantA 令牌被 tenantB 形参覆盖 → 拒绝")
58
69
  d = tokens.issue("designer", actor="dummy-designer", tenant="tenantA",
59
70
  path=tf)
60
- p, err = _verify_capture(d["token"], "tenantB", tf)
61
- check("冲突覆盖发 stderr 告警", MARK in err,
62
- (err.strip().splitlines() or ["(零 stderr 输出——静默覆盖成立)"])[-1][:90])
63
- check("告警含两侧租户名", "tenantA" in err and "tenantB" in err,
64
- err.strip()[:100])
65
-
66
- # ---------------- ② 零闸变(止血不改判定) ----------------
67
- print("\n[2] 零闸变(判停结果与修复前一致,只加告警)")
68
- check("形参/env 租户仍优先生效", p.tenant == "tenantB",
69
- f"principal.tenant={p.tenant}")
70
- check("权限面不受影响(designer/can_admin 原样)",
71
- p.role == "designer" and p.can_admin is True,
72
- f"role={p.role} admin={p.can_admin}")
71
+ p, err, exc = _verify_capture(d["token"], "tenantB", tf)
72
+ check("冲突覆盖被拒(TokenError 且含锚点)",
73
+ p is None and exc is not None and MARK in str(exc),
74
+ f"p={p} exc={type(exc).__name__}: {str(exc)[:80]}")
75
+ check("拒绝消息含两侧租户与校正指引",
76
+ exc is not None and "tenantA" in str(exc) and "tenantB" in str(exc)
77
+ and "MDCG_TENANT" in str(exc), str(exc)[:100])
78
+
79
+ # ---------------- ② fail-closed(覆盖零放行) ----------------
80
+ print("\n[2] fail-closed(冲突路径零 Principal 放行)")
81
+ check("冲突路径不返回 Principal", p is None, f"p={p}")
73
82
 
74
83
  # ---------------- ③ 无冲突路径零误伤 ----------------
75
84
  print("\n[3] 无冲突路径零误伤")
76
- p0, err0 = _verify_capture(d["token"], None, tf)
77
- check("tenant=None:零告警且回退令牌租户",
78
- MARK not in err0 and p0.tenant == "tenantA",
79
- f"tenant={p0.tenant}")
80
- p1, err1 = _verify_capture(d["token"], "", tf)
81
- check("tenant=''(env 未设/空串):零告警且回退令牌租户",
82
- MARK not in err1 and p1.tenant == "tenantA",
83
- f"tenant={p1.tenant}")
84
- p2, err2 = _verify_capture(d["token"], "tenantA", tf)
85
- check("tenant 同值:零告警", MARK not in err2 and p2.tenant == "tenantA",
86
- f"tenant={p2.tenant}")
85
+ p0, err0, exc0 = _verify_capture(d["token"], None, tf)
86
+ check("tenant=None:零异常且回退令牌租户",
87
+ exc0 is None and MARK not in err0 and p0.tenant == "tenantA",
88
+ f"tenant={p0.tenant} err={exc0}")
89
+ p1, err1, exc1 = _verify_capture(d["token"], "", tf)
90
+ check("tenant=''(env 未设/空串):零异常且回退令牌租户",
91
+ exc1 is None and MARK not in err1 and p1.tenant == "tenantA",
92
+ f"tenant={p1.tenant} err={exc1}")
93
+ p2, err2, exc2 = _verify_capture(d["token"], "tenantA", tf)
94
+ check("tenant 同值:零异常零告警",
95
+ exc2 is None and MARK not in err2 and p2.tenant == "tenantA",
96
+ f"tenant={p2.tenant} err={exc2}")
87
97
 
88
98
  # ---------------- ④ MCP 进程级入口(_build_principal 直调先例 b26) ----------------
89
99
  print("\n[4] MCP 进程级:MDCG_TOKEN(tenantA) + MDCG_TENANT=tenantB")
90
100
  from .mcp_server import _build_principal
91
101
  saved = {k: os.environ.get(k) for k in
92
102
  ("MDCG_TOKEN", "MDCG_TENANT", "MDCG_TOKEN_FILE", "MDCG_ROOT",
93
- "MDCG_LEGACY_ENV_AUTH", "MDCG_CAN_ADMIN", "MDCG_CAN_WRITE",
103
+ "MDCG_TENANT_REGISTRY", "MDCG_LEGACY_ENV_AUTH",
104
+ "MDCG_LEGACY_ENV_ADMIN", "MDCG_CAN_ADMIN", "MDCG_CAN_WRITE",
94
105
  "MDCG_CLEARANCE", "MDCG_ACTOR")}
95
106
  try:
96
107
  os.environ["MDCG_TOKEN"] = d["token"]
97
108
  os.environ["MDCG_TOKEN_FILE"] = tf
98
109
  os.environ["MDCG_ROOT"] = os.path.join(root, "mcp")
110
+ os.environ["MDCG_TENANT_REGISTRY"] = reg_path
99
111
  os.environ["MDCG_TENANT"] = "tenantB"
100
- for k in ("MDCG_LEGACY_ENV_AUTH", "MDCG_CAN_ADMIN",
101
- "MDCG_CAN_WRITE", "MDCG_CLEARANCE", "MDCG_ACTOR"):
112
+ for k in ("MDCG_LEGACY_ENV_AUTH", "MDCG_LEGACY_ENV_ADMIN",
113
+ "MDCG_CAN_ADMIN", "MDCG_CAN_WRITE", "MDCG_CLEARANCE",
114
+ "MDCG_ACTOR"):
102
115
  os.environ.pop(k, None)
103
116
  buf = io.StringIO()
104
117
  with contextlib.redirect_stderr(buf):
105
118
  mp, merr = _build_principal()
106
119
  mout = buf.getvalue()
107
- check("MCP 入口构建成功(攻击原样可达)", merr is None and mp is not None,
108
- f"err={merr}")
109
- check("MCP 入口租户被覆盖为 tenantB(攻击面成立)",
110
- getattr(mp, "tenant", None) == "tenantB",
111
- f"tenant={getattr(mp, 'tenant', None)}")
112
- check("MCP 入口 stderr 含覆盖告警", MARK in mout,
113
- (mout.strip().splitlines() or ["(零 stderr)"])[-1][:90])
114
- # 无冲突对照:MDCG_TENANT 与令牌租户一致 → 零告警
120
+ check("MCP 入口覆盖被拒(err 含锚点,principal=None)",
121
+ mp is None and merr is not None and MARK in merr,
122
+ f"err={str(merr)[:90]}")
123
+ # 无冲突对照:MDCG_TENANT 与令牌租户一致 → 零异常正常通过
115
124
  os.environ["MDCG_TENANT"] = "tenantA"
116
125
  buf2 = io.StringIO()
117
126
  with contextlib.redirect_stderr(buf2):
118
127
  mp2, merr2 = _build_principal()
119
- check("MDCG_TENANT 同值:零告警",
128
+ check("MDCG_TENANT 同值:零异常零告警",
120
129
  merr2 is None and MARK not in buf2.getvalue()
121
130
  and getattr(mp2, "tenant", None) == "tenantA",
122
131
  buf2.getvalue().strip()[:90])
@@ -127,6 +136,10 @@ def main():
127
136
  else:
128
137
  os.environ[k] = v
129
138
  finally:
139
+ if old_reg is None:
140
+ os.environ.pop("MDCG_TENANT_REGISTRY", None)
141
+ else:
142
+ os.environ["MDCG_TENANT_REGISTRY"] = old_reg
130
143
  shutil.rmtree(root, ignore_errors=True)
131
144
 
132
145
  print(f"\n通过 {PASS} / 失败 {FAIL}")