@ai-sdk/policy-opa 0.0.0 → 1.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,636 @@
1
+ # @ai-sdk/policy-opa
2
+
3
+ Policy-as-code authorization for AI SDK tool calls, powered by [Open Policy Agent](https://www.openpolicyagent.org/).
4
+
5
+ Write your "what can this agent do?" rules in a `.rego` file. Plug them into `generateText` / `streamText` / `ToolLoopAgent` as a `toolApproval` configuration. The SDK enforces them at every tool call, with the same wire format used by built-in approvals (`tool-approval-request` / `tool-approval-response`).
6
+
7
+ ## Why
8
+
9
+ `toolApproval` in `ai` already supports three outcomes: `approved`, `denied`, and `user-approval` (the human-in-the-loop case). What it does not give you is a place to author the rules that does not require a code deploy. Authorization is the kind of thing where you want a written, testable artifact reviewed by the right people, not a function buried in your agent's setup code.
10
+
11
+ This package fills that gap. Rules live in `.rego`. They are evaluated by OPA (in-process via WASM, or out-of-process via HTTP) and the result is mapped to the SDK's `ToolApprovalStatus`. Nothing new on the wire; everything sits on top of the existing public `toolApproval` callback.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pnpm add @ai-sdk/policy-opa
17
+ # pick one (or both) of the OPA backends:
18
+ pnpm add @open-policy-agent/opa-wasm # in-process WASM evaluation
19
+ pnpm add @open-policy-agent/opa # HTTP client to a running OPA server
20
+ ```
21
+
22
+ The OPA backends are optional peer dependencies. The package only loads the one you import.
23
+
24
+ ## How it works
25
+
26
+ Without policy:
27
+
28
+ ```
29
+ model → tool call → tool.execute → result back to model
30
+ ```
31
+
32
+ With policy:
33
+
34
+ ```
35
+ model
36
+ → tool call
37
+ → toolApproval evaluates ──┬──► approved → tool.execute → result back to model
38
+ ├──► denied → tool-approval-response (auto, with reason) → model sees the denial
39
+ └──► user-approval → tool-approval-request → wait for human
40
+ → tool-approval-response on resume
41
+ → tool.execute or denial
42
+ ```
43
+
44
+ The policy is consulted **before** every tool dispatch. Auto-deny does not require a human; user-approval pauses the run until a human responds with a `tool-approval-response`. The model sees the denial as a structured result on its next step and can reason about it (for example, "I can't drop that table, let me try something else").
45
+
46
+ ## Quick start
47
+
48
+ ```ts
49
+ import { generateText } from 'ai';
50
+ import { anthropic } from '@ai-sdk/anthropic';
51
+ import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
52
+ import { readFile } from 'node:fs/promises';
53
+
54
+ // 1. Load the compiled policy bundle.
55
+ const wasm = await readFile('./policy.wasm');
56
+ const client = await wasmPolicyClient({ wasm });
57
+
58
+ // 2. Build the toolApproval configuration.
59
+ const toolApproval = opaPolicy({
60
+ client,
61
+ path: 'agent/call/decision',
62
+ });
63
+
64
+ // 3. Pass it to generateText. Everything else is normal.
65
+ const result = await generateText({
66
+ model: anthropic('claude-sonnet-4-5'),
67
+ tools: { git, bash, queryLogs },
68
+ toolApproval,
69
+ prompt: 'find the failing test and push the fix',
70
+ });
71
+ ```
72
+
73
+ ## Writing the Rego policy
74
+
75
+ The adapter expects the policy to emit a decision object with one of three `decision` values. `reason` is optional and gets surfaced back to the model (for `deny`) or to the human approver (for `requires-approval`).
76
+
77
+ ```rego
78
+ package agent.call
79
+
80
+ # Default to "not-applicable" so unmatched calls fall through to whatever
81
+ # behavior toolApproval has configured for them. Use { decision: "deny" } if
82
+ # you want to default-deny instead.
83
+ default decision := { "decision": "not-applicable" }
84
+
85
+ # Hard deny: pushes are never allowed automatically.
86
+ decision := { "decision": "deny", "reason": "pushes require human review" } {
87
+ input.tool.name == "git"
88
+ input.args.args[0] == "push"
89
+ }
90
+
91
+ # Auto-allow: read-only git operations.
92
+ decision := { "decision": "allow" } {
93
+ input.tool.name == "git"
94
+ input.args.args[0] in {"status", "log", "diff", "show"}
95
+ }
96
+
97
+ # Human-in-the-loop: kubectl by oncall during business hours.
98
+ decision := { "decision": "requires-approval", "reason": "kubectl by oncall" } {
99
+ input.tool.name == "kubectl"
100
+ input.runtimeContext.role == "sre-oncall"
101
+ }
102
+ ```
103
+
104
+ The adapter also accepts the legacy boolean shape (`{ "allow": true | false, "reason": "..." }`) so existing rules migrate without rewriting.
105
+
106
+ ### Errors fail closed
107
+
108
+ If the backend itself errors (OPA server unreachable, WASM fault, a misbuilt bundle that yields no result), `opaPolicy` returns `{ type: 'denied' }` with the underlying message as the reason. The error never rejects out of the `toolApproval` callback and never aborts the run. A backend outage blocks the affected tool call rather than silently letting it through. This matches `opaCapabilityMiddleware`, which also fails closed.
109
+
110
+ Note this is distinct from a Rego rule that returns no matching decision: that normalizes to `not-applicable` ("no opinion"), which the SDK treats as allow. Use `default decision := { "decision": "deny" }` in your policy if you want unmatched calls to be denied too.
111
+
112
+ ### What the adapter passes as `input`
113
+
114
+ By default, the OPA input shape is:
115
+
116
+ ```jsonc
117
+ {
118
+ "tool": { "name": "git" },
119
+ "args": { "args": ["push", "origin", "main"] },
120
+ "messages": [
121
+ /* model messages for this generation */
122
+ ],
123
+ "runtimeContext": {
124
+ /* whatever you passed as runtimeContext */
125
+ },
126
+ }
127
+ ```
128
+
129
+ Override the shape with `toInput`:
130
+
131
+ ```ts
132
+ opaPolicy({
133
+ client,
134
+ path: 'agent/call/decision',
135
+ toInput: ({ toolCall, runtimeContext }) => ({
136
+ action: toolCall.toolName,
137
+ principal: runtimeContext.role,
138
+ resource: toolCall.input,
139
+ }),
140
+ });
141
+ ```
142
+
143
+ ### Testing the policy
144
+
145
+ OPA ships its own test framework:
146
+
147
+ ```rego
148
+ # policy_test.rego
149
+ package agent.call
150
+
151
+ test_push_denied {
152
+ decision.decision == "deny" with input as {
153
+ "tool": { "name": "git" },
154
+ "args": { "args": ["push", "origin", "main"] }
155
+ }
156
+ }
157
+
158
+ test_status_allowed {
159
+ decision.decision == "allow" with input as {
160
+ "tool": { "name": "git" },
161
+ "args": { "args": ["status"] }
162
+ }
163
+ }
164
+ ```
165
+
166
+ Run `opa test policy.rego policy_test.rego`. These tests run in CI without involving the SDK at all, which is the main practical reason policy-as-code beats policy-in-application-code.
167
+
168
+ ## Loading the policy
169
+
170
+ ### Option A: WASM (in-process)
171
+
172
+ Compile the `.rego` to WASM ahead of time:
173
+
174
+ ```sh
175
+ opa build -t wasm -e 'agent/call/decision' -o bundle.tar.gz policy.rego
176
+ tar -xzf bundle.tar.gz /policy.wasm
177
+ ```
178
+
179
+ Load it at startup:
180
+
181
+ ```ts
182
+ import { wasmPolicyClient, opaPolicy } from '@ai-sdk/policy-opa';
183
+ import { readFile } from 'node:fs/promises';
184
+
185
+ const wasm = await readFile('./policy.wasm');
186
+ const client = await wasmPolicyClient({ wasm });
187
+
188
+ const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });
189
+ ```
190
+
191
+ No network call per decision. Good fit when you ship the policy with the app, or fetch it from object storage at startup. Hot-reloading means rebuilding the WASM and re-instantiating the client.
192
+
193
+ ### Option B: HTTP (running OPA server)
194
+
195
+ Run OPA somewhere:
196
+
197
+ ```yaml
198
+ # docker-compose.yml
199
+ services:
200
+ opa:
201
+ image: openpolicyagent/opa:latest
202
+ command: ['run', '--server', '--addr', ':8181', '/policies']
203
+ ports: ['8181:8181']
204
+ volumes: ['./policies:/policies']
205
+ ```
206
+
207
+ Point the client at it:
208
+
209
+ ```ts
210
+ import { httpPolicyClient, opaPolicy } from '@ai-sdk/policy-opa';
211
+
212
+ const client = httpPolicyClient({ url: 'http://localhost:8181' });
213
+ const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });
214
+ ```
215
+
216
+ One HTTP round-trip per decision. Good fit when policies change frequently and you want hot-reload without redeploying the app, or when multiple services share one OPA. Headers can be supplied for Styra DAS / EOPA authentication:
217
+
218
+ ```ts
219
+ httpPolicyClient({
220
+ url: 'https://opa.internal',
221
+ headers: { Authorization: `Bearer ${token}` },
222
+ });
223
+ ```
224
+
225
+ ## Transitive enforcement: composite tools
226
+
227
+ `toolApproval` only fires when the model calls a tool directly. Anywhere your agent has a coarse "dispatcher" tool that can perform many fine-grained actions, the model can bypass a per-action rule by going through the dispatcher. Classic example: the agent has a granular `git` tool that's gated, plus a coarse `bash` tool that isn't. `bash 'git push'` skips the `git` rule.
228
+
229
+ The fix lives inside the dispatcher's `toolApproval` entry, not inside the tool's `execute`. Parse the dispatcher input down to a `(name, args)` pair, then evaluate it against the same rule the direct tool uses. The granular rule fires whether the model called the granular tool directly or routed through the dispatcher.
230
+
231
+ To avoid writing the matching logic twice (once for the direct tool, once for the dispatcher), define it once and reuse it. Two forms work; pick whichever is easier to maintain for your codebase.
232
+
233
+ ### Form A: shared TypeScript predicate
234
+
235
+ The matching logic lives in TS. Both approvals call the same predicate, varying only in how they extract `(name, args)` from the tool input. No OPA round-trip needed for the shared check, but you lose the policy-as-code authoring story for the rule itself.
236
+
237
+ ```ts
238
+ function deniedForAction(name: string, args: string[]): string | undefined {
239
+ if (name === 'git' && args[0] === 'push') {
240
+ return 'pushes require human review';
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ const toolApproval = ({ toolCall }) => {
246
+ if (toolCall.toolName === 'bash') {
247
+ const [name, ...args] = (
248
+ toolCall.input as { command: string }
249
+ ).command.split(/\s+/);
250
+ const reason = deniedForAction(name, args);
251
+ if (reason) return { type: 'denied', reason };
252
+ }
253
+ if (toolCall.toolName === 'git') {
254
+ const args = (toolCall.input as { args: string[] }).args;
255
+ const reason = deniedForAction('git', args);
256
+ if (reason) return { type: 'denied', reason };
257
+ }
258
+ return 'approved';
259
+ };
260
+
261
+ await generateText({ model, tools: { git, bash }, toolApproval, prompt });
262
+ ```
263
+
264
+ ### Form B: shared Rego helper rule
265
+
266
+ The matching logic lives in Rego. Both approvals call `opaPolicy` with the same `path`, varying only in how their `toInput` derives the `(kind, args)` pair. You get the authoring story (rules live in `.rego`, tested with `opa test`, reviewed independently), at the cost of one OPA evaluation per call.
267
+
268
+ ```rego
269
+ package agent.action
270
+
271
+ # Shared helper: classifies a logical action regardless of which tool surface
272
+ # it arrived on. The dispatcher's toInput is responsible for setting
273
+ # `input.kind` and `input.args` consistently.
274
+ deny_reason["pushes require human review"] {
275
+ input.kind == "git"
276
+ input.args[0] == "push"
277
+ }
278
+
279
+ decision := { "decision": "deny", "reason": r } {
280
+ r := deny_reason[_]
281
+ }
282
+ ```
283
+
284
+ ```ts
285
+ import { generateText } from 'ai';
286
+ import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
287
+
288
+ const client = await wasmPolicyClient({ wasm });
289
+
290
+ const gitApproval = opaPolicy({
291
+ client,
292
+ path: 'agent/action/decision',
293
+ toInput: ({ toolCall }) => ({
294
+ kind: 'git',
295
+ args: (toolCall.input as { args: string[] }).args,
296
+ }),
297
+ });
298
+
299
+ const bashApproval = opaPolicy({
300
+ client,
301
+ path: 'agent/action/decision',
302
+ toInput: ({ toolCall }) => {
303
+ const { command } = toolCall.input as { command: string };
304
+ const [bin, ...rest] = command.split(/\s+/);
305
+ return { kind: bin, args: rest };
306
+ },
307
+ });
308
+
309
+ await generateText({
310
+ model,
311
+ tools: { git, bash },
312
+ toolApproval: {
313
+ git: gitApproval,
314
+ bash: bashApproval,
315
+ },
316
+ prompt,
317
+ });
318
+ ```
319
+
320
+ The patterns below are all the same five lines: derive a logical `(kind, args)` from the dispatcher input, ask OPA, return the SDK status. What changes per domain is only the parsing.
321
+
322
+ ### SQL meta-tool
323
+
324
+ ```ts
325
+ const sqlApproval = opaPolicy({
326
+ client,
327
+ path: 'agent/action/decision',
328
+ toInput: ({ toolCall }) => {
329
+ const sql = (toolCall.input as { sql: string }).sql;
330
+ const verb = sql.trim().split(/\s+/)[0]?.toLowerCase(); // select | insert | delete | drop
331
+ return { kind: `db.${verb}`, sql };
332
+ },
333
+ });
334
+ ```
335
+
336
+ Rules like `input.kind == "db.delete"` or `input.kind == "db.drop"` fire whether the model called those granular tools directly or routed a `DROP TABLE` through `db.query`. For finer matches you can parse the statement with a SQL AST library and emit the affected tables in the input shape.
337
+
338
+ ### HTTP dispatcher
339
+
340
+ ```ts
341
+ const httpApproval = opaPolicy({
342
+ client,
343
+ path: 'agent/action/decision',
344
+ toInput: ({ toolCall }) => {
345
+ const { method, url } = toolCall.input as { method: string; url: string };
346
+ const u = new URL(url);
347
+ return {
348
+ kind: `http.${method.toLowerCase()}`,
349
+ host: u.host,
350
+ path: u.pathname,
351
+ };
352
+ },
353
+ });
354
+ ```
355
+
356
+ Rules can now match by host (`input.host == "api.production.internal"`), by method (`input.kind == "http.delete"`), or by both.
357
+
358
+ ### MCP proxy
359
+
360
+ When an agent talks to an MCP server via a single `mcp.invoke` meta-tool, the server's entire surface arrives as one giant `*`-shaped capability. Narrow it back down inside the proxy's `toolApproval`:
361
+
362
+ ```ts
363
+ const mcpApproval = opaPolicy({
364
+ client,
365
+ path: 'agent/action/decision',
366
+ toInput: ({ toolCall }) => {
367
+ const { name, input } = toolCall.input as { name: string; input: unknown };
368
+ return { kind: `mcp.${name}`, args: input };
369
+ },
370
+ });
371
+ ```
372
+
373
+ A direct alternative is `wrapMcpTools` (below), which expands the MCP surface into per-tool entries so each one gets its own rule lookup; pick whichever model fits the way you discover and dispatch.
374
+
375
+ ### Browser dispatcher
376
+
377
+ ```ts
378
+ const browserApproval = opaPolicy({
379
+ client,
380
+ path: 'agent/action/decision',
381
+ toInput: ({ toolCall }) => {
382
+ const { action, target } = toolCall.input as {
383
+ action: 'click' | 'type' | 'navigate';
384
+ target: string;
385
+ };
386
+ return { kind: `browser.${action}`, target };
387
+ },
388
+ });
389
+ ```
390
+
391
+ ### Shell tool: gating git subcommands
392
+
393
+ A `bash` tool (such as [vercel-labs/bash-tool](https://github.com/vercel-labs/bash-tool), input `{ command }`) can run any git operation, so the model could route a `git clone` or `git push` through it and skip a granular `git` rule. Gate it by parsing the command down to a git subcommand and deciding on that — and **deny anything you cannot reduce to a single clean git invocation**. Bash is adversarial to parse, so "can't prove it's safe" means "deny".
394
+
395
+ ```ts
396
+ // Returns the git invocation, or null if `command` is not a single clean git
397
+ // command. Shell metacharacters (&& || ; | redirects, subshells, substitution)
398
+ // return null so the policy fails closed.
399
+ function parseGitInvocation(command: string) {
400
+ if (/[;&|<>`$(){}\\\n]/.test(command)) return null;
401
+ const tokens = command.trim().split(/\s+/);
402
+ if (tokens[0] !== 'git' || tokens.length < 2) return null;
403
+ const [, subcommand, ...args] = tokens;
404
+ return { subcommand, args };
405
+ }
406
+
407
+ const bashApproval = opaPolicy({
408
+ client,
409
+ path: 'agent/action/decision',
410
+ toInput: ({ toolCall }) => {
411
+ const { command } = toolCall.input as { command: string };
412
+ const git = parseGitInvocation(command);
413
+ // Unparseable / non-git → kind:'bash' → the policy default-denies it.
414
+ return git
415
+ ? { kind: 'git', subcommand: git.subcommand, args: git.args }
416
+ : { kind: 'bash', command };
417
+ },
418
+ });
419
+ ```
420
+
421
+ The matching Rego allows only read-only git and denies the rest by default:
422
+
423
+ ```rego
424
+ package agent.action
425
+
426
+ import rego.v1
427
+
428
+ # Subcommands that are read-only in every form.
429
+ git_read_only := {"status", "log", "diff", "show"}
430
+
431
+ # Default deny covers clone, push, pull, fetch, reset, and every kind:"bash"
432
+ # the parser refused to vouch for.
433
+ default decision := {"decision": "deny", "reason": "command not permitted by policy"}
434
+
435
+ decision := {"decision": "allow"} if {
436
+ input.kind == "git"
437
+ git_read_only[input.subcommand]
438
+ }
439
+
440
+ decision := {"decision": "deny", "reason": msg} if {
441
+ input.kind == "git"
442
+ not git_read_only[input.subcommand]
443
+ msg := sprintf("git %s is not permitted (read-only git only)", [input.subcommand])
444
+ }
445
+ ```
446
+
447
+ Note the allowlist is the four subcommands that are read-only in _every_ form. `git branch` and `git remote` are deliberately left out: `git branch -D` deletes and `git remote update` fetches, so a subcommand-level allowlist is too coarse for them — they need an additional listing-form check on their args. The [`examples/git-in-bash`](./examples/git-in-bash) policy shows that check in full.
448
+
449
+ A granular `git` tool shares the exact same rule, varying only in how it derives the action:
450
+
451
+ ```ts
452
+ const gitApproval = opaPolicy({
453
+ client,
454
+ path: 'agent/action/decision',
455
+ toInput: ({ toolCall }) => {
456
+ const args = (toolCall.input as { args: string[] }).args;
457
+ return { kind: 'git', subcommand: args[0], args: args.slice(1) };
458
+ },
459
+ });
460
+ ```
461
+
462
+ So `git status` is allowed on both surfaces, `git clone …` is denied on both, and `cd /tmp && git clone …` (a compound bash command) is denied because the parser refuses it. A complete, runnable version — `policy.rego`, `policy_test.rego` (run with `opa test`), the parser and its tests, and a `generateText` demo — lives in [`examples/git-in-bash`](./examples/git-in-bash). For a stricter parser, swap the regex for a shell-aware tokenizer (e.g. `shell-quote`).
463
+
464
+ ### The honest limitation
465
+
466
+ This approach gates dispatch at the model's call boundary: the agent asks to run `bash 'git push'`, the policy sees `{ kind: 'git', args: ['push'] }`, the call is denied before `bash.execute` is ever invoked. What it does **not** defend against is a tool that, once approved, performs additional side effects beyond what its input describes. If `bash.execute` runs `command` and then _also_ sends a side-channel HTTP request, no policy at the call boundary can stop it.
467
+
468
+ The framework's job here is to give you one obvious place to write per-action authorization for the cases this design covers. For stronger guarantees against arbitrary side effects, run untrusted execution in an out-of-band sandbox (Vercel Sandbox, Firecracker, containers) and treat the sandbox boundary, not the tool boundary, as the trust frontier.
469
+
470
+ ## Optional policy: allow-all when no policy is configured
471
+
472
+ Most apps load the policy from one source (a WASM bundle, a remote OPA server) and only want enforcement when that source is available. In local development, in CI, in a brand-new environment, you probably want the agent to just work without a policy file present.
473
+
474
+ `optionalOpaPolicy` makes this case a one-liner: pass `client: undefined` and the helper returns `undefined`, which is the same as not passing `toolApproval` at all (the SDK approves every tool call).
475
+
476
+ ```ts
477
+ import { readFile } from 'node:fs/promises';
478
+ import { optionalOpaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
479
+
480
+ const wasm = process.env.POLICY_WASM_PATH
481
+ ? await readFile(process.env.POLICY_WASM_PATH)
482
+ : undefined;
483
+
484
+ const client = wasm ? await wasmPolicyClient({ wasm }) : undefined;
485
+
486
+ const toolApproval = optionalOpaPolicy({
487
+ client,
488
+ path: 'agent/call/decision',
489
+ });
490
+
491
+ await generateText({ model, tools, toolApproval, prompt });
492
+ ```
493
+
494
+ Behavior:
495
+
496
+ - `POLICY_WASM_PATH` unset ➜ `client` is `undefined` ➜ `toolApproval` is `undefined` ➜ SDK allows all tool calls. The OPA modules are never loaded; lazy imports stay lazy.
497
+ - `POLICY_WASM_PATH` set ➜ policy loads, enforcement is on.
498
+
499
+ A symmetric option exists for the HTTP backend: gate `httpPolicyClient({ url })` on whether `OPA_URL` is set.
500
+
501
+ If you want stricter behavior (refuse to start without a policy), construct `opaPolicy` directly and let the missing-bytes case throw at startup. The "optional" framing is only for environments where you've intentionally decided absence means allow-all.
502
+
503
+ ## Rolling out a new policy safely: shadow mode
504
+
505
+ Don't ship a new policy straight to enforce. The first version of any policy almost certainly denies things you didn't mean to deny, and you find out by breaking real agent runs. The fix is the same pattern Cloudflare uses for new rules and GitHub uses for new code-scanning checks: run the policy in **shadow mode** for a while, capture what it _would_ have decided, inspect, fix, then graduate.
506
+
507
+ `shadow(approval, opts)` wraps any `ToolApprovalConfiguration`. The wrapped policy is evaluated normally and the decision is reported via `onDecision`, but the SDK is told the call is approved regardless of what the policy said.
508
+
509
+ ```ts
510
+ import { opaPolicy, shadow, wasmPolicyClient } from '@ai-sdk/policy-opa';
511
+
512
+ const client = await wasmPolicyClient({ wasm });
513
+
514
+ const toolApproval = shadow(
515
+ opaPolicy({ client, path: 'agent/call/decision' }),
516
+ {
517
+ enforce: process.env.ENFORCE_POLICY === 'true',
518
+ onDecision: event => {
519
+ logger.info('policy.decision', {
520
+ tool: event.toolCall.toolName,
521
+ decision: event.decision.type,
522
+ reason: event.decision.reason,
523
+ enforced: event.enforced,
524
+ wouldBlock: event.decision.type === 'denied',
525
+ });
526
+ },
527
+ },
528
+ );
529
+
530
+ await generateText({ model, tools, toolApproval, prompt });
531
+ ```
532
+
533
+ ### Recommended rollout
534
+
535
+ 1. Write the policy. Test it locally with `opa test`.
536
+ 2. Wrap it in `shadow(...)` with `enforce: false` (the default) and an `onDecision` callback wired to your normal log / metrics pipeline.
537
+ 3. Run for a while in your real environment. Inspect events where `decision.type === 'denied'` or `'user-approval'`: these are the calls the policy _would_ have changed.
538
+ 4. Fix anything wrong with the policy. Iterate from step 2.
539
+ 5. When the only `denied` / `user-approval` events are ones you actually want, flip `enforce: true`. The policy is now load-bearing.
540
+
541
+ Each event carries both the policy's verdict (`decision`) and what the SDK actually acted on (`effective`). In shadow mode they disagree whenever the policy returned anything other than approved; in enforce mode they always agree. Compare them in your dashboard to spot drift.
542
+
543
+ ### Telemetry semantics
544
+
545
+ `onDecision` is fired **fire-and-forget**: a slow or throwing logger does not block tool execution and cannot break enforcement. Errors thrown from the callback are swallowed. The contract is "enforcement first, observability second."
546
+
547
+ If you want the opposite (enforcement waits for the audit log to commit), call your logger from inside the underlying `toolApproval` instead of through `shadow`.
548
+
549
+ ## Defense in depth: capability scoping at the model boundary
550
+
551
+ `toolApproval` enforces policy when the model **tries to call** a tool. `opaCapabilityMiddleware` enforces it earlier, before the model is even told the tool exists. This is defense in depth: if a bug or regression lets something slip through the call-time gate, the middleware still prevents the model from seeing the disallowed tool in its first place.
552
+
553
+ ```ts
554
+ import { wrapLanguageModel } from 'ai';
555
+ import { wasmPolicyClient, opaCapabilityMiddleware } from '@ai-sdk/policy-opa';
556
+
557
+ const client = await wasmPolicyClient({ wasm });
558
+
559
+ const wrappedModel = wrapLanguageModel({
560
+ model: anthropic('claude-sonnet-4-5'),
561
+ middleware: opaCapabilityMiddleware({
562
+ client,
563
+ path: 'agent/tools/allowed',
564
+ }),
565
+ });
566
+
567
+ await generateText({ model: wrappedModel, tools, toolApproval, prompt });
568
+ ```
569
+
570
+ The Rego rule at `path` returns either a `string[]` of allowed tool names or `{ tools: string[] }`. Function tools are matched by `name`; provider tools are matched by either their dotted `id` (`<provider>.<tool>`) or their bare `name`, so an allowlist authored with the plain name keeps them. Anything not in the allowlist gets dropped before the model sees the list.
571
+
572
+ Two non-obvious benefits beyond the security one:
573
+
574
+ - **Token savings.** The model doesn't read descriptions for tools it cannot call.
575
+ - **Better jailbreak rejection.** When the model is steered toward a denied tool, it responds with "I don't have access to that" rather than producing a tool call that gets blocked by `toolApproval`, which reads as friendlier in chat UIs.
576
+
577
+ ### Failure mode
578
+
579
+ On a malformed OPA response or an evaluator error, the middleware **fails closed**: `params.tools` is set to `undefined`, so the model is told it has no tools available at all. Misconfiguration should not silently widen the agent's capability surface. If you want fail-open behavior, write the policy fallback in Rego (e.g., a default rule that returns the full tool list) rather than in the middleware.
580
+
581
+ ## Scoping a discovered tool surface
582
+
583
+ When tools come from somewhere external (MCP discovery, a plugin registry, a remote agent catalog) you do not get to write per-tool rules ahead of time. You don't know which tools the server will expose until runtime. The risk: any tool you forgot to write a rule for is silently allowed.
584
+
585
+ `wrapMcpTools` closes that gap by making the resulting `toolApproval` configuration **total** over the discovered surface. Any tool the supplied approval does not match falls through to a configurable default:
586
+
587
+ ```ts
588
+ import { opaPolicy, wasmPolicyClient, wrapMcpTools } from '@ai-sdk/policy-opa';
589
+
590
+ const discovered = await mcpClient.tools();
591
+ const client = await wasmPolicyClient({ wasm });
592
+
593
+ const { tools, toolApproval } = wrapMcpTools(
594
+ discovered,
595
+ opaPolicy({ client, path: 'agent/call/decision' }),
596
+ { default: 'user-approval' }, // anything OPA does not match needs a human
597
+ );
598
+
599
+ await generateText({ model, tools, toolApproval, prompt });
600
+ ```
601
+
602
+ Three useful defaults:
603
+
604
+ - `'user-approval'` (the default): uncovered tools require a human. Right choice when you trust the discovery source but want a safety net for tools you forgot about.
605
+ - `'denied'`: uncovered tools are blocked. Right choice for hard allowlist mode: the OPA policy enumerates what's allowed; everything else is rejected before the model can call it.
606
+ - `'approved'`: uncovered tools are allowed. Right choice only when the discovery source is fully trusted (rare; usually the wrong call for MCP).
607
+
608
+ Despite the name, the helper works on any `Record<string, Tool>`, not just MCP-discovered tools.
609
+
610
+ ## API
611
+
612
+ Everything is exported from the package root, `@ai-sdk/policy-opa`.
613
+
614
+ ### Engine-neutral core
615
+
616
+ - `shadow(approval, opts?)`: wrap any `ToolApprovalConfiguration` so the policy is evaluated and reported via `onDecision`, but the SDK acts as if every call was approved. Flip `opts.enforce: true` to graduate to real enforcement. Recommended starting point for any new policy.
617
+ - `wrapMcpTools(tools, approval, opts?)`: bundle a discovered tool set with a fallback approval policy so the resulting `toolApproval` configuration is total over the discovered surface. `opts.default` controls what happens to tools the supplied approval does not match (`'user-approval'` by default; use `'denied'` for hard allowlist mode).
618
+ - `PolicyClient`: interface implemented by the OPA backends. Use directly if you want to plug in a non-OPA engine.
619
+ - Helper types: `PolicyDecision`, `WrappedMcpTools`, `PolicyDecisionEvent`.
620
+
621
+ ### OPA backend and adapters
622
+
623
+ - `wasmPolicyClient({ wasm, data? })`: async; loads a compiled OPA WASM bundle in-process. Optional `data` is passed to `setData` if the bundle exposes it.
624
+ - `httpPolicyClient({ url, headers? })`: sync; constructs a client against a running OPA server.
625
+ - `opaPolicy({ client, path, toInput? })`: returns a `ToolApprovalConfiguration` you pass to `generateText` / `streamText` / `ToolLoopAgent`. Fails closed (denies) if the backend errors.
626
+ - `optionalOpaPolicy({ client, path, toInput? })`: like `opaPolicy` but returns `undefined` when `client` is `undefined`, for environments where the policy file is optional and absence means allow-all.
627
+ - `opaCapabilityMiddleware({ client, path, toInput? })`: returns a `LanguageModelV4Middleware` that narrows `params.tools` to an OPA-supplied allowlist before the model sees them. Fails closed on malformed responses.
628
+ - `normalizeOpaDecision(result)`: exposed for users who want to call OPA themselves and just need the result normalization.
629
+
630
+ ## Versioning
631
+
632
+ This package follows the AI SDK's release cadence. `peerDependencies` pins `ai` to the workspace version; the OPA backends are versioned independently.
633
+
634
+ ## License
635
+
636
+ Apache-2.0.