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 studiedGuardrail and authorization changes in the public DeerFlow repository
Core questionWhich tools should a task receive, and when should tools that the principal cannot use disappear from the candidate set?
EvidencePRs #1240, #3665, #3837, #4260, and #4370, plus current source, tests, and public documentation
ADPS patternsA1 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.

DeerFlow tool assembly and two-layer authorization
Figure 1 · Relevance narrows candidates; authorization decides what the current principal may see and call.

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
  1. Layer 1, assembly time: remove tools that can never be used from schemas and the deferred catalog. The model never sees them, and tool_search cannot promote them later.
  2. 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

Five stages in the evolution of DeerFlow guardrails
Figure 2 · The evolution moved from a call boundary to a visibility boundary. Dates follow Git merge history.
MergedPRCapability addedStill missing at that point
2026-03-23#1240Pre-call GuardrailMiddleware and pluggable providersTrusted identity, durable audit, resource policy
2026-06-21#3665User, role, run, and channel identity in each requestIndependent RBAC and assembly filtering
2026-07-03#3837Denials and provider failures in RunJournalConsistent journal inheritance across all paths
2026-07-21#4260AuthorizationProvider, built-in RBAC, provider factoryModel-visible tools could still exceed permission
2026-07-23#4370Assembly filter and run-time check sharing one providerAsk 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

LevelWhen it takes effectTypical changes
ConfigurationProcess start or explicit reloadProvider type, role policy, fail-closed
Agent buildNew lead agent, subagent, or embedded clientTool groups, middleware, provider instance
Per requestPrincipal and GuardrailRequest constructionUser, role, run, channel, attributes
Per callImmediately before tool executionArguments, 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

PatternDeerFlow implementationBoundary
A5 Minimal Tool SetAssembly removes invisible tools; Skills and tool groups narrow candidates furtherTask relevance remains a separate declaration and activation concern
A1 Tool DispatchThe model selects only within the admitted setDoes not define routing quality or ranking
A4 Guardrail SandwichA strong PRE interception pathNo general POST business verification or compensation
G1 Approval GateAllow/deny decisions with structured reasonsAsk, durable intent, and resume-time revalidation remain separate work
G2 Blast-Radius ControlLeast visibility, sandboxing, and fail-closed behaviorBusiness quotas, scope, rate limits, and circuit breakers are deployment policy
G4 ObservabilityDenials and provider failures enter RunJournalUnified evidence across subagents remains incomplete
G5 Hooks PipelineA shared middleware point with short-circuit semanticsThe 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

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.