feat(policy): add @ai-sdk/policy-opa package (OPA adapter for toolApproval)#15732
Merged
Conversation
dnukumamras
force-pushed
the
dnukumamras/policy-package-prompt-context
branch
from
June 1, 2026 01:09
80682ed to
3207233
Compare
dnukumamras
force-pushed
the
dnukumamras/policy-package-prompt-context
branch
from
June 1, 2026 01:13
3207233 to
695144d
Compare
…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>
dnukumamras
force-pushed
the
dnukumamras/policy-package-prompt-context
branch
from
June 1, 2026 14:55
695144d to
2710983
Compare
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>
gr2m
reviewed
Jun 3, 2026
Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
gr2m
approved these changes
Jun 3, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new package,
@ai-sdk/policy-opa(packages/policy-opa/), that adapts Open Policy Agent policies to the AI SDK's publictoolApprovalcallback ongenerateText/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 astoolApproval. Same wire format as built-in approvals; nothing new on the wire.This supersedes the earlier
policy-engine-featureprototype, which added SDK-side plumbing (PolicyCheckeronToolExecutionOptions,buildPolicyCheckeringenerate-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— theevaluate(path, input)interface every backend implements. The seam for non-OPA engines (Cedar, OpenFGA) later.shadow(approval, { enforce?, onDecision? })— wrap anyToolApprovalConfigurationso the policy is evaluated and reported viaonDecision, but the SDK is told "approved" until you flipenforce: true. Telemetry is fire-and-forget; a slow or throwingonDecisioncan'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 (defaultuser-approval), so a tool you forgot to write a rule for is never silently allowed.OPA backend and adapters:
opaPolicy({ client, path, toInput? })— returns aToolApprovalConfiguration. 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 returnsundefinedwhenclientis undefined, so environments without a configured policy fall back to allow-all.wasmPolicyClient({ wasm, data? })andhttpPolicyClient({ url, headers? })— backends wrapping@open-policy-agent/opa-wasmand@open-policy-agent/opa. Both optional peer deps, lazy-imported, with a clear install-me error if absent.opaCapabilityMiddleware({ client, path, toInput? })— aLanguageModelV4MiddlewarewhosetransformParamsnarrowsparams.toolsto 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
bashtool that can rungit push, an HTTP tool, an MCP proxy) are handled inside the user'stoolApproval: 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: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
StubPolicyClientstands in for any OPA backend; lazy-import failures are exercised via mocked module resolution).src/opa/opa-policy.integration.test.ts) drives the full path throughgenerateText+MockLanguageModelV3: allow executes the tool, deny skips it and surfaces anexecution-deniedresult, and abash-dispatchedgit pushis caught by the same rule viatoInput.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 (usesObject.assign(new Error(msg), { cause }), not theErrorcauseconstructor option).Package layout
Mirrors the standard
@ai-sdk/*layout: ESM-only,sideEffects: false, tsup build with a single.entry, dualvitest.{node,edge}.config.js,tsconfigextending@vercel/ai-tsconfig. Peer dep onai; optional peer deps on the two@open-policy-agent/*backends viapeerDependenciesMeta. Internally, OPA-specific code lives undersrc/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-linetsconfig.jsonreference, the regeneratedpnpm-lock.yaml, and apatchchangeset.🤖 Generated with Claude Code