@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.
- package/README.md +588 -552
- package/codebuddy/CODEBUDDY.md +10 -10
- package/data/policy.json +27 -0
- package/docs/discipline/harnesses.yaml +31 -0
- package/docs/discipline/templates/full.md.tmpl +1 -1
- package/docs/discipline/templates/rules.mdc.tmpl +1 -1
- 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
- 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
- package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
- 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
- package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
- 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
- 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
- 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
- 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
- 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
- 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
- package/dsh/cordis-patch-profile-web.example.yml +35 -0
- package/lib/bridge.js +24 -2
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +66 -0
- package/lib/hooks.d.ts +31 -2
- package/lib/hooks.js +219 -16
- package/lib/init.d.ts +88 -0
- package/lib/init.js +302 -0
- package/lib/lib/prompt_safety.d.ts +52 -1
- package/lib/lib/prompt_safety.js +76 -1
- package/md_cg/audit.py +652 -379
- package/md_cg/bench6_arms.py +1 -1
- package/md_cg/bench_p0.py +1 -1
- package/md_cg/branches.py +2 -2
- package/md_cg/ccgc.py +1071 -1006
- package/md_cg/chain.py +1 -1
- package/md_cg/consistency.py +97 -9
- package/md_cg/consolidate.py +34 -9
- package/md_cg/crypto.py +59 -26
- package/md_cg/docindex.py +16 -1
- package/md_cg/evidence.py +585 -582
- package/md_cg/evolution.py +17 -1
- package/md_cg/export.py +1 -1
- package/md_cg/forgetting.py +309 -13
- package/md_cg/fsutil.py +454 -8
- package/md_cg/identity.py +3 -3
- package/md_cg/insight.py +24 -3
- package/md_cg/linkref.py +1 -1
- package/md_cg/logref.py +327 -0
- package/md_cg/mcp_server.py +4152 -3818
- package/md_cg/mdcg.py +4208 -3462
- package/md_cg/mdcos.py +4491 -4137
- package/md_cg/mreview/pipeline.py +15 -1
- package/md_cg/nodefile.py +639 -575
- package/md_cg/protect.py +158 -11
- package/md_cg/protocol.py +41 -2
- package/md_cg/provenance.py +22 -4
- package/md_cg/reach.py +4 -4
- package/md_cg/readcache.py +76 -15
- package/md_cg/reconcile.py +1 -1
- package/md_cg/refindex.py +246 -37
- package/md_cg/refine.py +1 -1
- package/md_cg/review_cli.py +49 -4
- package/md_cg/routing.py +32 -5
- package/md_cg/run_tests.py +17 -0
- package/md_cg/scrub.py +30 -3
- package/md_cg/security.py +47 -1
- package/md_cg/self_state.py +5 -5
- package/md_cg/sources.py +23 -6
- package/md_cg/srcindex.py +352 -0
- package/md_cg/stg.py +47 -3
- package/md_cg/subgraph.py +2 -2
- package/md_cg/sustain.py +1299 -1168
- package/md_cg/tasks.py +469 -470
- package/md_cg/test_b1_auto_id_multiproc.py +277 -0
- package/md_cg/test_b1b2_write_face.py +417 -0
- package/md_cg/test_b2_sensitivity_landing.py +223 -0
- package/md_cg/test_b3_merge_keeps_content.py +576 -0
- package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
- package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
- package/md_cg/test_c3_transient_read_negative.py +793 -0
- package/md_cg/test_c8_search_rrf_gates.py +502 -0
- package/md_cg/test_ccg_form_parity.py +188 -0
- package/md_cg/test_ccgc.py +28 -3
- package/md_cg/test_govern_directread.py +17 -4
- package/md_cg/test_h2_session_view_norm.py +233 -0
- package/md_cg/test_h4_sustain_snapshot.py +1377 -0
- package/md_cg/test_hive_ingest.py +285 -285
- package/md_cg/test_index_crossprocess_reload.py +301 -0
- package/md_cg/test_issue39_utf8_stdio.py +307 -16
- package/md_cg/test_issue43_default_policy.py +865 -0
- package/md_cg/test_legacy_p3_node_id_type.py +375 -0
- package/md_cg/test_linkref.py +10 -3
- package/md_cg/test_lock.py +2 -2
- package/md_cg/test_logref.py +1109 -0
- package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
- package/md_cg/test_m3_h9_semantic_guard.py +525 -0
- package/md_cg/test_mr_m2.py +15 -3
- package/md_cg/test_n130_verify_falsified_protect.py +1 -1
- package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
- package/md_cg/test_n176_link_trust.py +221 -0
- package/md_cg/test_n178_units_jobid_gate.py +212 -0
- package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
- package/md_cg/test_n195_writepath_reload.py +283 -0
- package/md_cg/test_n196_stale_gate_skip.py +169 -0
- package/md_cg/test_n197_n208_write_face_gates.py +471 -0
- package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
- package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
- package/md_cg/test_n201_proposal_visibility.py +382 -0
- package/md_cg/test_n202_session_notes_visibility.py +461 -0
- package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
- package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
- package/md_cg/test_n209_verify_write_face_gates.py +461 -0
- package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
- package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
- package/md_cg/test_n225_nonobject_load.py +1110 -0
- package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
- package/md_cg/test_neg_condition_hits.py +333 -0
- package/md_cg/test_neg_tail_honesty.py +663 -0
- package/md_cg/test_none_id_write_guard.py +166 -0
- package/md_cg/test_opt_batch1_md_cg.py +451 -0
- package/md_cg/test_p1.py +5 -5
- package/md_cg/test_p11_consistency.py +161 -12
- package/md_cg/test_p26_refindex.py +2 -2
- package/md_cg/test_p27_docindex.py +777 -774
- package/md_cg/test_p28_refcheck.py +740 -13
- package/md_cg/test_p29_session_ingest_export.py +2 -2
- package/md_cg/test_p2_mcp.py +12 -1
- package/md_cg/test_p30_maintain.py +33 -2
- package/md_cg/test_p31_insight.py +1 -0
- package/md_cg/test_p9c_dedup_hints.py +276 -0
- package/md_cg/test_policy_required_ccg.py +426 -0
- package/md_cg/test_protocol.py +22 -0
- package/md_cg/test_rank_parity_score_mode.py +516 -0
- package/md_cg/test_read_face_input_gates.py +363 -0
- package/md_cg/test_read_face_semantics.py +489 -0
- package/md_cg/test_recall_face_guards.py +812 -0
- package/md_cg/test_rejected_credential_forms.py +188 -0
- package/md_cg/test_rejected_redact.py +146 -0
- package/md_cg/test_retr_s1b.py +2 -2
- package/md_cg/test_retr_s5.py +20 -7
- package/md_cg/test_review_cli_attribution.py +177 -0
- package/md_cg/test_review_cli_visibility.py +235 -0
- package/md_cg/test_review_conformance.py +21 -3
- package/md_cg/test_security_audit_v21.py +2 -2
- package/md_cg/test_server_version.py +67 -0
- package/md_cg/test_srcindex.py +171 -0
- package/md_cg/test_tenant_env_override_warn.py +63 -50
- package/md_cg/test_token_lowercase_form.py +315 -0
- package/md_cg/test_v21r1_package_version.py +310 -0
- package/md_cg/test_writepipe.py +10 -4
- package/md_cg/tokens.py +225 -95
- package/md_cg/tool_face.py +4 -4
- package/md_cg/trust.py +49 -5
- package/md_cg/units.py +204 -6
- package/md_cg/weights.py +4 -4
- package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
- package/md_cg/writelimit.py +42 -8
- package/md_cg/writepipe.py +651 -554
- package/package.json +8 -2
- package/skills/plugin.json +1 -1
- package/src/bridge.ts +25 -2
- package/src/cli.ts +65 -0
- package/src/hooks.ts +229 -16
- package/src/init.ts +361 -0
- package/src/lib/prompt_safety.ts +88 -1
- 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
|
|
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
|
-
#
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
-
#
|
|
434
|
-
#
|
|
435
|
-
# rec["tenant"]
|
|
436
|
-
#
|
|
437
|
-
#
|
|
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
|
-
|
|
443
|
-
f"[mdcg-tokens]
|
|
444
|
-
f"
|
|
445
|
-
f"
|
|
446
|
-
f"
|
|
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=
|
|
561
|
+
tenant=_eff_tenant,
|
|
449
562
|
actor=rec.get("actor") or role,
|
|
450
|
-
clearance=
|
|
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)
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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:
|
package/md_cg/tool_face.py
CHANGED
|
@@ -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
|
|
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
|
|