案例/开源工程案例

ADPS 开源工程案例

DeerFlow Guardrail:从调用前拦截到双层授权

五次公开 PR 把工具控制从运行时拒绝推进到装配时可见性过滤,并让身份、策略与证据进入同一条执行链。

案例范围

研究对象DeerFlow 公开仓库中的 Guardrail 与 Authorization 演进
核心问题一个任务应装配哪些工具,模型无权调用的工具何时从候选集中移除
证据范围PR #1240、#3665、#3837、#4260、#4370,现行源码、测试和公开文档
ADPS 对应A1 工具调度、A4 护栏三明治、A5 最简工具集、G1 审批门、G2 爆炸半径、G4 可观测性、G5 钩子流水线

1. 一个任务到底装配哪些工具

DeerFlow 没有把这个问题交给一次自由生成。模型只在已经进入运行上下文的工具 schema 中选择。进入这份清单之前,候选能力会经过 Agent 声明、当前 Skill 和主体权限的连续收窄。

最终能力集 = 全局候选 ∩ Agent 能力声明 ∩ 当前 Skill ∩ 主体权限。

DeerFlow 工具装配与双层授权流程
图 1 · 任务相关性负责收窄候选,授权负责判断当前主体是否有权看见和调用。

这里有两个容易混在一起的问题。工具是否相关tool_groups、subagent allow/deny、Skill 激活和 deferred discovery 处理。调用者是否有权使用由 AuthorizationProvider 处理。相关性判断不会授予权限,授权也不替任务猜测最合适的工具。

2. 装配过滤与运行拦截共用一套策略

apply_tool_authorization 把 provider 解析、主体构造和候选过滤收在一个入口,并返回过滤后的工具与 provider 实例。调用方随后把同一个实例接到运行时 middleware。

def apply_tool_authorization(tools, *, context, app_config,
                             authorization_provider=None):
    if app_config.authorization.enabled is not True:
        return tools, None

    provider = authorization_provider or resolve_authorization_provider(
        app_config.authorization
    )
    principal = build_principal_from_context(context)
    filtered = filter_tools_by_authorization(
        tools, provider=provider, principal=principal,
        fail_closed=app_config.authorization.fail_closed,
    )
    return filtered, provider

这段接口表达了两层控制:

  1. Layer 1,装配时:从 schema 和 deferred catalog 中移除永远无权使用的工具。模型看不到它们,tool_search 也不能把它们重新提升回来。
  2. Layer 2,调用时:模型提出具体调用后,再按当前身份、参数和动态资源执行一次授权。随后才进入显式 Guardrail。

两层复用同一个 provider,避免“界面上看不见,运行时却能调”或“装配时准入,执行时使用另一份策略”的分裂。

3. 五次 PR 改变了五条边界

DeerFlow Guardrail 五次 PR 的架构演进
图 2 · 从工具调用边界到可见性边界。日期按 Git 合并记录校正。
合并时间PR进入系统的能力当时仍缺少什么
2026-03-23#1240调用前 GuardrailMiddleware 与可插拔 provider可信运行身份、持久审计、资源级策略
2026-06-21#3665用户、角色、run、channel 等运行身份进入请求独立 RBAC 策略与装配过滤
2026-07-03#3837拒绝、fail-open 与 fail-closed 进入 RunJournal所有运行路径上的一致审计继承
2026-07-21#4260AuthorizationProvider、内置 RBAC 和策略工厂模型可见工具仍可能大于主体权限
2026-07-23#4370装配过滤与运行授权共用同一 provider审批型 ask、完整 POST 验证和业务补偿

4. PR #1240:先建立不可绕过的调用支点

第一版把授权放进 wrap_tool_callawrap_tool_call。middleware 在工具执行前构造 GuardrailRequest,并把决定委托给结构化 provider。deny 会返回带原因码的 ToolMessage,Agent 可以改走其他路径。provider 异常时,部署方可选择 fail-closed 或 fail-open。

decision = provider.evaluate(guardrail_request)
if not decision.allow:
    return ToolMessage(
        content="Guardrail denied: ...",
        status="error",
        tool_call_id=tool_call_id,
    )
return handler(request)

GraphBubbleUp 等控制流异常保持透传,避免授权层吞掉暂停与恢复信号。这个细节很小,却决定 middleware 能否安全地嵌入 Agent 运行时。

5. PR #3665:身份必须来自可信注入点

只看工具名不足以做生产授权。同一个 write_file,普通用户、内部服务和受托 subagent 的权限可能完全不同。第二次演进把 user_iduser_role、OAuth 来源、run_idtool_call_id、channel identity 与 is_internal 放进 GuardrailRequest。

这些字段来自网关和 runtime context,不从 prompt 或模型输出读取。授权主体与工具执行主体也分开保存。这样才能回答“谁请求、哪个 Agent 执行、在哪个 run 中发生”。

6. PR #3837:拒绝发生过,还要能被找到

安全相关决定进入 RunJournal,记录工具名、调用 ID、角色、策略 ID、原因码、fail-closed 配置和 provider 是否异常。普通 allow 不逐条写安全事件,避免审计流被正常调用淹没。

实现刻意不复制原始 tool_input 与用户标识。参数中可能带密钥和业务数据,安全日志不应成为新的泄漏面。Journal 写入采用 best-effort。审计设施失败会告警,但不会反过来改变本次授权结论。

