Policy-Based Tool Approvals
Tool Approvals let you approve or deny tool calls with a function in your agent setup. @ai-sdk/policy-opa moves those rules out of code and into Open Policy Agent (OPA) policies written in .rego.
Use it when you want authorization to be:
- Written as a separate, reviewable artifact instead of a function buried in agent setup.
- Testable in CI with
opa test, independent of the SDK. - Editable without a code deploy (when served from a running OPA instance).
The package sits entirely on top of the public toolApproval callback. Nothing changes on the wire: the same tool-approval-request / tool-approval-response flow as built-in approvals.
What you can enforce
OPA is a general policy engine. If you can express a rule as code over structured input, you can enforce it at the tool boundary. Common categories:
- Security and access: scopes, roles, permissions, tenant isolation, allowlists of tools, hosts, or paths.
- Business rules: who can do what, on which resources, under which conditions (for example, payments above a threshold need approval).
- Cost and usage: deny or require approval for calls that exceed a budget, token ceiling, or per-user quota.
- Compliance and change control: gate destructive or regulated actions on the right approver, environment, or time window.
The decision is not limited to the current call's arguments. The policy input also carries messages, the full model and tool-call history for the run, so a rule can factor in what already happened: prior tool calls, the sequence of actions so far, or a running total across the conversation. Examples:
- Require approval once a run has already performed N writes.
- Deny a second irreversible action (a push, a delete) in the same conversation.
- Track cumulative spend or token usage across steps and stop at a ceiling.
Best fit: deterministic checks
Policy enforcement is strongest when the decision is deterministic and verifiable from structured fields, whether those fields come from the current call or the history in messages. The policy extracts a value and compares it, so the answer is the same every time.
- Strong fits: scopes, permissions, numeric thresholds, allowlists, time windows, and history-aware checks like counts, sequences, and running totals.
- Weak fits: content-based or semantic filtering such as "do not use bad language" or "block toxic content". These are best-effort, hard to verify, and easy to bypass. Use a dedicated moderation or classification step for them, and keep policy for the deterministic gate around it.
Rule of thumb: if you can point at a field in the input object and write an exact check on it, this is the right tool. If the rule depends on judging free-form meaning, do not lean on policy alone.
Install
pnpm add @ai-sdk/policy-opa# pick one (or both) OPA backends:pnpm add @open-policy-agent/opa-wasm # in-process WASM evaluationpnpm add @open-policy-agent/opa # HTTP client to a running OPA server- The backends are optional peer dependencies.
- Only the backend you import is loaded.
How it works
The policy is consulted before every tool dispatch and maps to one of the standard approval statuses:
allowruns the tool.denyreturns a denied result the model can reason about (no human needed).requires-approvalpauses the run and waits for a humantool-approval-response.- No matching rule normalizes to
not-applicable, which the SDK treats as allow. Adddefault decision := { "decision": "deny" }to your policy to default-deny instead.
Quick start
import { generateText } from 'ai';import { anthropic } from '@ai-sdk/anthropic';import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';import { readFile } from 'node:fs/promises';
// 1. Load the compiled policy bundle.const wasm = await readFile('./policy.wasm');const client = await wasmPolicyClient({ wasm });
// 2. Build the toolApproval configuration.const toolApproval = opaPolicy({ client, path: 'agent/call/decision',});
// 3. Pass it to generateText (or streamText / ToolLoopAgent). Everything else is normal.const result = await generateText({ model: anthropic('claude-sonnet-4-5'), tools: { git, bash, queryLogs }, toolApproval, prompt: 'find the failing test and push the fix',});Writing the Rego policy
The policy emits a decision object. reason is optional and is surfaced back to the model (for deny) or to the human approver (for requires-approval).
package agent.call
# Default to "not-applicable" so unmatched calls fall through.# Use { decision: "deny" } to default-deny instead.default decision := { "decision": "not-applicable" }
# Hard deny: pushes are never allowed automatically.decision := { "decision": "deny", "reason": "pushes require human review" } { input.tool.name == "git" input.args.args[0] == "push"}
# Auto-allow: read-only git operations.decision := { "decision": "allow" } { input.tool.name == "git" input.args.args[0] in {"status", "log", "diff", "show"}}
# Human-in-the-loop: kubectl by oncall.decision := { "decision": "requires-approval", "reason": "kubectl by oncall" } { input.tool.name == "kubectl" input.runtimeContext.role == "sre-oncall"}Notes:
- The legacy boolean shape (
{ "allow": true | false, "reason": "..." }) is also accepted, so existing rules migrate without rewriting. - The default OPA
inputshape is{ tool: { name }, args, messages, runtimeContext }. - Override the input shape with
toInput:
opaPolicy({ client, path: 'agent/call/decision', toInput: ({ toolCall, runtimeContext }) => ({ action: toolCall.toolName, principal: runtimeContext.role, resource: toolCall.input, }),});Test the policy in CI
OPA ships its own test framework. These tests run without the SDK, which is the main reason policy-as-code beats policy-in-application-code.
# policy_test.regopackage agent.call
test_push_denied { decision.decision == "deny" with input as { "tool": { "name": "git" }, "args": { "args": ["push", "origin", "main"] } }}Run with opa test policy.rego policy_test.rego.
Errors fail closed
- If the backend errors (server unreachable, WASM fault, misbuilt bundle),
opaPolicyreturnsdeniedwith the error message as the reason. - The error never rejects out of the callback and never aborts the run.
- A backend outage blocks the affected call rather than silently allowing it.
- This is distinct from a rule that returns no match, which normalizes to
not-applicable(allow). Use adefault ... denyrule if you want unmatched calls denied too.
Loading the policy
Option A: WASM (in-process)
Compile the .rego to WASM ahead of time:
opa build -t wasm -e 'agent/call/decision' -o bundle.tar.gz policy.regotar -xzf bundle.tar.gz /policy.wasmimport { wasmPolicyClient, opaPolicy } from '@ai-sdk/policy-opa';import { readFile } from 'node:fs/promises';
const wasm = await readFile('./policy.wasm');const client = await wasmPolicyClient({ wasm });const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });- No network call per decision.
- Good fit when you ship the policy with the app, or fetch it from object storage at startup.
- Hot-reload means rebuilding the WASM and re-instantiating the client.
Option B: HTTP (running OPA server)
import { httpPolicyClient, opaPolicy } from '@ai-sdk/policy-opa';
const client = httpPolicyClient({ url: 'http://localhost:8181' });const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });- One HTTP round-trip per decision.
- Good fit when policies change frequently and you want hot-reload without redeploying, or when multiple services share one OPA.
- Pass
headersfor Styra DAS / EOPA authentication.
Bring in external data and integrations
Policies are not limited to the tool-call input. OPA can decide based on external data such as role-to-permission mappings, IDP group memberships, entitlement lists, or allowlists, and that data can update without redeploying your app.
How OPA sources data is OPA's concern, not this package's. The common approaches:
- Static data at load time: pass
datatowasmPolicyClient({ wasm, data }). It is handed to the bundle'ssetData, so the policy can read it underdata.*. - Bundles: point a running OPA server (the HTTP backend) at a bundle service so it periodically pulls fresh policy and data. Role mappings and IDP groups update without a code deploy.
- Lookups during evaluation: a policy can call out to an external service (for example an IDP or entitlements API) while it evaluates a decision.
Learn the details from OPA, not here:
- Policy reference
- External data (including connecting to an IDP)
This package only maps the resulting decision to the SDK's approval status. The input it passes is what your policy combines with whatever data OPA already has.
Roll out safely with shadow mode
Do not ship a new policy straight to enforce. The first version almost always denies things you did not mean to. shadow(approval, opts) evaluates the policy and reports the decision via onDecision, but tells the SDK every call is approved until you flip enforce: true.
import { opaPolicy, shadow, wasmPolicyClient } from '@ai-sdk/policy-opa';
const client = await wasmPolicyClient({ wasm });
const toolApproval = shadow( opaPolicy({ client, path: 'agent/call/decision' }), { enforce: process.env.ENFORCE_POLICY === 'true', onDecision: event => { logger.info('policy.decision', { tool: event.toolCall.toolName, decision: event.decision.type, reason: event.decision.reason, enforced: event.enforced, wouldBlock: event.decision.type === 'denied', }); }, },);Recommended rollout:
- Write the policy and test it with
opa test. - Wrap it in
shadow(...)withenforce: false(the default) and wireonDecisionto your logs/metrics. - Run in your real environment. Inspect events where
decision.typeisdeniedoruser-approval: those are the calls the policy would have changed. - Fix the policy and iterate.
- When the only
denied/user-approvalevents are ones you want, setenforce: true.
Telemetry semantics:
onDecisionis fire-and-forget. A slow or throwing logger cannot block or break enforcement, and thrown errors are swallowed.- The contract is enforcement first, observability second.
- For the opposite (enforcement waits for the audit log), log from inside the underlying
toolApprovalinstead of throughshadow.
Send decisions to your observability platform
onDecision is not only for rollout. Keep shadow in place with enforce: true and every decision flows to your logging, metrics, or tracing stack while the policy stays load-bearing.
const toolApproval = shadow( opaPolicy({ client, path: 'agent/call/decision' }), { enforce: true, // enforcing AND observing onDecision: event => { metrics.increment('agent.policy.decision', { tool: event.toolCall.toolName, decision: event.decision.type, // approved | denied | user-approval | not-applicable enforced: String(event.enforced), }); }, },);- Each
PolicyDecisionEventcarries the tool call, the policydecision(type and reason),enforced, andeffective(what the SDK acted on). Comparedecisionagainsteffectiveto spot drift. onDecisionis fire-and-forget; see the telemetry semantics under shadow mode.
When you use the HTTP backend, OPA can also emit decision logs natively, shipping every evaluation to a remote service for a full audit trail without any application code.
Capability scoping at the model boundary
opaCapabilityMiddleware enforces policy earlier than toolApproval: before the model is even told a tool exists. This is defense in depth, plus it saves tokens and improves jailbreak rejection.
import { wrapLanguageModel } from 'ai';import { wasmPolicyClient, opaCapabilityMiddleware } from '@ai-sdk/policy-opa';
const client = await wasmPolicyClient({ wasm });
const wrappedModel = wrapLanguageModel({ model: anthropic('claude-sonnet-4-5'), middleware: opaCapabilityMiddleware({ client, path: 'agent/tools/allowed' }),});- The rule at
pathreturns astring[]of allowed tool names, or{ tools: string[] }. - Tools not in the allowlist are dropped before the model sees them.
- Fails closed: on a malformed response or evaluator error,
params.toolsis set toundefinedso the model is told it has no tools. For fail-open, write the fallback in Rego.
Scoping a discovered tool surface
When tools come from MCP discovery or a plugin registry, you cannot write per-tool rules ahead of time, and any tool you forgot is silently allowed. wrapMcpTools makes the approval total over the discovered surface by routing unmatched tools to a configurable default.
import { opaPolicy, wasmPolicyClient, wrapMcpTools } from '@ai-sdk/policy-opa';
const discovered = await mcpClient.tools();const client = await wasmPolicyClient({ wasm });
const { tools, toolApproval } = wrapMcpTools( discovered, opaPolicy({ client, path: 'agent/call/decision' }), { default: 'user-approval' }, // anything OPA does not match needs a human);
await generateText({ model, tools, toolApproval, prompt });Defaults for uncovered tools:
'user-approval'(default): require a human. Good when you trust the source but want a safety net.'denied': hard allowlist mode. The policy enumerates what is allowed; everything else is rejected.'approved': allow. Only when the discovery source is fully trusted.
Despite the name, it works on any Record<string, Tool>.
Allow-all when no policy is configured
optionalOpaPolicy returns undefined when client is undefined, which is the same as not passing toolApproval (the SDK approves every call). Useful for local dev or CI where the policy file is absent.
import { optionalOpaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';import { readFile } from 'node:fs/promises';
const wasm = process.env.POLICY_WASM_PATH ? await readFile(process.env.POLICY_WASM_PATH) : undefined;
const client = wasm ? await wasmPolicyClient({ wasm }) : undefined;
const toolApproval = optionalOpaPolicy({ client, path: 'agent/call/decision' });POLICY_WASM_PATHunset:toolApprovalisundefined, all calls allowed, OPA modules never loaded.POLICY_WASM_PATHset: policy loads, enforcement on.- For stricter behavior (refuse to start without a policy), use
opaPolicydirectly and let the missing bytes throw at startup.
Transitive enforcement: composite tools
toolApproval only fires when the model calls a tool directly. A coarse dispatcher tool (a bash tool that can run git push, an HTTP tool, an MCP proxy) lets the model bypass a per-action rule by routing through it.
The fix lives inside the dispatcher's toolApproval entry: parse the dispatcher input down to a logical (name, args) pair, then evaluate it against the same rule the direct tool uses.
const bashApproval = opaPolicy({ client, path: 'agent/action/decision', toInput: ({ toolCall }) => { const { command } = toolCall.input as { command: string }; const [bin, ...rest] = command.split(/\s+/); return { kind: bin, args: rest }; },});Guidance:
- Keep the matching logic in one place: either a shared Rego helper rule (both approvals call
opaPolicywith the samepath) or a shared TypeScript predicate. - Deny anything you cannot reduce to a clean invocation. Shell input is adversarial to parse, so "cannot prove it is safe" means deny.
- Honest limitation: this gates dispatch at the model's call boundary. It does not stop a tool that, once approved, performs extra side effects beyond what its input describes. For that, run untrusted execution in an out-of-band sandbox (Vercel Sandbox, Firecracker, containers) and treat the sandbox as the trust boundary.
Worked examples for SQL, HTTP, MCP, browser, and shell dispatchers live in the package README.
Example application
The ai-sdk-slackbot example wires this package into a real agent end to end. It shows:
- Every tool call gated by Rego in
policies/decision.rego, editable without touching TypeScript. - Real tools under policy: a web search scoped to a domain, a weather tool with a city allowlist, and a
bashtool restricted to a read-only command allowlist (transitive enforcement). - Switching backends with one env var (
POLICY_MODE): in-process WASM by default, or a live OPA HTTP server for hot-reloading policies in dev. - Policy code tested independently with OPA-native tests in
policies/decision_test.rego.
See policies/ and lib/policy/load.ts for how the policy is loaded and applied.
API reference
Everything is exported from the package root, @ai-sdk/policy-opa.
Engine-neutral core:
shadow(approval, opts?): wrap any approval so it is evaluated and reported but not enforced untilopts.enforce: true. Recommended starting point for any new policy.wrapMcpTools(tools, approval, opts?): make an approval total over a discovered tool set.opts.defaultcontrols uncovered tools.PolicyClient: theevaluate(path, input)interface every backend implements. The seam for non-OPA engines.- Helper types:
PolicyDecision,WrappedMcpTools,PolicyDecisionEvent.
OPA backend and adapters:
wasmPolicyClient({ wasm, data? }): async; loads a compiled OPA WASM bundle in-process.httpPolicyClient({ url, headers? }): sync; client against a running OPA server.opaPolicy({ client, path, toInput? }): returns atoolApprovalconfiguration. Fails closed.optionalOpaPolicy({ client, path, toInput? }): likeopaPolicybut returnsundefinedwhenclientisundefined.opaCapabilityMiddleware({ client, path, toInput? }): aLanguageModelV4Middlewarethat narrowsparams.toolsto an allowlist. Fails closed.normalizeOpaDecision(result): standalone result normalization, for users who call OPA themselves.
Related
- Tool Approvals: the underlying
toolApprovalcallback this package plugs into. - Building Agents
- ai-sdk-slackbot example: a full agent with policy-gated tools.
- Open Policy Agent documentation