@furongjun1999/dsh-memory 0.5.1 → 0.6.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 +22 -2
- 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/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//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//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/dsh/cordis-patch-profile-web.example.yml +35 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +66 -0
- package/lib/init.d.ts +88 -0
- package/lib/init.js +287 -0
- package/md_cg/evidence.py +585 -582
- package/md_cg/mcp_server.py +2 -2
- package/md_cg/mdcg.py +89 -4
- package/md_cg/mdcos.py +24 -2
- package/md_cg/review_cli.py +49 -4
- package/md_cg/test_index_crossprocess_reload.py +301 -0
- package/md_cg/test_none_id_write_guard.py +165 -0
- package/md_cg/test_review_cli_attribution.py +177 -0
- package/md_cg/test_review_cli_visibility.py +235 -0
- package/package.json +6 -1
- package/src/cli.ts +65 -0
- package/src/init.ts +347 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""test_none_id_write_guard —— None.md 脏节点写入面守卫(DSH 在役库实测缺陷 P3)
|
|
3
|
+
|
|
4
|
+
现场(DSH 端在役库实测):``knowledge/orphan/None.md`` 出现在检索与 route 候选中
|
|
5
|
+
参与打分。取证链(2026-09-26,临时库实弹复现):
|
|
6
|
+
|
|
7
|
+
1. 批次 24(2a57c2a2,2026-09-24)之前 ``add()`` 对 node_id **零校验**,
|
|
8
|
+
``path = os.path.join(d, f"{node_id}.md")`` 直拼——``add(None, ...)`` 直落
|
|
9
|
+
``None.md``(旧版直接源头,git 取证 ``git show 2a57c2a2^:md_cg/mdcg.py``)。
|
|
10
|
+
2. 现行代码 ``add(None)``/``add("")`` 已被 P0-1 白名单拒绝(mdcg.py _NODE_ID_RE),
|
|
11
|
+
**但字符串 ``"None"``(Python ``str(None)`` 的污染形态)形状合法照常放行**:
|
|
12
|
+
实弹复现 ``add("None", ...)`` → 生成 ``knowledge/orphan/None.md``。
|
|
13
|
+
``"null"``(TS/JS ``String(null)`` 形态)同族放行。这是现行写入面残留洞。
|
|
14
|
+
3. 盘上存量 ``None.md`` 经 ``_scan_nodes`` 全库扫描收录为 ``id="None"``,
|
|
15
|
+
``search``/route 候选参与打分(复现实测命中)——与 DSH 病理现象完全吻合。
|
|
16
|
+
|
|
17
|
+
修复(本守卫的红绿两态锚点):``md_cg/mdcg.py`` ``add()`` 写入边界把空语义值的
|
|
18
|
+
字符串化污染形态(整串精确等值 ``"None"`` / ``"null"``,**大小写敏感、不扩子串**)
|
|
19
|
+
结构化拒绝(ValueError,fail-closed)——add 是全部写入链(writepipe 执行器 /
|
|
20
|
+
review accept / identity / consolidate / branches)落盘的唯一真源边界,堵一处即
|
|
21
|
+
全链路堵死。``_move_layer`` 等直写点只对已在索引中的既有节点操作,不新造 id。
|
|
22
|
+
|
|
23
|
+
清理面(不在本守卫内,运维建议):存量 None.md 属历史脏数据——删除
|
|
24
|
+
``<root>/knowledge/orphan/None.md`` 文件后 ``rebuild_index()`` 即从索引与检索/
|
|
25
|
+
route 候选中消失([6] 的收录取证即清理有效性依据);勿用 ``add("None",...)``
|
|
26
|
+
覆写清理(修复后该形态被拒)。
|
|
27
|
+
|
|
28
|
+
隔离纪律:全程 ``tempfile.mkdtemp`` 临时库,MDCG_ROOT/MDCG_TOKEN_FILE 不参与
|
|
29
|
+
(库实例以显式 root 构造),不触真实令牌库与真实在役数据目录。
|
|
30
|
+
|
|
31
|
+
自检 floor:断言总数低于 FLOOR 视为失败——防「探针面失效 → 假绿」。
|
|
32
|
+
|
|
33
|
+
运行:python -X utf8 -m md_cg.test_none_id_write_guard
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
|
|
41
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
42
|
+
|
|
43
|
+
from md_cg.mdcos import MdCGOS # noqa: E402
|
|
44
|
+
|
|
45
|
+
FLOOR = 12 # 断言总数下限(防假绿)
|
|
46
|
+
passed = failed = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check(name, cond, detail=""):
|
|
50
|
+
global passed, failed
|
|
51
|
+
if cond:
|
|
52
|
+
passed += 1
|
|
53
|
+
print(" [PASS] " + name)
|
|
54
|
+
else:
|
|
55
|
+
failed += 1
|
|
56
|
+
print(" [FAIL] " + name + " " + str(detail)[:200])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _none_md_hits(root):
|
|
60
|
+
"""盘面上所有 None.md(大小写不敏感——Windows 盘面本就不区分)。"""
|
|
61
|
+
hits = []
|
|
62
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
63
|
+
for f in files:
|
|
64
|
+
if f.lower() == "none.md":
|
|
65
|
+
hits.append(os.path.relpath(os.path.join(dirpath, f), root)
|
|
66
|
+
.replace("\\", "/"))
|
|
67
|
+
return sorted(hits)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
root = tempfile.mkdtemp(prefix="mdcg_none_guard_")
|
|
72
|
+
try:
|
|
73
|
+
return _run(root)
|
|
74
|
+
finally:
|
|
75
|
+
import shutil
|
|
76
|
+
shutil.rmtree(root, ignore_errors=True) # 测毕清理(临时库纪律)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run(root):
|
|
80
|
+
cg = MdCGOS(root)
|
|
81
|
+
|
|
82
|
+
print("[1] 红形态:字符串 \"None\"(str(None) 污染形态)在写入边界拒绝")
|
|
83
|
+
rejected = False
|
|
84
|
+
try:
|
|
85
|
+
nid = cg.add("None", "污染形态内容 无条件")
|
|
86
|
+
check("add(\"None\") 被拒绝", False, "返回了 " + repr(nid))
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
rejected = True
|
|
89
|
+
check("add(\"None\") 被拒绝", True)
|
|
90
|
+
check("拒绝是结构化 ValueError 且消息点名形态",
|
|
91
|
+
"None" in str(e) and "node_id" in str(e), str(e)[:120])
|
|
92
|
+
check("盘面无 None.md 生成", _none_md_hits(root) == [], _none_md_hits(root))
|
|
93
|
+
check("索引无 id='None' 条目", "None" not in cg.index["nodes"])
|
|
94
|
+
|
|
95
|
+
print("[2] 红形态:字符串 \"null\"(String(null) 跨语言同族形态)")
|
|
96
|
+
try:
|
|
97
|
+
nid = cg.add("null", "跨语言污染形态内容 无条件")
|
|
98
|
+
check("add(\"null\") 被拒绝", False, "返回了 " + repr(nid))
|
|
99
|
+
except ValueError:
|
|
100
|
+
check("add(\"null\") 被拒绝", True)
|
|
101
|
+
check("盘面仍无 None.md/null.md", _none_md_hits(root) == [],
|
|
102
|
+
_none_md_hits(root))
|
|
103
|
+
|
|
104
|
+
print("[3] 防误杀:含 None/null 子串的合法 id 照常写入(只拒整串精确等值)")
|
|
105
|
+
ok1 = cg.add("NoneBot_1", "含 None 子串的合法 id 无条件")
|
|
106
|
+
ok2 = cg.add("nullify_test", "含 null 子串的合法 id 无条件")
|
|
107
|
+
check("add(\"NoneBot_1\") 正常", ok1 == "NoneBot_1")
|
|
108
|
+
check("add(\"nullify_test\") 正常", ok2 == "nullify_test")
|
|
109
|
+
|
|
110
|
+
print("[4] 既有语义不回退:None 本体 / falsy 仍结构化拒绝(P0-1)")
|
|
111
|
+
# 注:add(123) 是既有合法行为("123" 匹配白名单),不入守卫——非字符串
|
|
112
|
+
# 原值入索引快照属相邻缺陷(_scan_nodes/_load_index 混型键 sorted 崩),
|
|
113
|
+
# 超出本守卫目标,取证记录于修复报告。
|
|
114
|
+
for bad in (None, "", 0):
|
|
115
|
+
try:
|
|
116
|
+
cg.add(bad, "非法形态内容")
|
|
117
|
+
check("add(%r) 仍被拒绝" % (bad,), False, "竟返回成功")
|
|
118
|
+
except ValueError:
|
|
119
|
+
check("add(%r) 仍被拒绝(ValueError)" % (bad,), True)
|
|
120
|
+
|
|
121
|
+
print("[5] writepipe 链:node_id=\"None\" 不落盘(DEFER/BLINDSPOT 短路入队"
|
|
122
|
+
"或落盘前被 add 边界拒绝,两态皆 fail-closed)")
|
|
123
|
+
from md_cg import writepipe
|
|
124
|
+
pipe = writepipe.WritePipeline()
|
|
125
|
+
writepipe.install_default_gates(pipe)
|
|
126
|
+
committed = None
|
|
127
|
+
try:
|
|
128
|
+
out = pipe.execute(cg, {"content": "writepipe 污染形态", "node_id": "None",
|
|
129
|
+
"content_kind": "work_done",
|
|
130
|
+
"生效条件": "writepipe 守卫探针"})
|
|
131
|
+
committed = out.get("committed")
|
|
132
|
+
except ValueError:
|
|
133
|
+
committed = False # 落盘前被 add 边界拒绝(修复态)
|
|
134
|
+
check("writepipe 对 node_id='None' 不提交落盘", committed is not True,
|
|
135
|
+
repr(committed))
|
|
136
|
+
check("盘面无 None.md(writepipe 后复查)", _none_md_hits(root) == [],
|
|
137
|
+
_none_md_hits(root))
|
|
138
|
+
|
|
139
|
+
print("[6] 存量脏数据收录取证:盘上 None.md 被 _scan_nodes 收进索引(DSH 病理机理;"
|
|
140
|
+
"亦是清理建议的依据——删文件 + rebuild 即消失)")
|
|
141
|
+
d = os.path.join(root, "knowledge", "orphan")
|
|
142
|
+
os.makedirs(d, exist_ok=True)
|
|
143
|
+
with open(os.path.join(d, "None.md"), "w", encoding="utf-8") as f:
|
|
144
|
+
f.write("---\nid: None\nlayer: knowledge\nimportance: 0.5\n"
|
|
145
|
+
"confidence: 0.6\ncreated_at: 1\ntags: []\n---\n\n历史脏数据内容\n")
|
|
146
|
+
cg2 = MdCGOS(root) # 重开:走 _scan_nodes 全库扫描
|
|
147
|
+
cg2.rebuild_index()
|
|
148
|
+
check("历史 None.md 被扫描收录(机理复证)", "None" in cg2.index["nodes"])
|
|
149
|
+
res, _m = cg2.search_rrf("历史脏数据内容", k=10, judge=False)
|
|
150
|
+
check("检索候选命中 id='None'(病理复证)",
|
|
151
|
+
any(r[0]["id"] == "None" for r in res), [r[0]["id"] for r in res])
|
|
152
|
+
os.remove(os.path.join(d, "None.md")) # 清理后即消失(清理建议有效性)
|
|
153
|
+
cg3 = MdCGOS(root)
|
|
154
|
+
cg3.rebuild_index()
|
|
155
|
+
check("删文件 + rebuild 后 id='None' 从索引消失(清理有效)",
|
|
156
|
+
"None" not in cg3.index["nodes"])
|
|
157
|
+
|
|
158
|
+
check(f"断言总数不低于 FLOOR({passed} >= {FLOOR})", passed >= FLOOR)
|
|
159
|
+
|
|
160
|
+
print(f"\nnone_id_write_guard: {passed} 通过 / {failed} 失败")
|
|
161
|
+
return 0 if not failed else 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
sys.exit(main())
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · review_cli 归因守卫(P1 裁决面身份归因退化,DSH 端在役实测)
|
|
3
|
+
|
|
4
|
+
缺陷(修复前):`review_cli._cg` 构造 `Principal(actor="designer-cli", ...)`
|
|
5
|
+
不传 session → `security.Principal.__init__` 每次随机生成 `sess_<uuid12>`;
|
|
6
|
+
accept 落盘经 `MdCGSecure._attribution` 的 setdefault,writer 缺省成裁决者
|
|
7
|
+
designer-cli——于是 DSH 端实测:5 个裁决节点 5 个互不相同的随机会话 id、
|
|
8
|
+
writer 全被 designer-cli 覆盖,原始写入者(dsh-memory,提案记录 actor 有)
|
|
9
|
+
不可追溯。
|
|
10
|
+
|
|
11
|
+
守卫(修复前红):
|
|
12
|
+
① review_cli accept --session <s> → 落盘节点 frontmatter.session == <s>
|
|
13
|
+
② 落盘节点 frontmatter.writer == 提案记录的原始 actor(dsh-memory)
|
|
14
|
+
③ 裁决者身份不丢失:frontmatter.reviewer == "designer-cli"
|
|
15
|
+
④ 稳定性:同一 --session 两次裁决 → 两节点 session 一致
|
|
16
|
+
⑤ 兜底:未传 --session 时环境变量 MDCG_SESSION 生效
|
|
17
|
+
⑥ 仍无(两者皆缺省)→ stderr 告警一行 + 维持随机会话(现状兼容)
|
|
18
|
+
|
|
19
|
+
运行:python -m md_cg.test_review_cli_attribution
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
|
|
29
|
+
from . import nodefile
|
|
30
|
+
from .mdcos import MdCGOS, MdCGSecure
|
|
31
|
+
from .security import Principal
|
|
32
|
+
|
|
33
|
+
PASS = FAIL = 0
|
|
34
|
+
FAILS = []
|
|
35
|
+
|
|
36
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
|
|
38
|
+
DUMMY_KEY = "ab" * 32 # 哑主密钥:子进程绝不触真实 ~/.mdcg/master.key
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check(name, cond, detail=""):
|
|
42
|
+
global PASS, FAIL
|
|
43
|
+
if cond:
|
|
44
|
+
PASS += 1
|
|
45
|
+
print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
|
|
46
|
+
else:
|
|
47
|
+
FAIL += 1
|
|
48
|
+
FAILS.append(name)
|
|
49
|
+
print(f" [FAIL] {name} · {detail}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _child_env(extra=None, remove=("MDCG_SESSION", "DSH_SESSION_ID",
|
|
53
|
+
"MDCG_REDTEAM_REQUIRED")):
|
|
54
|
+
"""子进程环境:哑密钥 + UTF-8 + PYTHONPATH 指向本仓(纪律 15)。
|
|
55
|
+
|
|
56
|
+
remove 里的会话/红队环境变量先摘除——守卫 ①~④⑥ 必须证明 --session
|
|
57
|
+
是唯一来源,不能被外层环境偷跑。
|
|
58
|
+
"""
|
|
59
|
+
env = dict(os.environ)
|
|
60
|
+
for k in remove:
|
|
61
|
+
env.pop(k, None)
|
|
62
|
+
env["MDCG_MASTER_KEY"] = DUMMY_KEY
|
|
63
|
+
env["PYTHONUTF8"] = "1"
|
|
64
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
65
|
+
env["PYTHONPATH"] = REPO + os.pathsep + env.get("PYTHONPATH", "")
|
|
66
|
+
env.update(extra or {})
|
|
67
|
+
return env
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_cli(root, argv, extra=None):
|
|
71
|
+
"""跑一次 review_cli 子命令(包内入口 python -m md_cg.review_cli)。"""
|
|
72
|
+
return subprocess.run(
|
|
73
|
+
[sys.executable, "-m", "md_cg.review_cli"] + argv,
|
|
74
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
75
|
+
env=_child_env(extra), cwd=REPO)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _propose(root, nid, content):
|
|
79
|
+
"""以原始写入者身份(dsh-memory,DSH 端 agent 形态)入队一条提案。"""
|
|
80
|
+
cg = MdCGSecure(root, principal=Principal(
|
|
81
|
+
actor="dsh-memory", clearance="internal", can_write=True,
|
|
82
|
+
can_admin=False, role="recorder", session="sess_writer_orig"))
|
|
83
|
+
try:
|
|
84
|
+
return cg.propose(nid, content, layer="knowledge", tags=["attr"])
|
|
85
|
+
finally:
|
|
86
|
+
cg.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fm(root, nid):
|
|
90
|
+
"""直读节点文件 frontmatter(不经密级读闸——守卫只看归因字段)。"""
|
|
91
|
+
probe = MdCGOS(root, actor="probe")
|
|
92
|
+
try:
|
|
93
|
+
e = (probe.index.get("nodes") or {}).get(nid)
|
|
94
|
+
if not e:
|
|
95
|
+
return None
|
|
96
|
+
with open(os.path.join(root, e["path"]), encoding="utf-8") as f:
|
|
97
|
+
fm, _c = nodefile.loads(f.read())
|
|
98
|
+
return fm
|
|
99
|
+
finally:
|
|
100
|
+
probe.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main():
|
|
104
|
+
roots = []
|
|
105
|
+
try:
|
|
106
|
+
print("\n【①~③】accept --session → 落盘归因(session/writer/reviewer)")
|
|
107
|
+
root_a = tempfile.mkdtemp(prefix="cliattrib_a_")
|
|
108
|
+
roots.append(root_a)
|
|
109
|
+
pid = _propose(root_a, "attr-node-1", "P1 归因守卫:原始写入者应可追溯。")
|
|
110
|
+
r = _run_cli(root_a, ["accept", pid, "--session", "sess_decide_01",
|
|
111
|
+
"--root", root_a, "--reason", "归因守卫"])
|
|
112
|
+
check("review_cli accept --session 退出码 0", r.returncode == 0,
|
|
113
|
+
(r.stdout or r.stderr or "")[:200])
|
|
114
|
+
fm = _fm(root_a, "attr-node-1")
|
|
115
|
+
check("frontmatter.session == 传入 --session",
|
|
116
|
+
bool(fm) and fm.get("session") == "sess_decide_01",
|
|
117
|
+
str(fm and fm.get("session")))
|
|
118
|
+
check("frontmatter.writer == 提案原始 actor(dsh-memory)",
|
|
119
|
+
bool(fm) and fm.get("writer") == "dsh-memory",
|
|
120
|
+
str(fm and fm.get("writer")))
|
|
121
|
+
check("裁决者身份保留于 frontmatter.reviewer == designer-cli",
|
|
122
|
+
bool(fm) and fm.get("reviewer") == "designer-cli",
|
|
123
|
+
str(fm and fm.get("reviewer")))
|
|
124
|
+
|
|
125
|
+
print("\n【④】同一 --session 两次裁决 → 落盘归属一致")
|
|
126
|
+
pid2 = _propose(root_a, "attr-node-2",
|
|
127
|
+
"P1 归因守卫第二提案:同批裁决同会话。")
|
|
128
|
+
r2 = _run_cli(root_a, ["accept", pid2, "--session", "sess_decide_01",
|
|
129
|
+
"--root", root_a, "--reason", "归因守卫"])
|
|
130
|
+
fm2 = _fm(root_a, "attr-node-2")
|
|
131
|
+
check("第二节点 session 与第一节点一致",
|
|
132
|
+
r2.returncode == 0 and bool(fm) and bool(fm2)
|
|
133
|
+
and fm2.get("session") == fm.get("session") == "sess_decide_01",
|
|
134
|
+
"%r vs %r" % (fm and fm.get("session"), fm2 and fm2.get("session")))
|
|
135
|
+
|
|
136
|
+
print("\n【⑤】MDCG_SESSION 环境变量兜底")
|
|
137
|
+
pid3 = _propose(root_a, "attr-node-3",
|
|
138
|
+
"P1 归因守卫第三提案:环境变量会话。")
|
|
139
|
+
r3 = _run_cli(root_a, ["accept", pid3, "--root", root_a,
|
|
140
|
+
"--reason", "归因守卫"],
|
|
141
|
+
extra={"MDCG_SESSION": "sess_env_03"})
|
|
142
|
+
fm3 = _fm(root_a, "attr-node-3")
|
|
143
|
+
check("无 --session 时 MDCG_SESSION 生效",
|
|
144
|
+
r3.returncode == 0 and bool(fm3)
|
|
145
|
+
and fm3.get("session") == "sess_env_03",
|
|
146
|
+
str(fm3 and fm3.get("session")))
|
|
147
|
+
|
|
148
|
+
print("\n【⑥】两者皆缺省 → stderr 告警一行 + 维持随机会话")
|
|
149
|
+
pid4 = _propose(root_a, "attr-node-4",
|
|
150
|
+
"P1 归因守卫第四提案:随机会话告警。")
|
|
151
|
+
r4 = _run_cli(root_a, ["accept", pid4, "--root", root_a,
|
|
152
|
+
"--reason", "归因守卫"])
|
|
153
|
+
fm4 = _fm(root_a, "attr-node-4")
|
|
154
|
+
check("退出码 0(告警不阻断裁决)", r4.returncode == 0,
|
|
155
|
+
(r4.stdout or r4.stderr or "")[:200])
|
|
156
|
+
warn = [ln for ln in (r4.stderr or "").splitlines() if ln.strip()]
|
|
157
|
+
check("stderr 恰一行随机会话告警",
|
|
158
|
+
len(warn) == 1 and "随机会话" in warn[0], str(warn))
|
|
159
|
+
check("缺省仍落随机会话 id(sess_ 前缀,现状兼容)",
|
|
160
|
+
bool(fm4) and str(fm4.get("session", "")).startswith("sess_"),
|
|
161
|
+
str(fm4 and fm4.get("session")))
|
|
162
|
+
check("无 --session 时原始 writer 归因同样在位",
|
|
163
|
+
bool(fm4) and fm4.get("writer") == "dsh-memory",
|
|
164
|
+
str(fm4 and fm4.get("writer")))
|
|
165
|
+
finally:
|
|
166
|
+
for r_ in roots:
|
|
167
|
+
shutil.rmtree(r_, ignore_errors=True)
|
|
168
|
+
|
|
169
|
+
print("\n" + "=" * 64)
|
|
170
|
+
print(f"PASS={PASS} FAIL={FAIL}")
|
|
171
|
+
if FAILS:
|
|
172
|
+
print("失败项:" + ";".join(FAILS))
|
|
173
|
+
return 0 if FAIL == 0 else 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
sys.exit(main())
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · review_cli 裁决落盘跨进程可见性守卫(P1b autoflush 第二落点,DSH 端在役实测)
|
|
3
|
+
|
|
4
|
+
缺陷(修复前):`review_cli._cg` 构造 `MdCGSecure(_root(args), principal=p)`
|
|
5
|
+
不传 autoflush → 缺省 64。库的索引增量链路是「写入先经 `_stage` 入内存
|
|
6
|
+
`_dirty` 与内存索引,攒够 autoflush 条才 flush() 落分片日志 `_index_log/`」;
|
|
7
|
+
短命进程的**正常退出**另有 main finally `cg.close()` + 库层 atexit 双兜底
|
|
8
|
+
(mdcg.py `_LIVE_CGS`,2026-09-16 取证),但**被 kill / 崩溃退出时两者都不
|
|
9
|
+
执行**(兜底刻意只覆盖正常退出这一条路径)——裁决落盘的少量节点停在内存
|
|
10
|
+
索引、分片日志从未落,盘面节点文件在而索引链路断。DSH 端在役实测:裁决后
|
|
11
|
+
MCP 读面 read null。对照:mcp_server.py:3695-3702 同症状 2026-09-16 已修
|
|
12
|
+
(server 级 autoflush=1 兜底),review_cli 这个短命工具落点被遗漏——本修复
|
|
13
|
+
补齐,与 server 同款。
|
|
14
|
+
|
|
15
|
+
守卫(四组,全部临时库 + 哑主密钥,绝不触真实 ~/.mdcg):
|
|
16
|
+
① 真实在役入口:subprocess 跑 `python -m md_cg.review_cli accept`(正常
|
|
17
|
+
完成)→ 进程 B(全新 python 子进程 _load_index + read)断言业务节点与
|
|
18
|
+
审计记录节点均可见。
|
|
19
|
+
② kill 形态可见性:进程 A' 等价直接调库落盘 2 条(无任何显式 flush)后
|
|
20
|
+
`os._exit(0)`(模拟被杀:close/atexit 全部跳过)→ 进程 B 全新子进程
|
|
21
|
+
断言读回可见。
|
|
22
|
+
③ 落账契约(autoflush=1 的直接可观察效应,**修复前红**):② 的进程 A'
|
|
23
|
+
被杀退出后,盘面 `_index_log/` 必须已含其全部写入的索引记录——"写入即
|
|
24
|
+
落分片日志",与 mcp_server 的 server 级 autoflush=1 同一契约。
|
|
25
|
+
④ 同病工具面:evidence._open_cg / scripts/linkref_backfill /
|
|
26
|
+
scripts/migrate_restricted 同样补 autoflush=1(前两者行为断言
|
|
27
|
+
.autoflush==1,后两者源级断言构造语句带参)。
|
|
28
|
+
|
|
29
|
+
如实边界:本仓 HEAD 上 ①② 的读面断言在修复前已绿——正常路径由 09-16 的
|
|
30
|
+
review_decide 显式 flush(mdcos.py)+ close/atexit 兜底覆盖;kill 形态的
|
|
31
|
+
重开读面由批次39(2026-09-25)`_load_index` 指纹失配全扫自愈兜住。修复前
|
|
32
|
+
红的只有 ③ 的落账契约与在役旧读面(0.4.x 副本 `_load_index` 盲信快照)——
|
|
33
|
+
后者正是 DSH 端观察到 read null 的形态;本守卫把读面断言一并钉住,防上述
|
|
34
|
+
两层兜底未来被移除时旧症复发。
|
|
35
|
+
|
|
36
|
+
运行:python -m md_cg.test_review_cli_visibility
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import shutil
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
import tempfile
|
|
47
|
+
|
|
48
|
+
from .mdcg import ShardedLog
|
|
49
|
+
from .mdcos import MdCGSecure
|
|
50
|
+
from .security import Principal
|
|
51
|
+
|
|
52
|
+
PASS = FAIL = 0
|
|
53
|
+
FAILS = []
|
|
54
|
+
|
|
55
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
56
|
+
|
|
57
|
+
DUMMY_KEY = "ab" * 32 # 哑主密钥:子进程绝不触真实 ~/.mdcg/master.key
|
|
58
|
+
|
|
59
|
+
KILL_CHILD = r'''
|
|
60
|
+
import argparse, os, sys
|
|
61
|
+
sys.path.insert(0, sys.argv[2])
|
|
62
|
+
from md_cg import review_cli
|
|
63
|
+
# 等价直接调库落盘:走 review_cli 的生产构造路径 _cg(与 CLI 同一 autoflush),
|
|
64
|
+
# 随后裸 add 两条(无任何显式 flush)——覆盖 review_decide 之外的写入形态。
|
|
65
|
+
cg = review_cli._cg(argparse.Namespace(root=sys.argv[1]))
|
|
66
|
+
sys.stderr.write("staged=0 autoflush=%d\n" % cg.autoflush)
|
|
67
|
+
cg.add("vis-node-1", "P1b 可见性守卫节点一:kill 形态落账。", layer="knowledge",
|
|
68
|
+
tags=["vis:p1b"])
|
|
69
|
+
cg.add("vis-node-2", "P1b 可见性守卫节点二:kill 形态落账。", layer="knowledge",
|
|
70
|
+
tags=["vis:p1b"])
|
|
71
|
+
os._exit(0) # 硬退:close / atexit 一律不执行(模拟被杀)
|
|
72
|
+
'''
|
|
73
|
+
|
|
74
|
+
READ_CHILD = r'''
|
|
75
|
+
import sys
|
|
76
|
+
sys.path.insert(0, sys.argv[2])
|
|
77
|
+
from md_cg.mdcos import MdCGSecure
|
|
78
|
+
from md_cg.security import Principal
|
|
79
|
+
p = Principal(actor="vis-reader", clearance="internal", can_write=True,
|
|
80
|
+
can_admin=True, role="designer", auth_mode="test")
|
|
81
|
+
cg = MdCGSecure(sys.argv[1], principal=p)
|
|
82
|
+
ids = sys.argv[3].split(",")
|
|
83
|
+
print("READ " + " ".join("1" if cg.get(nid) is not None else "0" for nid in ids))
|
|
84
|
+
'''
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check(name, cond, detail=""):
|
|
88
|
+
global PASS, FAIL
|
|
89
|
+
if cond:
|
|
90
|
+
PASS += 1
|
|
91
|
+
print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
|
|
92
|
+
else:
|
|
93
|
+
FAIL += 1
|
|
94
|
+
FAILS.append(name)
|
|
95
|
+
print(f" [FAIL] {name} · {detail}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _child_env(extra=None):
|
|
99
|
+
"""子进程环境:哑密钥 + UTF-8 + PYTHONPATH 指向本仓(纪律 15)。"""
|
|
100
|
+
env = dict(os.environ)
|
|
101
|
+
env["MDCG_MASTER_KEY"] = DUMMY_KEY
|
|
102
|
+
env["PYTHONUTF8"] = "1"
|
|
103
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
104
|
+
env["PYTHONPATH"] = REPO + os.pathsep + env.get("PYTHONPATH", "")
|
|
105
|
+
env.update(extra or {})
|
|
106
|
+
return env
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_cli(root, argv):
|
|
110
|
+
"""跑一次 review_cli(包内入口 python -m md_cg.review_cli,真实在役入口)。"""
|
|
111
|
+
return subprocess.run(
|
|
112
|
+
[sys.executable, "-X", "utf8", "-m", "md_cg.review_cli"] + argv,
|
|
113
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
114
|
+
env=_child_env(), cwd=REPO)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _spawn(code, root, arg3=""):
|
|
118
|
+
"""独立子进程跑一段内嵌脚本(进程 A' 杀退形态 / 进程 B 全新读面)。"""
|
|
119
|
+
return subprocess.run(
|
|
120
|
+
[sys.executable, "-X", "utf8", "-c", code, root, REPO, arg3],
|
|
121
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
122
|
+
env=_child_env())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _propose(root, nid, content):
|
|
126
|
+
"""以原始写入者身份入队一条提案(复刻 DSH 端 agent 形态)。"""
|
|
127
|
+
cg = MdCGSecure(root, principal=Principal(
|
|
128
|
+
actor="dsh-memory", clearance="internal", can_write=True,
|
|
129
|
+
can_admin=False, role="recorder", session="sess_writer_orig"))
|
|
130
|
+
try:
|
|
131
|
+
return cg.propose(nid, content, layer="knowledge", tags=["vis:p1b"])
|
|
132
|
+
finally:
|
|
133
|
+
cg.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _log_ids(root):
|
|
137
|
+
"""读盘面分片日志的记录 id 集(落账契约的观察点)。"""
|
|
138
|
+
log_dir = os.path.join(root, "_index_log")
|
|
139
|
+
if not os.path.isdir(log_dir):
|
|
140
|
+
return None # 目录不存在 = 一条都没落过
|
|
141
|
+
return {rec.get("id") for rec in ShardedLog.read_all(log_dir)
|
|
142
|
+
if rec.get("id")}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main():
|
|
146
|
+
roots = []
|
|
147
|
+
try:
|
|
148
|
+
# ---------- ① 真实在役入口:review_cli accept → 进程 B 读 ----------
|
|
149
|
+
print("\n【①】subprocess 跑 review_cli accept(正常完成)→ 进程 B 读")
|
|
150
|
+
root_1 = tempfile.mkdtemp(prefix="clivis_1_")
|
|
151
|
+
roots.append(root_1)
|
|
152
|
+
pid = _propose(root_1, "vis-acc-node",
|
|
153
|
+
"P1b 可见性守卫:accept 落盘后其它进程立即可见。")
|
|
154
|
+
r = _run_cli(root_1, ["accept", pid, "--root", root_1,
|
|
155
|
+
"--reason", "可见性守卫"])
|
|
156
|
+
check("review_cli accept 退出码 0", r.returncode == 0,
|
|
157
|
+
(r.stdout or r.stderr or "")[:200])
|
|
158
|
+
b = _spawn(READ_CHILD, root_1, "vis-acc-node") # 全新子进程 _load_index+read
|
|
159
|
+
m = re.search(r"READ (\d)", b.stdout or "")
|
|
160
|
+
check("进程 B 读回业务节点 vis-acc-node 可见",
|
|
161
|
+
bool(m) and m.group(1) == "1",
|
|
162
|
+
(b.stdout or b.stderr or "")[:200])
|
|
163
|
+
pa = MdCGSecure(root_1, principal=Principal(
|
|
164
|
+
actor="vis-reader", clearance="internal", can_write=True,
|
|
165
|
+
can_admin=True, role="designer", auth_mode="test"))
|
|
166
|
+
try:
|
|
167
|
+
recs = pa.review_records(pid)
|
|
168
|
+
finally:
|
|
169
|
+
pa.close()
|
|
170
|
+
check("进程 B 侧审计记录节点可见(review_records 命中 1 条)",
|
|
171
|
+
len(recs) == 1, str(recs))
|
|
172
|
+
|
|
173
|
+
# ---------- ②③ kill 形态:调库落盘后 os._exit → 落账契约 + 读 ----------
|
|
174
|
+
print("\n【②③】进程 A' 调库落 2 条即 os._exit(模拟被杀)→ 落账契约 + 进程 B 读")
|
|
175
|
+
root_2 = tempfile.mkdtemp(prefix="clivis_2_")
|
|
176
|
+
roots.append(root_2)
|
|
177
|
+
a = _spawn(KILL_CHILD, root_2)
|
|
178
|
+
check("进程 A' 经生产构造 _cg(autoflush=1 回显)",
|
|
179
|
+
a.returncode == 0 and "autoflush=1" in (a.stderr or ""),
|
|
180
|
+
(a.stderr or "")[:200])
|
|
181
|
+
ids = _log_ids(root_2)
|
|
182
|
+
check("③ 落账契约:A' 被杀退出后 _index_log 已含全部 2 条写入记录"
|
|
183
|
+
"(autoflush=1 写毕即落日志;修复前 autoflush=64 未达阈值=红)",
|
|
184
|
+
ids is not None and {"vis-node-1", "vis-node-2"} <= ids,
|
|
185
|
+
"log_ids=%s" % sorted(ids or []))
|
|
186
|
+
b2 = _spawn(READ_CHILD, root_2, "vis-node-1,vis-node-2") # 全新子进程读
|
|
187
|
+
m2 = re.search(r"READ (\d) (\d)", b2.stdout or "")
|
|
188
|
+
check("② 进程 B 读回 kill 形态的两条节点均可见",
|
|
189
|
+
bool(m2) and m2.group(1) == "1" and m2.group(2) == "1",
|
|
190
|
+
(b2.stdout or b2.stderr or "")[:200])
|
|
191
|
+
|
|
192
|
+
# ---------- ④ 同病工具面:三处同款补 autoflush=1 ----------
|
|
193
|
+
print("\n【④】同病工具面 autoflush=1")
|
|
194
|
+
cg_rv = None
|
|
195
|
+
try:
|
|
196
|
+
from . import review_cli as _rv
|
|
197
|
+
cg_rv = _rv._cg(argparse.Namespace(root=root_1))
|
|
198
|
+
check("review_cli._cg 构造 autoflush==1", cg_rv.autoflush == 1,
|
|
199
|
+
str(cg_rv.autoflush))
|
|
200
|
+
finally:
|
|
201
|
+
if cg_rv is not None:
|
|
202
|
+
cg_rv.close()
|
|
203
|
+
from . import evidence as _ev
|
|
204
|
+
cg_ev = _ev._open_cg(root_1)
|
|
205
|
+
try:
|
|
206
|
+
check("evidence._open_cg 构造 autoflush==1", cg_ev.autoflush == 1,
|
|
207
|
+
str(cg_ev.autoflush))
|
|
208
|
+
finally:
|
|
209
|
+
cg_ev.close()
|
|
210
|
+
for rel, anchor in (
|
|
211
|
+
("scripts/linkref_backfill.py",
|
|
212
|
+
"cg = MdCGSecure(a.root, principal=p, autoflush=1)"),
|
|
213
|
+
("scripts/migrate_restricted.py", None)):
|
|
214
|
+
with open(os.path.join(REPO, rel), encoding="utf-8") as f:
|
|
215
|
+
src = f.read()
|
|
216
|
+
if anchor is not None:
|
|
217
|
+
ok = anchor in src
|
|
218
|
+
else:
|
|
219
|
+
# migrate_restricted 构造跨多行:断言构造语句带 autoflush=1
|
|
220
|
+
ok = re.search(r"MdCGSecure\([^)]*?auth_mode=\"test\"\), "
|
|
221
|
+
r"autoflush=1\)", src, re.S) is not None
|
|
222
|
+
check("%s 构造语句带 autoflush=1(源级断言)" % rel, ok)
|
|
223
|
+
finally:
|
|
224
|
+
for r_ in roots:
|
|
225
|
+
shutil.rmtree(r_, ignore_errors=True)
|
|
226
|
+
|
|
227
|
+
print("\n" + "=" * 64)
|
|
228
|
+
print(f"PASS={PASS} FAIL={FAIL}")
|
|
229
|
+
if FAILS:
|
|
230
|
+
print("失败项:" + ";".join(FAILS))
|
|
231
|
+
return 0 if FAIL == 0 else 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@furongjun1999/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "灵枢(Lingshu·líng shū)DeepSeek Harness 插件:完整大脑——长期记忆/知识飞轮/自我认知/递归反思接入 DSH,对话自动沉淀进 md_cg 认知图(md 文档)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"lingshu": "./lib/cli.js",
|
|
10
|
+
"lingshu-init": "./lib/init.js",
|
|
11
|
+
"dsh-memory": "./lib/cli.js"
|
|
12
|
+
},
|
|
8
13
|
"exports": {
|
|
9
14
|
".": {
|
|
10
15
|
"types": "./lib/index.d.ts",
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli.ts —— `lingshu` 主命令入口(npx 兼容分发器)
|
|
4
|
+
*
|
|
5
|
+
* 为什么有三个 bin(package.json bin):
|
|
6
|
+
* · `lingshu` → 本文件:`lingshu init` 分发到 init.ts(未来其它子命令也挂这);
|
|
7
|
+
* · `lingshu-init` → lib/init.js:单命令直入(不想敲子命令的场景);
|
|
8
|
+
* · `dsh-memory` → 本文件:npx 的同名匹配规则——`npx @furongjun1999/dsh-memory init`
|
|
9
|
+
* 在包有多个 bin 时,只会自动运行**与包名末段同名**的 bin(npm 官方口径),
|
|
10
|
+
* 缺这个入口 npx 会因「多个 bin 且无同名」直接报错,一键配置命令即失效。
|
|
11
|
+
*
|
|
12
|
+
* 零运行时依赖:只用 Node 内置模块。
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from 'node:path'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { runInit } from './init.js'
|
|
17
|
+
|
|
18
|
+
const USAGE = [
|
|
19
|
+
'灵枢(Lingshu)命令行',
|
|
20
|
+
'',
|
|
21
|
+
'用法:lingshu <命令>',
|
|
22
|
+
'',
|
|
23
|
+
'命令:',
|
|
24
|
+
' init [选项] 一键配置生成器(交互三问;参数齐则跳过问答)',
|
|
25
|
+
' 选项:--end <dsh|claude|codex|generic> --root <dir> --python <interpreter>',
|
|
26
|
+
'',
|
|
27
|
+
'示例:',
|
|
28
|
+
' npx @furongjun1999/dsh-memory init',
|
|
29
|
+
' npx @furongjun1999/dsh-memory init -- --end claude --root D:/mem --python python3',
|
|
30
|
+
].join('\n')
|
|
31
|
+
|
|
32
|
+
/** 入口分发:argv 为去掉 node 与本脚本路径后的参数。仅导出供测试。 */
|
|
33
|
+
export async function main(argv: string[]): Promise<number> {
|
|
34
|
+
const cmd = argv[0] ?? ''
|
|
35
|
+
if (cmd === 'init') {
|
|
36
|
+
await runInit(argv.slice(1))
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
process.stdout.write(`${USAGE}\n`)
|
|
40
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') return 0
|
|
41
|
+
if (cmd !== '') process.stderr.write(`未知命令「${cmd}」。\n`)
|
|
42
|
+
else process.stderr.write('缺少命令。\n')
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isMainEntry(): boolean {
|
|
47
|
+
const entry = process.argv[1]
|
|
48
|
+
if (!entry) return false
|
|
49
|
+
try {
|
|
50
|
+
const entryUrl = pathToFileURL(resolve(entry)).href
|
|
51
|
+
return import.meta.url === entryUrl
|
|
52
|
+
|| (process.platform === 'win32' && import.meta.url.toLowerCase() === entryUrl.toLowerCase())
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isMainEntry()) {
|
|
59
|
+
main(process.argv.slice(2))
|
|
60
|
+
.then((code) => { process.exitCode = code })
|
|
61
|
+
.catch((e: unknown) => {
|
|
62
|
+
process.stderr.write(`lingshu:${e instanceof Error ? e.message : String(e)}\n`)
|
|
63
|
+
process.exitCode = 1
|
|
64
|
+
})
|
|
65
|
+
}
|