@furongjun1999/dsh-memory 0.5.0 → 0.5.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 (138) hide show
  1. package/README.md +552 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  4. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  5. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  6. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  7. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  8. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
  9. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
  10. 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_v1.md +156 -0
  11. 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_v10.md +210 -0
  12. 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_v11.md +227 -0
  13. 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_v12.md +203 -0
  14. 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_v13.md +233 -0
  15. 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_v14.md +191 -0
  16. 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_v15.md +213 -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_v16.md +214 -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_v17.md +199 -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_v2.md +156 -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_v3.md +152 -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_v4.md +128 -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_v5.md +114 -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_v6.md +192 -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_v7.md +187 -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_v8.md +207 -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_v9.md +203 -0
  27. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  28. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
  29. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  30. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  31. 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
  32. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  33. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  34. package/lib/bridge.d.ts +9 -0
  35. package/lib/bridge.js +35 -0
  36. package/lib/index.js +7 -1
  37. package/lib/lib/roleplay_web.js +530 -443
  38. package/lib/lib/token_store.d.ts +7 -1
  39. package/lib/lib/token_store.js +12 -3
  40. package/md_cg/audit.py +12 -1
  41. package/md_cg/backfill.py +16 -15
  42. package/md_cg/backfill_bucket_zh.py +35 -0
  43. package/md_cg/bench_e2e_judge.py +532 -0
  44. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  45. package/md_cg/bench_e2e_qa.py +256 -0
  46. package/md_cg/branches.py +18 -2
  47. package/md_cg/ccgc.py +3 -2
  48. package/md_cg/chain.py +19 -4
  49. package/md_cg/consolidate.py +7 -6
  50. package/md_cg/crosscheck.py +4 -3
  51. package/md_cg/crypto.py +439 -437
  52. package/md_cg/datapath.py +395 -335
  53. package/md_cg/evidence.py +582 -580
  54. package/md_cg/export.py +3 -1
  55. package/md_cg/forgetting.py +2 -2
  56. package/md_cg/fsutil.py +48 -0
  57. package/md_cg/hotcache.py +255 -238
  58. package/md_cg/interop.py +161 -22
  59. package/md_cg/judgment_manifest.py +177 -0
  60. package/md_cg/links.py +655 -622
  61. package/md_cg/mcp_server.py +280 -29
  62. package/md_cg/mdcg.py +616 -125
  63. package/md_cg/mdcos.py +430 -66
  64. package/md_cg/mreview/govern.py +5 -4
  65. package/md_cg/postings.py +4 -2
  66. package/md_cg/readcache.py +76 -18
  67. package/md_cg/reconcile.py +228 -0
  68. package/md_cg/review_cli.py +170 -0
  69. package/md_cg/routing.py +28 -0
  70. package/md_cg/run_tests.py +211 -0
  71. package/md_cg/scrub.py +862 -852
  72. package/md_cg/security.py +385 -275
  73. package/md_cg/selfreport.py +3 -2
  74. package/md_cg/signer.py +565 -562
  75. package/md_cg/sources.py +3 -2
  76. package/md_cg/stg.py +6 -0
  77. package/md_cg/sustain.py +1168 -1138
  78. package/md_cg/test_access_hints.py +147 -0
  79. package/md_cg/test_branch_discard_tombstone.py +136 -0
  80. package/md_cg/test_branches.py +259 -249
  81. package/md_cg/test_chain_read_isolate.py +168 -0
  82. package/md_cg/test_datapath_device_name.py +203 -0
  83. package/md_cg/test_emit_negtail_cache.py +156 -0
  84. package/md_cg/test_en_pipeline.py +186 -166
  85. package/md_cg/test_govern_directread.py +421 -0
  86. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  87. package/md_cg/test_identity_attribution.py +228 -147
  88. package/md_cg/test_index_durability.py +238 -224
  89. package/md_cg/test_interop.py +4 -2
  90. package/md_cg/test_interop_judgment.py +228 -0
  91. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  92. package/md_cg/test_links_concurrent_write.py +188 -0
  93. package/md_cg/test_merge_upsert.py +168 -0
  94. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  95. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  96. package/md_cg/test_n131_merge_gate.py +205 -0
  97. package/md_cg/test_p1x_ref_root.py +160 -0
  98. package/md_cg/test_p27_docindex.py +774 -765
  99. package/md_cg/test_p2_mcp.py +3 -0
  100. package/md_cg/test_p32_backfill.py +304 -298
  101. package/md_cg/test_p39_verify_flow.py +90 -50
  102. package/md_cg/test_p47_session_view.py +60 -25
  103. package/md_cg/test_propose_tail_index.py +157 -0
  104. package/md_cg/test_read_scope_b27.py +277 -0
  105. package/md_cg/test_readcache_default_on.py +168 -0
  106. package/md_cg/test_readcache_precise_inval.py +270 -0
  107. package/md_cg/test_readcache_prodpath.py +55 -7
  108. package/md_cg/test_reconcile_v0.py +294 -0
  109. package/md_cg/test_retr_s1.py +6 -2
  110. package/md_cg/test_retr_s1b.py +67 -0
  111. package/md_cg/test_retr_s7.py +8 -0
  112. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  113. package/md_cg/test_retr_score_once.py +208 -0
  114. package/md_cg/test_review_onepass.py +170 -0
  115. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  116. package/md_cg/test_security_audit.py +155 -0
  117. package/md_cg/test_security_audit_b26.py +161 -0
  118. package/md_cg/test_security_audit_v21.py +250 -0
  119. package/md_cg/test_semantic_canonical.py +255 -241
  120. package/md_cg/test_session_isolation.py +168 -0
  121. package/md_cg/test_snapshot_autoclose.py +187 -0
  122. package/md_cg/test_tail_watermark_race.py +208 -0
  123. package/md_cg/test_tenant_env_override_warn.py +139 -0
  124. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  125. package/md_cg/test_v14_fixes.py +415 -397
  126. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  127. package/md_cg/theory.py +276 -273
  128. package/md_cg/tokens.py +734 -677
  129. package/md_cg/units.py +3 -2
  130. package/md_cg/vision_evidence.py +4 -3
  131. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  132. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  133. package/md_cg/writepipe.py +554 -550
  134. package/package.json +6 -2
  135. package/src/bridge.ts +434 -401
  136. package/src/index.ts +526 -518
  137. package/src/lib/roleplay_web.ts +1019 -932
  138. package/src/lib/token_store.ts +202 -192
