mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:31:59 +00:00
d071bf119f
一、表结构、实体与引文
- 数据源工具查库时顺带存一份表结构快照(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 … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查
六、其他
- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
168 lines
7.1 KiB
Python
168 lines
7.1 KiB
Python
"""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
|