feat: add per-agent memory namespacing (owner-scoped keys) - #2267
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: Zhipu (GLM)
Recommendation: REQUEST_CHANGES — one P1 (per-agent isolation breach via scope: 'all') plus a related P2 and a P3 nit. The schema, migration, wiring, and tests are solid; the gap is narrowly in the agent-facing tool surface.
What I verified (and looks correct)
- Migration 163: idempotent, backfills every existing row to
scope='space'/owner=''with no key loss, keepsspace_agent_core_memory+memory_vectorsFK cascades valid (verified both insert+cascade-delete paths). Thelegacy_alter_table=ONrename approach correctly preserves the FK references. - FTS after rebuild: I empirically confirmed legacy rows remain FTS-searchable post-migration even when the legacy FTS index was empty (real
runMigration163+ realrepo.search, no embedder). The "rebuilds the FTS index" test passes for the right reason. - Repo scoping: default = "mine + space";
mine/spacefilters and per-owner consolidation dedup + shared-only core-memory ranking all behave correctly. Cross-owner writes/updates don't collide (UNIQUE(space_id, owner_agent_id, key)+CHECKtying scope to owner). - Runtime wiring: all three
attachLongTermAgentMcpServerscall sites pass the correct agent id;promptProvenance.agentId === agent.idat construction; member/space_chat/workflow-node paths stay space-scoped. Backward-compatible (private method, optional last param). - Backward compat: every non-MCP caller (RPC handler, task-agent-manager, consolidation job) correctly defaults to space-scoped; no positional-arg meaning changed for any live caller.
- Tests/CI: 81 tests across the 4 affected files pass;
bun run checkgreen (incl. schema-parity + test-quality);space-test-db.tshelper matchescreateAgentMemoryTablesbyte-for-byte including FTS triggers + owner index.
P1 — scope: 'all' on the agent-facing memory tools breaches per-agent isolation
This is the only blocker. The default scope isolates agents correctly, but MemorySearchSchema / MemoryReadSchema / MemoryDeleteSchema each expose scope: 'all' and pass it straight through to the repo with no authorization gate. At the repo layer, all means every owner:
I reproduced the breach end-to-end through the real tool handlers (two agents in the same space, A writes a private memory, B calls with scope: 'all'):
memory.search({ query, scope: 'all' })→ returns agent A's private rows to agent B.memory.delete({ key, scope: 'all' })→ deleted agent A's private row (deleted: true; A's subsequentmemory.readreturnssuccess: false). Confirmed destroyed.
This directly fails the PR's own acceptance criterion — "Agent A writes a key that agent B cannot overwrite/delete when scoped to A" — because B can delete A's scoped key by passing scope: 'all'. It also contradicts the repository's own inline comment on the delete path:
// Administrators only — wipe every row under this key across owners.
…which acknowledges all is privileged, yet the tool offers it to every agent. Even in a fully cooperative space, a single buggy agent loop calling memory.delete({scope:'all'}) would wipe every peer's private memory.
Recommended fix (low-risk, narrow): drop 'all' from the three agent-facing schemas — keep 'mine' | 'space' plus the omitted default. The repo-level all support can remain for the future trusted RPC/admin (memory-UI) path where authorization is actually enforced; the agent MCP tool is not that path. (If you want to keep all in the tool, gate it behind an explicit allowAllScope/trusted flag on AgentMemoryToolsConfig that is false for normal agent sessions.)
P2 — read with scope: 'all' is inconsistent with search/list/delete
For read, scope: 'all' falls into the "mine + shared" branch (agent-memory-repository.ts read) and never returns another agent's private row — empirically B read(scope='all') returns the shared row, not A's private one. So read's all is safe but means something different than search/list/delete's all (every owner), and differs from its own schema doc ("'all' = either"). If P1 is fixed by removing all from the tools this becomes moot at the tool layer; if repo-level all is retained for trusted callers, please make read consistent (or document the divergence).
P3 — migration comment overstates the FTS rebuild mechanism (nit, optional)
The migration doc says "The FTS index is then rebuilt from the copied rows" and that it "mirrors the rebuild pattern in ensureAgentMemoryNamedPrimaryKey" — but neither function issues an explicit FTS5 'rebuild' (INSERT INTO space_agent_memory_fts(space_agent_memory_fts) VALUES ('rebuild')). The index does end up populated (verified), but via implicit re-population rather than the documented explicit rebuild. Consider adding the explicit 'rebuild' statement after createAgentMemoryTables so correctness doesn't depend on the implicit behavior. Not blocking — behavior is correct today.
Summary
The namespacing foundation (schema, migration, repo semantics, wiring, tests) is well-built and verified. The blocker is purely the tool surface: scope: 'all' lets any agent read/destroy every other agent's private memory, defeating the isolation guarantee. Removing 'all' from the agent schemas (P1) unblocks; the P2/P3 are cleanups. Happy to re-review once the tool scope is tightened.
…tools Review of PR #2267 found that scope:'all' was exposed on the agent-facing memory.search/read/delete tools with no authorization gate — agent B could read or delete agent A's private rows, violating the 'B cannot touch A scoped key' acceptance criterion. P1: drop 'all' from MemorySearchSchema/MemoryReadSchema/MemoryDeleteSchema (keep 'mine' | 'space' plus the omitted 'mine + space' default). The repo-level 'all' support is retained for the future trusted RPC / memory-UI path where authz is enforced. Schemas are now exported so the boundary is covered by a schema-rejection test. P2: repo read() with scope:'all' previously fell into the 'mine + shared' branch and could never return another owner's private row. Added an explicit 'all' branch (readAnyRow) that returns any row under the key, preferring caller then shared then other — consistent with search/list/delete. P3: migration 163 now issues an explicit FTS5 'rebuild' after recreating the FTS surface, so post-migration searchability is intentional rather than relying on implicit re-population.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: Zhipu (GLM)
Recommendation: APPROVE — all three prior findings addressed and verified; zero remaining findings.
Re-reviewed fresh from 96463d8. Confirmed each fix empirically:
- P1 (blocker) — resolved.
MemorySearchSchema/MemoryReadSchema/MemoryDeleteSchemanow.enum(['mine', 'space']); zod rejectsscope:'all'for all three (verifiedsafeParse→success:false), whilemine/space/ omitted-default still parse. The agent MCP tool can no longer reach the cross-owner read/delete path, so the original breach (B deleting/searching A's private memory) is closed. Repo-levelallis correctly retained for the trusted RPC/memory-UI path. - P2 — resolved.
read()now has an explicitscope === 'all'branch (readAnyRow: own → shared → other), consistent with search/list/delete; the default branch no longer conflatesallwith "mine + space". - P3 — resolved. Migration 163 now issues an explicit
INSERT INTO space_agent_memory_fts(...) VALUES ('rebuild')aftercreateAgentMemoryTables(idempotent), so post-migration FTS searchability is intentional rather than implicit.
Verification on 96463d8: 83 tests pass across the 4 affected files (incl. the new schema-rejection + readAnyRow tests); bun run check green (lint, typecheck, knip, session-guards, space-task-handler-tests, db-schema-parity, test-quality). Schema/migration/wiring/backward-compat remain correct from the prior round.
Nice fix — the narrow schema tightening closes the breach without losing the trusted-path capability.
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Memory was a single shared pool per Space (UNIQUE(space_id, key)) with no
owner dimension, so any session could overwrite or delete any other's keys. This introduces agent-scoped memory so each long-horizon agent holds a private namespace while the shared Space pool stays accessible.
Schema (migration 163, idempotent table rebuild): add owner_agent_id + scope to space_agent_memory and widen the unique key to (space_id, owner_agent_id, key). Existing rows backfill to scope='space', owner='' with no key loss. owner_agent_id uses '' as the space-scoped sentinel (SQLite treats NULL as distinct, which would break the composite UNIQUE). The table is rebuilt under PRAGMA legacy_alter_table=ON so space_agent_core_memory / memory_vectors FK references survive the rename; FTS index is rebuilt.
owner_agent_id is intentionally NOT a foreign key: promptProvenance.agentId can reference either space_agents or space_long_horizon_agents, and SQLite cannot express a FK to one of two tables. It is an opaque, application-resolved key; orphaned references just hide those rows from owner-scoped searches (never lost).
Repository: write/read/delete/search/list accept ownerAgentId + scope ('mine'|'space'|'all'); default search returns the caller's agent-scoped rows plus shared. recordAccess is now keyed by row id (keys are no longer unique). consolidate dedups per-owner and restricts core-memory ranking to shared rows so private memory never leaks into the common core.
Tools: AgentMemoryToolsConfig.ownerAgentId (resolved from promptProvenance.agentId at LH-agent attach). memory.write gains an optional scope; memory.search/read/delete gain scope filters. Member / space_chat / workflow-node sessions stay space-scoped (no durable owner). RPC handler unchanged (space-scoped, no agent attribution trusted from RPC).
Tests: repository owner-isolation + scope filters + consolidation boundaries; tool namespacing; migration idempotency, backfill, FK survival, and FTS-after-rebuild.
…tools Review of PR #2267 found that scope:'all' was exposed on the agent-facing memory.search/read/delete tools with no authorization gate — agent B could read or delete agent A's private rows, violating the 'B cannot touch A scoped key' acceptance criterion. P1: drop 'all' from MemorySearchSchema/MemoryReadSchema/MemoryDeleteSchema (keep 'mine' | 'space' plus the omitted 'mine + space' default). The repo-level 'all' support is retained for the future trusted RPC / memory-UI path where authz is enforced. Schemas are now exported so the boundary is covered by a schema-rejection test. P2: repo read() with scope:'all' previously fell into the 'mine + shared' branch and could never return another owner's private row. Added an explicit 'all' branch (readAnyRow) that returns any row under the key, preferring caller then shared then other — consistent with search/list/delete. P3: migration 163 now issues an explicit FTS5 'rebuild' after recreating the FTS surface, so post-migration searchability is intentional rather than relying on implicit re-population.
96463d8 to
ae81b7d
Compare
Problem
Memory was a single shared pool per Space —
UNIQUE(space_id, key), no owner dimension. Any session (coordinator, LH agent, workflow worker, ad-hoc member) could overwrite or delete any other's keys. Per-agent long-term memory was not a first-class concept.Change
Introduces agent-scoped memory so each long-horizon agent holds a private namespace while the shared Space pool remains accessible.
owner_agent_id+scopetospace_agent_memory; widens the unique key to(space_id, owner_agent_id, key)so two agents can each hold a private row under the same key. Existing rows backfill toscope='space',owner=''(no key loss). Idempotent table rebuild.write/read/delete/search/listacceptownerAgentId+scope('mine' | 'space' | 'all'); default search returns the caller's agent-scoped rows + shared.recordAccessis now keyed by row id (keys are no longer unique).consolidatededups per-owner and restricts core-memory ranking to shared rows so private memory never leaks into the common core.AgentMemoryToolsConfig.ownerAgentId(resolved frompromptProvenance.agentIdat LH-agent attach).memory.writegains an optionalscope;memory.search/read/deletegain scope filters. Member / space_chat / workflow-node sessions stay space-scoped. RPC handler unchanged (space-scoped; no agent attribution trusted from RPC).Acceptance
mine | space | allfilters workDesign notes (deviations from the task brief, please review)
owner_agent_iduses''(empty string) as the space-scoped sentinel, notNULL. SQLite treats NULLs as distinct in a UNIQUE constraint, soUNIQUE(space_id, owner_agent_id, key)would allow duplicate shared rows under NULL. The empty string keeps the composite UNIQUE enforceable; the public entry type maps'' → null.space_long_horizon_agents.promptProvenance.agentIdcan reference eitherspace_agents(worker) orspace_long_horizon_agents, and a worker-only agent has no LH row — a FK to the LH table would reject those writes. SQLite cannot express a FK to "one of two tables," soowner_agent_idis an opaque, application-resolved key. Orphaned references (agent hard-deleted) just hide those rows from owner-scoped searches; they are never lost and can be reclaimed later. If the reviewer prefers strict LH-only ownership, the alternative is to gate owner assignment on LH-existence (worker agents would then be space-scoped only).PRAGMA legacy_alter_table=ONsospace_agent_core_memory/memory_vectorsFK references survive the rename (the pre-existingensureAgentMemoryNamedPrimaryKeyavoided this only because it ran before those tables existed).Test plan
bun run check(lint/typecheck/knip/schema-parity) passes.