安全边界
问数系统要执行模型生成的 SQL。这一页讲 Trove 怎么把这件事变安全: 只读的三层门、限流、加密、审计,以及几处刻意选择「失败时拒绝」而不是「失败时放行」的地方。
只读执行:三层门
「只允许 SELECT」这句话在三个不同层次上被检查,而不是只在一个地方:
| 层 | 做法 | 拦什么 |
|---|---|---|
| ① 关键词正则 | 廉价的首层筛查 | 明显带 DML/DDL 关键字的语句 |
| ② AST 防火墙 | SQLGlot 解析成语法树后整树扫描 | 数据修改型 CTE、SELECT ... INTO OUTFILE 等伪装成 SELECT 的写操作 |
| ③ 执行器统一门 | 最终执行入口再查一次 | 任何绕过前两层的路径 |
guard.py:50 check_readonly guard.py:100 find_all registry.py:416 _guard_read_only
flowchart TB
SRC["SQL 从哪来:模型生成 · 编译产物 · job 载荷 · CLI · MCP"]:::entry --> G1["只读门 ① 关键词正则
廉价筛查明显的写操作"]:::gate
G1 --> G2["只读门 ② AST 防火墙
SQLGlot 整树扫 DML / DDL 节点"]:::gate
G2 --> G3["只读门 ③ 执行器统一门
注册表执行入口再查一次"]:::gate
G3 --> AU["执行前授权门(execute_sql)
数据源级恒拦;表级默认只警告"]:::gate
AU --> HITL["人在环(开启时)
载荷认不出 → 默认拒绝"]:::gate
HITL --> EX["正式执行
只读账号 · 30s 超时"]:::exec
EX --> MK["字段级脱敏
partial / hash / null"]:::exec
MK --> OUT["交付:前端 / 模型上下文"]:::entry
SEM["POST /v1/semantic/query"]:::entry -. "不走 workflow:只有路由层数据源授权 + 只读门" .-> EX
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
正则挡不住这种:WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x。
它以 WITH 开头、含 SELECT,看起来人畜无害,实际在删数据。
要发现它只能真的解析成语法树,然后扫描整棵树的 DML/DDL 节点—— 而不是只看第一个词。
代码里对这一点写得很直白:真正的授权边界仍然是数据库侧的只读角色。 三层门是纵深防御,不是「有了它就可以给写权限」的理由。
给 Trove 一个只会 SELECT 的账号,是这里性价比最高的一件事。账号怎么建见 接入数据源。
执行侧的资源上限
| 限制 | 值 | 作用 |
|---|---|---|
| 试跑取数 | 10 行 | agentic 循环里的 probe_query |
| 试跑超时 | 5 秒 | 探测不该拖垮循环 |
| 规则校验取数 | 50 行 | 规则只消费行数/首行值/列形状,50 足够 |
| 执行超时 | 30 秒 | 正式执行的默认上限(应用侧) |
| 库侧语句超时 | 60 秒 | 装在连接上的服务端闸,见下 |
gen_sql.py:597 PROBE_LIMIT gen_sql.py:599 PROBE_TIMEOUT_S gen_sql.py:799 CHECK_RESULT_LIMIT execute_sql.py:67 timeout_ms
注意试跑超时(5s)与执行超时(30s)是两个不同的数。 一个模型自发的探测不该占用正式执行的预算,卡住它就该快速失败、换个思路。
库侧的兜底闸
上面这些上限都活在 Trove 的进程里。进程被杀、事件循环卡死时它们一个都不会执行——
而数据库里那条查询还在跑。所以支持的方言在连接上再装一道服务端上限:
默认 60 秒,比应用侧的 30 秒大。顺序是有意的:先由应用侧超时、取消、
发 KILL,失败归因里留下「Trove 杀了这条查询」的证据;库侧只在
「Trove 已经不在场」时兜底。两个数反过来,归因会悄悄变成「库自己超时了」。
| 方言 | 机制 | 备注 |
|---|---|---|
| MySQL | SET SESSION max_execution_time | 毫秒;建连时与每次 ping 后各发一次(重连会带走会话变量,而 ping 是否真重连无法区分) |
| MariaDB | SET SESSION max_statement_time | 秒;MariaDB 不认 MySQL 的变量名,按版本串自动分流 |
| PostgreSQL | options=-c statement_timeout= | 走连接参数而非建连后的 SET——psycopg 默认非 autocommit,会话 SET 会被一次回滚带走 |
| ClickHouse | settings.max_execution_time | 秒(请求级);旁路的 KILL 连接复用同一份参数 |
| SQLite / DuckDB | — | 进程内引擎,没有可保护的服务端对象 |
| Doris | — | 会话超时语句的语义未验证,如实声明不支持,退回既有取消链 |
连接参数 statement_timeout_ms 可改,≤0 表示关掉(不设上界)。
装闸失败不阻断连接:引擎认不出这条语句时(MySQL < 5.7.8、
MariaDB < 10.1.1、其他 MySQL 协议实现)记一条 WARN 并继续——用一次可用的降级换
一次不可用的保护,但降级必须看得见。声明不支持的方言一个字都不发。
adapters/base.py:36 DEFAULT_STATEMENT_TIMEOUT_MS adapters/mysql.py:362 _statement_timeout_sql adapters/postgres.py:280 _statement_timeout_connect_kwargs
自检:怎么确认那道边界真的在
「边界在数据库侧」这句话本身得有人验。启动时 Trove 会问每个数据源一句:
这个账号到底能不能写?结论照实分级写进日志——确认可写与没探成是
WARN,其余是 INFO;并作为每个源的 readonly 字段出现在
探活端点上。
只读也报 INFO 而不是 WARN:「这个方言没有角色概念」这类结论在 SQLite / DuckDB 上
每次启动都会命中,一条每次都响的告警只会训练人忽略它。
用一条「必然失败的写语句」来验证只读,是最容易想到也最不该做的事: 它「必然失败」的前提,正是「账号只读」这个还没有被证明的假设。 账号其实可写时,那条语句会真的写进去——一个探测变成一次生产写入。
所以自检只查权限表:MySQL/Doris 走 SHOW GRANTS,PostgreSQL 查
pg_roles / pg_class / table_privileges,
ClickHouse 查 system.grants(含角色继承展开)。一个字都不写。
datasource/readonly.py:64 probe_all core/types.py:236 ReadonlyProbe
限流与配额
业务端点按用户限流,两个维度叠加:每分钟的令牌桶,加上每日总配额。 被限流拒绝的请求不计入日配额——这一点是对的, 否则一次突发会把当天的额度烧光。
| 维度 | 默认 | 关掉 |
|---|---|---|
| 每分钟请求 | 30 | 设为 0 |
| 每日配额 | 300 | 设为 0 |
| 登录尝试 | 5 次 / 15 分钟 | 环境变量调整 |
deps.py:224 check_api_rate config.py:458 api_rate_per_minute ratelimit.py:24 TokenBucket
限流是进程内实现。单实例部署下这是准的;多实例部署时每个实例各限各的, 实际额度会翻倍——这是当前实现的一个已知边界,需要严格限流时应在网关层再做一层。
认证与授权
鉴权是 fail-closed 的:没有认证组件时服务拒绝启动,而不是裸奔运行并打条警告。 这条设计在系统架构里也提到了—— 它是「宁可起不来,也不要静默地不安全」。
app.py:354 auth is None deps.py:78 get_current_user deps.py:168 require_scope
授权有两个层次,容易混淆,值得分清:
| 概念 | 是什么 | 用处 |
|---|---|---|
| 账号角色 | users.role:user / analyst / admin(管理端用户表单就这三项) |
admin 决定能不能进管理端 |
| 工具 ACL 角色 | 从账号角色推出:admin 恒带 analyst,其余原样透传 |
决定生成时能看见哪些 catalog 工具 |
| 数据源 grants | 用户 ↔ 数据源的授权表 | 决定能问哪些源 |
analyst 是账号角色里的一项,但它与 admin 不是一类东西:
它不带来任何数据授权——能问哪些源照旧由 grants 决定,
它只决定生成时那几个能摸物理 schema 的工具(catalog 及其解锁的
search_values / lookup_schema / explain_plan)对这个账号可不可见。
方向是单向的:admin 隐含 analyst,反过来不成立——analyst 进不了管理端。
main.py:388 _resolve_user_roles policy.py:146 Principal deps.py:187 require_datasource
数据源 grants 的语义是三值的,而且三值都落在安全的一侧:
None(拿不到授权依据)拒绝一切;空集合只放行默认数据源;
非空集合是严格白名单。所以「忘了授权」和「读不到授权依据」都不会退化成放行。
这套策略统一在 services/authz/policy.py,HTTP 侧与 MCP 侧都调它。
此前两处各有一份手抄,已经漂移过——同一个策略抄两遍就会有两套行为,这正是收口要解决的问题。
执行前的授权门
上面那套判定此前挂在路由的装饰器上,语义是「这个端点需要授权」。
于是任何不经路由的入口——API 直执行、job 载荷、CLI、MCP——都绕开了它。
现在它被下沉到执行前的最后一米:
execute_sql 节点里,无论 SQL 从哪来都要过这一道。
拒绝是终止,不是一次修正轮。让模型重写十遍也改不掉「这个用户没有这张表的权限」, 只会烧掉共享的修正预算,所以拒绝不喂回生成节点。
execute_sql.py:209 执行前的最后一米 enforcer.py:84 Authorizer
一个例外要单独说:声明式接口 POST /v1/semantic/query 不经过工作流,
它自己编译、自己交给注册表执行。它拿到的是路由层的数据源授权与注册表那道
只读 AST 门,但上面这道执行前授权门与字段级脱敏都不在它的路径上
——两者都长在 workflow 的节点里。这不是漏接,是那条路的定位(严格契约、未声明即 422);
只是评估它的暴露面时要按「授权到数据源一级」来算。
还有 authz.require_principal(默认 true)。它不是
「缺主体就放行」的档位——那种档位不存在,缺主体恒拒;它管的是这个强制点装不装:
置 false 时整层不装配,留给不接语义层的嵌入场景,生产恒为 true。
semantic_query.py:146 require_datasource semantic_query.py:179 execute config.py:109 require_principal graphs.py:1945 require_principal
表级判定默认拒绝(authz.table_enforcement: enforce)。
2026-10 从 warn 切换,依据不是「观察期结束」而是静态核验:用 A3
同源基准(rls.declared_tables)对三份 KB 的全部快径示例 SQL
(650 条)逐条 SQLGlot 比对,违规 0 条 —— 按语义模型走的查询本来
就都在声明内,当初担心的「正常查询大面积 403」实测并不存在。
回退阀:存量部署若确有声明外的查询(绕开语义层直摸物理表),
显式写 authz.table_enforcement: warn 即回到「放行但记下被挡的表」
的观察档。数据源级判定与「必须有主体」不受这个开关影响,恒为拒绝。
被拒的次数按原因计数(trove_authz_deny_total{reason}),
不按人:单看总数读不出「是没配主体,还是表没授权」,而这两者的处置方向相反
——一个是部署没接好,一个是该加 grants。计数记在执行节点而不是判定服务里,
因为同一个判定函数也服务于「只判定不执行」的调用,那些没有落库
(与成本轨「节点记才等于这条查询落库了」同一条纪律)。
放行的那些要分开记。观察期最要紧的数不是「拒了多少」(此时恒为 0),
而是「本来会拒多少」——所以 warn 期的命中单记一条
trove_authz_table_warn_total{datasource},与拒绝计数分家:
并进同一个系列,运维读到的告警率里会混进一半根本没被拦的查询。
这条计数器回答量;名字(哪些表会被拒)落在审计行的
authz.table_warn 上——表名不进指标标签,基数随库增长而无限
(同「路由用模板而不是原始 URL」)。
观察期只靠日志是收不齐的:一行一条的日志答不了「一周里哪些表」这个聚合问题。 三条出口各答一个问题——日志给现场,计数器给量,审计行给名字。
字段级脱敏
RLS 管行,此前没人管列——手机号、身份证、邮箱照样出到前端与模型上下文。 现在在语义层声明字段的脱敏模式,SQL 验证之后、任何模型节点之前改写结果值。
| 模式 | 结果 | 用在 |
|---|---|---|
| none / "" | 原样 | 缺省值:没声明就不改写;none 与空串同义(存量兼容),写出来只是把「这里确实不脱敏」记下来 |
| partial | 138****8888 | 还要能对上人的场景(客服报号) |
| hash | sha256(salt+值) 前 12 位 | 要能分组/去重但不要原文(同一值仍映射到同一串) |
| null | — | 只当存在性用 |
masking.py:340 Masker.apply nodes/masking.py:62 make_masking
绕开脱敏看原文靠 scope 而不是 role:持 pii 的账号可以按原文取,
而 admin 不自动获得这个能力——管理员的身份是「能改配置」,不是「能看身份证号」。
每一次绕开都会写一条 masking.bypass 审计。
三条边界值得知道:脱敏按投影位置匹配,所以 SELECT * 与
函数/聚合投影(sum(amount))拿不到位置,走的是列名兜底或有意不脱敏;
模型与 salt 读不出来时拒绝执行,不降级为明文;
值本身不写日志、不写审计——审计只记「对哪些字段用了哪种模式」。
不可信内容隔离
模型上下文里混着大量不是 Trove 写的文本:库里的单元格、检索回来的值、工具返回的内容。 这些外部值进上下文只有两条结构性通道——渲染提示词时的参数插值, 与工具返回值回喂——两条都过同一个隔离核。收口在核上而不是在每个调用点上, 是因为「谁记得谁调用」的写法漏一条就是一个洞:新增模板变量或新增工具会自动继承,不需要各自接线。
| 通道 | 位置 | 怎么过核 |
|---|---|---|
| 模板渲染 | 插值进提示词之前 | 逐个参数过核,白名单放行 |
| 工具回喂 | 工具返回值写进 messages 之前 | 按返回值的结构逐叶过核 |
loader.py:71 _isolate_vars agent_loop.py:89 _model_observation untrusted.py:86 isolate_tree
信任分类只有三条规则,其余默认按数据扫:用户本人的话不隔离(隔离它等于篡改需求); 经人确认后落库的结构化知识(语义层、KB)按参数名放行;系统自身的标量与枚举同理。 重点在「其余」:白名单之外一律进扫描,新增参数不会因为忘了登记而裸奔。
「是谁生成的」不构成信任理由:模型自己的产物(计划、反思、SQL、记忆)照样扫。 这一条推翻过更早的边界声明(旧版只扫 probe / search 返回的单元格),因为它有两处错: 漏掉了十几条同样把外部内容送进提示词的路;而「LLM 自生成的不在此列」是一条洗白通道 ——模型会把外部内容回声到自己的输出里,下一轮它就成了「模型自己说的」。
处置是整值作废,不是剪掉命中的那一段:命中即把整个值换成中性标记
([data: content isolated])。剪一半会让模型以为自己拿到的是「不完整但可用」的数据,
而它已经不可信了。隔离按值的结构粒度生效——传单元格列表就逐格判,传拼好的大文本只能整块作废,
所以调用方应尽量把结构化值交给回喂口。超长值(64KB 以上)无法逐字审查,同样按保守方向整值作废。
另有一档不是数据:AdminConfirmed——管理员确认过的配置文本(org skill 正文)。
它按类型放行,不按内容长相,因为对指令性文本做整值作废处置与语义相反:
一句「忽略之前的指令」会把整份方法论变成标记。它的安全来自写入关口(确认时筛查、给人看),
不来自运行期扫指令文本。
还有一处值得点名:追问改写这类派生值——模型产出的问题,字面走的是
question 这个「用户原话」参数,却不是用户说的。它在诞生点单独筛一次,
命中就弃用改写、回落到引导话术。不筛就是一条洗白通道:外部数据 → 模型回声 → 下一轮被当成用户说的。
隔离只作用于喂回模型的那一份:工具历史、transcript 与观察者拿到的是原文
——审计面要留证据;finish 载荷(最终 SQL 这类控制值)根本不走这条路,产物不是输入,
隔离它是篡改。命中会记一条 trove_prompt_isolation_total{channel,var,pattern}:
通道与模式名是闭集,工具名下越界的一律记成 unknown——模型给的名字基数无限,
不能进标签。不记原文,原文正是要被隔离掉的东西。
untrusted.py:60 is_trusted_var untrusted.py:65 AdminConfirmed untrusted.py:97 screen_derived injection.py:32 ISOLATED_MARKER
以用户身份重放
排障时最常问的一句是「用户 A 看到的到底是什么」。此前只能借账号。
现在 admin 可以在自己的会话里带 on_behalf_of 重放:
POST /v1/chat { "question": "...", "on_behalf_of": "user:42" }
四道校验在流开始之前跑完:发起人是 admin、会话属于发起人、目标用户存在、
subject 类型只认 user:。缺一道,这就是一条 admin 的越权读取通道。
重放期间发起人的 scopes 全部不参与判定——目标用户的权限才是这个视图该有的全部,
「减掉 pii 一项」是一张永远列不全的清单。目标自己的 scopes 也不假装知道:
scope 属于 token 而不属于用户表,读不到就取空集。方向因此偏严——
重放视图只会比目标本人的视图更脱敏,不会更宽。
审计双记:authz.on_behalf_of 记「谁发起了一次重放」,
query.execute 的 details 里另带一条 on_behalf_of——排障的人先翻的是查询记录,
只在另一张表里记一次意味着要拼两张表才知道当时的身份。
chat.py:43 _replay_subject session.py:201 _replay_wire session.py:239 scopes
前端会在答案上方标出「已脱敏字段 N」;bypass 的那次单独显示为「原文」——
那次字段表是空的,写出来会读成「本次没脱敏」,而事实恰好相反。
表头命中的列也有标记,名字对不上就不标(宁可少标,不给没被改写的列打上标记)。
凭证与加密
数据源连接串里的密码在落盘前加密(TROVE_SECRET_KEY 作为密钥)。
加密值带 enc:v1: 前缀标记;没有前缀的是旧版明文,
读时透传、下次写时自动加密。
secrets.py:123 TROVE_SECRET_KEY secrets.py:39 enc:v1
这个「带前缀标记 + 向后兼容」的形态让迁移不需要一次性的批处理: 存量明文能继续用,随着配置被重新保存自然加密完成。
模型 API key 建议走环境变量或配置文件里的 ${ENV_VAR} 占位符——
这样配置文件本身可以进版本库。详见配置参考。
审计
管理操作会写入审计日志,管理端有查询端点。另外,检索层有自己的查询日志
(retrieval_log),记录每次检索的分支大小、融合 top、精排 top 与耗时。
service.py:416 record_audit admin.py:351 audit query_log.py:23 retrieval_log
生成阶段还会往日志流里写 sql_audit 行。它和落库的
query.execute 是同一次查询的两条互补记录——一条进日志平台,
一条进管理端可查的审计表:
sql_audit(日志流) | query.execute(审计表) | |
|---|---|---|
| 落在哪 | 日志里,按前缀 grep 捞 | 审计表,管理端 /v1/admin/audit 可查 |
| 何时写 | 每次工具调用(探针 / 规则链自检 / 收工),含缓存命中 | 每次查询交付时 |
| 记什么 | 版本、用户、工具、问题(截断)、SQL(截断)、结果观测(截断) | 会话与运行 id、问题、SQL、数据源、判定、行数、耗时、错误 |
| 回答什么 | 谁在什么时候让系统执行了什么 | 谁在什么时候查到了什么结果 |
gen_sql.py:1239 sql_audit session.py:1816 _audit_query
身份与脱敏这条链上另有五条。其中四条由同一个后置审计写——它们记的是运行结果里的
判定报告与脱敏报告,得等结果回来;authz.on_behalf_of 早在重放主体解析出来时就写了,
那时还没有结果可等。
| action | 记的是 |
|---|---|
| authz.deny | 执行期的拒绝:哪个数据源、哪张表、什么原因 |
| authz.table_warn | warn 期放行了未声明的表:数据源 + 表名清单。门看见了越界并让行——比一次干脆的拒绝更值得留痕,也是「哪些表会被拒」唯一答得全的地方 |
| authz.on_behalf_of | 谁发起了一次以他人身份的重放 |
| masking.applied | 哪些字段被改写成哪种模式(不记值) |
| masking.bypass | 谁按原文读走了数据(持 pii scope 的那次) |
「改写了哪些字段」与「谁看了原文」分开记,而不是合成一条:一次 bypass 的运行字段表是空的,
合并之后最该留痕的那次读取会表现成「这次没脱敏」。
applied 与「脱敏节点跑了但没改动」也不等价——前者说明结果里确实有被改写的列。
人在环:默认不执行
HITL 开启后,SQL 在执行前被拦下交给人确认。这里有一条贯穿全站的原则的又一次体现: 认不出来就拒绝。
hitl.py:45 未识别的载荷一律 hitl.py:74 hitl hitl.py:79 state.auto_approve
恢复载荷解析不出来时,节点不会「当作批准」——它会明确拒绝执行并记录原因。 这个方向的选择很好理解:一个签名验不过的批准,比没有批准更可疑。
| 什么时候跳过 HITL | 为什么 |
|---|---|
状态带 auto_approve | 批内「确认并继续全部」之后的剩余子任务——那次确认已经覆盖了整批 |
| 配置里没开 | — |
| 没有 SQL 可执行 | 没什么可确认的 |
| 上游已经出错 | 执行路径根本不会走 |
| 处于修正轮 | 第一轮已经确认过意图了 |
定时任务走同一条路但自动放行——否则无人值守的任务会永远卡住。 这个区别写在调度器里,不在提示词里。
另有一条状态旁路:批内「确认并继续全部」之后,剩余子任务带 auto_approve
直接标成 approved——人已经为整批确认过一次,逐个再问一遍只是把同一份确认念 N 遍。
它与定时任务的自动放行是两回事,一条写在状态里,一条写在调度器里,所以两处都得看。