Cases/Open-source engineering case
ADPS Open-Source Engineering Case
DeerFlow Guardrails: From Pre-Call Interception to Two-Layer Authorization
Five public pull requests moved tool control from run-time denial to assembly-time visibility filtering, while bringing identity, policy, and evidence into one execution path.
Case scope
| System studied | Guardrail and authorization changes in the public DeerFlow repository |
| Core question | Which tools should a task receive, and when should tools that the principal cannot use disappear from the candidate set? |
| Evidence | PRs #1240, #3665, #3837, #4260, and #4370, plus current source, tests, and public documentation |
| ADPS patterns | A1 Tool Dispatch, A4 Guardrail Sandwich, A5 Minimal Tool Set, G1 Approval Gate, G2 Blast-Radius Control, G4 Observability, and G5 Hooks Pipeline |
1. Which tools should one task receive?
DeerFlow does not delegate this question to one unconstrained generation. The model can choose only among tool schemas already present in its run context. Before a capability enters that list, agent declarations, the active Skill, and principal permissions narrow the candidates.
Effective capability = global candidates ∩ agent declaration ∩ active Skill ∩ principal permission.
Two questions are easy to conflate. Is the tool relevant? is handled by tool_groups, subagent allow/deny lists, Skill activation, and deferred discovery. May this principal use it? is handled by the AuthorizationProvider. Relevance does not grant permission, and authorization does not guess the best tool for the task.
2. Assembly filtering and run-time interception share one policy
apply_tool_authorization resolves the provider, constructs the principal, and filters candidates through one entry point. It returns both the filtered list and the provider instance, which the caller then wires into run-time 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, assembly time: remove tools that can never be used from schemas and the deferred catalog. The model never sees them, and
tool_searchcannot promote them later. - Layer 2, call time: when the model proposes a concrete call, authorize again against current identity, arguments, and dynamic resources. Explicit guardrails run after that decision.
Reusing one provider prevents a split in which a capability is hidden in one layer but callable in another, or the two layers evaluate different policy versions.
3. Five pull requests changed five boundaries
| Merged | PR | Capability added | Still missing at that point |
|---|---|---|---|
| 2026-03-23 | #1240 | Pre-call GuardrailMiddleware and pluggable providers | Trusted identity, durable audit, resource policy |
| 2026-06-21 | #3665 | User, role, run, and channel identity in each request | Independent RBAC and assembly filtering |
| 2026-07-03 | #3837 | Denials and provider failures in RunJournal | Consistent journal inheritance across all paths |
| 2026-07-21 | #4260 | AuthorizationProvider, built-in RBAC, provider factory | Model-visible tools could still exceed permission |
| 2026-07-23 | #4370 | Assembly filter and run-time check sharing one provider | Ask decisions, general post-checks, business compensation |
4. PR #1240: establish a non-bypassable call point
The first version placed authorization in wrap_tool_call and awrap_tool_call. Middleware constructs a GuardrailRequest before execution and delegates the decision to a structural provider. A denial becomes a ToolMessage with a reason code, allowing the agent to adapt. Deployments choose fail-closed or fail-open behavior for provider failures.
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)
Control-flow exceptions such as GraphBubbleUp pass through unchanged. Middleware must not swallow pause and resume signals simply because it occupies the tool boundary.
5. PR #3665: identity must come from a trusted injection point
A tool name alone is insufficient for production authorization. The same write_file call may have different permissions for a regular user, an internal workload, and a delegated subagent. The request therefore gained user and role identity, OAuth provenance, run and tool-call IDs, channel identity, and an internal-workload flag.
These fields come from gateway and run-time context, not from prompts or model output. The system also keeps the requesting principal distinct from the executing agent, making it possible to answer who requested the action, which agent executed it, and in which run.
6. PR #3837: a denial must remain discoverable
Security-relevant decisions enter RunJournal with the tool name, call ID, role, policy ID, reason codes, fail-closed setting, and provider-error flag. Normal allows do not produce a security event for every call, which keeps the stream focused on interventions.
The event intentionally omits raw tool_input and user identifiers. Arguments may contain credentials or business data; an audit stream should not create a second exposure surface. Journal persistence is best-effort. A storage failure emits a warning but does not change the authorization result.
Current code also records an open boundary: native subagents do not automatically inherit __run_journal. Custom runtimes can supply it, but cross-agent evidence still needs further consolidation.
7. PR #4260: separate the policy brain from the enforcement point
GuardrailMiddleware owns the execution point. AuthorizationProvider owns resource decisions. A provider receives a Principal, resource, action, target, and context. Built-in RBAC validates and compiles role policy during construction, leaving deterministic lookups on the request path.
Deny wins over allow. Unknown roles, empty targets, and misspelled configuration keys fail. allow: false and an empty allow list both mean deny-all. Only an absent policy for a resource type means unrestricted. Tests, rather than operator intuition, fix these semantics.
8. PR #4370: the model should not see tools it can never use
With run-time denial alone, the model still sees unauthorized schemas. It can spend tokens planning a path that must fail, and deferred search may rediscover the tool. Assembly-time filtering makes visibility part of authorization.
The change had to cover lead-agent, subagent, and embedded-client construction. Missing one path would give the same role a different capability set depending on its entry point.
tool_search is a special boundary. It may defer MCP schemas, but the deferred catalog must itself be filtered first. A generated tool_search result can reuse that filtered catalog. An ordinary tool with the same name receives no exemption from run-time authorization.
9. “Dynamic configuration” has four meanings
| Level | When it takes effect | Typical changes |
|---|---|---|
| Configuration | Process start or explicit reload | Provider type, role policy, fail-closed |
| Agent build | New lead agent, subagent, or embedded client | Tool groups, middleware, provider instance |
| Per request | Principal and GuardrailRequest construction | User, role, run, channel, attributes |
| Per call | Immediately before tool execution | Arguments, dynamic resources, external policy, risk |
The built-in RBAC provider compiles policy at construction time. Editing an external file does not mutate an existing instance. A hot-update design must explicitly choose reload, agent reconstruction, or a provider that reads dynamic policy, and preserve the policy version in evidence.
10. Middleware order determines cost and semantics
Assembly filtering runs before the model sees schemas. At call time, the AuthorizationAdapter is the outer authorization check and explicit guardrails provide inner business or external-policy checks. The tool and sandbox follow. Cheap, stable checks with a high rejection rate belong early; remote or argument-heavy checks belong later.
A sandbox isolates processes and resources. Authorization answers whether this principal may call a capability. A guardrail asks whether this invocation satisfies additional constraints. The three controls are complementary.
11. Mapping the implementation to ADPS patterns
| Pattern | DeerFlow implementation | Boundary |
|---|---|---|
| A5 Minimal Tool Set | Assembly removes invisible tools; Skills and tool groups narrow candidates further | Task relevance remains a separate declaration and activation concern |
| A1 Tool Dispatch | The model selects only within the admitted set | Does not define routing quality or ranking |
| A4 Guardrail Sandwich | A strong PRE interception path | No general POST business verification or compensation |
| G1 Approval Gate | Allow/deny decisions with structured reasons | Ask, durable intent, and resume-time revalidation remain separate work |
| G2 Blast-Radius Control | Least visibility, sandboxing, and fail-closed behavior | Business quotas, scope, rate limits, and circuit breakers are deployment policy |
| G4 Observability | Denials and provider failures enter RunJournal | Unified evidence across subagents remains incomplete |
| G5 Hooks Pipeline | A shared middleware point with short-circuit semantics | The feature does not by itself provide a complete pre/post hook pipeline |
12. Boundaries the test suite should hold
- Authorization disabled preserves tool order and identity.
- Deny overrides allow; an empty allow list cannot mean “unset.”
- Unknown roles, empty targets, and invalid policy keys fail closed.
- Lead agent, subagent, and embedded client receive consistent filtering.
- Layer 1 and Layer 2 reuse one provider instance.
- The deferred catalog is filtered before tool_search can promote schemas.
- Provider failures cover both fail-open and fail-closed behavior.
- Pause, resume, and GraphBubbleUp signals pass through middleware.
- Denial events carry policy IDs and reason codes without raw sensitive arguments.
- Journal persistence failure does not change the decision.
13. Work that remains outside this implementation
The public implementation provides a substantial PRE authorization path, but it does not solve all production governance. High-risk actions still need POST business verification, idempotency keys, external receipts, and compensation. Human decisions require an ask state, approval expiry, resume-time state checks, and single consumption. Policy hot updates need versioning, atomic cutover, and rollback.
The case therefore shows more than a deny list. A production guardrail is an engineering path from capability assembly and trusted identity through policy decisions and execution interception to durable evidence.
Public sources
- DeerFlow public repository
- Guardrails: Pre-Tool-Call Authorization
- Issue #4063 · Pluggable Authorization
- PR #1240 · #3665 · #3837 · #4260 · #4370
Scope: ADPS independently prepared this case from DeerFlow's public code, pull requests, and documentation. It is not an official DeerFlow design document.
DeerFlow source code is available under the MIT License. This article and its diagrams are released under CC BY 4.0.