compound-memory 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {compound_memory-0.1.0/src/compound_memory.egg-info → compound_memory-0.2.0}/PKG-INFO +2 -2
  2. {compound_memory-0.1.0 → compound_memory-0.2.0}/README.md +1 -1
  3. {compound_memory-0.1.0 → compound_memory-0.2.0}/pyproject.toml +1 -1
  4. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/__init__.py +1 -1
  5. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/cli.py +26 -7
  6. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/server.py +35 -16
  7. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/storage.py +103 -8
  8. {compound_memory-0.1.0 → compound_memory-0.2.0/src/compound_memory.egg-info}/PKG-INFO +2 -2
  9. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/SOURCES.txt +1 -0
  10. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_mcp_tools.py +79 -2
  11. compound_memory-0.2.0/tests/test_ns_isolation.py +265 -0
  12. {compound_memory-0.1.0 → compound_memory-0.2.0}/LICENSE +0 -0
  13. {compound_memory-0.1.0 → compound_memory-0.2.0}/setup.cfg +0 -0
  14. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/embedding.py +0 -0
  15. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/index.py +0 -0
  16. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/model.py +0 -0
  17. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/review_queue.py +0 -0
  18. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/scoring.py +0 -0
  19. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/vector_index.py +0 -0
  20. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/dependency_links.txt +0 -0
  21. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/entry_points.txt +0 -0
  22. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/requires.txt +0 -0
  23. {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/top_level.txt +0 -0
  24. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_distill.py +0 -0
  25. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_embedding.py +0 -0
  26. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_index.py +0 -0
  27. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_lifecycle.py +0 -0
  28. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_model.py +0 -0
  29. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_scoring.py +0 -0
  30. {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_vector_index.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compound-memory
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Local multi-agent shared memory with compounding (MCP server + CLI)
5
5
  Author: chinwe
6
6
  License-Expression: MIT
@@ -147,4 +147,4 @@ PyPI 版本不可重传,tag 必须与 `pyproject.toml` 的 `version` 一致(
147
147
 
148
148
  1. **一次性配置**(PyPI → 项目 → Publishing):owner `chinwe`、repo `compound-memory`、workflow `release.yml`、environment `pypi`。首次发布时项目尚不存在,在 pypi.org 用"pending publisher"预注册即可。
149
149
  2. **发布**:`git tag v0.1.0 && git push origin v0.1.0` → `release.yml` 自动 build + `uv publish`。
150
- 3. 发布后 `uvx compound-memory-server` 即为通用安装形态(MCP 配置里的 `command` 也可换成 `uvx`,不再依赖仓库克隆路径)。
150
+ 3. 发布后 `uvx --from compound-memory compound-memory-server` 即为通用安装形态(`uvx` 的参数是包名,script 名不同须用 `--from`;MCP 配置里的 `command` 换成 uvx 后不再依赖仓库克隆路径)。
@@ -118,4 +118,4 @@ PyPI 版本不可重传,tag 必须与 `pyproject.toml` 的 `version` 一致(
118
118
 
119
119
  1. **一次性配置**(PyPI → 项目 → Publishing):owner `chinwe`、repo `compound-memory`、workflow `release.yml`、environment `pypi`。首次发布时项目尚不存在,在 pypi.org 用"pending publisher"预注册即可。
120
120
  2. **发布**:`git tag v0.1.0 && git push origin v0.1.0` → `release.yml` 自动 build + `uv publish`。
121
- 3. 发布后 `uvx compound-memory-server` 即为通用安装形态(MCP 配置里的 `command` 也可换成 `uvx`,不再依赖仓库克隆路径)。
121
+ 3. 发布后 `uvx --from compound-memory compound-memory-server` 即为通用安装形态(`uvx` 的参数是包名,script 名不同须用 `--from`;MCP 配置里的 `command` 换成 uvx 后不再依赖仓库克隆路径)。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compound-memory"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Local multi-agent shared memory with compounding (MCP server + CLI)"
9
9
  readme = "README.md"
10
10
  # PEP 639 SPDX 表达式;与 License:: classifier 互斥,故后者已移除
@@ -1,3 +1,3 @@
1
1
  """compound-memory: local multi-agent shared memory with compounding."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import datetime as dt
7
7
  import json
8
+ import os
8
9
  import sys
9
10
  from pathlib import Path
10
11
  from typing import Any
@@ -19,8 +20,13 @@ def _emit(payload: Any) -> None:
19
20
 
20
21
 
21
22
  def _open_store(args: argparse.Namespace) -> MemoryStore:
22
- # CLI 与 server 同权:vec extra + 模型就绪即启用向量路,否则自动降级纯词面
23
- return MemoryStore(Path(args.root), embedder=auto_encoder())
23
+ # CLI 与 server 同权:vec extra + 模型就绪即启用向量路,否则自动降级纯词面;
24
+ # 环境注入的进程身份(若有)同样生效——未设置时保持自报身份模式
25
+ return MemoryStore(
26
+ Path(args.root),
27
+ embedder=auto_encoder(),
28
+ agent_id=os.environ.get("COMPOUND_MEMORY_AGENT_ID") or None,
29
+ )
24
30
 
25
31
 
26
32
  def cmd_init(args: argparse.Namespace) -> None:
@@ -35,13 +41,17 @@ def cmd_write(args: argparse.Namespace) -> None:
35
41
  def cmd_search(args: argparse.Namespace) -> None:
36
42
  _emit(
37
43
  _open_store(args).search(
38
- query=args.query, ns=args.ns, top_k=args.top_k, include_neighbors=args.include_neighbors
44
+ query=args.query,
45
+ ns=args.ns,
46
+ top_k=args.top_k,
47
+ include_neighbors=args.include_neighbors,
48
+ reader=args.reader,
39
49
  )
40
50
  )
41
51
 
42
52
 
43
53
  def cmd_get(args: argparse.Namespace) -> None:
44
- _emit(_open_store(args).get(args.id))
54
+ _emit(_open_store(args).get(args.id, reader=args.reader))
45
55
 
46
56
 
47
57
  def cmd_link(args: argparse.Namespace) -> None:
@@ -62,7 +72,7 @@ def cmd_decay(args: argparse.Namespace) -> None:
62
72
 
63
73
 
64
74
  def cmd_revive(args: argparse.Namespace) -> None:
65
- _emit(_open_store(args).revive(args.id))
75
+ _emit(_open_store(args).revive(args.id, reader=args.reader))
66
76
 
67
77
 
68
78
  def cmd_stats(args: argparse.Namespace) -> None:
@@ -88,6 +98,7 @@ def cmd_distill_plan(args: argparse.Namespace) -> None:
88
98
  min_uses=args.min_uses,
89
99
  min_confidence=args.min_confidence,
90
100
  ns=args.ns,
101
+ reader=args.reader,
91
102
  )
92
103
  )
93
104
 
@@ -125,18 +136,25 @@ def build_parser() -> argparse.ArgumentParser:
125
136
 
126
137
  p = sub.add_parser("search")
127
138
  p.add_argument("query"); p.add_argument("--ns", default="_shared"); p.add_argument("--top-k", type=int, default=5)
139
+ p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
128
140
  p.add_argument("--no-neighbors", dest="include_neighbors", action="store_false",
129
141
  help="omit embedded one-hop neighbors from hits")
130
142
  p.set_defaults(func=cmd_search)
131
143
 
132
- p = sub.add_parser("get"); p.add_argument("id"); p.set_defaults(func=cmd_get)
144
+ p = sub.add_parser("get")
145
+ p.add_argument("id")
146
+ p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
147
+ p.set_defaults(func=cmd_get)
133
148
  p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b"); p.set_defaults(func=cmd_link)
134
149
  p = sub.add_parser("feedback"); p.add_argument("id"); p.add_argument("agent"); p.set_defaults(func=cmd_feedback)
135
150
 
136
151
  p = sub.add_parser("decay"); p.add_argument("--now", default=None, help="ISO date override (testing)")
137
152
  p.set_defaults(func=cmd_decay)
138
153
 
139
- p = sub.add_parser("revive"); p.add_argument("id"); p.set_defaults(func=cmd_revive)
154
+ p = sub.add_parser("revive")
155
+ p.add_argument("id")
156
+ p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
157
+ p.set_defaults(func=cmd_revive)
140
158
  p = sub.add_parser(
141
159
  "distill-plan",
142
160
  help="scan distillation candidates and print a signal-annotated list",
@@ -148,6 +166,7 @@ def build_parser() -> argparse.ArgumentParser:
148
166
  p.add_argument("--min-uses", type=int, default=1, help="activity gate: uses >= this")
149
167
  p.add_argument("--min-confidence", type=float, default=0.5, help="activity gate: confidence >= this")
150
168
  p.add_argument("--ns", default="_shared")
169
+ p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
151
170
  p.set_defaults(func=cmd_distill_plan)
152
171
  p = sub.add_parser("distill-apply")
153
172
  p.add_argument("content"); p.add_argument("type", choices=MEMORY_TYPES); p.add_argument("source")
@@ -2,10 +2,15 @@
2
2
 
3
3
  恰好 5 个 tool:memory_write / memory_search / memory_get / memory_link / memory_feedback。
4
4
  memory_feedback 是一等公民——复利闭环依赖它。
5
+
6
+ 所有 tool 声明 structured_output=False(单份序列化):mcp 2.x 会从 `dict[str, Any]`
7
+ 注解推断 outputSchema,结构化载荷与文本回退同时下发双份,撑大宿主上下文;
8
+ 关闭后只走 text 一份 JSON,形状不变。
5
9
  """
6
10
 
7
11
  from __future__ import annotations
8
12
 
13
+ import os
9
14
  from pathlib import Path
10
15
  from typing import Any, Callable
11
16
 
@@ -24,10 +29,15 @@ def configure(
24
29
  git: bool = True,
25
30
  git_probe: Callable[[], bool] | None = None,
26
31
  embedder: Callable[[list[str]], list[list[float]]] | None = None,
32
+ agent_id: str | None = None,
27
33
  ) -> MemoryStore:
28
34
  global _store
29
35
  _store = MemoryStore(
30
- Path(root) if root is not None else default_root(), git=git, git_probe=git_probe, embedder=embedder
36
+ Path(root) if root is not None else default_root(),
37
+ git=git,
38
+ git_probe=git_probe,
39
+ embedder=embedder,
40
+ agent_id=agent_id,
31
41
  )
32
42
  return _store
33
43
 
@@ -39,7 +49,7 @@ def _store_or_configure() -> MemoryStore:
39
49
  return _store
40
50
 
41
51
 
42
- @mcp.tool()
52
+ @mcp.tool(structured_output=False)
43
53
  def memory_write(
44
54
  content: str,
45
55
  type: str,
@@ -52,35 +62,44 @@ def memory_write(
52
62
  return _store_or_configure().write(content=content, type=type, source=source, ns=ns, key=key, links=links)
53
63
 
54
64
 
55
- @mcp.tool()
56
- def memory_search(query: str, ns: str = "_shared", top_k: int = 5, include_neighbors: bool = True) -> dict[str, Any]:
57
- """Search memories. Fuses lexical (BM25) and, when the vec extra + model are installed, vector (BGE) recall via RRF; otherwise falls back to lexical only. Confidence/recency/type act only as a small tie-break. Default namespace is _shared. Each hit embeds up to 3 trimmed one-hop neighbors (active only) unless include_neighbors=False. Returns {'hits': [...]} sorted by score. Compounding rule: after actually adopting a hit, call memory_feedback (agent = your source id) — skipped feedbacks leave the store static."""
58
- hits = _store_or_configure().search(query=query, ns=ns, top_k=top_k, include_neighbors=include_neighbors)
65
+ @mcp.tool(structured_output=False)
66
+ def memory_search(
67
+ query: str,
68
+ ns: str = "_shared",
69
+ top_k: int = 5,
70
+ include_neighbors: bool = True,
71
+ reader: str | None = None,
72
+ ) -> dict[str, Any]:
73
+ """Search memories. Fuses lexical (BM25) and, when the vec extra + model are installed, vector (BGE) recall via RRF; otherwise falls back to lexical only. Confidence/recency/type act only as a small tie-break. Default namespace is _shared. Each hit embeds up to 3 trimmed one-hop neighbors (active only) unless include_neighbors=False. reader: your own source agent id — REQUIRED when ns is 'agent-<name>' (private namespace, readable only by its owner host); ignored for _shared. Returns {'hits': [...]} sorted by score. Compounding rule: after actually adopting a hit, call memory_feedback (agent = your source id) — skipped feedbacks leave the store static."""
74
+ hits = _store_or_configure().search(
75
+ query=query, ns=ns, top_k=top_k, include_neighbors=include_neighbors, reader=reader
76
+ )
59
77
  return {"hits": hits, "count": len(hits)}
60
78
 
61
79
 
62
- @mcp.tool()
63
- def memory_get(mem_id: str, include_neighbors: bool = True) -> dict[str, Any]:
64
- """Fetch a memory by id; one-hop link neighbors are included by default. After adopting it, call memory_feedback (agent = your source id)."""
65
- return _store_or_configure().get(mem_id, include_neighbors=include_neighbors)
80
+ @mcp.tool(structured_output=False)
81
+ def memory_get(mem_id: str, include_neighbors: bool = True, reader: str | None = None) -> dict[str, Any]:
82
+ """Fetch a memory by id; one-hop link neighbors are included by default. reader: your own source agent id — required when the memory lives in a private 'agent-<name>' namespace (readable only by its owner host). After adopting it, call memory_feedback (agent = your source id)."""
83
+ return _store_or_configure().get(mem_id, include_neighbors=include_neighbors, reader=reader)
66
84
 
67
85
 
68
- @mcp.tool()
86
+ @mcp.tool(structured_output=False)
69
87
  def memory_link(id_a: str, id_b: str) -> dict[str, Any]:
70
- """Create a bidirectional link between two memories (compounding source #2: association)."""
88
+ """Create a bidirectional link between two memories (compounding source #2: association). Both memories must live in the same namespace; cross-namespace links are rejected."""
71
89
  return _store_or_configure().link(id_a, id_b)
72
90
 
73
91
 
74
- @mcp.tool()
92
+ @mcp.tool(structured_output=False)
75
93
  def memory_feedback(mem_id: str, agent: str) -> dict[str, Any]:
76
- """Report that a memory was actually used. Increments uses, raises confidence (+0.1; extra +0.15 when a different agent validates). agent must be your own source agent id. Archiving is reversed on feedback. MUST be called after a memory is adopted — this closes the compounding loop."""
94
+ """Report that a memory was actually used. Increments uses, raises confidence (+0.1; extra +0.15 when a different agent validates). agent must be your own source agent id. Memories in a private 'agent-<name>' namespace accept feedback only from the owner (agent = 'agent-<name>' or '<name>'). Archiving is reversed on feedback. MUST be called after a memory is adopted — this closes the compounding loop."""
77
95
  return _store_or_configure().feedback(mem_id, agent)
78
96
 
79
97
 
80
98
  def main() -> None:
81
99
  if _store is None:
82
- # 生产入口自动挂向量路(vec extra + 模型就绪才生效,否则静默降级纯词面)
83
- configure(embedder=auto_encoder())
100
+ # 生产入口自动挂向量路(vec extra + 模型就绪才生效,否则静默降级纯词面);
101
+ # 宿主经 COMPOUND_MEMORY_AGENT_ID 注入进程身份,未设置则保持自报身份模式
102
+ configure(embedder=auto_encoder(), agent_id=os.environ.get("COMPOUND_MEMORY_AGENT_ID") or None)
84
103
  mcp.run()
85
104
 
86
105
 
@@ -18,7 +18,7 @@ import subprocess
18
18
  import uuid
19
19
  from dataclasses import asdict
20
20
  from pathlib import Path
21
- from typing import Any, Callable
21
+ from typing import Any, Callable, overload
22
22
 
23
23
  import yaml
24
24
 
@@ -115,6 +115,7 @@ class MemoryStore:
115
115
  remover: Callable[[Path], None] | None = None,
116
116
  git_probe: Callable[[], bool] | None = None,
117
117
  embedder: Callable[[list[str]], list[list[float]]] | None = None,
118
+ agent_id: str | None = None,
118
119
  ) -> None:
119
120
  self.root = Path(root)
120
121
  self.ns_root = self.root / "namespaces"
@@ -126,6 +127,10 @@ class MemoryStore:
126
127
  self.git_enabled = git and (git_probe or _git_available)()
127
128
  self._clock = clock
128
129
  self._remover = remover or _unlink_file
130
+ # 进程侧身份证明:agent_id 非空时(宿主经 COMPOUND_MEMORY_AGENT_ID 注入),
131
+ # 所有调用方自报身份(source/reader/agent)必须与其一致,缺省 reader 自动补真值。
132
+ # 只由 server/cli 入口显式传入,store 自身不读环境变量(测试与库调用保持确定性)。
133
+ self.agent_id = agent_id
129
134
  self._ensure_layout()
130
135
  if self.git_enabled and not (self.root / ".git").exists():
131
136
  # init commit 仅限首次创建:__init__ 在每次 CLI/MCP 启动都会执行,
@@ -232,6 +237,55 @@ class MemoryStore:
232
237
  if ns != "_shared" and not ns.startswith("agent-"):
233
238
  raise ValueError("ns must be '_shared' or start with 'agent-'")
234
239
 
240
+ @staticmethod
241
+ def _check_ns_owner(ns: str, identity: str | None, role: str = "reader") -> None:
242
+ """读/反馈侧 owner 校验:agent-* 私有 ns 只有属主宿主可读、可反馈。
243
+
244
+ 与 write 的 `_check_ns + PermissionError` 对称——写侧已保证非属主写不进
245
+ 私有 ns,读侧若不校验则任何宿主显式传 ns=agent-<别人> 即可越权读全量
246
+ (2026-10-03 实测:search 签名原本无调用方身份参数,跨宿主零阻力);
247
+ feedback 侧不校验则外来 agent 可刷 uses/confidence 或复活归档。
248
+
249
+ identity 缺省时对 _shared 放行、对 agent-* 拒绝:宁可不读,不猜身份。
250
+ role 只是让报错指引对得上调用方的参数名(reader / agent)。
251
+ """
252
+ if not ns.startswith("agent-"):
253
+ return
254
+ owner = ns[len("agent-"):]
255
+ if identity in (ns, owner):
256
+ return
257
+ raise PermissionError(
258
+ f"namespace {ns!r} is private to {owner!r}; {role} is {identity!r}. "
259
+ f"Pass {role}={ns!r} or {role}={owner!r} if you are that host."
260
+ )
261
+
262
+ @overload
263
+ def _resolve_identity(self, value: str, role: str) -> str: ...
264
+
265
+ @overload
266
+ def _resolve_identity(self, value: None, role: str) -> str | None: ...
267
+
268
+ def _resolve_identity(self, value: str | None, role: str) -> str | None:
269
+ """身份裁决:进程注入(agent_id)优先于调用方自报。
270
+
271
+ - 未启用 attestation(agent_id 为空)⇒ 原样放行,行为同旧版(自报身份)。
272
+ - 调用方缺省 ⇒ 自动补进程身份(诚实缺省,如 search 私有 ns 忘带 reader)。
273
+ - 调用方与进程身份等价(agent-x / x 两种形式)⇒ 归一化为 agent_id,
274
+ 保证 validated_by 等记录字段去重一致。
275
+ - 调用方与进程身份矛盾 ⇒ 响亮报错(伪造/配错宿主都该炸,不该静默改写)。
276
+ """
277
+ if self.agent_id is None:
278
+ return value
279
+ if value is None:
280
+ return self.agent_id
281
+ accepted = {self.agent_id, self.agent_id.removeprefix("agent-")}
282
+ if value in accepted:
283
+ return self.agent_id
284
+ raise PermissionError(
285
+ f"{role} {value!r} contradicts attested agent {self.agent_id!r} "
286
+ f"(COMPOUND_MEMORY_AGENT_ID); the process identity wins"
287
+ )
288
+
235
289
  def write(
236
290
  self,
237
291
  content: str,
@@ -244,6 +298,7 @@ class MemoryStore:
244
298
  confidence: float | None = None,
245
299
  origin: str | None = None,
246
300
  ) -> dict[str, Any]:
301
+ source = self._resolve_identity(source, "source")
247
302
  mem, conflict_with = self._write_new(
248
303
  content,
249
304
  type=type,
@@ -306,21 +361,33 @@ class MemoryStore:
306
361
  result["conflicts_with"] = conflict_with.id
307
362
  return result
308
363
 
309
- def get(self, mem_id: str, include_neighbors: bool = True) -> dict[str, Any]:
364
+ def get(self, mem_id: str, include_neighbors: bool = True, reader: str | None = None) -> dict[str, Any]:
365
+ reader = self._resolve_identity(reader, "reader")
310
366
  mem = self.find(mem_id)
311
367
  if mem is None:
312
368
  return {"found": False}
369
+ self._check_ns_owner(mem.ns, reader)
313
370
  result = asdict(mem)
314
371
  result["found"] = True
372
+ # links 输出同 ns 脱敏:跨 ns 遗留链不把对侧 id 暴露给本侧读者(与邻居召回同规则)
373
+ same_ns_links: list[str] = []
374
+ for l in mem.links:
375
+ t = self.find(l)
376
+ if t is None or t.ns == mem.ns:
377
+ same_ns_links.append(l)
378
+ result["links"] = same_ns_links
315
379
  if include_neighbors and mem.links:
316
- neighbors = [asdict(n) for n in (self.find(l) for l in mem.links) if n is not None]
380
+ neighbors = [asdict(n) for n in (self.find(l) for l in same_ns_links) if n is not None]
317
381
  result["neighbors"] = neighbors
318
382
  return result
319
383
 
320
384
  def feedback(self, mem_id: str, agent: str) -> dict[str, Any]:
385
+ agent = self._resolve_identity(agent, "agent")
321
386
  mem = self.find(mem_id)
322
387
  if mem is None:
323
388
  return {"found": False}
389
+ # 私有记忆只有属主可反馈:防外来 agent 刷 uses/confidence、混入 validated_by 或复活归档
390
+ self._check_ns_owner(mem.ns, agent, role="agent")
324
391
  if mem.archived:
325
392
  self._move_to_active(mem)
326
393
  mem.uses += 1
@@ -346,6 +413,10 @@ class MemoryStore:
346
413
  if missing:
347
414
  return {"found": False, "missing": missing}
348
415
  assert mem_a is not None and mem_b is not None
416
+ # 跨 ns 链会把对侧 id 写进本侧文件 frontmatter,成为私有 id 的泄漏源;
417
+ # 且邻居召回本就同 ns 过滤,跨 ns 链对复利无贡献——创建侧直接禁止
418
+ if mem_a.ns != mem_b.ns:
419
+ raise ValueError(f"cannot link memories across namespaces: {mem_a.ns!r} vs {mem_b.ns!r}")
349
420
  if id_b not in mem_a.links:
350
421
  mem_a.links.append(id_b)
351
422
  if id_a not in mem_b.links:
@@ -361,13 +432,19 @@ class MemoryStore:
361
432
  ns: str = "_shared",
362
433
  top_k: int = 5,
363
434
  include_neighbors: bool = True,
435
+ reader: str | None = None,
364
436
  ) -> list[dict[str, Any]]:
365
437
  """检索 = 选候选(store 的 layout 职责)+ 排序(scoring.rank 单一定义点)。
366
438
 
367
439
  embedder 可用时叠加向量召回:候选 = 词面命中 ∪ 向量 KNN(ns/活性过滤),
368
440
  两路 rank 在 rank 内 RRF 融合;向量路任何故障都降级纯词面(宁缺勿炸)。
441
+
442
+ reader 是调用方身份,ns=agent-* 时必填且须为属主(读侧 owner 校验,
443
+ 与 write 的越权抛 PermissionError 对称);ns=_shared 时忽略。
369
444
  """
370
445
  self._check_ns(ns)
446
+ reader = self._resolve_identity(reader, "reader")
447
+ self._check_ns_owner(ns, reader)
371
448
  now = self._clock()
372
449
  q_tokens = tokenize(query)
373
450
  if not q_tokens:
@@ -378,7 +455,7 @@ class MemoryStore:
378
455
  self._candidates(q_tokens, ns, vec_rels),
379
456
  now=now,
380
457
  top_k=top_k,
381
- neighbor_lookup=self._active_neighbors if include_neighbors else None,
458
+ neighbor_lookup=(lambda mid: self._active_neighbors(mid, ns)) if include_neighbors else None,
382
459
  vec_sims=vec_sims,
383
460
  )
384
461
 
@@ -414,15 +491,19 @@ class MemoryStore:
414
491
  except Exception:
415
492
  return None, []
416
493
 
417
- def _active_neighbors(self, mem_id: str) -> list[Memory]:
418
- """邻居召回的数据源:hit 的一度 links,归档邻居不召回(截断/上限/去环归 rank)。"""
494
+ def _active_neighbors(self, mem_id: str, ns: str = "_shared") -> list[Memory]:
495
+ """邻居召回的数据源:hit 的一度 links,归档邻居不召回(截断/上限/去环归 rank)。
496
+
497
+ ns 过滤是访问控制的一部分,不可省:_shared 记忆若链到agent-* 私有记忆,
498
+ 邻居会把私有正文带进 _shared 的检索结果(2026-10-03 实测泄漏)。
499
+ """
419
500
  mem = self.find(mem_id)
420
501
  if mem is None:
421
502
  return []
422
503
  out: list[Memory] = []
423
504
  for link_id in mem.links:
424
505
  neighbor = self.find(link_id)
425
- if neighbor is not None and not neighbor.archived:
506
+ if neighbor is not None and not neighbor.archived and neighbor.ns == ns:
426
507
  out.append(neighbor)
427
508
  return out
428
509
 
@@ -445,10 +526,13 @@ class MemoryStore:
445
526
  self._commit("decay: archive " + ", ".join(archived))
446
527
  return archived
447
528
 
448
- def revive(self, mem_id: str) -> dict[str, Any]:
529
+ def revive(self, mem_id: str, reader: str | None = None) -> dict[str, Any]:
530
+ reader = self._resolve_identity(reader, "reader")
449
531
  mem = self.find(mem_id)
450
532
  if mem is None:
451
533
  return {"found": False}
534
+ # revive 返回全文,与 get 同属按 id 读路径:私有 ns 仅属主可复活
535
+ self._check_ns_owner(mem.ns, reader)
452
536
  if mem.archived:
453
537
  self._move_to_active(mem)
454
538
  self._save(mem)
@@ -480,6 +564,7 @@ class MemoryStore:
480
564
  min_uses: int = 1,
481
565
  min_confidence: float = 0.5,
482
566
  ns: str = "_shared",
567
+ reader: str | None = None,
483
568
  ) -> dict[str, Any]:
484
569
  """蒸馏候选扫描:窗口 + 活性门过滤,产出带信号标注的建议清单(只标注不合并)。
485
570
 
@@ -487,8 +572,12 @@ class MemoryStore:
487
572
  (BM25 normalized_similarity ≥ DISTILL_DUP_SIM_THRESHOLD,弱信号)、
488
573
  promotion_candidate(episode 高活性,晋升建议——判断后置,#6)。
489
574
  归档区不参与;坏日期记忆按宁缺勿滥跳过。
575
+
576
+ reader:候选带正文返回,扫私有 ns 须属主(与 get/search 同规则)。
490
577
  """
491
578
  self._check_ns(ns)
579
+ reader = self._resolve_identity(reader, "reader")
580
+ self._check_ns_owner(ns, reader)
492
581
  now = self._clock()
493
582
  cands: list[Memory] = []
494
583
  for path in sorted((self.ns_root / ns).rglob("*.md")):
@@ -548,11 +637,17 @@ class MemoryStore:
548
637
  源批量归档,收进一次 commit。源任一不存在 ⇒ 整体不落库(found: False)。
549
638
  产物与现存 fact/insight 的 key 冲突走既有 review 队列机制,不特殊对待。
550
639
  """
640
+ source = self._resolve_identity(source, "source")
551
641
  source_ids = list(dict.fromkeys(source_ids)) # 去重保序:重复源只归档一次
552
642
  sources = [self.find(mid) for mid in source_ids]
553
643
  missing = [mid for mid, mem in zip(source_ids, sources) if mem is None]
554
644
  if missing:
555
645
  return {"found": False, "missing": missing}
646
+ # 蒸馏不跨 ns:私有记忆被当源蒸进 _shared 是正文泄漏通道;
647
+ # distill_plan 本就按单 ns 扫描,源与产物同 ns 是既定流程
648
+ foreign_ns = sorted({s.ns for s in sources if s is not None and s.ns != ns})
649
+ if foreign_ns:
650
+ raise ValueError(f"distill sources must live in target ns {ns!r}; found in: {foreign_ns}")
556
651
  mem, conflict_with = self._write_new(
557
652
  content,
558
653
  type=type,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compound-memory
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Local multi-agent shared memory with compounding (MCP server + CLI)
5
5
  Author: chinwe
6
6
  License-Expression: MIT
@@ -147,4 +147,4 @@ PyPI 版本不可重传,tag 必须与 `pyproject.toml` 的 `version` 一致(
147
147
 
148
148
  1. **一次性配置**(PyPI → 项目 → Publishing):owner `chinwe`、repo `compound-memory`、workflow `release.yml`、environment `pypi`。首次发布时项目尚不存在,在 pypi.org 用"pending publisher"预注册即可。
149
149
  2. **发布**:`git tag v0.1.0 && git push origin v0.1.0` → `release.yml` 自动 build + `uv publish`。
150
- 3. 发布后 `uvx compound-memory-server` 即为通用安装形态(MCP 配置里的 `command` 也可换成 `uvx`,不再依赖仓库克隆路径)。
150
+ 3. 发布后 `uvx --from compound-memory compound-memory-server` 即为通用安装形态(`uvx` 的参数是包名,script 名不同须用 `--from`;MCP 配置里的 `command` 换成 uvx 后不再依赖仓库克隆路径)。
@@ -23,5 +23,6 @@ tests/test_index.py
23
23
  tests/test_lifecycle.py
24
24
  tests/test_mcp_tools.py
25
25
  tests/test_model.py
26
+ tests/test_ns_isolation.py
26
27
  tests/test_scoring.py
27
28
  tests/test_vector_index.py
@@ -34,10 +34,12 @@ current_store: MemoryStore
34
34
 
35
35
 
36
36
  @asynccontextmanager
37
- async def make_client(root: Path, git_off: bool = False):
37
+ async def make_client(root: Path, git_off: bool = False, agent_id: str | None = None):
38
38
  """配置 server store 并开内存 client;无 git 场景经 configure 注入探测 adapter。"""
39
39
  global current_store
40
- current_store = cm_server.configure(root, git=True, git_probe=(lambda: False) if git_off else None)
40
+ current_store = cm_server.configure(
41
+ root, git=True, git_probe=(lambda: False) if git_off else None, agent_id=agent_id
42
+ )
41
43
  async with mcp.Client(cm_server.mcp) as c:
42
44
  yield c
43
45
 
@@ -269,6 +271,81 @@ class TestNamespacePermissions:
269
271
  }))
270
272
  assert res["ns"] == "agent-tars"
271
273
 
274
+ async def test_private_read_requires_reader_param(self, memroot):
275
+ """search/get 的 reader 透传:缺身份 is_error,属主身份放行."""
276
+ async with make_client(memroot) as client:
277
+ priv = call(await client.call_tool("memory_write", {
278
+ "content": "私有检索草稿", "type": "fact", "source": "agent-tars", "ns": "agent-tars",
279
+ }))
280
+ no_reader = await client.call_tool(
281
+ "memory_search", {"query": "私有检索", "ns": "agent-tars"}
282
+ )
283
+ assert no_reader.is_error
284
+ assert (await client.call_tool("memory_get", {"mem_id": priv["id"]})).is_error
285
+ hits = call(await client.call_tool(
286
+ "memory_search", {"query": "私有检索", "ns": "agent-tars", "reader": "agent-tars"}
287
+ ))
288
+ assert [h["id"] for h in hits["hits"]] == [priv["id"]]
289
+ got = call(await client.call_tool(
290
+ "memory_get", {"mem_id": priv["id"], "reader": "tars"}
291
+ ))
292
+ assert got["found"] is True
293
+
294
+ async def test_cross_ns_link_rejected(self, memroot):
295
+ async with make_client(memroot) as client:
296
+ shared = call(await client.call_tool("memory_write", {
297
+ "content": "共享记忆", "type": "episode", "source": "agent-a",
298
+ }))
299
+ priv = call(await client.call_tool("memory_write", {
300
+ "content": "私有记忆", "type": "episode", "source": "agent-tars", "ns": "agent-tars",
301
+ }))
302
+ res = await client.call_tool("memory_link", {"id_a": shared["id"], "id_b": priv["id"]})
303
+ assert res.is_error
304
+
305
+ async def test_private_feedback_rejects_foreign_agent(self, memroot):
306
+ async with make_client(memroot) as client:
307
+ priv = call(await client.call_tool("memory_write", {
308
+ "content": "私有反馈目标", "type": "fact", "source": "agent-tars", "ns": "agent-tars",
309
+ }))
310
+ res = await client.call_tool("memory_feedback", {"mem_id": priv["id"], "agent": "agent-a"})
311
+ assert res.is_error
312
+ ok = call(await client.call_tool("memory_feedback", {"mem_id": priv["id"], "agent": "tars"}))
313
+ assert ok["uses"] == 1
314
+
315
+
316
+ class TestSingleCopySerialization:
317
+ """structured_output=False:载荷只走 text 一份,无 structuredContent 双份下发。"""
318
+
319
+ async def test_no_structured_content_duplication(self, memroot):
320
+ async with make_client(memroot) as client:
321
+ res = await client.call_tool("memory_search", {"query": "anything"})
322
+ assert res.structured_content is None
323
+ assert len(res.content) == 1
324
+ assert res.content[0].type == "text"
325
+
326
+
327
+ class TestAttestationWiring:
328
+ """进程身份注入(configure 的 agent_id,生产来自 COMPOUND_MEMORY_AGENT_ID)经 tool 缝生效。"""
329
+
330
+ async def test_forged_identity_rejected(self, memroot):
331
+ async with make_client(memroot, agent_id="agent-zcode") as client:
332
+ res = await client.call_tool("memory_write", {
333
+ "content": "x", "type": "fact", "source": "agent-a",
334
+ })
335
+ assert res.is_error
336
+ ok = call(await client.call_tool("memory_write", {
337
+ "content": "x", "type": "fact", "source": "agent-zcode",
338
+ }))
339
+ assert ok["source"] == "agent-zcode"
340
+
341
+ async def test_reader_autofilled_from_process_identity(self, memroot):
342
+ async with make_client(memroot, agent_id="agent-zcode") as client:
343
+ priv = call(await client.call_tool("memory_write", {
344
+ "content": "私有自动补 reader", "type": "fact", "source": "agent-zcode", "ns": "agent-zcode",
345
+ }))
346
+ hits = call(await client.call_tool("memory_search", {"query": "私有自动补", "ns": "agent-zcode"}))
347
+ assert [h["id"] for h in hits["hits"]] == [priv["id"]]
348
+
272
349
 
273
350
  class TestWithoutGit:
274
351
  async def test_tools_work_without_git(self, memroot2):
@@ -0,0 +1,265 @@
1
+ """ns 隔离行为测试(store 缝):读写双侧属主边界 + 旁路通道(link/feedback/distill)。
2
+
3
+ spec 原为「读不隔离」,2026-10-03 修订为双侧属主校验(fail-closed,缺身份即拒绝)。
4
+ 默认检索的候选圈定已挡住直读;这里钉的是显式 ns 与旁路的拒绝/放行,
5
+ 以及 get 输出对跨 ns 遗留链的脱敏。MCP 参数透传在 test_mcp_tools.py。
6
+
7
+ 为何要钉旁路:私有内容的主路(search/get)封死后,剩余攻击面是按 id
8
+ 直操作的三条旁路——共同前提是拿到私有 id,所以 links 脱敏也是边界的一部分。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+ import json
15
+
16
+ import pytest
17
+
18
+ from compound_memory.cli import main as cli_main
19
+ from compound_memory.storage import MemoryStore
20
+ from conftest import CLOCK_DATE
21
+
22
+ OWNER = "agent-zcode"
23
+ OWNER_BARE = "zcode"
24
+ FOREIGN = "agent-workbuddy"
25
+ PRIVATE_NS = "agent-zcode"
26
+
27
+
28
+ def _days_ago(n: int) -> str:
29
+ """相对测试固定"今天"(CLOCK_DATE)推算——store 的 clock 已注入同一日期。"""
30
+ return (CLOCK_DATE - dt.timedelta(days=n)).isoformat()
31
+
32
+
33
+ def _seed(store: MemoryStore) -> tuple[str, str]:
34
+ """预置一对最简数据:属主私有记忆 + 外来 _shared 公开记忆。"""
35
+ priv = store.write("zcode private draft", type="fact", source=OWNER, ns=PRIVATE_NS, key="priv")
36
+ pub = store.write("shared public note", type="fact", source=FOREIGN, ns="_shared", key="pub")
37
+ return priv["id"], pub["id"]
38
+
39
+
40
+ def _add_legacy_link(store: MemoryStore, mem_id: str, target_id: str) -> None:
41
+ """伪造存量跨 ns 链(规则生效前的遗留数据):改文件 links 后重建索引。
42
+
43
+ 正路 link() 现已拒绝跨 ns,跨 ns 链只能这样造出来——这正是测试目的。
44
+ """
45
+ mem = store.find(mem_id)
46
+ assert mem is not None
47
+ mem.links.append(target_id)
48
+ store._save(mem)
49
+
50
+
51
+ class TestReadIsolation:
52
+ def test_default_search_never_sees_private(self, store: MemoryStore):
53
+ """主路隔离:_shared 检索即便用私有记忆的原词也命中不了私有 ns。"""
54
+ _seed(store)
55
+ hits = store.search("zcode private draft")
56
+ assert all(h["ns"] != PRIVATE_NS for h in hits)
57
+
58
+ def test_private_search_requires_owner_reader(self, store: MemoryStore):
59
+ priv_id, _ = _seed(store)
60
+ with pytest.raises(PermissionError):
61
+ store.search("private draft", ns=PRIVATE_NS)
62
+ with pytest.raises(PermissionError):
63
+ store.search("private draft", ns=PRIVATE_NS, reader=FOREIGN)
64
+ # 属主两种身份形式(全称/短名)都放行
65
+ assert [h["id"] for h in store.search("private draft", ns=PRIVATE_NS, reader=OWNER)] == [priv_id]
66
+ assert [h["id"] for h in store.search("private draft", ns=PRIVATE_NS, reader=OWNER_BARE)] == [priv_id]
67
+
68
+ def test_shared_search_ignores_reader(self, store: MemoryStore):
69
+ _seed(store)
70
+ hits = store.search("public note", reader=FOREIGN)
71
+ assert [h["ns"] for h in hits] == ["_shared"]
72
+
73
+ def test_private_get_requires_owner_reader(self, store: MemoryStore):
74
+ priv_id, _ = _seed(store)
75
+ with pytest.raises(PermissionError):
76
+ store.get(priv_id)
77
+ with pytest.raises(PermissionError):
78
+ store.get(priv_id, reader=FOREIGN)
79
+ assert store.get(priv_id, reader=OWNER)["found"] is True
80
+ assert store.get(priv_id, reader=OWNER_BARE)["found"] is True
81
+
82
+ def test_legacy_cross_ns_link_redacted_from_get(self, store: MemoryStore):
83
+ """存量跨 ns 链的双向脱敏:links 输出与邻居召回都不暴露对侧 id。"""
84
+ priv_id, _ = _seed(store)
85
+ bridge = store.write("bridging note", type="episode", source=FOREIGN)
86
+ _add_legacy_link(store, bridge["id"], priv_id)
87
+ _add_legacy_link(store, priv_id, bridge["id"])
88
+ store.rebuild_index()
89
+
90
+ got_pub = store.get(bridge["id"])
91
+ assert got_pub["links"] == []
92
+ assert got_pub.get("neighbors", []) == []
93
+ got_priv = store.get(priv_id, reader=OWNER)
94
+ assert got_priv["links"] == []
95
+
96
+ hits = store.search("bridging")
97
+ assert hits and hits[0]["neighbors"] == []
98
+
99
+
100
+ class TestLinkNamespaceRule:
101
+ def test_cross_ns_link_rejected(self, store: MemoryStore):
102
+ priv_id, pub_id = _seed(store)
103
+ with pytest.raises(ValueError, match="across namespaces"):
104
+ store.link(pub_id, priv_id)
105
+ # 原子拒绝:两侧文件都不留下链
106
+ assert store.find(pub_id) is not None and store.find(pub_id).links == [] # type: ignore[union-attr]
107
+ assert store.find(priv_id) is not None and store.find(priv_id).links == [] # type: ignore[union-attr]
108
+
109
+ def test_same_ns_link_ok(self, store: MemoryStore):
110
+ _, pub_id = _seed(store)
111
+ other = store.write("another shared note", type="fact", source=FOREIGN, ns="_shared")
112
+ res = store.link(pub_id, other["id"])
113
+ assert res["found"] is True
114
+ assert other["id"] in store.find(pub_id).links # type: ignore[union-attr]
115
+
116
+ def test_self_link_still_rejected(self, store: MemoryStore):
117
+ _, pub_id = _seed(store)
118
+ with pytest.raises(ValueError, match="itself"):
119
+ store.link(pub_id, pub_id)
120
+
121
+
122
+ class TestFeedbackNamespaceRule:
123
+ def test_foreign_feedback_on_private_denied(self, store: MemoryStore):
124
+ priv_id, _ = _seed(store)
125
+ with pytest.raises(PermissionError):
126
+ store.feedback(priv_id, FOREIGN)
127
+ mem = store.find(priv_id)
128
+ assert mem is not None and mem.uses == 0 and mem.confidence == 0.5
129
+
130
+ def test_owner_feedback_on_private_ok(self, store: MemoryStore):
131
+ priv_id, _ = _seed(store)
132
+ res = store.feedback(priv_id, OWNER)
133
+ assert res["uses"] == 1 and res["confidence"] == 0.6
134
+
135
+ def test_shared_feedback_unaffected(self, store: MemoryStore):
136
+ _, pub_id = _seed(store)
137
+ assert store.feedback(pub_id, FOREIGN)["found"] is True
138
+
139
+
140
+ class TestDistillNamespaceRule:
141
+ def test_cross_ns_sources_rejected_atomically(self, store: MemoryStore):
142
+ priv_id, pub_id = _seed(store)
143
+ # 私有 → _shared(正文外泄方向)与 _shared → 私有(越权并源)都拒绝
144
+ with pytest.raises(ValueError, match="must live in target ns"):
145
+ store.distill_apply("合并产物", type="insight", source=OWNER, source_ids=[priv_id], ns="_shared")
146
+ with pytest.raises(ValueError, match="must live in target ns"):
147
+ store.distill_apply("合并产物", type="insight", source=OWNER, source_ids=[pub_id], ns=PRIVATE_NS)
148
+ # 原子:源未被归档、产物未落库
149
+ assert store.find(priv_id) is not None and not store.find(priv_id).archived # type: ignore[union-attr]
150
+ assert store.find(pub_id) is not None and not store.find(pub_id).archived # type: ignore[union-attr]
151
+
152
+ def test_same_ns_distill_by_owner_ok(self, store: MemoryStore):
153
+ priv_id, _ = _seed(store)
154
+ res = store.distill_apply(
155
+ "蒸馏产物内容", type="insight", source=OWNER, source_ids=[priv_id], ns=PRIVATE_NS
156
+ )
157
+ assert res["ns"] == PRIVATE_NS and priv_id in res["links"]
158
+ assert store.find(priv_id) is not None and store.find(priv_id).archived # type: ignore[union-attr]
159
+
160
+ def test_distill_plan_private_requires_owner_reader(self, store: MemoryStore):
161
+ """distill_plan 候选带正文返回:扫私有 ns 与 get/search 同规则。"""
162
+ priv_id, _ = _seed(store)
163
+ store.feedback(priv_id, OWNER) # 过活性门(uses >= 1)
164
+ with pytest.raises(PermissionError):
165
+ store.distill_plan(ns=PRIVATE_NS)
166
+ plan = store.distill_plan(ns=PRIVATE_NS, reader=OWNER)
167
+ assert [c["id"] for c in plan["candidates"]] == [priv_id]
168
+
169
+
170
+ class TestReviveNamespaceRule:
171
+ def test_revive_private_requires_owner_reader(self, store: MemoryStore):
172
+ """revive 返回全文,与 get 同属按 id 读路径:私有 ns 仅属主可复活。"""
173
+ old = store.write(
174
+ "zcode old private episode", type="episode", source=OWNER, ns=PRIVATE_NS, created=_days_ago(120)
175
+ )
176
+ assert old["id"] in store.decay_sweep()
177
+ with pytest.raises(PermissionError):
178
+ store.revive(old["id"])
179
+ assert store.revive(old["id"], reader=OWNER)["archived"] is False
180
+
181
+
182
+ class TestAttestation:
183
+ """进程侧身份证明:身份等于进程(COMPOUND_MEMORY_AGENT_ID),不等于自称。
184
+
185
+ 三条裁决规则:缺省自动补进程身份(诚实缺省)、等价形式归一化、
186
+ 矛盾响亮报错——连带关闭「_shared 伪造 source 污染跨 Agent 验证」的口子。
187
+ """
188
+
189
+ @pytest.fixture
190
+ def attested(self, tmp_path) -> MemoryStore:
191
+ from conftest import sandbox_safe_remove
192
+
193
+ return MemoryStore(
194
+ tmp_path / "attested", clock=lambda: CLOCK_DATE, remover=sandbox_safe_remove, agent_id=OWNER
195
+ )
196
+
197
+ def test_reader_auto_filled_for_owner_ns(self, attested: MemoryStore):
198
+ priv = attested.write("私有草稿", type="fact", source=OWNER, ns=PRIVATE_NS)
199
+ hits = attested.search("私有草稿", ns=PRIVATE_NS) # 忘带 reader:自动补进程身份
200
+ assert [h["id"] for h in hits] == [priv["id"]]
201
+
202
+ def test_forged_reader_rejected(self, attested: MemoryStore):
203
+ attested.write("私有草稿", type="fact", source=OWNER, ns=PRIVATE_NS)
204
+ with pytest.raises(PermissionError, match="attested agent"):
205
+ attested.search("私有草稿", ns=PRIVATE_NS, reader=FOREIGN)
206
+
207
+ def test_foreign_ns_denied_even_with_autofill(self, attested: MemoryStore):
208
+ """自动补的是进程身份:读别人的私有 ns 依旧被属主门挡住。"""
209
+ mem, _ = attested._write_new(
210
+ "workbuddy 私密",
211
+ type="fact",
212
+ source=FOREIGN,
213
+ ns="agent-workbuddy",
214
+ key=None,
215
+ links=None,
216
+ created=None,
217
+ confidence=None,
218
+ origin=None,
219
+ )
220
+ with pytest.raises(PermissionError):
221
+ attested.search("workbuddy 私密", ns="agent-workbuddy")
222
+ with pytest.raises(PermissionError):
223
+ attested.get(mem.id)
224
+
225
+ def test_write_source_must_match_process(self, attested: MemoryStore):
226
+ """attestation 连带关闭 _shared 伪造 source 的口子;等价形式归一化。"""
227
+ with pytest.raises(PermissionError, match="attested agent"):
228
+ attested.write("x", type="fact", source=FOREIGN)
229
+ res = attested.write("y", type="fact", source="zcode") # 短名等价
230
+ assert res["source"] == OWNER # 落库归一化为进程身份
231
+
232
+ def test_feedback_agent_must_match_process(self, attested: MemoryStore):
233
+ mem = attested.write("x", type="fact", source=OWNER)
234
+ with pytest.raises(PermissionError, match="attested agent"):
235
+ attested.feedback(mem["id"], FOREIGN)
236
+ assert attested.feedback(mem["id"], "zcode")["validated_by"] == [OWNER]
237
+
238
+ def test_unattested_keeps_self_declared(self, store: MemoryStore):
239
+ """回归护栏:未启用 attestation 的 store 行为与旧版完全一致。"""
240
+ assert store.write("x", type="fact", source=FOREIGN)["source"] == FOREIGN
241
+
242
+ def test_cli_env_attestation(self, store: MemoryStore, monkeypatch, capsys):
243
+ priv_id, _ = _seed(store)
244
+ monkeypatch.setenv("COMPOUND_MEMORY_AGENT_ID", OWNER)
245
+ root = ["--root", str(store.root)]
246
+ assert cli_main(root + ["get", priv_id]) == 0 # reader 自动补进程身份
247
+ capsys.readouterr()
248
+ assert cli_main(root + ["get", priv_id, "--reader", FOREIGN]) == 2
249
+
250
+
251
+ class TestCliReaderFlag:
252
+ """CLI 缺 reader 时 fail-closed(exit 2),--reader 全称/短名都放行。"""
253
+
254
+ def test_cli_get_and_search_roundtrip(self, store: MemoryStore, capsys: pytest.CaptureFixture[str]):
255
+ priv_id, _ = _seed(store)
256
+ root = ["--root", str(store.root)]
257
+ assert cli_main(root + ["search", "private draft", "--ns", PRIVATE_NS]) == 2
258
+ assert cli_main(root + ["get", priv_id]) == 2
259
+ assert cli_main(root + ["search", "private draft", "--ns", PRIVATE_NS, "--reader", OWNER]) == 0
260
+ out = json.loads(capsys.readouterr().out)
261
+ # CLI search 输出是裸数组({"hits": ...} 包装只在 MCP 层)
262
+ assert [h["id"] for h in out] == [priv_id]
263
+ assert cli_main(root + ["get", priv_id, "--reader", OWNER_BARE]) == 0
264
+ out = json.loads(capsys.readouterr().out)
265
+ assert out["found"] is True
File without changes