首页/运维/安全边界

安全边界

问数系统要执行模型生成的 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;
图 1 · 一条 SQL 从生成到交付要过的每一道门——本页正是按这个顺序展开。虚线那条是声明式接口的独立路径:授权门与脱敏长在 workflow 节点里,它够不到。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。
为什么必须做到 AST 级

正则挡不住这种: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 已经不在场」时兜底。两个数反过来,归因会悄悄变成「库自己超时了」。

方言机制备注
MySQLSET SESSION max_execution_time毫秒;建连时与每次 ping 后各发一次(重连会带走会话变量,而 ping 是否真重连无法区分)
MariaDBSET SESSION max_statement_time秒;MariaDB 不认 MySQL 的变量名,按版本串自动分流
PostgreSQLoptions=-c statement_timeout=走连接参数而非建连后的 SET——psycopg 默认非 autocommit,会话 SET 会被一次回滚带走
ClickHousesettings.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 与空串同义(存量兼容),写出来只是把「这里确实不脱敏」记下来
partial138****8888还要能对上人的场景(客服报号)
hashsha256(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_warnwarn 期放行了未声明的表:数据源 + 表名清单。门看见了越界并让行——比一次干脆的拒绝更值得留痕,也是「哪些表会被拒」唯一答得全的地方
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 遍。 它与定时任务的自动放行是两回事,一条写在状态里,一条写在调度器里,所以两处都得看。

继续读

想了解去哪
只读账号怎么建接入数据源
用户、授权、审计的管理界面运维管理
日志与轨迹可观测性
限流与相关配置项配置参考
本页源码锚点的基准提交:anchor-base: 7c6df87 GitHub 仓库 文档首页