mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:41:59 +00:00
39e7a5b014
阶段①。产品原先能取数的只有 http_request 和沙箱里自己写代码——面对"分析上季度
华东区销量下滑"这类需求,用户得自己知道数据在哪张表、字段叫什么,再手写 SQL 塞进
代码节点。最难的部分留给了用户。
## 连接层(app/data/)
方言差异全收敛在 engine.py 一处:Oracle 要 service_name/sid、MySQL 要 charset、
各家默认端口不同。三个驱动都是 asyncio 原生且免装客户端——oracledb 走 thin 模式,
不需要 Instant Client。密码复用 app/core/crypto 的 Fernet,和 Provider 同一套;
build_url 默认把密码替换成星号,日志和错误信息里用这个版本。
introspect.py 产出两种粒度:summary()(表名+代表字段,几百 token,给 Copilot 的
system prompt 用)和 describe_table()(单表完整字段,按需取)。分两种是因为一个
中等业务库全量塞进 prompt 轻松几万 token,既贵又会把真正的需求淹掉。
## 守卫(app/data/guard.py)
这是唯一不能出错的地方:SQL 由模型生成,模型会写出什么事前无法穷举,防线不能
建立在"它应该不会这么干"上。三层:
- 只读判定用**白名单**而非黑名单——黑名单永远漏,MERGE/CALL/GRANT/各家私货写不完
- 多语句在到达驱动前就断掉,不赌驱动默认只跑第一条(MySQL 开了
CLIENT_MULTI_STATEMENTS 就会全跑)
- 行数/字节/超时三重上限,一条 SELECT * 不该把内存和模型上下文一起打爆
刻意不引 sqlparse:完整解析 SQL 方言是无底洞,而这里的判断很窄——"首关键字是
什么"和"有没有第二条语句"。窄问题用窄解法。难点是不能误杀:`SELECT ';'` 只是
一条语句,简单 split(";") 会把它切两半然后拒掉,那比漏杀更让人无从下手。
## 工具(app/tools/datasource.py)
每个数据源动态生成一对 db_query__<源> / db_schema__<源>,而不是一个通用工具带
datasource 参数:工具名自带语义,模型不会把查销售库的 SQL 发给财务库;description
里直接塞表清单,省掉"先问有哪些表"那一轮。接进 registry.build_tools 作为第四来源
(前三个是内置/MCP/自定义)。
查询结果落 put_json(kind="query_snapshot"),把 SQL 原文 + 结果集 + 数据源一起存,
出具体系的数字回指能下钻到"这个数是哪条 SQL 查出来的"。
## 实测
真实 SQLite 业务库(600 行订单,埋了华东 6 月断崖的信号)+ 真实模型:
agent 8 轮自主查询——列表 → 查三张表结构 → 探数据时间范围 → 查地区维度 →
按月汇总 → 按地区+月份下钻,准确定位到华东 6 月从 1187 件跌到 354 件。
只读守卫在 agent 路径上同样生效(UPDATE/DROP/多语句全被拒且数据未变)。
测试 55 项:守卫 30 项(含"字符串里的分号不能误杀")、连接与探查 15 项、
URL 构造 6 项、Copilot 流式 4 项。
147 lines
5.5 KiB
Python
147 lines
5.5 KiB
Python
"""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)
|
||
|
||
|
||
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 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 and verb not in _READONLY_VERBS:
|
||
where = f"「{source_name}」" if source_name else "这个数据源"
|
||
raise SqlRejected(
|
||
f"{where}是只读的,{verb.upper()} 不被允许。"
|
||
"只能执行 SELECT / WITH / SHOW / DESCRIBE / EXPLAIN。"
|
||
"确实需要写入的话,请在设置里另建一个关闭了只读的数据源。"
|
||
)
|
||
|
||
return stmt
|
||
|
||
|
||
def is_write(sql: str) -> bool:
|
||
"""是不是写操作。用来决定要不要走人工审批。"""
|
||
return first_verb(sql) not in _READONLY_VERBS
|