One policy, every capability
An Agents SDK recipe: three agents, one
Authority per agent, and every capability gate reading it. Runs offline with
agents.testing.ScriptedModel — no API key, no network. Verified against
openai-agents 0.22.0 on 2026-08-25.
What the SDK built
The SDK already ships every gate this pattern needs, and each one is in the right place:
| Gate | API | What it decides |
|---|---|---|
| Function-tool visibility | FunctionTool.is_enabled |
whether the model sees a local tool this turn |
| MCP tool visibility | tool_filter (ToolFilterContext) |
whether the model sees a server's tool this turn |
| Delegation visibility | Handoff.is_enabled |
whether a handoff is offered this turn |
| Invocation | FunctionTool.tool_input_guardrails |
whether a specific call, with its arguments, runs at all |
Handoffs also carry input_filter,
because by default "the new agent takes over the conversation, and gets to see the entire
previous conversation history". And the effective tool and handoff list for every model call
is already recorded on the agent span, as the maintainers confirmed when closing
#4626 — the SDK's observability
is not the thing this recipe adds.
Issue #4618 (opened 2026-08-24, closed as a docs request 2026-08-25) asks for what to do with all of them at once:
The Agents SDK now has several useful mechanisms for controlling what an agent can see at runtime:
FunctionTool.is_enabled;Handoff.is_enabled; MCPtool_filter; tool guardrails / approvals for execution-time checks; handoff input filtering. … I think there is an opportunity to document a single production pattern for using them together when capability availability comes from the same runtime policy.
It also states the distinction this recipe is built around:
visibility filtering is not itself a security boundary … argument/resource-level authorization belongs at invocation time.
What this recipe shows
- One policy, read by all four gates. Each agent holds one
Authority— a scope set and ceilings.FunctionTool.is_enabled, the MCPtool_filterandHandoff.is_enabledall ask it the same question, and the invocation check asks it again with the call's arguments. The policy is 40 lines and the wiring is a callback per gate. - A handoff narrows.
billingis grantedmeet(triage, request), so it may refund at most USD 50 where triage may spend USD 500. The relation holds by construction:billing.authority.is_narrower_than(triage.authority). - Escalation by routing is refused structurally.
srerequestsinfra.deploy, which triage does not hold. The recipe'sHandoff.is_enabledcompares the two sides, sotransfer_to_srenever reaches the model at all and the refusal is on the ledger. The SDK applies no relation of its own here:check_handoff_enabledinagents/run_internal/turn_preparation.py::get_handoffscallsis_enabled(context_wrapper, agent)with the sending agent and names the receiver only byHandoff.agent_name— enough for an application to compute the relation, and left to the application to do so. - Visibility is not the boundary, and the recipe does not pretend otherwise.
kb_exportis visible to triage — triage holdskb.*— and the call is still denied, because the egress ceiling binds on an argument no visibility gate can see. A second run with every visibility gate switched off denies exactly the same calls (test_bypass_visibility_off_still_denies_at_invocation). - The receiver is handed only history it may hold. An
input_filterdrops the tool traffic whose scope the receiver does not hold; the SDK's own routing items stay. - The record verifies offline. The hash-chained ledger verifies with no service running, and a signed evidence bundle verifies integrity, child-subset-of-parent and containment from the bundle alone. This is a check a third party can run without us and without the model vendor — a different job from a trace, not a replacement for one.
The MCP server in the recipe is an in-memory MCPServer subclass so the demo needs no
subprocess. It applies the same ToolFilterCallable you would pass to
MCPServerStdio(tool_filter=…), called the same way the shipped servers call it.
Run it
pip install 'attenu-guard[openai-agents]'
python examples/integrations/openai_agents/one_policy/demo.py
python -m pytest -q tests/integrations/test_openai_agents_one_policy.py # 19 tests
# RUN_LIVE=1 OPENAI_API_KEY=... python examples/integrations/openai_agents/one_policy/live_smoke.py
Expected: [1] the plain SDK offers the sre handoff and the USD 250 credit body runs ·
[2] the sre handoff is never offered, billing ⊆ triage, the USD 42 credit runs, the
USD 250 credit and the export are denied with empty side-effect sinks · [3] the chain and
the signed bundle verify · RESULT: OK.
Exit codes: 0 every expectation held · 1 an expectation failed · 3 the premise of step 1
changed — the SDK now relates the two sides of a handoff itself, or no longer passes the
sending agent to Handoff.is_enabled. Steps 2–6 still hold in that case.
Trust boundary (read this before relying on it)
The recipe mediates the SDK's own dispatch: Runner.run's tool-invocation path
(FunctionTool.tool_input_guardrails, which runs before the tool body and before
RunHooks.on_tool_start), handoff resolution, and the tool lists the SDK assembles per turn.
It does not see a direct Python call around the SDK — issue_credit_impl(sink, 250.0)
from your own code runs, and test_bypass_direct_python_call_is_outside_the_boundary proves
it so nobody mistakes mediation for a sandbox. It also does not see other processes, or the
credentials the process itself holds.
Inside that boundary: a denied call never executes its body; a tool with no declared scope is
checked against a scope nobody holds, so it is denied by default; retries stay denied and each
attempt is on the ledger; if the ledger cannot be written the call does not proceed; and
require_guard() refuses to start a run whose policy or checks are not wired.
Evidence manifest
Verified against openai-agents==0.22.0 (released 2026-08-19) on 2026-08-25, Python 3.12.
The same tests pass on 0.21.1.
| Claim | Pinned to | Test |
|---|---|---|
| The SDK offers a handoff to a receiver more capable than the sender | openai-agents==0.22.0, agents/run_internal/turn_preparation.py::get_handoffs |
test_semantic_sdk_offers_a_handoff_to_a_more_capable_agent |
Handoff.is_enabled is handed the sending agent, so an application can compute the relation |
same path, check_handoff_enabled |
test_semantic_handoff_is_enabled_receives_the_sending_agent |
| The receiver is handed the prior conversation by default | Handoff.input_filter default None |
test_semantic_handoff_forwards_the_prior_conversation_by_default |
| The four gate APIs exist and have the shape the recipe uses | FunctionTool, Handoff, agents.mcp.ToolFilterContext, agents.mcp.MCPServer |
test_compat_the_four_primitives_exist |
| The denied credit left no trace (side-effect oracle) | this recipe | test_side_effect_oracle_denied_credit_left_no_trace |
| The denied MCP export left no trace, though the tool was visible | this recipe | test_side_effect_oracle_denied_mcp_export_left_no_trace |
billing ⊆ triage, refund ceiling USD 50 |
Guard.is_narrower_than |
test_authority_is_monotonic_down_the_chain |
| Escalation by routing refused and recorded | this recipe | test_escalation_by_routing_is_refused_and_recorded |
| Only history the receiver may hold is forwarded | Handoff.input_filter |
test_narrowed_history_is_what_the_receiver_is_handed |
| The model "decides" the over-limit credit and is denied | this recipe | test_injection_the_model_decides_the_over_limit_credit_and_is_denied |
| Undeclared alternate tool denied · visibility off changes nothing · retries stay denied · policy absent refuses to run · ledger-write failure fails closed · direct call is outside the boundary · tampered bundle fails, clean bundle passes | this recipe | test_bypass_* |
Re-run this table the week the recipe is published; a semantic failure names the premise that changed.
Related: OWASP Top 10 for Agentic Applications 2026 — ASI03 (un-scoped privilege inheritance),
ASI07, ASI08 · Agent Baseline AUT-03 (delegation attenuation) ·
docs/DENIAL-CONTRACT.md ·
docs/INTEGRATIONS.md.
What remains the SDK's
Routing, the four gates themselves, the handoff and MCP machinery, tracing and the agent span, approvals, and the decision about which capability belongs to which agent. This recipe adds two things on top: the relation between the two sides of a handoff, and a record of every decision that a third party can check offline. Both are ordinary application code against public APIs — which is what #4618 asked for.