@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
package/md_cg/tokens.py CHANGED
@@ -33,10 +33,11 @@ import secrets
33
33
  import sys
34
34
  import time
35
35
  from collections import OrderedDict
36
+ from contextlib import contextmanager
36
37
 
37
38
  from .datapath import aux_root
38
- from .fsutil import publish
39
- from .security import Principal, _rank
39
+ from .fsutil import FileLock, atomic_write
40
+ from .security import Principal, TenantRegistry, _rank
40
41
 
41
42
  TOKEN_ENV = "MDCG_TOKEN"
42
43
  TOKEN_FILE_ENV = "MDCG_TOKEN_FILE"
@@ -249,6 +250,14 @@ ORCH_OPS_ALLOW = ("route", "read", "write", "review", "recent", "consistency")
249
250
  ORCH_LAYERS_ALLOW = tuple(ROLE_SPECS[ORCH_ROLE]["layers_allow"])
250
251
 
251
252
 
253
+ # 三面口径(2026-09-28 使用者裁定,勿把其中的差异当疏漏「统一」掉):
254
+ # · **入参面(本函数)=识别宽容**:角色/单元名入参(role=、as_unit=)允许大写,
255
+ # strip + lower + 别名归一后照常受理——存量大写写法不动,存量断言
256
+ # `test_p46_unit_scope.py::test_c_failclosed` 的「C2 大小写归一(RECORD -> record)」
257
+ # 就是这条口径的凭据;收窄只减不增(narrowed_principal 恒 can_admin=False),无提权面。
258
+ # · **令牌字符串面=凭据从严**:parse_token 对 role/token_id 含大写即拒(见该函数注释)
259
+ # ——凭据形态本身是安全边界,允许变形等于给伪造串留门。
260
+ # · **写面统一小写**:新产出(issue 签发的令牌、文档与示例)一律小写形态。
252
261
  # 生效条件:role 为假值(None/空串)时按 "" 处理,经 strip().lower() 得 r,r 命中 ROLE_ALIASES 键时返回别名,否则返回 r 本身。
253
262
  def normalize_role(role: str) -> str:
254
263
  r = (role or "").strip().lower()
@@ -284,29 +293,105 @@ def token_file(path: str = None) -> str:
284
293
  return path or os.environ.get(TOKEN_FILE_ENV) or DEFAULT_TOKEN_FILE
285
294
 
286
295
 
287
- # 生效条件:p=token_file(path) 的 os.path.exists(p) 为真且 json.load 得到 dict 时,setdefault("tokens", {}) 后返回该 dict;路径不存在、解析结果非 dict 或抛 OSError/ValueError 时返回 {"schema": SCHEMA, "tokens": {}}。
296
+ # 令牌库跨进程写锁的等待上限(秒);超时即 fail-closed(见 _store_lock)。
297
+ TOKEN_LOCK_TIMEOUT = 10.0
298
+
299
+
300
+ # 生效条件:yield token_file(path) 的归一结果 p 之前,以 FileLock(p, timeout=TOKEN_LOCK_TIMEOUT, strict=True) 取跨进程排它锁;__enter__ 超时(TimeoutError)时转抛 TokenError(fail-closed),其余异常原样传播。
301
+ @contextmanager
302
+ def _store_lock(path: str = None):
303
+ """令牌库「读-改-写」的**单一临界区**(N199)。
304
+
305
+ 三条写路径(issue / derive / revoke)原先是「_load → 改 → _save」无锁三步:
306
+ 两进程交错时,后写者拿陈旧快照整份覆盖盘面——并发新增的凭据无痕消失
307
+ (调用方拿到明文却永远验不过),且固定共享临时名 `p + ".tmp"` 让两个写者
308
+ 互截内容、把库撕成不可解析(叠加 N198 写前对账后即永久不可写,全库令牌
309
+ 失效)。这里把三步收进同一把 OS 级锁,并镜像 crypto.provision_dek 的
310
+ N184 口径:strict=True 超时抛错,绝不静默放行退化成无锁并发。
311
+
312
+ FileLock **非重入**(同进程对同一锁文件的二次加锁同样拿不到),故递归调用
313
+ 者(revoke 级联)走 `_revoke_locked` 复用同一临界区,不得再取锁。
314
+ """
315
+ p = token_file(path)
316
+ try:
317
+ with FileLock(p, timeout=TOKEN_LOCK_TIMEOUT, strict=True):
318
+ yield p
319
+ except TimeoutError as e:
320
+ raise TokenError(
321
+ f"令牌库写锁({p}.lock)竞争超时:并发签发/派生/吊销未在 "
322
+ f"{TOKEN_LOCK_TIMEOUT:.0f}s 内获得互斥——fail-closed 拒绝写入"
323
+ f"(无锁整份写回会无痕抹除并发新增的凭据,N199)。请稍后重试。"
324
+ ) from e
325
+
326
+
327
+ # 生效条件:p 不存在时返回 ({"schema": SCHEMA, "tokens": {}}, None);否则 json.load 结果为 dict 且其 "tokens"(缺键先 setdefault 为 {})是映射时返回 (该 dict, None);解析抛 OSError/ValueError、顶层非 dict、或 "tokens" 非映射时返回 (空骨架, 损坏描述字符串)。
328
+ def _load_raw(p: str):
329
+ """(令牌库快照, 损坏描述)——「可解析性」判据的**唯一**生产者。
330
+
331
+ N198:此前 `_load` 只有「解析失败 → 回落空表」这一半,没有「解析失败」
332
+ 这个判据本身,于是写面无法区分「空库」与「损坏库」,一路「读空表 →
333
+ 改 → 整份写回」把盘上全部令牌记录无痕抹除(同型 crypto/_keys.json 的
334
+ N139 已 fail-closed)。写面(`_save`)按下述 err 拒写,读面(`_load`)
335
+ 仍按原口径回落空表——两态由此可区分。
336
+ """
337
+ if not os.path.exists(p):
338
+ return {"schema": SCHEMA, "tokens": {}}, None
339
+ try:
340
+ with open(p, encoding="utf-8") as f:
341
+ d = json.load(f)
342
+ if isinstance(d, dict):
343
+ t = d.setdefault("tokens", {})
344
+ if isinstance(t, dict):
345
+ return d, None
346
+ return ({"schema": SCHEMA, "tokens": {}},
347
+ "顶层 tokens 不是映射(版本漂移?)")
348
+ return ({"schema": SCHEMA, "tokens": {}},
349
+ "顶层不是对象(版本漂移?)")
350
+ except (OSError, ValueError) as e:
351
+ return {"schema": SCHEMA, "tokens": {}}, f"{type(e).__name__}: {e}"
352
+
353
+
354
+ # 生效条件:p=token_file(path)。_load_raw(p) 无损坏描述时原样返回其快照;有损坏描述(不可解析/顶层非对象/tokens 非映射)时向 stderr 写「令牌库损坏/不可读」告警(N198,2026-09-28:静默回落会让 issue/derive/revoke 据空表整份写回,既有令牌记录被无痕抹除、旧令牌一律验签失败,使用者只见「令牌不存在」——告警必须开口)并返回带 load_error 标记的空结构;_save 见 load_error 即拒绝写回。
288
355
  def _load(path: str = None) -> dict:
