@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,78 @@
1
+ import type { PolicyClient } from '../policy-client';
2
+
3
+ /**
4
+ * Loaded OPA WASM bundle. Compiled offline with `opa build -t wasm` and
5
+ * passed in as bytes.
6
+ */
7
+ type LoadedPolicy = {
8
+ evaluate(input: unknown): Array<{ result: unknown }>;
9
+ /** Some versions expose `setData` for documents bundled at evaluation time. */
10
+ setData?(data: unknown): void;
11
+ };
12
+
13
+ /**
14
+ * Construct a {@link PolicyClient} that evaluates a compiled OPA WASM bundle
15
+ * in-process using `@open-policy-agent/opa-wasm`.
16
+ *
17
+ * The `@open-policy-agent/opa-wasm` package is an optional peer dependency;
18
+ * install it before using this client:
19
+ *
20
+ * ```sh
21
+ * pnpm add @open-policy-agent/opa-wasm
22
+ * ```
23
+ *
24
+ * The `path` argument to `evaluate(path, input)` is informational. The WASM
25
+ * bundle is built around a fixed entrypoint at `opa build` time. The path is
26
+ * recorded for audit logs but does not affect the evaluation.
27
+ */
28
+ export async function wasmPolicyClient(opts: {
29
+ wasm: Uint8Array | ArrayBuffer;
30
+ /** Optional data document bundled into the policy (passed to `setData`). */
31
+ data?: unknown;
32
+ }): Promise<PolicyClient> {
33
+ type WasmModule = {
34
+ loadPolicy(wasm: Uint8Array | ArrayBuffer): Promise<LoadedPolicy>;
35
+ };
36
+
37
+ let mod: WasmModule;
38
+ try {
39
+ mod =
40
+ (await import('@open-policy-agent/opa-wasm')) as unknown as WasmModule;
41
+ } catch (cause) {
42
+ throw Object.assign(
43
+ new Error(
44
+ 'Cannot import "@open-policy-agent/opa-wasm". Install it as a peer dependency to use wasmPolicyClient().',
45
+ ),
46
+ { cause },
47
+ );
48
+ }
49
+
50
+ const policy = await mod.loadPolicy(opts.wasm);
51
+
52
+ if (opts.data !== undefined && typeof policy.setData === 'function') {
53
+ policy.setData(opts.data);
54
+ }
55
+
56
+ return {
57
+ async evaluate(_path, input) {
58
+ const results = policy.evaluate(input);
59
+ // The WASM SDK returns an array of `{ result }` entries, one per
60
+ // top-level expression in the entrypoint. A non-array or empty array
61
+ // means the entrypoint produced no value (wrong entrypoint at
62
+ // `opa build` time, an undefined decision rule, or a misbehaving
63
+ // bundle). Throw rather than return undefined so the caller fails closed
64
+ // instead of silently treating it as "no opinion".
65
+ if (!Array.isArray(results) || results.length === 0) {
66
+ throw Object.assign(
67
+ new Error(
68
+ 'OPA WASM policy produced no result. Check that the bundle was built with the correct entrypoint (`opa build -t wasm -e <path>`).',
69
+ ),
70
+ { input },
71
+ );
72
+ }
73
+ // Return the first entry's `result`, matching the HTTP client's
74
+ // single-decision shape.
75
+ return results[0].result as never;
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A generic client for evaluating a policy decision against some external
3
+ * engine (OPA, Cedar, OpenFGA, a remote HTTP rule service, etc.).
4
+ *
5
+ * Each adapter in this package wraps a backend behind this interface so that
6
+ * the rest of the package (and user code) can switch engines without changing
7
+ * call sites.
8
+ */
9
+ export interface PolicyClient {
10
+ /**
11
+ * Evaluate the given input against the policy identified by `path` and
12
+ * return the decision payload the engine emitted. Adapters are responsible
13
+ * for interpreting that payload. For OPA, `opaPolicy` normalizes it into
14
+ * the SDK's `ToolApprovalStatus` shape.
15
+ */
16
+ evaluate<TInput = unknown, TResult = unknown>(
17
+ path: string,
18
+ input: TInput,
19
+ ): Promise<TResult>;
20
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Narrowed object form of the SDK's `ToolApprovalStatus`.
3
+ *
4
+ * The public `ToolApprovalStatus` from `ai` allows both bare strings
5
+ * (`'approved'`) and the matching object form (`{ type: 'approved' }`). This
6
+ * package normalizes everything to the object form internally so callers like
7
+ * `shadow` can rely on a single discriminant.
8
+ */
9
+ export type PolicyDecision =
10
+ | { type: 'approved'; reason?: string }
11
+ | { type: 'denied'; reason?: string }
12
+ | { type: 'user-approval' }
13
+ | { type: 'not-applicable' };
package/src/shadow.ts ADDED
@@ -0,0 +1,180 @@
1
+ import type {
2
+ Context,
3
+ ModelMessage,
4
+ Tool,
5
+ ToolSet,
6
+ } from '@ai-sdk/provider-utils';
7
+ import type { ToolApprovalConfiguration } from 'ai';
8
+ import type { PolicyDecision } from './policy-decision';
9
+
10
+ interface GenericApprovalArgs {
11
+ toolCall: { toolName: string; toolCallId: string; input: unknown };
12
+ tools: unknown;
13
+ toolsContext: unknown;
14
+ runtimeContext: unknown;
15
+ messages: ModelMessage[];
16
+ }
17
+
18
+ /**
19
+ * Event emitted by {@link shadow} every time the wrapped policy is evaluated.
20
+ *
21
+ * Compare `decision` and `effective` to see what would have happened
22
+ * differently if the policy were enforcing: any record where they disagree
23
+ * is a tool call that shadow mode is letting through but enforce mode would
24
+ * have blocked or escalated.
25
+ */
26
+ export interface PolicyDecisionEvent {
27
+ /** Identifying info from the tool call being evaluated. */
28
+ toolCall: { toolName: string; toolCallId: string; input: unknown };
29
+ /** The decision the wrapped policy returned, normalized to the object form. */
30
+ decision: PolicyDecision;
31
+ /** Whether the SDK will act on `decision` (true) or override to allow (false). */
32
+ enforced: boolean;
33
+ /** The decision the SDK actually acts on. Equals `decision` when enforcing,
34
+ * `{ type: 'approved' }` in shadow mode. */
35
+ effective: PolicyDecision;
36
+ /** ISO 8601 timestamp of the evaluation. */
37
+ timestamp: string;
38
+ }
39
+
40
+ /**
41
+ * Wrap a `toolApproval` in shadow mode so the policy is evaluated and the
42
+ * decision is reported via `onDecision`, but the SDK is told the call is
43
+ * approved regardless of what the policy said.
44
+ *
45
+ * Use this when you are rolling out a new policy and want to see what it
46
+ * *would* deny before letting it actually deny anything in production. Wire
47
+ * `onDecision` to your logger / metrics pipeline, run for a while, inspect
48
+ * the events where `decision.type !== 'approved'`, fix the policy, then
49
+ * flip `enforce: true` to graduate.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { shadow } from '@ai-sdk/policy-opa';
54
+ * import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
55
+ *
56
+ * const client = await wasmPolicyClient({ wasm });
57
+ *
58
+ * const toolApproval = shadow(
59
+ * opaPolicy({ client, path: 'agent/call/decision' }),
60
+ * {
61
+ * enforce: process.env.ENFORCE_POLICY === 'true',
62
+ * onDecision: (event) => {
63
+ * logger.info('policy.decision', {
64
+ * tool: event.toolCall.toolName,
65
+ * decision: event.decision.type,
66
+ * enforced: event.enforced,
67
+ * wouldBlock: event.decision.type === 'denied',
68
+ * });
69
+ * },
70
+ * },
71
+ * );
72
+ * ```
73
+ */
74
+ export function shadow<
75
+ TOOLS extends Record<string, Tool>,
76
+ RUNTIME_CONTEXT extends Context | unknown | never = unknown,
77
+ >(
78
+ approval: ToolApprovalConfiguration<TOOLS & ToolSet, RUNTIME_CONTEXT>,
79
+ opts: {
80
+ /** When true, the SDK acts on the policy's decision. When false (default),
81
+ * the SDK is told every call is approved. */
82
+ enforce?: boolean;
83
+ /** Invoked once per evaluated tool call with the captured decision. */
84
+ onDecision?: (event: PolicyDecisionEvent) => void | Promise<void>;
85
+ } = {},
86
+ ): ToolApprovalConfiguration<TOOLS & ToolSet, RUNTIME_CONTEXT> {
87
+ const enforce = opts.enforce === true;
88
+ const { onDecision } = opts;
89
+
90
+ const wrapped = async (args: GenericApprovalArgs) => {
91
+ const raw = await evaluateApproval(approval, args);
92
+ const decision = normalizePolicyDecision(raw);
93
+
94
+ const effective: PolicyDecision = enforce ? decision : { type: 'approved' };
95
+
96
+ if (onDecision) {
97
+ const event: PolicyDecisionEvent = {
98
+ toolCall: {
99
+ toolName: args.toolCall.toolName,
100
+ toolCallId: args.toolCall.toolCallId,
101
+ input: args.toolCall.input,
102
+ },
103
+ decision,
104
+ enforced: enforce,
105
+ effective,
106
+ timestamp: new Date().toISOString(),
107
+ };
108
+ // Fire-and-forget so a slow or throwing logger does not block the model.
109
+ void (async () => {
110
+ try {
111
+ await onDecision(event);
112
+ } catch {
113
+ // Swallow errors from telemetry; enforcement must not depend on it.
114
+ }
115
+ })();
116
+ }
117
+
118
+ return effective;
119
+ };
120
+
121
+ // `wrapped` is typed against the erased `GenericApprovalArgs`; TS can't prove
122
+ // that matches the SDK's per-tool-set function arm, so bridge it here.
123
+ return wrapped as unknown as ToolApprovalConfiguration<
124
+ TOOLS & ToolSet,
125
+ RUNTIME_CONTEXT
126
+ >;
127
+ }
128
+
129
+ async function evaluateApproval(
130
+ approval: unknown,
131
+ args: GenericApprovalArgs,
132
+ ): Promise<unknown> {
133
+ if (typeof approval === 'function') {
134
+ return await (
135
+ approval as (a: GenericApprovalArgs) => Promise<unknown> | unknown
136
+ )(args);
137
+ }
138
+
139
+ const map = approval as Record<string, unknown>;
140
+ const perTool = map[args.toolCall.toolName];
141
+ if (perTool == null) return undefined;
142
+ if (typeof perTool === 'function') {
143
+ return await (
144
+ perTool as (
145
+ input: unknown,
146
+ options: {
147
+ toolCallId: string;
148
+ messages: ModelMessage[];
149
+ toolContext: unknown;
150
+ runtimeContext: unknown;
151
+ },
152
+ ) => Promise<unknown> | unknown
153
+ )(args.toolCall.input, {
154
+ toolCallId: args.toolCall.toolCallId,
155
+ messages: args.messages,
156
+ toolContext: undefined,
157
+ runtimeContext: args.runtimeContext,
158
+ });
159
+ }
160
+ return perTool;
161
+ }
162
+
163
+ function normalizePolicyDecision(status: unknown): PolicyDecision {
164
+ if (status == null) return { type: 'not-applicable' };
165
+ if (typeof status === 'string') {
166
+ if (
167
+ status === 'approved' ||
168
+ status === 'denied' ||
169
+ status === 'user-approval' ||
170
+ status === 'not-applicable'
171
+ ) {
172
+ return { type: status };
173
+ }
174
+ return { type: 'not-applicable' };
175
+ }
176
+ if (typeof status === 'object' && 'type' in (status as object)) {
177
+ return status as PolicyDecision;
178
+ }
179
+ return { type: 'not-applicable' };
180
+ }
@@ -0,0 +1,97 @@
1
+ import type { Context, Tool } from '@ai-sdk/provider-utils';
2
+ import type { ToolApprovalConfiguration, ToolApprovalStatus } from 'ai';
3
+
4
+ type ApprovalLiteralStatus = Extract<ToolApprovalStatus, string>;
5
+
6
+ /**
7
+ * Result returned by {@link wrapMcpTools}: the original tool set plus a
8
+ * `toolApproval` that falls back to a configurable default for any tool the
9
+ * supplied approval does not explicitly handle.
10
+ */
11
+ export interface WrappedMcpTools<
12
+ TOOLS extends Record<string, Tool>,
13
+ RUNTIME_CONTEXT extends Context | unknown | never,
14
+ > {
15
+ tools: TOOLS;
16
+ toolApproval: ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT>;
17
+ }
18
+
19
+ /**
20
+ * Apply a fallback approval policy to a discovered tool set so the resulting
21
+ * configuration is total: every tool in `tools` is gated either by the
22
+ * supplied `approval` (when it has an opinion) or by `default` (otherwise).
23
+ *
24
+ * The motivating case is MCP. An MCP server hands you whatever tools it
25
+ * exposes, and the agent's effective permissions become the union of the
26
+ * full server surface. Without a fallback, any tool you forgot to write a
27
+ * rule for is silently allowed. This helper closes that gap by forcing every
28
+ * uncovered tool through `default` (which defaults to `user-approval` so the
29
+ * human is in the loop for anything you didn't think about).
30
+ *
31
+ * Works for any tool set, not just MCP-discovered tools. The name reflects
32
+ * the primary use case rather than a hard constraint.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { wrapMcpTools } from '@ai-sdk/policy-opa';
37
+ * import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
38
+ *
39
+ * const discovered = await mcpClient.tools();
40
+ * const client = await wasmPolicyClient({ wasm });
41
+ *
42
+ * const { tools, toolApproval } = wrapMcpTools(
43
+ * discovered,
44
+ * opaPolicy({ client, path: 'agent/call/decision' }),
45
+ * { default: 'user-approval' }, // anything OPA does not match needs a human
46
+ * );
47
+ *
48
+ * await generateText({ model, tools, toolApproval, prompt });
49
+ * ```
50
+ */
51
+ export function wrapMcpTools<
52
+ TOOLS extends Record<string, Tool>,
53
+ RUNTIME_CONTEXT extends Context | unknown | never = unknown,
54
+ >(
55
+ tools: TOOLS,
56
+ approval: ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT>,
57
+ opts?: { default?: ApprovalLiteralStatus },
58
+ ): WrappedMcpTools<TOOLS, RUNTIME_CONTEXT> {
59
+ const fallback: ApprovalLiteralStatus = opts?.default ?? 'user-approval';
60
+
61
+ if (typeof approval === 'function') {
62
+ const wrapped: ToolApprovalConfiguration<
63
+ TOOLS,
64
+ RUNTIME_CONTEXT
65
+ > = async args => {
66
+ const status = await (
67
+ approval as (a: typeof args) => Promise<unknown> | unknown
68
+ )(args);
69
+ return isNotApplicable(status) ? fallback : (status as never);
70
+ };
71
+ return { tools, toolApproval: wrapped };
72
+ }
73
+
74
+ // Per-tool map form: fill in any tool that the user did not list with the
75
+ // fallback decision. We use `Object.keys(tools)` (not the approval keys) so
76
+ // the result is total over the discovered surface.
77
+ const filled = {} as Record<keyof TOOLS, unknown>;
78
+ for (const name of Object.keys(tools) as Array<keyof TOOLS>) {
79
+ filled[name] = approval[name] ?? fallback;
80
+ }
81
+
82
+ // `filled` is a plain per-tool map; cast to the SDK's map arm, which TS
83
+ // can't infer from the erased `unknown` values.
84
+ return {
85
+ tools,
86
+ toolApproval: filled as ToolApprovalConfiguration<TOOLS, RUNTIME_CONTEXT>,
87
+ };
88
+ }
89
+
90
+ function isNotApplicable(status: unknown): boolean {
91
+ if (status == null) return true;
92
+ if (status === 'not-applicable') return true;
93
+ if (typeof status === 'object' && status !== null) {
94
+ return (status as { type?: unknown }).type === 'not-applicable';
95
+ }
96
+ return false;
97
+ }