@ai-sdk/policy-opa 1.0.122 → 1.0.124
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.
- package/CHANGELOG.md +24 -0
- package/dist/index.d.ts +113 -104
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +435 -279
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -1,312 +1,468 @@
|
|
|
1
|
-
|
|
1
|
+
//#region src/shadow.ts
|
|
2
|
+
/**
|
|
3
|
+
* Wrap a `toolApproval` in shadow mode so the policy is evaluated and the
|
|
4
|
+
* decision is reported via `onDecision`, but the SDK is told the call is
|
|
5
|
+
* approved regardless of what the policy said.
|
|
6
|
+
*
|
|
7
|
+
* Use this when you are rolling out a new policy and want to see what it
|
|
8
|
+
* *would* deny before letting it actually deny anything in production. Wire
|
|
9
|
+
* `onDecision` to your logger / metrics pipeline, run for a while, inspect
|
|
10
|
+
* the events where `decision.type !== 'approved'`, fix the policy, then
|
|
11
|
+
* flip `enforce: true` to graduate.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { shadow } from '@ai-sdk/policy-opa';
|
|
16
|
+
* import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
|
|
17
|
+
*
|
|
18
|
+
* const client = await wasmPolicyClient({ wasm });
|
|
19
|
+
*
|
|
20
|
+
* const toolApproval = shadow(
|
|
21
|
+
* opaPolicy({ client, path: 'agent/call/decision' }),
|
|
22
|
+
* {
|
|
23
|
+
* enforce: process.env.ENFORCE_POLICY === 'true',
|
|
24
|
+
* onDecision: (event) => {
|
|
25
|
+
* logger.info('policy.decision', {
|
|
26
|
+
* tool: event.toolCall.toolName,
|
|
27
|
+
* decision: event.decision.type,
|
|
28
|
+
* enforced: event.enforced,
|
|
29
|
+
* wouldBlock: event.decision.type === 'denied',
|
|
30
|
+
* });
|
|
31
|
+
* },
|
|
32
|
+
* },
|
|
33
|
+
* );
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
2
36
|
function shadow(approval, opts = {}) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
};
|
|
30
|
-
return wrapped;
|
|
37
|
+
const enforce = opts.enforce === true;
|
|
38
|
+
const { onDecision } = opts;
|
|
39
|
+
const wrapped = async (args) => {
|
|
40
|
+
const decision = normalizePolicyDecision(await evaluateApproval(approval, args));
|
|
41
|
+
const effective = enforce ? decision : { type: "approved" };
|
|
42
|
+
if (onDecision) {
|
|
43
|
+
const event = {
|
|
44
|
+
toolCall: {
|
|
45
|
+
toolName: args.toolCall.toolName,
|
|
46
|
+
toolCallId: args.toolCall.toolCallId,
|
|
47
|
+
input: args.toolCall.input
|
|
48
|
+
},
|
|
49
|
+
decision,
|
|
50
|
+
enforced: enforce,
|
|
51
|
+
effective,
|
|
52
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
53
|
+
};
|
|
54
|
+
(async () => {
|
|
55
|
+
try {
|
|
56
|
+
await onDecision(event);
|
|
57
|
+
} catch {}
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
return effective;
|
|
61
|
+
};
|
|
62
|
+
return wrapped;
|
|
31
63
|
}
|
|
32
64
|
async function evaluateApproval(approval, args) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
toolCallId: args.toolCall.toolCallId,
|
|
45
|
-
messages: args.messages,
|
|
46
|
-
toolContext: void 0,
|
|
47
|
-
runtimeContext: args.runtimeContext
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return perTool;
|
|
65
|
+
if (typeof approval === "function") return await approval(args);
|
|
66
|
+
const map = approval;
|
|
67
|
+
const perTool = Object.prototype.hasOwnProperty.call(map, args.toolCall.toolName) ? map[args.toolCall.toolName] : void 0;
|
|
68
|
+
if (perTool == null) return void 0;
|
|
69
|
+
if (typeof perTool === "function") return await perTool(args.toolCall.input, {
|
|
70
|
+
toolCallId: args.toolCall.toolCallId,
|
|
71
|
+
messages: args.messages,
|
|
72
|
+
toolContext: void 0,
|
|
73
|
+
runtimeContext: args.runtimeContext
|
|
74
|
+
});
|
|
75
|
+
return perTool;
|
|
51
76
|
}
|
|
52
77
|
function normalizePolicyDecision(status) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (typeof status === "object" && "type" in status) {
|
|
61
|
-
return status;
|
|
62
|
-
}
|
|
63
|
-
return { type: "not-applicable" };
|
|
78
|
+
if (status == null) return { type: "not-applicable" };
|
|
79
|
+
if (typeof status === "string") {
|
|
80
|
+
if (status === "approved" || status === "denied" || status === "user-approval" || status === "not-applicable") return { type: status };
|
|
81
|
+
return { type: "not-applicable" };
|
|
82
|
+
}
|
|
83
|
+
if (typeof status === "object" && "type" in status) return status;
|
|
84
|
+
return { type: "not-applicable" };
|
|
64
85
|
}
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/wrap-mcp-tools.ts
|
|
88
|
+
/**
|
|
89
|
+
* Apply a fallback approval policy to a discovered tool set so the resulting
|
|
90
|
+
* configuration is total: every tool in `tools` is gated either by the
|
|
91
|
+
* supplied `approval` (when it has an opinion) or by `default` (otherwise).
|
|
92
|
+
*
|
|
93
|
+
* The motivating case is MCP. An MCP server hands you whatever tools it
|
|
94
|
+
* exposes, and the agent's effective permissions become the union of the
|
|
95
|
+
* full server surface. Without a fallback, any tool you forgot to write a
|
|
96
|
+
* rule for is silently allowed. This helper closes that gap by forcing every
|
|
97
|
+
* uncovered tool through `default` (which defaults to `user-approval` so the
|
|
98
|
+
* human is in the loop for anything you didn't think about).
|
|
99
|
+
*
|
|
100
|
+
* Works for any tool set, not just MCP-discovered tools. The name reflects
|
|
101
|
+
* the primary use case rather than a hard constraint.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { wrapMcpTools } from '@ai-sdk/policy-opa';
|
|
106
|
+
* import { opaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
|
|
107
|
+
*
|
|
108
|
+
* const discovered = await mcpClient.tools();
|
|
109
|
+
* const client = await wasmPolicyClient({ wasm });
|
|
110
|
+
*
|
|
111
|
+
* const { tools, toolApproval } = wrapMcpTools(
|
|
112
|
+
* discovered,
|
|
113
|
+
* opaPolicy({ client, path: 'agent/call/decision' }),
|
|
114
|
+
* { default: 'user-approval' }, // anything OPA does not match needs a human
|
|
115
|
+
* );
|
|
116
|
+
*
|
|
117
|
+
* await generateText({ model, tools, toolApproval, prompt });
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
67
120
|
function wrapMcpTools(tools, approval, opts) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
const fallback = opts?.default ?? "user-approval";
|
|
122
|
+
if (typeof approval === "function") {
|
|
123
|
+
const wrapped = async (args) => {
|
|
124
|
+
const status = await approval(args);
|
|
125
|
+
return isNotApplicable(status) ? fallback : status;
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
tools,
|
|
129
|
+
toolApproval: wrapped
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const filled = Object.create(null);
|
|
133
|
+
for (const name of Object.keys(tools)) {
|
|
134
|
+
const configured = Object.prototype.hasOwnProperty.call(approval, name) ? approval[name] : void 0;
|
|
135
|
+
if (configured == null) filled[name] = fallback;
|
|
136
|
+
else if (typeof configured === "function") {
|
|
137
|
+
const original = configured;
|
|
138
|
+
filled[name] = async (...args) => {
|
|
139
|
+
const status = await original(...args);
|
|
140
|
+
return isNotApplicable(status) ? fallback : status;
|
|
141
|
+
};
|
|
142
|
+
} else filled[name] = configured;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
tools,
|
|
146
|
+
toolApproval: filled
|
|
147
|
+
};
|
|
95
148
|
}
|
|
96
149
|
function isNotApplicable(status) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
150
|
+
if (status == null) return true;
|
|
151
|
+
if (status === "not-applicable") return true;
|
|
152
|
+
if (typeof status === "object" && status !== null) return status.type === "not-applicable";
|
|
153
|
+
return false;
|
|
103
154
|
}
|
|
104
|
-
|
|
105
|
-
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/opa/http-policy-client.ts
|
|
157
|
+
/**
|
|
158
|
+
* Construct a {@link PolicyClient} that talks to a running OPA server over
|
|
159
|
+
* HTTP using `@open-policy-agent/opa`.
|
|
160
|
+
*
|
|
161
|
+
* The `@open-policy-agent/opa` package is an optional peer dependency; install
|
|
162
|
+
* it before using this client:
|
|
163
|
+
*
|
|
164
|
+
* ```sh
|
|
165
|
+
* pnpm add @open-policy-agent/opa
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* The `url` typically points at `http://localhost:8181` for a locally running
|
|
169
|
+
* OPA. `headers` is forwarded for Styra DAS / EOPA authentication.
|
|
170
|
+
*/
|
|
106
171
|
function httpPolicyClient(opts) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return underlying;
|
|
124
|
-
}
|
|
125
|
-
return {
|
|
126
|
-
async evaluate(path, input) {
|
|
127
|
-
const client = await getUnderlying();
|
|
128
|
-
return await client.evaluate(path, input);
|
|
129
|
-
}
|
|
130
|
-
};
|
|
172
|
+
const { url, headers } = opts;
|
|
173
|
+
let underlying;
|
|
174
|
+
async function getUnderlying() {
|
|
175
|
+
if (underlying) return underlying;
|
|
176
|
+
let mod;
|
|
177
|
+
try {
|
|
178
|
+
mod = await import("@open-policy-agent/opa");
|
|
179
|
+
} catch (cause) {
|
|
180
|
+
throw Object.assign(/* @__PURE__ */ new Error("Cannot import \"@open-policy-agent/opa\". Install it as a peer dependency to use httpPolicyClient()."), { cause });
|
|
181
|
+
}
|
|
182
|
+
underlying = new mod.OPAClient(url, headers ? { headers } : void 0);
|
|
183
|
+
return underlying;
|
|
184
|
+
}
|
|
185
|
+
return { async evaluate(path, input) {
|
|
186
|
+
return await (await getUnderlying()).evaluate(path, input);
|
|
187
|
+
} };
|
|
131
188
|
}
|
|
132
|
-
|
|
133
|
-
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/opa/normalize-opa-decision.ts
|
|
191
|
+
/**
|
|
192
|
+
* Normalize an OPA evaluation result into the package's {@link PolicyDecision}
|
|
193
|
+
* shape.
|
|
194
|
+
*
|
|
195
|
+
* Supports two Rego output conventions:
|
|
196
|
+
*
|
|
197
|
+
* - **Recommended (explicit):** `{ "decision": "allow" | "deny" | "requires-approval", "reason": string }`.
|
|
198
|
+
* Maps to `approved` / `denied` / `user-approval` respectively.
|
|
199
|
+
*
|
|
200
|
+
* - **Legacy (boolean):** `{ "allow": boolean, "reason"?: string }`. `true`
|
|
201
|
+
* maps to `approved`, `false` to `denied`.
|
|
202
|
+
*
|
|
203
|
+
* `null` and `undefined` are treated as `not-applicable` so that a Rego rule
|
|
204
|
+
* that does not match any branch defaults to "no opinion" rather than
|
|
205
|
+
* blocking. Any other unrecognized result is denied so malformed policy
|
|
206
|
+
* output cannot silently bypass the approval gate.
|
|
207
|
+
*/
|
|
134
208
|
function normalizeOpaDecision(result) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
case "deny":
|
|
148
|
-
return withReason("denied", reason);
|
|
149
|
-
case "requires-approval":
|
|
150
|
-
return withReason("user-approval", reason);
|
|
151
|
-
case "not-applicable":
|
|
152
|
-
return { type: "not-applicable" };
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (typeof record.allow === "boolean") {
|
|
156
|
-
return withReason(record.allow ? "approved" : "denied", reason);
|
|
157
|
-
}
|
|
158
|
-
return unrecognizedDecision();
|
|
209
|
+
if (result == null) return { type: "not-applicable" };
|
|
210
|
+
if (typeof result !== "object") return unrecognizedDecision();
|
|
211
|
+
const record = result;
|
|
212
|
+
const reason = typeof record.reason === "string" ? record.reason : void 0;
|
|
213
|
+
if (typeof record.decision === "string") switch (record.decision) {
|
|
214
|
+
case "allow": return withReason("approved", reason);
|
|
215
|
+
case "deny": return withReason("denied", reason);
|
|
216
|
+
case "requires-approval": return withReason("user-approval", reason);
|
|
217
|
+
case "not-applicable": return { type: "not-applicable" };
|
|
218
|
+
}
|
|
219
|
+
if (typeof record.allow === "boolean") return withReason(record.allow ? "approved" : "denied", reason);
|
|
220
|
+
return unrecognizedDecision();
|
|
159
221
|
}
|
|
160
222
|
function unrecognizedDecision() {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
223
|
+
return {
|
|
224
|
+
type: "denied",
|
|
225
|
+
reason: "unrecognized OPA policy decision"
|
|
226
|
+
};
|
|
165
227
|
}
|
|
166
228
|
function withReason(type, reason) {
|
|
167
|
-
|
|
229
|
+
return reason ? {
|
|
230
|
+
type,
|
|
231
|
+
reason
|
|
232
|
+
} : { type };
|
|
168
233
|
}
|
|
169
|
-
|
|
170
|
-
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/opa/evaluate-policy.ts
|
|
236
|
+
/**
|
|
237
|
+
* Evaluate a policy, capturing a backend error (OPA unreachable, WASM fault,
|
|
238
|
+
* bad path) as a value instead of letting it throw. This is the package's
|
|
239
|
+
* fail-closed invariant in one place: a thrown `client.evaluate` must never
|
|
240
|
+
* escape into the SDK callback or middleware, where it would abort the run.
|
|
241
|
+
* Each caller turns `ok: false` into its own safe fallback (deny / no tools).
|
|
242
|
+
*/
|
|
171
243
|
async function evaluatePolicy(client, path, input) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
244
|
+
try {
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
result: await client.evaluate(path, input)
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error
|
|
253
|
+
};
|
|
254
|
+
}
|
|
177
255
|
}
|
|
178
|
-
|
|
179
|
-
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/opa/opa-capability-middleware.ts
|
|
258
|
+
/**
|
|
259
|
+
* Construct an experimental {@link LanguageModelV4Middleware} that narrows
|
|
260
|
+
* the `tools` field on every model call to an allowlist returned by OPA.
|
|
261
|
+
*
|
|
262
|
+
* Why use this alongside the per-call `toolApproval` gate? Two reasons:
|
|
263
|
+
*
|
|
264
|
+
* 1. **Defense in depth.** If a bug or regression slips through the policy
|
|
265
|
+
* used by `toolApproval`, the middleware still prevents the model from
|
|
266
|
+
* seeing the disallowed tools in the first place.
|
|
267
|
+
* 2. **Capability disclosure.** The model does not waste tokens describing
|
|
268
|
+
* tools it cannot call, and jailbreak attempts get "I don't have access
|
|
269
|
+
* to that tool" rather than "[approval denied]".
|
|
270
|
+
*
|
|
271
|
+
* The OPA rule at `path` is expected to return either a `string[]` of allowed
|
|
272
|
+
* tool names, or an object `{ tools: string[] }`. Function tools whose `name`
|
|
273
|
+
* matches are kept; provider tools whose dotted `id` or bare `name` matches are
|
|
274
|
+
* kept; everything else is dropped from `params.tools`.
|
|
275
|
+
*
|
|
276
|
+
* On a malformed result (or an OPA error), the middleware **fails closed**:
|
|
277
|
+
* `params.tools` is set to `undefined`, so the model is told it has no tools
|
|
278
|
+
* available. Misconfiguration should not silently widen capabilities.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* import { wrapLanguageModel } from 'ai';
|
|
283
|
+
* import { wasmPolicyClient, opaCapabilityMiddleware } from '@ai-sdk/policy-opa';
|
|
284
|
+
*
|
|
285
|
+
* const client = await wasmPolicyClient({ wasm });
|
|
286
|
+
*
|
|
287
|
+
* const wrappedModel = wrapLanguageModel({
|
|
288
|
+
* model: anthropic('claude-sonnet-4-5'),
|
|
289
|
+
* middleware: opaCapabilityMiddleware({
|
|
290
|
+
* client,
|
|
291
|
+
* path: 'agent/tools/allowed',
|
|
292
|
+
* }),
|
|
293
|
+
* });
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
180
296
|
function opaCapabilityMiddleware(opts) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
297
|
+
const { client, path, toInput } = opts;
|
|
298
|
+
return {
|
|
299
|
+
specificationVersion: "v4",
|
|
300
|
+
async transformParams({ params }) {
|
|
301
|
+
if (params.tools == null || params.tools.length === 0) return params;
|
|
302
|
+
const input = toInput?.({
|
|
303
|
+
messages: params.prompt,
|
|
304
|
+
providerOptions: params.providerOptions
|
|
305
|
+
}) ?? {
|
|
306
|
+
messages: params.prompt,
|
|
307
|
+
providerOptions: params.providerOptions
|
|
308
|
+
};
|
|
309
|
+
const outcome = await evaluatePolicy(client, path, input);
|
|
310
|
+
if (!outcome.ok) return {
|
|
311
|
+
...params,
|
|
312
|
+
tools: void 0
|
|
313
|
+
};
|
|
314
|
+
const allowed = extractAllowedNameSet(outcome.result);
|
|
315
|
+
if (allowed == null) return {
|
|
316
|
+
...params,
|
|
317
|
+
tools: void 0
|
|
318
|
+
};
|
|
319
|
+
let removed = false;
|
|
320
|
+
const filtered = params.tools.filter((t) => {
|
|
321
|
+
const keep = t.type === "function" ? allowed.has(t.name) : allowed.has(t.id) || allowed.has(t.name);
|
|
322
|
+
if (!keep) removed = true;
|
|
323
|
+
return keep;
|
|
324
|
+
});
|
|
325
|
+
if (!removed) return params;
|
|
326
|
+
return {
|
|
327
|
+
...params,
|
|
328
|
+
tools: filtered
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
};
|
|
213
332
|
}
|
|
214
333
|
function extractAllowedNameSet(result) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
334
|
+
const list = Array.isArray(result) ? result : result != null && typeof result === "object" ? result.tools : void 0;
|
|
335
|
+
if (!Array.isArray(list)) return null;
|
|
336
|
+
const out = /* @__PURE__ */ new Set();
|
|
337
|
+
for (const item of list) {
|
|
338
|
+
if (typeof item !== "string") return null;
|
|
339
|
+
out.add(item);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
223
342
|
}
|
|
224
|
-
|
|
225
|
-
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/opa/opa-policy.ts
|
|
345
|
+
/**
|
|
346
|
+
* Construct a {@link ToolApprovalConfiguration} backed by an OPA policy.
|
|
347
|
+
*
|
|
348
|
+
* The returned generic approval function evaluates the supplied Rego entry
|
|
349
|
+
* (`path`) for every tool call and maps the result to the SDK's approval
|
|
350
|
+
* status via {@link normalizeOpaDecision}. Pass the result directly as
|
|
351
|
+
* `toolApproval` on `generateText` / `streamText` / `ToolLoopAgent`.
|
|
352
|
+
*
|
|
353
|
+
* ```ts
|
|
354
|
+
* import { wasmPolicyClient } from '@ai-sdk/policy-opa';
|
|
355
|
+
* import { opaPolicy } from '@ai-sdk/policy-opa';
|
|
356
|
+
*
|
|
357
|
+
* const client = await wasmPolicyClient({ wasm });
|
|
358
|
+
* const toolApproval = opaPolicy({ client, path: 'agent/call/decision' });
|
|
359
|
+
*
|
|
360
|
+
* await generateText({ model, tools, toolApproval, prompt });
|
|
361
|
+
* ```
|
|
362
|
+
*
|
|
363
|
+
* @param opts.client The OPA client (HTTP or WASM).
|
|
364
|
+
* @param opts.path The Rego entrypoint that returns the decision object.
|
|
365
|
+
* @param opts.toInput Optional transformer to shape the OPA input.
|
|
366
|
+
*/
|
|
226
367
|
function opaPolicy(opts) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return normalizeOpaDecision(outcome.result);
|
|
249
|
-
};
|
|
368
|
+
const { client, path, toInput } = opts;
|
|
369
|
+
return async ({ toolCall, tools, toolsContext, runtimeContext, messages }) => {
|
|
370
|
+
const opaInput = toInput?.({
|
|
371
|
+
toolCall,
|
|
372
|
+
tools,
|
|
373
|
+
toolsContext,
|
|
374
|
+
runtimeContext,
|
|
375
|
+
messages
|
|
376
|
+
}) ?? {
|
|
377
|
+
tool: { name: toolCall.toolName },
|
|
378
|
+
args: toolCall.input,
|
|
379
|
+
messages,
|
|
380
|
+
runtimeContext
|
|
381
|
+
};
|
|
382
|
+
const outcome = await evaluatePolicy(client, path, opaInput);
|
|
383
|
+
if (!outcome.ok) return {
|
|
384
|
+
type: "denied",
|
|
385
|
+
reason: `policy evaluation failed: ${errorMessage(outcome.error)}`
|
|
386
|
+
};
|
|
387
|
+
return normalizeOpaDecision(outcome.result);
|
|
388
|
+
};
|
|
250
389
|
}
|
|
251
390
|
function errorMessage(cause) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return String(cause);
|
|
391
|
+
if (cause instanceof Error) return cause.message;
|
|
392
|
+
if (typeof cause === "object" && cause !== null) try {
|
|
393
|
+
return JSON.stringify(cause);
|
|
394
|
+
} catch {}
|
|
395
|
+
return String(cause);
|
|
260
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* Optional variant of {@link opaPolicy} that gracefully degrades when no
|
|
399
|
+
* policy backend is available.
|
|
400
|
+
*
|
|
401
|
+
* Returns `undefined` when `client` is `undefined`. Pass that directly as
|
|
402
|
+
* `toolApproval` and the SDK falls back to its default allow-all behavior.
|
|
403
|
+
* When `client` is supplied, behaves exactly like {@link opaPolicy}.
|
|
404
|
+
*
|
|
405
|
+
* Use this when the policy file is configured per environment (e.g. loaded
|
|
406
|
+
* in production, absent in local development):
|
|
407
|
+
*
|
|
408
|
+
* ```ts
|
|
409
|
+
* import { readFile } from 'node:fs/promises';
|
|
410
|
+
* import { optionalOpaPolicy, wasmPolicyClient } from '@ai-sdk/policy-opa';
|
|
411
|
+
*
|
|
412
|
+
* const wasm = process.env.POLICY_WASM_PATH
|
|
413
|
+
* ? await readFile(process.env.POLICY_WASM_PATH)
|
|
414
|
+
* : undefined;
|
|
415
|
+
* const client = wasm ? await wasmPolicyClient({ wasm }) : undefined;
|
|
416
|
+
*
|
|
417
|
+
* const toolApproval = optionalOpaPolicy({
|
|
418
|
+
* client,
|
|
419
|
+
* path: 'agent/call/decision',
|
|
420
|
+
* });
|
|
421
|
+
*
|
|
422
|
+
* await generateText({ model, tools, toolApproval, prompt });
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
261
425
|
function optionalOpaPolicy(opts) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
426
|
+
if (opts.client == null) return void 0;
|
|
427
|
+
return opaPolicy({
|
|
428
|
+
client: opts.client,
|
|
429
|
+
path: opts.path,
|
|
430
|
+
toInput: opts.toInput
|
|
431
|
+
});
|
|
268
432
|
}
|
|
269
|
-
|
|
270
|
-
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/opa/wasm-policy-client.ts
|
|
435
|
+
/**
|
|
436
|
+
* Construct a {@link PolicyClient} that evaluates a compiled OPA WASM bundle
|
|
437
|
+
* in-process using `@open-policy-agent/opa-wasm`.
|
|
438
|
+
*
|
|
439
|
+
* The `@open-policy-agent/opa-wasm` package is an optional peer dependency;
|
|
440
|
+
* install it before using this client:
|
|
441
|
+
*
|
|
442
|
+
* ```sh
|
|
443
|
+
* pnpm add @open-policy-agent/opa-wasm
|
|
444
|
+
* ```
|
|
445
|
+
*
|
|
446
|
+
* The `path` argument to `evaluate(path, input)` is informational. The WASM
|
|
447
|
+
* bundle is built around a fixed entrypoint at `opa build` time. The path is
|
|
448
|
+
* recorded for audit logs but does not affect the evaluation.
|
|
449
|
+
*/
|
|
271
450
|
async function wasmPolicyClient(opts) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
policy.setData(opts.data);
|
|
286
|
-
}
|
|
287
|
-
return {
|
|
288
|
-
async evaluate(_path, input) {
|
|
289
|
-
const results = policy.evaluate(input);
|
|
290
|
-
if (!Array.isArray(results) || results.length === 0) {
|
|
291
|
-
throw Object.assign(
|
|
292
|
-
new Error(
|
|
293
|
-
"OPA WASM policy produced no result. Check that the bundle was built with the correct entrypoint (`opa build -t wasm -e <path>`)."
|
|
294
|
-
),
|
|
295
|
-
{ input }
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
return results[0].result;
|
|
299
|
-
}
|
|
300
|
-
};
|
|
451
|
+
let mod;
|
|
452
|
+
try {
|
|
453
|
+
mod = await import("@open-policy-agent/opa-wasm");
|
|
454
|
+
} catch (cause) {
|
|
455
|
+
throw Object.assign(/* @__PURE__ */ new Error("Cannot import \"@open-policy-agent/opa-wasm\". Install it as a peer dependency to use wasmPolicyClient()."), { cause });
|
|
456
|
+
}
|
|
457
|
+
const policy = await mod.loadPolicy(opts.wasm);
|
|
458
|
+
if (opts.data !== void 0 && typeof policy.setData === "function") policy.setData(opts.data);
|
|
459
|
+
return { async evaluate(_path, input) {
|
|
460
|
+
const results = policy.evaluate(input);
|
|
461
|
+
if (!Array.isArray(results) || results.length === 0) throw Object.assign(/* @__PURE__ */ new Error("OPA WASM policy produced no result. Check that the bundle was built with the correct entrypoint (`opa build -t wasm -e <path>`)."), { input });
|
|
462
|
+
return results[0].result;
|
|
463
|
+
} };
|
|
301
464
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
opaCapabilityMiddleware,
|
|
306
|
-
opaPolicy,
|
|
307
|
-
optionalOpaPolicy,
|
|
308
|
-
shadow,
|
|
309
|
-
wasmPolicyClient,
|
|
310
|
-
wrapMcpTools
|
|
311
|
-
};
|
|
465
|
+
//#endregion
|
|
466
|
+
export { httpPolicyClient, normalizeOpaDecision, opaCapabilityMiddleware, opaPolicy, optionalOpaPolicy, shadow, wasmPolicyClient, wrapMcpTools };
|
|
467
|
+
|
|
312
468
|
//# sourceMappingURL=index.js.map
|