mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:41:59 +00:00
K3b:工具边界的按值遮只换文字格,换过的格子记进快照;占位的短词不当遮罩值
以前工具边界按值遮扩到没设遮罩的数值列:访问次数正好等于某一笔被遮的金额,交给模型的、落库的都成了「已遮罩」,column_types 仍是 number、快照不留记录,盲算引用到时说成「不是数值」,存下的证据被改写。现在 _masked_view 在工具边界不按值换数值格(数、列类型记成 number 的格子),只换文字格(整格就是遮罩值的、文字里夹着的那一截),换过的格子随快照存下(masked_cells:[行, 列号],不交给模型),核查 agent 的格子引用指到它们时照实说是遮罩。回答、质询工件里的按值遮(第二道防线)照旧。遮罩列里占位的写法(未知、暂无、N/A……)不收进遮罩值,没设遮罩的区域、备注里的同一个词不再被换掉。补测试,分别去掉数值格的豁免、masked_cells 的记录、引用时的判断、占位词的过滤,对应的测试都变红。
This commit is contained in:
@@ -46,8 +46,10 @@
|
||||
已知局限(遮罩只减少暴露,不是安全边界,同证据面板):
|
||||
- 按值认有门槛,两头都有误差:短的数(年龄、等级这类不到 SECRET_DIGITS 位的)在回答正文、格子里不按值遮,agent 在
|
||||
正文里写出某人的年龄遮不住,只靠 SQL 里的等值比较和按列遮;同一个短的数在 SQL 里被换掉了,别的列上撞上它的阈值、
|
||||
LIMIT 的行数照原样(age = 30 AND visits >= 30)。够长的数在格子和正文里按值遮,碰巧和某个遮罩值相同的数(访问次数
|
||||
正好等于某一笔金额)也遮掉。这是产品取舍,不按上下文去猜
|
||||
LIMIT 的行数照原样(age = 30 AND visits >= 30)。够长的数在回答正文和回答引用的格子里按值遮,碰巧和某个遮罩值相同的
|
||||
数(访问次数正好等于某一笔金额)也遮掉,这是产品取舍,不按上下文去猜;工具边界不按值换数值格(存下的是证据),交给
|
||||
agent 的、存下的查询快照里这样的数照原样,只在回答和质询工件里遮。占位的写法(未知、暂无……,masking.PLACEHOLDERS)
|
||||
不当遮罩值
|
||||
- 遮罩数值列的 SUM / AVG / MIN / MAX 算派生自遮罩列,整列遮(COUNT 不算,见 _value_columns),CASE 里有一支取的是
|
||||
遮罩列的也算:MIN / MAX 就是某一行的原值,只有一行的分组 SUM / AVG 也是,按列认不出哪些汇总值安全,宁可多遮。
|
||||
核查 agent 的查询在工具边界照这个办法遮,所以这样的指标双盲复算不了(原 SQL 里认得出的,派盲算之前就记为无法
|
||||
@@ -106,8 +108,9 @@
|
||||
从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮)
|
||||
- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照落库时就遮好了(工具边界先遮),取自遮罩列的值、
|
||||
报告快照里的遮罩值、agent 前面的查询里遮掉的值都看不到。只有一种照原样:先查了写着号码的备注(那时还不知道这个号码
|
||||
是遮罩值),后来才查出号码——备注快照落库时原样,从通用接口打开照原样,质询工件接口照事件里记下的位置换。报告自己
|
||||
的查询快照(写作时的查询)仍原样存着,从那里打开不经这里的遮罩
|
||||
是遮罩值),后来才查出号码——备注快照落库时原样,从通用接口打开照原样,质询工件接口照事件里记下的位置换。没设遮罩的
|
||||
数值格碰巧等于遮罩值的(见上),落库时照原样,从通用接口打开也照原样。报告自己的查询快照(写作时的查询)仍原样存着,
|
||||
从那里打开不经这里的遮罩
|
||||
- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧)。agent 自己查到的值先收,报告快照里的后收:核查
|
||||
agent 把两份分开记(masking.SeenValues),查询时按值遮、交回调用方、回答时合起来,都是 agent 查到的在前
|
||||
- 换了写法的号码只认数字之间夹着一到四个分隔符(空格、制表符、全角空格、不换行空格、窄空格这类空白,连字符、减号、
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
质询(engine/inquiry.py)、双盲复算(nodes/dual_check.py,经 inquiry 的同名导出)共用:
|
||||
|
||||
- 工具边界先遮(mask_query_result):核查 agent 的查询结果交给模型、落进快照之前,取自遮罩列的格子换成「已遮罩」,
|
||||
别的格子按值换:这份结果里的遮罩值,加上这个 agent 前面的查询遮掉的、调用方交进来的(报告快照里的)。模型看不到
|
||||
别的文字格按值换:这份结果里的遮罩值,加上这个 agent 前面的查询遮掉的、调用方交进来的(报告快照里的)。模型看不到
|
||||
遮罩值,就不会把它写进回答、理由、SQL——事后按值认每一轮复核都找到新的漏法,根治靠这一步;审批质询回答、反方
|
||||
审稿理由里的按值遮降为第二道防线。查询失败时报错原话里的值也换(mask_error_text)
|
||||
审稿理由里的按值遮降为第二道防线。宁可多遮,但不改写没设遮罩的数值证据:数值格不按值换,按值换过的文字格记进
|
||||
快照(masked_cells)。查询失败时报错原话里的值也换(mask_error_text)。分析本身出了意外,整份遮(_masked_whole)
|
||||
|
||||
- 哪些列(_derived_hidden):从遮罩列本身起,按 SQL 一层层认出值取自遮罩列的结果列(别名、函数、子查询、WITH、UNION 的
|
||||
另一支、和 * 写在同一支里没起别名的表达式、FROM 里拆开遮罩列的表值函数、函数参数里的 JSON 箭头 col->'path');* 把
|
||||
@@ -21,7 +22,8 @@
|
||||
TABLE x)的,整份快照的列都算。双盲复算按同一套认法在派盲算之前挡下取自遮罩列的指标(derived_columns)
|
||||
- 只装着分档标签的列(_label_columns):标签是 agent 写在 SQL 里的字,不当遮罩值;* 展开数据源里的表时按冻结的表结构
|
||||
认它带不带同名的列(_schema_tables)
|
||||
- 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),换的时候先按词、按开头几个字筛出
|
||||
- 哪些值(_Secrets):遮罩列里出现过的值,按值认有门槛(SECRET_MIN、SECRET_DIGITS),占位的写法(PLACEHOLDERS:未知、
|
||||
暂无……)不收;换的时候先按词、按开头几个字筛出
|
||||
正文里真有的那几个值,不现编上万个分支的正则;正文、文字格、SQL 的字符串和注释里换了写法的长号码(199-1474-2785、
|
||||
+86 …、全角数字)按纯数字写法在集合里查(loose_spans);已经摆在读者眼前的值免遮,之后并进来的照样免(protected)
|
||||
- 快照遮好的样子(_masked_view),回答时才认得出、事后要照位置重放的地方(view_edits、replay_edits)
|
||||
@@ -56,6 +58,12 @@ SECRET_MIN = 2
|
||||
SECRET_DIGITS = 5
|
||||
#: 遮罩列的值最多收这么多种写法(一份快照最多上千行):再多就按已经收到的换
|
||||
SECRETS_MAX = 20_000
|
||||
#: 占位的写法:遮罩列里没填的格子常这样写,不是哪个人的信息。当遮罩值收了的话,没设遮罩的列、备注里同一个词都会被换掉
|
||||
#: (区域「未知」、「原因未知」变成「原因已遮罩」),一个都不收(不分大小写)
|
||||
PLACEHOLDERS = frozenset({
|
||||
"未知", "暂无", "不详", "不明", "无", "空", "未填", "未填写", "未提供", "待定", "待补充", "暂缺", "缺失", "没有",
|
||||
"其他", "其它", "保密", "匿名", "未登记", "未记录", "无数据", "null", "none", "nil", "nan", "n/a", "na", "-", "--",
|
||||
})
|
||||
|
||||
|
||||
#: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截)
|
||||
@@ -1415,6 +1423,11 @@ def _forms(value: Any) -> set[str]:
|
||||
return forms
|
||||
|
||||
|
||||
def _placeholder(value: Any) -> bool:
|
||||
"""一格是占位的写法(PLACEHOLDERS:未知、暂无、N/A……),不当遮罩值收。"""
|
||||
return isinstance(value, str) and _plain_text(value).lower() in PLACEHOLDERS
|
||||
|
||||
|
||||
def _strong(form: str) -> bool:
|
||||
"""按值认得出的写法(见 SECRET_MIN、SECRET_DIGITS)。"""
|
||||
if _NUMERIC.fullmatch(form):
|
||||
@@ -1522,7 +1535,8 @@ class _Secrets:
|
||||
def add(self, snapshot: Any, hidden: set[str], *, labels: dict[str, set[str]] | None = None,
|
||||
base: Iterable[str] = ()) -> None:
|
||||
"""收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照里只装着分档标签的列(_label_columns):这几列的
|
||||
格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。"""
|
||||
格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。
|
||||
占位的写法(PLACEHOLDERS:未知、暂无……)不收。"""
|
||||
if not _query_shaped(snapshot) or not hidden:
|
||||
return
|
||||
own = set(base)
|
||||
@@ -1541,7 +1555,7 @@ class _Secrets:
|
||||
else:
|
||||
continue
|
||||
for name, value in cells:
|
||||
if value is None or isinstance(value, bool) or value == word:
|
||||
if value is None or isinstance(value, bool) or value == word or _placeholder(value):
|
||||
continue
|
||||
forms = _forms(value)
|
||||
if forms & written.get(name, set()):
|
||||
@@ -1777,7 +1791,8 @@ def _scrubbed_source(text: str, secrets: _Secrets) -> str:
|
||||
|
||||
|
||||
def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets, *, embedded: bool = False,
|
||||
rename: bool = True) -> tuple[dict[str, Any], list[str]]:
|
||||
rename: bool = True, numbers: bool = True, changed: list[list[int]] | None = None
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数);
|
||||
别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。
|
||||
列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。
|
||||
@@ -1788,6 +1803,9 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets,
|
||||
里也没设遮罩。回答一路不这样换:渲染出来露了就整份不交(_leaked),不然回答被问的那一格看着像查到了别的东西。
|
||||
rename 为假时列名不换(工具边界先遮时:列名是模型自己写的,换了它引用的列名就对不上)。列名按文字的办法换(换了写法的
|
||||
号码也认,同 SQL 里带引号的名字)。
|
||||
numbers 为假时数值格(数、列类型记成 number 的格子)不按值换,只换文字格(工具边界先遮时:存下的就是证据,访问次数
|
||||
正好等于某一笔被遮的金额,换掉就改写了没设遮罩的数,双盲复算也取不到它);要遮的列照旧整列换。changed 给了的话,按值
|
||||
换过的格子(整格换的、文字里换掉一截的)按 [行, 列] 记进去,要遮的列里的不记。
|
||||
工具边界遮过的快照(记着 masked_at_query)里,整格就是「已遮罩」的是查询时按值换掉的:这一列照样记进遮掉的列,和回答时
|
||||
按值换掉的一样画。"""
|
||||
columns = [str(c) for c in snapshot.get("columns") or []]
|
||||
@@ -1801,8 +1819,10 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets,
|
||||
edited = False
|
||||
position = {c: i for i, c in enumerate(columns)}
|
||||
names = [secrets.scrub(c, loose=True) for c in columns] if by_value and rename else columns
|
||||
kinds = snapshot.get("column_types") if isinstance(snapshot.get("column_types"), dict) else {}
|
||||
counted = {i for i, c in enumerate(columns) if kinds.get(c) == "number"} if not numbers else set()
|
||||
|
||||
def cell(i: int | None, value: Any) -> Any:
|
||||
def cell(r: int, i: int | None, value: Any) -> Any:
|
||||
nonlocal edited
|
||||
if i in index:
|
||||
return word
|
||||
@@ -1810,15 +1830,21 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets,
|
||||
if i is not None:
|
||||
touched.add(i)
|
||||
return value
|
||||
if not numbers and (i in counted or (isinstance(value, (int, float)) and not isinstance(value, bool))):
|
||||
return value
|
||||
if by_value and secrets.hit(value):
|
||||
if i is not None:
|
||||
touched.add(i)
|
||||
if changed is not None:
|
||||
changed.append([r, i])
|
||||
return word
|
||||
if embedded and by_value and isinstance(value, (str, dict, list)):
|
||||
# JSON 列(对象、数组)按界面画出来的样子换,换过的照文字给
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
if (scrubbed := secrets.scrub(text, loose=True)) != text:
|
||||
edited = True
|
||||
if changed is not None and i is not None:
|
||||
changed.append([r, i])
|
||||
return scrubbed
|
||||
return value
|
||||
|
||||
@@ -1827,11 +1853,11 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets,
|
||||
return names[i] if i is not None else k
|
||||
|
||||
rows: list[Any] = []
|
||||
for row in snapshot["rows"]:
|
||||
for r, row in enumerate(snapshot["rows"]):
|
||||
if isinstance(row, list):
|
||||
rows.append([cell(i, v) for i, v in enumerate(row)])
|
||||
rows.append([cell(r, i, v) for i, v in enumerate(row)])
|
||||
elif isinstance(row, dict):
|
||||
rows.append({key(k): word if str(k).lower() in hidden else cell(position.get(str(k)), v)
|
||||
rows.append({key(k): word if str(k).lower() in hidden else cell(r, position.get(str(k)), v)
|
||||
for k, v in row.items()})
|
||||
else:
|
||||
rows.append(row)
|
||||
@@ -1972,11 +1998,13 @@ class QueryMask:
|
||||
|
||||
payload:遮好的结果,交给模型的、落进快照的都是它。columns:查询时就整列遮掉的结果列(结果里的列名,按先后)。
|
||||
secrets:这份结果里遮掉的值,只在内存里交给调用方(审批质询按值遮的第二道防线:模型照着别处看到的号码写进回答、
|
||||
查询条件时认得出),不进快照、不交给模型。"""
|
||||
查询条件时认得出),不进快照、不交给模型。cells:别的列里按值换过的格子 [行, 列](整格换的、文字里换掉一截的),
|
||||
随快照存下(masked_cells),格子引用指到它们照实说是遮罩,不说成「不是数值」。"""
|
||||
|
||||
payload: dict[str, Any]
|
||||
columns: list[str]
|
||||
secrets: _Secrets
|
||||
cells: list[list[int]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -2013,10 +2041,12 @@ def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str
|
||||
SELECT *(展开的列换了名字的也算)、表值函数、整行引用和函数参数里的 *、列名表、TABLE x;COUNT、只在条件里用到、
|
||||
分档当分组键的不算);SQL 解析不了、又写着遮罩列(或者 *、TABLE x)的,整份结果的列都遮。这几列的格子换成
|
||||
「已遮罩」,类型记成文本
|
||||
- 别的列按值换:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,_Secrets.loose_spans)。遮罩值
|
||||
是这份结果里的加上 known:先查号码、再查写着号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样
|
||||
遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN);只装着分档标签的列里的标签不算遮罩值,这份结果自己写的标签也不
|
||||
按 known 换(前面的查询认不准、当遮罩值收了的同名标签,不能把这一份的档位列换掉)
|
||||
- 别的列按值换,只换文字格:整格就是遮罩值的换掉,文字格里夹着的那一截换掉(换了写法的号码也认,
|
||||
_Secrets.loose_spans),换过的格子记下来(QueryMask.cells)。遮罩值是这份结果里的加上 known:先查号码、再查写着
|
||||
号码的备注、没设遮罩的联系方式表(别的库里的也一样)时,后一份同样遮好。门槛同按值遮(SECRET_DIGITS、SECRET_MIN);
|
||||
只装着分档标签的列里的标签不算遮罩值,这份结果自己写的标签也不按 known 换(前面的查询认不准、当遮罩值收了的同名
|
||||
标签,不能把这一份的档位列换掉)。数值格(数、列类型是 number 的格子)不按值换:存下的快照就是证据,没设遮罩的
|
||||
数值列(访问次数正好等于某一笔被遮的金额)换掉就改写了证据,双盲复算也取不到它
|
||||
- 列名不换(模型自己写的,换了它引用的列名就对不上);结果里一列都没取自遮罩列、也没有可按值换的原样交回:数值的
|
||||
汇总、只数个数的查询不受影响
|
||||
|
||||
@@ -2044,11 +2074,12 @@ def mask_query_result(payload: dict[str, Any], masked: Iterable[str], kind: str
|
||||
if not pool.strong and not any(c.lower() in hidden for c in columns):
|
||||
return QueryMask(payload, [], own)
|
||||
pool.prepare()
|
||||
view, _ = _masked_view(payload, hidden, pool, embedded=True, rename=False)
|
||||
cells: list[list[int]] = []
|
||||
view, _ = _masked_view(payload, hidden, pool, embedded=True, rename=False, numbers=False, changed=cells)
|
||||
except Exception: # noqa: BLE001 - 认不准就整份遮掉,原值不交给模型
|
||||
logger.warning("核查查询的遮罩分析出错,整份结果按遮罩处理", exc_info=True)
|
||||
return QueryMask(_masked_whole(payload), columns, _Secrets())
|
||||
return QueryMask(view, [c for c in columns if c.lower() in hidden], own)
|
||||
return QueryMask(view, [c for c in columns if c.lower() in hidden], own, cells)
|
||||
|
||||
|
||||
def _masked_whole(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
**先遮再交。** 建查询工具时还打开遮罩开关(tools/datasource 的 mask_results):数据源设了遮罩时,取自遮罩列的格子
|
||||
(按 SQL 认,起别名、套函数、经子查询或 WITH 转一道、SELECT *(展开的列换了名字的也算)、表值函数拆开的都算)在交给
|
||||
模型、落进查询快照之前就换成「已遮罩」,别的格子按值换:这份结果里的遮罩值、前面的查询遮掉的、调用方交进来的
|
||||
模型、落进查询快照之前就换成「已遮罩」,别的文字格按值换:这份结果里的遮罩值、前面的查询遮掉的、调用方交进来的
|
||||
(masked_values:审批质询、反方审稿收的报告快照里的值),文字格里夹着的换那一截(engine/masking.py 的
|
||||
mask_query_result);查询失败时报错原话里的值也换。模型看不到遮罩值,就不会把它写进回答、理由、SQL;以前是事后
|
||||
按值认,每一轮复核都找到新的漏法。快照里的 masked_at_query 记下查询时就遮了的列,格子引用指到这几列、指到按值换掉
|
||||
的格子的都不采信。遮掉的值只在内存里交回调用方(VerifyOutcome.masked_values,审批质询、反方审稿按值遮的第二道防线
|
||||
mask_query_result);数值格不按值换(存下的是证据,没设遮罩的访问次数正好等于某一笔被遮的金额也照原样);查询失败时
|
||||
报错原话里的值也换。模型看不到遮罩值,就不会把它写进回答、理由、SQL;以前是事后按值认,每一轮复核都找到新的漏法。
|
||||
快照里的 masked_at_query 记下查询时就遮了的列,masked_cells 记下按值换过的格子,格子引用指到这几列、这几格的都不采信,
|
||||
照实说是遮罩。遮掉的值只在内存里交回调用方(VerifyOutcome.masked_values,审批质询、反方审稿按值遮的第二道防线
|
||||
用),不进快照、不交给模型、不进 as_dict。调用方交进来的和查询时遮掉的分开记(masking.SeenValues),合起来时 agent
|
||||
查到的在前:合起来到了上限 SECRETS_MAX,挤掉的是报告里的值。
|
||||
|
||||
@@ -438,6 +439,13 @@ class _Cells:
|
||||
"row": row, "column": name, "artifact": query["artifact"],
|
||||
"reason": f"{alias} 第 {row} 行「{name}」与设置了遮罩的值相同,查询时已遮罩,不采信"})
|
||||
return None
|
||||
if _masked_cell(content, row, name):
|
||||
# 文字里有一截查询时按值换掉了(快照的 masked_cells 记着):这一格不是数据库里的原样,同样照实说
|
||||
self.cells.append({"path": path, "status": "unresolved", "ref": f"{alias}.r{row}.{name}", "alias": alias,
|
||||
"row": row, "column": name, "artifact": query["artifact"],
|
||||
"reason": f"{alias} 第 {row} 行「{name}」里有与设置了遮罩的值相同的部分,查询时已遮罩,"
|
||||
"不采信"})
|
||||
return None
|
||||
truth = _typed(raw, {"type": sorted(types)}, column_kind(content, name))
|
||||
cited = {"ref": f"{alias}.r{row}.{name}", "alias": alias, "row": row, "column": name,
|
||||
"artifact": query["artifact"], "eid": cell_eid(query["artifact"], row, name), "value": truth}
|
||||
@@ -539,6 +547,16 @@ def _masked_at_query(content: Any) -> set[str]:
|
||||
return {c.lower() for c in listed if isinstance(c, str)} if isinstance(listed, list) else set()
|
||||
|
||||
|
||||
def _masked_cell(content: Any, row: Any, name: str) -> bool:
|
||||
"""查询快照里第 row 行、name 这一列的格子查询时按值换过(tools/datasource 记下的 masked_cells:[行, 列号])。列名
|
||||
同名的按第一列算,同 locate_cell。"""
|
||||
listed = content.get("masked_cells") if isinstance(content, dict) else None
|
||||
columns = [str(c) for c in content.get("columns") or []] if isinstance(content, dict) else []
|
||||
if not isinstance(listed, list) or name not in columns:
|
||||
return False
|
||||
return [row, columns.index(name)] in listed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 花费
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -470,7 +470,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
mask_results:数据源设了遮罩时,取自遮罩列的格子(按 SQL 认,改了名字的也算)在交给模型、落进快照之前就换成
|
||||
「已遮罩」,见 engine/masking.py 的 mask_query_result;这一次遮掉的值并进 ctx.extra[MASKED_VALUES_KEY](有的话)。
|
||||
那里已经有的值(这个 agent 前面的查询遮掉的、调用方交进来的)这一次也按值换,数据源没设遮罩也一样:先查号码、
|
||||
再查写着号码的备注或别的库,后一份同样遮好。查询失败时报错原话里的值也换(masking.mask_error_text)。
|
||||
再查写着号码的备注或别的库,后一份同样遮好。按值换只换文字格,数值格照原样(存下的是证据)。查询失败时报错原话里
|
||||
的值也换(masking.mask_error_text)。
|
||||
"""
|
||||
# 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句,
|
||||
# 引擎按它告诉界面上限是多少(metadata["timeout_s"])
|
||||
@@ -530,8 +531,10 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
|
||||
payload = result.to_payload()
|
||||
payload["source"] = source.name
|
||||
# 查询时就整列遮掉的结果列;没开遮罩开关、数据源没设遮罩又没有可按值换的时为 None(快照和原来一字不差)
|
||||
# 查询时就整列遮掉的结果列;没开遮罩开关、数据源没设遮罩又没有可按值换的时为 None(快照和原来一字不差)。
|
||||
# 别的列里按值换过的格子另记(masked_cells),没有就不记
|
||||
masked_now: list[str] | None = None
|
||||
masked_cells: list[list[int]] = []
|
||||
if mask_results and (mask or known is not None):
|
||||
from app.engine.masking import mask_query_result
|
||||
|
||||
@@ -539,7 +542,7 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
done = await asyncio.to_thread(mask_query_result, payload, mask, source.kind, known,
|
||||
schema=source.schema_cache)
|
||||
if mask or done.payload is not payload:
|
||||
payload, masked_now = done.payload, done.columns
|
||||
payload, masked_now, masked_cells = done.payload, done.columns, done.cells
|
||||
if sink is not None:
|
||||
# 并进 agent 查到的那一份(不和调用方交进来的混在一起,上限收满了也不丢),也放进线程
|
||||
await asyncio.to_thread(sink.add, done.secrets)
|
||||
@@ -562,6 +565,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
# 同源各份快照记的这一项并起来,从表的字段清单里去掉,混进结果列的别名会把同名的真实字段也去掉;
|
||||
# - masked_at_query:开了遮罩开关时,查询时就整列遮掉的结果列(这几列的格子是「已遮罩」,不是数据源里的值,
|
||||
# 改了名字的也在这里)。有这一项的快照,别的格子整格是「已遮罩」的是查询时按值换掉的;
|
||||
# - masked_cells:别的列里查询时按值换过的文字格 [行, 列号](整格换的、换掉一截的),格子引用指到它们时照实
|
||||
# 说是遮罩。数值格不按值换,存下的数和数据库里的一样;
|
||||
# - data_version:上传表格查的是哪个快照,事后据此认得出这个数出自哪一版数据。
|
||||
# 手工源、没设遮罩的,快照和原来一字不差
|
||||
extra: dict[str, Any] = {}
|
||||
@@ -569,6 +574,8 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False,
|
||||
extra["mask_columns"] = mask
|
||||
if masked_now is not None:
|
||||
extra["masked_at_query"] = masked_now
|
||||
if masked_cells:
|
||||
extra["masked_cells"] = masked_cells
|
||||
if version := getattr(source, "snapshot_id", None):
|
||||
extra["data_version"] = version
|
||||
# 工件引用的 meta 记下源和 SQL 里用到的表:数据目录按它数每张表被运行查询过几次
|
||||
|
||||
@@ -90,3 +90,95 @@ async def test_the_model_and_the_snapshot_see_only_masks_when_the_analysis_fails
|
||||
assert got.status_code == 200 and _clean(got.text) and "1200" not in got.text
|
||||
assert out.result["value"] is None
|
||||
assert out.cells[0]["reason"] == "Q1 的「amount」取自设置了遮罩的列,查询时已遮罩,不采信"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 多遮不改写没设遮罩的数值证据;占位的短词不当遮罩值
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: 金额设了遮罩,都是五位数;访问次数没设遮罩,第二、三单正好等于别的订单的金额(20000、30000)。联系表的访问次数存成
|
||||
#: 文字('20000'),客户名设了遮罩、里面有占位的「未知」,区域和备注里也写着「未知」
|
||||
BIG = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, visits INTEGER, buyer_name TEXT,"
|
||||
" note TEXT);"
|
||||
"INSERT INTO sales VALUES (1, '华东', 10000, 25000, '张伟', '原因未知'), (2, '华东', 12000, 20000, '未知', ''),"
|
||||
" (3, '华南', 20000, 30000, '李娜', '客户未知来源'), (4, '未知', 30000, 22000, '王芳', ''),"
|
||||
" (5, '华北', 50000, 9000, '刘洋', '');"
|
||||
"CREATE TABLE contacts (id INTEGER PRIMARY KEY, visits_text TEXT, remark TEXT);"
|
||||
"INSERT INTO contacts VALUES (1, '20000', '上月消费 30000 元'), (2, '8', '例行回访');")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def big_shop(tmp_path):
|
||||
name, source_id = await _source(tmp_path, {"mask_columns": "amount, buyer_name"}, script=BIG)
|
||||
yield name
|
||||
await engines.invalidate(source_id)
|
||||
|
||||
|
||||
async def test_an_unmasked_numeric_column_is_kept_even_when_it_equals_a_masked_value(monkeypatch, big_shop):
|
||||
"""访问次数没设遮罩,第二、三单正好等于别的订单被遮的金额:交给模型的、落库的都是数据库里的原数,类型照旧是数,
|
||||
引用它照常取值(双盲复算取得到 20000)。以前按值整格换成「已遮罩」,快照里不留记录,引用时说成「不是数值」。"""
|
||||
name = big_shop
|
||||
sql = "SELECT id, amount, visits FROM sales ORDER BY id"
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 1, "column": "visits"},
|
||||
"rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
seen = json.loads(script.tool_results()["c1"].split("\n", 1)[1])
|
||||
assert seen["rows"] == [[1, MASKED, 25000], [2, MASKED, 20000], [3, MASKED, 30000], [4, MASKED, 22000],
|
||||
[5, MASKED, 9000]]
|
||||
snapshot = artifact_store.load(out.queries[0]["artifact"])
|
||||
assert snapshot["rows"] == seen["rows"] and snapshot["column_types"]["visits"] == "number"
|
||||
assert snapshot["masked_at_query"] == ["amount"] and "masked_cells" not in snapshot
|
||||
assert out.result["value"] == 20000 and out.cells[0]["status"] == "resolved"
|
||||
|
||||
|
||||
async def test_text_cells_masked_by_value_are_recorded_and_cited_as_masked(monkeypatch, big_shop):
|
||||
"""存成文字的数('20000')、备注里夹着的金额(30000)照样按值换:交给模型的、落库的都换掉,换过的格子记进快照
|
||||
(masked_cells),引用到它们照实说是遮罩,不说成「不是数值」。"""
|
||||
name = big_shop
|
||||
steps = [q(name, "SELECT amount FROM sales ORDER BY id", "c1"),
|
||||
q(name, "SELECT id, visits_text, remark FROM contacts ORDER BY id", "c2")]
|
||||
script = Script(monkeypatch, steps, lambda messages: {"value": {"alias": "Q2", "row": 0, "column": "visits_text"},
|
||||
"rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
seen = json.loads(script.tool_results()["c2"].split("\n", 1)[1])
|
||||
assert seen["rows"] == [[1, MASKED, f"上月消费 {MASKED} 元"], [2, "8", "例行回访"]]
|
||||
assert "masked_cells" not in seen, "记录只在快照里,不交给模型"
|
||||
snapshot = artifact_store.load(out.queries[1]["artifact"])
|
||||
assert snapshot["rows"] == seen["rows"] and snapshot["masked_cells"] == [[0, 1], [0, 2]]
|
||||
assert snapshot["masked_at_query"] == []
|
||||
assert out.result["value"] is None
|
||||
assert out.cells[0]["reason"] == "Q2 第 0 行「visits_text」与设置了遮罩的值相同,查询时已遮罩,不采信"
|
||||
|
||||
|
||||
async def test_a_partly_masked_text_cell_is_cited_as_masked(monkeypatch, big_shop):
|
||||
"""引用指到文字里换掉了一截的格子:同样照实说是遮罩。"""
|
||||
name = big_shop
|
||||
steps = [q(name, "SELECT amount FROM sales ORDER BY id", "c1"),
|
||||
q(name, "SELECT id, remark FROM contacts ORDER BY id", "c2")]
|
||||
Script(monkeypatch, steps, {"value": {"alias": "Q2", "row": 0, "column": "remark"}, "rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
assert out.result["value"] is None
|
||||
assert out.cells[0]["reason"] == "Q2 第 0 行「remark」里有与设置了遮罩的值相同的部分,查询时已遮罩,不采信"
|
||||
|
||||
|
||||
async def test_placeholder_words_are_not_masked_elsewhere(monkeypatch, big_shop):
|
||||
"""客户名设了遮罩,里面有占位的「未知」:客户名整列照旧遮;没设遮罩的区域里的「未知」、备注里的「原因未知」「客户
|
||||
未知来源」照原样。以前「未知」当遮罩值收了,这几格都被换掉。"""
|
||||
name = big_shop
|
||||
sql = "SELECT id, buyer_name, region, note FROM sales ORDER BY id"
|
||||
script = Script(monkeypatch, [q(name, sql, "c1")], {"value": {"alias": "Q1", "row": 0, "column": "id"},
|
||||
"rationale": "x"})
|
||||
out, _ = await ask(name)
|
||||
seen = json.loads(script.tool_results()["c1"].split("\n", 1)[1])
|
||||
assert [row[1] for row in seen["rows"]] == [MASKED] * 5
|
||||
assert [row[2:] for row in seen["rows"]] == [["华东", "原因未知"], ["华东", ""], ["华南", "客户未知来源"],
|
||||
["未知", ""], ["华北", ""]]
|
||||
assert out.masked_values.hit("张伟") and not out.masked_values.hit("未知")
|
||||
|
||||
|
||||
def test_placeholders_are_never_collected():
|
||||
secrets = masking._Secrets()
|
||||
secrets.add({"columns": ["name"], "rows": [["未知"], ["N/A"], ["暂无"], ["张伟"], ["NULL"]]}, {"name"})
|
||||
assert secrets.strong == {"张伟"} and secrets.weak == {"张伟"}
|
||||
assert secrets.scrub("原因未知,暂无记录,N/A,张伟") == f"原因未知,暂无记录,N/A,{MASKED}"
|
||||
|
||||
Reference in New Issue
Block a user