Files
AgentLab/backend/app/data/guard.py
T
ThearchyHelios 26ff8e1e06 只读数据源真的只读了:WITH … DELETE 不再从首关键字下面钻过去,连接本身也写不进去
守卫只看首关键字,而 with / pragma 都在只读白名单里。在只读的 SQLite 源上
实测:`WITH k AS (SELECT 1) DELETE FROM t` 被放行,5 行删得只剩 2 行;
`PRAGMA user_version = 7` 也写进去了。调用方只看到一个 ResourceClosedError——
pysqlite 只在 INSERT/UPDATE/DELETE 开头的语句前隐式开事务,WITH 开头的直接
自动提交,连接关闭时的回滚兜不住。Excel/CSV 上传生成的正是这种 SQLite 源。

两层一起收:

守卫:以查询开头的语句(SELECT / WITH / EXPLAIN)要把正文扫一遍,先抹掉注释
和引号里的内容,再找写关键字(DELETE、UPDATE、INTO、FOR UPDATE、EXPLAIN
ANALYZE 里的语句……),关键字后紧跟 ( 的是同名函数不算(MySQL 的 INSERT()、
TRUNCATE())。PRAGMA 改成只读白名单,`= 值` 和 `(值)` 两种赋值写法都拦。
另收一小撮有副作用的函数,其中 set_config 必须拦:PG 真库实测,它能把连接层
的只读关掉,同一连接的下一个事务就能写。函数名加引号、dblink 另开连接这两种
绕法也补上了。is_write 与只读判定共用一份判定,免得审批那道门另有一套口径。

连接层:只读源连接时就只读(engine_args)。SQLite 以 mode=ro 打开(file: URI,
路径带空格、中文、# 都测过);PostgreSQL 设会话默认事务只读;MySQL / MariaDB
设会话事务只读。Oracle 没有会话级开关,只剩守卫——README 写明请配只读账号。

真库验证(临时实例,已清理):PostgreSQL 18 与 MySQL 8.0 上,DELETE、WITH … DELETE、
PG 的数据修改 CTE、nextval、EXPLAIN ANALYZE 绕过守卫直连执行,都被数据库以
只读事务拒绝;可写源不受影响。

测试:守卫新增 33 例(藏在查询里的写、会写的 PRAGMA、同名函数与引号标识符不误杀),
去掉修复重跑是 17 例失败;test_readonly_source.py 6 例(当初那条绕过、绕过守卫
直连写、中文路径、可写源不受波及、各库的会话参数)。297 → 334 passed。

顺带查到但没在这里修的:可写源上的写操作既不审批也不生效——is_dangerous_call
全仓库没人调用,而 run_query 从不 commit,写完就被回滚。两件事必须一起修
(先接审批再让写入生效,反过来就是一个无审批写库的窗口),放在审批统一那一步。
2026-09-25 14:44:56 +08:00

259 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQL 安全守卫。
这是数据源接入里唯一不能出错的地方:SQL 是 Copilot 或 agent 生成的,不是人写的。
模型会写出什么,事前无法穷举——所以防线不能建立在"模型应该不会这么干"上。
三层,从外到内:
1. **只读判定**:只读数据源只放行确定无副作用的语句。用白名单而不是黑名单——
黑名单永远漏,`TRUNCATE`、`MERGE`、`CALL`、`GRANT`、各家方言的私货写不完。
2. **多语句拦截**:`SELECT 1; DROP TABLE users` 这种拼接必须在到达驱动之前就断掉。
DBAPI 层的 `execute` 多数驱动只跑第一条,但"多数"不是"全部",MySQL 开了
`CLIENT_MULTI_STATEMENTS` 就会全跑。不赌驱动的默认值。
3. **结果集上限**:行数 + 字节 + 超时。一条 `SELECT * FROM 亿级表` 不该把后端打爆,
也不该把几百 MB 塞进模型的上下文。
刻意不引入 sqlparse 之类的重型依赖:完整解析 SQL 方言是个无底洞,而这里需要的
判断很窄——"第一个关键字是什么"和"有没有第二条语句"。窄问题用窄解法,
把复杂度留在能看懂的范围内。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# 只读数据源放行的首关键字。只有确定不产生副作用的才进这个名单。
# 注意 SHOW/DESCRIBE/EXPLAIN 要留着:agent 探查表结构靠它们。
_READONLY_VERBS = frozenset({
"select", "with", "show", "describe", "desc", "explain", "pragma", "values",
})
# 即便在可写数据源上也一律拒绝的语句。这类操作的破坏是不可逆的,
# 不该由一个实验工具里的模型来发起——要做请人去数据库客户端里做。
_ALWAYS_DENIED = frozenset({
"drop", "truncate", "grant", "revoke", "alter", "create", "shutdown",
"attach", "detach",
})
_LINE_COMMENT = re.compile(r"--[^\n]*")
_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.S)
# 首关键字是查询、正文却能写的语句。只看首关键字的话它们全都能混过去:
# WITH k AS (SELECT 1) DELETE FROM t SQLite / MySQL / PG 都认
# WITH x AS (DELETE … RETURNING *) SELECT … PG 的数据修改 CTE
# SELECT … INTO backup / INTO OUTFILE '…' PG 建表 / MySQL 写服务器文件
# EXPLAIN ANALYZE DELETE … PG 的 ANALYZE 会真的执行
# 关键字后面紧跟 ( 的不算:那是同名函数(MySQL 的 INSERT()、TRUNCATE())。
_HIDDEN_WRITE = re.compile(
r"\b(insert|update|delete|merge|upsert|truncate|drop|alter|create|grant|revoke|"
r"call|copy|into|attach|detach|vacuum|reindex|lock\s+tables?|replace\s+into)\b(?!\s*\()",
re.I,
)
# 查询里一调用就有副作用的函数。函数没法白名单,这里只收一小撮要命的:
# set_config 能把 PG 连接层的只读关掉(真库实测:关掉后同一连接的下一个事务
# 就能写),dblink 另开一条不受只读约束的连接,nextval/setval 推进序列,其余是
# 杀连接、读写服务器文件、加载本地扩展。函数名可以加引号写("set_config"(…)),
# 所以名字两边允许引号,扫描时也只抹字符串、不抹标识符。
_SIDE_EFFECT_CALL = re.compile(
r"[\"`]?\b(set_config|nextval|setval|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|"
r"lo_import|lo_export|lo_unlink|dblink\w*|load_extension)\b[\"`]?\s*\(",
re.I,
)
# SQLite 的 PRAGMA 有读有写,而且写有两种写法:`= 值` 和 `(值)`。
# 只放行确定只读的:前一组的参数是表名/索引名,后一组不带参数。
_PRAGMA_READ_WITH_ARG = frozenset({
"table_info", "table_xinfo", "table_list", "index_list", "index_info",
"index_xinfo", "foreign_key_list",
})
_PRAGMA_READ_NO_ARG = frozenset({
"table_list", "database_list", "collation_list", "function_list", "module_list",
"pragma_list", "compile_options", "encoding", "page_count", "page_size",
"freelist_count", "schema_version", "user_version", "application_id", "data_version",
})
_PRAGMA = re.compile(r"pragma\s+(?:\w+\s*\.\s*)?(\w+)\s*(.*)$", re.I | re.S)
class SqlRejected(ValueError):
"""SQL 没通过守卫。消息直接给模型看,所以要说清楚为什么被拒、该怎么改。"""
@dataclass(frozen=True)
class QueryLimits:
max_rows: int = 1000
max_bytes: int = 1_000_000
timeout_seconds: int = 30
def strip_comments(sql: str) -> str:
"""去掉注释再判断。否则 `/*x*/ DROP TABLE t` 的首关键字会被看成注释内容。"""
return _BLOCK_COMMENT.sub(" ", _LINE_COMMENT.sub(" ", sql))
def split_statements(sql: str) -> list[str]:
"""按分号切语句,但要认得字符串字面量里的分号。
`SELECT ';'` 只是一条语句。简单 split(";") 会把它切成两半,然后把后半截
当成"第二条语句"拒掉——那是误杀,比漏杀更让人无从下手。
"""
out: list[str] = []
buf: list[str] = []
quote: str | None = None
i = 0
text = strip_comments(sql)
while i < len(text):
ch = text[i]
if quote:
buf.append(ch)
if ch == quote:
# SQL 里连续两个引号是转义('' 表示一个单引号)
if i + 1 < len(text) and text[i + 1] == quote:
buf.append(text[i + 1])
i += 2
continue
quote = None
elif ch in ("'", '"', "`"):
quote = ch
buf.append(ch)
elif ch == ";":
stmt = "".join(buf).strip()
if stmt:
out.append(stmt)
buf = []
else:
buf.append(ch)
i += 1
tail = "".join(buf).strip()
if tail:
out.append(tail)
return out
def first_verb(sql: str) -> str:
"""取语句的首关键字(小写)。括号开头的 `(SELECT ...)` 也要认出来。"""
text = strip_comments(sql).lstrip().lstrip("(").lstrip()
match = re.match(r"[A-Za-z_]+", text)
return match.group(0).lower() if match else ""
def _blank_quoted(sql: str, *, identifiers: bool = True) -> str:
"""把引号里的内容换成空格:字符串 'please delete' 和标识符 "update" 都不是关键字。
identifiers=False 时只抹字符串、保留 "…" 和 `…`——找函数调用时要看得见
被引号括起来的函数名。
"""
quotes = ("'", '"', "`") if identifiers else ("'",)
out: list[str] = []
quote: str | None = None
i = 0
while i < len(sql):
ch = sql[i]
if quote:
if ch == quote:
if i + 1 < len(sql) and sql[i + 1] == quote: # '' 是转义,不是结束
out.append(" ")
i += 2
continue
quote = None
out.append(ch)
else:
out.append(" ")
else:
if ch in quotes:
quote = ch
out.append(ch)
i += 1
return "".join(out)
def _pragma_write_reason(stmt: str) -> str | None:
match = _PRAGMA.match(strip_comments(stmt).strip())
if not match:
return "看不出这条 PRAGMA 读的是什么"
name, rest = match.group(1).lower(), match.group(2).strip()
if rest.startswith("="):
return f"PRAGMA {name} = … 会改数据库设置"
if name in _PRAGMA_READ_WITH_ARG or (name in _PRAGMA_READ_NO_ARG and not rest):
return None
return (
f"PRAGMA {name}{' ' + rest if rest else ''} 不在只读白名单里"
"(看表结构用 table_info / index_list / foreign_key_list)"
)
def _write_reason(stmt: str) -> str | None:
"""这条语句会不会写。会写就返回一句说给模型听的原因,不会写返回 None。
首关键字只是第一道:以查询开头的语句正文照样能写(见 _HIDDEN_WRITE),
所以 SELECT / WITH / EXPLAIN 还要把正文扫一遍。SHOW / DESCRIBE / VALUES
结构上写不了,不扫——`SHOW CREATE TABLE` 里的 CREATE 不是在建表。
"""
verb = first_verb(stmt)
if verb not in _READONLY_VERBS:
return f"{verb.upper()} 会修改数据"
if verb == "pragma":
return _pragma_write_reason(stmt)
if verb in ("select", "with", "explain"):
text = strip_comments(stmt)
hidden = _HIDDEN_WRITE.search(_blank_quoted(text))
if hidden:
word = " ".join(hidden.group(1).split()).upper()
return f"语句里有 {word}——以查询开头的语句也能写数据"
call = _SIDE_EFFECT_CALL.search(_blank_quoted(text, identifiers=False))
if call:
return f"{call.group(1)}() 有副作用"
return None
def check(sql: str, *, readonly: bool, source_name: str = "") -> str:
"""校验并返回规范化后的单条 SQL。不通过就抛 SqlRejected。
返回值是去掉尾分号的那一条语句——调用方应该执行这个返回值,
而不是原始输入,否则守卫等于没做。
"""
if not sql or not sql.strip():
raise SqlRejected("SQL 是空的")
statements = split_statements(sql)
if not statements:
raise SqlRejected("SQL 里没有可执行的语句")
if len(statements) > 1:
raise SqlRejected(
f"一次只能执行一条语句,这里有 {len(statements)} 条。"
"拆成多次调用——分号拼接的语句不会被执行。"
)
stmt = statements[0]
verb = first_verb(stmt)
if not verb:
raise SqlRejected("看不出这条 SQL 要做什么(没有识别到关键字)")
if verb in _ALWAYS_DENIED:
raise SqlRejected(
f"{verb.upper()} 属于结构变更或权限操作,任何数据源上都不允许。"
"这类操作请在数据库客户端里人工执行。"
)
if readonly:
reason = _write_reason(stmt)
if reason:
where = f"「{source_name}」" if source_name else "这个数据源"
raise SqlRejected(
f"{where}是只读的:{reason},不被允许。"
"只能执行不改数据的 SELECT / WITH / SHOW / DESCRIBE / EXPLAIN。"
"确实需要写入的话,请在设置里另建一个关闭了只读的数据源。"
)
return stmt
def is_write(sql: str) -> bool:
"""是不是写操作。用来决定要不要走人工审批。
和只读判定用同一份 _write_reason:审批这道门要是只看首关键字,
`WITH … DELETE` 在可写源上就能不经审批直接写。
"""
statements = split_statements(sql)
return not statements or any(_write_reason(s) is not None for s in statements)