289
356
  p = token_file(path)
290
- if os.path.exists(p):
291
- try:
292
- with open(p, encoding="utf-8") as f:
293
- d = json.load(f)
294
- if isinstance(d, dict):
295
- d.setdefault("tokens", {})
296
- return d
297
- except (OSError, ValueError):
298
- pass
299
- return {"schema": SCHEMA, "tokens": {}}
300
-
301
-
302
- # 生效条件:以 data 为内容、p=token_file(path) 为目标,先对 os.path.dirname(p) or "." 做 makedirs(exist_ok=True),写 p+".tmp" 后 publish(带 Windows 短重试的 os.replace)覆盖 p,再尝试 chmod 0600(仅吞 OSError)。
357
+ data, err = _load_raw(p)
358
+ if err is None:
359
+ return data
360
+ sys.stderr.write(
361
+ f"[mdcg-tokens] ⚠ 令牌库损坏/不可读({err}):{p}"
362
+ f"——按回落口径返回空表并置损坏标记(load_error);签发/派生/吊销在"
363
+ f"标记下拒绝写回(静默写回会把空表覆盖落盘、无痕抹除全部既有令牌"
364
+ f"记录,N198)。请修复或恢复该文件后重试。\n")
365
+ return {"schema": SCHEMA, "tokens": {}, "load_error": err}
366
+
367
+
368
+ # 生效条件:p=token_file(path),先以 _load_raw(p) 做写前对账——盘面有损坏描述、或本次 data 带 load_error 标记(载入时损坏而写前被人为修复的窗口)时抛 TokenError 拒绝整份写回;data["tokens"] 非映射时同样抛 TokenError(拒写类型混淆的快照);否则把盘面**独有**的令牌记录逐键并入 data["tokens"](同名键以本次为准),再经 fsutil.atomic_write 整文件替换 p(同目录唯一临时名 mkstemp + 带 Windows 短重试的 replace,失败即清理临时文件),最后尝试 chmod 0600(仅吞 OSError)。调用方一般须已持有 _store_lock(path)(issue/derive/_revoke_locked 持锁调用)。
303
369
  def _save(data: dict, path: str = None):
304
370
  p = token_file(path)
305
- os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
306
- tmp = p + ".tmp"
307
- with open(tmp, "w", encoding="utf-8") as f:
308
- json.dump(data, f, ensure_ascii=False, indent=1)
309
- publish(tmp, p)
371
+ # N198 写前对账(镜像 crypto._save_keys 的 N139/N184 口径):issue/derive/
372
+ # revoke 三条写路径共用本单点,闸在这里等于三面同时收口——解析不出来的
373
+ # 既有记录一概不能被写掉(旧令牌一旦被抹除即永久失效,且原实现零告警)。
374
+ _disk, err = _load_raw(p)
375
+ if err is None and data.get("load_error"):
376
+ err = data["load_error"]
377
+ if err is not None:
378
+ raise TokenError(
379
+ f"令牌库损坏/不可读({err}):{p}"
380
+ f"——拒绝整份写回(静默写回会无痕抹除全部既有令牌记录,N198)。"
381
+ f"请先手工处理该文件(备份/修复/移除)再重试。")
382
+ _tk = data.setdefault("tokens", {})
383
+ if not isinstance(_tk, dict):
384
+ raise TokenError(
385
+ f"令牌库快照类型异常(tokens 非映射:{type(_tk).__name__}):{p}"
386
+ f"——拒绝写回(N199)。请检查调用方传入的快照。")
387
+ # N199 纵深防御(镜像 crypto._save_keys 的 N184 写前合并):本次快照里没有、
388
+ # 盘面上有的记录一律带回。正常路径下锁已保证快照不陈旧,此处的意义是兜住
389
+ # 「未持锁的直接 _save 调用」以及锁语义将来被误用时的整份覆盖——原子替换
390
+ # 只防撕裂不防丢失更新。令牌记录只增不改(无删除路径),故带回恒为安全。
391
+ for _tid, _rec in ((_disk or {}).get("tokens") or {}).items():
392
+ if _tid not in _tk:
393
+ _tk[_tid] = _rec
394
+ atomic_write(p, json.dumps(data, ensure_ascii=False, indent=1))
310
395
  try:
311
396
  os.chmod(p, 0o600) # 令牌摘要文件不可被其他用户读
312
397
  except OSError:
@@ -340,7 +425,7 @@ def make_token(role: str, token_id: str, secret: str) -> str:
340
425
  return f"{PREFIX}.{role}.{token_id}.{secret}"
341
426
 
342
427
 
