案例/开源工程案例
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 ∩ 主体权限。
这里有两个容易混在一起的问题。工具是否相关由 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
这段接口表达了两层控制:
- Layer 1,装配时:从 schema 和 deferred catalog 中移除永远无权使用的工具。模型看不到它们,
tool_search也不能把它们重新提升回来。 - Layer 2,调用时:模型提出具体调用后,再按当前身份、参数和动态资源执行一次授权。随后才进入显式 Guardrail。
两层复用同一个 provider,避免“界面上看不见,运行时却能调”或“装配时准入,执行时使用另一份策略”的分裂。
3. 五次 PR 改变了五条边界
| 合并时间 | 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 | #4260 | AuthorizationProvider、内置 RBAC 和策略工厂 | 模型可见工具仍可能大于主体权限 |
| 2026-07-23 | #4370 | 装配过滤与运行授权共用同一 provider | 审批型 ask、完整 POST 验证和业务补偿 |
4. PR #1240:先建立不可绕过的调用支点
第一版把授权放进 wrap_tool_call 与 awrap_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_id、user_role、OAuth 来源、run_id、tool_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 client | tool 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 不是一条正则表达式,也不是一个“安全开关”。它是一条从能力装配、可信身份、策略裁决、执行拦截到证据留存的工程链。
公开来源
- DeerFlow 公开仓库
- Guardrails: Pre-Tool-Call Authorization
- Issue #4063 · Pluggable Authorization
- PR #1240 · #3665 · #3837 · #4260 · #4370
阅读边界:本文由 ADPS 根据 DeerFlow 公开代码、PR 与文档独立整理,用于说明可复用的架构机制,不是 DeerFlow 项目的官方设计说明。
DeerFlow 源码采用 MIT License。本文与图示采用 CC BY 4.0。