Files
AgentLab/backend/tests/test_sql_guard_scan.py
T
ThearchyHelios d071bf119f 可点击证据第三期:表名字段名和引文也点得开、可疑实体与结论句、受管门禁 G1–G5、证据审计导出
一、表结构、实体与引文

- 数据源工具查库时顺带存一份表结构快照(schema_snapshot),和查询快照一起进台账、进封存;
  台账里的查询记下 SQL 用到的表
- 报告里的表名、字段名可以点:反引号里的名字一律核对,正文里的裸名字只在明显是标识符时才自动链接,
  常见英文单词不链接;点开看到它出现在哪次查询、类型、所属的表,快照被截断时写明
- 找不到的名字标成可疑实体:在快照、SQL、结果列里都没有的记「可能是编造的名字」并给出最接近的 3 个;
  快照被截断、核对不了的记「核对不了」,不冤枉成编造
- 检索引文 [[q:K1|原文]]:原文在检索快照的命中片段里逐字出现(空白归一化后)才算有出处,点开高亮原文位置
- 自动链接的名字不算句子的引用,只有写作者写的引用才算

二、结论句与出具

- 报告节点新增 claims:off(默认,保持原样)或 require_citation(没有引用的结论句计入缺口)
- 受管级别的正式运行里,可疑实体和没有引用的结论句按「有缺口」降档,不拦截;探索运行和已发布级别只标注
- 契约的 claims 和报告节点的设置取更严的那个;受管正式运行里写 ignore 也按降档处理,并给出警告
- 出具横幅除了数字,也写出可疑实体和没有引用的结论句

三、受管门禁 G1–G5(只看图和配置)

- G1 带契约的出口,成果字段的文字只能来自报告撰写节点
- G2 模型写的文字不绕过报告撰写节点直接流进出口
- G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation
- G4 口径卡的输入不来自计算角色的沙箱代码
- G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields
- 受管级别是错误、挡住发布;已发布级别只给警告;已经发布的版本照常运行,下次发布时按新规则检查
- 每条规则都接进发布前自动修复(一键修复 / 给选项 / 交给 Copilot);禁止的改动补上关 cite_fields、
  删 output_schema、放宽契约 claims;Copilot 兜底不许自己把代码节点标成 source、不许放宽报告节点
- 发布弹窗里选「交给 Copilot」时明确写「正在交给 Copilot」

四、旧运行与审计

- 没有契约的旧答案:数字和已封存的查询单元格、口径卡值按数值比对,每个数最多 3 个候选,
  默认折叠,写明「猜测的来源,不能当证据」
- 有契约的旧运行:候选只从已封存的口径卡里找,人为插进工件表的行不当证据
- 证据审计:GET /api/runs/{id}/evidence/audit 按「无证据 / 可疑实体 / 有出处 / 旧运行猜测」分组,
  可导出 JSON 和 CSV(带封存状态);记录页和画布上的报告节点显示「有出处 N · 无证据 M」

五、SQL 守卫修正(安全)

- 去注释改成单遍扫描,认得字符串和各方言的引号(单引号转义、MySQL 反斜杠与 #、PostgreSQL 的 $$ 与嵌套注释、
  Oracle 的 q'[…]'、SQLite 的方括号);以前字符串里的 -- 会被当成注释,SELECT '--'; DROP … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查

六、其他

- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
2026-09-29 05:26:34 +08:00

