alitrack

DuckDB 装了 gatekeeper:AI 写的 SQL,先过检再执行

把数据库连接交出去的那一刻,问题就变了:你不再担心 SQL 写错,而是担心它写对。

一句 SELECT * FROM users 语法没毛病,跑起来把整张表读走了,日志里看不出任何异常。

社区里刚上了一个扩展补这块,叫 gatekeeper。它不执行 SQL,只把语句绑定后的真实对象拿出来,对着策略判一遍放不放行。

DuckDB 自带的闸门全是路径级的。enable_external_access 能把整个外部访问关掉,allowed_directories 能限定允许访问的目录,lock_configuration 能把这些设置锁死。但你说不出「这条连接只准读 reporting.orders 这一张表」——表级 ACL,DuckDB 一直没有。

gatekeeper 9 月 17 日进的 DuckDB 社区仓库,MIT 协议。

● ● ●

装上是两条命令

INSTALL gatekeeper FROM community;
LOAD gatekeeper;

我在 DuckDB 1.5.5 上装的,版本 0.1.2。作者的仓库 9 月 12 日才建,9 月 17 日就进了社区仓库。

装完先看它管什么:gatekeeper_configure 设策略,gatekeeper_validate 判一条 SQL。旧版的表函数语法,返回一行结构化结果。

● ● ●

四道闸门

一条 SQL 走完的检查,全程不执行

一条 SQL 走完的检查,全程不执行

闸门
管什么
实测结果
表 / 视图 ACL
allowed_tables
 里的对象才放行,支持 '*' 通配;[] 等于全拒
允许的表 → ok;没配策略前的默认姿态是所有非内部表都可读
函数 ACL
从 953 个审过的默认函数起步,可加白名单、黑名单;黑名单永远赢
md5
 进黑名单后,SELECT md5('hello') → forbidden
只读 + 禁元数据
只准 SELECT;INSERT、DROP、COPY、动态 SQL 全拒
DROP TABLE
 → unsupported;SELECT * FROM duckdb_tables() → forbidden
单语句
一次只收一条语句
SELECT 1; SELECT 2;
 → forbidden,规则 limit

第三行值得单独说:duckdb_tables() 这类元数据表函数本身没碰你的数据,但它们能把库里有什么暴露出去。gatekeeper 把它们当普通函数一起管了。

策略还能锁死。CALL gatekeeper_configure(...) 之后加一句 SET lock_configuration = true,之后 SET、RESET、再调用 configure 都改不动,单条请求里的参数只能把范围收窄,不能放宽。

顺手试了两个写错的情况:字段名敲错(把 table 写成 tabl)、结构里塞 NULL,configure 都直接报 Invalid Input Error 退出,不会当成合法策略收下。

● ● ●

真正有意思的地方:它按绑定结果判

前面那些闸门,用字符串匹配也能糊出来一半。gatekeeper 不一样的地方在于,它是先让 DuckDB 把 SQL 绑定一遍,再拿绑定出来的真实对象去比。

我搭了个场景。一张放行的表,一张不许碰的表,然后建一个视图把两张表 join 起来:

CREATE SCHEMA reporting;
CREATE TABLE reporting.orders (customer_id INTEGER, amount DOUBLE);
CREATE TABLE secrets (token VARCHAR);
CREATE VIEW reporting.summary AS
  SELECT o.customer_id, s.token FROM reporting.orders o JOIN secrets s ON true;

CALL gatekeeper_configure(
    allowed_tables := [{schema: 'reporting', 'table': '*'}],
    blocked_tables := [{schema: 'main', 'table': 'secrets'}]
);

视图 reporting.summary 在允许的表里,直接查它应该没问题?

SELECT allowed, code, violations[1].rule AS rule, violations[1]."table" AS v_table
FROM gatekeeper_validate('SELECT * FROM reporting.summary');
 allowed |   code    | rule  | v_table
---------+-----------+-------+---------
 false   | forbidden | table | secrets
视图套了一层,被点名的还是底下那张表

视图套了一层,被点名的还是底下那张表

拒绝,而且点的是 secrets——视图底下那张表,不是视图自己的名字。

同样的东西藏在 CTE 加标量子查询里、藏在别名后面,结果一样。路径扫描也一样:SELECT * FROM '/tmp/x.parquet' 会被按 read_parquet 这个函数拦下(顺带发现,它默认不在这 953 个名字里)。

这就是字符串检查挡不住的部分。你可以把表名换成别名、包一层视图、写进宏,文本上看起来干干净净;但引擎绑定之后只有一张真实的依赖表,策略比的是那个。

反过来说,它也不是万能的:函数匹配是按名字,不是对宏和 UDF 的实现做证明。作者在文档里写得很直白——「catalog 完整性是前提假设」。

● ● ●

三种失败要分开看

gatekeeper_validate 返回的 code 有六种值,其中三种值得分开处理:

返回码按「有没有引擎原文」分两类

返回码按「有没有引擎原文」分两类

  • forbidden
     / unsupported:策略拒绝,响应里不带引擎原文。可以直接打给调用方。
  • binding
     / parser:引擎自己绑定或解析失败,error_message 原样带回来。

