@furongjun1999/dsh-memory 0.6.0 → 0.7.0
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 +618 -572
- package/codebuddy/CODEBUDDY.md +10 -10
- package/data/policy.json +27 -0
- package/docs/README.md +1 -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/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/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/207/272/350/264/247/351/235/242/345/206/222/347/203/237_/350/277/233/350/264/247/351/227/250/347/246/201_v1.0.md +460 -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//345/217/221/345/270/20308_/350/207/252/350/277/255/344/273/243/344/270/216/347/235/241/347/234/240_/345/233/276/346/243/200/347/264/242/350/267/257/344/270/216/346/235/203/351/207/215_v1.0.md +97 -0
- package/docs/eval//345/275/222/344/270/200/345/261/202/347/274/272/347/234/201/347/277/273/345/205/263_/344/277/256/345/244/215/350/256/260/345/275/225_v1.0.md +458 -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_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/347/256/227/346/263/225/345/217/243/345/276/204/345/257/271/347/205/247_v0.1.md +136 -11
- 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 +52 -2
- 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 +100 -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 +57 -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//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +183 -0
- 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//347/235/241/347/234/240/344/270/216/350/207/252/350/277/255/344/273/243_/345/212/237/350/203/275/344/274/230/345/214/226/350/256/276/350/256/241_v0.4.md +547 -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/lib/bridge.js +24 -2
- package/lib/hooks.d.ts +31 -2
- package/lib/hooks.js +219 -16
- package/lib/init.js +16 -1
- 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_e2e_locomo_qa.py +11 -4
- 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 +48 -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/evolution.py +17 -1
- package/md_cg/export.py +1 -1
- package/md_cg/forgetting.py +309 -13
- package/md_cg/freshness.py +511 -0
- package/md_cg/fsutil.py +454 -8
- package/md_cg/generation.py +409 -0
- package/md_cg/hotcache.py +4 -1
- 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 +4261 -3818
- package/md_cg/mdcg.py +4695 -3547
- package/md_cg/mdcos.py +4672 -4159
- package/md_cg/mreview/pipeline.py +15 -1
- package/md_cg/nodefile.py +713 -576
- 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/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/semantic/canonical.py +22 -0
- package/md_cg/semantic/unify.py +73 -22
- package/md_cg/semantic/unify_fixture.json +25 -0
- package/md_cg/sleep.py +1297 -0
- 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 +1436 -1168
- package/md_cg/tasks.py +469 -470
- package/md_cg/test_auto_defaults.py +424 -0
- 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_boundary_hit.py +410 -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_en_pipeline.py +22 -13
- package/md_cg/test_generation_guard.py +352 -0
- 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 +1387 -0
- package/md_cg/test_hive_ingest.py +285 -285
- 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 +519 -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_n230_dirty_replay.py +375 -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 +4 -3
- 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_p2_six_elements.py +463 -0
- package/md_cg/test_p30_maintain.py +33 -2
- package/md_cg/test_p31_insight.py +1 -0
- package/md_cg/test_p3_legacy_closure.py +433 -0
- package/md_cg/test_p4_freshness.py +680 -0
- package/md_cg/test_p8_subgraph_chain.py +14 -1
- 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 +522 -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_conformance.py +21 -3
- package/md_cg/test_security_audit_v21.py +2 -2
- package/md_cg/test_semantic_canonical.py +5 -3
- package/md_cg/test_server_version.py +67 -0
- package/md_cg/test_sleep.py +611 -0
- package/md_cg/test_sleep_p1.py +784 -0
- package/md_cg/test_srcindex.py +171 -0
- package/md_cg/test_tenant_env_override_warn.py +63 -50
- package/md_cg/test_time_core_lint.py +968 -0
- package/md_cg/test_token_lowercase_form.py +315 -0
- package/md_cg/test_unify_default_off.py +701 -0
- package/md_cg/test_unify_scope.py +182 -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/aeis_core/time_core.py +8 -0
- 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 +4 -2
- package/skills/plugin.json +1 -1
- package/src/bridge.ts +25 -2
- package/src/hooks.ts +229 -16
- package/src/init.ts +15 -1
- package/src/lib/prompt_safety.ts +88 -1
- package/utf8_boot.py +237 -0
- package/zcode/AGENTS.md +10 -10
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""N139 守卫(第 5 轮,2026-09-27):密钥库损坏/漂移回落 fail-closed——
|
|
3
|
+
provision_dek 不得据空表静默重签整份覆盖。
|
|
4
|
+
|
|
5
|
+
留档复测(v8 N139 首报 → v9/v15/v16 历轮成立 → 本轮第 5 次,medium 维持):
|
|
6
|
+
|
|
7
|
+
病灶①(crypto.py:263-274):_load_keys 遇「文件在但 JSON 截断/损坏」
|
|
8
|
+
(ValueError)或「OSError 不可读」或「顶层 dict 但 envelopes 缺失/非 dict」
|
|
9
|
+
(版本漂移)时 `except (ValueError, OSError): pass` 静默回落空 envelopes
|
|
10
|
+
——零告警零标记。
|
|
11
|
+
病灶②(:290-314):provision_dek 据空表走重签分支生成新 DEK 并 _save_keys
|
|
12
|
+
(:313,唯一写点;rotate_dek :343-345 委托之)整份覆盖写回——旧信封无痕
|
|
13
|
+
抹除、旧密文永久不可解,全程无备份无审计无 stderr。
|
|
14
|
+
|
|
15
|
+
红守卫(哑 KEK=os.urandom(32) + 临时目录,确定性、零真实状态):
|
|
16
|
+
R1 截断 _keys.json → provision_dek 必失败(LockedError)+ stderr 告警
|
|
17
|
+
+ 原文件字节不被覆盖;
|
|
18
|
+
R2 读面零闸变:unwrap_dek 对损坏文件照旧按无信封抛 LockedError;
|
|
19
|
+
R3 fresh install(缺文件 ≠ 损坏)零闸变:provision 照常签发;
|
|
20
|
+
R4 完好文件幂等路径零误伤(同 (tenant, actor) 重进 → 原 DEK 原样);
|
|
21
|
+
R5 版本漂移面(合法 JSON 但 envelopes 非映射)同闸;
|
|
22
|
+
R6 生产面(mdcos.MdCGSecure 构造):损坏文件下 _crypto_error 留痕、
|
|
23
|
+
dek=None、坏文件字节原样(fail-closed 降级不崩进程)。
|
|
24
|
+
|
|
25
|
+
运行:python -m md_cg.test_n139_dek_provision_failclosed
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import io
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
|
|
36
|
+
from . import crypto
|
|
37
|
+
from .mdcos import MdCGSecure
|
|
38
|
+
|
|
39
|
+
PASS = FAIL = 0
|
|
40
|
+
FAILS = []
|
|
41
|
+
MARK = "密钥库损坏" # stderr 告警/拒绝消息稳定锚点
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check(name, cond, detail=""):
|
|
45
|
+
global PASS, FAIL
|
|
46
|
+
if cond:
|
|
47
|
+
PASS += 1
|
|
48
|
+
print(" OK " + name)
|
|
49
|
+
else:
|
|
50
|
+
FAIL += 1
|
|
51
|
+
FAILS.append(name)
|
|
52
|
+
print(" FAIL " + name + " " + str(detail)[:110])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _provision(root, kek, tenant, actor, **kw):
|
|
56
|
+
"""provision_dek 捕获异常,返回 (dek|None, exc|None)。"""
|
|
57
|
+
try:
|
|
58
|
+
return crypto.provision_dek(root, kek, tenant, actor, **kw), None
|
|
59
|
+
except crypto.CryptoError as e:
|
|
60
|
+
return None, e
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unwrap(root, kek, tenant, actor):
|
|
64
|
+
try:
|
|
65
|
+
return crypto.unwrap_dek(root, kek, tenant, actor), None
|
|
66
|
+
except crypto.CryptoError as e:
|
|
67
|
+
return None, e
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read(p):
|
|
71
|
+
with open(p, "rb") as f:
|
|
72
|
+
return f.read()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main():
|
|
76
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
77
|
+
tmp = tempfile.mkdtemp(prefix="mdcg_n139_")
|
|
78
|
+
kek = os.urandom(32) # 哑 KEK:仅本守卫用,绝不触真实主密钥
|
|
79
|
+
try:
|
|
80
|
+
# ---------- R1 截断文件 → provision fail-closed ----------
|
|
81
|
+
print("== R1 截断 _keys.json → provision_dek 必失败且原文件不被覆盖 ==")
|
|
82
|
+
root1 = os.path.join(tmp, "r1")
|
|
83
|
+
os.makedirs(root1)
|
|
84
|
+
dek_a, e_a = _provision(root1, kek, "tenantA", "actorA")
|
|
85
|
+
check("R1a 基线:正常签发 DEK", dek_a is not None and e_a is None,
|
|
86
|
+
f"dek={'有' if dek_a else '无'} exc={e_a}")
|
|
87
|
+
kf = crypto.keys_path(root1)
|
|
88
|
+
good = _read(kf)
|
|
89
|
+
with open(kf, "wb") as f:
|
|
90
|
+
f.write(b'{"v": 1, "alg": "cha') # 截断 JSON(哑损坏样本)
|
|
91
|
+
corrupt = _read(kf)
|
|
92
|
+
check("R1b 损坏样本在位(截断 JSON)",
|
|
93
|
+
corrupt != good and len(corrupt) < len(good),
|
|
94
|
+
f"len={len(corrupt)} vs {len(good)}")
|
|
95
|
+
buf = io.StringIO()
|
|
96
|
+
with contextlib.redirect_stderr(buf):
|
|
97
|
+
dek_b, e_b = _provision(root1, kek, "tenantB", "actorB")
|
|
98
|
+
check("R1c 损坏标记下 provision 被拒(LockedError 且含锚点)",
|
|
99
|
+
dek_b is None and e_b is not None and MARK in str(e_b),
|
|
100
|
+
f"dek={'有' if dek_b else '无'} "
|
|
101
|
+
f"exc={type(e_b).__name__}: {str(e_b)[:80]}")
|
|
102
|
+
check("R1d stderr 告警开口(含锚点与路径)",
|
|
103
|
+
MARK in buf.getvalue() and kf in buf.getvalue(),
|
|
104
|
+
buf.getvalue().strip()[:100])
|
|
105
|
+
check("R1e 原文件字节不被覆盖(无痕抹除被阻断)",
|
|
106
|
+
_read(kf) == corrupt,
|
|
107
|
+
f"len={len(_read(kf))} vs {len(corrupt)}")
|
|
108
|
+
|
|
109
|
+
# ---------- R2 读面零闸变 ----------
|
|
110
|
+
print("== R2 读面零闸变:unwrap 照旧按无信封抛 ==")
|
|
111
|
+
_, e2 = _unwrap(root1, kek, "tenantA", "actorA")
|
|
112
|
+
check("R2 损坏文件下 unwrap 仍抛 LockedError(不假装能解密)",
|
|
113
|
+
e2 is not None, f"exc={type(e2).__name__ if e2 else None}")
|
|
114
|
+
|
|
115
|
+
# ---------- R3 fresh install 零闸变 ----------
|
|
116
|
+
print("== R3 fresh install(缺文件≠损坏)零闸变 ==")
|
|
117
|
+
root3 = os.path.join(tmp, "r3")
|
|
118
|
+
os.makedirs(root3)
|
|
119
|
+
dek3, e3 = _provision(root3, kek, "tenantC", "actorC")
|
|
120
|
+
check("R3 缺文件时 provision 照常签发",
|
|
121
|
+
e3 is None and dek3 is not None
|
|
122
|
+
and os.path.isfile(crypto.keys_path(root3)),
|
|
123
|
+
f"dek={'有' if dek3 else '无'} exc={e3}")
|
|
124
|
+
|
|
125
|
+
# ---------- R4 完好文件幂等路径零误伤 ----------
|
|
126
|
+
print("== R4 完好文件幂等路径零误伤 ==")
|
|
127
|
+
dek4, e4 = _provision(root3, kek, "tenantC", "actorC")
|
|
128
|
+
check("R4 同 (tenant, actor) 重进 → 原 DEK 原样",
|
|
129
|
+
e4 is None and dek4 == dek3, f"exc={e4}")
|
|
130
|
+
|
|
131
|
+
# ---------- R5 版本漂移面 ----------
|
|
132
|
+
print("== R5 版本漂移(顶层合法但 envelopes 缺失/非 dict)同闸 ==")
|
|
133
|
+
root5 = os.path.join(tmp, "r5")
|
|
134
|
+
os.makedirs(root5)
|
|
135
|
+
drift = b'{"v": 0, "note": "legacy-without-envelopes"}'
|
|
136
|
+
with open(crypto.keys_path(root5), "wb") as f:
|
|
137
|
+
f.write(drift)
|
|
138
|
+
buf5 = io.StringIO()
|
|
139
|
+
with contextlib.redirect_stderr(buf5):
|
|
140
|
+
dek5, e5 = _provision(root5, kek, "tenantD", "actorD")
|
|
141
|
+
check("R5a 漂移文件下 provision 被拒(LockedError 且含锚点)",
|
|
142
|
+
dek5 is None and e5 is not None and MARK in str(e5),
|
|
143
|
+
f"dek={'有' if dek5 else '无'} "
|
|
144
|
+
f"exc={type(e5).__name__}: {str(e5)[:80]}")
|
|
145
|
+
check("R5b 漂移原文件字节不被覆盖",
|
|
146
|
+
_read(crypto.keys_path(root5)) == drift)
|
|
147
|
+
|
|
148
|
+
# ---------- R6 生产面(MdCGSecure 构造) ----------
|
|
149
|
+
print("== R6 生产面:MdCGSecure 构造遇损坏密钥库 fail-closed 降级 ==")
|
|
150
|
+
root6 = os.path.join(tmp, "r6")
|
|
151
|
+
os.makedirs(root6)
|
|
152
|
+
with open(crypto.keys_path(root6), "wb") as f:
|
|
153
|
+
f.write(b'{"v": 1, "alg": "cha')
|
|
154
|
+
corrupt6 = _read(crypto.keys_path(root6))
|
|
155
|
+
old_mk = os.environ.get(crypto.MASTER_ENV)
|
|
156
|
+
os.environ[crypto.MASTER_ENV] = os.urandom(32).hex() # 哑主密钥(env 路径)
|
|
157
|
+
try:
|
|
158
|
+
cg = MdCGSecure(root6, principal=None, master_key=bytes(range(32)))
|
|
159
|
+
check("R6a 构造不崩(LockedError ⊂ CryptoError 被既有 catch 捕获)",
|
|
160
|
+
cg is not None)
|
|
161
|
+
check("R6b _crypto_error 留痕且含锚点",
|
|
162
|
+
MARK in str(getattr(cg, "_crypto_error", "")),
|
|
163
|
+
f"_crypto_error={str(getattr(cg, '_crypto_error', ''))[:90]}")
|
|
164
|
+
check("R6c dek=None(fail-closed 降级,private/secret 写被拒)",
|
|
165
|
+
getattr(cg, "dek", "MISSING") is None,
|
|
166
|
+
f"dek={getattr(cg, 'dek', 'MISSING')}")
|
|
167
|
+
check("R6d 坏文件字节原样", _read(crypto.keys_path(root6)) == corrupt6)
|
|
168
|
+
finally:
|
|
169
|
+
if old_mk is None:
|
|
170
|
+
os.environ.pop(crypto.MASTER_ENV, None)
|
|
171
|
+
else:
|
|
172
|
+
os.environ[crypto.MASTER_ENV] = old_mk
|
|
173
|
+
finally:
|
|
174
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
175
|
+
|
|
176
|
+
print()
|
|
177
|
+
if FAILS:
|
|
178
|
+
print("FAILED: %d 项 → %s" % (len(FAILS), ", ".join(FAILS)))
|
|
179
|
+
return 1
|
|
180
|
+
print("ALL OK: %d 项(N139 密钥库损坏回落 fail-closed 守卫全绿)" % PASS)
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main())
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""N176 守卫:无令牌签章(self-reported)不得经 link 落 knowledge 层。
|
|
3
|
+
|
|
4
|
+
病灶(N176,high,2026-09-27):attest 无 verifier_token 时验证方身份是
|
|
5
|
+
**纯自报字符串**,AttestResult.verifier_identity="self-reported" 只是如实
|
|
6
|
+
标注——但 link(:709-798)/link_pending(:984-1000)与全仓对该字段**零消费**,
|
|
7
|
+
docstring「下游策略可据此拒绝」是未兑现空防线。record 角色(can_write=True、
|
|
8
|
+
ops_allow 含 ccg,md_cg/tokens.py:126-141)单 agent 即可:
|
|
9
|
+
compile → attest(verdict=ACCEPT, verifier=任意编造名, 无令牌) → link(apply=true)
|
|
10
|
+
编造名与 compiled_by 不同源(E041 只拦同源变体),未经验证的内容带「已签章」
|
|
11
|
+
状态直写 knowledge 层(_cg._write_node)并进入检索传播,绕过 review 编外复核
|
|
12
|
+
与裁定 A(LLM 不得自己验证自己)。
|
|
13
|
+
|
|
14
|
+
修复契约(N176 最小修,兑现下游消费):
|
|
15
|
+
link(apply=true) 对 verifier_identity != "token"(含历史 pending 缺字段)
|
|
16
|
+
一律 E052 拒绝写入;dry-run 不拦(附 E052 预告 warning);
|
|
17
|
+
有令牌路径与同源 E041 不动;link_pending 经委托同闸,拒绝后 pending 保留
|
|
18
|
+
(暂存非存档,待令牌验证方复核)。全量验证方信任协议留档不扩。
|
|
19
|
+
|
|
20
|
+
运行:python -m md_cg.test_n176_link_trust
|
|
21
|
+
环境纪律:全程哑令牌(临时 MDCG_TOKEN_FILE)+ 临时记忆根,不触真实令牌库。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
32
|
+
|
|
33
|
+
from md_cg import ccgc, nodefile, tokens
|
|
34
|
+
from md_cg.mdcos import MdCGOS
|
|
35
|
+
from md_cg.readcache import direct_read
|
|
36
|
+
|
|
37
|
+
PASS = 0
|
|
38
|
+
FAIL = 0
|
|
39
|
+
FAILS = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check(name, cond, detail=""):
|
|
43
|
+
global PASS, FAIL
|
|
44
|
+
if cond:
|
|
45
|
+
PASS += 1
|
|
46
|
+
print(" OK " + name)
|
|
47
|
+
else:
|
|
48
|
+
FAIL += 1
|
|
49
|
+
FAILS.append(name)
|
|
50
|
+
print(" FAIL " + name + " " + detail)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DIALOG = ("文件摄取分派:按扩展名把文件路由到对应摄取器"
|
|
54
|
+
"。该方法使用扩展名映射表进行路由"
|
|
55
|
+
"。此功能在本地文件系统中生效"
|
|
56
|
+
"。仅对本地可读文件生效"
|
|
57
|
+
"。2026-09-16"
|
|
58
|
+
"。通过回放测试验证"
|
|
59
|
+
"。二进制文件不适用")
|
|
60
|
+
|
|
61
|
+
SLOTS = {
|
|
62
|
+
"observation_position": {"value": "此功能在本地文件系统中生效"},
|
|
63
|
+
"observation_tool": {"value": "该方法使用扩展名映射表进行路由"},
|
|
64
|
+
"time_window": {"value": [0.0, 9999999999.0]},
|
|
65
|
+
"existence_constraint": {"value": "仅对本地可读文件生效"},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
MARKS = {
|
|
69
|
+
"功能名": {"value": "文件摄取分派:按扩展名把文件路由到对应摄取器"},
|
|
70
|
+
"子功能": {"value": "按扩展名把文件路由到对应摄取器"},
|
|
71
|
+
"执行": {"value": "使用扩展名映射表进行路由"},
|
|
72
|
+
"验证方式": {"value": "回放测试", "basis": "test"},
|
|
73
|
+
"不适用条件": {"value": "二进制文件不适用"},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
MARK_LINE = "功能名:文件摄取分派:按扩展名把文件路由到对应摄取器"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _seed(root, node):
|
|
80
|
+
cg = MdCGOS(root)
|
|
81
|
+
cg.add(node, "# 功能名:待编译节点\n\n占位正文。\n", layer="knowledge")
|
|
82
|
+
cg.flush()
|
|
83
|
+
cg.rebuild_index()
|
|
84
|
+
return cg
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _disk(cg, node):
|
|
88
|
+
entry = (cg.index.get("nodes") or {}).get(node)
|
|
89
|
+
return direct_read(cg, entry)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main():
|
|
93
|
+
global PASS, FAIL
|
|
94
|
+
tmp = tempfile.mkdtemp(prefix="mdcg_n176_")
|
|
95
|
+
tok_env = os.path.join(tmp, "tokens.json")
|
|
96
|
+
old_tf = os.environ.get(tokens.TOKEN_FILE_ENV)
|
|
97
|
+
os.environ[tokens.TOKEN_FILE_ENV] = tok_env
|
|
98
|
+
try:
|
|
99
|
+
# ---------- X1 攻击前提:record 权限面(tokens.py:126-141) ----------
|
|
100
|
+
print("== X1 record 角色权限面(哑令牌临时库)==")
|
|
101
|
+
rec = tokens.issue("record", actor="worker-a", path=tok_env)
|
|
102
|
+
check("X1a record 令牌可签发(哑临时令牌文件)", bool(rec.get("token")))
|
|
103
|
+
spec = tokens.ROLE_SPECS.get("record") or {}
|
|
104
|
+
check("X1b record can_write=True", spec.get("can_write") is True)
|
|
105
|
+
check("X1c record ops_allow 含 ccg", "ccg" in (spec.get("ops_allow") or []))
|
|
106
|
+
pr = tokens.verify_token(rec["token"])
|
|
107
|
+
check("X1d 令牌主体=worker-a", getattr(pr, "actor", "") == "worker-a")
|
|
108
|
+
|
|
109
|
+
root = os.path.join(tmp, "root")
|
|
110
|
+
cg = _seed(root, "tgt")
|
|
111
|
+
|
|
112
|
+
# ---------- X2 攻击复现:编造名无令牌 → link(apply=true) 必须拒 ----------
|
|
113
|
+
print("== X2 攻击复现:编造名无令牌签章 → link(apply=true) 必须拒 ==")
|
|
114
|
+
r = ccgc.compile_dialog(DIALOG, "tgt", "worker-a",
|
|
115
|
+
slots=SLOTS, marks=MARKS, cg=cg)
|
|
116
|
+
check("X2a compile success(攻击面成立)", r.success,
|
|
117
|
+
"errors=" + "; ".join(r.errors[:2]))
|
|
118
|
+
at = ccgc.attest("tgt", ccgc.ACCEPT, "hive:reflect:fake-unit", "worker-a",
|
|
119
|
+
evidence="编外复核(伪造)", cg=cg)
|
|
120
|
+
check("X2b 编造名过 attest(E041 只拦同源,病灶如实留证)",
|
|
121
|
+
at.ok and at.verifier_identity == "self-reported",
|
|
122
|
+
"ok=%s id=%s err=%s" % (at.ok, at.verifier_identity, at.error[:60]))
|
|
123
|
+
l = ccgc.link(r, at, cg=cg, apply=True)
|
|
124
|
+
check("X2c link(apply=true) 拒绝且 written==0(E052)",
|
|
125
|
+
(not l.ok) and l.written == 0
|
|
126
|
+
and any(str(e).startswith("E052") for e in l.errors),
|
|
127
|
+
"ok=%s written=%s errors=%s" % (l.ok, l.written, l.errors[:2]))
|
|
128
|
+
_fm1, c1 = _disk(cg, "tgt")
|
|
129
|
+
check("X2d knowledge 层未被写入(六要素仍不全)",
|
|
130
|
+
not nodefile.ccg_completeness(c1)["complete"],
|
|
131
|
+
"complete=" + str(nodefile.ccg_completeness(c1)))
|
|
132
|
+
check("X2e 未进入检索传播(盘上正文无六行)", MARK_LINE not in (c1 or ""))
|
|
133
|
+
|
|
134
|
+
# ---------- X3 link_pending 同闸(委托面,:984-1000) ----------
|
|
135
|
+
print("== X3 link_pending 同闸 ==")
|
|
136
|
+
sv = ccgc.save_pending(cg, r, at)
|
|
137
|
+
lp = ccgc.link_pending(cg, "tgt", apply=True, actor="worker-a")
|
|
138
|
+
check("X3a link_pending(apply=true) 拒绝(E052)",
|
|
139
|
+
(not lp.ok) and lp.written == 0
|
|
140
|
+
and any(str(e).startswith("E052") for e in lp.errors),
|
|
141
|
+
"ok=%s errors=%s" % (lp.ok, lp.errors[:2]))
|
|
142
|
+
check("X3b pending 保留(暂存非存档,可待令牌复核)",
|
|
143
|
+
os.path.isfile(sv.get("path") or ""))
|
|
144
|
+
|
|
145
|
+
# ---------- X4 MCP 工具面同闸(compile→attest→link 全程) ----------
|
|
146
|
+
print("== X4 MCP 工具面同闸 ==")
|
|
147
|
+
cg2 = _seed(root, "tgt2")
|
|
148
|
+
from md_cg import mcp_server as mcp
|
|
149
|
+
c2 = mcp._ccg_call(cg2, {"ccg": {"action": "compile", "node_id": "tgt2",
|
|
150
|
+
"actor": "worker-a", "dialog": DIALOG,
|
|
151
|
+
"marks": MARKS, "slots": SLOTS}})
|
|
152
|
+
check("X4a MCP compile 成功并落 pending",
|
|
153
|
+
c2.get("ok") and (c2.get("pending") or {}).get("ok"))
|
|
154
|
+
c3 = mcp._ccg_call(cg2, {"ccg": {"action": "attest", "node_id": "tgt2",
|
|
155
|
+
"verdict": "ACCEPT",
|
|
156
|
+
"verifier": "hive:reflect:fake-unit2",
|
|
157
|
+
"compiled_by": "worker-a",
|
|
158
|
+
"evidence": "伪造复核"}})
|
|
159
|
+
check("X4b MCP attest 自报签章通过(病灶如实留证)",
|
|
160
|
+
c3.get("ok") and (c3.get("attest") or {}).get("verifier_identity")
|
|
161
|
+
== "self-reported",
|
|
162
|
+
json.dumps(c3.get("attest") or {}, ensure_ascii=False)[:120])
|
|
163
|
+
c4 = mcp._ccg_call(cg2, {"ccg": {"action": "link", "node_id": "tgt2",
|
|
164
|
+
"apply": True}})
|
|
165
|
+
check("X4c MCP link(apply=true) 拒绝(E052)",
|
|
166
|
+
(not c4.get("ok")) and "E052" in json.dumps(c4, ensure_ascii=False),
|
|
167
|
+
json.dumps(c4, ensure_ascii=False)[:160])
|
|
168
|
+
|
|
169
|
+
# ---------- X5 对照面:有令牌路径不误伤 ----------
|
|
170
|
+
print("== X5 对照面:有令牌签章正常落库 ==")
|
|
171
|
+
tk = tokens.issue("verify", actor="external-reviewer", path=tok_env)["token"]
|
|
172
|
+
at2 = ccgc.attest("tgt", ccgc.ACCEPT, "随意自报字符串会被覆盖", "worker-a",
|
|
173
|
+
evidence="真实编外验证方", cg=cg, verifier_token=tk)
|
|
174
|
+
check("X5a 令牌签章 identity=token 且覆盖自报",
|
|
175
|
+
at2.ok and at2.verifier == "external-reviewer"
|
|
176
|
+
and at2.verifier_identity == "token",
|
|
177
|
+
"ok=%s id=%s" % (at2.ok, at2.verifier_identity))
|
|
178
|
+
l2 = ccgc.link(r, at2, cg=cg, apply=True)
|
|
179
|
+
check("X5b 有令牌签章 link 落库六行", l2.ok and l2.written == 6,
|
|
180
|
+
"ok=%s written=%s errors=%s" % (l2.ok, l2.written, l2.errors[:2]))
|
|
181
|
+
_fm2, c2d = _disk(cg, "tgt")
|
|
182
|
+
check("X5c 六要素齐备(正常通路不受影响)",
|
|
183
|
+
nodefile.ccg_completeness(c2d)["complete"])
|
|
184
|
+
|
|
185
|
+
# ---------- X6 对照面:同源 E041 不动 ----------
|
|
186
|
+
print("== X6 对照面:同源 E041 不动 ==")
|
|
187
|
+
at3 = ccgc.attest("tgt", ccgc.ACCEPT, "Worker-A", "worker-a", cg=cg)
|
|
188
|
+
check("X6a 同源变体仍 E041", (not at3.ok) and at3.error.startswith("E041"),
|
|
189
|
+
"err=" + at3.error[:60])
|
|
190
|
+
|
|
191
|
+
# ---------- X7 dry-run 不拦(附 E052 预告) ----------
|
|
192
|
+
print("== X7 dry-run 不拦 ==")
|
|
193
|
+
cg3 = _seed(root, "tgt3")
|
|
194
|
+
r3 = ccgc.compile_dialog(DIALOG, "tgt3", "worker-a",
|
|
195
|
+
slots=SLOTS, marks=MARKS, cg=cg3)
|
|
196
|
+
at4 = ccgc.attest("tgt3", ccgc.ACCEPT, "hive:reflect:fake-unit3", "worker-a",
|
|
197
|
+
cg=cg3)
|
|
198
|
+
ld = ccgc.link(r3, at4, cg=cg3, apply=False)
|
|
199
|
+
check("X7a dry-run ok=True 且附 E052 预告 warning",
|
|
200
|
+
ld.ok and ld.dry_run and ld.written == 0
|
|
201
|
+
and any("E052" in w for w in ld.warnings),
|
|
202
|
+
"ok=%s warnings=%s" % (ld.ok, ld.warnings[:2]))
|
|
203
|
+
_fm3, c3d = _disk(cg3, "tgt3")
|
|
204
|
+
check("X7b dry-run 确未写入", MARK_LINE not in (c3d or ""))
|
|
205
|
+
finally:
|
|
206
|
+
if old_tf is None:
|
|
207
|
+
os.environ.pop(tokens.TOKEN_FILE_ENV, None)
|
|
208
|
+
else:
|
|
209
|
+
os.environ[tokens.TOKEN_FILE_ENV] = old_tf
|
|
210
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
211
|
+
|
|
212
|
+
print()
|
|
213
|
+
if FAILS:
|
|
214
|
+
print("FAILED: %d 项 → %s" % (len(FAILS), ", ".join(FAILS)))
|
|
215
|
+
return 1
|
|
216
|
+
print("ALL OK: %d 项(N176 无令牌签章不落 knowledge 层守卫全绿)" % PASS)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
sys.exit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · units 派发通道 job_id 结构闸守卫(N178,越池读写)
|
|
3
|
+
|
|
4
|
+
缺陷(修复前):`md_cg/units.py` 把**调用方给的** job_id 直接 `os.path.join`
|
|
5
|
+
进池路径,全程无结构校验——
|
|
6
|
+
· `poll(job_id, jobs)`(units.py:336)`d = os.path.join(jobs_dir(jobs), str(job_id or ""))`
|
|
7
|
+
→ `poll("../victim")`、`poll("<绝对路径>")` 都把 d 指到**池外**,随后
|
|
8
|
+
`RESULT_FILE`(result.json)被正常读出、`content` 原样进返回值
|
|
9
|
+
(:353-373)——越池读全文;`wait`(:386 循环 poll)与 `run` 同源继承。
|
|
10
|
+
· `submit(...)`(:305-306)`jd, job_id = jobs_dir(jobs), _job_id()` 后
|
|
11
|
+
`d = os.path.join(jd, job_id)` 直接 `os.makedirs(d)` + 落 spec.json/status.json
|
|
12
|
+
——job_id 来源一旦不是 `_job_id()`(同族 N145 池推导、外部派发器注入、
|
|
13
|
+
未来新增 id 源),就是「任意目录 + 生成名」的建目录/写文件原语。
|
|
14
|
+
同族家法已有:`hive/hive_mcp/mcp_server.py:133-143 _valid_job_id`(`_t_poll`
|
|
15
|
+
:481、`_t_kill` :515 用)与 `hive/src/job.rs:63-67 valid_job_id`(同为
|
|
16
|
+
「单路径分量」结构闸,2026-09-25 缺陷)。units 是本缺口的**漏网面**。
|
|
17
|
+
|
|
18
|
+
修复:`units._valid_job_id`(单点结构闸,deny-by-default 字符白名单)+
|
|
19
|
+
三处接线——`poll` 入口拒(`state="invalid_job_id"` 结构化拒、content 恒 None)、
|
|
20
|
+
`wait` 入口拒(**不得进轮询循环**:否则非法 id 要空转满 timeout_s)、
|
|
21
|
+
`submit` 落盘前拒(makedirs 之前)。
|
|
22
|
+
|
|
23
|
+
口径说明(为何**不**照抄 hive 的 "h" 前缀):`_valid_job_id` 家法要求
|
|
24
|
+
`startswith("h")`,而本模块对外的既有契约允许任意宿主派发器的 job 目录——
|
|
25
|
+
`md_cg/test_units_poll.py:47/53/57` 用的就是 "j_empty"/"j_good"/"j_fail"。
|
|
26
|
+
前缀收紧会误杀既有契约,故此处只保留「**不含路径成分**」的结构判据
|
|
27
|
+
(字符集 = **区块白名单** `hive/id_charset_blocks.txt`,与 hive 两侧共读同一份数据;
|
|
28
|
+
另允许结构字符 `_` `-` `.`;首尾非点;拒 `/` `\\` `:` NUL、控制/零宽/双向控制字符
|
|
29
|
+
与首尾空白),判别力等价于路径穿越防护,且 `_job_id()` 自身产物必过(下方 :[0]
|
|
30
|
+
自洽断言)。**2026-09-30 ①-(c) 判据换面后**本闸从「ASCII 白名单」换成「同一份区块
|
|
31
|
+
表」,放宽面带来的静默风险由逐条显式拒收挡住(`md_cg/units.py::_valid_job_id`)。
|
|
32
|
+
|
|
33
|
+
守卫(五组,全部临时池 + 临时目录,**绝不碰在役 jobs 目录**):
|
|
34
|
+
① 红转绿:池外造 `outside/result.json`(content="SECRET-OUTSIDE")→
|
|
35
|
+
`poll("../outside")` 修前**读到池外全文**(ok=True/content 泄漏);
|
|
36
|
+
修后结构化拒 `state="invalid_job_id"` 且 content 为 None;
|
|
37
|
+
② 绝对路径形态同拒(`os.path.join` 遇绝对路径会丢弃池前缀);
|
|
38
|
+
③ 拒形态清单:`..` / 分隔符 / 盘符·ADS(`C:x`) / NUL / 空串 / 纯点 / 尾点 /
|
|
39
|
+
空白 / 非 str,全拒且 content 恒 None;
|
|
40
|
+
④ `wait` 同闸且**立即返回**(不进轮询:无 timeout 键、无 waited_s);
|
|
41
|
+
⑤ `submit` 落盘前拒:注入 `_job_id` 返回 "../escaped"(证明落点无闸)——
|
|
42
|
+
修前在**池外**建出 `escaped/spec.json`;修后 ok=False 且该目录不存在;
|
|
43
|
+
并验证合法 submit→poll 闭环不回归(状态 pending、终态 False)。
|
|
44
|
+
|
|
45
|
+
运行:python -m md_cg.test_n178_units_jobid_gate
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import gc
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import shutil
|
|
53
|
+
import sys
|
|
54
|
+
import tempfile
|
|
55
|
+
import time
|
|
56
|
+
|
|
57
|
+
from . import units
|
|
58
|
+
|
|
59
|
+
PASS = FAIL = 0
|
|
60
|
+
FAILS = []
|
|
61
|
+
|
|
62
|
+
SECRET = "SECRET-OUTSIDE" # 池外 result.json 的正文(泄漏探针)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _clean(root):
|
|
66
|
+
"""尽力清理临时池:Windows 下句柄未释放会让 rmtree 静默失败,故重试并告警。"""
|
|
67
|
+
for _ in range(4):
|
|
68
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
69
|
+
if not os.path.exists(root):
|
|
70
|
+
return
|
|
71
|
+
gc.collect()
|
|
72
|
+
time.sleep(0.2)
|
|
73
|
+
print(" [warn] 临时目录未能清理(句柄占用?):%s" % os.path.basename(root))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check(name, cond, detail=""):
|
|
77
|
+
global PASS, FAIL
|
|
78
|
+
if cond:
|
|
79
|
+
PASS += 1
|
|
80
|
+
print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
|
|
81
|
+
else:
|
|
82
|
+
FAIL += 1
|
|
83
|
+
FAILS.append(f"{name} · {detail}" if detail else name)
|
|
84
|
+
print(f" [FAIL] {name}" + (f" · {detail}" if detail else ""))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mk_job(root, job_id, payload):
|
|
88
|
+
"""在 root 下造一个终态 job 目录(result.json + status.json)。"""
|
|
89
|
+
d = os.path.join(root, job_id)
|
|
90
|
+
os.makedirs(d, exist_ok=True)
|
|
91
|
+
with open(os.path.join(d, "result.json"), "w", encoding="utf-8") as f:
|
|
92
|
+
json.dump(payload, f, ensure_ascii=False)
|
|
93
|
+
with open(os.path.join(d, "status.json"), "w", encoding="utf-8") as f:
|
|
94
|
+
json.dump({"job_id": job_id, "state": "done", "model": "stub"}, f,
|
|
95
|
+
ensure_ascii=False)
|
|
96
|
+
return d
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _leaked(r) -> bool:
|
|
100
|
+
"""结果是否带出池外正文(越池读成立的判据)。"""
|
|
101
|
+
return SECRET in str(r.get("content") or "")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
print("=" * 68)
|
|
106
|
+
print("md_cg units · job_id 结构闸(N178 越池读写)")
|
|
107
|
+
print("=" * 68)
|
|
108
|
+
tmp = tempfile.mkdtemp(prefix="mdcg_units_n178_")
|
|
109
|
+
pool = os.path.join(tmp, "pool")
|
|
110
|
+
outside = os.path.join(tmp, "outside")
|
|
111
|
+
os.makedirs(pool, exist_ok=True)
|
|
112
|
+
_mk_job(outside, "", {"ok": True, "content": SECRET})
|
|
113
|
+
# 注:_mk_job(root=outside, job_id="") 落到 outside 本身(result.json 直接在池外根)
|
|
114
|
+
vj = getattr(units, "_valid_job_id", None)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# ---------- [0] 闸门自洽:合法形态必过(含既有测试契约) ----------
|
|
118
|
+
print("\n[0] 闸门存在且合法形态必过(不误杀既有契约)")
|
|
119
|
+
check("① _valid_job_id 已定义且可调用", callable(vj),
|
|
120
|
+
f"got {vj!r}(修前无此闸)")
|
|
121
|
+
for good in ("h1758000000000_1a2b", "j_empty", "j_good", "j_fail",
|
|
122
|
+
units._job_id()):
|
|
123
|
+
check(f"① 合法 id 放行:{good!r}", callable(vj) and vj(good) is True,
|
|
124
|
+
f"got {vj(good) if callable(vj) else None!r}")
|
|
125
|
+
|
|
126
|
+
# ---------- [1] 红转绿:相对穿越读池外 ----------
|
|
127
|
+
print("\n[1] poll 相对穿越(../outside)")
|
|
128
|
+
r1 = units.poll("../outside", jobs=pool)
|
|
129
|
+
check("① 结构化拒(state=invalid_job_id)",
|
|
130
|
+
r1.get("state") == "invalid_job_id", str(r1)[:140])
|
|
131
|
+
check("① 池外正文未进返回值(修前 content=SECRET-OUTSIDE)",
|
|
132
|
+
not _leaked(r1) and r1.get("content") is None,
|
|
133
|
+
f"content={r1.get('content')!r}")
|
|
134
|
+
check("① ok 为 False 且给出可归因 error",
|
|
135
|
+
r1.get("ok") is False and "非法" in str(r1.get("error") or ""),
|
|
136
|
+
str(r1.get("error"))[:120])
|
|
137
|
+
check("① 池内对照:同名相对形态不误伤(pool/<合法名>)",
|
|
138
|
+
units.poll("j_none", jobs=pool).get("state") == "missing",
|
|
139
|
+
str(units.poll("j_none", jobs=pool))[:100])
|
|
140
|
+
|
|
141
|
+
# ---------- [2] 绝对路径形态 ----------
|
|
142
|
+
print("\n[2] poll 绝对路径(os.path.join 遇绝对路径丢弃池前缀)")
|
|
143
|
+
r2 = units.poll(outside, jobs=pool)
|
|
144
|
+
check("② 结构化拒且未泄漏", r2.get("state") == "invalid_job_id"
|
|
145
|
+
and not _leaked(r2), str(r2)[:140])
|
|
146
|
+
|
|
147
|
+
# ---------- [3] 拒形态清单 ----------
|
|
148
|
+
print("\n[3] 拒形态清单(均须结构化拒且 content 恒 None)")
|
|
149
|
+
bad_ids = ["..", "../outside", r"..\outside", "a/b", "a\\b", "C:x", "h:x",
|
|
150
|
+
"a\x00b", "", ".", "...", "x.", "x..", " x", "x ", " ",
|
|
151
|
+
"h/../../x", "h/."]
|
|
152
|
+
for bad in bad_ids:
|
|
153
|
+
r = units.poll(bad, jobs=pool)
|
|
154
|
+
check(f"③ 拒 {bad!r}",
|
|
155
|
+
r.get("state") == "invalid_job_id" and r.get("content") is None,
|
|
156
|
+
f"state={r.get('state')!r} content={r.get('content')!r}")
|
|
157
|
+
for bad_ns in (123, None, 1.5, ["a"], {"a": 1}):
|
|
158
|
+
r = units.poll(bad_ns, jobs=pool)
|
|
159
|
+
check(f"③ 拒非 str {bad_ns!r}",
|
|
160
|
+
r.get("state") == "invalid_job_id" and r.get("content") is None,
|
|
161
|
+
f"state={r.get('state')!r}")
|
|
162
|
+
|
|
163
|
+
# ---------- [4] wait 同闸且不进轮询 ----------
|
|
164
|
+
print("\n[4] wait 入口同闸(fail-fast,不空转 timeout_s)")
|
|
165
|
+
r4 = units.wait("../outside", jobs=pool, timeout_s=0.3, poll_s=0.05)
|
|
166
|
+
check("④ 结构化拒且未泄漏", r4.get("state") == "invalid_job_id"
|
|
167
|
+
and not _leaked(r4), str(r4)[:140])
|
|
168
|
+
check("④ 未进轮询循环(无 timeout 键 / 无 waited_s)",
|
|
169
|
+
"timeout" not in r4 and r4.get("waited_s") is None, str(r4)[:140])
|
|
170
|
+
|
|
171
|
+
# ---------- [5] submit 落盘前拒 ----------
|
|
172
|
+
print("\n[5] submit 落盘前拒(注入生成器证明落点无闸)")
|
|
173
|
+
escaped = os.path.join(tmp, "escaped")
|
|
174
|
+
orig_gen = units._job_id
|
|
175
|
+
units._job_id = lambda: "../escaped" # 模拟非生成器的 id 来源
|
|
176
|
+
try:
|
|
177
|
+
r5 = units.submit(prompt="探针", role=units.REFLECT,
|
|
178
|
+
model="stub-model", jobs=pool)
|
|
179
|
+
finally:
|
|
180
|
+
units._job_id = orig_gen
|
|
181
|
+
check("⑤ 池外未建出 escaped/spec.json(修前会被建出)",
|
|
182
|
+
not os.path.isfile(os.path.join(escaped, "spec.json")),
|
|
183
|
+
f"ok={r5.get('ok')!r} spec存在={os.path.isfile(os.path.join(escaped, 'spec.json'))}")
|
|
184
|
+
check("⑤ 绿:submit 结构化拒",
|
|
185
|
+
r5.get("ok") is False and "非法" in str(r5.get("error") or ""),
|
|
186
|
+
str(r5.get("error"))[:120])
|
|
187
|
+
|
|
188
|
+
print("\n[5b] 合法 submit → poll 闭环不回归")
|
|
189
|
+
sub = units.submit(prompt="探针", role=units.REFLECT, model="stub-model",
|
|
190
|
+
jobs=pool)
|
|
191
|
+
check("⑤ 合法 submit 仍可用(ok=True 且 id 过闸)",
|
|
192
|
+
sub.get("ok") is True and callable(vj) and vj(sub.get("job_id")) is True,
|
|
193
|
+
str(sub)[:140])
|
|
194
|
+
check("⑤ job 目录落在池内",
|
|
195
|
+
str(sub.get("job_dir") or "").startswith(os.path.abspath(pool)),
|
|
196
|
+
str(sub.get("job_dir")))
|
|
197
|
+
p = units.poll(sub.get("job_id"), jobs=pool)
|
|
198
|
+
check("⑤ 合法 poll 读到 pending(不误伤正常路径)",
|
|
199
|
+
p.get("state") == "pending" and p.get("terminal") is False,
|
|
200
|
+
f"state={p.get('state')!r} terminal={p.get('terminal')!r}")
|
|
201
|
+
finally:
|
|
202
|
+
_clean(tmp)
|
|
203
|
+
|
|
204
|
+
print("\n" + "=" * 68)
|
|
205
|
+
print(f"PASS={PASS} FAIL={FAIL}")
|
|
206
|
+
if FAILS:
|
|
207
|
+
print("失败项:" + ";".join(FAILS))
|
|
208
|
+
return 0 if FAIL == 0 else 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
sys.exit(main())
|