343
- # 生效条件:token 为假值时按 "" 处理,split(".") 后长度不为 4 或 parts[0] != PREFIX 即抛 TokenError;长度与前缀合规后 role/token_id/secret 任一为空再抛 TokenError;否则返回 (role.lower(), token_id, secret)。
428
+ # 生效条件:token 为假值时按 "" 处理,split(".") 后长度不为 4 或 parts[0] != PREFIX 即抛 TokenError(前缀只认小写,故 `MDCG1.*` 在这一步就已拒);role/token_id 含大写即抛 TokenError(**只认小写**,2026-09-28 使用者裁定:不再把小写化当归一,变形一律拒收),secret 不受此限(`secrets.token_urlsafe` 产 base64url、必含大写);长度与前缀合规后 role/token_id/secret 任一为空再抛 TokenError;否则返回 (role, token_id, secret)。
344
429
  def parse_token(token: str):
345
430
  parts = (token or "").strip().split(".")
346
431
  if len(parts) != 4 or parts[0] != PREFIX:
@@ -348,14 +433,23 @@ def parse_token(token: str):
348
433
  _, role, token_id, secret = parts
349
434
  if not role or not token_id or not secret:
350
435
  raise TokenError("令牌字段缺失")
351
- return role.lower(), token_id, secret
436
+ # 识别面宽、受理面窄:检测表(policy.forbidden / hooks.SENSITIVE_PATTERNS)认全形态
437
+ # 是为了「拦得住」,此处只认小写是为了「说得准」——签发面(issue 经 normalize_role +
438
+ # secrets.token_hex(6))只产小写 role/token_id,大写形态没有任何合法来源,故一律视为
439
+ # 伪造或手抄变形,不再静默小写化后放行。(旧实现返回 role.lower() 恰是「受理面比识别面
440
+ # 更宽」的反例:大写十六进制 id 在 hooks.ts 的 `[0-9a-f]` 检测面上漏过、却被受理面接受。)
441
+ # 与 normalize_role 的分工见该函数上方「三面口径」——入参面容忍大写是解析友好,令牌面
442
+ # 拒大写是凭据从严,两处**有意**不同。
443
+ if role != role.lower() or token_id != token_id.lower():
444
+ raise TokenError("令牌形态非法:role/token_id 只认小写,含大写即拒(不做小写化归一)")
445
+ return role, token_id, secret
352
446
 
353
447
 
354
448
  # --------------------------------------------------------------------------
355
449
  # 签发 / 校验 / 派生 / 吊销
356
450
  # --------------------------------------------------------------------------
357
451
 