@@ -59,10 +59,11 @@ def _s(_desc, **props):
59
59
 
60
60
 
61
61
  # 生效条件:始终返回 {"type":t,"description":desc},仅当 req 为真值时该字典额外含 "_req": True。
62
- def _p(t, desc, req=False):
62
+ def _p(t, desc, req=False, **extra):
63
63
  d = {"type": t, "description": desc}
64
64
  if req:
65
65
  d["_req"] = True
66
+ d.update(extra) # P2-22 前置(批次 24):node_id 等可传 pattern/maxLength
66
67
  return d
67
68
 
68
69
 
@@ -106,7 +107,9 @@ TOOLS = [
106
107
  "适用于写入情景层记忆时的筛选(未指定 layer 时默认 contextual)。"
107
108
  "写入恒带会话身份(session)以区分不同会话的记忆;省略则归因到"
108
109
  "进程/环境身份。",
109
- "inputSchema": _s("", node_id=_p("string", "节点 id(省略则自动生成)"),
110
+ "inputSchema": _s("", node_id=_p("string", "节点 id(省略则自动生成)",
111
+ pattern="^[A-Za-z0-9_.@\u4e00-\u9fff,-]{1,128}$",
112
+ maxLength=128),
110
113
  content=_p("string", "节点内容", True),
111
114
  layer=_p("string", "层:anchor|structural|knowledge|contextual|self"),
112
115
  role=_p("string", "角色:knowledge|user|assistant|tool-output|command|edit"
@@ -179,7 +182,10 @@ TOOLS = [
179
182
  },
180
183
  {
181
184
  "name": "mdcg_get",
182
- "description": "按 id 读取一个记忆节点(frontmatter + content)。",
185
+ "description": "按 id 读取一个记忆节点(frontmatter + content)。"
186
+ "id 不存在或密级不可读时返回 {ok:false, error:'not_found', "
187
+ "readable_sensitivities, hint}(不再返回裸 null——"
188
+ "调用方须能区分「没有」与「读不到」)。",
183
189
  "inputSchema": _s("", node_id=_p("string", "节点 id", True)),
184
190
  },
185
191
  {
@@ -191,10 +197,13 @@ TOOLS = [
191
197
  {
192
198
  "name": "mdcg_verify",
193
199
  "description": "验证单元:对节点做外部裁决 confirmed/weakened/falsified"
194
- "(falsified 会移入 rejected/ 负记忆)。",
200
+ "(falsified 会移入 rejected/ 负记忆;受保护节点"
201
+ " self/anchor 层、protected 标记、importance≥0.7 不可"
202
+ "一键证伪删除,需显式 override=true,旧版本自动快照+留痕)。",
195
203
  "inputSchema": _s("", node_id=_p("string", "节点 id", True),
196
204
  evidence=_p("string", "证据", True),
197
- verdict=_p("string", "confirmed|weakened|falsified", True)),
205
+ verdict=_p("string", "confirmed|weakened|falsified", True),
206
+ override=_p("boolean", "受保护节点需显式 true 才可证伪删除")),
198
207
  },
199
208
  {
200
209
  "name": "mdcg_flywheel",
@@ -233,7 +242,9 @@ TOOLS = [
233
242
  },
234
243
  {
235
244
  "name": "mdcg_review_decide",
236
- "description": "审核裁决:accept / reject / edit / merge / noop(merge 需 merge_into)。"
245
+ "description": "审核裁决:accept / reject / edit / merge / noop(merge 需 merge_into;"
246
+ "merge 与 add 同受写保护——目标层须可写,self/anchor/immutable "
247
+ "目标需显式 override=true)。"
237
248
  "noop=已评估、判定不改变任何现有记忆:只留痕并关闭提案,"
238
249
  "不落业务节点、不进负记忆(与 reject 的区别是语义而非路径)。"
239
250
  "redteam.verdict=reject 不落库,转 needs_reapproval,"
@@ -243,7 +254,8 @@ TOOLS = [
243
254
  edits=_p("object", "edit 时的覆盖字段"), merge_into=_p("string", "merge 目标节点 id"),
244
255
  reason=_p("string", "裁决理由"),
245
256
  redteam=_p("object", "红队裁决 {verdict:pass|reject, issues:[], round:n}"),
246
- issues=_p("array", "问题清单(打回理由)")),
257
+ issues=_p("array", "问题清单(打回理由)"),
258
+ override=_p("boolean", "merge 进受保护目标需显式 true")),
247
259
  },
248
260
  {
249
261
  "name": "mdcg_review_records",
@@ -1084,6 +1096,15 @@ def _trust_state(fm):
1084
1096
  return None
1085
1097
 
1086
1098
 
1099
+ # 生效条件:cg 具有 whoami() 且其返回 dict 含 readable_sensitivities 时返回该值;cg 无该方法或抛异常时返回 None(不编造、不抛)。
1100
+ def _readable_sensitivities(cg):
1101
+ """当前身份可读密级(whoami 同源;取不到返回 None,不编造)。"""
1102
+ try:
1103
+ return (cg.whoami() or {}).get("readable_sensitivities")
1104
+ except Exception:
1105
+ return None
1106
+
1107
+
1087
1108
  def _node_view(node, offset: int = 0):
1088
1109
  if not node:
1089
1110
  return None
@@ -2021,6 +2042,10 @@ def _cg_dispatch(cg, a):
2021
2042
  limit=int(a.get("limit") or a.get("k") or 100))
2022
2043
  if act in ("export", "evidence_export"):
2023
2044
  from . import evidence as _ev
2045
+ from .security import check_path_root
2046
+ # P1-4(批次 26):证据包写/读路径根约束(报告点名的
2047
+ # 「link export 不经 require_admin → 任意文件写」缺口)
2048
+ check_path_root(a.get("out"), "MDCG_EXPORT_ROOT", "link/export")
2024
2049
  subs = a.get("subjects") or a.get("subject")
2025
2050
  if isinstance(subs, str):
2026
2051
  subs = [subs]
@@ -2034,6 +2059,9 @@ def _cg_dispatch(cg, a):
2034
2059
  return res
2035
2060
  if act in ("import", "evidence_import"):
2036
2061
  from . import evidence as _ev
2062
+ from .security import check_path_root
2063
+ check_path_root(a.get("path") or a.get("pack"),
2064
+ "MDCG_EXPORT_ROOT", "link/import")
2037
2065
  src = a.get("path") or a.get("pack")
2038
2066
  if isinstance(src, str) and src.lstrip().startswith("{"):
2039
2067
  src = json.loads(src)
@@ -2123,7 +2151,9 @@ def _cg_dispatch(cg, a):
2123
2151
  return _node_view(cg.get(a["node_id"]),
2124
2152
  offset=int(a.get("offset") or 0))
2125
2153
  q = a.get("query") or a.get("intent") or ""
2126
- if a.get("budget_tokens"):
2154
+ # 显式 0 也是「显式」(2026-09-24 修复):旧写法 `if a.get("budget_tokens")`
2155
+ # 把 0 当没传,静默换成 1200 —— 调用方以为「零预算」拿到的是满预算结果。
2156
+ if a.get("budget_tokens") is not None:
2127
2157
  return cg.recall(q, budget_tokens=int(a["budget_tokens"]),
2128
2158
  k=int(a.get("k") or 20), context=a.get("context"),
2129
2159
  goal_text=a.get("goal"),
@@ -2185,7 +2215,8 @@ def _cg_dispatch(cg, a):
2185
2215
 
2186
2216
  if op == "verify":
2187
2217
  return cg.verify(a.get("node_id", ""), a.get("evidence", ""),
2188
- a.get("verdict", ""))
2218
+ a.get("verdict", ""),
2219
+ override=bool(a.get("override")))
2189
2220
 
2190
2221
  if op == "review":
2191
2222
  act = (a.get("action") or "list").strip().lower()
@@ -2205,7 +2236,8 @@ def _cg_dispatch(cg, a):
2205
2236
  return cg.review_decide(a.get("pid", ""), a.get("decision", ""),
2206
2237
  edits=a.get("edits"), merge_into=a.get("merge_into"),
2207
2238
  reason=a.get("reason", ""),
2208
- redteam=a.get("redteam"), issues=a.get("issues"))
2239
+ redteam=a.get("redteam"), issues=a.get("issues"),
2240
+ override=bool(a.get("override")))
2209
2241
 
2210
2242
  if op == "forget":
2211
2243
  if (a.get("action") or "forget").strip().lower() == "restore":
@@ -2248,7 +2280,12 @@ def _cg_dispatch(cg, a):
2248
2280
 
2249
2281
  if op == "index_code":
2250
2282
  from . import refindex
2283
+ from .security import check_path_root
2251
2284
  root = a.get("path") or cg.root
2285
+ # P1-X:path 是模型可控输入——索引会读取目录下全部
2286
+ # 命中文件。根白名单与 ingest 的 P1-4 同语义:显式 path 越界即拒;
2287
+ # 未传(回落服务器侧 cg.root)不校验,默认部署行为不变。
2288
+ check_path_root(a.get("path"), "MDCG_INGEST_ROOT", "index_code")
2252
2289
  if not os.path.isdir(root):
2253
2290
  return {"ok": False, "error": f"目录不存在:{root}"}
2254
2291
  items, errors, stats = refindex.index_dir(
@@ -2280,7 +2317,11 @@ def _cg_dispatch(cg, a):
2280
2317
 
2281
2318
  if op == "index_doc":
2282
2319
  from . import refindex
2320
+ from .security import check_path_root
2283
2321
  root = a.get("path") or cg.root
2322
+ # P1-X:与 index_code 同族——path 模型可控,根白名单
2323
+ # 越界即拒;未传(回落 cg.root)不校验,默认部署行为不变。
2324
+ check_path_root(a.get("path"), "MDCG_INGEST_ROOT", "index_doc")
2284
2325
  if not os.path.isdir(root):
2285
2326
  return {"ok": False, "error": f"目录不存在:{root}"}
2286
2327
  layer = a.get("layer") or "knowledge"
@@ -2542,7 +2583,9 @@ def _session_call(cg, a):
2542
2583
  return cg.session_recall(
2543
2584
  session=a.get("session"), limit=int(a.get("limit") or 5),
2544
2585
  recent_limit=int(a.get("recent_limit") or 10),
2545
- budget_tokens=int(a.get("budget_tokens") or 1200),
2586
+ # `is not None`:显式 0 不再被吞成默认 1200(2026-09-24 修复)
2587
+ budget_tokens=int(a["budget_tokens"])
2588
+ if a.get("budget_tokens") is not None else 1200,
2546
2589
  include_state=bool(a.get("include_state", True)))
2547
2590
  if act == "compact":
2548
2591
  if a.get("note") and principal is not None \
@@ -2594,7 +2637,7 @@ def _skip_dirs_report(stats, limit=20):
2594
2637
  return out
2595
2638
 
2596
2639
 
2597
- # 生效条件:act=(a.get("action") or "stat").strip().lower();act 属 ("file","dir","jsonl") 且 principal 非 None 且其 can_write 为假时先 require_admin(f"ingest_{act}"),随后以 action=act 及各透传参数调 sources.run 并返回。
2640
+ # 生效条件:先经 check_path_root(a.get("path"), "MDCG_INGEST_ROOT", "ingest")(env 未设置或 path 空时放行,越界抛 PermissionError);act=(a.get("action") or "stat").strip().lower();act 属 ("file","dir","jsonl") 且 principal 非 None 且其 can_write 为假时先 require_admin(f"ingest_{act}"),随后以 action=act 及各透传参数调 sources.run 并返回。
2598
2641
  def _ingest_call(cg, a):
2599
2642
  """文件摄取分派(P0):file / dir / jsonl / stat。
2600
2643
 
@@ -2602,7 +2645,11 @@ def _ingest_call(cg, a):
2602
2645
  支持 dry_run 预演(对应计划「可预演」要求)。
2603
2646
  """
2604
2647
  from . import sources
2648
+ from .security import check_path_root
2605
2649
  act = (a.get("action") or "stat").strip().lower()
2650
+ # P1-4(批次 26):ingest 的 path 是模型可控输入——根白名单约束
2651
+ # (MDCG_INGEST_ROOT 设置时越界即拒;未设置保持现状)
2652
+ check_path_root(a.get("path"), "MDCG_INGEST_ROOT", "ingest")
2606
2653
  principal = getattr(cg, "principal", None)
2607
2654
  if act in ("file", "dir", "jsonl") and principal is not None \
2608
2655
  and not getattr(principal, "can_write", False):
@@ -2615,17 +2662,20 @@ def _ingest_call(cg, a):
2615
2662
  max_events=a.get("max_events"))
2616
2663
 
2617
2664
 
2618
- # 生效条件:act=(a.get("action") or "stat").strip().lower();principal 非 None 时一律先 require_admin(f"export_{act}"),随后以 include_content=True if a.get("include_content") is None else bool(a.get("include_content")) 等参数调 _ex.run 并返回。
2665
+ # 生效条件:先经 require_admin("export_{act}")(principal 非 None 时)与 check_path_root(a.get("out"), "MDCG_EXPORT_ROOT", "export")(env 未设置或 out 空时放行,越界抛 PermissionError);act=(a.get("action") or "stat").strip().lower();principal 非 None 时一律先 require_admin(f"export_{act}"),随后以 include_content=True if a.get("include_content") is None else bool(a.get("include_content")) 等参数调 _ex.run 并返回。
2619
2666
  def _export_call(cg, a):
2620
2667
  """全库导出(P0):graph / nodes / slice / stat。
2621
2668
 
2622
2669
  导出整库属**管理操作** → 一律 require_admin(designer 专属)。
2623
2670
  """
2624
2671
  from . import export as _ex
2672
+ from .security import check_path_root
2625
2673
  act = (a.get("action") or "stat").strip().lower()
2626
2674
  principal = getattr(cg, "principal", None)
2627
2675
  if principal is not None:
2628
2676
  principal.require_admin(f"export_{act}")
2677
+ # P1-4(批次 26):export 的 out 是写路径——根白名单约束
2678
+ check_path_root(a.get("out"), "MDCG_EXPORT_ROOT", "export")
2629
2679
  inc = a.get("include_content")
2630
2680
  return _ex.run(
2631
2681
  cg, action=act, out=a.get("out"), ids=a.get("ids"),
@@ -2851,6 +2901,17 @@ def _ref_call(cg, a):
2851
2901
  if not ref:
2852
2902
  return {"ok": False,
2853
2903
  "error": "该节点没有 code_ref/doc_ref(不是索引节点)"}
2904
+ # P1-X(两份独立报告合并):ref 回读是任意文件读原语——
2905
+ # 自报 root、inline ref 自带的 root/path 均模型可控,probe_ref 直接
2906
+ # os.path.join 后 open 全文回读(绝对 path 丢弃 root、'..' 上跳同漏)。
2907
+ # 按最终将打开的完整路径过根白名单:env 未设置=放开(部署开关,与
2908
+ # P1-4 的 ingest/export/link 同语义);设置了=realpath 落根内否则拒
2909
+ # (fail-closed),上跳与绝对路径在 realpath 归一后自然涵盖。
2910
+ from .security import check_path_root
2911
+ check_path_root(
2912
+ os.path.join(a.get("root") or ref.get("root") or "",
2913
+ ref.get("path") or ""),
2914
+ "MDCG_INGEST_ROOT", "ref")
2854
2915
  out = refindex.read_ref(ref, root=a.get("root"), ref_kind=ref_kind)
2855
2916
  out["node_id"] = nid or None
2856
2917
  return out
@@ -2942,17 +3003,17 @@ def call_tool(cg, name, args):
2942
3003
  scope = narrowed_principal(p, unit)
2943
3004
  except TokenError as e:
2944
3005
  return {"ok": False, "error": f"as_unit 非法:{e}"}
2945
- if sess:
2946
- import copy
2947
- scope = copy.copy(scope) # 浅拷贝:会话只在本调用内生效,不污染共享 Principal
2948
- scope.session = sess
2949
- if scope is p:
3006
+ # 请求级 session 只做**归因**(cg.session,写入归属/_attribution 取它),
3007
+ # **不**改 principal.session——绑定档(private/secret)的读授权锚定连接级
3008
+ # 身份(issue #35 会话隔离定稿:调用方自报的会话不得成为看他人 private
3009
+ # 的授权;与上方「session 不参与任何 ops 裁决」的原设计意图一致)。
3010
+ if scope is p and not sess:
2950
3011
  return _dispatch(cg, name, a)
2951
3012
  saved_p, saved_s = cg.principal, getattr(cg, "session", None)
2952
3013
  had_s = hasattr(cg, "session")
2953
3014
  cg.principal = scope # 单线程 stdin 循环:无并发竞争
2954
3015
  if had_s:
2955
- cg.session = scope.session
3016
+ cg.session = sess or scope.session
2956
3017
  try:
2957
3018
  return _dispatch(cg, name, a)
2958
3019
  finally:
@@ -2961,9 +3022,45 @@ def call_tool(cg, name, args):
2961
3022
  cg.session = saved_s
2962
3023
 
2963
3024
 
2964
- # 生效条件:name 为已注册工具名之一(cg / stg / mdcg_whitebox / mdcg_service_info / mdcg_remember 等);未识别的 name 返回含 error 的响应字典而不抛异常,进程不因此中断;
3025
+ # P1-1(批次 24,外部审查报告):mdcg_* 工具的 op 要求映射——此前 require_op
3026
+ # 只在 _cg_dispatch(cg 工具)内生效,mdcg_* 分支直接落库层(只验
3027
+ # can_write/层/密级,不验 ops_allow)→「--ops-allow read」的只读令牌仍可经
3028
+ # mdcg_remember 写入,权限收窄形同虚设。映射用令牌签发域内的既有 op 词
3029
+ # (read/write/verify——tokens.ROLE_SPECS 全角色都含 read,写类角色含
3030
+ # write),ops_allow=None(不限)令牌零影响。
3031
+ _MDCG_OP_REQUIRE = {
3032
+ # 写面
3033
+ "mdcg_remember": "write", "mdcg_reflect": "write",
3034
+ "mdcg_flywheel": "write", "mdcg_mine_fix_pairs": "write",
3035
+ "mdcg_rejected": "write", "mdcg_unresolved": "write",
3036
+ "mdcg_propose": "write", "mdcg_review_decide": "write",
3037
+ "mdcg_forget": "write", "mdcg_restore": "write",
3038
+ "mdcg_protect": "write", "mdcg_ingest": "write",
3039
+ "mdcg_consistency": "write",
3040
+ # 读面
3041
+ "mdcg_recall": "read", "mdcg_search": "read", "mdcg_get": "read",
3042
+ "mdcg_review_list": "read", "mdcg_review_records": "read",
3043
+ "mdcg_forgetting_history": "read", "mdcg_identity": "read",
3044
+ "mdcg_metacognition": "read", "mdcg_self_state": "read",
3045
+ "mdcg_predict": "read", "mdcg_causal": "read",
3046
+ "mdcg_evolution": "read", "mdcg_health": "read",
3047
+ "mdcg_whoami": "read", "mdcg_watermarks": "read",
3048
+ "mdcg_whitebox": "read",
3049
+ # 裁决面(仅高权角色域含 verify)
3050
+ "mdcg_verify": "verify",
3051
+ }
3052
+
3053
+
3054
+ # 生效条件:name 为已注册工具名之一(cg / stg / mdcg_whitebox / mdcg_service_info / mdcg_remember 等);name 属 _MDCG_OP_REQUIRE 且 cg.principal 具 require_op 属性时先 require_op(越权抛 AccessDenied),principal 为 None 或无该方法时跳过;cg 走 _cg_call、stg 走 _stg_call、whitebox 走 _whitebox_call;未识别的 name 返回含 error 的响应字典而不抛异常,进程不因此中断;
2965
3055
  def _dispatch(cg, name, args):
2966
3056
  a = args or {}
3057
+ # P1-1:mdcg_* 面的角色作用域闸(与 cg 工具的 require_op 同一语义——
3058
+ # ops_allow=None 令牌不受影响;受限令牌越权即 AccessDenied)
3059
+ _need_op = _MDCG_OP_REQUIRE.get(name)
3060
+ if _need_op:
3061
+ _p = getattr(cg, "principal", None)
3062
+ if _p is not None and hasattr(_p, "require_op"):
3063
+ _p.require_op(_need_op)
2967
3064
  if name == "cg":
2968
3065
  return _cg_call(cg, a)
2969
3066
  if name == "stg":
@@ -3037,7 +3134,9 @@ def _dispatch(cg, name, args):
3037
3134
  # 直接引用 mdcos.DEFAULT_MAX_ITEM_TOKENS 会 NameError —— 故此处按需导入)。
3038
3135
  from .mdcos import DEFAULT_MAX_ITEM_TOKENS as _DEFAULT_MAX_ITEM
3039
3136
  return cg.recall(a.get("query", ""),
3040
- budget_tokens=int(a.get("budget_tokens") or 1200),
3137
+ # `is not None`:显式 0 不再被吞成默认 1200(2026-09-24 修复)
3138
+ budget_tokens=int(a["budget_tokens"])
3139
+ if a.get("budget_tokens") is not None else 1200,
3041
3140
  max_item_tokens=int(a["max_item_tokens"])
3042
3141
  if a.get("max_item_tokens") is not None
3043
3142
  else _DEFAULT_MAX_ITEM,
@@ -3066,15 +3165,24 @@ def _dispatch(cg, name, args):
3066
3165
  for n, s, q in res]}
3067
3166
 
3068
3167
  if name == "mdcg_get":
3069
- return _node_view(cg.get(a.get("node_id", "")),
3070
- offset=int(a.get("offset") or 0))
3168
+ _nid = a.get("node_id", "")
3169
+ node = cg.get(_nid)
3170
+ if not node:
3171
+ # 裸 null 让调用方无从区分「id 不存在」与「密级不可读」(2026-09-24 修复)
3172
+ return {"ok": False, "error": "not_found", "node_id": _nid,
3173
+ "readable_sensitivities": _readable_sensitivities(cg),
3174
+ "hint": "id 不存在(或已被清退/证伪移入 rejected),"
3175
+ "或该节点密级高于当前身份可读——whoami 可查 clearance"}
3176
+ return _node_view(node, offset=int(a.get("offset") or 0))
3071
3177
 
3072
3178
  if name == "mdcg_reflect":
3073
3179
  res, _ = cg.search(a.get("query", ""), k=int(a.get("k") or 10), record=False)
3074
3180
  return cg.reflect(a.get("query", ""), res, a.get("feedback"))
3075
3181
 
3076
3182
  if name == "mdcg_verify":
3077
- return cg.verify(a.get("node_id", ""), a.get("evidence", ""), a.get("verdict", ""))
3183
+ return cg.verify(a.get("node_id", ""), a.get("evidence", ""),
3184
+ a.get("verdict", ""),
3185
+ override=bool(a.get("override")))
3078
3186
 
3079
3187
  if name == "mdcg_flywheel":
3080
3188
  return cg.flywheel_step(a.get("error_report") or {})
@@ -3112,7 +3220,8 @@ def _dispatch(cg, name, args):
3112
3220
  return cg.review_decide(a.get("pid", ""), a.get("decision", ""),
3113
3221
  edits=a.get("edits"), merge_into=a.get("merge_into"),
3114
3222
  reason=a.get("reason", ""),
3115
- redteam=a.get("redteam"), issues=a.get("issues"))
3223
+ redteam=a.get("redteam"), issues=a.get("issues"),
3224
+ override=bool(a.get("override")))
3116
3225
 
3117
3226
  if name == "mdcg_review_records":
3118
3227
  nid = a.get("node_id")
@@ -3163,6 +3272,12 @@ def _dispatch(cg, name, args):
3163
3272
  if name == "mdcg_ingest":
3164
3273
  from .sources import DSHSessionSource, JsonlSource, Ingestor
3165
3274
  src_arg = (a.get("source") or "auto").strip()
3275
+ # P1-4(批次 26):报告点名「mdcg_ingest 完全没有权限闸」——权限闸
3276
+ # 已由批次 24 _MDCG_OP_REQUIRE(write)补上,此处补路径根约束;
3277
+ # auto(自动发现 ~/.dsh/sessions)不经路径参数
3278
+ from .security import check_path_root
3279
+ if src_arg != "auto":
3280
+ check_path_root(src_arg, "MDCG_INGEST_ROOT", "mdcg_ingest")
3166
3281
  ing = Ingestor(cg)
3167
3282
  picked, cands = None, []
3168
3283
  if src_arg == "auto":
@@ -3342,7 +3457,7 @@ def _apply_attribution(p):
3342
3457
  p.unit = unit
3343
3458
 
3344
3459
 
3345
- # 生效条件:环境变量 MDCG_TOKEN 去空白后非空时经 verify_token(token, tenant=os.environ.get("MDCG_TENANT")) 构造——抛 TokenError 则返回 (None, f"令牌校验失败:{e}"),成功则返回 (_attach_theory(p), None);否则 MDCG_LEGACY_ENV_AUTH 值为 "1"/"true"/"True" 时按 MDCG_CAN_ADMIN/MDCG_CAN_WRITE 落到 designer/recorder/output 角色的 legacy_env 身份,两者都不满足时构造 can_write=False、can_admin=False、auth_mode="anonymous" 的 guest 身份,后两条路径同样返回 (_attach_theory(p), None);
3460
+ # 生效条件:环境变量 MDCG_TOKEN 去空白后非空时经 verify_token(token, tenant=os.environ.get("MDCG_TENANT")) 构造——抛 TokenError 则返回 (None, f"令牌校验失败:{e}"),成功则返回 (_attach_theory(p), None);否则 MDCG_LEGACY_ENV_AUTH 值为 "1"/"true"/"True" 时按 MDCG_CAN_ADMIN/MDCG_CAN_WRITE 落角色(P1-5 批次 26:admin 需 MDCG_CAN_ADMIN 与 MDCG_LEGACY_ENV_ADMIN 同时为真,只设前者时降级 recorder 并写 stderr 提示),两者都不满足时构造 can_write=False、can_admin=False、auth_mode="anonymous" 的 guest 身份,后两条路径同样返回 (_attach_theory(p), None);
3346
3461
  def _build_principal():
3347
3462
  """构造 Principal(令牌优先,fail-closed)。返回 (principal, error)。
3348
3463
 
@@ -3364,11 +3479,33 @@ def _build_principal():
3364
3479
  p = verify_token(token, tenant=os.environ.get("MDCG_TENANT"))
3365
3480
  except TokenError as e:
3366
3481
  return None, f"令牌校验失败:{e}"
3482
+ # 密级口径冲突必须开口(2026-09-24 修复):令牌优先时 MDCG_CLEARANCE
3483
+ # **完全不参与**(令牌里冻结的 clearance 说了算),而插件侧
3484
+ # config.mdcg.clearance 默认 'private'——配置写 private、实际 internal
3485
+ # 会让 private/secret 节点静默读不到(本机实测:readable=[public,internal])。
3486
+ _want = (os.environ.get("MDCG_CLEARANCE") or "").strip()
3487
+ if _want and _want != (p.clearance or ""):
3488
+ sys.stderr.write(
3489
+ f"[mdcg-mcp] ⚠ 密级配置被令牌覆盖:MDCG_CLEARANCE={_want} 但令牌"
3490
+ f" clearance={p.clearance}(令牌优先,实际可读密级以令牌为准)。"
3491
+ f"要让配置生效:用 --clearance {_want} 重新签发令牌,"
3492
+ f"或删除 MDCG_TOKEN 走 legacy/env 身份。\n")
3367
3493
  _apply_attribution(p)
3368
3494
  return _attach_theory(p), None
3369
3495
 
3370
3496
  if os.environ.get("MDCG_LEGACY_ENV_AUTH", "0") in ("1", "true", "True"):
3371
- can_admin = os.environ.get("MDCG_CAN_ADMIN", "0") in ("1", "true", "True")
3497
+ # P1-5(批次 26,外部审查报告):legacy env 直连身份不再能给 admin——
3498
+ # 「环境变量可达即 designer/can_admin」= 配置即提权后门。保留兼容但
3499
+ # admin 需**显式二次开关** MDCG_LEGACY_ENV_ADMIN=1;只设 MDCG_CAN_ADMIN
3500
+ # 时降级 recorder 并 stderr 提示(不静默)。
3501
+ _want_admin = os.environ.get("MDCG_CAN_ADMIN", "0") in ("1", "true", "True")
3502
+ can_admin = _want_admin and os.environ.get(
3503
+ "MDCG_LEGACY_ENV_ADMIN", "0") in ("1", "true", "True")
3504
+ if _want_admin and not can_admin:
3505
+ sys.stderr.write(
3506
+ "[mdcg-mcp] ⚠ MDCG_CAN_ADMIN=1 在 legacy env 身份下已不生效:"
3507
+ "管理员需要二次开关 MDCG_LEGACY_ENV_ADMIN=1(P1-5 收紧,防 env "
3508
+ "注入自授 admin)。当前降级为 recorder。\n")
3372
3509
  can_write = os.environ.get("MDCG_CAN_WRITE", "1") not in ("0", "false", "False")
3373
3510
  role = "designer" if can_admin else ("recorder" if can_write else "output")
3374
3511
  spec = role_spec(role)
@@ -3407,8 +3544,96 @@ def _attach_theory(p):
3407
3544
 
3408
3545
 
3409
3546
  # 生效条件:环境变量 MDCG_ROOT 非空且其 os.path.abspath 规范化后的 basename 小写不以 "_md_cg_" 开头、且 _build_principal() 返回的 err 为空时,构造 MdCGSecure(root, principal=principal, autoflush=1) 并对 sys.stdin 逐行 method 分派(initialize 回 protocolVersion=PROTOCOL_VERSION 与 SERVER_NAME/SERVER_VERSION,tools/list 回 tools_for_surface(),tools/call 经 call_tool 后回 content,shutdown 跳出循环),遍历结束后调用 sustain.stop_all() 与 cg.close() 并返回 0;MDCG_ROOT 为空或 basename 命中 "_md_cg_" 前缀返回 2,令牌校验失败返回 3;
3547
+ # 生效条件:env 缺省取 os.environ;返回 (root, err)——MDCG_TENANT 已登记(登记表路径可经 MDCG_TENANT_REGISTRY 覆盖,缺省 ~/.mdcg/_tenants.json)时 root=登记根,与显式 MDCG_ROOT 冲突返回 (None, 冲突说明);未登记租户回落 MDCG_ROOT;两者皆空返回 (None, None);登记表损坏/不可读(TenantRegistry.load_error 留痕或读取抛异常,N124 2026-09-25)时向 stderr 写「登记根绑定失效回落 MDCG_ROOT」告警后按未登记同形回落(零闸变,只开口)。
3548
+ def _resolve_root(env=None):
3549
+ """租户 × 认知图根解析(issue #35 接线,独立函数供守卫测试)。
3550
+
3551
+ 口径:MDCG_TENANT 已登记 → 登记根;与显式 MDCG_ROOT 不一致 → 冲突
3552
+ (fail-closed,由调用方拒启);未登记租户 → 维持 MDCG_ROOT(零变更)。
3553
+ 登记表损坏/不可读 → 同未登记回落,但必须开口(N124):静默回落会让
3554
+ 租户根物理隔离绑定无声消失、写入落进公共根,且下方冲突 fail-closed
3555
+ 检测连带失效。
3556
+ """
3557
+ env = env if env is not None else os.environ
3558
+ root = env.get("MDCG_ROOT")
3559
+ tenant = env.get("MDCG_TENANT")
3560
+ if tenant:
3561
+ t_root = None
3562
+ reg_err = ""
3563
+ try:
3564
+ from .security import TenantRegistry
3565
+ reg = TenantRegistry(env.get("MDCG_TENANT_REGISTRY") or None)
3566
+ t_root = reg.root_of(tenant)
3567
+ reg_err = getattr(reg, "load_error", "") or ""
3568
+ except Exception as e: # noqa: BLE001
3569
+ reg_err = f"{type(e).__name__}: {e}"
3570
+ t_root = None
3571
+ if reg_err:
3572
+ # N124(2026-09-25 止血):登记表损坏/不可读时隔离绑定无声消失,
3573
+ # 对照 MDCG_CLEARANCE 覆盖告警同款口径——零闸变、只开口。
3574
+ sys.stderr.write(
3575
+ f"[mdcg-mcp] ⚠ 租户登记表损坏/不可读({reg_err}):"
3576
+ f"MDCG_TENANT={tenant} 的登记根绑定失效,本次回落 "
3577
+ f"MDCG_ROOT={root or '(未设)'};请修复登记表"
3578
+ f"(MDCG_TENANT_REGISTRY,缺省 ~/.mdcg/_tenants.json)。\n")
3579
+ if t_root:
3580
+ if root and os.path.abspath(root) != os.path.abspath(t_root):
3581
+ return None, (f"MDCG_TENANT={tenant} 已登记根 {t_root},"
3582
+ f"与显式 MDCG_ROOT={root} 不一致(fail-closed)")
3583
+ root = t_root
3584
+ return root, None
3585
+
3586
+
3587
+ # 生效条件:sys.stdin/stdout/stderr 三个流逐个 reconfigure(encoding="utf-8", errors="replace"),
3588
+ # 流不支持 reconfigure(AttributeError,如被替换为非 TextIO 对象)或取值非法(ValueError)时静默跳过该流,无返回值。
3589
+ def _force_utf8_stdio():
3590
+ """进程内强制 stdio 三流 = UTF-8(issue #39 纵深防线)。
3591
+
3592
+ MCP 协议是 UTF-8 JSON,但 Windows 控制台默认代码页(如 CP936/GBK)会让
3593
+ stdio 管道跟随 locale——宿主按 UTF-8 发来的中文参数被按 GBK 解码:strict
3594
+ 下读侧直接 UnicodeDecodeError,surrogateescape 下解出代理对(\\udcXX),
3595
+ ``_j()``(ensure_ascii=False)按 UTF-8 编码时即抛 ``UnicodeEncodeError:
3596
+ surrogates not allowed``,整条请求失败(写英文正常,故用户难自查)。
3597
+ 桥层已在 ``src/lib/mdcg_client.ts`` 的 ``mdcgChildEnv()`` 注入
3598
+ ``PYTHONUTF8=1``,但只覆盖 DSH 桥路径——Claude Code / code CLI 直接
3599
+ mcp.json 接入不经桥,仍踩(issue #39)。进程内 reconfigure 是对桥层
3600
+ 防线的纵深补位;``errors="replace"`` 保证坏字节最多丢字符、不炸整条请求。
3601
+ 必须在 main() 首行调用:早于一切 stderr 中文写与 stdin 读取。
3602
+ """
3603
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
3604
+ try:
3605
+ stream.reconfigure(encoding="utf-8", errors="replace")
3606
+ except (AttributeError, ValueError):
3607
+ pass
3608
+
3609
+
3610
+ # 生效条件:_force_utf8_stdio() 先执行(stdio 三流强制 UTF-8,issue #39);随后对 mdcg_root()/aux_root() 各探一次,抛 ValueError(Windows 保留设备名末段被 GetFullPathNameW 吞成设备路径,datapath._abs_host_path 守卫)时向 stderr 写一行含原始消息的告警并返回 2;此后 _resolve_root() 返回 err 非空时向 stderr 写冲突说明并返回 2;root 为空写缺少 MDCG_ROOT 并返回 2;root 的 basename 小写以 _md_cg_ 开头返回 2,令牌校验失败返回 3;其余构造 MdCGSecure 并进入 stdin 分派循环。
3410
3611
  def main():
3411
- root = os.environ.get("MDCG_ROOT")
3612
+ _force_utf8_stdio()
3613
+ # 启动早期承接受理(Windows 保留设备名守卫):MDCG_ROOT/MDCG_AUX_ROOT/
3614
+ # MDCG_DATA_ROOT 等覆盖值末段若是保留设备名(aux/con/nul/com1-9/lpt1-9…),
3615
+ # ntpath.abspath 经 GetFullPathNameW 会把整个路径吞成设备命名空间形态
3616
+ # (例 D:\sandbox\aux → \\.\aux),datapath._abs_host_path 即抛 ValueError。
3617
+ # 此处先探一次 root/aux 解析,命中即拒绝启动(对照下方 `_md_cg_` 前缀守卫
3618
+ # 先例「启动即拒绝,不带病运行」)——否则首个 aux 常量面(crypto/links/
3619
+ # signer/theory/tokens/evidence 的模块级 aux_root())会在下方 import 处
3620
+ # 裸崩 traceback,密钥/令牌/信任面被指到不存在的设备路径。
3621
+ try:
3622
+ from .datapath import mdcg_root as _probe_root
3623
+ from .datapath import aux_root as _probe_aux
3624
+ _probe_root()
3625
+ _probe_aux()
3626
+ except ValueError as _ve:
3627
+ sys.stderr.write(f"[mdcg-mcp] {_ve}\n"
3628
+ "[mdcg-mcp] 拒绝启动(fail-closed):请把上述覆盖键"
3629
+ "改指向末段不含保留设备名的普通目录。\n")
3630
+ return 2
3631
+ root, _terr = _resolve_root()
3632
+ if _terr:
3633
+ sys.stderr.write(f"[mdcg-mcp] 租户根冲突:{_terr}。"
3634
+ "请二选一:清空 MDCG_ROOT 走租户登记根,或从登记表"
3635
+ "(~/.mdcg/_tenants.json)移除该租户。\n")
3636
+ return 2
3412
3637
  if not root:
3413
3638
  sys.stderr.write("[mdcg-mcp] 缺少 MDCG_ROOT 环境变量\n")
3414
3639
  return 2
@@ -3444,6 +3669,15 @@ def main():
3444
3669
  except Exception as _mig_exc: # 迁移故障不得影响服务可用性
3445
3670
  sys.stderr.write("[mdcg-mcp] 数据面迁移检查失败(不阻塞启动): %r\n"
3446
3671
  % (_mig_exc,))
3672
+ # 两个根一并留痕(2026-09-24):记忆真源与「身份/凭据根」**有意分离**
3673
+ # (身份不随认知图迁移),但分居两处必须可见——历史事故多为「以为在一处」。
3674
+ try:
3675
+ from .datapath import aux_root as _aux_root
3676
+ sys.stderr.write(
3677
+ "[mdcg-mcp] 路径:记忆真源 root=%s · 身份/凭据根 aux=%s"
3678
+ "(aux 可用 MDCG_AUX_ROOT 覆盖)\n" % (root, _aux_root()))
3679
+ except Exception: # 仅日志:任何失败都不影响启动
3680
+ pass
3447
3681
  principal, err = _build_principal()
3448
3682
  if err:
3449
3683
  sys.stderr.write(
@@ -3489,6 +3723,17 @@ def main():
3489
3723
  except Exception as _exc: # 对账故障不得影响服务可用性
3490
3724
  sys.stderr.write("[mdcg-mcp] 两段式对账失败(不阻塞启动): %r\n"
3491
3725
  % (_exc,))
3726
+ # ③' 启动对账 reconcile v0(v0.3 T9 自稳定定理落地,落地清单 P0-3):两段式
3727
+ # 清账只覆盖「有意图无结果的写入」,这里补全量 diff——索引(派生态)vs 盘面
3728
+ # 真源:多/缺索引条目与内容哈希漂移三类,以盘面为真源**增量**修复索引面;
3729
+ # 真源自身损坏只告警留痕不自动改写(T9 边界:结构合法≠内容正确)。告警与
3730
+ # 留痕由模块发声(diff 非空才输出,干净库零噪声);MDCG_RECONCILE=0 关闭
3731
+ # (缺省开,开关真源 reconcile.enabled)。失败不阻塞启动,与③同风格。
3732
+ try:
3733
+ cg.reconcile_state()
3734
+ except Exception as _exc: # 对账故障不得影响服务可用性
3735
+ sys.stderr.write("[mdcg-mcp] 启动对账 v0 失败(不阻塞启动): %r\n"
3736
+ % (_exc,))
3492
3737
  # 进程自报(诊断设施,fail-safe):把「md_cg 实际加载源 + render 契约代际」
3493
3738
  # 落成 `<tempdir>/md_cg_servers/<pid>.json`,供 `scripts/mdcg_stale_servers.py`
3494
3739
  # 机械判定**活进程代际**。根因(第4条取证):原先只能比「进程启动时间 vs
@@ -3542,8 +3787,14 @@ def main():
3542
3787
  _reply(rid, {"content": [{"type": "text", "text": _j(out)}],
3543
3788
  "isError": False})
3544
3789
  except Exception as exc: # noqa: BLE001 —— 工具错误以 MCP 结果返回
3790
+ # issue #34:失败路径必须带「怎么办」——AccessDenied 的 hint
3791
+ # (guest 配凭据 / 令牌补授权 / 过期重签)随结构化错误透出。
3792
+ err = {"error": f"{type(exc).__name__}: {exc}"}
3793
+ _hint = getattr(exc, "hint", None)
3794
+ if _hint:
3795
+ err["hint"] = _hint
3545
3796
  _reply(rid, {"content": [{"type": "text",
3546
- "text": _j({"error": f"{type(exc).__name__}: {exc}"})}],
3797
+ "text": _j(err)}],
3547
3798
  "isError": True})
3548
3799
  elif method == "shutdown":
3549
3800
  _reply(rid, {})