我拿一张不存在的表试了下,引擎回的是 Table with name missing_table does not exist! Did you mean "pg_tables"?。

注意后半句。引擎的报错会主动提示库里有哪些表名——这份提示如果直接返回给租户,等于送你一份表名清单。作者在 security.md 里把 error_message 标成敏感数据:记给运维看,返回给调用方要换一句通用错误,而且不要指望「截掉提示」能保密,名字可能出现在任意一行。

● ● ●

我装到的这一版,少一个函数

README 里把 CALL gatekeeper_enforce() 写成了给代理用的首选接法:在这条连接上开强制模式,DuckDB 自己拒绝执行策略不允许的语句,不用宿主额外判断。

但社区仓库编的这一版没有它。

我在里面查 duckdb_functions(),gatekeeper 只注册了两个函数:gatekeeper_validate 和 gatekeeper_configure;设置里也只有 gatekeeper_policy。社区仓库的构建来自 v0.1.2 这个 tag(commit bc6578f,9 月 15 日 06:23),而强制连接的提交是 9 月 17 日 20:24 才进的(#45),在 tag 之后。

所以今天能用的只有两步法:先 validate,拿到 allowed = true 且 code = 'ok',再执行同一段 SQL。听起来简单,坑在最后半句——验证的 SQL 和真正执行的 SQL 必须是同一段、同一条连接,中间不能有拼接,异常和空结果都算拒绝。这一步得宿主自己保证,扩展帮不上忙。

作者在文档里也承认:未做强制的连接上,宿主仍然要控制谁能拿到原始连接。

● ● ●

它不是沙箱

扩展自己在文档里列了一串边界,我照着核了一遍,几条值得抄出来:

  • 这是语句级沙箱,不是操作系统、内存、网络沙箱。
  • 没有行、列级授权;不管内存、不管执行时间、不限制结果大小。
  • 953 个默认函数是「人工审过的名单」,不是「每个重载、每种传参都无害」的证明。
  • 绑定阶段本身可能触发远程 I/O 或求值绑定期表达式,哪怕这条语句最后会被拒。
  • 名单里放行了 now、current_date、uuidv7 这类时钟函数和 setseed——用了它们,结果没法只从 SQL 文本复现,做查询缓存的要留意。
  • 输入上限:SQL 与序列化 AST 8 MiB、AST 遍历 10 万节点、深度 512。

那个 953 我核过一遍,因为它是个容易被随手接受的数字。仓库的 inventories/ 目录里按 DuckDB 1.5.5 的 2903 个注册函数逐个分类:核心 622 个,icu 186 个,spatial 158 个,json 33 个,inet 11 个,加上十几个小项,加起来正好 953。另有 115 个标成 elevated(会读会话或计划器状态、做 I/O、或者按调用方给的名字去查目录),不进默认名单。

● ● ●

回到开头那件事

把连接交出去,本来没有中间态:要么全放开,要么把整个文件系统关掉。

gatekeeper 的贡献是把中间态做出来了——你可以说清「这条连接只能读 reporting 下的东西」,拒绝发生在绑定之后、执行之前,而且拒绝的理由是结构化的,code 加 violations[].rule,能直接进日志和告警。

但它现在是个刚上线几天、版本 0.1.2 的扩展,给代理用的强制连接还没进二进制,函数匹配也还停在名字层。我不建议现在拿它当唯一一道墙。

真正值得抄的是思路:授权要卡在绑定结果上,不要卡在 SQL 文本上。 前者贵,但它是引擎自己算出来的答案;后者便宜,而绕过它的方法有一堆。

如果你的 DuckDB 要开放给 AI 代理、多租户看板或者 BI 自助查询,可以先从最小的一步开始:给连接配一份 allowed_tables,写错也没关系,configure 会直接报错退出。等你确认哪些表真的要被读,再决定要不要上黑名单和 lock。

你会把权限卡在 SQL 文本这一层,还是卡在绑定结果这一层?评论区聊聊。

● ● ●

参考来源

  1. 01
    duckdb/community-extensions PR #2698(gatekeeper v0.1.2 收录),2026-09-17

https://github.com/duckdb/community-extensions/pull/2698

  1. 01
    gatekeeper 仓库与 README(0.1.2 行为、四道闸门、953 默认函数)

https://github.com/nozzle/duckdb-gatekeeper

  1. 01
    gatekeeper 安全模型文档(语句级沙箱边界、error_message 敏感、AST 上限)

https://github.com/nozzle/duckdb-gatekeeper/blob/main/docs/security.md

  1. 01
    gatekeeper 函数清单与基线(core.json、inventories/extensions/*)

https://github.com/nozzle/duckdb-gatekeeper/tree/main/inventories

  1. 01
    DuckDB 1.5.5 release

https://github.com/duckdb/duckdb/releases/tag/v1.5.5

  1. 01
    本文实测环境:DuckDB v1.5.5(Variegata d8cdaa33fd)+ gatekeeper 0.1.2,INSTALL gatekeeper FROM community;文中所有 SQL 与返回结果均来自本机运行