Skip to content

Commit 6d2577f

Browse files
dnukumamrasclaudegr2m
committed
feat(policy): add @ai-sdk/policy-opa package (OPA adapter for toolApproval) (#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>
1 parent 69a6346 commit 6d2577f

41 files changed

Lines changed: 3660 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/policy-opa-package.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@ai-sdk/policy-opa': major
3+
---
4+
5+
Introduce `@ai-sdk/policy-opa`, an Open Policy Agent adapter for the
6+
`toolApproval` callback on `generateText` / `streamText` / `ToolLoopAgent`.
7+
8+
Everything is exported from the package root. The engine-neutral core is a
9+
`PolicyClient` interface, `shadow()` for safe policy rollout with
10+
fire-and-forget telemetry, and `wrapMcpTools()` for making approval
11+
configuration total over a discovered tool surface. The OPA layer ships
12+
`opaPolicy` / `optionalOpaPolicy` (Rego-as-code authorization),
13+
`wasmPolicyClient` and `httpPolicyClient` backends (lazy-loaded optional peer
14+
deps), `opaCapabilityMiddleware` for fail-closed model-level tool filtering,
15+
and `normalizeOpaDecision` for users who call OPA themselves.
16+
17+
Sits entirely on top of the public SDK surface, with no changes to `ai`,
18+
`@ai-sdk/provider`, or `@ai-sdk/provider-utils`. Transitive enforcement
19+
(coarse dispatchers like `bash` / `http.request` / MCP proxies) is handled
20+
inside the user's `toolApproval` by parsing the dispatcher input and routing
21+
to the same Rego rule that gates the direct tool.

.github/konsistent.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"!packages/llamaindex",
4141
"!packages/mcp",
4242
"!packages/otel",
43+
"!packages/policy-opa",
4344
"!packages/provider",
4445
"!packages/provider-utils",
4546
"!packages/react",

packages/policy-opa/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# @ai-sdk/policy

0 commit comments

Comments
 (0)