@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
@@ -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}")
@@ -0,0 +1,315 @@
1
+ # -*- coding: utf-8 -*-
2
+ """令牌形态「只认小写」的守卫(批次79,2026-09-28 使用者裁定)。
3
+
4
+ 运行:python -X utf8 -m md_cg.test_token_lowercase_form # 正常跑
5
+ python -X utf8 -m md_cg.test_token_lowercase_form --head-baseline # 判别力自证
6
+
7
+ 裁定原文:「不认大写。统一只认小写。」落点=**受理面单点** `tokens.parse_token`
8
+ (`verify_token` 的唯一入口)。改动前该函数返回 `role.lower()`、且对 token_id 不做
9
+ 任何形态检查——即受理面比识别面**更宽**:`md_cg` 自己的检测表
10
+ (`data/policy.json` forbidden #9 `\\btk_[0-9a-f]{8,}\\b`、`src/hooks.ts`
11
+ SENSITIVE_PATTERNS 的同形规则)**有意**不含 `A-F`,而受理面却会接受一个含大写的
12
+ token_id——检测面漏过的形态照样能通过校验,两面的松紧就倒挂了。
13
+
14
+ 本套断言分七组:
15
+ G1 放行面(真令牌通过;secret 含大写仍通过——base64url 必含大写,不得误伤)
16
+ G2 token_id 含大写 → 拒(`TK_` 前缀、全大写 hex、混合大小写 id 三种)
17
+ G3 role 含大写 → 拒(改动前 role.lower() 会**静默接受**)
18
+ G4 前缀含大写 → 拒(既有判据,钉住它别被后续重构放松)
19
+ G5 端到端(真临时令牌库:合法令牌过闸;同 secret 的大写变形令牌抛 TokenError,
20
+ 而不是落到「令牌不存在」这类下游软失败上——形态问题必须在形态层拒掉)
21
+ G6 生成面自证(**推论前提**:`issue` 只产小写 role/token_id ⇒ 大写形态没有合法来源)
22
+ G7 口径面(识别面**仍然**宽:禁表两条原样存在、hooks.ts 的 role 段仍 `[A-Za-z]+`;
23
+ 受理面已窄。二者关系=「识别面宽、受理面窄」,不是「两面都收紧」)
24
+ G8 三面口径(2026-09-28 使用者补裁「大写也转小写识别,以后写统一小写,现有的大写
25
+ 内容就不动了」):入参面宽容(role_spec/as_unit 的大写照旧归一,收窄只减不增)、
26
+ 令牌面从严(同为大写 role 的令牌字符串仍被拒——两面**有意**不同)、
27
+ 存量不迁移(test_p46 C2 那条存量断言必须还在)、写面统一小写(issue 产出恒小写)
28
+
29
+ **红基线自证(--head-baseline)**:把 `parse_token` 临时换回改动前的实现
30
+ (`return role.lower(), token_id, secret`),G2/G3/G5/G8d 必须转红(实测 11 红)。若红
31
+ 基线不红,说明这几条断言没有判别力(删掉新分支也照样全绿)。
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import io
37
+ import json
38
+ import os
39
+ import re
40
+ import secrets
41
+ import shutil
42
+ import sys
43
+ import tempfile
44
+
45
+ from . import tokens
46
+ from .tokens import ROLE_SPECS, TokenError
47
+
48
+ _PASS = []
49
+ _FAIL = []
50
+
51
+
52
+ def ok(cond, msg, extra=""):
53
+ (_PASS if cond else _FAIL).append(msg)
54
+ print((" PASS " if cond else " FAIL ") + msg + ((" ← " + str(extra)) if (extra and not cond) else ""))
55
+
56
+
57
+ def _rejects(token: str, want: str = "") -> tuple:
58
+ """调 parse_token 判拒:返回 (是否被拒, 错误文本)。"""
59
+ try:
60
+ tokens.parse_token(token)
61
+ return False, ""
62
+ except TokenError as e:
63
+ t = str(e)
64
+ return (want in t), t
65
+ except Exception as e: # noqa: BLE001
66
+ return False, "%s: %s" % (type(e).__name__, e)
67
+
68
+
69
+ # ---------------------------------------------------------------- 旧实现(红基线用)
70
+ def _old_parse_token(token: str):
71
+ """改动前的实现原文——仅用于 --head-baseline 的判别力自证,不参与生产。"""
72
+ parts = (token or "").strip().split(".")
73
+ if len(parts) != 4 or parts[0] != tokens.PREFIX:
74
+ raise TokenError("令牌格式非法(应为 mdcg1.<role>.<token_id>.<secret>)")
75
+ _, role, token_id, secret = parts
76
+ if not role or not token_id or not secret:
77
+ raise TokenError("令牌字段缺失")
78
+ return role.lower(), token_id, secret
79
+
80
+
81
+ # 合成件:secret 刻意含大写(与 token_urlsafe 的真实产物同分布),用来把「只认小写」
82
+ # 的适用面钉在 role/token_id 两段上,而不是顺手把 secret 也收紧。值里带 `dummy`/`fake`
83
+ # 标记是**发版门禁的既有约定**(scripts/check_publish_artifact.py::FAKE_TOKEN_MARKERS):
84
+ # 测试里的合成凭据必须一眼可辨为假值,否则 R1 凭据面会把本文件判成真实泄露(本批次首跑
85
+ # 实测命中 APIKEY_LITERAL + TOKEN_MDCG 两条,即按该约定改正而成现在这样)。
86
+ DUMMY_SECRET_UC = "Fake-Dummy-aB3xY9zQ7wV1uT5sR2pN8mL4kJ6"
87
+ HEX_ID = "9f3a2b8c7d4e" # token_hex(6) 形态
88
+
89
+
90
+ def _tok(role: str, tid: str, secret: str = None) -> str:
91
+ """拼一条令牌:经 make_token 而非字面量——测试源码里不留「像真凭据」的整串。"""
92
+ return tokens.make_token(role, tid, secret or DUMMY_SECRET_UC)
93
+
94
+
95
+ def g1_allow():
96
+ print("\n[G1] 放行面:合法令牌与含大写 secret 的令牌都通过")
97
+ good = _tok("designer", "tk_" + HEX_ID)
98
+ role, token_id, secret = tokens.parse_token(good)
99
+ ok(role == "designer" and token_id == "tk_" + HEX_ID and secret == DUMMY_SECRET_UC,
100
+ "G1a 合法令牌原样通过(role/token_id/secret 都不改写)")
101
+ ok(secret == DUMMY_SECRET_UC and any(c.isupper() for c in secret),
102
+ "G1b secret 含大写仍然通过(base64url 必含大写,不得误伤)",
103
+ "样例 secret 大写字母数=%d" % sum(1 for c in secret if c.isupper()))
104
+ ok(tokens.parse_token(_tok("guest", "tk_" + "0123456789ab"))[0] == "guest",
105
+ "G1c 另一角色同样通过(避免只对 designer 生效的伪断言)")
106
+
107
+
108
+ def g2_id_uppercase():
109
+ print("\n[G2] token_id 含大写 → 拒(受理面比识别面宽的那个洞)")
110
+ for label, tid in (("TK_ 前缀大写", "TK_" + HEX_ID),
111
+ ("全大写 hex", "tk_" + HEX_ID.upper()),
112
+ ("混合大小写", "tk_9f3a2B8c7d4e")):
113
+ hit, err = _rejects(_tok("designer", tid), want="小写")
114
+ ok(hit, "G2 token_id %s → 拒且文案点明「小写」" % label, err)
115
+ ok(tokens.parse_token(_tok("designer", "tk_" + HEX_ID))[1] == "tk_" + HEX_ID,
116
+ "G2 正对照:同一形态换成小写即通过(拒的是大小写,不是 tk_ 前缀)")
117
+
118
+
119
+ def g3_role_uppercase():
120
+ print("\n[G3] role 含大写 → 拒(改动前 role.lower() 静默接受)")
121
+ for label, role in (("全大写", "DESIGNER"), ("首字母大写", "Designer"),
122
+ ("别名大写", "VERIFIER")):
123
+ hit, err = _rejects(_tok(role, "tk_" + HEX_ID), want="小写")
124
+ ok(hit, "G3 role %s → 拒且文案点明「小写」" % label, err)
125
+ ok(tokens.parse_token(_tok("designer", "tk_" + HEX_ID))[0] == "designer",
126
+ "G3 正对照:全小写 role 通过且不被改写")
127
+
128
+
129
+ def g4_prefix():
130
+ print("\n[G4] 前缀含大写 → 拒(既有判据,钉住别被后续重构放松)")
131
+ hit, err = _rejects(_tok("designer", "tk_" + HEX_ID).replace("mdcg1", "MDCG1", 1), want="格式非法")
132
+ ok(hit, "G4a 前缀 MDCG1 → 拒", err)
133
+ hit2, err2 = _rejects(_tok("designer", "tk_" + HEX_ID).replace("mdcg1", "Mdcg1", 1), want="格式非法")
134
+ ok(hit2, "G4b 前缀 Mdcg1 → 拒", err2)
135
+ ok(tokens.PREFIX == tokens.PREFIX.lower() and tokens.PREFIX == "mdcg1",
136
+ "G4c PREFIX 常量本身即小写(前缀大小写敏感的前提)", tokens.PREFIX)
137
+
138
+
139
+ def g5_end_to_end():
140
+ print("\n[G5] 端到端:真令牌库上,合法令牌过闸、大写变形令牌在形态层即拒")
141
+ root = tempfile.mkdtemp(prefix="mdcg_toklower_")
142
+ tf = os.path.join(root, "_tokens.json")
143
+ try:
144
+ rec = tokens.issue("designer", path=tf)
145
+ tok = rec["token"]
146
+ p = tokens.verify_token(tok, path=tf)
147
+ ok(p.role == "designer", "G5a 合法令牌 verify_token 通过(role=%s)" % p.role)
148
+ role, tid, sec = tokens.parse_token(tok)
149
+ up_id = tokens.make_token(role, tid.upper(), sec)
150
+ try:
151
+ tokens.verify_token(up_id, path=tf)
152
+ ok(False, "G5b 大写 token_id 的变形令牌必须被拒", "竟然通过了")
153
+ except TokenError as e:
154
+ ok("小写" in str(e), "G5b 大写 token_id 变形 → TokenError 且文案指向形态(非下游软失败)", str(e))
155
+ up_role = tokens.make_token(role.upper(), tid, sec)
156
+ try:
157
+ tokens.verify_token(up_role, path=tf)
158
+ ok(False, "G5c 大写 role 的变形令牌必须被拒", "竟然通过了")
159
+ except TokenError as e:
160
+ ok("小写" in str(e), "G5c 大写 role 变形 → TokenError 同上", str(e))
161
+ msg = _reject_text(up_id)
162
+ ok("小写" in msg and "不存在" not in msg and "不匹配" not in msg,
163
+ "G5d 拒因是形态层(不含「令牌不存在/密钥不匹配」这类下游软失败文案)", msg)
164
+ finally:
165
+ shutil.rmtree(root, ignore_errors=True)
166
+
167
+
168
+ def _reject_text(token: str) -> str:
169
+ """取原始错误文本(不做 must-contain 判据),供 G5d 反向核对。"""
170
+ try:
171
+ tokens.parse_token(token)
172
+ return "(未被拒)"
173
+ except TokenError as e:
174
+ return str(e)
175
+
176
+
177
+ def g6_issuer():
178
+ print("\n[G6] 生成面自证:大写形态没有合法来源(推论的**前提**)")
179
+ hexes = [secrets.token_hex(6) for _ in range(64)]
180
+ ok(all(re.fullmatch(r"[0-9a-f]{12}", h) for h in hexes),
181
+ "G6a secrets.token_hex(6) 只产小写 hex(64 次采样零违例)",
182
+ [h for h in hexes if not re.fullmatch(r"[0-9a-f]{12}", h)][:3])
183
+ ok(all(k == k.lower() for k in ROLE_SPECS),
184
+ "G6b ROLE_SPECS 全部键为小写(签发面 role 取值域即小写)", sorted(ROLE_SPECS))
185
+ # 签发面另有一层归一:issue 经 normalize_role 把小写化做在「我方自己发」这一侧。
186
+ # 这不算「认大写」——产出物恒为小写;受理面收的是外部来的串,没有这层信任。
187
+ # 结构断言(不写整串字面量:测试源码里不留「像真凭据」的串,见 DUMMY_SECRET_UC 处注释)
188
+ ok(tokens.make_token("designer", "tk_" + HEX_ID, DUMMY_SECRET_UC).split(".") ==
189
+ [tokens.PREFIX, "designer", "tk_" + HEX_ID, DUMMY_SECRET_UC],
190
+ "G6c make_token 是纯拼接(四段原样,不在拼接层做大小写变换)")
191
+ root = tempfile.mkdtemp(prefix="mdcg_toklower_iss_")
192
+ try:
193
+ tok = tokens.issue("DESIGNER", path=os.path.join(root, "_tokens.json"))["token"]
194
+ ok(tok.startswith("mdcg1.designer."),
195
+ "G6d 即便调用方传大写 role,issue 产出仍是小写(签发面归一生效处)", tok[:24])
196
+ finally:
197
+ shutil.rmtree(root, ignore_errors=True)
198
+
199
+
200
+ def _hooks_rules() -> list:
201
+ """从 src/hooks.ts 源码里取出现行的 `/…/g` 字面(JS 与 Python 在这些构造上同义)。"""
202
+ repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
203
+ src = io.open(os.path.join(repo, "src", "hooks.ts"), encoding="utf-8").read().splitlines()
204
+ out = []
205
+ for i, line in enumerate(src, 1):
206
+ m = re.search(r"re:\s*/(.+)/[a-z]*\s*,", line)
207
+ if m:
208
+ out.append((i, m.group(1), line))
209
+ return out
210
+
211
+
212
+ def g7_detection_face():
213
+ print("\n[G7] 口径面:识别面**仍然**宽、受理面已窄(不是两面都收紧)")
214
+ repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
215
+ pol = json.loads(io.open(os.path.join(repo, "data", "policy.json"), encoding="utf-8").read())
216
+ forb = pol["forbidden"]
217
+ vals = list(forb.values()) if isinstance(forb, dict) else list(forb)
218
+ ok(any(v.strip() == r"\btk_[0-9a-f]{8,}\b" for v in vals),
219
+ "G7a 禁表 id 规则原样保留(未顺手加 A-F / re.I——那会把「只认小写」变成误报源)")
220
+ ok(any("mdcg1" in v for v in vals),
221
+ "G7b 禁表全形态令牌规则原样保留")
222
+ ok(all("(?i)" not in v for v in vals if "tk_" in v),
223
+ "G7c 令牌相关禁用规则不带 (?i)(大小写敏感是刻意的)")
224
+ rules = _hooks_rules()
225
+ full = [(ln, p) for ln, p, _ in rules if "mdcg1" in p]
226
+ bare = [(ln, p) for ln, p, _ in rules if "tk_" in p and "mdcg1" not in p]
227
+ ok(len(full) == 1 and len(bare) == 1,
228
+ "G7d hooks.ts 恰好各有一条全形态/裸 id 令牌规则(行号=%s)"
229
+ % ([ln for ln, _ in full] + [ln for ln, _ in bare]))
230
+ if not full or not bare:
231
+ return
232
+ up_tok = _tok("DESIGNER", ("tk_" + HEX_ID).upper(), DUMMY_SECRET_UC.upper())
233
+ lo_tok = _tok("designer", "tk_" + HEX_ID)
234
+ ok(re.search(full[0][1], up_tok) is not None and re.search(full[0][1], lo_tok) is not None,
235
+ "G7e 插件全形态规则**行为上宽**:含大写的令牌也整条命中(拦得住)",
236
+ full[0][1])
237
+ ok(re.search(bare[0][1], "tk_" + HEX_ID) is not None,
238
+ "G7f 插件裸 id 规则匹配小写 hex id", bare[0][1])
239
+ ok(re.search(bare[0][1], "tk_" + HEX_ID.upper()) is None,
240
+ "G7g 插件裸 id 规则**有意**不匹配大写 hex id(识别面固有缺口=本批次修的另一半)",
241
+ bare[0][1])
242
+ # 缺口闭合:识别面漏掉的大写形态,受理面必须拒 ⇒ 两面组合后无缝隙。
243
+ # 反过来说,若受理面像改动前那样认大写,G7g 的形态就是一条真的凭据通道。
244
+ hit, _ = _rejects(_tok("designer", "tk_" + HEX_ID.upper()))
245
+ ok(hit, "G7h 识别面漏(大写 hex)⇔ 受理面拒:不存在「检测漏 + 受理收」的缝隙")
246
+
247
+
248
+ def g8_three_faces():
249
+ """三面口径(2026-09-28 使用者补裁):入参面宽容、令牌面从严、写面统一小写。
250
+
251
+ 裁定原文:「已有测试把大写归一当预期行为。大写也转小写识别,以后写统一小写,
252
+ 现有的大写内容就不动了。」故本组**同时**钉住三件事——G8a/G8b 是「不要顺手收紧入参面」
253
+ (收紧会打断存量语义与 `test_p46_unit_scope.py` 的 C2),G8c 是「令牌面照旧拒」
254
+ (批次79 的裁定不被本组软化),G8e 是「存量断言不许被删」。
255
+ """
256
+ print("\n[G8] 三面口径:入参面宽容 / 令牌面从严 / 写面统一小写")
257
+ spec = tokens.role_spec("RECORD") # 大写 role 名照常解析
258
+ ok(spec.get("clearance_cap") is not None or isinstance(spec, dict),
259
+ "G8a 入参面宽容:role_spec('RECORD') 照常解析(大写经 strip+lower+别名归一)")
260
+ ok(tokens.normalize_role("RECORD") == "record"
261
+ and tokens.normalize_role(" ReCorder ") == "record",
262
+ "G8b 入参面宽容:'RECORD' / ' ReCorder ' 均归一为 record(别名 + 空白一并归一)")
263
+ from .security import Principal as _P
264
+ owner = _P(role="designer", actor="t", can_write=True, can_admin=True, clearance="secret")
265
+ narrowed = tokens.narrowed_principal(owner, "RECORD")
266
+ ok(narrowed.unit == "record" and narrowed.can_admin is False and owner.can_admin is True,
267
+ "G8c 大写 as_unit 只减不增:unit=record、can_admin 恒 False(无提权面)")
268
+ hit, err = _rejects(_tok("RECORD", "tk_" + HEX_ID), want="小写")
269
+ ok(hit, "G8d 令牌面从严(对照):同为大写 role 的**令牌字符串**被拒——两面有意不同",
270
+ err)
271
+ src = io.open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
272
+ "md_cg", "test_p46_unit_scope.py"), encoding="utf-8").read()
273
+ ok("大小写归一(RECORD -> record)" in src,
274
+ "G8e 存量不迁移:test_p46_unit_scope.py 的 C2 存量断言仍在(口径的既有凭据未被改掉)")
275
+ tok = tokens.issue("designer", path=os.path.join(tempfile.mkdtemp(prefix="mdcg_cap_"),
276
+ "_tokens.json"))["token"]
277
+ parts = tok.split(".")
278
+ ok(parts[1] == parts[1].lower() and parts[2] == parts[2].lower(),
279
+ "G8f 写面统一小写:issue 新签发的 role/token_id 段恒小写(以后写一律小写)")
280
+
281
+
282
+ def _run_groups() -> int:
283
+ _PASS.clear(); _FAIL.clear()
284
+ for g in (g1_allow, g2_id_uppercase, g3_role_uppercase, g4_prefix, g5_end_to_end,
285
+ g6_issuer, g7_detection_face, g8_three_faces):
286
+ g()
287
+ return len(_FAIL)
288
+
289
+
290
+ def main() -> int:
291
+ if sys.stdout.encoding and sys.stdout.encoding.lower().startswith("cp"):
292
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
293
+ base = "--head-baseline" in sys.argv
294
+ if base:
295
+ print("!! 红基线模式:parse_token 临时替换回改动前实现(role.lower()、不查 token_id 形态)")
296
+ print(" 期望 G2/G3/G5/G8d 转红\n")
297
+ tokens.parse_token = _old_parse_token
298
+ globals()["tokens"] = tokens
299
+ groups = (g1_allow, g2_id_uppercase, g3_role_uppercase, g4_prefix, g5_end_to_end,
300
+ g6_issuer, g7_detection_face, g8_three_faces)
301
+ for g in groups:
302
+ g()
303
+ if base:
304
+ print("\n== 红基线判定:应有失败 ==")
305
+ print("红基线失败数 = %d(>0 才算断言有判别力)" % len(_FAIL))
306
+ for f in _FAIL:
307
+ print(" 红:", f)
308
+ return 0 if _FAIL else 1
309
+ print("\n令牌形态(只认小写)守卫:%d 通过,%d 失败" % (len(_PASS), len(_FAIL)))
310
+ return 0 if not _FAIL else 1
311
+
312
+
313
+ if __name__ == "__main__":
314
+ with contextlib.redirect_stdout(sys.stdout):
315
+ sys.exit(main())