Commit 6d2577f
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
File tree
- .changeset
- .github
- packages/policy-opa
- examples
- git-in-bash
- mock
- src
- opa
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| 43 | + | |
43 | 44 | | |
44 | 45 | | |
45 | 46 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
0 commit comments