358
- # 生效条件:role 经 normalize_role+role_spec(未知角色抛 TokenError),clearance 为假值时取 spec["clearance_cap"] 再经 _clamp_level 收窄到该 cap,layers_allow/ops_allow 经 _narrow 与 spec 默认求交,actor 为假值时取 role,delegable is None 时取 spec["delegable"]、否则按所传值取 bool,ttl 为真值时 expires_at=now+float(ttl)、为假值(None/0)时 None,记录写入 token_file(path) 后返回含明文 token 的字典。
452
+ # 生效条件:role 经 normalize_role+role_spec(未知角色抛 TokenError),clearance 为假值时取 spec["clearance_cap"] 再经 _clamp_level 收窄到该 cap,layers_allow/ops_allow 经 _narrow 与 spec 默认求交,actor 为假值时取 role,delegable is None 时取 spec["delegable"]、否则按所传值取 bool,ttl 为真值时 expires_at=now+float(ttl)、为假值(None/0)时 None,随后在 _store_lock(path) 临界区内把记录写入 token_file(path)(N199:读-改-写无锁会被并发写者用陈旧快照整份覆盖)并返回含明文 token 的字典。
359
453
  def issue(role: str, actor: str = None, clearance: str = None,
360
454
  tenant: str = "default", ttl: float = None, label: str = "",
361
455
  issued_by: str = "root", parent: str = None, delegable: bool = None,
@@ -380,17 +474,20 @@ def issue(role: str, actor: str = None, clearance: str = None,
380
474
  "expires_at": (now + float(ttl)) if ttl else None,
381
475
  "revoked_at": None, "label": label, "hash": _hash(secret),
382
476
  }
383
- data = _load(path)
384
- data["tokens"][token_id] = rec
385
- data["schema"] = SCHEMA
386
- _save(data, path)
477
+ # N199:读-改-写整段收进令牌库跨进程临界区——无锁时后写者用陈旧快照整份
478
+ # 覆盖,并发签发的新凭据无痕消失(调用方拿到明文却永远验不过)。
479
+ with _store_lock(path):
480
+ data = _load(path)
481
+ data["tokens"][token_id] = rec
482
+ data["schema"] = SCHEMA
483
+ _save(data, path)
387
484
  return {"ok": True, "token": make_token(role, token_id, secret),
388
485
  "token_id": token_id, "role": role, "actor": rec["actor"],
389
486
  "clearance": clearance, "layers_allow": layers, "ops_allow": ops,
390
487
  "expires_at": rec["expires_at"], "token_file": token_file(path)}
391
488
 
392
489
 
393
- # 生效条件:token 先经 parse_token(格式非法即抛 TokenError),其后 _load(path) 的 tokens 中该 token_id 无记录、rec 的 role 与解析出的 role 不等、revoked_at 为真、hash 与 _hash(secret) 经 hmac.compare_digest 不等、expires_at 为真且小于当前时间、或沿 parent 链上溯(seen 集合防环;链上父记录缺失时仅向 stderr 告警不阻断——吊销已由 revoke 级联物化,此处只补过期维度,文件写权不在令牌威胁模型内)任一祖先 expires_at 为真且小于当前时间中任一成立即抛 TokenError;否则返回 Principal,tenant=tenant or rec.get("tenant") or "default"、actor=rec.get("actor") or role、clearance=rec.get("clearance") or "internal"、can_write/can_admin 取对应 rec 值的 bool;返回前 rec["tenant"] 与非空形参 tenant 去空白后不等时先向 stderr 写「租户绑定被入参覆盖」告警(仅告警零闸变,对照 _build_principal 的 MDCG_CLEARANCE 先例;租户强校验与 clearance_cap 夹紧接线另行 deferred)。
490
+ # 生效条件:token 先经 parse_token(格式非法即抛 TokenError),其后 _load(path) 的 tokens 中该 token_id 无记录、rec 的 role 与解析出的 role 不等、revoked_at 为真、hash 与 _hash(secret) 经 hmac.compare_digest 不等、expires_at 为真且小于当前时间、或沿 parent 链上溯(seen 集合防环;链上父记录缺失时仅向 stderr 告警不阻断——吊销已由 revoke 级联物化,此处只补过期维度,文件写权不在令牌威胁模型内)任一祖先 expires_at 为真且小于当前时间中任一成立即抛 TokenError;否则返回 Principal,tenant=tenant or rec.get("tenant") or "default"、actor=rec.get("actor") or role、clearance=rec.get("clearance") or "internal"、can_write/can_admin 取对应 rec 值的 bool;返回前 rec["tenant"] 与非空形参 tenant 去空白后不等时先向 stderr 写「租户绑定被入参覆盖」告警;rec["tenant"] 与非空形参 tenant 去空白后不等时抛 TokenError 拒绝(N62②,2026-09-27 第 5 轮:告警改 fail-closed——原告警文案已定性「若非有意迁移请校正 MDCG_TENANT」),clearance 先取 rec 值再按登记租户上限夹紧(N62①:MDCG_TENANT_REGISTRY 登记表(与 _resolve_root 同源)在册租户 _rank(clearance) 高于其 clearance_cap 时夹紧为 cap;未登记租户/登记表缺失或损坏零闸变)。
394
491
  def verify_token(token: str, tenant: str = None, path: str = None) -> Principal:
395
492
  """校验令牌 → Principal。任何异常都抛 TokenError(fail-closed)。"""
396
493
  role, token_id, secret = parse_token(token)
@@ -430,24 +527,40 @@ def verify_token(token: str, tenant: str = None, path: str = None) -> Principal:
430
527
  raise TokenError(
431
528
  f"令牌已失效:派生链祖先 {pid} 已过期(过期沿派生链传播)")
432
529
  pid = ancestor.get("parent")
433
- # 租户绑定被 env 覆盖必须开口(2026-09-25 止血,v8 N62/v9/第15轮三次
434
- # 成立):形参 tenant(MCP 侧来自 MDCG_TENANT)非空且与令牌记录
435
- # rec["tenant"] 不同时,形参值静默顶替令牌租户——tenantA 签发的
436
- # secret/designer 令牌在 MDCG_TENANT=tenantB 下以原权限对 tenantB 登记根
437
- # 运行且零痕迹。对照 MDCG_CLEARANCE 先例(mcp_server._build_principal):
438
- # 只告警不改闸(租户绑定强校验 + clearance_cap 夹紧接线另行 deferred)。
530
+ # ② N62(第 5 轮成立,2026-09-27):租户绑定被入参覆盖由告警改 fail-closed
531
+ # 拒绝——形参 tenant(MCP 侧来自 MDCG_TENANT)非空且与令牌记录
532
+ # rec["tenant"] 不同时抛 TokenError(v8 N62 首报→v9/v15/v16 历轮成立,
533
+ # warn-only 五轮证明告警不构成闸门;原告警文案已定性「若非有意迁移请
534
+ # 校正」)。有意迁移的正道:校正 MDCG_TENANT 或为该租户重新签发令牌。
439
535
  _rec_tenant = str(rec.get("tenant") or "").strip()
440
536
  _arg_tenant = str(tenant or "").strip()
441
537
  if _arg_tenant and _rec_tenant and _arg_tenant != _rec_tenant:
442
- sys.stderr.write(
443
- f"[mdcg-tokens] ⚠ 租户绑定被入参覆盖:令牌按 {_rec_tenant} 签发,"
444
- f"但入参 tenant={_arg_tenant}(MCP 侧来自 MDCG_TENANT)优先生效"
445
- f"——令牌将以原 clearance/can_admin 对 {_arg_tenant} 运行。"
446
- f"若非有意迁移,请校正 MDCG_TENANT 或为该租户重新签发令牌。\n")
538
+ raise TokenError(
539
+ f"[mdcg-tokens] 租户绑定被入参覆盖,拒绝校验(fail-closed,"
540
+ f"N62 第 5 轮):令牌按 {_rec_tenant} 签发,但入参 "
541
+ f"tenant={_arg_tenant}(MCP 侧来自 MDCG_TENANT)——令牌不得以原 "
542
+ f"clearance/can_admin 对 {_arg_tenant} 运行。若非有意迁移,请校正 "
543
+ f"MDCG_TENANT;若为有意迁移,请为该租户重新签发令牌"
544
+ f"(python -m md_cg.tokens issue …)。\n")
545
+ # ① N62 接线:令牌路径 clearance 按登记租户上限夹紧(principal_for/cap_of
546
+ # 现成,纯收紧不改宽)——仅登记租户收紧;未登记租户/登记表缺失或损坏
547
+ # 零闸变(fresh install 无 _tenants.json 行为不变,守卫 R3 钉住;登记表
548
+ # 损坏回落与 _resolve_root 的 N124 先例同口径)。登记表路径经
549
+ # MDCG_TENANT_REGISTRY(与 mcp_server._resolve_root 同源)。
550
+ _eff_tenant = tenant or rec.get("tenant") or "default"
551
+ _clearance = rec.get("clearance") or "internal"
552
+ try:
553
+ _reg = TenantRegistry(os.environ.get("MDCG_TENANT_REGISTRY") or None)
554
+ if _reg.get(_eff_tenant):
555
+ _cap = _reg.cap_of(_eff_tenant)
556
+ if _rank(_clearance) > _rank(_cap):
557
+ _clearance = _cap
558
+ except Exception: # noqa: BLE001 —— 登记表缺失/损坏按未登记(零闸变)
559
+ pass
447
560
  return Principal(
448
- tenant=tenant or rec.get("tenant") or "default",
561
+ tenant=_eff_tenant,
449
562
  actor=rec.get("actor") or role,
450
- clearance=rec.get("clearance") or "internal",
563
+ clearance=_clearance,
451
564
  can_write=bool(rec.get("can_write")),
452
565
  can_admin=bool(rec.get("can_admin")),
453
566
  role=role, token_id=token_id, parent=rec.get("parent"),
@@ -455,53 +568,66 @@ def verify_token(token: str, tenant: str = None, path: str = None) -> Principal:
455
568
  ops_allow=rec.get("ops_allow"), auth_mode="token")
456
569
 
457
570
 
458
- # 生效条件:parent_token 经 verify_token(parent_token, path=path) 成功且父记录 delegable 为真才继续,否则抛 TokenError;role 经 normalize_role+role_spec,clearance 为假值时取 spec["clearance_cap"] 再 _clamp_level,若仍高于 parent.clearance 则降为 parent.clearance 并向 clamped 追加 "clearance";layers/ops 先与 spec 求交再与父记录求交;can_write/can_admin 取 spec 与父对应值的与;ttl 为真值时过期候选=now+float(ttl)、为假值(None/0)时沿用父记录 expires_at,候选与父记录 expires_at 均非空时取 min(N123:派生在时间维度同样只能收窄,父无界时取候选),被父夹紧时向 clamped 追加 "expires_at";新记录 delegable 恒 False、parent/issued_by 为 parent.token_id,返回含明文 token 与 clamped 的字典。
571
+ # 生效条件:parent_token 经 verify_token(parent_token, path=path) 成功,随后在 _store_lock(path) 临界区内复核父令牌**落盘态**:父记录 delegable 为假抛 TokenError,父记录已并发吊销(revoked_at 为真,N199)同样抛 TokenError;role 经 normalize_role+role_spec,clearance 为假值时取 spec["clearance_cap"] 再 _clamp_level,若仍高于 parent.clearance 则降为 parent.clearance 并向 clamped 追加 "clearance";layers/ops 先与 spec 求交再与父记录求交;can_write/can_admin 取 spec 与父对应值的与;ttl 为真值时过期候选=now+float(ttl)、为假值(None/0)时沿用父记录 expires_at,候选与父记录 expires_at 均非空时取 min(N123:派生在时间维度同样只能收窄,父无界时取候选),被父夹紧时向 clamped 追加 "expires_at";新记录 delegable 恒 False、parent/issued_by 为 parent.token_id,_save 后返回含明文 token 与 clamped 的字典。
459
572
  def derive(parent_token: str, role: str, actor: str = None, ttl: float = None,
460
573
  label: str = "", path: str = None, clearance: str = None,
461
574
  layers_allow=None, ops_allow=None):
462
575
  """设计者令牌派生受限子令牌:权限只能收窄,子令牌默认不可再派生。"""
463
576
  parent = verify_token(parent_token, path=path)
464
- data = _load(path)
465
- prec = (data.get("tokens") or {}).get(parent.token_id) or {}
466
- if not prec.get("delegable"):
467
- raise TokenError(f"令牌 {parent.token_id} 不可派生(role={parent.role})")
468
- role = normalize_role(role)
469
- spec = role_spec(role)
470
- clamped = []
471
- want_clear = clearance or spec["clearance_cap"]
472
- final_clear = _clamp_level(want_clear, spec["clearance_cap"])
473
- if _rank(parent.clearance) < _rank(final_clear):
474
- final_clear = parent.clearance
475
- clamped.append("clearance")
476
- layers = _narrow(_narrow(layers_allow, spec["layers_allow"]),
477
- prec.get("layers_allow"))
478
- ops = _narrow(_narrow(ops_allow, spec["ops_allow"]), prec.get("ops_allow"))
479
- can_write = bool(spec["can_write"]) and bool(parent.can_write)
480
- can_admin = bool(spec["can_admin"]) and bool(parent.can_admin)
481
- # N123(2026-09-25 止血):「派生只能收窄」补上时间维度——子令牌有效期
482
- # 不得超过父令牌(revoke 有级联,过期原先没有:父过期后子令牌仍以原密级
483
- # 通过 verify_token,授权回收被长 TTL 子令牌旁路)。父无界(expires_at
484
- # 为 None)时不夹紧,取请求值;子未指定 ttl 时沿用父界(含无界)。
485
- now = time.time()
486
- pexp = prec.get("expires_at")
487
- want_exp = (now + float(ttl)) if ttl else pexp
488
- final_exp = want_exp
489
- if want_exp is not None and pexp is not None \
490
- and float(want_exp) > float(pexp):
491
- final_exp = pexp
492
- clamped.append("expires_at")
493
- token_id, secret = "tk_" + secrets.token_hex(6), secrets.token_urlsafe(32)
494
- rec = {
495
- "role": role, "actor": actor or role, "tenant": parent.tenant,
496
- "clearance": final_clear, "can_write": can_write, "can_admin": can_admin,
497
- "layers_allow": layers, "ops_allow": ops,
498
- "delegable": False, "parent": parent.token_id,
499
- "issued_by": parent.token_id, "issued_at": now,
500
- "expires_at": final_exp,
501
- "revoked_at": None, "label": label, "hash": _hash(secret),
502
- }
503
- data["tokens"][token_id] = rec
504
- _save(data, path)
577
+ # N199:本函数的「读-改-写」必须与父令牌的**落盘态复核**同处一个临界区
578
+ # (原先无锁,并发 revoke 与之交错时会产出「父已吊销、子仍有效」的凭据:
579
+ # 吊销级联只覆盖它落锁那一刻盘上已存在的子令牌)。持锁后 revoke 只能落在
580
+ # 本函数之前(则下方 prec["revoked_at"] 判据拒绝派生)或之后(则级联能看见
581
+ # 这枚新子令牌并一并吊销)——两向都收口。
582
+ with _store_lock(path):
583
+ data = _load(path)
584
+ prec = (data.get("tokens") or {}).get(parent.token_id) or {}
585
+ if not prec.get("delegable"):
586
+ raise TokenError(
587
+ f"令牌 {parent.token_id} 不可派生(role={parent.role})")
588
+ if prec.get("revoked_at"):
589
+ raise TokenError(
590
+ f"父令牌 {parent.token_id} 已被并发吊销,拒绝派生(N199)")
591
+ role = normalize_role(role)
592
+ spec = role_spec(role)
593
+ clamped = []
594
+ want_clear = clearance or spec["clearance_cap"]
595
+ final_clear = _clamp_level(want_clear, spec["clearance_cap"])
596
+ if _rank(parent.clearance) < _rank(final_clear):
597
+ final_clear = parent.clearance
598
+ clamped.append("clearance")
599
+ layers = _narrow(_narrow(layers_allow, spec["layers_allow"]),
600
+ prec.get("layers_allow"))
601
+ ops = _narrow(_narrow(ops_allow, spec["ops_allow"]),
602
+ prec.get("ops_allow"))
603
+ can_write = bool(spec["can_write"]) and bool(parent.can_write)
604
+ can_admin = bool(spec["can_admin"]) and bool(parent.can_admin)
605
+ # N123(2026-09-25 止血):「派生只能收窄」补上时间维度——子令牌有效期
606
+ # 不得超过父令牌(revoke 有级联,过期原先没有:父过期后子令牌仍以原密级
607
+ # 通过 verify_token,授权回收被长 TTL 子令牌旁路)。父无界(expires_at
608
+ # 为 None)时不夹紧,取请求值;子未指定 ttl 时沿用父界(含无界)。
609
+ now = time.time()
610
+ pexp = prec.get("expires_at")
611
+ want_exp = (now + float(ttl)) if ttl else pexp
612
+ final_exp = want_exp
613
+ if want_exp is not None and pexp is not None \
614
+ and float(want_exp) > float(pexp):
615
+ final_exp = pexp
616
+ clamped.append("expires_at")
617
+ token_id, secret = ("tk_" + secrets.token_hex(6),
618
+ secrets.token_urlsafe(32))
619
+ rec = {
620
+ "role": role, "actor": actor or role, "tenant": parent.tenant,
621
+ "clearance": final_clear, "can_write": can_write,
622
+ "can_admin": can_admin,
623
+ "layers_allow": layers, "ops_allow": ops,
624
+ "delegable": False, "parent": parent.token_id,
625
+ "issued_by": parent.token_id, "issued_at": now,
626
+ "expires_at": final_exp,
627
+ "revoked_at": None, "label": label, "hash": _hash(secret),
628
+ }
629
+ data["tokens"][token_id] = rec
630
+ _save(data, path)
505
631
  return {"ok": True, "token": make_token(role, token_id, secret),
506
632
  "token_id": token_id, "role": role, "actor": rec["actor"],
507
633
  "clearance": final_clear, "layers_allow": layers, "ops_allow": ops,
@@ -547,18 +673,26 @@ def narrowed_principal(p: Principal, unit: str) -> Principal:
547
673
  theory_ok=p.theory_ok, theory_version=p.theory_version)
548
674
 
549
675
 
550
- # 生效条件:token_id 在 _load(path) 的 tokens 中无记录时抛 TokenError;有记录则将其 revoked_at 置为 time.time() 并 _save,再对 tokens 中 parent 等于 token_id 的每个子令牌递归 revoke(c, path),返回 {"ok": True, "token_id": token_id, "revoked_children": children}。
676
+ # 生效条件:先以 _store_lock(path) 取令牌库跨进程写锁(超时抛 TokenError),再在临界区内执行 _revoke_locked(token_id, path) 并返回其结果。
551
677
  def revoke(token_id: str, path: str = None):
678
+ """吊销令牌(级联派生链)。整个「读-改-写 + 级联」在同一临界区内完成。"""
679
+ with _store_lock(path):
680
+ return _revoke_locked(token_id, path)
681
+
682
+
683
+ # 生效条件:调用方**已持** _store_lock(path)(FileLock 非重入,递归不得再取锁);token_id 在 _load(path) 的 tokens 中无记录时抛 TokenError;有记录则将其 revoked_at 置为 time.time() 并 _save,再对 tokens 中 parent 等于 token_id 的每个子令牌递归 _revoke_locked(c, path),返回 {"ok": True, "token_id": token_id, "revoked_children": children}。
684
+ def _revoke_locked(token_id: str, path: str = None):
552
685
  data = _load(path)
553
686
  rec = (data.get("tokens") or {}).get(token_id)
554
687
  if not rec:
555
688
  raise TokenError(f"令牌不存在:{token_id}")
556
689
  rec["revoked_at"] = time.time()
557
690
  _save(data, path)
558
- # 级联吊销派生链
691
+ # 级联吊销派生链(同一临界区内递归:N199 保证「落锁期间不会有新子令牌
692
+ # 悄悄挂到本令牌下」,否则那枚子令牌会绕过级联、在父已吊销后仍有效)
559
693
  children = [t for t, r in data["tokens"].items() if r.get("parent") == token_id]
560
694
  for c in children:
561
- revoke(c, path)
695
+ _revoke_locked(c, path)
562
696
  return {"ok": True, "token_id": token_id, "revoked_children": children}
563
697
 
564
698
 
@@ -603,17 +737,13 @@ def _csv_list(v):
603
737
  return [x.strip() for x in str(v).split(",") if x.strip()] or None
604
738
 
605
739
 
606
- # 生效条件:path 经 os.path.abspath 得 p,其 dirname 非空时 makedirs(exist_ok=True),text 原样写入 p+".tmp" 后 publish(带 Windows 短重试的 os.replace)覆盖 p,再尝试 chmod 0600(仅吞 OSError)。
740
+ # 生效条件:path 经 os.path.abspath 得 p(不含共享固定临时名),text 经 fsutil.atomic_write 整体替换写入 p(同目录唯一临时名 mkstemp + 带 Windows 短重试的 replace,失败即清理),再尝试 chmod 0600(仅吞 OSError)。
607
741
  def _write_secret(path: str, text: str) -> None:
608
742
  """把令牌明文写入文件(0600,原子替换)——供 HIVE_ORCH_TOKEN_FILE 读取。"""
609
743
  p = os.path.abspath(path)
610
- d = os.path.dirname(p)
611
- if d:
612
- os.makedirs(d, exist_ok=True)
613
- tmp = p + ".tmp"
614
- with open(tmp, "w", encoding="utf-8") as f:
615
- f.write(text)
616
- publish(tmp, p)
744
+ # N199:原先写 p + ".tmp"(固定共享名)再 publish——两进程同写一个目标时
745
+ # 互截内容(半截文件可能被 rename 到位)。atomic_write 用唯一临时名。
746
+ atomic_write(p, text)
617
747
  try:
618
748
  os.chmod(p, 0o600)
619
749
  except OSError:
@@ -52,19 +52,19 @@ TOOL_BRIEFS = {
52
52
  "mdcg_rejected": "写入负记忆(被证伪假设),同内容幂等。",
53
53
  "mdcg_unresolved": "写入未解问题(驱动主动探索)。",
54
54
  "mdcg_propose": "候选记忆入审核队列(海马体 inbox)。",
55
- "mdcg_review_list": "列出待审核候选(红队打回者带 needs_reapproval)。",
55
+ "mdcg_review_list": "列出待审核候选(红队打回者带 needs_reapproval;需 review op)。",
56
56
  "mdcg_review_decide": "审核裁决 accept|reject|edit|merge(redteam 打回需再审批)。",
57
57
  "mdcg_review_records": "裁决记录审计(可复核 record_hash 一致性)。",
58
58
  "mdcg_forget": "软删除(tombstone → trash/,受保护节点需 override)。",
59
- "mdcg_protect": "写保护:stats|check|mark|snapshot|history|forgetting(mark 需 admin)。",
60
- "mdcg_forgetting_history": "主动遗忘裁决留痕(四态 + 三问判据)。",
59
+ "mdcg_protect": "写保护:stats|check|mark|snapshot|history|forgetting(需 protect op;mark 还需 admin)。",
60
+ "mdcg_forgetting_history": "主动遗忘裁决留痕(四态 + 三问判据;需 protect op)。",
61
61
  "mdcg_identity": "身份特征识别(self/user/role/agent 的锚点、位置效应、条件特征)。",
62
62
  "mdcg_consistency": "节点间冲突检测(L0 情绪 → L1 反思 → L2 递归反思;可投飞轮)。",
63
63
  "mdcg_metacognition": "独立元认知(情绪/校准/盲区/信任四面;只读留痕)。",
64
64
  "mdcg_self_state": "自我状态层(薄自我 + 富索引:状态卡单例 + 关系节点)。",
65
65
  "mdcg_predict": "生成式预测(候选未来;过滤门 + 四维评分 + 命中反馈校准)。",
66
66
  "mdcg_causal": "因果推理(causal 边 = 条件依赖;path|gate|chain|explain)。",
67
- "mdcg_evolution": "演化账本(每次修改 = 补一条缺失条件;可 rollback 留痕)。",
67
+ "mdcg_evolution": "演化账本(每次修改 = 补一条缺失条件;可 rollback 留痕;需 evolution op)。",
68
68
  "mdcg_restore": "恢复被 forget 的节点(在删除清单中且未 force 则拒绝)。",
69
69
  "mdcg_health": "健康度(分桶健康 + CCG 完整度 + 验证基底覆盖 + OS 指标)。",
70
70
  "mdcg_whoami": "身份与权限(tenant/actor/clearance/可见节点/可读密级)。",
package/md_cg/trust.py CHANGED
@@ -40,7 +40,10 @@ from __future__ import annotations
40
40
  import os
41
41
  import time
42
42
 
43
+ from . import protect
44
+ from . import security as _security
43
45
  from .fsutil import append_jsonl, read_jsonl
46
+ from .security import AccessDenied
44
47
 
45
48
  #: 验证态全集。语义:
46
49
  #: unverified 未验证(默认;存量缺字段即视为它)
@@ -635,7 +638,7 @@ def dependents_index(cg, refresh: bool = False) -> dict:
635
638
  return cached
636
639
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
637
640
  idx = {}
638
- for nid, e in nodes.items():
641
+ for nid, e in list(nodes.items()):
639
642
  for p in as_deps((e or {}).get(DEPS_FIELD)):
640
643
  idx.setdefault(p, []).append(nid)
641
644
  for k in idx:
@@ -674,19 +677,39 @@ def _sync_index(cg, node_id, fm) -> None:
674
677
  flush()
675
678
 
676
679
 
680
+ # 生效条件:cg.get(node_id) 为假值(节点不存在)时返回 {"ok": False, "error": "node_not_found", "node_id": node_id};否则先过层写闸 protect.require_layer(cg, node_id, layer=fm.layer, sensitivity=fm.sensitivity)(N209:越权身份抛 AccessDenied → 转成 {"ok": False, "error": "layer_denied", "changed": False} 负路由,记 set_state_denied 台账,**不写盘**),再经 stamp 裁决:code 为 noop 或非法迁移时返回 ok=False 或 changed=False 不写盘,成功时把 fm 经 cg._write_node 落盘、_sync_index 同步验证态快照、_record 追加台账,fm 含 depends_on 时再 invalidate_cache,返回 {node_id, from, to, code, ok, changed, reason, at}。
677
681
  def set_state(cg, node_id: str, dst: str, reason: str = None, actor: str = None,
678
682
  evidence: str = None, method: str = None, trigger: str = None,
679
683
  override: bool = False) -> dict:
680
684
  """推进一个节点的验证态(**唯一推进入口**)。
681
685
 
682
686
  非法迁移不走异常而是返回 `{"ok": False, "error": <code>, ...}`(负路由)。
683
- 幂等迁移返回 `changed=False` 且不写盘。
687
+ 幂等迁移返回 `changed=False` 且不写盘。**写盘前另过 principal 层写闸**
688
+ (N209:无权写该层 → `error="layer_denied"` 负路由,零写盘 + 台账留痕)。
684
689
  """
685
690
  node = cg.get(node_id)
686
691
  if not node:
687
692
  return {"ok": False, "error": "node_not_found", "node_id": node_id}
688
693
  fm = dict(node.get("frontmatter") or {})
689
694
  src = state_of(fm)
695
+ # N209(同族未接线的相邻写面):验证态推进 = 对节点本体 frontmatter 的一次
696
+ # **覆写**(`cg._write_node` 全量重写 fm + 正文),与 add / 覆写写面同一层闸。
697
+ # 此前该路径零层闸(MdCGSecure 对 set_state/set_verification/mark_dependents
698
+ # 无任何覆盖)⇒ 持 verify 令牌者经 verify→mark_dependents → 把**任意层**
699
+ # 依赖者(实测 self 层)置 doubted。拒绝走**负路由**而非 raise:本函数契约是
700
+ # 「非法迁移不抛异常(ok=False + 机器码)」,且 mark_dependents 契约是
701
+ # 「永不抛、不阻断本次裁决」——但**绝不写盘**,并把拒绝记进 `_trust.jsonl`
702
+ #(不静默:与 `mark_dependents_degraded` 同一「留痕降级」口径)。
703
+ try:
704
+ protect.require_layer(cg, node_id, layer=fm.get("layer"),
705
+ sensitivity=fm.get("sensitivity"))
706
+ except AccessDenied as exc:
707
+ _record(cg, {"t": time.time(), "action": "set_state_denied",
708
+ "node_id": node_id, "from": src, "to": dst,
709
+ "error": f"{type(exc).__name__}: {exc}",
710
+ "actor": actor, "trigger": trigger})
711
+ return {"node_id": node_id, "from": src, "to": dst, "ok": False,
712
+ "error": "layer_denied", "reason": str(exc), "changed": False}
690
713
  ok, code, why = stamp(fm, dst, reason=reason, actor=actor, evidence=evidence,
691
714
  method=method, trigger=trigger, override=override)
692
715
  base = {"node_id": node_id, "from": src, "to": dst, "code": code}
@@ -781,7 +804,7 @@ def propagate(cg, *, apply: bool = False, max_nodes: int = MAX_NODES_DEFAULT,
781
804
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
782
805
  dep_map = dependents_index(cg)
783
806
  roots = []
784
- for nid, e in nodes.items():
807
+ for nid, e in list(nodes.items()):
785
808
  st = (e or {}).get(STATE_FIELD)
786
809
  if st in ("expired", "rechecking"):
787
810
  roots.append(nid)
@@ -910,7 +933,16 @@ def backfill(cg, apply: bool = False, limit: int = 5000) -> dict:
910
933
 
911
934
 
912
935
  def describe(cg, node_id: str, now: float = None) -> dict:
913
- """单节点验证态全貌(供 op=status / 状态头渲染)。只读、失败不抛。"""
936
+ """单节点验证态全貌(供 op=status / 状态头渲染)。只读、失败不抛。
937
+
938
+ N205(2026-09-28):`cg.get` 被读闸拒后**不得**回落索引条目仍报 ok=True——
939
+ 索引条目本身就是密级/会话/验证态元数据(layer/tags/session/verification_state),
940
+ 回落等于把「不可读」当「可见」返回(实测:guest 对 private 节点 get→None,
941
+ 而 status 仍回 ok=True + verification_state='verified' + depends_on +
942
+ 状态头「✓ 已验证」)。口径改为与 get/search/recall 一致的「不可见即不存在」:
943
+ 判定走跨层单点 `security.node_visible`(纯 MdCGOS 无身份模型 → 不受影响;
944
+ 设计者 can_admin 豁免不变)。
945
+ """
914
946
  try:
915
947
  node = cg.get(node_id)
916
948
  except Exception: # noqa: BLE001
@@ -918,6 +950,8 @@ def describe(cg, node_id: str, now: float = None) -> dict:
918
950
  e = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
919
951
  if not node and not e:
920
952
  return {"ok": False, "error": "node_not_found", "node_id": node_id}
953
+ if not node and not _security.node_visible(cg, node_id):
954
+ return {"ok": False, "error": "node_not_found", "node_id": node_id}
921
955
  fm = (node or {}).get("frontmatter") or {}
922
956
  deps = as_deps(fm.get(DEPS_FIELD)) or as_deps((e or {}).get(DEPS_FIELD))
923
957
  kind, start, end = validity(fm if fm else (e or {}), now=now)
@@ -951,7 +985,15 @@ def summary(cg) -> dict:
951
985
 
952
986
 
953
987
  def load_ledger(cg, *, node_id: str = None, limit: int = None) -> list:
954
- """读验证态台账(跳过坏行;可按节点过滤)。"""
988
+ """读验证态台账(跳过坏行;可按节点过滤)。
989
+
990
+ N205(2026-09-28):行内 `reason` 含**明文证据串**(外部裁决理由),此前
991
+ 只按 node_id 等值过滤、零可见性判定 ⇒ 任何持 read op 的身份(含无令牌
992
+ guest,`cg(op="status", action="ledger")` 在 guest 的 ops_allow 内)可读走
993
+ 他人私密节点的裁决证据与验证态。口径与库读面统一:逐行按
994
+ `security.node_visible`(跨层单点)判定行所属节点对本身份是否可见,
995
+ 不可见/不可证(已 forget、索引无条目)→ 该行整条不出;设计者豁免。
996
+ """
955
997
  path = os.path.join(cg.root, AUDIT_FILE)
956
998
  out = []
957
999
  for r in read_jsonl(path):
@@ -959,6 +1001,8 @@ def load_ledger(cg, *, node_id: str = None, limit: int = None) -> list:
959
1001
  continue
960
1002
  if node_id and r.get("node_id") != node_id:
961
1003
  continue
1004
+ if not _security.node_visible(cg, r.get("node_id")):
1005
+ continue
962
1006
  out.append(r)
963
1007
  return out[:int(limit)] if limit else out
964
1008