Skip to content

feat(policy): add @ai-sdk/policy-opa package (OPA adapter for toolApproval)#15732

Merged
gr2m merged 9 commits into
mainfrom
dnukumamras/policy-package-prompt-context
Jun 3, 2026
Merged

feat(policy): add @ai-sdk/policy-opa package (OPA adapter for toolApproval)#15732
gr2m merged 9 commits into
mainfrom
dnukumamras/policy-package-prompt-context

Conversation

@dnukumamras

@dnukumamras dnukumamras commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new package, @ai-sdk/policy-opa (packages/policy-opa/), that adapts Open Policy Agent policies to the AI SDK's public toolApproval callback on generateText / streamText / ToolLoopAgent.

Write your "what can this agent do?" rules in .rego, load them as WASM or via a running OPA server, and pass the result as toolApproval. Same wire format as built-in approvals; nothing new on the wire.

Design constraint: the package sits entirely on top of the existing public API. There are zero edits to packages/ai, packages/provider, or packages/provider-utils. It uses only the public toolApproval callback and its runtimeContext arg.

This supersedes the earlier policy-engine-feature prototype, which added SDK-side plumbing (PolicyChecker on ToolExecutionOptions, buildPolicyChecker in generate-text.ts / execute-tools-from-stream.ts). All of that is walked back here; the same external value is delivered as a plugin on the published surface.

What's included

Everything is exported from the package root, @ai-sdk/policy-opa (single entrypoint).

Engine-neutral core:

  • PolicyClient — the evaluate(path, input) interface every backend implements. The seam for non-OPA engines (Cedar, OpenFGA) later.
  • shadow(approval, { enforce?, onDecision? }) — wrap any ToolApprovalConfiguration so the policy is evaluated and reported via onDecision, but the SDK is told "approved" until you flip enforce: true. Telemetry is fire-and-forget; a slow or throwing onDecision can't block or break enforcement.
  • wrapMcpTools(tools, approval, opts?) — make an approval total over a discovered tool surface (MCP, plugin registries) with a configurable fallback (default user-approval), so a tool you forgot to write a rule for is never silently allowed.

OPA backend and adapters:

  • opaPolicy({ client, path, toInput? }) — returns a ToolApprovalConfiguration. Default OPA input is { tool: { name }, args, messages, runtimeContext }; Rego emits { decision: 'allow' | 'deny' | 'requires-approval', reason? }, normalized to the SDK's status union. Legacy { allow: bool, reason } also accepted.
  • optionalOpaPolicy(...) — same, but returns undefined when client is undefined, so environments without a configured policy fall back to allow-all.
  • wasmPolicyClient({ wasm, data? }) and httpPolicyClient({ url, headers? }) — backends wrapping @open-policy-agent/opa-wasm and @open-policy-agent/opa. Both optional peer deps, lazy-imported, with a clear install-me error if absent.
  • opaCapabilityMiddleware({ client, path, toInput? }) — a LanguageModelV4Middleware whose transformParams narrows params.tools to an OPA allowlist before the model sees them. Fails closed on errors / malformed responses.
  • normalizeOpaDecision(result) — standalone, for users who call OPA themselves.

Transitive enforcement

Coarse dispatcher tools (a bash tool that can run git push, an HTTP tool, an MCP proxy) are handled inside the user's toolApproval: parse the dispatcher input down to a logical (name, args) pair and route it to the same Rego rule that gates the direct tool. The README documents two DRY forms so the matching logic lives in one place:

  • a shared TypeScript predicate, and
  • a shared Rego helper rule.

Worked examples for SQL, HTTP, MCP, browser, and shell dispatchers are included, along with an honest scope note: the framework can't force a tool author to write the check, so out-of-band sandboxing remains the only hard guarantee against arbitrary side effects.

