Files
AgentLab/backend/tests/test_challenge_masking.py

248 lines
15 KiB
Python
Raw Permalink 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.
"""反方审稿的遮罩收口(engine/nodes/challenge.py):
- 交给反方 agent 的证据摘录先遮:报告取数把遮罩列改了名字选出来(buyer_phone AS 手机号),摘录里不给这一列(按 SQL
认,和工具边界同一套规则);别的列(备注里换了写法的号码)、SQL 里写着的遮罩值换成「已遮罩」。以前摘录只按列名遮,
四个号码原样交给模型,模型照抄进理由
- 理由正文按值遮(报告快照里的值、agent 查询时遮掉的值、换了写法的号码),端到端;组装之后还认得出遮罩值的(第二道
防线漏了),整句降为无法判断,理由不记,同审批质询的最后一道关
模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额、手机号都是虚构的。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
import pytest
from app.core import artifact_store
from app.data import introspect
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.engine import masking
from app.engine.nodes import challenge as challenge_mod
from tests.fixtures.names import letters
from tests.test_challenge_node import Models, Play, engine_up, finish, graph, output_of # noqa: F401 - engine_up 是自动用上的夹具
#: 四笔订单,买家手机号设了遮罩;备注没设遮罩,其中一条写着换了写法的号码
SCRIPT = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, refunded INTEGER,"
" note TEXT);"
"INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0, '客户 139-0000-0002 代下单'),"
" (2, '华东', '13900000002', 300, 1, ''), (3, '华南', '13900000003', 900, 0, '例行回访'),"
" (4, '华北', '13900000004', 400, 0, '');")
SECRETS = ("1390000000", "0000-0002", "139 0000")
MASKED = "已遮罩"
@pytest.fixture
async def masked_shop(tmp_path):
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript(SCRIPT)
db.commit()
db.close()
name = f"shop_{letters(8)}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True,
options={"mask_columns": ["buyer_phone"]})
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name
await engines.invalidate(source_id)
def _clean(text: str) -> bool:
return not any(s in text for s in SECRETS)
#: 报告取数:手机号改了名字选出来,备注照选,按号码排序时把号码写进了 SQL
REPORT_SQL = ("SELECT buyer_phone AS 手机号, note AS 备注, amount AS 金额 FROM sales "
"ORDER BY buyer_phone = '13900000001' DESC, amount DESC")
WRITER = ("金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]",)
CLAIM = "金额最高的一笔"
async def test_the_excerpt_in_the_task_is_masked_before_the_agent_sees_it(monkeypatch, masked_shop):
"""反方 agent 收到的任务里:改了名字的遮罩列(手机号)不给,备注里换了写法的号码、SQL 里写着的号码换成「已遮罩」,
金额照常。以前「列:金额 | 手机号」「r0:1,200 | 13900000001」…,四个号码都在。"""
name = masked_shop
plays = {CLAIM: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
models = Models(monkeypatch, name, writer=WRITER, plays=plays)
row = await finish(graph(name, sql=REPORT_SQL), run_class="exploratory")
assert row.status == "succeeded", row.error
task = models.tasks()[CLAIM]
assert "金额最高的一笔 1,200。" in task and "【原报告 Q1】" in task
excerpt = task[task.index("【原报告 Q1】"):]
assert _clean(excerpt), excerpt
columns = next(line for line in excerpt.splitlines() if line.startswith("列:"))
assert "手机号" not in columns and "金额" in columns and "备注" in columns
assert "客户 已遮罩 代下单" in excerpt and "buyer_phone = '已遮罩'" in excerpt
assert "(有的列按数据源的设置遮罩了,没有给出)" in excerpt
async def test_masked_values_the_agent_found_itself_are_masked_in_the_rationale(monkeypatch, masked_shop):
"""端到端:反方 agent 自己查出的号码(查询时在工具边界遮掉、只在内存里)被它换着写法写进理由:审稿记录里的理由正文、
理由文档都换成「已遮罩」。报告没有选出号码,值只来自 agent 查询时遮掉的那一份。"""
name = masked_shop
top = "SELECT buyer_phone AS 电话, amount AS 金额 FROM sales WHERE region = '华南'"
said = "华南那笔来自 +86 139 0000 0003,金额 [[v:Q1.r0.金额]]。[[see:Q1]]"
plays = {CLAIM: Play([top], "stands", said)}
Models(monkeypatch, name, writer=WRITER, plays=plays)
row = await finish(graph(name, sql="SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY amount DESC"),
run_class="exploratory")
assert row.status == "succeeded", row.error
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
[claim] = [c for c in record["claims"] if CLAIM in c["text"]]
assert claim["verdict"] == "stands" and claim["rationale"] == "华南那笔来自 已遮罩,金额 900。"
assert claim["rationale_doc"]["markdown"] == claim["rationale"]
assert "0000 0003" not in json.dumps(record, ensure_ascii=False)
async def test_a_rationale_still_showing_a_masked_value_is_not_kept(monkeypatch, masked_shop):
"""最后一道关:按值遮之后组装出来的理由里还认得出遮罩值(这里让按值换失手),这句降为无法判断,理由不记、不存,
原值不进审稿记录。同审批质询的回答。"""
name = masked_shop
monkeypatch.setattr(masking, "_scrubbed_source", lambda text, secrets: text)
said = "号码 13900000001 的那笔属实,金额 [[v:Q1.r0.笔数]]。[[see:Q1]]"
plays = {CLAIM: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "refuted", said)}
Models(monkeypatch, name, writer=WRITER, plays=plays)
row = await finish(graph(name, sql=REPORT_SQL), run_class="exploratory")
assert row.status == "succeeded", row.error
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
[claim] = [c for c in record["claims"] if CLAIM in c["text"]]
assert claim["verdict"] == "inconclusive" and claim["rationale_doc"] is None
assert claim["rationale"] == challenge_mod.LEAKED
assert _clean(json.dumps(record, ensure_ascii=False))
out = await output_of(row.id, "chal")
assert out["counts"]["refuted"] == 0, "遮不住的理由不能让出具降档"
# --------------------------------------------------------------------------
# 摘录分段遮:阈值、没设遮罩的数值格照原样,和遮罩列做等值比较的字面量都换
# --------------------------------------------------------------------------
#: 金额、客户名设了遮罩,金额都是五位数;访问次数没设遮罩,第二、三、四单正好等于别的订单的金额
BIG = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_name TEXT, amount INTEGER, visits INTEGER);"
"INSERT INTO sales VALUES (1, '华东', '张伟', 10000, 25000), (2, '华东', '李娜', 12000, 20000),"
" (3, '华南', '王芳', 20000, 30000), (4, '华北', '刘洋', 30000, 50000), (5, '华北', '赵敏', 50000, 9000);")
@pytest.fixture
async def big_shop(tmp_path):
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript(BIG)
db.commit()
db.close()
name = f"shop_{letters(8)}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True,
options={"mask_columns": ["amount", "buyer_name"]})
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name
await engines.invalidate(source_id)
def _excerpt_of(task: str) -> str:
return task[task.index("【原报告 Q1】"):]
async def test_thresholds_and_unmasked_numbers_in_the_excerpt_are_kept(monkeypatch, big_shop):
"""报告按金额阈值取数(amount >= 20000,正好等于某一笔被遮的金额),访问次数没设遮罩、第二行正好等于另一笔金额:
摘录里 SQL 的阈值、访问次数都照原样,金额列不给。以前对渲染好的整段按值换,成了「amount >= 已遮罩」「r1:4 | 华北 |
已遮罩」,agent 看不到报告按什么取数,没设遮罩的数也被改写。"""
report = "SELECT id, region, amount, visits FROM sales WHERE amount >= 20000 ORDER BY id"
claim = "大额订单里访问最多的一笔在华南"
plays = {claim: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
models = Models(monkeypatch, big_shop, writer=(f"{claim},访问 [[v:Q1.r0.visits]] 次。[[see:Q1]]",),
plays=plays)
row = await finish(graph(big_shop, sql=report), run_class="exploratory")
assert row.status == "succeeded", row.error
excerpt = _excerpt_of(models.tasks()[claim])
assert f"SQL:{report}" in excerpt, excerpt
assert "列:visits | id | region" in excerpt
assert "r1:50,000 | 4 | 华北" in excerpt and MASKED not in excerpt.split("列:", 1)[1].split("(", 1)[0]
async def test_literals_compared_with_a_masked_column_are_masked_even_when_never_collected(monkeypatch, masked_shop):
"""报告没选出号码,只在条件里排除了两个(一个换了写法):报告快照里收不到这两个值,摘录的 SQL 照样换掉——和遮罩列
做等值、IN 比较的字面量不论认不认得出都换。以前按值换只认快照里收到的,号码原样交给 agent。"""
report = ("SELECT region AS 区域, COUNT(*) AS 笔数 FROM sales WHERE buyer_phone NOT IN ('13900000002', "
"'139-0000-0003') GROUP BY region ORDER BY 笔数 DESC")
claim = "笔数最多的区域"
plays = {claim: Play(["SELECT COUNT(*) AS 笔数 FROM sales"], "stands", "共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")}
models = Models(monkeypatch, masked_shop, writer=(f"{claim}有 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]",), plays=plays)
row = await finish(graph(masked_shop, sql=report), run_class="exploratory")
assert row.status == "succeeded", row.error
task = models.tasks()[claim]
assert _clean(task), task
assert f"buyer_phone NOT IN ('{MASKED}', '{MASKED}')" in _excerpt_of(task)
# --------------------------------------------------------------------------
# 最后一道关只看正文和原文:agent 起的列名、照原样留着的阈值不算露了遮罩值
# --------------------------------------------------------------------------
AMOUNTS = "SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY id"
FIRST = "华东首单金额"
@pytest.mark.parametrize("verdict", ["stands", "refuted"])
async def test_a_threshold_in_an_alias_does_not_void_the_verdict(monkeypatch, big_shop, verdict):
"""反方 agent 把阈值写进别名(不低于20000的笔数),20000 恰好是某一笔被遮的金额:结论照常采信,理由照常记。以前最后
一道关把目录里的列名当成露了遮罩值,整句改判无法判断,「被推翻」也不再让出具降档,日志里多一条误报。"""
top = "SELECT COUNT(*) AS 不低于20000的笔数 FROM sales WHERE amount >= 20000"
said = "金额不低于两万的订单有 [[v:Q1.r0.不低于20000的笔数]] 笔,与结论" + ("不矛盾" if verdict == "stands" else "矛盾") \
+ "。[[see:Q1]]"
Models(monkeypatch, big_shop, writer=(f"{FIRST} [[v:Q1.r0.金额]]。[[see:Q1]]",),
plays={FIRST: Play([top], verdict, said)})
row = await finish(graph(big_shop, sql=AMOUNTS), run_class="exploratory")
assert row.status == "succeeded", row.error
out = await output_of(row.id, "chal")
record = artifact_store.load(out["challenge_artifact"])
[claim] = [c for c in record["claims"] if FIRST in c["text"]]
assert claim["verdict"] == verdict, claim["rationale"]
assert claim["rationale"].startswith("金额不低于两万的订单有 3 笔")
assert out["counts"][verdict] == 1
async def test_a_threshold_the_agent_wrote_in_its_sql_stays_in_the_rationale(monkeypatch, big_shop):
"""理由正文复述 agent 自己 SQL 里照原样留着的阈值(「不低于 20000 元」,恰好等于某一笔被遮的金额):照原样,同审批质询
的回答;它随 SQL 存进审稿记录、摆在读者眼前。以前换成「已遮罩」,理由和 SQL 对不上。"""
top = "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount >= 20000"
said = "金额不低于 20000 元的订单有 [[v:Q1.r0.笔数]] 笔,与结论不矛盾。[[see:Q1]]"
Models(monkeypatch, big_shop, writer=(f"{FIRST} [[v:Q1.r0.金额]]。[[see:Q1]]",),
plays={FIRST: Play([top], "stands", said)})
row = await finish(graph(big_shop, sql=AMOUNTS), run_class="exploratory")
assert row.status == "succeeded", row.error
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
[claim] = [c for c in record["claims"] if FIRST in c["text"]]
assert (claim["verdict"], claim["rationale"]) == ("stands", "金额不低于 20000 元的订单有 3 笔,与结论不矛盾。")
async def test_an_alias_naming_a_masked_value_is_renamed_like_the_snapshot(monkeypatch, big_shop):
"""agent 把找的那个值写进了别名(金额为20000的笔数,20000 是一笔被遮的金额,不是阈值):理由文档的目录列名照快照遮好的
样子换(同质询的 _renamed_entry),引用照样对得上,结论照常采信,存下的理由文档里没有这个值。以前目录原样传进重新
组装,最后一道关又把目录拿去认,整句改判无法判断。"""
top = "SELECT COUNT(*) AS 金额为20000的笔数 FROM sales WHERE amount = 20000"
said = "金额为两万的订单有 [[v:Q1.r0.金额为20000的笔数]] 笔,与结论不矛盾。[[see:Q1]]"
Models(monkeypatch, big_shop, writer=(f"{FIRST} [[v:Q1.r0.金额]]。[[see:Q1]]",),
plays={FIRST: Play([top], "stands", said)})
row = await finish(graph(big_shop, sql=AMOUNTS), run_class="exploratory")
assert row.status == "succeeded", row.error
record = artifact_store.load((await output_of(row.id, "chal"))["challenge_artifact"])
[claim] = [c for c in record["claims"] if FIRST in c["text"]]
assert (claim["verdict"], claim["rationale"]) == ("stands", "金额为两万的订单有 1 笔,与结论不矛盾。")
assert "20000" not in json.dumps(claim["rationale_doc"], ensure_ascii=False)