Let the server decide whose memory a caller may read
A holder is now somebody who can prove it. Schema v11 stores a sha256 of a per-holder token beside the directory row, so a copied database cannot replay it and the plaintext is shown exactly once. The operator token mints and revokes those tokens; a holder token reads that holder's own memories and nothing else; naming somebody else without the operator token is refused with both ids in the message. The visibility rule was leaking before this: browse had none at all, and the search rules short-circuited to the whole library whenever no holder was named. The rule is now written out in five copies with one parameter carried four times, and a test compares the copies so they cannot drift — semgrep rejects an interpolated constant, so the copies are not a choice. Eyes carry three states on purpose. NULL means the whole library, which is what a process that owns the store is entitled to; a holder id means that holder; the empty string means nobody, which is what a tokenless HTTP caller is. Making NULL mean nobody instead would have forced every internal caller — CLI, proofs, the compiler, the bench harnesses — to name eyes, contradicting the rule that a local process reads as its own trusted principal. The capability card answers "can this store answer my kind of question" before anything is sent. It is derived from what the store holds, so it cannot promise a modality the store lacks, and it carries no memory text, so it is safe to read before trusting the store. HTTP, CLI and one MCP tool all serve it without a token.
This commit is contained in:
1 parent
209f2d0a1c
commit
83dfc14daa
29 files changed
+1335
-77
No files matched your search
+16
-5
@@ -421,11 +421,22 @@
|
||||
- **评审第 4 轮 FAIL**(同一独立 pi,7 项全跑,~557 工具轮;D40/D41 的修法实测成立,16 对逐字复现、五对 D40 全记上):三条——D42 就是上面 P1 那句被实验推翻(Go 层重发缺失字段,旧服务照样过);D43 `:412` 的机制句**方向写反**,我写「主语之前的裸数字并入主语键」,而代码的改名槽是 `index + 1`(主语 token 的**下一个**,`trust.py:275-276`),实测 `3 room holds 22 degrees` 主语是 `3`、不会向前融合,三个例子里只有 `2026 saw …` 数字在前、走的是 `trust.py:270-272` 首数字分支;D44 `_numbers_follow` 的**漏报侧没披露**:改不改名取决于每条记忆自己后面还留着没有第二个数字,`温度 22 度` + `温度 24 度 湿度 40`(`温度`/`温度24`)、`温度 22 度 湿度 40` + `温度 24 度`、`温度 22 度 湿度 40` + `温度 24 度 湿度 60`(`温度22`/`温度24`)、`预算 5000 元 超了 8000` + `预算 6000 元`(`预算5000`/`预算`)四对全 0 条,方向 fail-closed(两条都留在页上)。D44 按评审建议只**披露 + 加测试**(`test_a_second_number_in_one_memory_hides_the_update`),不用「未改名主语兜底比对」,那会把 `Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` 退回 D37 的误报。四条精度说明全部采纳:P-a `degrees` 键也吞年份(`in 2026` → `{'degrees':'2026'}`),P5 那句已改成「第二个数字,含年份」;P-b 反向实验的红名单是 4 条不是 5 条(全套 4 failed / 379 passed,无连带);P-c 「数字只要站在某个词之后就是量不是名字」已就地加限定词(且不在紧挨主语那个位置);P-d 8799 守卫仍只在开跑时探一次,现在指纹补上了**旧构建**答题这一口——同树残留的服务占住端口时指纹仍给 200(评审实测:同树同 `ELY_EYE_AUTH_TOKEN` 的服务把 5 条 atom 落进它自己的 home,直到挂载卡带才失败),那种情形仍只有 busy-port 守卫能挡。该轮同时实测确认:门禁全绿且 mypy 两边逐字相同、tokenize 0 块、最大文件 `bench/systems.py` 498 行;D41 的新测试文案与实测一致;P4 的 stamp 判断站得住(`bench/leg_report.py` 两处 blob 全同 `40596d29…`)。判定:p2-3.1/3.2/3.3 与两条验收都能 `[x]`,p2-3.4 因 D43/D44 的记录级缺陷暂缓。
|
||||
- **评审第 5 轮 PASS**(同一独立 pi,7 项全跑,本轮 diff 仅 3 个文件 +31/−5、机制 blob 全同:`trust.py d0b1a090`、`memory.py 13b0ebbf`、`retrieval.py 305d6e3a`、`compiler.py 5d341ca5`、`api_memory.py 22a03666`):D42/D43/D44 全部按实测改掉并被独立复现——D42 的判别子它自己起两边服务量过(`4c6af87` 的 `/api/memory/working` 404、HEAD 200);D43 三个主语实测 `3` / `room3` / `2026`;D44 四对逐对 0 条且贴出了页面(两条都留着),新测试的两条断言它逐条读过(第二条真的检查了「都在页上」)。**「不用未改名主语兜底比对」这个判断它用实验支持了**:把 `quantity_conflict` 改成主语不等时退回比值键,`Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` 立刻变回 `('holds','22','24')`,即第 2 轮 D37 的误报复活。反向实验(去掉 `_numbers_follow` 条件)红名单仍是 4 条、全套 4 failed / 379 passed,**新加那条不变红**(无条件改名下它两条断言仍成立)。20 对冲突 + D40 五对 + `Room 3 is big` 边界逐字复现。门禁全部重跑且与本报告逐格一致(384 passed、六文件 83、conflicts 27、三文件 35、mypy 与 `9cb46c1` 文件集合与去行号文本逐字相同、tokenize 148 个 `.py` 0 块、最大文件 `bench/systems.py` 498、go `-count=1` 全套 + gofmt 空、`index-CSDHCFNo.js 221.48 kB`、smoke PASS)。两条精度说明(不算缺陷)已采纳:P-e 把 D44 成因从「一侧改名」改成「改名后主语键各自吃进自己的数字,两侧都改名也永不相等」(我另测 `温度 22 度 湿度 40` + `温度 24 度 湿度 40` → 0 条证实对称情形);P-f 把「指纹补上 8799 那一口」限定为旧构建那一口。判定:p2-3.1/3.2/3.3/3.4 与两条验收全部 `[x]`。
|
||||
- 顺带:`cli.py` 495→432 行(写路径拆进 `cli_memory.py`),`bench/leg_check.py` 拆出 `bench/leg_report.py`;typer 0.27.2 用**关键字**调用回调,`*atom_ids: str` 形式的命令一调就 `TypeError`,改成 `list[str]`——这才是之前 `test_maintenance` 那条 TypeError 的真因,命令重名只是表象。报告 stamp 早于 harness 的缺陷也修了(`a52c104`)。
|
||||
- [ ] **p2-4** 共享记忆
|
||||
- [ ] p2-4.1 多用户/多 agent 作用域 + ACL
|
||||
- [ ] p2-4.2 默认可见性:answer-only 转介
|
||||
- [ ] p2-4.3 能力卡:让外部 agent 能路由进 ely-eye
|
||||
- 验收:两 holder 互不可见;越权被拒;一次真实跨 holder 会话证明能力卡可用
|
||||
- [x] **p2-4** 共享记忆
|
||||
- [x] p2-4.1 多用户/多 agent 作用域 + ACL(schema v11:holder token + 三态眼睛)
|
||||
- 事实:`holders` 加 `token_hash` / `token_issued_at`(`migrations.py:92` DDL,`_NEW_HOLDER_COLUMNS` 给老库 ALTER)。**只存哈希**(`sha256`,`holder_store._token_hash`),所以拷走的库不能重放 token;明文只在 `POST /api/holders/{id}/token` 的响应里出现一次(`IssuedToken`),`GET /api/holders/wife` 只回 `token_issued_at`。生成用 `secrets.token_urlsafe(32)`,比对用 `secrets.compare_digest`。`register_minimal` 是 `INSERT … ON CONFLICT DO NOTHING`,`remember` 在写记忆前调它(`memory.py:120` 之后),所以被转介的人一定有目录行,且它绝不覆盖人已声明的 name/contact。
|
||||
- 身份是**服务器算的**:`api_auth.resolve_caller` 三选一——operator(全局 token,可指名任何人)、holder(自己的 token,`verify_token`)、anonymous(没 token)。`read_as(caller, claimed)` 把「调用者声称的 holder」降级成「服务器认定的眼睛」:operator 不声称 → `None`(整库),operator 声称 → 那个人,holder 声称自己 → 自己,**声称别人 → 403 并把两个人都点名**,anonymous 声称任何人 → 403。`/api/search`、`/api/atoms`、`/api/atoms/{id}/image`、`/api/chat`、`/api/memory/working`、`/api/memory/conflicts`、`/api/ask` 全部走它;`remember`/`supersede` 的 holder 也只能是 caller 自己(p0-2 那条「客户端不能声称自己是别人」现在由 token 保证,不再靠 `default_holder` 兜底)。
|
||||
- **眼睛是三态、不是一个字符串的两可**(跑出来的决定):`NULL` = 整库(operator,或自己拥有这个 store 的进程),holder id = 那个人的眼睛,`''` = 谁也不是(无 token 的 HTTP 调用者)。让 `NULL` 也代表「谁也不是」会逼 CLI、proofs、compiler、bench harness、`who_knows`、`ask` 全都传眼睛,那跟 p0-2 定的「拥有 store 的进程按本机受信主体读」直接冲突——第一次这样改时 `pytest -q` 红了 29 条(`test_vector_index.py:46` `assert [] == ['mem-eaf3b790ca27bf3e:text']`、`test_proofs.py` 回放 0 hits、`test_visual_index.py:43` `ValueError: invalid literal for int() with base 10: 'widget gadget sprocket'`,因为一条本来被跳过的腿开始跑了)。`''` 能当「谁也不是」是因为本 schema 里 `holder_id = ''` 本来就是「无主」。
|
||||
- 修前是真的漏:`browse.py`(`GET /api/atoms` 与 `/api/atoms/{id}/image`)**完全没有可见性规则**,无 token 就能翻页读到别人的记忆正文和图片;`store_search.py:81-86` 与 `vector_index.py:245-252` 的规则是 `(? IS NULL OR holder_id = '' OR scope='everyone' OR holder_id = ? OR …)`,`as_holder IS NULL` 短路成「整库」,所以无 token 搜索也看见全部。现在五处副本统一成 `(? IS NULL OR holder_id = '' OR json_extract(visibility_json,'$.scope') = 'everyone' OR (? <> '' AND (holder_id = ? OR EXISTS (… value = ?))))`,**四个参数同一个值**。
|
||||
- 规则为什么写五份而不抽常量:semgrep `python.sqlalchemy.security.sqlalchemy-execute-raw-query` 对 `connection.execute(f"…{常量}…")` 报警,哪怕那个常量是模块级的,所以只能写整句字面量。防漂移靠 `backend/tests/test_visibility_rule.py`:`EXPECTED_COPIES = 5`,正则抓出每处、`_normalize`(去空白、去引号、去 `a.` 别名)后必须**只剩一个字符串**,第二条测试钉住 clause 里 `? IS NULL` / `holder_id = ''` / `$.scope')='everyone'` / `? <> ''` / `holder_id = ?` / `value = ?` 六个特征还在。第一版正则 `\(\? IS NULL[^;]*?` 会把 `(? IS NULL OR modality = ?)` 这类过滤谓词一起吃掉,读出 4 份不同 clause——那是正则错,不是规则漂移。
|
||||
- [x] p2-4.2 默认可见性:answer-only 转介(p0-5 的转介现在被 ACL 兜住)
|
||||
- p0-5 只做了一半:`ask` 命中别人的时候只回指针不回正文(`_pointer`),但**搜索本身**还是能读到正文,所以「只给指针」是客气不是边界。现在无 token 的 `search_atoms` 搜不到那句私钥(smoke 实测),`ask` 的 `answer` 仍为空、`secret` 在整个 referral JSON 里不出现,而 `who_knows` 照旧能用——目录路由**故意不设卡**,因为它只出计数、分数、contact 和声明过的话题,不出记忆正文(`api_holders.py` 注册处一行注释写明)。`visibility` 三档实测:默认 `holder`(只有主人)、`everyone`(无 token 可搜到)、`visible_to: [husband]`(主人 + 被指名的人能读,陌生人和第三个 holder 不能)。
|
||||
- [x] p2-4.3 能力卡:让外部 agent 能路由进 ely-eye
|
||||
- `GET /api/card`(整库)与 `GET /api/card/{holder}`(一个人),CLI `ely-eye card`,MCP `ely_eye_card`(Go `apiclient.Card` / `HolderCard`,空 id 在**发请求之前**拒)。卡是**派生的不是声明的**(`card.build_card`):`PRAGMA user_version`、`sources` 计数、`v_atoms_current` 按模态分组、每人 `(条数, MAX(ingested_at))`、每人模态/场景、合并目录、`has_audio = "audio" in senses`、挂载状态。所以它不能承诺一个 store 里没有的模态——`has_audio: false` 就是把视觉/音频腿的缺口在**别人提问之前**说出来。
|
||||
- 无 token 就能读(smoke 实测 HTTP 与 MCP 两条路都通),且卡里**不含任何记忆正文**(smoke 断言 `secret not in json.dumps(card)`);`token_issued` 只说「这个人有 token」,不发 token。卡的 schema 版本与整库卡一致这条也进了 smoke 的 tokenless 分支。
|
||||
- 偏差(都是跑出来的):`/api/memory/working` 与 `/api/memory/conflicts` 现在拒绝「谁也不是」的调用者(422,消息含「somebody」),因为这两件事本来就属于某个人;`compiler.py:41` 的缓存 key 加了眼睛——先前写 `as_holder or ""` 会把 operator 的上下文和陌生人的**撞成同一个 key**(两边都哈希 `""`),于是陌生人那份空 pack 会端给 operator;`forget` 的级联、卡带成员、目录都不因 ACL 改动。
|
||||
- 验收:`pytest backend/tests/test_holder_acl.py backend/tests/test_visibility_rule.py backend/tests/test_transactive.py` → **13 + 2 + 19** 全绿(两 holder 互不可见、无 token 读不到私钥、`everyone` 无 token 可搜、`visible_to` 只放行被指名的人、声称别人 403 且点名两人、token 只出现一次且库里只有 sha256、吊销后旧 token 401、卡无 token 可读且不含正文、陌生人的图片 404 而无主的 200、`compile(as_holder="")` 返回 0 hits 且 `cache_trace_id` 与 `as_holder="wife"` / `None` 都不同)。跨进程:`bash scripts/mcp_write_smoke.sh` → 两行 PASS,第二行是「a caller with no token sees only public memory, a holder's token sees her own, naming somebody else is refused, and the capability card answers without a token」——它先证明无 token 搜不到私钥,再用 operator token 铸出妻子的 token、证明她能搜到自己的、搜不到 `local` 写的 `the oven needs descaling`,最后**把 MCP 进程不带 `ELY_EYE_AUTH_TOKEN` 重启一遍**,证明 Go 那条路守同一条规则(不是假设)。
|
||||
- 顺手修的两条自己写的断言缺陷(都是跑出来的):smoke 里 `if secret in found(None, token=token)` 是同义反复(搜的还是那句话,当然命中),改成拿**别人写的**记忆当探针;指纹探测 `GET /api/memory/working?holder=fingerprint` 现在返回 **403**(路由在、但匿名不能声称 holder),所以探测必须带 operator token——旧树上没有这条路由仍是 404,判别力保住。
|
||||
- 门禁:`pytest -q` **401 passed**;`ruff check` 对 22 个改动文件干净;`bench/filesize_check` 干净;`bench.audit --product` → `audited 32 harness files, 2 reports, 6 set files` / `clean`;mypy 冷缓存 **24 errors in 9 files (checked 112 source files)**,与 `9cb46c1` 基线(24/9)**净新增 0**;`./node_modules/.bin/pyright` 对 17 个后端源文件 + 5 个测试文件 → **0 errors**;`go build` / `go vet` / `go test`(apiclient ok、mcpserver ok)+ `gofmt -l` 空;`npm run build` → `dist/assets/index-CSDHCFNo.js 221.48 kB`(bundle 哈希与 `246dae9` 相同,与「本轮没动前端」一致)+ eslint exit 0;`bash scripts/mcp_write_smoke.sh` 3.3s 两行 PASS。
|
||||
- [ ] **p2-5** 文档单一真相
|
||||
- [ ] p2-5.1 `AGENTS.md` 重写并停止 gitignore
|
||||
- [ ] p2-5.2 `README.md` 每条宣称指向可重跑命令
|
||||
|
||||
Reference in new issue
Block a user