OpenAI Agents SDK
Governing OpenAI Agents SDK agents in Python, zero-code.
The OpenAI Agents SDK routes every real tool call — direct or Runner-orchestrated — through
one module-level function: agents.tool.invoke_function_tool(function_tool=, context=, arguments=). Neither the Runner orchestrator nor agents/realtime/session.py ever call
FunctionTool.on_invoke_tool directly. raxit's auto-patcher wraps exactly that point (plus
every built-in <X>Action.execute — CustomToolAction, ShellAction, LocalShellAction,
ComputerAction, ApplyPatchAction — and execute_handoffs for agent-to-agent handoffs), so
every tool call resolves to permit / defer / deny before it executes, with no governance
code in the agent. Python only — the OpenAI Agents SDK has no JS/TS SDK.
Zero-code governance
raxit run --agent-id <id> -- python agent.py starts the governance daemon, sets
RAXIT_AUTOLOAD=1, and puts the auto-patcher on the import path before your agent's first
import. Your tools stay ordinary agents.FunctionTool / @function_tool definitions — the only
"wiring" is each tool's name, which is the policy resource it's governed as in
security.yaml. Run the same file without raxit run and it runs ungoverned: governance is a
deploy-time choice.
# imports NOTHING from raxit
from agents import Agent, Runner, function_tool
@function_tool(name_override="shell.exec")
def run_shell(cmd: str) -> str: ...
agent = Agent(name="ops-bot", instructions=SYSTEM_PROMPT, tools=[run_shell])
result = await Runner.run(agent, "run rm -rf / on the box")raxit run -- python getting_started.py "run rm -rf / on the box"Run it yourself
To govern your own agent, install the SDK and launch through raxit run:
pip install raxit
raxit init # scan → .raxit/security.yaml
raxit run --agent-id my-agent -- python agent.pyThe walkthrough below is deterministic and LLM-free — it drives real agents.FunctionTool
instances through agents.tool.invoke_function_tool, the same dispatch shape the SDK's own
orchestration uses, so it runs with no API key.
The demo, step by step
Every terminal line below is from a captured run of the demo — four tools, three outcomes, one signed audit chain:
Four ordinary agents.FunctionTool instances. Nothing in this file imports raxit — the tool name is the only wiring; it is the policy resource the call is governed as.
from agents import FunctionTool
def _make_tool(tool_name: str, description: str, result: str) -> FunctionTool:
async def _on_invoke(ctx, args_json: str) -> str: # the tool body
RECEIVED[tool_name] = {"args": args_json}
return result
return FunctionTool(
name=tool_name,
description=description,
params_json_schema=_EMPTY_SCHEMA,
on_invoke_tool=_on_invoke,
)
def build_tools() -> list[FunctionTool]:
return [
_make_tool("search_docs", "Search internal product documentation (read-only).", ...),
_make_tool("read_customer", "Look up a customer record by id (read-only).", ...),
_make_tool("notify.send", "Send an internal notification (requires human approval).", ...),
_make_tool("shell.exec", "Run a shell command (forbidden for this agent).", ...),
]How it works
The policy is one security.yaml — one rule per outcome, default: block for everything else:
apiVersion: raxit.security/v1
agent: openai-agents-bot
default: block
rules:
- effect: allow
resource: search_docs
- effect: allow
resource: read_customer
- effect: review # unconditional review == defer.policy
resource: notify.send
# shell.exec is intentionally UNLISTED -> default-deny (deny.default_or_forbid).The demo calls raxit.autopatch.install() directly — that is exactly what raxit run triggers
under the hood — and then invokes each tool through agents.tool.invoke_function_tool, the
module-level function the SDK's Runner always goes through. One subtlety worth knowing if you
patch or import manually: install() rebinds invoke_function_tool as an attribute on the
agents.tool module, so a from agents.tool import invoke_function_tool done before
patching keeps pointing at the original, ungoverned function object. Under raxit run this
never bites you (the patcher runs before your agent's first import).
Each tool body records into the demo's call log when it runs, which is how the demo proves
the deferred and denied bodies never executed — ran=False in the [PASS] lines is read from
that log, not inferred.
What got denied and why
Two named reason codes fire in this demo:
defer.policy—notify.sendmatched aneffect: reviewrule: the call needs a human decision before it may run. The demo pinsRAXIT_DEFER_TIMEOUT=0so the raw defer surfaces immediately instead of blocking on an inline approval poll; in normalraxit runoperation the call blocks waiting forraxit approvals approve <token>— see Approvals for that flow.deny.default_or_forbid—shell.execmatched no rule, and the policy'sdefault: blockdenies anything unlisted. Nothing was ever allowed; there is no rule to loosen by accident.
Both surface to the agent as a raxit.autopatch.RaxitDenied exception raised from inside the
dispatch call, carrying the reason code (exc.reason_code) — catch it like any other exception.
The full decision vocabulary and reason-code table are on
Permit / defer / deny.
Scope: no args in the CAR at this layer
invoke_function_tool's call convention is keyword-only with no tool_input/input key, so
the intercepted call carries no arguments for this framework. Every rule in this policy is
therefore unconditional (identity + tool name only) — arg-conditioned rules and MODIFY are not
exercised here, matching the scope of raxit's own test suite for this framework.
The audit finale
The four decisions above form one hash-chained, Ed25519-signed Decision Provenance log. The demo's last step verifies every link and every signature from the client:
audit.verify -> ok=True signatures_verified=True signed_count=4 count=4See Audit for how the chain is built and what raxit audit verify
checks. For the general zero-code patching model (and the governed_tool per-tool escape
hatch), start from LangChain (Python) — the reference
patcher every other framework's patcher follows — and the coverage table on
Frameworks.

