local-deep-research 出口安全重构:基于 Sensitivity × Exposure 双轴数据分类的 DLP 决策模型(ADR-0007 深度解读)
【免费下载链接】local-deep-research~95% on SimpleQA (e.g. Qwen3.6-27B on a 3090). Supports all local and cloud LLMs (llama.cpp, Ollama, Google, ...). 10+ search engines - arXiv, PubMed, your private documents. Everything Local & Encrypted.项目地址: https://gitcode.com/GitHub_Trending/lo/local-deep-research
本篇技术指南系统讲解 local-deep-research 中出口安全护栏(egress guardrail)的核心架构决策:如何把原来"单轴"的egress_scope策略重构为Sensitivity(敏感度)× Exposure(暴露度)两个正交轴的数据分类模型。文中将结合 ADR-0007 原文、egress 包 README 与 classification.py 等源码实现,完整给出四象限组合规则、evaluate_run判定算法、Enforcing/Permissive 实施模式、旧模型迁移映射与分阶段落地路线。读完你将掌握:如何用两个标签描述一个组件(引擎/集合/LLM/嵌入模型)的进出风险、为什么"敏感数据绝不能到达暴露型出口"是唯一核心不变量,以及该模型在代码中的真实判定流程与测试真值表。
一、为什么需要重构:单轴模型的三个根本缺陷
重构之前,出口护栏(位于src/local_deep_research/security/egress/)只在单一维度上表达策略:每次运行有一个policy.egress_scope枚举(adaptive/both/public_only/private_only/strict),辅以每个集合的is_public标志与两个require_local推理开关。这套模型能覆盖常见场景,但把两个本质独立的属性压缩进了一个词里,具体暴露出三个问题:
问题 1:敏感度与暴露度是两回事,却被融进同一个词。"public ↔ private" 被视为一条光谱,但其中隐藏着两个截然不同的问题:
- 数据是否敏感?——这是**来源(source)**的属性(一个集合、一个文档库)。
- 目的地是否把数据向外暴露?——这是**出口(sink)**的属性(你把查询发过去的网络搜索引擎;你把文本块发过去的云端 LLM)。
最典型的症状是集合的is_public标志:这个词暗示数据被"公开发布",但集合永远是本地存储——检索它绝不会把其内容推给搜索引擎。is_public真正授权的是云推理,即"public"用暴露轴的语言夸大了敏感轴的含义。
问题 2:双重风险来源无法表达。Elasticsearch 与 Paperless 直接击穿了单轴模型。一个 Elasticsearch 实例可能同时是:
- 敏感数据存储(你的私有语料)→ 绝不能与暴露型出口组合;以及/或者
- 受监控/外部服务(一个本身会暴露你查询的出口)。
这是两种相反的风险,单一的is_local/is_public标志只能二选一。在当前模型下,对这些引擎只能依赖不对称的 URL fail-up 保护,文档明确承认"cannot guarantee anything"(无法保证任何东西),因此现阶段不应自动与其他来源组合。
问题 3:both是静默的 blanket 许可。both一次性覆盖所有来源的逐项分类,且没有任何可见或概念上的信号提示保护已被撤销——它是"逐来源分类"与"关掉策略"之间一团模糊的中间地带。
二、两轴模型:Sensitivity × Exposure
重构后的核心是把每个运行会触碰的模型(搜索引擎、集合/存储、LLM、嵌入模型)在两个正交轴上打标签。完整矩阵如下(语义:具有某敏感度(行)× 某暴露度(列)的组件应如何处理):
| Contained 出口 | Exposing 出口 | |
|---|---|---|
| Non-sensitive 来源 | 可与任何东西组合——公共集合、本地 Ollama | 只能与non-sensitive 来源组合——公共网络引擎、云端 LLM |
| Sensitive 来源 | 只能与其他contained来源组合;绝不能是 exposing 出口——私有集合 + 本地 LLM | 仅限单独使用,配 contained(本地)推理——存放私有数据的远程受监控存储 |
一句话规则:一次运行绝不能让 sensitive 来源到达 exposing 出口——除非显式切换到Permissive模式(该模式只警告、从不拦截)。两个轴的正式定义:
| 轴 | 适用对象 | 取值 | 对应问题 |
|---|---|---|---|
| Sensitivity | 来源 sources | sensitive/non-sensitive | 这些数据是否绝不能离开本机? |
| Exposure | 出口 sinks | exposing/contained | 把数据发到这里是否使其离开本机? |
值得强调的是:某些组件同时扮演两种角色——搜索引擎既是你查询的sink,又是结果的source;Elasticsearch 既是数据的source,又是你查询的sink。因此每个组件在它参与的每个轴上都要有一个标签,这正是单轴模型无法表达的、本次变更的核心。
2.1 该模型与业界标准的渊源
双轴框架就是标准的DLP / 信息流控制(information-flow control)模型:按敏感度给数据分类、按信任/暴露度给目的地分类、禁止敏感→暴露的流动。实施模式的词汇借用自SELinux(enforcing/permissive/disabled)。egress 包原本就借用了 XACML / 零信任的 PDP–PEP 词汇,因此这是延续而非外来引入。
2.2 与查询文本无关(设计边界)
该模型分类的是来源与出口,不是用户输入的查询问题。运行查询会原样发送给运行所使用的各个搜索出口,因此在暴露型引擎上输入敏感问题,无论如何标注来源,数据都会离开机器。护栏无法检查或净化查询意图——这始终是用户的责任。UI 必须明确说明:我们无法保护你的问题内容,请根据问题敏感度选择合适的来源。
三、逐组件分类:每个组件都携带两个标签
分类是按组件计算的,"组件"指运行触碰的每一个模型:每个搜索引擎、每个集合/存储、LLM 与嵌入模型。ADR 设想的统一入口是单一函数classify(component, settings) -> (Sensitivity, Exposure),成为今天分散在classify_engine、_CLOUD_LLM_PROVIDERS集合、逐集合is_public查询与 URL fail-up 中的共享逻辑的唯一归属。
ADR 给出的示例分类表:
| 组件(示例) | Sensitivity | Exposure |
|---|---|---|
| 公共集合 / 公共网络结果 | non-sensitive | contained |
| 私有集合(默认) | sensitive | contained |
| 公共网络 / 学术引擎(Google、arXiv) | non-sensitive | exposing(查询出口) |
| 云端 LLM / 嵌入模型(Anthropic、OpenAI) | non-sensitive | exposing |
| 本地 LLM / 嵌入模型(Ollama、sentence-transformers) | non-sensitive | contained |
| Paperless / Elasticsearch — 本地、私有 | sensitive | contained |
| Elasticsearch — 远程 / 受监控、私有 | sensitive | exposing |
源码佐证:这些标签在引擎类上是显式声明的类属性。例如 search_engine_arxiv.py 声明egress_sensitivity = Sensitivity.NON_SENSITIVE、egress_exposure = Exposure.EXPOSING;search_engine_elasticsearch.py 声明egress_sensitivity = Sensitivity.SENSITIVE、egress_exposure = Exposure.CONTAINED。同样被标注的还有 Brave、DuckDuckGo、Exa、GitHub 等公共引擎(均为NON_SENSITIVE + EXPOSING)。Sensitivity/Exposure枚举本身定义在 classification.py 中。
3.1 角色(Role):一个组件可以多重参与
由于搜索引擎既收查询又产结果,classification.py 定义了三种参与角色:
SOURCE—— 向运行贡献数据;SEARCH_SINK—— 接收查询(以及可能的扩展结果);INFERENCE_SINK—— LLM / 嵌入模型,能看到全部来源数据。
同一个真实组件会被按它扮演的每个角色分别传给evaluate_run,每次使用相同的Component.name——第四象限"双重风险存储单独使用"的自我排除机制正是依赖这一共享身份。
四、四象限组合规则与判定算法
一次运行触碰的是一组组件,集合是否被允许直接由两个标签推导:
| # | 一个组件是… | 可与以下内容组合 | 示例 |
|---|---|---|---|
| 1 | non-sensitive, contained | 任何东西 | 公共集合;本地 Ollama |
| 2 | sensitive, contained | 仅其他contained来源(敏感与否均可)——不得有 exposing 出口 | 私有集合 + 本地 LLM |
| 3 | non-sensitive,exposing | 仅non-sensitive来源 | 云端 LLM;公共网络引擎 |
| 4 | sensitive + exposing | 唯一的 sensitive 来源(non-sensitive contained 同伴可以),配contained(本地)推理 | 存放私有数据的远程受监控存储 |
其底层不变量是:运行不得让 sensitive 来源到达 exposing 出口。由于智能体(agentic)运行能把一个引擎的结果变成另一个引擎的查询(即威胁模型中的 LangGraph 静默扩展类问题),该规则是针对整次运行的组件集合执行的,而不是逐次调用。等价表述:一次运行必须要么全 non-sensitive,要么不含 exposing 出口——唯一的例外是允许一个孤立的第四象限组件作为唯一 sensitive 来源运行(non-sensitive contained 同伴仍可),因为把自身数据返回给自身并不是新的泄露,且其推理被强制 contained。
4.1evaluate_run:四象限的真实判定逻辑
纯决策核心实现在 classification.py 的 evaluate_run(模式无关的_decide_enforcing在 L131-L187)。算法分三步:
- 收集敏感来源:汇总所有
Role.SOURCE且 sensitivity 为SENSITIVE的组件名;若为空(no_sensitive_source),任何出口都无所谓,直接放行——这对应第一/三象限。 - 检查推理出口(
sensitive_to_exposing_inference):LLM/嵌入模型能看见每个来源的数据,因此任何EXPOSING的INFERENCE_SINK都会泄露敏感内容,立即拒绝。 - 检查搜索出口(
sensitive_to_exposing_search):一个 exposing 的SEARCH_SINK只有在它自身就是唯一敏感来源(第四象限单独使用)时才允许;若存在其他敏感来源(可能被智能体扩展成发往该出口的查询),或该出口自身不敏感(防止名称巧合被误认为单独双风险场景),则拒绝。
拒绝时返回Decision(allowed=False, reason, offending),其中reason是简短机器码(如sensitive_to_exposing_inference),offending列出违规组件名。推理出口检查优先于搜索出口检查(对应测试test_multi_violation_inference_takes_precedence)。
4.2 测试真值表:规则被钉死
test_egress_classification.py 将四象限规则完整钉死为单元测试真值表(阶段 A 的纯核心在接入 PEP 前即被测试锁定,后续接线无法悄然改变行为),关键断言包括:
- 全 non-sensitive 运行允许任意出口(
no_sensitive_source); - 私有集合 + 本地 Ollama 允许(
sensitive_contained),私有集合 + Anthropic 拒绝且offending == ("llm:anthropic",); - 私有集合 + arXiv 公共引擎拒绝且
offending == ("arxiv",); - 双风险存储(Elasticsearch)单独 + 本地推理允许;配云推理、配其他敏感来源、或两个双风险存储并存均拒绝;
- 暴露型嵌入模型(
embeddings:openai)与敏感来源同现拒绝; - Permissive 模式下所有被拒组合均放行,但保留
permissive:<reason>前缀的诊断供 UI 横幅使用; - 空集合允许;名称巧合不能豁免非敏感出口(
test_name_collision_does_not_exempt_non_sensitive_sink)。
五、实施模式:SELinux 词汇与 UNPROTECTED 逃生舱
classification.py 定义了两种实施模式:
- Enforcing—— 当前行为:执行不变量,违规引擎/出口被拦截。
- Permissive("Unprotected")—— 逃生舱:策略仍被评估,因此警告横幅照常触发,但什么都不拦截。取代
both,配以诚实、醒目、浅红色的 UI。(用户可见文案待定:"Unprotected" / "Unrestricted" / "Permissive"。)
逃生舱在evaluate_run中的实现很讲究:Mode.PERMISSIVE下运行总是被允许,但底层本应被执行的reason/offending会被保留(以permissive:前缀),以便调用方仍能弹出警告横幅(见 classification.py L118-L128)。
在policy.py中,EgressScope.UNPROTECTED是运算符启用的逃生舱(默认关闭),对应环境变量LDR_POLICY_ALLOW_UNPROTECTED_EGRESS=true。启用时出口范围限制被禁用,任何引擎/URL/供应商都允许,但evaluate_url中的硬性 SSRF + 云元数据拦截仍然生效,且强制本地推理的要求被解除。激活期间会显示不可关闭的横幅。迁移0027(0027_disable_legacy_unprotected_egress.py)会把遗留的已存储/已排队unprotected值一次性改写成adaptive,防止后续选择静默重新激活。
六、旧模型到双轴的映射(迁移而非重写)
原单轴模型的每个概念都被映射到新轴上(见 policy.py 中EgressScope的注释说明):
- 集合
is_public=False→ Sensitivitysensitive;is_public=True→non-sensitive(从"public"重新标签——它从不发布)。 - 公共网络 / 学术引擎 → Exposureexposing。
- 云端 LLM / 云端嵌入模型 → Exposureexposing出口;
require_local_*意为"禁止暴露型推理出口"。 - 本地集合 / 本地 LLM(Ollama)→ Exposurecontained。
- 各 scope 重新表达:
private_only≈ "无 exposing 出口";public_only≈ "允许 exposing 出口,排除 sensitive 来源";strict≈ 单一来源;adaptive≈ 从主引擎推断运行姿态。 both→移除,由逐来源分类 + Permissive 模式取代。- Elasticsearch / Paperless → 默认声明为sensitive + contained(可用于第四象限以外的其他 contained/本地来源组合),当配置的端点解析为公共主机时,URL fail-up 把暴露度翻转为exposing(第四象限)。按目的地的信任条目(
policy.trusted_search_engines)可以把恰好位于公共主机名上的自托管实例重新标回 contained。
关于both的退役细节(policy.py L70-L75):EgressScope.BOTH仍作为枚举值存在,但已不再是用户可选择的 scope——它只是adaptive解析到不可分类主引擎时的内部解析结果。任何残留的用户both值(存储/环境变量/队列)都会被context_from_snapshot强制改写为adaptive,数据库行由迁移0019(0019_retire_both_egress_scope.py)重写。USER_SELECTABLE_PROTECTED_SCOPES(policy.py L95-L102)只保留adaptive/public_only/private_only/strict四个受保护范围,BOTH刻意缺席。
6.1 动态细化:运行时解析
纯核心之上的运行时解析器是 run_classification.py。engine_label()(L45-L107)读取引擎类声明的标签,并应用三个依赖配置的动态细化:
- URL fail-up:自托管存储的配置 URL 解析为公共主机时,exposure 翻转为
EXPOSING(复用策略的不对称 URL 覆盖,只会收紧不会放松); - 集合敏感度翻转:标记为 public 的集合 →
NON_SENSITIVE;聚合的library始终保持 sensitive(_resolve_collection_is_public对其硬返回私有); - 向量存储暴露:当索引所在的向量存储不是本地文件(如 FAISS 的
is_local_file=True)且端点不解析为本地时,集合的 exposure 翻转为EXPOSING——同样的 fail-up 应用到 RAG 出口上。
推理出口(LLM/嵌入模型)的标签由_inference_label()(L142-L165)解析:与强制实施的 PEP 使用完全相同的分类(在 require-local 探测上下文中调用evaluate_llm_endpoint/evaluate_embeddings),使解析器与实施保持精确同步——自托管本地 URL 端点判为 contained,而非误判 exposing。
classify_run()(L190-L232)组装运行组件集合:每个搜索引擎贡献一对同名组件(SOURCE+SEARCH_SINK),LLM 与嵌入模型各贡献一个INFERENCE_SINK。未知引擎(不在注册表中)fail closed 到最严格的第四象限(SENSITIVE + EXPOSING),确保未分类来源永远不会悄然放宽运行的可准入性。
七、按目的地信任("我信任 Anthropic"场景)
这是用户管理的覆盖项,把特定出口重新标记为contained(例如零保留的 Anthropic 端点、位于公共主机名上的自托管 Paperless)——即暴露轴上的"集合提升"对应物。落地形式为两个 JSON 列表设置:
policy.trusted_inference_providers—— 信任的推理供应商(把暴露型 LLM/嵌入出口放松为 contained);policy.trusted_search_engines—— 信任的搜索引擎(重新包含因公共 URL 而 fail-up 的双风险存储,从第四象限移回第二象限)。
run_classification.py L99-L105 中的关键安全约束:信任只对因 URL fail-up 的本地性质存储生效,绝不会应用于天生公共的引擎(is_public)——受信任的名字不能"洗白"一个公共搜索出口。设置保存时由 validators.py 的 validate_trusted_search_engines 校验,拒绝把天生公共的引擎写入信任列表。信任生效后会有专门的信任横幅提示用户。
八、落地路线图:核心优先、测试网保护下的增量重写
出口护栏是安全关键组件,已经历过两轮对抗性评审。大爆炸式重写爆炸半径太大,因此 ADR 明确选择核心优先、每阶段保持 egress 测试套件全绿的增量路线:
- 阶段 A —— 分类核心(security/egress/classification.py):
Sensitivity/Exposure类型、classify(component, settings)、实现四象限规则的evaluate_run(components)——纯函数、对照上述真值表完全单元测试、尚未接线(零行为变更)。 - 阶段 B —— 接入 PEP:把各执行点接到核心上,用新模型词汇重新表达既有 scopes,保持行为对等(既有 egress 测试继续全绿),同时新能力(第四象限、双风险来源)变为可达。
- 阶段 C —— UI:呈现两个轴、增加Permissive / "Unprotected"模式、移除
both、让集合标签诚实化。 - 阶段 D —— 按目的地信任(暴露轴覆盖项,即"信任 Anthropic"场景)与 Elasticsearch / Paperless 的显式分类。
当前状态(PR #4882):四个阶段全部实现。
- A / B—— 分类核心(
classification.py)+ 解析器(run_classification.py)+ 每个引擎与供应商上的显式逐元素标签。 - C——
UNPROTECTED逃生舱(SSRF / 云元数据不变量在逃生舱门之后仍保留)、both彻底退役(从选择器中移除,存量行由迁移 0019 改写为adaptive,任何残留值——环境变量/排队快照/未迁移数据库——在读取时强制改写为adaptive);EgressScope.BOTH仅作为不可分类 ADAPTIVE 主引擎的内部解析结果存续;新增不可关闭的"保护已禁用"横幅;以及实施翻转:运行启动预检把双轴拒绝作为 scope PEP 之上的纵深防御(UNPROTECTED按 permissive 评估;不可计算的决策fail closed——返回拒绝性audit_error——而不是 open)。该预检同时运行在 Web/api/start_research预检和run_research_process的共享 worker 咽喉点,因此 follow-up / chat / queue 运行都被覆盖;只有绕过两者的 CLI / 程序化调用方仅受 scope PEP 保护(在private_only/ adaptive-private 下仍强制本地推理)。 - D—— 按目的地信任(
policy.trusted_inference_providers/policy.trusted_search_engines)把受信任的离机出口放松为 contained、信任横幅、诚实的集合标签文案。
在发布版本中开始强制实施前,必须通过新一轮对抗性评审——实施翻转是对安全边界的既有行为变更。
九、已知残留问题(跟踪于 issue #4951)
四轮对抗性评审确认:运行启动审计是尽力而为的纵深防御,而非完备性保证——它从设置重新推导每个出口的分类,可能与运行实际连接的对象产生偏差。默认adaptive配置受 scope PEP 保护(私有主引擎强制本地推理),退役both消除了审计是唯一守卫的 scope。以下边缘被记录为 issue 而非阻塞项:
- 搜索轴 —— Elasticsearch
cloud_id:暴露 fail-up 检查的是hosts而非cloud_id;permissive scope 下通过cloud_id可达的敏感 ES 存储被分类为 contained。引擎自带的_cloud_id_forbidden_by_scope覆盖private_only/strict。 - 完整引擎集合 / 智能体扩展:强制实施(审计和 scope PEP 的本地推理耦合)以运行的主引擎为键;运行中途扩展拉入的非主敏感引擎不会回映到
require_local。 - 公共主机上的双风险存储:URL 解析为公共的自托管存储被分类为 PUBLIC_ONLY,scope 级本地推理耦合不触发(Web 路径上的运行启动审计仍会标记)。
- 按名称键控的信任漂移:
trusted_inference_providers按供应商名称而非经核验的端点 URL 键控,且仅在审计中生效、不在边界 PEP 中生效。 LibraryRAGService直接构造:从全局search.tool解析其主引擎,直接(非工厂)构造可能漏掉本地嵌入耦合。- 查询文本:按设计不在范围内(见上文)。
十、开放问题的最终决议
- 延后 —— 用户可见轴词汇。内部已定为
Sensitivity{sensitive, non_sensitive}/Exposure{contained, exposing}。两个轴尚未作为独立 UI 控件呈现——目前通过既有 egress-scope 选择器、逐集合 public 标志与信任列表表达。专用双轴 UI 是后续工作。 - 已解决 —— Unprotected。设置值 / 标签 /
EgressScope.UNPROTECTED(内部实施模式另为Mode.PERMISSIVE)。 - 已解决。Elasticsearch / Paperless 默认sensitive + contained;公共端点上的 URL fail-up 把暴露度翻转为 exposing(第四象限);
policy.trusted_search_engines是显式覆盖项。 - 已解决 —— 否。聚合
library始终敏感(_resolve_collection_is_public对其硬返回私有)。 - 已解决 —— 设置列表。
policy.trusted_inference_providers/policy.trusted_search_engines(JSON 列表设置,镜像allowed_local_hostnames的形态);不建独立数据表。
延伸阅读
- egress 包 README —— 完整背景:PDP–PEP 架构、全部 PEP 落点表、设置键、威胁模型与数据流图。
- classification.py —— 纯决策核心(阶段 A)。
- run_classification.py —— 运行时解析 + 审计/实施接线(阶段 B)。
- test_egress_classification.py —— 四象限规则真值表测试;test_egress_policy.py 与 test_egress_run_classification.py 覆盖 scope 解析与运行分类。
- 迁移:0019_retire_both_egress_scope.py、0027_disable_legacy_unprotected_egress.py。
【免费下载链接】local-deep-research~95% on SimpleQA (e.g. Qwen3.6-27B on a 3090). Supports all local and cloud LLMs (llama.cpp, Ollama, Google, ...). 10+ search engines - arXiv, PubMed, your private documents. Everything Local & Encrypted.项目地址: https://gitcode.com/GitHub_Trending/lo/local-deep-research
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考