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 evaluation
pnpm 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:

  • allow runs the tool.
  • deny returns a denied result the model can reason about (no human needed).
  • requires-approval pauses the run and waits for a human tool-approval-response.
  • No matching rule normalizes to not-applicable, which the SDK treats as allow. Add default 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 input shape 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.rego
package 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), opaPolicy returns denied with 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 a default ... deny rule 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.rego
tar -xzf bundle.tar.gz /policy.wasm
import { 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 headers for 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 data to wasmPolicyClient({ wasm, data }). It is handed to the bundle's setData, so the policy can read it under data.*.
  • 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:

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:

  1. Write the policy and test it with opa test.
  2. Wrap it in shadow(...) with enforce: false (the default) and wire onDecision to your logs/metrics.
  3. Run in your real environment. Inspect events where decision.type is denied or user-approval: those are the calls the policy would have changed.
  4. Fix the policy and iterate.
  5. When the only denied / user-approval events are ones you want, set enforce: true.

Telemetry semantics:

  • onDecision is 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 toolApproval instead of through shadow.

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 PolicyDecisionEvent carries the tool call, the policy decision (type and reason), enforced, and effective (what the SDK acted on). Compare decision against effective to spot drift.
  • onDecision is 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 path returns a string[] 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.tools is set to undefined so 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_PATH unset: toolApproval is undefined, all calls allowed, OPA modules never loaded.
  • POLICY_WASM_PATH set: policy loads, enforcement on.
  • For stricter behavior (refuse to start without a policy), use opaPolicy directly 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 opaPolicy with the same path) 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 bash tool 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 until opts.enforce: true. Recommended starting point for any new policy.
  • wrapMcpTools(tools, approval, opts?): make an approval total over a discovered tool set. opts.default controls uncovered tools.
  • PolicyClient: the evaluate(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 a toolApproval configuration. Fails closed.
  • optionalOpaPolicy({ client, path, toInput? }): like opaPolicy but returns undefined when client is undefined.
  • opaCapabilityMiddleware({ client, path, toInput? }): a LanguageModelV4Middleware that narrows params.tools to an allowlist. Fails closed.
  • normalizeOpaDecision(result): standalone result normalization, for users who call OPA themselves.