168 lines
7.1 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 和写的不一样
(run_query 执行的正是 guard.check 的返回值)。只改成执行原文又会让守卫看到的和数据库执行的
不一致,所以扫描必须一遍走完,同时认得注释、字符串、标识符。
"""
from __future__ import annotations
import sqlite3
import uuid
from types import SimpleNamespace
import pytest
from app.data.engine import engines, run_query
from app.data.guard import SqlRejected, check, is_write, split_statements, strip_comments
def rejected(sql: str, *, readonly: bool = True, dialect: str | None = None) -> str | None:
try:
check(sql, readonly=readonly, source_name="测试库", dialect=dialect)
return None
except SqlRejected as e:
return str(e)
# ---------------------------------------------------------------- 字符串原样保留
@pytest.mark.parametrize("dialect", [None, "sqlite", "postgresql", "mysql", "oracle"])
@pytest.mark.parametrize("sql", [
"SELECT '--' AS a",
"SELECT '/* x */' AS a",
"SELECT 'a -- b', 'c /* d' AS a",
'SELECT "col--x" FROM t',
])
def test_comment_markers_inside_quotes_are_kept(sql, dialect):
assert check(sql, readonly=True, dialect=dialect) == sql
assert strip_comments(sql, dialect=dialect) == sql
def test_real_comments_are_still_removed():
assert check("SELECT 1 -- 行注释", readonly=True).strip() == "SELECT 1"
assert check("SELECT /* 块注释 */ 1", readonly=True).split() == ["SELECT", "1"]
assert check("SELECT '--' /* 注释 */ AS a", readonly=True).split() == ["SELECT", "'--'", "AS", "a"]
# 删掉的注释要留一个空格:'x'/**/AS 删成 'x'AS 就粘成一个词了
assert check("SELECT 'x'/**/AS a", readonly=True).split() == ["SELECT", "'x'", "AS", "a"]
@pytest.mark.parametrize("sql,dialect,kept", [
("SELECT 1 /* a /* b */ c */ AS n", "postgresql", "SELECT 1 AS n"), # PG 的块注释可以嵌套
("SELECT E'it\\'s -- x' AS a", "postgresql", None), # E'' 里认反斜杠
("SELECT [a--b] FROM t", "sqlite", None), # SQLite 的方括号标识符
("SELECT q'[it's -- x]' AS a FROM dual", "oracle", None), # Oracle 的 q'[…]'
("SELECT a$b$ AS c -- $b$", "postgresql", "SELECT a$b$ AS c"), # 标识符里的 $ 不开字符串
])
def test_dialect_specific_quoting(sql, dialect, kept):
assert check(sql, readonly=True, dialect=dialect) == (kept if kept is not None else sql)
def test_doubled_quote_escape_is_one_string():
sql = "SELECT 'it''s -- fine' AS a"
assert check(sql, readonly=True) == sql
def test_dollar_quoted_body_is_a_string_on_postgres():
sql = "SELECT $$ -- not a comment $$ AS a, $tag$ /* nor this */ $tag$ AS b"
assert check(sql, readonly=True, dialect="postgresql") == sql
def test_mysql_backslash_escape_without_ambiguity_is_allowed():
sql = r"SELECT 'O\'Brien' AS s"
assert check(sql, readonly=True, dialect="mysql") == sql
def test_mysql_backslash_that_moves_a_comment_boundary_is_rejected():
"""默认 sql_mode 下是一个字符串,NO_BACKSLASH_ESCAPES 下后半截是注释:两种读法不一致就拒。"""
reason = rejected(r"SELECT 'a\'b -- c' AS s", dialect="mysql")
assert reason and "反斜杠" in reason
@pytest.fixture
async def data_source(tmp_path):
path = tmp_path / "scan.db"
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE t (id INTEGER, note TEXT)")
conn.executemany("INSERT INTO t VALUES (?, ?)",
[(1, "a -- b"), (2, "c /* d */"), (3, "plain")])
conn.commit()
conn.close()
src = SimpleNamespace(
id=f"scan-{uuid.uuid4().hex[:8]}", kind="sqlite", database=str(path), readonly=True,
name="测试源", options={}, password=None, username="u", host="h", port=None,
)
yield src
await engines.invalidate(src.id)
async def test_query_with_comment_markers_in_strings_runs_and_returns_right_rows(data_source):
"""字符串里的 -- 和 /* 要原样到达数据库,结果才对。"""
result = await run_query(data_source, "SELECT id FROM t WHERE note = 'a -- b'")
assert result.rows == [[1]]
result = await run_query(data_source, "SELECT id FROM t WHERE note = 'c /* d */' -- 真注释")
assert result.rows == [[2]]
result = await run_query(data_source, "SELECT '--' AS a, '/*' AS b")
assert result.rows == [["--", "/*"]]
result = await run_query(data_source, "SELECT id FROM t WHERE note LIKE '%--%' /* ' */ ORDER BY id")
assert result.rows == [[1]]
# ---------------------------------------------------------------- 拦截
@pytest.mark.parametrize("sql,dialect", [
("SELECT '--'; DROP TABLE t", None),
("SELECT '--'; DROP TABLE t", "sqlite"),
("SELECT '/*'; DELETE FROM t", None),
("SELECT 1 /* ' */; DELETE FROM t", None),
("SELECT 1 -- '\n; DELETE FROM t", None),
("SELECT $$--$$; DROP TABLE t", "postgresql"),
("SELECT $x$ ; $x$; DELETE FROM t", "postgresql"),
("SELECT '\\'; DELETE FROM t", "sqlite"),
("SELECT '\\'; DELETE FROM t", "postgresql"),
("SELECT '\\\\'; DELETE FROM t", "mysql"),
("SELECT 1 # '\n; DELETE FROM t", "mysql"),
("SELECT \"--\"; DELETE FROM t", None),
("SELECT `--`; DELETE FROM t", None),
])
def test_statement_smuggling_is_rejected(sql, dialect):
reason = rejected(sql, dialect=dialect)
assert reason is not None, f"放过了:{sql!r} ({dialect})"
reason_rw = rejected(sql, readonly=False, dialect=dialect)
assert reason_rw is not None, f"可写源上放过了:{sql!r} ({dialect})"
@pytest.mark.parametrize("sql,dialect", [
("SELECT '--' AS a FROM t WHERE 1=1 /* ' */ AND 0 = 0 UNION SELECT 1 FROM t FOR UPDATE", None),
("SELECT $$ ' $$ AS a FROM t FOR UPDATE", "postgresql"),
])
def test_hidden_writes_are_seen_through_quotes_and_comments(sql, dialect):
reason = rejected(sql, dialect=dialect)
assert reason and "只读" in reason, f"漏了:{sql!r}"
@pytest.mark.parametrize("sql,dialect", [
("SELECT '--'; DELETE FROM t", None),
("SELECT 1 /* ' */; DELETE FROM t", "sqlite"),
("SELECT $$--$$; DELETE FROM t", "postgresql"),
])
def test_is_write_counts_smuggled_statements(sql, dialect):
"""审批这道门:看不清的一律当写,等人批。"""
assert is_write(sql, dialect=dialect)
def test_split_statements_respects_quotes_before_comments():
assert len(split_statements("SELECT '--'; SELECT 2")) == 2
assert len(split_statements("SELECT '-- ; x'")) == 1
assert len(split_statements("SELECT $$;$$", dialect="postgresql")) == 1
def test_a_write_word_seen_under_another_mysql_setting_counts():
"""默认设置下是字符串,关了反斜杠转义就成了代码:哪种读法下会写,就按会写处理。"""
sql = r"SELECT 'can\'t delete' AS a"
reason = rejected(sql, dialect="mysql")
assert reason and "只读" in reason and "反斜杠" in reason
assert is_write(sql, dialect="mysql")
assert rejected(sql, readonly=False, dialect="mysql") is None