From 3ced800cde4b5a547e28320ec062091fdf9778df Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 22:44:48 +0800 Subject: [PATCH] =?UTF-8?q?K3b=EF=BC=9A=E5=B7=A5=E5=85=B7=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E7=9A=84=E6=8C=89=E5=80=BC=E9=81=AE=E5=8F=AA=E6=8D=A2?= =?UTF-8?q?=E6=96=87=E5=AD=97=E6=A0=BC=EF=BC=8C=E6=8D=A2=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E6=A0=BC=E5=AD=90=E8=AE=B0=E8=BF=9B=E5=BF=AB=E7=85=A7=EF=BC=9B?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=9A=84=E7=9F=AD=E8=AF=8D=E4=B8=8D=E5=BD=93?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前工具边界按值遮扩到没设遮罩的数值列:访问次数正好等于某一笔被遮的金额,交给模型的、落库的都成了「已遮罩」,column_types 仍是 number、快照不留记录,盲算引用到时说成「不是数值」,存下的证据被改写。现在 _masked_view 在工具边界不按值换数值格(数、列类型记成 number 的格子),只换文字格(整格就是遮罩值的、文字里夹着的那一截),换过的格子随快照存下(masked_cells:[行, 列号],不交给模型),核查 agent 的格子引用指到它们时照实说是遮罩。回答、质询工件里的按值遮(第二道防线)照旧。遮罩列里占位的写法(未知、暂无、N/A……)不收进遮罩值,没设遮罩的区域、备注里的同一个词不再被换掉。补测试,分别去掉数值格的豁免、masked_cells 的记录、引用时的判断、占位词的过滤,对应的测试都变红。 --- backend/app/engine/inquiry.py | 11 ++- backend/app/engine/masking.py | 65 +++++++++++---- backend/app/engine/verify_agent.py | 26 +++++- backend/app/tools/datasource.py | 13 ++- backend/tests/test_masking_tool_boundary.py | 92 +++++++++++++++++++++ 5 files changed, 179 insertions(+), 28 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 696ad70d..3d974140 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -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 查到的在前 - 换了写法的号码只认数字之间夹着一到四个分隔符(空格、制表符、全角空格、不换行空格、窄空格这类空白,连字符、减号、 diff --git a/backend/app/engine/masking.py b/backend/app/engine/masking.py index eee69f4a..a6fce84d 100644 --- a/backend/app/engine/masking.py +++ b/backend/app/engine/masking.py @@ -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]: diff --git a/backend/app/engine/verify_agent.py b/backend/app/engine/verify_agent.py index fd7b3bf8..229e9f0f 100644 --- a/backend/app/engine/verify_agent.py +++ b/backend/app/engine/verify_agent.py @@ -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 + + # -------------------------------------------------------------------------- # 花费 # -------------------------------------------------------------------------- diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 1cf26969..e8b79057 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -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 里用到的表:数据目录按它数每张表被运行查询过几次 diff --git a/backend/tests/test_masking_tool_boundary.py b/backend/tests/test_masking_tool_boundary.py index b5db6244..1f44345e 100644 --- a/backend/tests/test_masking_tool_boundary.py +++ b/backend/tests/test_masking_tool_boundary.py @@ -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}"