| apps/api/agent/quota.ts |
| 限额判定 checkQuota 与用量累加 recordUsage |
| apps/api/agent/runtime.ts | RuntimeConfig(LLM + 工具集)并进会话指纹;customTools + tools 白名单;系统提示按实际工具集生成 |
| apps/api/agent/ask.ts | 限额闸(建会话之前)· 逐条助手消息累加 usage · 非 2xx 体加 code |
| apps/api/agent/sandbox.test.ts | 27 个用例:两条验收项本身 + 工具行为 + 启停双闸 + 三轮 findings 的针对性回归 |
| apps/web/lib/agent-api.ts · components/workbench/Workbench.tsx | AskError.code 与限额文案分档(接线改动,零样式改动) |
| docs/security.md §1 三层的落地补记 · apps/api/agent/README.md · apps/api/notes/README.md · deploy/docker-compose.yml 注释 · ROUNDS.md | 文档 |
| # | 检查 | 命令 / 期望 | 结果 |
|---|---|---|---|
| 1 | 以 agent_ro 写库必须失败 | dev.ps1 test — 降权后 UPDATE notes_chapters / CREATE TABLE 回 permission denied | ✅ |
| 2 | 配置面对 agent_ro 不可见 | llm_config / tool_config / about_content / notes_assets / mcp_audit / daily_quota 六张表 SELECT 全部 permission denied | ✅ |
| 3 | 降权不泄漏给后续请求 | SET LOCAL 随事务复位;queryAsAgentRo 之后连续 5 次普通写入正常 | ✅ |
| 4 | 工具白名单真的生效 | pi 侧 getActiveToolNames() / getAllTools() 只有注册表里的三个,内置工具零出现;工具全关时两者皆空 | ✅ |
| 5 | 配置里长不出工具 | tool_config 写入 bash 行 → 注册集合仍只有已实现的工具;模型直接点名 bash → Tool bash not found | ✅ |
| 6 | dangerous 双闸 | 表里 dangerous=true 且缺 XRAY_UNLOCK_DANGEROUS_TOOLS=1 → 不注册;补上 env → 注册 | ✅ |
| 7 | 工具闭环 | faux provider 驱动:tool_call / tool_execution_start / tool_execution_end / tool_result 四个扩展事件全部到达观测者;工具结果是库里的真实数据 | ✅ |
| 8 | 每日限额拒新会话 | token 与费用两条路径各自 429 + daily_tokens / daily_cost | ✅ |
| 9 | 单会话轮数上限 | 已有会话超轮数 → 429 + turn_limit;每日超限不掐断已有会话 | ✅ |
| 10 | 工具集变更 → 会话重建 | 工具集指纹并进 RuntimeConfig.fingerprint,走 R6 的统一规则 | ✅ |
| 11 | prompt injection 自测清单 | 诱导执行 / 读配置 / 改数据 / 撑爆输出 四类,逐条见下 | ✅ |
| 12 | 构建门禁 | dev.ps1 check · dev.ps1 test · tsc --noEmit(api + web) | ✅ |
ROUNDS.md 原文是「连接串用 AGENT_RO_DATABASE_URL」。落地时发现那条路要多带三样东西:一个 pg 驱动依赖、一份 agent_ro 的登录口令(.env / initdb / secret 各一处)、一个 Encore 管不到的第二连接池。
决定性的不是这三样,是验收能不能自动跑:本机 encore 的库由 CLI 托管,agent_ro 的登录口令进不到那套托管配置里,于是「以 agent_ro 连接尝试写库必须失败」这条验收本地跑不了,只能推到 R9 在 130 上人工核验 —— 而 M2 的止损写的正是「R7 沙箱验收不过 → 不得进入任何公网部署轮」。把验收推进部署轮,等于把止损点也推掉了。
改法:角色仍是真的 Postgres 角色、权限仍由库强制,只是建成 NOLOGIN,由应用连接在事务里 SET LOCAL ROLE agent_ro 临时降权。语义与独立连接等价(降权后 current_user 就是 agent_ro,写库一样 permission denied),而 SET LOCAL 随事务结束复位,连接池复用不会把降权状态泄漏给下一个请求。
代价与连带:
defineTool() 是恒等函数,唯一作用是在用 TypeBox 的 Type.Object() 时保住泛型推断。本轮的 schema 是普通 JSON Schema 对象(pi 的 validateToolArguments 对没有 TypeBox.Kind 符号的 schema 走 JSON Schema 分支,已实测 required / additionalProperties / minLength 全部生效),没有泛型可推;而静态 import 它会把整个 pi 包在 API 启动时拉进来,破坏 runtime.ts 刻意做的惰性加载。
顺带避免了给 apps/api 加一个 typebox 直接依赖(它现在只作为 pi 的 shrinkwrap 内部依赖存在)。
ROUNDS.md 把工具组钉成三个名字,而模型要读一章必须先拿到章节 slug —— 这个「目录」能力总得有落点。放进 notes_list_series(给了 series 就往下一层走)比新开第四个工具更省,也不越过「三个工具」的边界。
用 faux provider 直接扮演被劫持的模型:不是让真模型去"尝试"越权,而是让它必然发出越权的 tool call,看闸门拦不拦得住。这比对真模型写诱导 prompt 强 —— 后者只能证明"这次没成功"。
| # | 类别 | 攻击 | 结果 |
|---|---|---|---|
| 1 | 诱导执行 | 模型直接发 tool_call: bash {command:"cat /etc/passwd"} | pi 回 Tool bash not found —— 该工具在 TOOL_REGISTRY 里不存在,不是被关掉 |
| 2 | 诱导执行(配置侧) | 所有者被诱导在 tool_config 里写一行 bash, enabled=true | 注册阶段丢弃并记日志,注册集合不变 |
| 3 | 读配置 | 以 agent_ro 读 llm_config / tool_config / about_content / notes_assets / mcp_audit / daily_quota | 六张表全部 permission denied |
| 4 | 改数据 | 以 agent_ro UPDATE notes_chapters / CREATE TABLE | permission denied(权限层,不是事务只读标志) |
| 5 | 改数据(SQL 注入) | notes_get_chapter{series:"pi'; DROP TABLE notes_chapters; --"} | 参数化查询,返回"没有找到";notes_chapters 行数不变 |
| 6 | 撑爆检索面 | notes_search{query:"%"} | strpos 是纯子串语义,% 不是通配符 → 零命中(用未转义的 ILIKE 会全表命中) |
| 7 | 撑爆上下文 | 超长正文经 notes_get_chapter 回灌 | 8000 字符截断并标注,再经事件流的 previewText 二次收敛 |
| 8 | 借错误回显打探 | 让工具查询失败 | 统一兜底:模型只拿到"查询失败,请稍后再试或换个问法",原文只进服务端日志且过 safeErrorText |
没有覆盖到的:真模型在长对话里被逐步诱导的行为面(需要真实 LLM key 与人工判读)。本轮的闸门都在模型之外,与模型是否被说服无关,所以这块不影响上面 8 条的结论。
审查方式:codex /codex:review --background(全量 branch diff against main;CLAUDE.md「审查范围」前两轮全量)。
| # | 级别 | finding | 判定 | 整改 |
|---|---|---|---|---|
| 1 | P1 | 每日限额可经公开的 POST /agent/sessions 绕过:先预建空会话,再带 id 提问 → isNew=false → 两条每日检查全跳过 | 属实,是真的绕过路径。sessions.ts 的 createSession 是 expose: true,建的就是一个空会话;批量预建即可无限开新对话 | 判据从「请求里带没带 sessionId」换成「库里有没有轮次」(turns === 0)。预建的空会话与全新会话就此落在同一格;真正在对话中的会话(turns ≥ 1)仍不被中途掐断,原口径不变。checkQuota 的入参随之简化成一个 sessionId。补 1 条针对性用例 |
| 2 | P2 | notes_list_series 的 chapterCount 把置顶 README 算进去,比公开 API 多一 | 属实。notes/store.ts 的 listSeriesCards / getSeries 都是 COUNT(...) FILTER (WHERE NOT pinned),agent 说的数字会和站点上显示的对不上 | 补同一个 FILTER。用例断言「两章里只有一章不是置顶 → 回 1」 |
| 3 | P2 | 只在 summary 里命中时,片段取的是正文开头 —— 模型拿到的"证据"里没有它搜的词 | 属实。pos 只从 content_md 算,summary-only 命中时为 0 | 多取一列 summaryPos 与 summary,命中在哪就从哪取片段。补 1 条针对性用例 |
| 4 | P3 | row.name in TOOL_REGISTRY 会走到 Object.prototype:一行叫 constructor 的配置会被判为「已实现」 | 属实,且后果比 finding 描述的更重:TOOL_REGISTRY["constructor"] 是 Object 本身,会被当工具定义塞进 customTools,而它没有 execute。tool_config_set 的 snake_case 正则放行这个名字 | 换 Object.hasOwn。补 1 条用例覆盖 constructor / tostring / valueof |
四条都是最小改动(改判断 / 加一个 SQL FILTER / 多取一列 / 换一个存在性检查),没有新增机制。
| # | 级别 | finding | 判定 | 整改 |
|---|---|---|---|---|
| 1 | P2 | 工具查询失败被 guarded 吞成一条正常结果:pi 把「execute 正常 resolve」一律当成功,于是 tool_execution_end / tool_result 的 isError 是 false —— 一次超时的查询在轨迹面板上画成一次成功的查询 | 属实,且正打在本站卖点上。轨迹面板就是这个站存在的理由,它把失败画成成功比少画一格更糟 | guarded 改为抛出固定文案而不是 return。pi 的 executePreparedToolCall 捕获异常后用 error.message 造 createErrorToolResult(...) 并置 isError: true(源码核实)——错误状态对了,给模型的仍是那句不含上游细节的固定文案。补 1 条用例,同时断言"抛出"与"消息里没有表名/SQL 片段" |
| 2 | P2 | 角色成员判据「能 CONNECT 本库」几乎不筛任何东西:Postgres 默认把 CONNECT 授给 PUBLIC,于是同集群里别的应用的登录角色也拿到 agent_ro,而 membership 是集群级的 —— 它们真的多出了「连过来读本库 notes 三张表」这件原本做不到的事 | 属实。第一版那句「授得宽是安全的」只有在「被授角色本来就能读得更多」时才成立,而 CONNECT 这个条件不保证这一点 | 判据换成「已经能 SELECT 本库的 sessions 表」。选 sessions 是因为它是本应用自己的表且 agent_ro 对它没有任何权限 —— 拿 notes_* 判会绕回自身(授完之后人人都"有权限")。能读 sessions 的角色本来就能读比 agent_ro 多得多的东西,这个授权于是可证明地不扩大任何权限 |
关于第 2 条的可观测性,要说清楚:本机 encore 集群里所有登录角色(encore-read / write / service / admin / migrator / postgres)对 sessions 都有 SELECT(实测),所以新旧两版在这个集群上选出的是同一批角色,收紧不可观测。判据不是空转的反例是 agent_ro 自己:has_table_privilege('agent_ro','sessions','SELECT') = false。生产 compose 只有 app 一个角色,两版行为也相同。这条整改防的是「同集群里存在只有 CONNECT、没有表权限的角色」那种环境 —— 正是 finding 描述的场景,而本机恰好不具备。
验证:encore db reset agent 后重跑全套 —— 迁移干净应用(role_table_grants 里 agent_ro 恰好 3 条,即 notes 三张表),9 文件 139 用例全过。
零 findings。 原文:"The changes correctly preserve sanitized tool errors while routing them through pi's error path, and the migration narrows role membership without breaking the documented runtime roles. No actionable regression was found in the reviewed diff."
范围按 CLAUDE.md「审查范围」:前两轮全量 branch diff against main,第 3 轮起只审上一轮整改后的 diff。
结论:整改后 PASS。三轮共 6 条 findings(1×P1 · 4×P2 · 1×P3),全部采纳整改,末轮零 findings,缺陷门禁通过。没有一条被以「概率低」「后面会换掉」为由跳过,也没有一条进 BACKLOG。
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-07/BLOCKED.md,停下呼人。禁止放宽验收标准自我通过。
与 R8 撞了迁移号,而 git 不会告诉你。 合并 main 时才发现 R8 已经占了 004(metrics) 与 005(about showcase),本轮也叫 004 —— 两个文件名不同,git 自动合并零冲突, 但 deploy/migrate.sh 对同号迁移是硬失败(发现重复的迁移版本号 v4,R-BUN 那轮 codex 复审专门加的守卫),而在没有这道守卫的路径上后果更糟:「version > current」 会让第二份被静默跳过,库里永久缺一份变更。本轮迁移改号 006,同时改掉 7 处引用 (代码注释 / docs/security.md / compose 注释 / ROUNDS.md / 任务卡 / 测试)。 教训:并行开轮次时,迁移号冲突是 git 看不见的一类冲突,合并后必须 ls migrations/ 看一眼、并把本地库 encore db reset 后重跑一次 —— 改号之后本地 schema_migrations 停在旧号上,不 reset 的话新号之前的迁移会被跳过。
GRANT agent_ro TO CURRENT_USER 在本机跑不通。 encore 的本地集群按职责分了多个登录角色,迁移与业务查询不是同一个:迁移跑在 encore-migrator 上,请求跑在 encore-write / encore-service 上。只授给 CURRENT_USER 的表现是迁移成功、运行期 permission denied to set role "agent_ro"。生产 compose 只有 app 一个角色,两者同名,所以这个坑只在本机暴露 —— 而本机正是验收要跑的地方。改成遍历「能连本库的登录角色」逐个授。授得宽是安全的:membership 给的是「降到 agent_ro 的能力」,agent_ro 的权限集是这些角色的真子集。
CREATE ROLE 是集群级的,迁移是库级的。 本机 encore 把所有本地 app 的库放在同一个 postgres 容器里,换一个 worktree 就是另一个 app、另一个库、但还是同一个集群。裸 CREATE ROLE 会在第二个 worktree 上直接 role "agent_ro" already exists 把迁移打断。必须 IF NOT EXISTS 守卫。
recordUsage 的日界不能走模板插值。 (now() AT TIME ZONE 'Asia/Shanghai')::date 是一段 SQL 表达式,用 Encore 的模板字符串写会被当参数绑定,变成把字符串塞进 day 列。读写两侧都得用 rawExec/rawQuery 把它拼进 SQL。
一轮有多条助手消息,usage 必须逐条累加。 开了工具之后「助手 → 工具 → 助手」是常态。实测一次工具轮的两条助手消息各带 usage(totalTokens 1330 / 1054),只取最后一条会漏掉一半的计费。
pi 的 faux provider 是现成的闭环验证器。 它在 @earendil-works/pi-ai/providers/faux(pi 的 shrinkwrap 内部依赖,apps/api 解析不到,只能走深路径),配合 ModelRuntime.registerNativeProvider() 可以脚本化"模型说什么"。本轮用它跑通了工具闭环与四条注入用例,全程不需要 LLM key。这个探针没有提交:深路径穿过另一个包的私有 node_modules,pi 升级时会以"整个测试文件 import 失败"的方式炸掉整个套件。要复现按下面的方法重建。
在 apps/api/agent/ 下临时建一个 *.test.ts(跑完删掉),要点:
其余按 createRuntimeSession 的参数照搬(noTools:"all" + customTools + tools),session.prompt(...) 即可。 跑法:encore test --reporter=verbose <文件名>(默认 reporter 不打 stdout)。