Testing & verification

  • 54 unit + integration tests, passing on both node and edge runtimes, with zero external services (a StubPolicyClient stands in for any OPA backend; lazy-import failures are exercised via mocked module resolution).
  • An end-to-end integration test (src/opa/opa-policy.integration.test.ts) drives the full path through generateText + MockLanguageModelV3: allow executes the tool, deny skips it and surfaces an execution-denied result, and a bash-dispatched git push is caught by the same rule via toInput.
  • A runnable example at examples/mock/basic.ts (pnpm tsx examples/mock/basic.ts) prints the allow / deny / transitive-deny paths end to end. Provider-agnostic — one-line swap from the mock model to a real provider.
  • pnpm build, pnpm type-check, and lint are clean. ES2018 target (uses Object.assign(new Error(msg), { cause }), not the Error cause constructor option).

Package layout

Mirrors the standard @ai-sdk/* layout: ESM-only, sideEffects: false, tsup build with a single . entry, dual vitest.{node,edge}.config.js, tsconfig extending @vercel/ai-tsconfig. Peer dep on ai; optional peer deps on the two @open-policy-agent/* backends via peerDependenciesMeta. Internally, OPA-specific code lives under src/opa/, kept separate from the engine-neutral core, but all of it is re-exported from the package root.

The only changes outside packages/policy-opa/ are the one-line tsconfig.json reference, the regenerated pnpm-lock.yaml, and a patch changeset.

🤖 Generated with Claude Code

Comment thread packages/policy-opa/src/opa/opa-capability-middleware.ts
…roval)

Introduces a new monorepo package at packages/policy-opa that adapts Open
Policy Agent policies to the AI SDK's public toolApproval callback. The
package sits entirely on top of the published surface; no edits to
packages/ai, packages/provider, or packages/provider-utils.

Everything is exported from the package root. The engine-neutral core is a
PolicyClient interface, shadow() for safe policy rollout with fire-and-forget
telemetry, and wrapMcpTools() for making approval configuration total over a
discovered tool surface. The OPA layer ships opaPolicy / optionalOpaPolicy
(Rego-as-code), the wasmPolicyClient and httpPolicyClient backends
(lazy-loaded optional peer deps), opaCapabilityMiddleware for fail-closed
model-level tool filtering, and normalizeOpaDecision for users who call OPA
themselves.

Transitive enforcement (coarse dispatchers like bash / http.request /
MCP proxies) is handled inside the user's toolApproval by parsing the
dispatcher input and routing to the same Rego rule that gates the
direct tool. README documents two DRY forms: a shared TS predicate and
a shared Rego helper rule.

Includes an end-to-end integration test (generateText +
MockLanguageModelV3) plus a runnable example at examples/mock/basic.ts
that demonstrates allow / deny / transitive-deny paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds examples/git-in-bash: a real, runnable transitive-enforcement policy that
allows only read-only git (status, log, diff, show, branch, remote -v) and
denies everything else (clone, push, remote mutations) whether the model calls
a granular git tool or shells out via bash (vercel-labs/bash-tool, { command }).

- policy.rego + policy_test.rego (8/8 via `opa test`), read-only allowlist with
  default-deny.
- parse-git-invocation.ts + tests: a fail-closed command parser that maps any
  command it can't reduce to a single clean git invocation to kind:"bash", which
  the policy default-denies. Compound/piped/substituted commands are denied.
- git-in-bash.ts: runnable generateText demo over httpPolicyClient.

Also expands the README's transitive-enforcement section into this worked
example and aligns the shell examples to bash-tool's real { command } input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
policy-opa is a toolApproval policy adapter, not a model provider, so it does
not export the createX / VERSION / XProvider / instance symbols the
provider-package rule requires. Exclude it alongside the other non-provider
@ai-sdk packages (mcp, otel, valibot, devtools, ...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dleware

opaCapabilityMiddleware matched provider tools only by their dotted `id`, so a
fail-closed allowlist authored with a provider tool's bare `name` silently
dropped it. Match provider tools by either `id` or `name` (function tools are
still matched by `name`). Adds tests for both id- and name-based allowlisting.

Addresses PR review on packages/policy-opa/src/opa/opa-capability-middleware.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- example policy.rego: git branch/remote are read-only only in listing form.
  Previously `git remote update` (fetches) and `git branch -D` (deletes) were
  allowed because the allowlist was subcommand-level only, contradicting the
  example's "read-only git" claim. Now requires a listing form (no args / a
  single listing flag) for branch and remote; everything else default-denies.
  Adds tests for the branch-delete and remote-update deny paths.
- wasm-policy-client: guard evaluate() with Array.isArray so a misbehaving
  bundle returning a non-array fails closed with the clear "produced no result"
  error instead of an opaque TypeError.
- opa-policy errorMessage: JSON-stringify non-Error throws so deny reasons carry
  detail instead of "[object Object]".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e ternary

- Extract evaluatePolicy() (errors-as-values) so the "client.evaluate never
  throws past the package boundary" invariant lives in one place. opaPolicy and
  opaCapabilityMiddleware both call it instead of each wrapping evaluate in
  their own try/catch; each still maps the error to its own fallback (deny /
  drop tools). Behavior unchanged.
- Flatten the nested ternary in the git-in-bash example's report() to an
  if/else cascade for readability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread .changeset/policy-opa-package.md Outdated
gr2m added 2 commits June 3, 2026 12:23
@gr2m
gr2m merged commit a94c258 into main Jun 3, 2026
49 checks passed
@gr2m
gr2m deleted the dnukumamras/policy-package-prompt-context branch June 3, 2026 19:55
gr2m added a commit that referenced this pull request Jun 5, 2026
…roval) (#15732)

## Summary

Adds a new package, **`@ai-sdk/policy-opa`** (`packages/policy-opa/`),
that adapts [Open Policy Agent](https://www.openpolicyagent.org/)
policies to the AI SDK's public `toolApproval` callback on
`generateText` / `streamText` / `ToolLoopAgent`.

Write your "what can this agent do?" rules in `.rego`, load them as WASM
or via a running OPA server, and pass the result as `toolApproval`. Same
wire format as built-in approvals; nothing new on the wire.

> **Design constraint:** the package sits entirely on top of the
**existing public API**. There are **zero edits** to `packages/ai`,
`packages/provider`, or `packages/provider-utils`. It uses only the
public `toolApproval` callback and its `runtimeContext` arg.

This supersedes the earlier `policy-engine-feature` prototype, which
added SDK-side plumbing (`PolicyChecker` on `ToolExecutionOptions`,
`buildPolicyChecker` in `generate-text.ts` /
`execute-tools-from-stream.ts`). All of that is walked back here; the
same external value is delivered as a plugin on the published surface.

## What's included

Everything is exported from the package root, **`@ai-sdk/policy-opa`**
(single entrypoint).

**Engine-neutral core:**
- `PolicyClient` — the `evaluate(path, input)` interface every backend
implements. The seam for non-OPA engines (Cedar, OpenFGA) later.
- `shadow(approval, { enforce?, onDecision? })` — wrap any
`ToolApprovalConfiguration` so the policy is evaluated and reported via
`onDecision`, but the SDK is told "approved" until you flip `enforce:
true`. Telemetry is fire-and-forget; a slow or throwing `onDecision`
can't block or break enforcement.
- `wrapMcpTools(tools, approval, opts?)` — make an approval total over a
discovered tool surface (MCP, plugin registries) with a configurable
fallback (default `user-approval`), so a tool you forgot to write a rule
for is never silently allowed.

**OPA backend and adapters:**
- `opaPolicy({ client, path, toInput? })` — returns a
`ToolApprovalConfiguration`. Default OPA input is `{ tool: { name },
args, messages, runtimeContext }`; Rego emits `{ decision: 'allow' |
'deny' | 'requires-approval', reason? }`, normalized to the SDK's status
union. Legacy `{ allow: bool, reason }` also accepted.
- `optionalOpaPolicy(...)` — same, but returns `undefined` when `client`
is undefined, so environments without a configured policy fall back to
allow-all.
- `wasmPolicyClient({ wasm, data? })` and `httpPolicyClient({ url,
headers? })` — backends wrapping `@open-policy-agent/opa-wasm` and
`@open-policy-agent/opa`. Both **optional peer deps**, lazy-imported,
with a clear install-me error if absent.
- `opaCapabilityMiddleware({ client, path, toInput? })` — a
`LanguageModelV4Middleware` whose `transformParams` narrows
`params.tools` to an OPA allowlist before the model sees them. **Fails
closed** on errors / malformed responses.
- `normalizeOpaDecision(result)` — standalone, for users who call OPA
themselves.

## Transitive enforcement

Coarse dispatcher tools (a `bash` tool that can run `git push`, an HTTP
tool, an MCP proxy) are handled **inside the user's `toolApproval`**:
parse the dispatcher input down to a logical `(name, args)` pair and
route it to the same Rego rule that gates the direct tool. The README
documents two DRY forms so the matching logic lives in one place:
- a **shared TypeScript predicate**, and
- a **shared Rego helper rule**.

Worked examples for SQL, HTTP, MCP, browser, and shell dispatchers are
included, along with an honest scope note: the framework can't force a
tool author to write the check, so out-of-band sandboxing remains the
only hard guarantee against arbitrary side effects.

## Testing & verification

- **54 unit + integration tests**, passing on both **node and edge**
runtimes, with **zero external services** (a `StubPolicyClient` stands
in for any OPA backend; lazy-import failures are exercised via mocked
module resolution).
- An **end-to-end integration test**
(`src/opa/opa-policy.integration.test.ts`) drives the full path through
`generateText` + `MockLanguageModelV3`: allow executes the tool, deny
skips it and surfaces an `execution-denied` result, and a
`bash`-dispatched `git push` is caught by the same rule via `toInput`.
- A **runnable example** at `examples/mock/basic.ts` (`pnpm tsx
examples/mock/basic.ts`) prints the allow / deny / transitive-deny paths
end to end. Provider-agnostic — one-line swap from the mock model to a
real provider.
- `pnpm build`, `pnpm type-check`, and lint are clean. ES2018 target
(uses `Object.assign(new Error(msg), { cause })`, not the `Error`
`cause` constructor option).

## Package layout

Mirrors the standard `@ai-sdk/*` layout: ESM-only, `sideEffects: false`,
tsup build with a single `.` entry, dual `vitest.{node,edge}.config.js`,
`tsconfig` extending `@vercel/ai-tsconfig`. Peer dep on `ai`; optional
peer deps on the two `@open-policy-agent/*` backends via
`peerDependenciesMeta`. Internally, OPA-specific code lives under
`src/opa/`, kept separate from the engine-neutral core, but all of it is
re-exported from the package root.

The only changes outside `packages/policy-opa/` are the one-line
`tsconfig.json` reference, the regenerated `pnpm-lock.yaml`, and a
`patch` changeset.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
gr2m pushed a commit that referenced this pull request Jun 9, 2026
Follow-up to #15732 (which added the `@ai-sdk/policy-opa` package). That
PR shipped the package and its README; this PR adds the corresponding
docs on ai-sdk.dev.

## What's added

New page **`content/docs/03-agents/06-policy-tool-approvals.mdx`**
(Policy-Based Tool Approvals), covering:

- How `@ai-sdk/policy-opa` plugs OPA policies into the existing
`toolApproval` callback (nothing new on the wire).
- **What you can enforce**: it is a generic policy engine (security,
business rules, cost/usage, compliance). Calls out the sweet spot:
deterministic, verifiable checks (scopes, permissions, thresholds,
allowlists, history-aware counts) over best-effort content/semantic
filtering. Notes that the policy `input` includes the full tool-call
history (`messages`).
- Install, how it works, quick start, writing and testing the Rego
policy.
- Loading via WASM (in-process) or HTTP (running OPA server).
- **External data and integrations**: defers to OPA's own docs for
bundles, push data, and lookups (e.g. connecting to an IDP).
- **Shadow-mode rollout** and **sending decisions to your observability
platform** (including OPA decision logs).
- Capability scoping at the model boundary, scoping a discovered MCP
surface, allow-all when no policy is configured, and transitive
enforcement for composite/dispatcher tools.
- Links the [ai-sdk-slackbot
example](https://github.com/vercel-labs/ai-sdk-slackbot) as an
end-to-end app.

Cross-links added from the Agents index (`index.mdx`) and the existing
Tool Approvals page (`06-tool-approvals.mdx`).

## Notes

- Docs-only; no changeset (not a published package).
- OPA links verified against OPA docs (policy reference, external data,
decision logs).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants