Skip to contents

语言: English | 简体中文

本文是英文版 vignette("data-shield") 的完整中文翻译,供内部讨论使用。概念定义、参数默认值以本文为准;如与代码有出入,以代码为准(两版会同步更新,但请以英文版 + 代码作为最终权威来源)。

状态 — P0/P0.5/P1/P1.5/C2/C5-policy 已实现;完整设计仍在推进。 egress 行数上限、受保护值精确匹配、严格 DescribeData、有序扫描器管道、 通用工具调用前置扫描、按工具策略、基于 promise 的 egress 审批、便携式 路径/符号链接沙箱、可选的脱敏代码语义审查器、以及 DP 加噪类别计数 (distributions="dp")均已接线完成。完整 OS 执行隔离适配器与数值/连续 统计量的差分隐私仍是路线图项。默认关闭(data_shield = NULL)。

为什么需要它

当 codeagent 作为后端接入敏感数据(临床、金融、PII)时,我们要的保证是: LLM 可以看到元数据和描述性摘要,但绝不能看到原始行级数据。数据盾是一个 默认关闭、可插拔的安全阀(data_shield = NULL),开启时由若干独立策略组合而成。

核心:三条边

去掉 agent 的其他机制,数据会在三条边穿过模型边界。数据盾在每一条边上 都安装了 gate:

  1. 用户/模型输入(边 1,input gate)——用户输入的文本和含文本的附件在 模型看到之前先扫描。框架自行注入的受保护数据上下文则单独生成经过过滤的 schema-only 元数据(名称、类型、维度及策略允许的安全摘要),绝不包含原始行。
  2. 工具流量(边 2,tool gate)——工具参数在执行前检查,工具结果在回灌 模型前过滤。
  3. 模型最终回复(边 3,output gate)——完整回复在到达用户前扫描,因为 即使用户输入干净,模型也可能复述从边 2 聚合结果中推断出的受保护值。

RAG 内容、附件和错误都必须经过其中一条边。相同策略会递归应用于框架拥有的 前台子代理。

图片边界: prompt 中的图片附件默认不扫描。宿主可以选择接入 OCR hook (data_shield_ocr_scanner()),扫描图片中烘焙的文字以保护边 1。图片/多模态 工具结果仍会绕过文本 egress 扫描,需要宿主另行提供图片结果控制。

技术架构一览

实线主干是自动流程。 蓝色虚线只在 ask 且存在宿主 callback 时进入人工 旁路;没有 callback 时,tool ingress 自动 deny、tool egress 自动 redact,input/ output 的 ask 也降级为 redact。红色虚线表示 reject 或 fail closed。Raw once 必须显式启用并写入审计。中央权限门保持独立,Shield bypass 永远不会绕过权限检查。

下面每个代码片段共用的起手式

本文档里每个代码块都接着一个已经建好的 chat(一个裸的 ellmer Chat, 不是 CodeagentClient 包装器)和一个 shield(DataShield 实例)继续, 两者只在最前面建一次:

chat <- ellmer::chat_openai_compatible(
  base_url    = Sys.getenv("CODEAGENT_BASE_URL"),
  model       = Sys.getenv("CODEAGENT_MODEL"),
  credentials = function() Sys.getenv("CODEAGENT_API_KEY"))
shield <- DataShield$new()

codeagent_client(chat, ...) 不会拷贝 chat——client$chat 跟你传进去的 chat 是同一个对象(可变的 R6/环境语义),所以某个片段调用 chat$register_tool(...) 还是 client$chat$register_tool(...) 效果相同; 本文档统一用裸 chat 变量,图个简洁。

设计:可组合策略(不是固定模式)

data_shield 接受 NULL(关闭)、一个有序策略列表(会创建一个私有 DataShield R6), 或一个显式的 DataShield 实例(用于上传数据、有意在 session/thread 间共享)。 已实现的策略先展示;规划中的策略单独列出,以免示例暗示它们已经存在。

# 当前已实现:
client <- codeagent_client(chat, data_shield = list(
  shield_describe(k_anon = 5),
  shield_egress(detectors = c("row_cap", "value_match"),
                max_rows = 0, on_fail = "block"),
  shield_regex(on_fail = "redact"),
  shield_ingress(langs = c("r", "python", "bash"), on_fail = "ask"),
  shield_tool_policy(rules = list(
    KMPlot = list(ingress = "scan", egress = "bypass"),
    DangerousExport = list(execution = "deny")
  )),
  shield_sandbox(project_root = getwd(), backend = "policy"),
  shield_reviewer(model = Sys.getenv("CODEAGENT_FAST_MODEL"),
                  scope = c("exec", "write", "net"), on_risk = "ask")
))

# 路线图(尚未实现):
# shield_narrow_tools()

主边界是边 2(工具结果);沙箱、ingress 黑名单、审查器、工具收窄都是纵深防御, 不是边界本身。

当前参数参考

data_shield 输入

值 效果
NULL 完全关闭;codeagent 现有行为不变
list(shield_*()) codeagent 为该 client 创建一个私有 DataShield R6;egress 扫描器按列表顺序运行,ingress/reviewer/sandbox/tool-policy 则在各自阶段运行
DataShield$new(...) 显式生命周期,用于上传数据、在多个 chat 间有意共享

Input/output gate 与扫描器默认值

Input gate 和最终回复 gate 属于 client setting,不是 shield_*() 策略。安全默认值如下:

Setting 默认值 效果
data_shield_prompt_on_fail "redact" 脱敏用户文本中的命中片段;"block" 拒绝整轮输入
data_shield_input_scanners c("value_match", "regex") 扫描已注册受保护值和 PII/token 形状
data_shield_response_on_fail "redact" 脱敏最终模型回复;"block" 用 blocked 提示替换回复
data_shield_output_scanners c("value_match", "regex") 对最终回复应用相同的两个检测器
data_shield_image_scanner NULL 除非宿主提供 hook,否则不扫描图片

未知扫描器名称和扫描器异常都会 fail closed。输入侧 "ask" 没有独立审批 通道,会降级为脱敏。含文本附件会被扫描;如果无法提取其文本,本轮会被拦截, 因为不可变附件无法安全地原地改写。盾开启时,CLI 和 Shiny 流式路径会先缓冲 完整模型回复、完成扫描,再一次性发送安全文本——不会先流出明文再告警。

Prompt 图片可这样选择启用 OCR:

client$settings$data_shield_image_scanner <-
  data_shield_ocr_scanner(client$data_shield)

tesseract 是可选依赖。没有安装时,现成的 scanner 会降级为 pass(图片盲区 仍存在)。一旦 OCR 可用且 scanner 已配置,图片不可读、OCR 出错、OCR 文本 扫描出错都会 block;检测到受保护文字时默认也会 block。

shield_egress() —— 核心的工具结果边界

参数 默认值 实际效果
detectors c("row_cap", "value_match") row_cap 挡整批表格输出;value_match 挡已索引的高熵值
max_rows 0 命中整批/表格状输出时,保留 0 行原始打印内容,只返回一句 withheld/blocked 提示。设为 5 会故意暴露前 5 行
on_fail "redact" redact:withheld 提示;block:blocked 提示;ask:交给 LLM 前先暂停
allow_raw_approval FALSE 询问时默认只显示 Redact/Block;设 TRUE 会加一个危险的”Raw once”选项
approval_timeout 60 异步场景下多少秒后自动 redact

max_rows = 0 不会泛化地拦所有 print()。print(nrow(df))、状态消息、 模型摘要、图表、错误信息都会放行,除非其他检测器命中敏感内容。只有输出具备 data.frame/tibble 打印签名或多行矩形表格外观时才会触发。

on_fail="ask" 时,原始结果留在本地,回调/UI 只收到 tool 名/id、策略、原因标签、 命中数、分数、超时时间、以及是否启用了 raw-once。无回调、报错、非法选择、ESC、 超时的默认安全动作都是 redact。Raw-once 只对本次结果生效,且会被审计。

shield_describe() —— 严格的模型安全元数据

参数 默认值 实际效果
distributions "off" "off":不给计数,只给类别标签。"on":真实类别计数(无隐私保护)。"dp":Laplace 加噪的类别计数,从每个 dataset 的 dp_budget 里扣 dp_epsilon;预算耗尽后降级为 "off" 式的纯标签输出。数值/日期/logical/自由文本列在三种模式下行为完全不变——见下文
k_anon 5 支持行数少于 k 的类别标签会变成 <rare suppressed>(三种 distributions 模式下都适用,计数是在这一步之后才加的)
category_max 20 判定为分类处理的最大字符值去重数
category_ratio 0.2 判定为字符分类处理的最大去重/非缺失比率
dp_epsilon 1 "dp" 模式下,每次 describe()/schema-block 调用里每个暴露计数的分类列收取的隐私成本,在该列存活类别间均分。只在 "dp" 下有意义
dp_budget 5 "dp" 模式下每个 dataset 的总 epsilon 预算——一次性额度,不做时间窗口重置。只在 "dp" 下有意义

敏感度仍然钳制输出:identifier/quasi 值始终被抑制;measure/open 可能给 数值/日期的 min-max 和安全的类别标签,"on"/"dp" 下会附加计数。

v1 范围边界:只有分类列会得到计数。数值列(mean/sum/分位数等)不在本轮范围 内——对连续统计量做差分隐私需要一个”裁剪边界”来算噪声强度,而这个边界不能从 真实数据自己的 min/max 直接算(那样等于用私有信息定义隐私保护的强度,是已知的 DP 陷阱)。在宿主能提供真实的、不依赖数据本身的边界之前,range=[min, max] 在三种 distributions 模式下保持不变。logical 列和自由文本列同理不变。 进展跟踪:references/plan/31x-dp-distributions.TODO.md。

某个 dataset 的 dp_budget 耗尽是静默且永久的(直到该 dataset 重新注册为止): 不报错、不给真实计数,只是悄悄退回 "off" 式的纯标签输出。调用 shield$dp_budget_remaining(name)(不传参数则返回所有已注册 dataset 的具名 向量)查看剩余预算,供宿主端展示”隐私预算:2/5”之类的提示。每次消耗/耗尽都会 记进审计日志(strategy = "dp_budget")。

