Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-policies-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/policy-opa': patch
---

fix(policy-opa): deny tool execution when OPA returns an unrecognized decision
3 changes: 2 additions & 1 deletion content/docs/03-agents/06-policy-tool-approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,10 @@ Run with `opa test policy.rego policy_test.rego`.
### Errors fail closed

- If the backend errors (server unreachable, WASM fault, misbuilt bundle), `opaPolicy` returns `denied` with the error message as the reason.
- If the backend returns a present but unrecognized decision, such as an unknown `decision` value or a non-boolean legacy `allow` value, `opaPolicy` returns `denied`.
- The error never rejects out of the callback and never aborts the run.
- A backend outage blocks the affected call rather than silently allowing it.
- This is distinct from a rule that returns no match, which normalizes to `not-applicable` (allow). Use a `default ... deny` rule if you want unmatched calls denied too.
- This is distinct from a rule that returns no match: `null` or `undefined` normalizes to `not-applicable` (allow). Use a `default ... deny` rule if you want unmatched calls denied too.

## Loading the policy

Expand Down
4 changes: 3 additions & 1 deletion packages/policy-opa/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ The adapter also accepts the legacy boolean shape (`{ "allow": true | false, "re

If the backend itself errors (OPA server unreachable, WASM fault, a misbuilt bundle that yields no result), `opaPolicy` returns `{ type: 'denied' }` with the underlying message as the reason. The error never rejects out of the `toolApproval` callback and never aborts the run. A backend outage blocks the affected tool call rather than silently letting it through. This matches `opaCapabilityMiddleware`, which also fails closed.

Note this is distinct from a Rego rule that returns no matching decision: that normalizes to `not-applicable` ("no opinion"), which the SDK treats as allow. Use `default decision := { "decision": "deny" }` in your policy if you want unmatched calls to be denied too.
Present but unrecognized policy results, such as an unknown `decision` value or a non-boolean legacy `allow` value, also fail closed with a denied result.

This is distinct from a Rego rule that returns no matching decision: `null` or `undefined` normalizes to `not-applicable` ("no opinion"), which the SDK treats as allow. Use `default decision := { "decision": "deny" }` in your policy if you want unmatched calls to be denied too.

### What the adapter passes as `input`

Expand Down
18 changes: 10 additions & 8 deletions packages/policy-opa/src/opa/normalize-opa-decision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,17 @@ describe('normalizeOpaDecision', () => {
});
});

it('treats unrecognized shape as not-applicable', () => {
expect(normalizeOpaDecision({ result: 'maybe' })).toEqual({
type: 'not-applicable',
it.each([
['an unknown decision value', { decision: 'blocked' }],
['a non-boolean legacy value', { allow: 'false' }],
['an unknown key', { verdict: 'deny' }],
['an unexpected nested shape', { result: { decision: 'deny' } }],
['a primitive', 'yes'],
])('denies %s', (_description, result) => {
expect(normalizeOpaDecision(result)).toEqual({
type: 'denied',
reason: 'unrecognized OPA policy decision',
});
});

it('treats primitives as not-applicable', () => {
expect(normalizeOpaDecision('yes')).toEqual({ type: 'not-applicable' });
expect(normalizeOpaDecision(42)).toEqual({ type: 'not-applicable' });
});
});
});
18 changes: 13 additions & 5 deletions packages/policy-opa/src/opa/normalize-opa-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ import type { PolicyDecision } from '../policy-decision';
* - **Legacy (boolean):** `{ "allow": boolean, "reason"?: string }`. `true`
* maps to `approved`, `false` to `denied`.
*
* Unknown shapes and `undefined` are treated as `not-applicable` so that a
* Rego rule that does not match any branch defaults to "no opinion" rather
* than blocking.
* `null` and `undefined` are treated as `not-applicable` so that a Rego rule
* that does not match any branch defaults to "no opinion" rather than
* blocking. Any other unrecognized result is denied so malformed policy
* output cannot silently bypass the approval gate.
*/
export function normalizeOpaDecision(result: unknown): PolicyDecision {
if (result == null) {
return { type: 'not-applicable' };
}

if (typeof result !== 'object') {
return { type: 'not-applicable' };
return unrecognizedDecision();
}

const record = result as Record<string, unknown>;
Expand All @@ -45,7 +46,14 @@ export function normalizeOpaDecision(result: unknown): PolicyDecision {
return withReason(record.allow ? 'approved' : 'denied', reason);
}

return { type: 'not-applicable' };
return unrecognizedDecision();
}

function unrecognizedDecision(): PolicyDecision {
return {
type: 'denied',
reason: 'unrecognized OPA policy decision',
};
}

function withReason(
Expand Down
39 changes: 39 additions & 0 deletions packages/policy-opa/src/opa/opa-policy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,45 @@ describe('opaPolicy end-to-end with generateText', () => {
});
});

it('skips execution when the policy returns an unrecognized decision', async () => {
const execute = vi.fn(async () => 'ok');

const result = await generateText({
model: modelEmittingOneToolCallThenText(),
prompt: 'do something',
stopWhen: isStepCount(3),
tools: {
git: tool({
inputSchema: jsonSchema<{ args: string[] }>({
type: 'object',
properties: { args: { type: 'array', items: { type: 'string' } } },
required: ['args'],
}),
execute,
}),
},
toolApproval: opaPolicy({
client: stubClient({ decision: 'blocked' }),
path: 'agent/call/decision',
}),
});

expect(execute).not.toHaveBeenCalled();
const toolMessage = result.responseMessages.find(m => m.role === 'tool') as
| {
content: Array<{
type: string;
output?: { type: string; reason?: string };
}>;
}
| undefined;
const toolResult = toolMessage?.content.find(c => c.type === 'tool-result');
expect(toolResult?.output).toEqual({
type: 'execution-denied',
reason: 'unrecognized OPA policy decision',
});
});

it('routes a dispatcher call through the same Rego rule via toInput', async () => {
// Demonstrates the transitive-enforcement pattern documented in the
// README: bash `'git push'` is rewritten to (kind: 'git', args: ['push'])
Expand Down