现象
memory_search 带 entity: / attr: 前缀时会绕过 strictScope 硬过滤:显式标注为他人
scope 的记忆,普通检索查不到,换这两个前缀就能原样读出来——而且是满分返回(连 A2 的
×0.5 降权都没有)。
暴露面:scopeEnabled + strictScope + entitySearchEnabled 三者同开(默认全关)。也就是
「开了 scope 隔离的人,隔离在这条路上是漏的」。而 entity: 正是 #24 图谱线在推的语法,这条
路的使用面只会越来越大。
最小复现(实跑输出)
const store = createStore(":memory:");
const service = createService({ store, mirror: null,
config: { scopeEnabled: true, strictScope: true, entitySearchEnabled: true } });
const row = store.save({ type: "preference", title: "别人的机密预算", content: "机密预算内容",
agent_scope: "other", agent_scope_source: "explicit" });
const entity = store.createEntity({ name: "阿尔托", type: "person" });
store.saveAttr({ entity_id: entity.id, attr_key: "国籍", attr_value: "芬兰", memory_id: row.id });
const me = { agent_scope: "me", workspace_scope: null };
await service.searchMemories("机密预算", { mode: "keyword", scope: me }); // 0 条 ✅
await service.searchMemories("entity:阿尔托", { scope: me }); // 1 条 ❌
await service.searchMemories("attr:国籍=芬兰", { scope: me }); // 1 条 ❌
实测:
① 普通关键词检索(应当滤掉) -> 命中 0 条 []
② entity:阿尔托 路由 -> 命中 1 条 [ '别人的机密预算' ]
③ attr:国籍=芬兰 路由 -> 命中 1 条 [ '别人的机密预算' ]
对照(原主人自己搜) -> 命中 1 条 [ '别人的机密预算' ]
对照组是为了排除「本来就搜不到」造成的假象。
根因
src/service.js 的 searchMemories 有两处早返回:
src/service.js:824-833:entity: / attr: 前缀在函数入口直接
return searchByEntity(...) / searchByAttr(...);
src/service.js:899-901:strictScope 过滤写在函数后半段的融合池上
(merged.filter(isVisibleInScope))——早返回的路根本走不到那一步。
而 searchByEntity(src/service.js:371)与 searchByAttr(src/service.js:397)的签名
只解构 { topK },options 里带的 scope 被丢掉;所以即使把过滤写进这两个函数,也得先把
scope 传进去。
顺带两处同源现象:
- A2 软加权一并被绕过:
SCOPE_FOREIGN_PENALTY(src/service.js:74)只在融合 / 注入路
径(scopeMultiplier)生效,这两条早返回路既不硬过滤也不降权。
- 越权命中会反馈到热度 / 边权:两个函数都无条件调用
touchRecalled(hits)
(src/service.js:384 / 404)——heat 或被动确认开启时,一次越权检索会给本不该看到的行
刷回温时钟、bump 关联边。
为什么现有测试没抓到
test/scope-strict.test.js 覆盖了 11 个入口(可见性谓词、普通 searchMemories、
store.list/count、injectCandidates、memory_update/delete、memory_get、memory_list),
唯独漏了 entity: / attr: 这两条前缀路——而它们恰好是仅有的「在过滤之前就 return」的
分支。
修法建议
把 scope 闸抽成单一实现,三处共用(普通路 + 两个早返回),避免「过滤点写在哪」再次漂移
——与 #349 把阈值口径收进 activeStoreSize() 是同一个理由:
const gateByScope = (rows, scope) =>
config?.strictScope === true && scope ? rows.filter((m) => isVisibleInScope(m, scope)) : rows;
strictScope 关时不改变任何行为(与现状逐字节一致),所以硬过滤可以先落;闸门的位置还要在
touchRecalled 之前(否则出局的行照样被回温 / bump,见上面第 2 条)。A2 软加权要不要一并
补到这两条路(他 scope 行降权但可见),可以按 #339 的口径单独定。
测试加两条,形状对齐现有的 searchMemories: explicit foreign filtered...:entity: 与
attr: 各一条,断言显式他者出局、auto / 存量他者保留。
关联
现象
memory_search带entity:/attr:前缀时会绕过strictScope硬过滤:显式标注为他人scope 的记忆,普通检索查不到,换这两个前缀就能原样读出来——而且是满分返回(连 A2 的
×0.5 降权都没有)。
暴露面:
scopeEnabled+strictScope+entitySearchEnabled三者同开(默认全关)。也就是「开了 scope 隔离的人,隔离在这条路上是漏的」。而
entity:正是 #24 图谱线在推的语法,这条路的使用面只会越来越大。
最小复现(实跑输出)
实测:
对照组是为了排除「本来就搜不到」造成的假象。
根因
src/service.js的searchMemories有两处早返回:src/service.js:824-833:entity:/attr:前缀在函数入口直接return searchByEntity(...)/searchByAttr(...);src/service.js:899-901:strictScope过滤写在函数后半段的融合池上(
merged.filter(isVisibleInScope))——早返回的路根本走不到那一步。而
searchByEntity(src/service.js:371)与searchByAttr(src/service.js:397)的签名只解构
{ topK },options里带的scope被丢掉;所以即使把过滤写进这两个函数,也得先把scope 传进去。
顺带两处同源现象:
SCOPE_FOREIGN_PENALTY(src/service.js:74)只在融合 / 注入路径(
scopeMultiplier)生效,这两条早返回路既不硬过滤也不降权。touchRecalled(hits)(
src/service.js:384/404)——heat 或被动确认开启时,一次越权检索会给本不该看到的行刷回温时钟、bump 关联边。
为什么现有测试没抓到
test/scope-strict.test.js覆盖了 11 个入口(可见性谓词、普通searchMemories、store.list/count、injectCandidates、memory_update/delete、memory_get、memory_list),唯独漏了
entity:/attr:这两条前缀路——而它们恰好是仅有的「在过滤之前就 return」的分支。
修法建议
把 scope 闸抽成单一实现,三处共用(普通路 + 两个早返回),避免「过滤点写在哪」再次漂移
——与 #349 把阈值口径收进
activeStoreSize()是同一个理由:strictScope关时不改变任何行为(与现状逐字节一致),所以硬过滤可以先落;闸门的位置还要在touchRecalled之前(否则出局的行照样被回温 / bump,见上面第 2 条)。A2 软加权要不要一并补到这两条路(他 scope 行降权但可见),可以按 #339 的口径单独定。
测试加两条,形状对齐现有的
searchMemories: explicit foreign filtered...:entity:与attr:各一条,断言显式他者出局、auto / 存量他者保留。关联
entity:语法,这条路的使用面会越来越大)底层行为未见改动);本条是照它的线索实跑复现后的定位与补测口径