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

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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shadow.ts","../src/wrap-mcp-tools.ts","../src/opa/http-policy-client.ts","../src/opa/normalize-opa-decision.ts","../src/opa/evaluate-policy.ts","../src/opa/opa-capability-middleware.ts","../src/opa/opa-policy.ts","../src/opa/wasm-policy-client.ts"],"sourcesContent":["import type {\n Context,\n ModelMessage,\n Tool,\n ToolSet,\n} from '@ai-sdk/provider-utils';\nimport type { ToolApprovalConfiguration } from 'ai';\nimport type { PolicyDecision } from './policy-decision';\n\ninterface GenericApprovalArgs {\n toolCall: { toolName: string; toolCallId: string; input: unknown };\n tools: unknown;\n toolsContext: unknown;\n runtimeContext: unknown;\n messages: ModelMessage[];\n}\n\n/**\n * Event emitted by {@link shadow} every time the wrapped policy is evaluated.\n *\n * Compare `decision` and `effective` to see what would have happened\n * differently if the policy were enforcing: any record where they disagree\n * is a tool call that shadow mode is letting through but enforce mode would\n * have blocked or escalated.\n */\nexport interface PolicyDecisionEvent {\n /** Identifying info from the tool call being evaluated. */\n toolCall: { toolName: string; toolCallId: string; input: unknown };\n /** The decision the wrapped policy returned, normalized to the object form. */\n decision: PolicyDecision;\n /** Whether the SDK will act on `decision` (true) or override to allow (false). */\n enforced: boolean;\n /** The decision the SDK actually acts on. Equals `decision` when enforcing,\n * `{ type: 'approved' }` in shadow mode. */\n effective: PolicyDecision;\n /** ISO 8601 timestamp of the evaluation. */\n timestamp: string;\n}\n\n/**\n * Wrap a `toolApproval` in shadow mode so the policy is evaluated and the\n * decision is reported via `onDecision`, but the SDK is told the call is\n * approved regardless of what the policy said.\n *\n * Use this when you are rolling out a new policy and want to see what it\n * *would* deny before letting it actually deny anything in production. Wire\n * `onDecision` to your logger / metrics pipeline, run for a while, inspect\n * the events where `decision.type !== 'approved'`, fix the policy, then\n * flip `enforce: true` to graduate.\n *\n * @example\n * ```ts\n * import { shadow } from '@ai-sdk/policy-opa';\n * import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';\n *\n * const client = await wasmPolicyClient({ wasm });\n *\n * const toolApproval = shadow(\n * opaPolicy({ client, path: 'agent/call/decision' }),\n * {\n * enforce: process.env.ENFORCE_POLICY === 'true',\n * onDecision: (event) => {\n * logger.info('policy.decision', {\n * tool: event.toolCall.toolName,\n * decision: event.decision.type,\n * enforced: event.enforced,\n * wouldBlock: event.decision.type === 'denied',\n * });\n * },\n * },\n * );\n * ```\n */\nexport function shadow<\n TOOLS extends Record<string, Tool>,\n RUNTIME_CONTEXT extends Context | unknown | never = unknown,\n>(\n approval: ToolApprovalConfiguration<TOOLS & ToolSet, RUNTIME_CONTEXT>,\n opts: {\n /** When true, the SDK acts on the policy's decision. When false (default),\n * the SDK is told every call is approved. */\n enforce?: boolean;\n /** Invoked once per evaluated tool call with the captured decision. */\n onDecision?: (event: PolicyDecisionEvent) => void | Promise<void>;\n } = {},\n): ToolApprovalConfiguration<TOOLS & ToolSet, RUNTIME_CONTEXT> {\n const enforce = opts.enforce === true;\n const { onDecision } = opts;\n\n const wrapped = async (args: GenericApprovalArgs) => {\n const raw = await evaluateApproval(approval, args);\n const decision = normalizePolicyDecision(raw);\n\n const effective: PolicyDecision = enforce ? decision : { type: 'approved' };\n\n if (onDecision) {\n const event: PolicyDecisionEvent = {\n toolCall: {\n toolName: args.toolCall.toolName,\n toolCallId: args.toolCall.toolCallId,\n input: args.toolCall.input,\n },\n decision,\n enforced: enforce,\n effective,\n timestamp: new Date().toISOString(),\n };\n // Fire-and-forget so a slow or throwing logger does not block the model.\n void (async () => {\n try {\n await onDecision(event);\n } catch {\n // Swallow errors from telemetry; enforcement must not depend on it.\n }\n })();\n }\n\n return effective;\n };\n\n // `wrapped` is typed against the erased `GenericApprovalArgs`; TS can't prove\n // that matches the SDK's per-tool-set function arm, so bridge it here.\n return wrapped as unknown as ToolApprovalConfiguration<\n TOOLS & ToolSet,\n RUNTIME_CONTEXT\n >;\n}\n\nasync function evaluateApproval(\n approval: unknown,\n args: GenericApprovalArgs,\n): Promise<unknown> {\n if (typeof approval === 'function') {\n return await (\n approval as (a: GenericApprovalArgs) => Promise<unknown> | unknown\n )(args);\n }\n\n const map = approval as Record<string, unknown>;\n const perTool = map[args.toolCall.toolName];\n if (perTool == null) return undefined;\n if (typeof perTool === 'function') {\n return await (\n perTool as (\n input: unknown,\n options: {\n toolCallId: string;\n messages: ModelMessage[];\n toolContext: unknown;\n runtimeContext: unknown;\n },\n ) => Promise<unknown> | unknown\n )(args.toolCall.input, {\n toolCallId: args.toolCall.toolCallId,\n messages: args.messages,\n toolContext: undefined,\n runtimeContext: args.runtimeContext,\n });\n }\n return perTool;\n}\n\nfunction normalizePolicyDecision(status: unknown): PolicyDecision {\n if (status == null) return { type: 'not-applicable' };\n if (typeof status === 'string') {\n if (\n status === 'approved' ||\n status === 'denied' ||\n status === 'user-approval' ||\n status === 'not-applicable'\n ) {\n return { type: status };\n }\n return { type: 'not-applicable' };\n }\n if (typeof status === 'object' && 'type' in (status as object)) {\n return status as PolicyDecision;\n }\n return { type: 'not-applicable' };\n}\n","import type { Context, Tool } from '@ai-sdk/provider-utils';\nimport type { ToolApprovalConfiguration, ToolApprovalStatus } from 'ai';\n\ntype ApprovalLiteralStatus = Extract<ToolApprovalStatus, string>;\n\n/**\n * Result returned by {@link wrapMcpTools}: the original tool set plus a\n * `toolApproval` that falls back to a configurable default for any tool the\n * supplied approval does not explicitly handle.\n */\nexport interface WrappedMcpTools<\n TOOLS extends Record<string, Tool>,\n RUNTIME_CONTEXT extends Context | unknown | never,\n> {\n tools: TOOLS;\n toolApproval: ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT>;\n}\n\n/**\n * Apply a fallback approval policy to a discovered tool set so the resulting\n * configuration is total: every tool in `tools` is gated either by the\n * supplied `approval` (when it has an opinion) or by `default` (otherwise).\n *\n * The motivating case is MCP. An MCP server hands you whatever tools it\n * exposes, and the agent's effective permissions become the union of the\n * full server surface. Without a fallback, any tool you forgot to write a\n * rule for is silently allowed. This helper closes that gap by forcing every\n * uncovered tool through `default` (which defaults to `user-approval` so the\n * human is in the loop for anything you didn't think about).\n *\n * Works for any tool set, not just MCP-discovered tools. The name reflects\n * the primary use case rather than a hard constraint.\n *\n * @example\n * ```ts\n * import { wrapMcpTools } from '@ai-sdk/policy-opa';\n * import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';\n *\n * const discovered = await mcpClient.tools();\n * const client = await wasmPolicyClient({ wasm });\n *\n * const { tools, toolApproval } = wrapMcpTools(\n * discovered,\n * opaPolicy({ client, path: 'agent/call/decision' }),\n * { default: 'user-approval' }, // anything OPA does not match needs a human\n * );\n *\n * await generateText({ model, tools, toolApproval, prompt });\n * ```\n */\nexport function wrapMcpTools<\n TOOLS extends Record<string, Tool>,\n RUNTIME_CONTEXT extends Context | unknown | never = unknown,\n>(\n tools: TOOLS,\n approval: ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT>,\n opts?: { default?: ApprovalLiteralStatus },\n): WrappedMcpTools<TOOLS, RUNTIME_CONTEXT> {\n const fallback: ApprovalLiteralStatus = opts?.default ?? 'user-approval';\n\n if (typeof approval === 'function') {\n const wrapped: ToolApprovalConfiguration<\n TOOLS,\n RUNTIME_CONTEXT\n > = async args => {\n const status = await (\n approval as (a: typeof args) => Promise<unknown> | unknown\n )(args);\n return isNotApplicable(status) ? fallback : (status as never);\n };\n return { tools, toolApproval: wrapped };\n }\n\n // Per-tool map form: fill in any tool that the user did not list with the\n // fallback decision. We use `Object.keys(tools)` (not the approval keys) so\n // the result is total over the discovered surface.\n const filled = {} as Record<keyof TOOLS, unknown>;\n for (const name of Object.keys(tools) as Array<keyof TOOLS>) {\n filled[name] = approval[name] ?? fallback;\n }\n\n // `filled` is a plain per-tool map; cast to the SDK's map arm, which TS\n // can't infer from the erased `unknown` values.\n return {\n tools,\n toolApproval: filled as ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT>,\n };\n}\n\nfunction isNotApplicable(status: unknown): boolean {\n if (status == null) return true;\n if (status === 'not-applicable') return true;\n if (typeof status === 'object' && status !== null) {\n return (status as { type?: unknown }).type === 'not-applicable';\n }\n return false;\n}\n","import type { PolicyClient } from '../policy-client';\n\n/**\n * Construct a {@link PolicyClient} that talks to a running OPA server over\n * HTTP using `@open-policy-agent/opa`.\n *\n * The `@open-policy-agent/opa` package is an optional peer dependency; install\n * it before using this client:\n *\n * ```sh\n * pnpm add @open-policy-agent/opa\n * ```\n *\n * The `url` typically points at `http://localhost:8181` for a locally running\n * OPA. `headers` is forwarded for Styra DAS / EOPA authentication.\n */\nexport function httpPolicyClient(opts: {\n url: string;\n headers?: Record<string, string>;\n}): PolicyClient {\n const { url, headers } = opts;\n type Underlying = {\n evaluate(path: string, input: unknown): Promise<unknown>;\n };\n type OPAModule = {\n OPAClient: new (\n url: string,\n opts?: { headers?: Record<string, string> },\n ) => Underlying;\n };\n\n let underlying: Underlying | undefined;\n\n async function getUnderlying(): Promise<Underlying> {\n if (underlying) return underlying;\n let mod: OPAModule;\n try {\n mod = (await import('@open-policy-agent/opa')) as unknown as OPAModule;\n } catch (cause) {\n throw Object.assign(\n new Error(\n 'Cannot import \"@open-policy-agent/opa\". Install it as a peer dependency to use httpPolicyClient().',\n ),\n { cause },\n );\n }\n underlying = new mod.OPAClient(url, headers ? { headers } : undefined);\n return underlying;\n }\n\n return {\n async evaluate(path, input) {\n const client = await getUnderlying();\n return (await client.evaluate(path, input)) as never;\n },\n };\n}\n","import type { PolicyDecision } from '../policy-decision';\n\n/**\n * Normalize an OPA evaluation result into the package's {@link PolicyDecision}\n * shape.\n *\n * Supports two Rego output conventions:\n *\n * - **Recommended (explicit):** `{ \"decision\": \"allow\" | \"deny\" | \"requires-approval\", \"reason\": string }`.\n * Maps to `approved` / `denied` / `user-approval` respectively.\n *\n * - **Legacy (boolean):** `{ \"allow\": boolean, \"reason\"?: string }`. `true`\n * maps to `approved`, `false` to `denied`.\n *\n * Unknown shapes and `undefined` are treated as `not-applicable` so that a\n * Rego rule that does not match any branch defaults to \"no opinion\" rather\n * than blocking.\n */\nexport function normalizeOpaDecision(result: unknown): PolicyDecision {\n if (result == null) {\n return { type: 'not-applicable' };\n }\n\n if (typeof result !== 'object') {\n return { type: 'not-applicable' };\n }\n\n const record = result as Record<string, unknown>;\n const reason = typeof record.reason === 'string' ? record.reason : undefined;\n\n if (typeof record.decision === 'string') {\n switch (record.decision) {\n case 'allow':\n return withReason('approved', reason);\n case 'deny':\n return withReason('denied', reason);\n case 'requires-approval':\n return { type: 'user-approval' };\n case 'not-applicable':\n return { type: 'not-applicable' };\n }\n }\n\n if (typeof record.allow === 'boolean') {\n return withReason(record.allow ? 'approved' : 'denied', reason);\n }\n\n return { type: 'not-applicable' };\n}\n\nfunction withReason(\n type: 'approved' | 'denied',\n reason: string | undefined,\n): PolicyDecision {\n return reason ? { type, reason } : { type };\n}\n","import type { PolicyClient } from '../policy-client';\n\nexport type EvaluateOutcome =\n | { ok: true; result: unknown }\n | { ok: false; error: unknown };\n\n/**\n * Evaluate a policy, capturing a backend error (OPA unreachable, WASM fault,\n * bad path) as a value instead of letting it throw. This is the package's\n * fail-closed invariant in one place: a thrown `client.evaluate` must never\n * escape into the SDK callback or middleware, where it would abort the run.\n * Each caller turns `ok: false` into its own safe fallback (deny / no tools).\n */\nexport async function evaluatePolicy(\n client: PolicyClient,\n path: string,\n input: unknown,\n): Promise<EvaluateOutcome> {\n try {\n return { ok: true, result: await client.evaluate(path, input) };\n } catch (error) {\n return { ok: false, error };\n }\n}\n","import type {\n LanguageModelV4CallOptions,\n LanguageModelV4Middleware,\n} from '@ai-sdk/provider';\nimport type { PolicyClient } from '../policy-client';\nimport { evaluatePolicy } from './evaluate-policy';\n\n/**\n * Default OPA input shape passed to the capability-scoping rule.\n * Override with `toInput` if your Rego expects a different schema.\n */\nexport interface DefaultOpaCapabilityInput {\n messages: LanguageModelV4CallOptions['prompt'];\n providerOptions: LanguageModelV4CallOptions['providerOptions'];\n}\n\n/**\n * Construct an experimental {@link LanguageModelV4Middleware} that narrows\n * the `tools` field on every model call to an allowlist returned by OPA.\n *\n * Why use this alongside the per-call `toolApproval` gate? Two reasons:\n *\n * 1. **Defense in depth.** If a bug or regression slips through the policy\n * used by `toolApproval`, the middleware still prevents the model from\n * seeing the disallowed tools in the first place.\n * 2. **Capability disclosure.** The model does not waste tokens describing\n * tools it cannot call, and jailbreak attempts get \"I don't have access\n * to that tool\" rather than \"[approval denied]\".\n *\n * The OPA rule at `path` is expected to return either a `string[]` of allowed\n * tool names, or an object `{ tools: string[] }`. Function tools whose `name`\n * matches are kept; provider tools whose dotted `id` or bare `name` matches are\n * kept; everything else is dropped from `params.tools`.\n *\n * On a malformed result (or an OPA error), the middleware **fails closed**:\n * `params.tools` is set to `undefined`, so the model is told it has no tools\n * available. Misconfiguration should not silently widen capabilities.\n *\n * @example\n * ```ts\n * import { wrapLanguageModel } from 'ai';\n * import { wasmPolicyClient, opaCapabilityMiddleware } from '@ai-sdk/policy-opa';\n *\n * const client = await wasmPolicyClient({ wasm });\n *\n * const wrappedModel = wrapLanguageModel({\n * model: anthropic('claude-sonnet-4-5'),\n * middleware: opaCapabilityMiddleware({\n * client,\n * path: 'agent/tools/allowed',\n * }),\n * });\n * ```\n */\nexport function opaCapabilityMiddleware(opts: {\n client: PolicyClient;\n path: string;\n toInput?: (args: {\n messages: LanguageModelV4CallOptions['prompt'];\n providerOptions: LanguageModelV4CallOptions['providerOptions'];\n }) => unknown;\n}): LanguageModelV4Middleware {\n const { client, path, toInput } = opts;\n\n return {\n specificationVersion: 'v4',\n async transformParams({ params }) {\n // No tools on the call: nothing to filter.\n if (params.tools == null || params.tools.length === 0) {\n return params;\n }\n\n const input =\n toInput?.({\n messages: params.prompt,\n providerOptions: params.providerOptions,\n }) ??\n ({\n messages: params.prompt,\n providerOptions: params.providerOptions,\n } satisfies DefaultOpaCapabilityInput);\n\n // Fail closed on evaluator error or a malformed allowlist: drop all\n // tools so a misconfiguration never silently widens capabilities.\n const outcome = await evaluatePolicy(client, path, input);\n if (!outcome.ok) {\n return { ...params, tools: undefined };\n }\n\n const allowed = extractAllowedNameSet(outcome.result);\n if (allowed == null) {\n return { ...params, tools: undefined };\n }\n\n let removed = false;\n const filtered = params.tools.filter(t => {\n // Function tools are keyed by `name`. Provider tools carry both a\n // dotted `id` (`<provider>.<tool>`) and a bare `name`; match either so\n // an allowlist authored with the bare name keeps the tool instead of\n // silently dropping it.\n const keep =\n t.type === 'function'\n ? allowed.has(t.name)\n : allowed.has(t.id) || allowed.has(t.name);\n if (!keep) removed = true;\n return keep;\n });\n\n // Preserve object identity when nothing was dropped so downstream\n // middleware can no-op on reference equality.\n if (!removed) return params;\n return { ...params, tools: filtered };\n },\n };\n}\n\nfunction extractAllowedNameSet(result: unknown): Set<string> | null {\n const list = Array.isArray(result)\n ? result\n : result != null && typeof result === 'object'\n ? (result as { tools?: unknown }).tools\n : undefined;\n if (!Array.isArray(list)) return null;\n const out = new Set<string>();\n for (const item of list) {\n if (typeof item !== 'string') return null;\n out.add(item);\n }\n return out;\n}\n","import type {\n Context,\n InferToolSetContext,\n ModelMessage,\n ToolSet,\n} from '@ai-sdk/provider-utils';\nimport type { ToolApprovalConfiguration } from 'ai';\nimport type { PolicyClient } from '../policy-client';\nimport { evaluatePolicy } from './evaluate-policy';\nimport { normalizeOpaDecision } from './normalize-opa-decision';\n\n/**\n * The default shape passed to the OPA rule as `input` when no `toInput` is\n * supplied. Rego rules can read `input.tool.name`, `input.args`, and so on.\n */\nexport interface DefaultOpaInput {\n tool: { name: string };\n args: unknown;\n messages: ReadonlyArray<ModelMessage>;\n runtimeContext: unknown;\n}\n\n/**\n * Construct a {@link ToolApprovalConfiguration} backed by an OPA policy.\n *\n * The returned generic approval function evaluates the supplied Rego entry\n * (`path`) for every tool call and maps the result to the SDK's approval\n * status via {@link normalizeOpaDecision}. Pass the result directly as\n * `toolApproval` on `generateText` / `streamText` / `ToolLoopAgent`.\n *\n * ```ts\n * import { wasmPolicyClient } from '@ai-sdk/policy-opa';\n * import { opaPolicy } from '@ai-sdk/policy-opa';\n *\n * const client = await wasmPolicyClient({ wasm });\n * const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });\n *\n * await generateText({ model, tools, toolApproval, prompt });\n * ```\n *\n * @param opts.client The OPA client (HTTP or WASM).\n * @param opts.path The Rego entrypoint that returns the decision object.\n * @param opts.toInput Optional transformer to shape the OPA input.\n */\nexport function opaPolicy<\n TOOLS extends ToolSet = ToolSet,\n RUNTIME_CONTEXT extends Context | unknown | never = unknown,\n>(opts: {\n client: PolicyClient;\n path: string;\n toInput?: (args: {\n toolCall: { toolName: string; toolCallId: string; input: unknown };\n tools: TOOLS | undefined;\n toolsContext: InferToolSetContext<TOOLS>;\n runtimeContext: RUNTIME_CONTEXT;\n messages: ModelMessage[];\n }) => unknown;\n}): ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT> {\n const { client, path, toInput } = opts;\n\n return async ({\n toolCall,\n tools,\n toolsContext,\n runtimeContext,\n messages,\n }) => {\n const opaInput =\n toInput?.({ toolCall, tools, toolsContext, runtimeContext, messages }) ??\n ({\n tool: { name: toolCall.toolName },\n args: toolCall.input,\n messages,\n runtimeContext,\n } satisfies DefaultOpaInput);\n\n // Fail closed: a backend error must not be read as \"no opinion\". Deny so\n // the model sees a structured result it can reason about, rather than the\n // error rejecting out of the callback and aborting the run.\n const outcome = await evaluatePolicy(client, path, opaInput);\n if (!outcome.ok) {\n return {\n type: 'denied',\n reason: `policy evaluation failed: ${errorMessage(outcome.error)}`,\n };\n }\n\n return normalizeOpaDecision(outcome.result);\n };\n}\n\nfunction errorMessage(cause: unknown): string {\n if (cause instanceof Error) return cause.message;\n if (typeof cause === 'object' && cause !== null) {\n try {\n return JSON.stringify(cause);\n } catch {\n // Fall through for circular / non-serializable objects.\n }\n }\n return String(cause);\n}\n\n/**\n * Optional variant of {@link opaPolicy} that gracefully degrades when no\n * policy backend is available.\n *\n * Returns `undefined` when `client` is `undefined`. Pass that directly as\n * `toolApproval` and the SDK falls back to its default allow-all behavior.\n * When `client` is supplied, behaves exactly like {@link opaPolicy}.\n *\n * Use this when the policy file is configured per environment (e.g. loaded\n * in production, absent in local development):\n *\n * ```ts\n * import { readFile } from 'node:fs/promises';\n * import { optionalOpaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';\n *\n * const wasm = process.env.POLICY_WASM_PATH\n * ? await readFile(process.env.POLICY_WASM_PATH)\n * : undefined;\n * const client = wasm ? await wasmPolicyClient({ wasm }) : undefined;\n *\n * const toolApproval = optionalOpaPolicy({\n * client,\n * path: 'agent/call/decision',\n * });\n *\n * await generateText({ model, tools, toolApproval, prompt });\n * ```\n */\nexport function optionalOpaPolicy<\n TOOLS extends ToolSet = ToolSet,\n RUNTIME_CONTEXT extends Context | unknown | never = unknown,\n>(opts: {\n client: PolicyClient | undefined;\n path: string;\n toInput?: (args: {\n toolCall: { toolName: string; toolCallId: string; input: unknown };\n tools: TOOLS | undefined;\n toolsContext: InferToolSetContext<TOOLS>;\n runtimeContext: RUNTIME_CONTEXT;\n messages: ModelMessage[];\n }) => unknown;\n}): ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT> | undefined {\n if (opts.client == null) return undefined;\n return opaPolicy<TOOLS, RUNTIME_CONTEXT>({\n client: opts.client,\n path: opts.path,\n toInput: opts.toInput,\n });\n}\n","import type { PolicyClient } from '../policy-client';\n\n/**\n * Loaded OPA WASM bundle. Compiled offline with `opa build -t wasm` and\n * passed in as bytes.\n */\ntype LoadedPolicy = {\n evaluate(input: unknown): Array<{ result: unknown }>;\n /** Some versions expose `setData` for documents bundled at evaluation time. */\n setData?(data: unknown): void;\n};\n\n/**\n * Construct a {@link PolicyClient} that evaluates a compiled OPA WASM bundle\n * in-process using `@open-policy-agent/opa-wasm`.\n *\n * The `@open-policy-agent/opa-wasm` package is an optional peer dependency;\n * install it before using this client:\n *\n * ```sh\n * pnpm add @open-policy-agent/opa-wasm\n * ```\n *\n * The `path` argument to `evaluate(path, input)` is informational. The WASM\n * bundle is built around a fixed entrypoint at `opa build` time. The path is\n * recorded for audit logs but does not affect the evaluation.\n */\nexport async function wasmPolicyClient(opts: {\n wasm: Uint8Array | ArrayBuffer;\n /** Optional data document bundled into the policy (passed to `setData`). */\n data?: unknown;\n}): Promise<PolicyClient> {\n type WasmModule = {\n loadPolicy(wasm: Uint8Array | ArrayBuffer): Promise<LoadedPolicy>;\n };\n\n let mod: WasmModule;\n try {\n mod =\n (await import('@open-policy-agent/opa-wasm')) as unknown as WasmModule;\n } catch (cause) {\n throw Object.assign(\n new Error(\n 'Cannot import \"@open-policy-agent/opa-wasm\". Install it as a peer dependency to use wasmPolicyClient().',\n ),\n { cause },\n );\n }\n\n const policy = await mod.loadPolicy(opts.wasm);\n\n if (opts.data !== undefined && typeof policy.setData === 'function') {\n policy.setData(opts.data);\n }\n\n return {\n async evaluate(_path, input) {\n const results = policy.evaluate(input);\n // The WASM SDK returns an array of `{ result }` entries, one per\n // top-level expression in the entrypoint. A non-array or empty array\n // means the entrypoint produced no value (wrong entrypoint at\n // `opa build` time, an undefined decision rule, or a misbehaving\n // bundle). Throw rather than return undefined so the caller fails closed\n // instead of silently treating it as \"no opinion\".\n if (!Array.isArray(results) || results.length === 0) {\n throw Object.assign(\n new Error(\n 'OPA WASM policy produced no result. Check that the bundle was built with the correct entrypoint (`opa build -t wasm -e <path>`).',\n ),\n { input },\n );\n }\n // Return the first entry's `result`, matching the HTTP client's\n // single-decision shape.\n return results[0].result as never;\n },\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1 +1,83 @@
1
- {"name":"@ai-sdk/policy-opa","version":"0.0.0"}
1
+ {
2
+ "name": "@ai-sdk/policy-opa",
3
+ "version": "1.0.0-beta.14",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist/**/*",
11
+ "src",
12
+ "!src/**/*.test.ts",
13
+ "!src/**/*.test-d.ts",
14
+ "!src/**/__snapshots__",
15
+ "!src/**/__fixtures__",
16
+ "CHANGELOG.md",
17
+ "README.md"
18
+ ],
19
+ "exports": {
20
+ "./package.json": "./package.json",
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "dependencies": {
28
+ "@ai-sdk/provider": "4.0.0-beta.19",
29
+ "@ai-sdk/provider-utils": "5.0.0-beta.49"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "22.19.19",
33
+ "tsup": "^8.5.1",
34
+ "typescript": "5.8.3",
35
+ "@vercel/ai-tsconfig": "0.0.0",
36
+ "ai": "7.0.0-beta.177"
37
+ },
38
+ "peerDependencies": {
39
+ "@open-policy-agent/opa": "^1.0.0",
40
+ "@open-policy-agent/opa-wasm": "^1.0.0",
41
+ "ai": "7.0.0-beta.177"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@open-policy-agent/opa": {
45
+ "optional": true
46
+ },
47
+ "@open-policy-agent/opa-wasm": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "engines": {
52
+ "node": ">=22"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public",
56
+ "provenance": true
57
+ },
58
+ "homepage": "https://ai-sdk.dev/docs",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "https://github.com/vercel/ai",
62
+ "directory": "packages/policy-opa"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/vercel/ai/issues"
66
+ },
67
+ "keywords": [
68
+ "ai",
69
+ "policy",
70
+ "opa",
71
+ "open-policy-agent"
72
+ ],
73
+ "scripts": {
74
+ "build": "pnpm clean && tsup --tsconfig tsconfig.build.json",
75
+ "build:watch": "pnpm clean && tsup --watch",
76
+ "clean": "del-cli dist *.tsbuildinfo",
77
+ "type-check": "tsc --build",
78
+ "test": "pnpm test:node && pnpm test:edge",
79
+ "test:watch": "vitest --config vitest.node.config.js",
80
+ "test:node": "vitest --config vitest.node.config.js --run",
81
+ "test:edge": "vitest --config vitest.edge.config.js --run"
82
+ }
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Engine-neutral core.
2
+ export type { PolicyClient } from './policy-client';
3
+ export type { PolicyDecision } from './policy-decision';
4
+ export { shadow, type PolicyDecisionEvent } from './shadow';
5
+ export { wrapMcpTools, type WrappedMcpTools } from './wrap-mcp-tools';
6
+
7
+ // OPA backend and adapters.
8
+ export { httpPolicyClient } from './opa/http-policy-client';
9
+ export { normalizeOpaDecision } from './opa/normalize-opa-decision';
10
+ export {
11
+ type DefaultOpaCapabilityInput,
12
+ opaCapabilityMiddleware,
13
+ } from './opa/opa-capability-middleware';
14
+ export {
15
+ type DefaultOpaInput,
16
+ opaPolicy,
17
+ optionalOpaPolicy,
18
+ } from './opa/opa-policy';
19
+ export { wasmPolicyClient } from './opa/wasm-policy-client';
@@ -0,0 +1,24 @@
1
+ import type { PolicyClient } from '../policy-client';
2
+
3
+ export type EvaluateOutcome =
4
+ | { ok: true; result: unknown }
5
+ | { ok: false; error: unknown };
6
+
7
+ /**
8
+ * Evaluate a policy, capturing a backend error (OPA unreachable, WASM fault,
9
+ * bad path) as a value instead of letting it throw. This is the package's
10
+ * fail-closed invariant in one place: a thrown `client.evaluate` must never
11
+ * escape into the SDK callback or middleware, where it would abort the run.
12
+ * Each caller turns `ok: false` into its own safe fallback (deny / no tools).
13
+ */
14
+ export async function evaluatePolicy(
15
+ client: PolicyClient,
16
+ path: string,
17
+ input: unknown,
18
+ ): Promise<EvaluateOutcome> {
19
+ try {
20
+ return { ok: true, result: await client.evaluate(path, input) };
21
+ } catch (error) {
22
+ return { ok: false, error };
23
+ }
24
+ }
@@ -0,0 +1,57 @@
1
+ import type { PolicyClient } from '../policy-client';
2
+
3
+ /**
4
+ * Construct a {@link PolicyClient} that talks to a running OPA server over
5
+ * HTTP using `@open-policy-agent/opa`.
6
+ *
7
+ * The `@open-policy-agent/opa` package is an optional peer dependency; install
8
+ * it before using this client:
9
+ *
10
+ * ```sh
11
+ * pnpm add @open-policy-agent/opa
12
+ * ```
13
+ *
14
+ * The `url` typically points at `http://localhost:8181` for a locally running
15
+ * OPA. `headers` is forwarded for Styra DAS / EOPA authentication.
16
+ */
17
+ export function httpPolicyClient(opts: {
18
+ url: string;
19
+ headers?: Record<string, string>;
20
+ }): PolicyClient {
21
+ const { url, headers } = opts;
22
+ type Underlying = {
23
+ evaluate(path: string, input: unknown): Promise<unknown>;
24
+ };
25
+ type OPAModule = {
26
+ OPAClient: new (
27
+ url: string,
28
+ opts?: { headers?: Record<string, string> },
29
+ ) => Underlying;
30
+ };
31
+
32
+ let underlying: Underlying | undefined;
33
+
34
+ async function getUnderlying(): Promise<Underlying> {
35
+ if (underlying) return underlying;
36
+ let mod: OPAModule;
37
+ try {
38
+ mod = (await import('@open-policy-agent/opa')) as unknown as OPAModule;
39
+ } catch (cause) {
40
+ throw Object.assign(
41
+ new Error(
42
+ 'Cannot import "@open-policy-agent/opa". Install it as a peer dependency to use httpPolicyClient().',
43
+ ),
44
+ { cause },
45
+ );
46
+ }
47
+ underlying = new mod.OPAClient(url, headers ? { headers } : undefined);
48
+ return underlying;
49
+ }
50
+
51
+ return {
52
+ async evaluate(path, input) {
53
+ const client = await getUnderlying();
54
+ return (await client.evaluate(path, input)) as never;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,56 @@
1
+ import type { PolicyDecision } from '../policy-decision';
2
+
3
+ /**
4
+ * Normalize an OPA evaluation result into the package's {@link PolicyDecision}
5
+ * shape.
6
+ *
7
+ * Supports two Rego output conventions:
8
+ *
9
+ * - **Recommended (explicit):** `{ "decision": "allow" | "deny" | "requires-approval", "reason": string }`.
10
+ * Maps to `approved` / `denied` / `user-approval` respectively.
11
+ *
12
+ * - **Legacy (boolean):** `{ "allow": boolean, "reason"?: string }`. `true`
13
+ * maps to `approved`, `false` to `denied`.
14
+ *
15
+ * Unknown shapes and `undefined` are treated as `not-applicable` so that a
16
+ * Rego rule that does not match any branch defaults to "no opinion" rather
17
+ * than blocking.
18
+ */
19
+ export function normalizeOpaDecision(result: unknown): PolicyDecision {
20
+ if (result == null) {
21
+ return { type: 'not-applicable' };
22
+ }
23
+
24
+ if (typeof result !== 'object') {
25
+ return { type: 'not-applicable' };
26
+ }
27
+
28
+ const record = result as Record<string, unknown>;
29
+ const reason = typeof record.reason === 'string' ? record.reason : undefined;
30
+
31
+ if (typeof record.decision === 'string') {
32
+ switch (record.decision) {
33
+ case 'allow':
34
+ return withReason('approved', reason);
35
+ case 'deny':
36
+ return withReason('denied', reason);
37
+ case 'requires-approval':
38
+ return { type: 'user-approval' };
39
+ case 'not-applicable':
40
+ return { type: 'not-applicable' };
41
+ }
42
+ }
43
+
44
+ if (typeof record.allow === 'boolean') {
45
+ return withReason(record.allow ? 'approved' : 'denied', reason);
46
+ }
47
+
48
+ return { type: 'not-applicable' };
49
+ }
50
+
51
+ function withReason(
52
+ type: 'approved' | 'denied',
53
+ reason: string | undefined,
54
+ ): PolicyDecision {
55
+ return reason ? { type, reason } : { type };
56
+ }
@@ -0,0 +1,130 @@
1
+ import type {
2
+ LanguageModelV4CallOptions,
3
+ LanguageModelV4Middleware,
4
+ } from '@ai-sdk/provider';
5
+ import type { PolicyClient } from '../policy-client';
6
+ import { evaluatePolicy } from './evaluate-policy';
7
+
8
+ /**
9
+ * Default OPA input shape passed to the capability-scoping rule.
10
+ * Override with `toInput` if your Rego expects a different schema.
11
+ */
12
+ export interface DefaultOpaCapabilityInput {
13
+ messages: LanguageModelV4CallOptions['prompt'];
14
+ providerOptions: LanguageModelV4CallOptions['providerOptions'];
15
+ }
16
+
17
+ /**
18
+ * Construct an experimental {@link LanguageModelV4Middleware} that narrows
19
+ * the `tools` field on every model call to an allowlist returned by OPA.
20
+ *
21
+ * Why use this alongside the per-call `toolApproval` gate? Two reasons:
22
+ *
23
+ * 1. **Defense in depth.** If a bug or regression slips through the policy
24
+ * used by `toolApproval`, the middleware still prevents the model from
25
+ * seeing the disallowed tools in the first place.
26
+ * 2. **Capability disclosure.** The model does not waste tokens describing
27
+ * tools it cannot call, and jailbreak attempts get "I don't have access
28
+ * to that tool" rather than "[approval denied]".
29
+ *
30
+ * The OPA rule at `path` is expected to return either a `string[]` of allowed
31
+ * tool names, or an object `{ tools: string[] }`. Function tools whose `name`
32
+ * matches are kept; provider tools whose dotted `id` or bare `name` matches are
33
+ * kept; everything else is dropped from `params.tools`.
34
+ *
35
+ * On a malformed result (or an OPA error), the middleware **fails closed**:
36
+ * `params.tools` is set to `undefined`, so the model is told it has no tools
37
+ * available. Misconfiguration should not silently widen capabilities.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { wrapLanguageModel } from 'ai';
42
+ * import { wasmPolicyClient, opaCapabilityMiddleware } from '@ai-sdk/policy-opa';
43
+ *
44
+ * const client = await wasmPolicyClient({ wasm });
45
+ *
46
+ * const wrappedModel = wrapLanguageModel({
47
+ * model: anthropic('claude-sonnet-4-5'),
48
+ * middleware: opaCapabilityMiddleware({
49
+ * client,
50
+ * path: 'agent/tools/allowed',
51
+ * }),
52
+ * });
53
+ * ```
54
+ */
55
+ export function opaCapabilityMiddleware(opts: {
56
+ client: PolicyClient;
57
+ path: string;
58
+ toInput?: (args: {
59
+ messages: LanguageModelV4CallOptions['prompt'];
60
+ providerOptions: LanguageModelV4CallOptions['providerOptions'];
61
+ }) => unknown;
62
+ }): LanguageModelV4Middleware {
63
+ const { client, path, toInput } = opts;
64
+
65
+ return {
66
+ specificationVersion: 'v4',
67
+ async transformParams({ params }) {
68
+ // No tools on the call: nothing to filter.
69
+ if (params.tools == null || params.tools.length === 0) {
70
+ return params;
71
+ }
72
+
73
+ const input =
74
+ toInput?.({
75
+ messages: params.prompt,
76
+ providerOptions: params.providerOptions,
77
+ }) ??
78
+ ({
79
+ messages: params.prompt,
80
+ providerOptions: params.providerOptions,
81
+ } satisfies DefaultOpaCapabilityInput);
82
+
83
+ // Fail closed on evaluator error or a malformed allowlist: drop all
84
+ // tools so a misconfiguration never silently widens capabilities.
85
+ const outcome = await evaluatePolicy(client, path, input);
86
+ if (!outcome.ok) {
87
+ return { ...params, tools: undefined };
88
+ }
89
+
90
+ const allowed = extractAllowedNameSet(outcome.result);
91
+ if (allowed == null) {
92
+ return { ...params, tools: undefined };
93
+ }
94
+
95
+ let removed = false;
96
+ const filtered = params.tools.filter(t => {
97
+ // Function tools are keyed by `name`. Provider tools carry both a
98
+ // dotted `id` (`<provider>.<tool>`) and a bare `name`; match either so
99
+ // an allowlist authored with the bare name keeps the tool instead of
100
+ // silently dropping it.
101
+ const keep =
102
+ t.type === 'function'
103
+ ? allowed.has(t.name)
104
+ : allowed.has(t.id) || allowed.has(t.name);
105
+ if (!keep) removed = true;
106
+ return keep;
107
+ });
108
+
109
+ // Preserve object identity when nothing was dropped so downstream
110
+ // middleware can no-op on reference equality.
111
+ if (!removed) return params;
112
+ return { ...params, tools: filtered };
113
+ },
114
+ };
115
+ }
116
+
117
+ function extractAllowedNameSet(result: unknown): Set<string> | null {
118
+ const list = Array.isArray(result)
119
+ ? result
120
+ : result != null && typeof result === 'object'
121
+ ? (result as { tools?: unknown }).tools
122
+ : undefined;
123
+ if (!Array.isArray(list)) return null;
124
+ const out = new Set<string>();
125
+ for (const item of list) {
126
+ if (typeof item !== 'string') return null;
127
+ out.add(item);
128
+ }
129
+ return out;
130
+ }
@@ -0,0 +1,152 @@
1
+ import type {
2
+ Context,
3
+ InferToolSetContext,
4
+ ModelMessage,
5
+ ToolSet,
6
+ } from '@ai-sdk/provider-utils';
7
+ import type { ToolApprovalConfiguration } from 'ai';
8
+ import type { PolicyClient } from '../policy-client';
9
+ import { evaluatePolicy } from './evaluate-policy';
10
+ import { normalizeOpaDecision } from './normalize-opa-decision';
11
+
12
+ /**
13
+ * The default shape passed to the OPA rule as `input` when no `toInput` is
14
+ * supplied. Rego rules can read `input.tool.name`, `input.args`, and so on.
15
+ */
16
+ export interface DefaultOpaInput {
17
+ tool: { name: string };
18
+ args: unknown;
19
+ messages: ReadonlyArray<ModelMessage>;
20
+ runtimeContext: unknown;
21
+ }
22
+
23
+ /**
24
+ * Construct a {@link ToolApprovalConfiguration} backed by an OPA policy.
25
+ *
26
+ * The returned generic approval function evaluates the supplied Rego entry
27
+ * (`path`) for every tool call and maps the result to the SDK's approval
28
+ * status via {@link normalizeOpaDecision}. Pass the result directly as
29
+ * `toolApproval` on `generateText` / `streamText` / `ToolLoopAgent`.
30
+ *
31
+ * ```ts
32
+ * import { wasmPolicyClient } from '@ai-sdk/policy-opa';
33
+ * import { opaPolicy } from '@ai-sdk/policy-opa';
34
+ *
35
+ * const client = await wasmPolicyClient({ wasm });
36
+ * const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });
37
+ *
38
+ * await generateText({ model, tools, toolApproval, prompt });
39
+ * ```
40
+ *
41
+ * @param opts.client The OPA client (HTTP or WASM).
42
+ * @param opts.path The Rego entrypoint that returns the decision object.
43
+ * @param opts.toInput Optional transformer to shape the OPA input.
44
+ */
45
+ export function opaPolicy<
46
+ TOOLS extends ToolSet = ToolSet,
47
+ RUNTIME_CONTEXT extends Context | unknown | never = unknown,
48
+ >(opts: {
49
+ client: PolicyClient;
50
+ path: string;
51
+ toInput?: (args: {
52
+ toolCall: { toolName: string; toolCallId: string; input: unknown };
53
+ tools: TOOLS | undefined;
54
+ toolsContext: InferToolSetContext<TOOLS>;
55
+ runtimeContext: RUNTIME_CONTEXT;
56
+ messages: ModelMessage[];
57
+ }) => unknown;
58
+ }): ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT> {
59
+ const { client, path, toInput } = opts;
60
+
61
+ return async ({
62
+ toolCall,
63
+ tools,
64
+ toolsContext,
65
+ runtimeContext,
66
+ messages,
67
+ }) => {
68
+ const opaInput =
69
+ toInput?.({ toolCall, tools, toolsContext, runtimeContext, messages }) ??
70
+ ({
71
+ tool: { name: toolCall.toolName },
72
+ args: toolCall.input,
73
+ messages,
74
+ runtimeContext,
75
+ } satisfies DefaultOpaInput);
76
+
77
+ // Fail closed: a backend error must not be read as "no opinion". Deny so
78
+ // the model sees a structured result it can reason about, rather than the
79
+ // error rejecting out of the callback and aborting the run.
80
+ const outcome = await evaluatePolicy(client, path, opaInput);
81
+ if (!outcome.ok) {
82
+ return {
83
+ type: 'denied',
84
+ reason: `policy evaluation failed: ${errorMessage(outcome.error)}`,
85
+ };
86
+ }
87
+
88
+ return normalizeOpaDecision(outcome.result);
89
+ };
90
+ }
91
+
92
+ function errorMessage(cause: unknown): string {
93
+ if (cause instanceof Error) return cause.message;
94
+ if (typeof cause === 'object' && cause !== null) {
95
+ try {
96
+ return JSON.stringify(cause);
97
+ } catch {
98
+ // Fall through for circular / non-serializable objects.
99
+ }
100
+ }
101
+ return String(cause);
102
+ }
103
+
104
+ /**
105
+ * Optional variant of {@link opaPolicy} that gracefully degrades when no
106
+ * policy backend is available.
107
+ *
108
+ * Returns `undefined` when `client` is `undefined`. Pass that directly as
109
+ * `toolApproval` and the SDK falls back to its default allow-all behavior.
110
+ * When `client` is supplied, behaves exactly like {@link opaPolicy}.
111
+ *
112
+ * Use this when the policy file is configured per environment (e.g. loaded
113
+ * in production, absent in local development):
114
+ *
115
+ * ```ts
116
+ * import { readFile } from 'node:fs/promises';
117
+ * import { optionalOpaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
118
+ *
119
+ * const wasm = process.env.POLICY_WASM_PATH
120
+ * ? await readFile(process.env.POLICY_WASM_PATH)
121
+ * : undefined;
122
+ * const client = wasm ? await wasmPolicyClient({ wasm }) : undefined;
123
+ *
124
+ * const toolApproval = optionalOpaPolicy({
125
+ * client,
126
+ * path: 'agent/call/decision',
127
+ * });
128
+ *
129
+ * await generateText({ model, tools, toolApproval, prompt });
130
+ * ```
131
+ */
132
+ export function optionalOpaPolicy<
133
+ TOOLS extends ToolSet = ToolSet,
134
+ RUNTIME_CONTEXT extends Context | unknown | never = unknown,
135
+ >(opts: {
136
+ client: PolicyClient | undefined;
137
+ path: string;
138
+ toInput?: (args: {
139
+ toolCall: { toolName: string; toolCallId: string; input: unknown };
140
+ tools: TOOLS | undefined;
141
+ toolsContext: InferToolSetContext<TOOLS>;
142
+ runtimeContext: RUNTIME_CONTEXT;
143
+ messages: ModelMessage[];
144
+ }) => unknown;
145
+ }): ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT> | undefined {
146
+ if (opts.client == null) return undefined;
147
+ return opaPolicy<TOOLS, RUNTIME_CONTEXT>({
148
+ client: opts.client,
149
+ path: opts.path,
150
+ toInput: opts.toInput,
151
+ });
152
+ }
@@ -0,0 +1,14 @@
1
+ import type { PolicyClient } from '../policy-client';
2
+
3
+ /**
4
+ * Returns a {@link PolicyClient} that always emits the same decision,
5
+ * ignoring the path and input. Used to isolate adapter-under-test from any
6
+ * real OPA backend.
7
+ */
8
+ export function stubClient(decision: unknown): PolicyClient {
9
+ return {
10
+ async evaluate() {
11
+ return decision as never;
12
+ },
13
+ };
14
+ }