现行代码也留下了明确边界:原生 subagent 当前不会自动继承 __run_journal。自定义运行时可以补齐,这仍是跨 Agent 审计需要继续收口的地方。

7. PR #4260:把策略脑从执行拦截器中拆开

GuardrailMiddleware 负责执行点,AuthorizationProvider 负责资源级决策。provider 接收 Principal、resource、action、target 和附加上下文。内置 RBAC 在构造时校验并编译角色策略,运行路径只做确定性查询。

内置语义中 deny 总是优先。未知角色、空 target、拼错的配置键都会报错。allow: false 与空 allowlist 均表示 deny-all。没有为某类资源配置策略时才按“unrestricted”处理。这些边界由测试固定,不能靠配置阅读者自行猜测。

8. PR #4370:模型不应看到永远不能用的工具

只有运行时 deny 时,模型仍会看到无权调用的工具 schema。它可能花费 token 规划一条必然失败的路径,deferred tool search 也可能重新发现该工具。装配时过滤把可见性纳入授权。

这一改动必须同时覆盖 lead agent、subagent 和 embedded client 三条构建路径。漏掉其中一条,就会出现同一角色从不同入口获得不同能力的情况。

tool_search 是特殊边界。它可以延迟暴露 MCP schema,但 deferred catalog 本身必须先经过授权过滤。由已过滤 catalog 动态生成的 tool_search 结果可复用该结论。一个普通的同名工具不能因此绕过运行授权。

9. “动态配置”有四个不同层次

层次何时生效适合改变什么
配置文件进程启动或配置重新加载provider 类型、角色策略、fail-closed
Agent build新建 lead agent、subagent 或 embedded clienttool group、middleware、provider 实例
每次请求构造 Principal 和 GuardrailRequest 时用户、角色、run、channel 与附加属性
每次调用工具运行前参数、动态资源、外部策略和风险条件

内置 RBAC 在 provider 构造时编译策略。外部配置文件变化不会自动改写一个已存在的实例。需要热更新时,应明确采用 reload、重建 Agent 或支持动态读取的自定义 provider,并为版本切换留下审计记录。

10. middleware 顺序决定成本和语义

装配过滤发生在模型看到工具之前。运行时 AuthorizationAdapter 是外层授权,显式 Guardrail 是内层业务或外部策略检查,之后才进入工具与 sandbox。便宜、稳定、拒绝率高的检查应靠前。需要远程访问或复杂参数分析的检查靠后。

Sandbox 解决进程与资源隔离,授权回答“这个主体能否调用”,Guardrail 回答“这次调用是否满足额外约束”。三者互补,不能互相代替。

11. 映射回 ADPS 模式

模式DeerFlow 中的实现切面尚未覆盖的部分
A5 最简工具集装配时移除无权可见的工具,Skill 与 tool group 继续收窄候选任务相关性仍需由声明和激活策略负责
A1 工具调度模型只在准入集合内选择不定义路由质量与工具排序
A4 护栏三明治完整的 PRE 调用前拦截尚无通用 POST 业务结果核验与补偿
G1 审批门allow / deny 决策与结构化原因ask、持久化意图和恢复前复验需要独立实现
G2 爆炸半径控制最小能力暴露、sandbox、fail-closed业务额度、速率、作用域与熔断仍由部署方配置
G4 可观测性拒绝与 provider 异常写入 RunJournal跨 subagent 的统一证据链仍需补齐
G5 钩子流水线统一 middleware 执行点和短路语义完整的前后钩子编排不由本功能单独承担

12. 测试应固定哪些边界

  • authorization 关闭时保持原始工具顺序和对象。
  • deny 优先于 allow,空 allowlist 不能被当成“未配置”。
  • 未知角色、空 target 和非法配置键按 fail-closed 处理。
  • lead agent、subagent、embedded client 得到一致的过滤结果。
  • Layer 1 与 Layer 2 使用同一个 provider 实例。
  • deferred catalog 先过滤,tool_search 不得恢复被拒工具。
  • provider 异常分别覆盖 fail-open 与 fail-closed。
  • GraphBubbleUp、暂停和恢复信号不能被 middleware 吞掉。
  • 拒绝事件有策略 ID 与原因码,日志不含原始敏感参数。
  • Journal 持久化失败不改变授权结果。

13. 还需要继续设计的部分

DeerFlow 的公开实现已经把 PRE 授权做得很完整,但它没有替生产系统解决所有治理问题。高风险动作还需要 POST 业务核验、幂等键、外部回执与补偿。需要人工判断的调用还要增加 ask 决策、审批有效期、状态变化后的复验和单次消费。策略热更新则需要明确版本、切换原子性和回滚路径。

这也是该案例的主要价值:Guardrail 不是一条正则表达式,也不是一个“安全开关”。它是一条从能力装配、可信身份、策略裁决、执行拦截到证据留存的工程链。

公开来源

阅读边界:本文由 ADPS 根据 DeerFlow 公开代码、PR 与文档独立整理,用于说明可复用的架构机制,不是 DeerFlow 项目的官方设计说明。

DeerFlow 源码采用 MIT License。本文与图示采用 CC BY 4.0