dp_metadata <- shield_describe(distributions = "dp", dp_epsilon = 1, dp_budget = 5)
shield <- DataShield$new(strategies = list(dp_metadata))
shield$register_data(df, name = "study")
shield$describe("study")             # 如果 "arm" 是分类列,花掉 1 epsilon
shield$dp_budget_remaining("study")  # 4

shield_regex() —— 未注册的 PII/密钥

参数 默认值 实际效果
patterns NULL 可选的具名正则规则,使用 PCRE 语法,例如 c(study_id = "STUDY-[0-9]+");开启时追加到默认规则
include_defaults TRUE email、类电话号码、常见 token 前缀、18 位身份证号规则
replacement "[REDACTED]" 每个合并后的命中片段插入一次的标记
on_fail "redact" redact:保留安全的周边文本;block:替换整个结果
ignore_case TRUE 所有规则大小写不敏感

shield_ingress() —— 每次工具调用执行前先扫描

参数 默认值 实际效果
langs c("r", "python", "bash") 为每种代码/shell 语言选择内置规则
patterns NULL 具名正则规则;名字与内置规则同名会替换该规则,新名字会追加。宿主要用文件管理黑名单,自己读文件成具名向量传进来即可
include_defaults TRUE 包含内置的按语言分组规则集(.DATA_SHIELD_INGRESS_RULES):序列化/编码、pandas/R 写文件、网络传输(含 nc/scp//dev/tcp)、数据文件显示、受保护名称预览
on_fail "block" block:拒绝该工具调用;ask:强制走现有权限审批 UI/回调
ignore_case TRUE 大小写不敏感

Ingress 在通常的读/写/执行能力快速路径之前扫描所有工具参数,包括未知 工具和只读工具。它不禁止普通读取:默认规则聚焦于高置信度的”读取并显示”、 序列化、编码、网络传输模式。它是廉价的预筛,因为代码可以被混淆,所以 egress 扫描才是主边界。

shield_tool_policy() —— 按工具精确名/glob 的信任与拒绝规则

设置 含义
default="scan" 每个工具默认都扫描,除非有规则覆盖
execution="deny" 执行前直接拒绝该工具
ingress="bypass" 跳过盾的参数扫描,但权限门仍然生效
egress="bypass" 该工具的输出不经盾过滤直接返回;每次 bypass 都会审计
egress="deny" 把结果替换成一句明确的”策略拒绝”提示

规则支持精确名和 * glob。精确匹配优先;否则第一个匹配的 glob 生效。例如, 当开发者能保证 KMPlot 的所有输出都对 LLM 安全时,可以让它 bypass egress; 而 btw_tool_docs_* 可以拿到一条更宽的信任规则。这条策略永远不会绕过 codeagent 独立的权限系统。

shield_sandbox() —— 失败关闭的便携路径策略

参数 默认值 实际效果
project_root getwd() 路径策略允许的项目根目录
protected_paths 无 额外注册的数据根目录;匹配最长的根目录决定模式
temp_root 新建的 session 临时目录 隔离的临时根目录
modes 项目 rwx、数据 rw、临时 rwx 逻辑上的盾能力(不是 chmod 位)
process_exec TRUE 允许继承同一盾的 Agent/AuditCode;所有非委派 exec 工具仍要求 OS backend
network "tool_policy" 交给工具策略决定;"deny" 直接拦网络能力
symlink_escape "deny" 解析真实路径,拒绝逃逸出允许根目录的软链接
backend "auto" policy、auto、或 required
on_unavailable "policy" 完整 OS 适配器不可用时降级为策略;block 对 exec/net 严格拒绝

当前实现是便携式中央门路径/能力策略,不是内核级隔离。它只放行能解析 到配置根目录的非 exec 路径操作。所有非委派 exec 工具(包括 Bash、RunR、 ExploreData 和 Lint)都会失败关闭:路径 metadata 无法约束代码字符串、子进程、 网络访问或 .lintr 这类可执行项目配置。未来完整适配器必须把这些工具放入 真正的 OS 沙箱。

shield_reviewer() —— 可选的脱敏代码语义审查

参数 默认值 实际效果
client_factory NULL 可选的函数,返回一个全新的独立 ellmer Chat
model CODEAGENT_FAST_MODEL 审查模型;未配置时按 on_error 处理,绝不静默回退主模型
scope exec/write/net 只有这些工具能力才产生审查开销
on_risk "ask" 风险分类结果变成 ask 或 block
on_error "ask" 模型缺失、超时、请求/JSON 错误都变成 ask 或 block;无审批通道则 block
backend "remote_sanitized" 远程只看 regex/value 脱敏后的代码;也支持 "local_only",但必须显式提供本地 client_factory,缺失时 fail closed
timeout 30 异步审查超时秒数

审查器不是一个工具,主模型无法跳过它。它没有工具、没有历史记录;代码被标记为 不可信数据,输出按固定 JSON(risk、confidence、reason)解析。确定性的 ingress 规则先跑,所以已经被拦下的调用不产生模型开销。

DataShield$new() 直接生命周期管理

strategies = NULL 时,构造函数直接参数(max_rows、distributions、 k_anon、category_max、category_ratio、audit_max、dp_epsilon、 dp_budget)会创建默认的 DescribeData + 核心 egress 配置。audit_max 默认 1000 条非敏感决策事件; 设为 0 禁用记录。传 strategies = list(...) 只会启用列出的策略。 列表顺序控制 egress 扫描器 pipeline;ingress、reviewer、sandbox 和 tool-policy 策略在各自独立阶段运行。动态/session 拥有的工作流用 shield$register_data()、 $install()、$describe()、$audit()、$clear_audit()、$clear()、$close()。

通俗术语表

术语 通俗含义
ingress 工具调用的名字/参数进入本地执行的那一刻;工具运行前扫描
egress 内容离开本地工具、即将进入 LLM 的那一刻
row-cap 打印表格行数的上限;0 表示不放行任何原始行
value-match 对照已注册受保护数据索引出的高熵值做精确匹配
PII 个人可识别信息,如邮箱、电话、身份证号、姓名
kind 一份已注册资产是什么(dataset/spec/document/synthetic),不是它的 R 数据类型或访问级别
provenance 可验证的来源标签,证明某个结果来自哪份已注册资产
raw access 内容可以不受行/值限制地进入某条 LLM 边;仍可能应用可选的 secret/PII 扫描
regex 正则表达式:如 STUDY-[0-9]+ 这样的文本模式
PCRE Perl 兼容正则表达式,R 用 perl=TRUE 时使用的正则语法
span 检测到的敏感子串的起止字符位置,用于精确替换
k-anonymity threshold 除非至少 k 行支持,否则不暴露某个类别标签
fail closed 安全扫描器出错时,默认拦截输出而非放行
semantic reviewer 一个独立的小模型,用来分类脱敏后的工具代码想做什么;它绝不会远程看到原始数据
R6 R 的可变对象系统;一个 DataShield 持有私有的数据集/索引/生命周期

非敏感审计日志

shield$audit() 返回一个内存中的策略决策 data.frame:

字段 含义
timestamp UTC 事件时间
edge 策略边界,包括 prompt、ingress、egress、response 或 describe
tool_name, tool_call_id 非敏感的关联标识符
strategy 包括 row_cap、value_match、regex、ingress、dp_budget、tool_policy、asset_policy、sandbox,或自定义 scanner 名
action, reason 如 redact、block、ask、consume、exhausted、bypass、deny 等策略动作及规则/原因标签
match_count, score 命中数量和归一化风险分数

它绝不存储原始工具输入/输出、命中的值、数据行、span 文本、或哈希值。 audit_max 限制内存(最旧的事件会被丢弃);用 shield$clear_audit() 清空。 每个 DataShield R6 都有自己的日志,所以 session/thread 隔离与策略实例保持一致。

recent <- shield$audit(limit=100)
shield$clear_audit()

数据资产策略:资产是什么 × LLM 能看到多少

资产的内容类型和 LLM 访问级别是正交的两个轴:

kind 默认 prompt 默认 egress 典型用途
dataset schema scan 患者/分析数据
spec raw scan ADaM spec、SDTMIG、公开字典
synthetic raw scan dummy/边界情况测试数据
document scan scan 普通参考文档
访问级别 含义
none 该 LLM 边完全看不到内容
schema 只给严格 DescribeData 风格的元数据
scan 内容必须经过已配置的盾扫描器
raw 绕过行/值限制;除非显式关闭,否则仍跑基础 secret/PII 正则
shield$register_asset(
  adam_spec,
  name = "adam_spec",
  kind = "spec",
  llm_access = list(prompt = "raw", egress = "scan"),
  scan_secrets = TRUE,
  reason = "Validated public specification",
  expires = "session"
)

prompt_text <- shield$prompt_content("adam_spec")

Raw egress 不会仅凭 kind 就自动放行,它需要显式策略 + 来源凭证(provenance):

shield$register_asset(
  adam_spec, name = "adam_spec", kind = "spec",
  llm_access = list(prompt = "raw", egress = "raw"),
  reason = "Validated public specification")

tool_result <- shield$trusted_result(value, source = "adam_spec")

未打标签或混合内容的工具结果仍会被扫描。Raw 资产策略必须填 reason,默认 随所属 DataShield 的 session 过期,可设置 POSIXct 过期时间,且每次都会产生 bypass 审计事件。Synthetic 的 raw 始终保留基础 PII/secret 扫描;spec 的 raw 可以显式设置 scan_secrets = FALSE。

列级 raw 访问

资产是整份对象级别的;register_data(column_access=) 是列粒度的对应机制, 用于一个大部分受保护、但含少量公开字典列(例如 SDTM 的 TESTCD 代码表)的 data.frame。它复用与资产相同的 none/schema/scan/raw 访问级别,拆成 prompt/egress 两个 scope,raw 边同样要求非空的 reason。

shield$register_data(
  vs, name = "vs",
  sensitivity   = c(SUBJID = "identifier", TESTCD = "identifier"),
  column_access = list(
    TESTCD = list(prompt = "raw", egress = "raw",
                  reason = "SDTM public codelist", scan_secrets = TRUE)))
  • prompt = "raw" 让 DescribeData 枚举该列的真实值(不做 k-匿名抑制), 这样模型才能写出正确的过滤条件。
  • egress = "raw" 把该列从 value-match 索引中移除,所以它的值不会从工具 输出中被扣留。
  • 缺少 reason 的 raw override 是硬错误(register_data() 拒绝整个 dataset),因此标错的 raw 授权不会被忽略。coverage()$raw_access_columns 统计当前生效的 override 数量。

egress 分档注意事项: 仅在 EGRESS 侧,none(任何该列值命中时拒绝 整个工具结果)和 raw(从 value-match 索引移除)目前有独立行为;schema 与 scan 都走普通 value-match 扫描,尚没有结构化的 schema/scan egress 差异。列级 scan_secrets 控制的是 PROMPT 侧的 raw DescribeData 路径, 不是 egress。完整的列级 egress 分档需要结果到列的 provenance;PROMPT 侧已经实现全部四个级别。

宿主模式:一个自带来源标记的 spec 工具

Raw 资产的 egress 需要来源凭证。与其在框架里造一个”trusted tool”类型, 不如让宿主用现成的原语——register_asset() 加 trusted_result()——自己 拼一个工具:

read_adam_spec_tool <- function(shield) {
  ellmer::tool(
    name = "ReadADaMSpec",
    fun = function(path) {
      text <- readLines(path, warn = FALSE)
      shield$trusted_result(paste(text, collapse = "\n"), source = "adam_spec")
    },
    description = "Read a registered, LLM-safe ADaM specification.",
    arguments = list(path = ellmer::type_string("Spec file path")))
}
# 只需注册资产策略一次;工具每次读取都会自动打上来源标记
shield$register_asset(adam_spec_path, name = "adam_spec", kind = "spec",
  llm_access = list(prompt = "raw", egress = "raw"),
  reason = "Validated public specification")
# 另外,把工具本身挂到 chat 上,模型才能调用它;然后(重新)install
# 一次,egress 包裹层才能读到 trusted_result() 打的来源标记
chat$register_tool(read_adam_spec_tool(shield))
shield$install(chat)

Agent 像调用任何普通工具一样调用 ReadADaMSpec;raw 放行由已注册的资产策略 授权并被审计,而任何其他工具返回的混合/未打标签结果仍会被扫描。

P0 —— 地基(现已可用)

已经能提供真实保护的最小确定性切片:

# 简单入口:策略 spec 会为这个 client 创建一个私有 DataShield R6。
client <- codeagent_client(chat, data_shield = list(
  shield_describe(k_anon = 5),

  shield_egress(max_rows = 0)
))

# 仅 harness 场景:先挂工具,再安装它的 R6 引擎。
client <- codeagent_client(chat, register_tools = FALSE,
  data_shield = list(shield_describe(), shield_egress(max_rows = 0)))
chat$register_tool(my_tool)
client$data_shield$install(client$chat)
  • 边 2 —— 基于形状的 egress 行数上限。 codeagent 不检查代码,也不拦 print 本身。它只看工具返回文本的形状:具有 data.frame/tibble 打印 签名或多行矩形表格的输出,会被截断成一句形状摘要;标量、消息、模型摘要、 图表和错误信息原样放行。与内容无关,可通过 max_rows 调节。
  • 边 1 —— ambient 注入保持仅 schema。 codeagent 的 ambient 注入本来就只 给出 name [data.frame N x M: col:type, ...](不给值);数据盾保持这个不变。

Shiny 中的运行时上传

数据集不需要在 app 启动时就已知。在上传的 observer 里,读完文件后立刻 注册即可;工具可能已经挂好并被包裹了,因为 value matching 在每次调用时 读取的是活的索引。

# 每个 Shiny server session 内部:一个 R6 可以被选定的多个 chat 共享。
shield <- DataShield$new(
  strategies = list(shield_describe(), shield_egress(max_rows = 0)))
data_env <- new.env(parent = emptyenv())

client_factory <- function() {
  codeagent_client(make_chat(), data_shield = shield)
}

observeEvent(input$file, {
  df <- read.csv(input$file$datapath)
  data_env$uploaded <- df
  shield$register_data(df, name = "uploaded") # 无需预先知道列
})

一个完整可运行的宿主风格 Shiny 示例安装在:

system.file("examples/data_shield_upload_app.R", package = "codeagent")

从开发检出目录:

devtools::load_all(".")
source("inst/examples/data_shield_upload_app.R")

它演示了上传后的五种结果:整批行被 row_cap 扣留、单个已索引值被 value_match 扣留、未注册 PII 被 shield_regex 脱敏、无害的形状摘要 正常放行、以及严格 DescribeData 元数据抑制了原始标识符。

若要一个更小、聚焦单一场景的演示——一个真实的 codeagent_client 接进一个 真实的 shinychat::chat_ui,一侧是 fileInput() 上传,另一侧是实时的 非敏感审计日志——见 inst/examples/data_shield_minimal_app.R。

多用户隔离: 在 Shiny server 函数内部创建 DataShield$new(), 只在目标 chat 线程之间共享它,并通过 shield$register_data() 注册数据。 其他浏览器 session 会拿到各自独立的 R6 实例,看不到也影响不了这个索引。

P0 行数上限的直观行为(已实现):

工具输出 P0 动作
print(mtcars)(整批行) 截断 → 形状摘要
tibble 打印(# A tibble: 320 x 12) 截断
print(nrow(df)) → 320 放行
一条状态消息 放行
print(summary(fit)) 放行(不是行数据)

P1 DescribeData:严格安全元数据契约

DescribeData 是模型理解受保护数据、但不接收行的唯一合法途径。它的输出 由三个正交维度共同决定:

维度 取值 目的
全局策略 distributions = "off" / "on" / "dp" 严格默认不给分布;on 是显式 opt-in;dp 加噪声 + 预算
列敏感度 identifier / quasi / measure / open 钳制该列最大披露程度的业务角色
数据类型 numeric / factor / character / Date / … 决定安全表示形式:范围、标签、还是自由文本标记

严格模式(distributions = "off")矩阵:

元数据 identifier / quasi measure / open
列名、类型、是否缺失 显示 显示
数值/日期 min-max 隐藏 显示
低基数分类标签 隐藏 显示但不给计数;支持行数 < k 的水平被抑制
真实自由文本示例 隐藏 隐藏
直方图、分位数、均值/标准差、类别计数 隐藏 隐藏(仅 on/dp opt-in)

factor 类型不代表自动安全:一个被 factor 化的受试者 ID 仍然是 identifier。 字符列只有在低基数、低唯一性、非 PII、且每个暴露的水平都满足 k-匿名阈值时, 才会给出分类标签。自由文本在严格模式下永远不给真实示例。

System prompt 中的受保护 schema

如果受保护数据在构建 codeagent_client() 之前已经注册,经过过滤的 DescribeData 风格 schema 会进入 system prompt 的 <protected-data> 区块。 模型因此能知道真实的 dataset 名、维度和允许使用的列,但看不到标识符值或稀有 类别。DescribeData 仍是权威的实时查询后备。

register_data() 会更新实时引擎和值索引,但刻意不会重写已有 Chat 的 prompt。 运行时上传后要显式刷新:

shield$register_data(df, name = "uploaded")
client <- refresh_data_shield_context(client)

刷新会重建 prompt,同时保留历史、工具、预算、hooks 和同一个实时 DataShield; 代价是一次 prompt cache miss。在 distributions = "dp" 下,生成 schema block 和调用 DescribeData 都会消耗配置的每 dataset 隐私预算。

P1.5 有序 egress 扫描器

策略列表的顺序就是执行顺序。即使没有注册任何 data.frame,shield_regex() 也能抓到敏感内容:

client <- codeagent_client(chat, data_shield=list(
  shield_egress(max_rows=0),
  shield_regex(on_fail="redact"),
  shield_regex(patterns=c(study_id="STUDY-[0-9]+"),
               include_defaults=FALSE, on_fail="block")
))

内置规则覆盖 email、类电话号码字符串、常见 API-token 前缀、18 位身份证号 形状。redact 只替换命中的片段;block 丢弃整个面向模型的结果。可以用 shield$add_scanner(name, fn) 追加自定义 scanner 函数;非法的 scanner 结果/错误会 fail closed。

C2 通用 ingress 扫描

shield_ingress() 被安装进 codeagent 现有的唯一中央权限门,不会创建 一个与之竞争的回调/门。它在只读快速路径之前扫描每个工具的参数。block 结果会抛出 tool_reject;ask 结果复用当前 CLI/Shiny 的审批回调, 包括 tool-call id 的关联。

client <- codeagent_client(chat, data_shield=list(
  shield_ingress(on_fail="ask"),
  shield_egress(max_rows=0),
  shield_regex()
))

默认规则刻意不会拒绝每一次 Read 或 print:nrow(study) 和 print("done") 会放行,而 head(study)、dput(study)、 base64/pickle/JSON 序列化、上传风格的 curl/requests 调用、shell 里显示 数据文件,都会被审查或拦截。

C5 便携式沙箱与 btw 边界

shield_sandbox() 保留可验证路径的非 exec 编码操作:项目目录和 session 临时 目录默认 rwx,受保护数据默认 rw。便携式后端会拦截允许根目录之外的路径并 拒绝符号链接逃逸;由于路径 metadata 无法约束代码或可执行项目配置,所有非委派 exec 工具(包括 Bash、RunR、ExploreData 和 Lint)都会失败关闭,继承同一盾的 Agent/AuditCode 仍可使用。

btw 不被假定提供 OS 级隔离。它的文件工具用 fs::path_has_parent() 强制限定 cwd,但我们的探测发现一个项目内指向外部文件的符号链接能够通过; 它的 RunR 在全局环境里通过 evaluate 执行,只恢复 cwd/options/环境变量。 因此数据盾对 native、btw、MCP 和宿主工具一视同仁地生效。

C4 语义审查器

shield_reviewer() 对确定性 ingress 规则起补充作用,用于间接别名、多步 序列化、以及混淆过的”数据来源到去处”意图。远程审查器只收到脱敏后的代码 和非值元数据。每次审查都会创建一个全新的独立 Chat,无工具、无历史记录。 默认工厂使用父级 provider 加 CODEAGENT_FAST_MODEL;显式 client_factory 可以提供本地或专用的审查器。缺失/失败/非法的审查器结果都按 on_error 处理;无审批通道时 ask 会退化为 block。

相关数据工具及其边界

  • DescribeData 只在启用 shield_describe() 时注册(或直接构造函数使用 默认策略时注册)。它返回上面的过滤后元数据契约,也能看到之后注册的数据集。
  • ExploreData 是执行任意 R 的数据查询工具,不是只读工具、保密边界或 OS 沙箱。 它在子环境中执行传入的 R 代码;数据盾开启时,其参数仍经过中央 ingress gate, 面向模型的结果仍经过 egress 过滤。data.frame 结果只向模型给出 shape 字符串, 丰富的行数据留在 UI artifact 中;标量文本仍可被 value_match/regex 命中。
  • audit_code_tool(shield, project_root) 是宿主可在执行 R 代码前选择注册的 AuditCode 工具。它解析静态引用,只读取 project_root 内扩展名获准的普通 source 文件,从不执行提交的代码,并返回风险/引用元数据而不是文件内容。盾 开启时,白名单内的 source 文本还会经过 reviewer rail。AuditCode 不向主模型 授予 read/write/shell 能力。

路线图

  • P0.5 —— value_match(已实现):通过对照用 shield$register_data() 注册的高熵值匹配工具输出,确定性地抓住行数 上限漏过的定点泄漏(例如只 print 一个病人的名字)。
  • P1 —— DescribeData + 受保护数据注册表(严格模式已实现):模型的 经批准的强化视图(schema、敏感度、是否缺失、measure/open 的范围和 满足 k-支持的标签;严格 "off" 模式下不给分布/计数/示例)。 distributions="on"/"dp"(类别计数,真实值或按 dataset 记账的 DP 加噪值)已实现;数值/连续统计量的 DP 仍是后续阶段(需要宿主提供 不依赖数据本身的裁剪边界)。
  • P1.5 —— 有序 scanner 管道 + shield_regex()(已实现):未注册的 PII/密钥会被精确定位并脱敏/拦截;自定义 scanner 失败会 fail closed。
  • C2 —— shield_ingress()(已实现):所有工具参数都经过中央权限门; 确定性的高置信度规则会拦截或强制审批。
  • C5 —— 便携式 shield_sandbox()(已实现):项目/临时目录默认 rwx,受保护数据默认 rw,具备 realpath/符号链接容纳,并对无法约束的 exec 失败关闭;完整 OS 进程适配器仍是路线图项。
  • C4 —— shield_reviewer()(已实现):可选的脱敏 ingress 代码语义 审查,使用一个全新的小模型 Chat;远程 raw 输出仍被禁止。
  • P2 —— 完整 OS 沙箱适配器,以及数值/连续统计量的差分隐私(opt-in)。

子代理边界

前台子代理(Agent)继承完全同一个实时 DataShield R6;在第一次子模型 请求之前,其工具已经安装在同一套中央权限门和 Shield gate 后面。这对同步和 并发异步 Agent 调用都适用。盾激活时,不会暴露无法接收该引擎的原始 btw_tool_agent_*/自定义委派路径。子代理回复会在进入 SubagentStop hook、 callback 或父级工具结果之前先经过 output gate,且受盾保护的 sidechain 不持久化。

BackgroundAgent 和 /bg 在数据盾开启时目前会 fail closed:它们的 mirai worker 是一个独立的 R 进程,无法安全地共享 session 的 R6 状态或 受保护值索引。在实现按所有者重建 worker 的协议之前,请使用前台 Agent。

排列组合安全性:每种组合到底能防住什么

数据盾由独立策略组合而成,所以有可能启用一种看起来有保护、实际上没有 的组合。下表直接来自 tests/testthat/test-data-shield-combinations.R (这是一个 CI 测试套件,不是一段描述性文字):未来任何破坏这些结论的重构 会立刻让这个测试套件失败,而不是悄悄过时。

组合 整批数据泄漏 定点单值泄漏 别名绕过(y <- study; print(y)) 结论
只开 shield_egress() 挡住 挡住 挡住(egress 检查的是输出内容,不是代码路径) ✅ 安全底线
egress + ingress + regex(推荐) 挡住 挡住 挡住 ✅ 推荐配置
只开 shield_ingress() 泄漏 泄漏 泄漏(已实测确认) ⚠️ 单独使用不安全
只开 shield_describe() 泄漏 泄漏 — ⚠️ 单独使用不安全(只管理模型自己的元数据查询,不过滤其他工具的输出)
只开 shield_regex() — 非 PII 形状的自定义 ID 会泄漏 — ⚠️ 只能挡住常见 PII 形状

一句话结论:shield_egress() 是唯一不可省的边界。其余所有策略都是纵深 防御,不能替代它。

三个可以直接用的组合模板

shield_preset_strict()、shield_preset_balanced()、shield_preset_clinical() 是可直接调用的函数,返回下面这些确切组合——不用复制粘贴:

# 严格:合规/审计演示场景
strict <- shield_preset_strict()
# shield_describe(k_anon = 5),
# shield_egress(detectors = c("row_cap", "value_match"), max_rows = 0, on_fail = "block"),
# shield_regex(on_fail = "block"),
# shield_ingress(on_fail = "block")

# 均衡:日常开发,摩擦小
balanced <- shield_preset_balanced()
# shield_egress(max_rows = 0, on_fail = "redact"),
# shield_regex(on_fail = "redact")

# 临床:加语义审查器 + 严格 k-匿名
clinical <- shield_preset_clinical()
# shield_describe(k_anon = 5),
# shield_egress(max_rows = 0),
# shield_regex(),
# shield_ingress(on_fail = "ask"),
# shield_reviewer(model = Sys.getenv("CODEAGENT_FAST_MODEL"), on_risk = "ask")

client <- codeagent_client(chat, data_shield = shield_preset_strict())

两个刻意不安全的演示组合

这两个组合复现了上面矩阵表里”单独使用不安全”的两行,和 inst/examples/data_shield_minimal_app.R 的”Shield strength”选择器里接线的 代码完全一致——在 demo 里切到其中一个,问聊天”dump the uploaded data”, 亲眼看它当场泄漏。

# 不安全:只开 ingress —— 完全没有 egress 边界。
unsafe_ingress_only <- list(shield_ingress(on_fail = "block"))

# 不安全:只开 describe —— DescribeData 注册了,但没有任何东西过滤
# 其他工具返回的内容。
unsafe_describe_only <- list(shield_describe(k_anon = 3))
  • unsafe_ingress_only 只在工具执行前扫描参数(见前面参数参考里的 shield_ingress())。它没有 shield_egress(),所以没有任何东西检查 工具实际返回了什么。它的静态正则规则也只看代码字面文本: y <- study; print(y) 匹配不上任何”打印/dump 已知数据集名”的模式, 别名就这样溜过去了——这正是上面矩阵表里标记”已实测确认”的那个绕过。
  • unsafe_describe_only 只注册了 DescribeData 工具(见前面的 shield_describe())。那个工具是模型自己经批准的查询通道;它不管 其他工具的返回值,所以一个简单返回原始 data.frame 的工具完全不受影响。
  • 两者都没有 shield_egress()——为什么这一个策略是所有其他组合都依赖的 基础,见上面的”排列组合安全性”。

inst/examples/data_shield_minimal_app.R 提供一个实时的”Shield strength” (盾的强度)下拉选择器,涵盖以上三个模板加这两个刻意不安全的组合,可以在 运行中的聊天里对这五种全部实时切换,亲眼看到上表里的具体泄漏当场发生。

诚实的局限性

数据盾降低披露风险,但不能消除它。确定性检测器(行数上限、 value_match、正则)会漏掉对抗式混淆的 egress(例如先把数据 base64 编码再打印);这些情况靠 ingress 黑名单和无网络沙箱来缓解——而不是 彻底解决。最强的保证来自结构性层面(只喂元数据 + 无网络执行),扫描 只是纵深防御。

依赖它之前需要权衡的具体残余风险:

  • 层组合很重要——见上面的排列组合安全表。 只开 shield_ingress() 或只开 shield_describe()、不开 shield_egress(),不是安全配置。 不要省略 egress。
  • 语义审查器本身就是一个 LLM。 shield_reviewer() 可能被足够混淆的 “数据来源到去处”路径绕过,且每次审查都增加延迟/成本;它是确定性防线 之上的纵深防御,不是保证。
  • value_match 线性增长,现已有上限。 在开源 {pharmaverse} 项目的 CDISC-ADaM 格式示例数据上做过 benchmark(inst/bench/value_match_benchmark.R): 索引 100 万个高熵值约需 130 MB 内存、约 10 秒构建时间,对普通临床文本 零误报,pharmaverse 格式的 USUBJID/SUBJID 均能命中。由于内存线性增长, register_data(max_index_values=) 给索引设了上限(默认 50 万,约 65 MB)。 如果该上限会截断索引,注册会报错并拒绝这个部分索引的数据集;应提高上限、 拆分数据,或显式使用 NULL/Inf。min_len/min_card 阈值在这些 ID 上 表现良好,未做调整。
  • 图片工具结果和完整 OS 隔离仍是路线图项(见文首状态横幅):工具返回的 原始行渲染表格/图表会绕过文本 egress 扫描,便携式沙箱是路径/能力策略, 不是内核级隔离。Prompt 图片 OCR 是上文所述、单独选择启用的边 1 控制; 可选依赖缺失时的 pass 降级是一个明确记录的盲区。
  • shinychat 含文本附件会在边 1 扫描。 codeagent 主 UI 启用了 chat_ui(allow_attachments = TRUE);input gate 会提取并扫描含文本附件 (例如 ContentPDF),无法验证内容时 fail closed 到 block(不可变 Content 不能原地脱敏)。图片附件仍是盲区,除非接入 OCR scanner (data_shield_ocr_scanner());scanner 已配置时,OCR 或扫描失败会 fail closed 到 block。fileInput() → register_data() 仍是表格上传的受控、建索引路径。
  • 数据盾不管破坏性操作(rm -rf、删表、强制推送)——这是另一个维度 (操作安全,不是数据保密),由权限门和 hooks 管,不归本文档任何 shield_*() 策略管。shield_ingress() 的模式匹配管道可以被借用来 做这件事,但它的内置默认规则聚焦于数据外泄,不是破坏性操作。具体机制 (rules deny glob、PreToolUse hook、自定义 shield_ingress() 规则) 以及为什么它们都防不住换种写法,见 vignette("permissions") (英文版)的”Hooks”节。