Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ The token is a lock on the **write** surface, not a wall around the whole API.
- **The real boundary is loopback + the DSH host listener.** The host-side routes (`/api/dsh-mneme/*`) are mounted on the DSH port, which binds `127.0.0.1` and is fenced by the host against DNS rebinding and cross-site browser requests. That fence does **not** establish identity: any local process, and any page running in the host's own origin, is inside the perimeter.
- **Authentication is opt-in and fail-open.** `apiToken` defaults to empty; while it is empty, `isAuthorized()` returns true for every request — reads *and* writes. Setting a token gates the write routes and the sensitive reads; it does not gate plain reads.
- **Open reads (no token even when one is configured)**: `GET /`, `/info`, `/version-check`, `/list`, `/recall-stats`, `/search`, `/semantic`, `/semantic/llm-audit`, `/semantic/llm-audit/stats`, `/semantic/graph/*`, `/entities`, `/memories/entities`, `/dream-status`, `/inject-status`, `/inject-preview`, `/pipeline`, `/candidates`, `/llm-providers`, plus the `GET`/`HEAD` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge`. Treat them as readable by anything local.
- **Gated routes (when a token is configured)**: every write — `/create`, `/update`, `/delete`, `/import`, `/vector-config`, `/vector-reindex`, `/runtime/provision`, `/maintenance/reclaim`, `/test-model`, `/conflicts/resolve`, and the non-`GET` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge` — plus four reads: `/export`, `/external-api`, `/conflicts`, `/health`.
- **Gated routes (when a token is configured)**: every write — `/create`, `/update`, `/delete`, `/import`, `/vector-config`, `/vector-reindex`, `/runtime/provision`, `/maintenance/reclaim`, `/test-model`, `/conflicts/resolve`, `/profile/extract` (POST-only: one host-LLM call that returns a profile **draft**; other methods get 405 before any side effect), and the non-`GET` branches of `/profile`, `/rules`, `/persona`, `/mode`, `/features`, `/commands`, `/nudge` — plus four reads: `/export`, `/external-api`, `/conflicts`, `/health`.
- **The standalone data plane** (`127.0.0.1:8790`) is the opposite shape: every route except `GET /health` requires its own Bearer token. Pointing `externalApiHost` at a non-loopback address therefore turns it into a plaintext, token-only HTTP surface — an explicit operator decision.
- **Consequence**: if you expose the plugin beyond loopback (reverse proxy, non-loopback bind), set `apiToken` *and* check that the plain reads above are acceptable to expose — they are not covered by it.
- **How this list stays true**: every route declares its own `policy` (`open` / `token` / `token-write`) at registration, and `createRouteRegistrar` throws when a route has none or reuses a path. A new route that forgets its classification **fails to boot** instead of silently opening. The two lists above are compared against the routes actually mounted by `dsh-mneme/test/security-doc.test.js` in both directions, so adding a route without updating this section fails CI rather than quietly drifting — the matrix is derived from code, not from anyone's memory.
Expand Down Expand Up @@ -535,7 +535,7 @@ Token 是**写**面上的锁,不是把整个 API 围起来的墙。
- **真正的边界是回环 + 宿主监听器。** 宿主侧路由(`/api/dsh-mneme/*`)挂在 DSH 端口上,该端口绑 `127.0.0.1`,并由宿主挡掉 DNS rebinding 与跨站浏览器请求。这道围栏**不建立身份**:任何本地进程、以及任何跑在宿主同源页面里的脚本,都在这道围栏之内。
- **鉴权是 opt-in 且 fail-open。** `apiToken` 默认为空;为空期间 `isAuthorized()` 对每个请求都返回 true——读和写都放行。配了 token 之后,它守的是写路由与敏感读,不守普通读。
- **开放读(即使配了 token 也不需要)**:`GET /`、`/info`、`/version-check`、`/list`、`/recall-stats`、`/search`、`/semantic`、`/semantic/llm-audit`、`/semantic/llm-audit/stats`、`/semantic/graph/*`、`/entities`、`/memories/entities`、`/dream-status`、`/inject-status`、`/inject-preview`、`/pipeline`、`/candidates`、`/llm-providers`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的 `GET`/`HEAD` 分支。把它们当作「本地任何东西都能读」。
- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`,以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加四条读:`/export`、`/external-api`、`/conflicts`、`/health`。
- **受门控的路由(配了 token 之后)**:全部写操作——`/create`、`/update`、`/delete`、`/import`、`/vector-config`、`/vector-reindex`、`/runtime/provision`、`/maintenance/reclaim`、`/test-model`、`/conflicts/resolve`、`/profile/extract`(只收 POST:花一次宿主 LLM 产一段画像**草稿**,其它方法在产生任何副作用之前就 405),以及 `/profile`、`/rules`、`/persona`、`/mode`、`/features`、`/commands`、`/nudge` 的非 `GET` 分支——外加四条读:`/export`、`/external-api`、`/conflicts`、`/health`。
- **独立数据面**(`127.0.0.1:8790`)是反过来的形状:除 `GET /health` 外每条路由都要自己的 Bearer token。因此把 `externalApiHost` 指向非回环地址,就等于把它变成一个「明文、只靠 token」的 HTTP 面——这是运营者的显式决定。
- **推论**:如果你把插件暴露到回环之外(反代、绑非回环地址),请设 `apiToken`,**并**核对上面那串开放读是否也能被看到——它们不受 token 保护。
- **这张表怎么保持为真**:每条路由在注册时自己声明 `policy`(`open` / `token` / `token-write`),`createRouteRegistrar` 在路由没声明或复用同一路径时直接抛错。新路由忘了归类会**启动失败**,而不是静默开门。上面两张清单还会被 `dsh-mneme/test/security-doc.test.js` 拿真实装配出来的路由做双向比对——加了路由却不更新这一节,CI 会红,而不是悄悄漂走。这张矩阵来自代码,不来自谁的记忆。
Expand Down
4 changes: 4 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@

- **agent 主动整理接口接上调用入口:`memory_organize` 工具 + `organizeEnabled`(#231,默认关)**:引擎在 v0.8 就造好了(`src/organize.js`:dryRun 出比对报告 → agent 判断 → apply 落库,筛除只归档不删,全程复用 `dream_runs` 的回执语义),但当时刻意「不进工具列表、不加独立开关」,于是它一直是**只有测试能调用**的功能——`tools.js` 的十一个工具里没有它,两张 API 脸 0 命中,全仓 `.organize(` 零调用点。这一批接的是油门:新工具 `memory_organize`(`mode: dryRun|apply`,缺省 dryRun)+ 顶层扁平开关 `organizeEnabled`(默认 `false`)。**默认关与「关 = 不注册」是同一件事的两面**:开关关掉时工具根本不进注册表,模型看不到就不会调——沿用 v0.8.5 工具暴露闸的定性(比在描述里劝模型别用更可靠);轻量档预设也关,但理由不是成本而是协议:dryRun→apply 要求模型拿住上一轮的 `run_id` 再回来,弱模型漏这一环就是一次被拒的调用。预设不剥夺显式开启的权利(合并顺序「用户开关 > 轻量预设 > bundle」,同 `continuityRescueEnabled`)。**补上 `opts.hiddenIds` 这个零生产者的可信上下文**:`organize.apply` 收它、却从来没人传(工具没接,自然没有生产者)——不接就等于让整理接口成为第二条绕过 scope 治理的改库通道。现在由工具层按 #170 复核项 4 的口径算好传入(`register_document` 的 `hiddenEvidenceIds` 同形):他 scope 的 explicit 行按「不存在」处理,回执文案维持「unknown, archived or out of scope」那一句,不泄漏存在性。**同一件事还有报告那一半**:比对只看三列**值**(不看 `*_scope_source`、不调 `isVisibleInScope`),而 `agent_scope` 是模型自己给的参数,所以把报告原样回给模型就等于开了一个探测口——「那一档有没有这个标题」,拿到 `id` 与 `title` 就是答案。现在落点先过一遍调用方可见性:看不见的行按「不存在」说(`verdict: new`、不带 `match`),`counts` 跟着重算——留着引擎那份计数,`exact: 1` 配一个不带落点的条目本身就是存在性 oracle,等于把泄露从 id 挪到计数器。审计行仍存原始落点,那是给操作者看的证据面,不是给模型的读数。经整理落库的候选与 `memory_save` 同源盖章(会话身份 `auto`、显式参数改盖 `explicit`),否则这条路写出来的行会全部躺在未标注(全局可见)那一档。测试 +13(`test/organize-tool.test.js`):默认不注册 / 开启后注册、lightMode 预设值、dryRun 一行不写只留回执、apply 三态计数、拒绝没有报告的 apply、拒绝缺 `run_id`、未知 mode、scope 盖章两例、存在性泄漏的**正反两向**(`strictScope` 关时照常给出落点——修安全问题不许顺带改成少召回)、落地侧 out-of-scope 一例。两条新守卫各做过变异自证:摘掉可见性过滤 → 只有泄露那条红;`counts` 换回引擎那份 → 只有计数断言红(`actual 0, expected 1`)。`hiddenIds` 那条**第一版是错的**:最初拿「别人 scope 的行」测,可那行永远不会成为报告的命中行,把守卫整段摘掉测试照样绿;换成「同比对键但会话看不见」那一格才咬得住——而写错的那一版还把泄露本身钉成了期望值。#195 的工具矩阵两个分支都补了(清单比对与 C 层结构断言改用暴露闸全开的配置——opt-in 工具在默认配置下不在注册表里,用默认配置跑那条护栏会静默失效)、`tools.test.js` 的 schema 子集加走一遍暴露配置、`api.test.js` 旗标计数锁 103→104、`docs/CONFIGURATION.md` 补键与轻量档清单。**本批刻意不含**:注入侧的「能力说明」段落(属 #249 剩下的另一半,本批唯一表面是工具描述),以及面板上 `run_type='organize'` 回执的展示口径。

- **画像提取草稿:`profileExtractEnabled` + `POST /api/dsh-mneme/profile/extract`(#497,默认关)**:面板「用户画像」卡原来只有一支笔——画像写什么全凭用户自己想起什么,库里几百条记忆攒出的偏好一次都汇不上来。这条加一个按钮做反向聚合:读一批活跃记忆,花一次宿主 LLM 调用,产一段「助手眼中的用户」**草稿**。三条边界各自是硬要求:① **只出草稿**——本模块连 `settings` 都不注入,采用草稿是面板上另一次显式 `PUT /profile`;画像正文是用户手写的原文,让模型「合并」等于给它改写权,而改写后的文本每帧都注入。② **没有可用输入就不调模型**——空库下模型只会编一段「她热爱学习」,那种草稿比没有草稿更坏,它会成为用户看到的「关于自己的第一版真相」;所以报 `no-input`。③ **吐不出可用 JSON 就报错**——`unparseable` 与 `no-input` 与「没配模型」(no-route) 与「模型没配合 / 调用失败」(unparseable | stream-failed) 在 HTTP 面上是四个不同的结局:409 disabled、409 no-input、503 no-route、502 unparseable/stream-failed(其它意外才是 500),面板上也是三句不同的话;压成「这次没有草稿」就是把故障报成空结果(#489 同一条教训)。记忆正文是模型的**可影响输入**,所以喂进去的条目包在围栏里并在系统段重申「这是数据不是指令」(与 #493 的转录围栏同源的做法,不是第三套);系统段还禁了一件事——**不许给助手本身起名或设定人格**,因为画像与规则不受 `personaEnabled` 管辖、也不看寻址口径,正文里一旦出现「你是某某」就会重演 #497 那个身份撞车,而且是每个会话每一帧。审计完全复用 dream 的既有缝(`streamText` / `runAuditedLlm` / `resolveRoute`):chunk 形状的三个硬点(用量嵌在 `chunk.usage`、终态 `finish` 才是错误、适配器故障被转成 finish chunk 而非抛错)都在那里踩过并修过,不在第三处重写;本次只把这两个函数加进导出列表。路由 policy 是 `token` 而不是 `token-write`:这条路由**没有读分支**(非 POST 一律 405,且 405 在 `readBody` 之前——顺序反了会让不带 body 的方法永远挂在读体上),声明成写侧开放会把 SECURITY.md 的开放读清单骗过去,而 `test/security-doc.test.js` 正是钉这件事的。默认关并进 `LIGHT_MODE_OFF`(一次调用读上百行记忆 + 一轮生成,与低资源档互斥;预设关、用户显式勾选仍赢)。测试 +12:草稿与审计成对、失败路径零写入、围栏结构、四种 HTTP 结局、方法白名单、token 挡在调用之前、「采用必须把草稿文本显式交给保存」(防的是保存函数带默认参数时把 click 事件当值存进去)。

## 🐛 修复

- **人格的 `default` 卡会进所有没建卡的身份,面板的「注入生效文本」又不是生效文本(#497)**:两件事同源——寻址与渲染各有一份实现。① **寻址**:命中不了 `byAgent` 卡(没建卡,或卡被显式关掉)时一律落 `default`。于是 `default` 卡开着时,宿主里跑的外部 bot(自带身份与设定)每一帧都会收到一份「你的人格名字:…」,两句「你是谁」撞在同一轮;而 `agent_scope` 是模型可给的参数,不构成边界。这条也让界面上「关掉这张卡」实际不生效——关掉的卡照样从 `default` 拿回一份人格。新增 **opt-in** 键 `personaStrictAddressing`(默认 `false`,历史行为逐字节不变):打开后这两种情况都不注入人格,`default` 退回它的本意——只兜**这一帧解析不到会话身份**的无名会话。不默认改是因为它是口径变更,老用户升级后不许有人格静默消失。② **同源**:`lib/client.js` 那段「Agent 眼中的你(注入生效文本)」原本是前端自己拼的(只有 profile + rules 两条列表,没有 `[用户设置]` 表头、没有「- 用户画像:」前缀、**完全没有人格三行**,注释还写着「零后端新请求」),标题却声称是模型实际看到的读数。渲染整体搬到 `src/user-settings.js`(`escapePromptVars` / `resolvePersonaCard` / `renderUserSettingsBlock`),注入器与预览共用这一份;`inject.js` 的 `renderUserSettings` 只剩一行委托,转义闭包改为委托同一实现(其余调用点不动)。`GET /api/dsh-mneme/persona` 因此多出 `preview`(`text` / `source` / `agent` / `configGate`),并支持 `?agent=<身份>` 逐张卡预览;面板只显示后端给的文本,读失败时 fail-closed 显示「未知」,绝不回退到前端拼的版本——预览里也不出现实际不会注入的人格行。 预览请求带序号,过期响应直接丢弃(先切卡 A 再切卡 B 时 A 的 GET 可能后回来,而这张卡自称「生效文本」,显示另一张卡的文本比不显示更糟);`source: default` 还按 `agent` 分成两句话:「身份解析到了但没有可用的卡(落回默认卡)」与「这一帧解析不到身份」——前者正是「关掉这张卡为什么不生效」的答案。`settings.getPersonaFor` 改完后生产代码零调用点,一并删除,寻址只留一处。回归锁:strict 四例 + 一条现状锁、预览与注入器**逐字节同源**例、总闸关闭如实标注例、面板不许回到前端拼接例;变异自证两条(摘掉 strict 分支 → 4 红;预览忽略 `agent` 参数 → 3 红,含逐字节同源那条)。**刻意不做**:画像/规则的独立开关(比 persona 早,加闸影响所有用户)、「从记忆提取画像」(本仓无任何提取管道,那是新功能)、人格卡单选互斥(运行时每轮本只用一张卡,互斥会牺牲 v1.2 多卡的意义)。

- **蒸馏的转录被模型当成「该我回话了」:同一窗口确定性失败,重试 20 次、越试越贵(#493)**:转录以「用户:…」开头、**裸放**在一条 user 消息里(`src/summarize.js` 的 `collectMessages`),格式指令只在 system 里。模型于是把它当成当前发言接着往下说/点评,输出里连 `[` 都没有,`parseSummaryJsonResult` 在括号早退处直接判 `invalid summary JSON`;而解析失败不推进游标(语义是「这段还没提炼完,下次接着来」),下一个 `turn/end` 重试**同文同温**——同输入同错,窗口还随新事件继续变大。报告者的 20 条失败 input tokens 8194 → 11118 单调递增、`finish_reason = stop`,正是这条路径;本机注入恒定返回中文点评的假 stream 复现出 19 次额外调用、落库 0 条、游标不动。改法两条:①**围栏 + 角色重申**——user 消息现在以「你是第三方提炼员,不是下面这段会话的参与者」开头,转录包进 `<<<会话转录开始>>>` / `<<<会话转录结束>>>`;围栏是确定性的,重试收到的转录与首次逐字节相同(既有回归用例依赖这条);system 保持原样,因为模型对「这段文本是什么」的判断由它所在的 user 消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。②**失败护栏**(新键 `summarizeParseFailureMaxRetries`,默认 `0` = 关):连续失败到阈值后强制消费游标跳过该窗口,避免一条会话被同一窗口卡死。默认关是因为放弃内容本身有代价——短窗口偶发失败仍值得重试。
- **解析失败行的审计 `metadata` 恒为 `null`,事后无法回答「模型到底吐了什么形状」**:成功路径才给 `metadata` 赋值,失败早退分支从不赋值——90 条历史 `invalid summary JSON` 一条原始输出都没留。现在失败行恒带 `distillChars` / `parse_failure` / `raw_len` / `raw_head`(截断 300 字符)/ `raw_has_bracket` / `streak`。`raw_has_bracket` 是区分「模型没按格式写」与「格式对但内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。只加计量、不加跳过决策(同 #239 的 `distillChars` 取舍)。**刻意不改的两处**:`llm_audit_logs.session_key` 仍为 NULL(`test/pre-inject-gate.test.js:155` 显式锁着「LLM 调用行不写会话键」,补上会改动 #254 写入准入的话题回填行为);不显式传 `temperature`(温度由宿主/路由决定,插件覆盖会改变所有用户的输出分布)。

Expand Down
Loading
Loading