Skip to content

Commit aad737d

Browse files
dnukumamrasclaude
andauthored
fix(ai,policy-opa): use own-property checks when resolving per-tool approvals (#16900)
## What Tool sets, tool contexts, and per-tool approval maps are indexed by names that come from model output or client-supplied message history. Plain bracket access (`tools[name]`) resolves names that match inherited object properties (`constructor`, `toString`, `valueOf`, `__proto__`) to values on `Object.prototype` instead of `undefined`, which can slip past the "unknown tool" / "unconfigured approval" guards. This routes those lookups through a new `getOwn` helper (own-property check via `Object.hasOwn`), builds the human-in-the-loop approval-matching maps with a null prototype, and adds the same own-property guards in policy-opa `wrapMcpTools` and `shadow`. Behavior is identical for every real tool set, since tool names are always own properties. The only change is that an inherited-property name now reads as absent. ## Flow Happy path (real tool named `weather`, unchanged): 1. Model or client references `weather`. 2. `getOwn(tools, "weather")` finds it as an own property and returns the tool. 3. Approval, validation, and execution proceed exactly as before. Unhappy path (name collides with a prototype member, e.g. `constructor`): 1. Model or client references `constructor`, which is not a registered tool. 2. Before: `tools["constructor"]` returned `Object.prototype.constructor` (a function), passed the `!= null` guard, and could be treated as a tool or invoked as an approval callback. 3. After: `getOwn(tools, "constructor")` returns `undefined`, so the name routes to the safe "no such tool" / "unconfigured, apply fallback" branch (`NoSuchToolError`, denied/user-approval fallback, etc.). ## Notes Covers the approval path (per-tool resolution and replay re-validation), tool-call parsing, execution, streaming callbacks, and UI message conversion/validation. Includes a `getOwn` unit test plus regression tests in `wrap-mcp-tools`, `shadow`, `resolve-tool-approval`, and `collect-tool-approvals`. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a602a0 commit aad737d

23 files changed

Lines changed: 346 additions & 38 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@ai-sdk/policy-opa': patch
3+
'ai': patch
4+
---
5+
6+
Use own-property checks when resolving per-tool approvals so tool names and approval ids that match inherited object properties (e.g. `constructor`, `toString`, `valueOf`, `__proto__`) are treated as unconfigured/absent.
7+
8+
- `@ai-sdk/policy-opa`: `wrapMcpTools` builds its per-tool map with a null prototype and reads supplied approvals via an own-property check, and `shadow` guards its per-tool map lookup the same way.
9+
- `ai`: tool and tool-context lookups keyed by a model- or client-supplied name now go through an own-property check (`getOwn`), so a name matching an inherited object property resolves to "no such tool"/"unconfigured" instead of a prototype value. This covers the approval path (per-tool approval resolution and replay re-validation) as well as tool-call parsing, execution, streaming callbacks, and UI message conversion/validation. The human-in-the-loop approval matching (`collectToolApprovals`) and streaming tool-name maps are built with a null prototype so a client-supplied id that matches an inherited property no longer slips past the "unknown approval" / "tool call not found" guards.

‎packages/ai/src/generate-text/collect-tool-approvals.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,44 @@ describe('collectToolApprovals', () => {
335335
);
336336
});
337337

338+
it.each(['toString', 'constructor', 'valueOf', '__proto__'])(
339+
'should throw for an unknown approvalId that matches the inherited object property "%s"',
340+
approvalId => {
341+
// Guards against inherited-property lookups: an approvalId that is not an
342+
// own key of the request map must be treated as unknown, not resolved to
343+
// a value on Object.prototype.
344+
expect(() =>
345+
collectToolApprovals({
346+
messages: [
347+
{
348+
role: 'assistant',
349+
content: [
350+
{
351+
type: 'tool-call',
352+
toolCallId: 'call-1',
353+
toolName: 'tool1',
354+
input: { value: 'test-input' },
355+
},
356+
],
357+
},
358+
{
359+
role: 'tool',
360+
content: [
361+
{
362+
type: 'tool-approval-response',
363+
approvalId, // no request with this id exists
364+
approved: true,
365+
},
366+
],
367+
},
368+
],
369+
}),
370+
).toThrow(
371+
`Tool approval response references unknown approvalId: ${JSON.stringify(approvalId)}`,
372+
);
373+
},
374+
);
375+
338376
it('should work for 2 approvals, 2 rejections, 1 approval with tool result, 1 rejection with tool result', () => {
339377
const result = collectToolApprovals({
340378
messages: [

‎packages/ai/src/generate-text/collect-tool-approvals.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,19 @@ export function collectToolApprovals<TOOLS extends ToolSet>({
3636
};
3737
}
3838

39-
// gather tool calls and prepare lookup
40-
const toolCallsByToolCallId: Record<string, TypedToolCall<TOOLS>> = {};
39+
// gather tool calls and prepare lookup.
40+
//
41+
// These maps are keyed by client-supplied ids (`toolCallId`, `approvalId`)
42+
// from the message history. Using `Object.create(null)` gives them no
43+
// prototype, so an id that matches an inherited object property (e.g.
44+
// `toString`, `constructor`, `__proto__`) is treated as absent instead of
45+
// resolving to a prototype value and slipping past the `== null` guards
46+
// below (which would otherwise skip the InvalidToolApproval /
47+
// ToolCallNotFound checks).
48+
const toolCallsByToolCallId: Record<
49+
string,
50+
TypedToolCall<TOOLS>
51+
> = Object.create(null);
4152
for (const message of messages) {
4253
if (message.role === 'assistant' && typeof message.content !== 'string') {
4354
const content = message.content;
@@ -51,7 +62,7 @@ export function collectToolApprovals<TOOLS extends ToolSet>({
5162

5263
// gather approval responses and prepare lookup
5364
const toolApprovalRequestsByApprovalId: Record<string, ToolApprovalRequest> =
54-
{};
65+
Object.create(null);
5566
for (const message of messages) {
5667
if (message.role === 'assistant' && typeof message.content !== 'string') {
5768
const content = message.content;
@@ -64,7 +75,9 @@ export function collectToolApprovals<TOOLS extends ToolSet>({
6475
}
6576

6677
// gather tool results from the last tool message
67-
const toolResults: Record<string, TypedToolResult<TOOLS>> = {};
78+
const toolResults: Record<string, TypedToolResult<TOOLS>> = Object.create(
79+
null,
80+
);
6881
for (const part of lastMessage.content) {
6982
if (part.type === 'tool-result') {
7083
toolResults[part.toolCallId] = part as TypedToolResult<TOOLS>;

‎packages/ai/src/generate-text/execute-tool-call.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type TimeoutConfiguration,
1414
} from '../prompt/request-options';
1515
import type { TelemetryDispatcher } from '../telemetry/telemetry';
16+
import { getOwn } from '../util/get-own';
1617
import { mergeAbortSignals } from '../util/merge-abort-signals';
1718
import { notify } from '../util/notify';
1819
import { now } from '../util/now';
@@ -83,15 +84,15 @@ export async function executeToolCall<TOOLS extends ToolSet>({
8384
| undefined
8485
> {
8586
const { toolName, toolCallId, input } = toolCall;
86-
const tool = tools?.[toolName];
87+
const tool = getOwn(tools, toolName);
8788

8889
if (!isExecutableTool(tool)) {
8990
return undefined;
9091
}
9192

9293
const context = await validateToolContext({
9394
toolName,
94-
context: toolsContext?.[toolName as keyof typeof toolsContext],
95+
context: getOwn(toolsContext, toolName),
9596
contextSchema: tool.contextSchema,
9697
});
9798

‎packages/ai/src/generate-text/execute-tools-from-stream.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from '@ai-sdk/provider-utils';
1010
import type { TimeoutConfiguration } from '../prompt/request-options';
1111
import type { Telemetry, TelemetryDispatcher } from '../telemetry/telemetry';
12+
import { getOwn } from '../util/get-own';
1213
import { executeToolCall } from './execute-tool-call';
1314
import { resolveToolApproval } from './resolve-tool-approval';
1415
import type { LanguageModelStreamPart } from './stream-language-model-call';
@@ -95,7 +96,7 @@ export function executeToolsFromStream<
9596
return;
9697
}
9798

98-
const tool = tools?.[chunk.toolName];
99+
const tool = getOwn(tools, chunk.toolName);
99100

100101
if (tool == null) {
101102
// ignore tool calls for tools that are not available,

‎packages/ai/src/generate-text/generate-text.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
type LanguageModelUsage,
5151
} from '../types/usage';
5252
import type { DownloadFunction } from '../util/download/download-function';
53+
import { getOwn } from '../util/get-own';
5354
import { mergeAbortSignals } from '../util/merge-abort-signals';
5455
import { mergeObjects } from '../util/merge-objects';
5556
import { now as originalNow } from '../util/now';
@@ -717,7 +718,7 @@ export async function generateText<
717718
const modelOutput = await createToolModelOutput({
718719
toolCallId: output.toolCallId,
719720
input: output.input,
720-
tool: tools?.[output.toolName],
721+
tool: getOwn(tools, output.toolName),
721722
output:
722723
output.type === 'tool-result' ? output.output : output.error,
723724
errorMode: output.type === 'tool-error' ? 'text' : 'none',
@@ -1040,7 +1041,7 @@ export async function generateText<
10401041
continue; // ignore invalid tool calls
10411042
}
10421043

1043-
const tool = tools?.[toolCall.toolName];
1044+
const tool = getOwn(tools, toolCall.toolName);
10441045

10451046
if (tool == null) {
10461047
// ignore tool calls for tools that are not available,
@@ -1234,7 +1235,7 @@ export async function generateText<
12341235
// the client tool's result is sent back.
12351236
for (const toolCall of stepToolCalls) {
12361237
if (!toolCall.providerExecuted) continue;
1237-
const tool = tools?.[toolCall.toolName];
1238+
const tool = getOwn(tools, toolCall.toolName);
12381239
if (tool?.type === 'provider' && tool.supportsDeferredResults) {
12391240
// Check if this tool call already has a result in the current response
12401241
const hasResultInResponse = currentModelResponse.content.some(
@@ -1699,7 +1700,7 @@ function asContent<TOOLS extends ToolSet>({
16991700
// result may be deferred to a later turn. In this case, there's no matching tool-call
17001701
// in the current response.
17011702
if (toolCall == null) {
1702-
const tool = tools?.[part.toolName];
1703+
const tool = getOwn(tools, part.toolName);
17031704
const supportsDeferredResults =
17041705
tool?.type === 'provider' && tool.supportsDeferredResults;
17051706

‎packages/ai/src/generate-text/invoke-tool-callbacks-from-stream.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Context, ModelMessage, ToolSet } from '@ai-sdk/provider-utils';
2+
import { createIdMap } from '../util/create-id-map';
3+
import { getOwn } from '../util/get-own';
24
import type { LanguageModelStreamPart } from './stream-language-model-call';
35

46
export function invokeToolCallbacksFromStream<
@@ -19,7 +21,7 @@ export function invokeToolCallbacksFromStream<
1921
}): ReadableStream<LanguageModelStreamPart<TOOLS>> {
2022
if (tools == null) return stream;
2123

22-
const ongoingToolCallToolNames: Record<string, string> = {};
24+
const ongoingToolCallToolNames: Record<string, string> = createIdMap();
2325

2426
return stream.pipeThrough(
2527
new TransformStream({
@@ -30,7 +32,7 @@ export function invokeToolCallbacksFromStream<
3032
case 'tool-input-start': {
3133
ongoingToolCallToolNames[chunk.id] = chunk.toolName;
3234

33-
const tool = tools?.[chunk.toolName];
35+
const tool = getOwn(tools, chunk.toolName);
3436
if (tool?.onInputStart != null) {
3537
await tool.onInputStart({
3638
toolCallId: chunk.id,
@@ -45,7 +47,7 @@ export function invokeToolCallbacksFromStream<
4547

4648
case 'tool-input-delta': {
4749
const toolName = ongoingToolCallToolNames[chunk.id];
48-
const tool = tools?.[toolName];
50+
const tool = getOwn(tools, toolName);
4951

5052
if (tool?.onInputDelta != null) {
5153
await tool.onInputDelta({
@@ -62,7 +64,7 @@ export function invokeToolCallbacksFromStream<
6264

6365
case 'tool-call': {
6466
const toolName = ongoingToolCallToolNames[chunk.toolCallId];
65-
const tool = tools?.[toolName];
67+
const tool = getOwn(tools, toolName);
6668

6769
delete ongoingToolCallToolNames[chunk.toolCallId];
6870

‎packages/ai/src/generate-text/parse-tool-call.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,32 @@ describe('parseToolCall', () => {
8080
`);
8181
});
8282

83+
it('should not treat an inherited object property as a refinement for a tool whose name collides with it', async () => {
84+
// `refineToolInput` is keyed by tool name; a tool named after an
85+
// Object.prototype member must not resolve to the inherited method and be
86+
// invoked as a refiner. With no own entry for `toString`, the raw parsed
87+
// input is returned unchanged.
88+
const result = await parseToolCall({
89+
toolCall: {
90+
type: 'tool-call',
91+
toolName: 'toString',
92+
toolCallId: '123',
93+
input: '{"value": "raw"}',
94+
},
95+
tools: {
96+
toString: tool({
97+
inputSchema: z.object({ value: z.string() }),
98+
}),
99+
} as const,
100+
repairToolCall: undefined,
101+
refineToolInput: { testTool: () => ({ value: 'refined' }) } as any,
102+
messages: [],
103+
instructions: undefined,
104+
});
105+
106+
expect(result.input).toEqual({ value: 'raw' });
107+
});
108+
83109
it('should successfully parse a valid provider-executed dynamic tool call', async () => {
84110
const result = await parseToolCall({
85111
toolCall: {

‎packages/ai/src/generate-text/parse-tool-call.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { InvalidToolInputError } from '../error/invalid-tool-input-error';
1111
import { NoSuchToolError } from '../error/no-such-tool-error';
1212
import { ToolCallRepairError } from '../error/tool-call-repair-error';
1313
import type { Instructions } from '../prompt';
14+
import { getOwn } from '../util/get-own';
1415
import type { DynamicToolCall, TypedToolCall } from './tool-call';
1516
import type { ToolCallRepairFunction } from './tool-call-repair-function';
1617
import type { ToolInputRefinement } from './tool-input-refinement';
@@ -66,7 +67,7 @@ export async function parseToolCall<TOOLS extends ToolSet>({
6667
toolCall,
6768
tools,
6869
inputSchema: async ({ toolName }) => {
69-
const { inputSchema } = tools[toolName];
70+
const inputSchema = getOwn(tools, toolName)?.inputSchema;
7071
return await asSchema(inputSchema).jsonSchema;
7172
},
7273
instructions,
@@ -95,7 +96,7 @@ export async function parseToolCall<TOOLS extends ToolSet>({
9596
// use parsed input when possible
9697
const parsedInput = await safeParseJSON({ text: toolCall.input });
9798
const input = parsedInput.success ? parsedInput.value : toolCall.input;
98-
const tool = tools?.[toolCall.toolName];
99+
const tool = getOwn(tools, toolCall.toolName);
99100

100101
// TODO AI SDK 6: special invalid tool call parts
101102
return {
@@ -121,7 +122,7 @@ async function refineParsedToolCallInput<TOOLS extends ToolSet>({
121122
toolCall: TypedToolCall<TOOLS>;
122123
refineToolInput: ToolInputRefinement<TOOLS> | undefined;
123124
}): Promise<TypedToolCall<TOOLS>> {
124-
const refine = refineToolInput?.[toolCall.toolName];
125+
const refine = getOwn(refineToolInput, toolCall.toolName);
125126

126127
if (refine == null) {
127128
return toolCall;
@@ -169,7 +170,7 @@ async function doParseToolCall<TOOLS extends ToolSet>({
169170
}): Promise<TypedToolCall<TOOLS>> {
170171
const toolName = toolCall.toolName as keyof TOOLS & string;
171172

172-
const tool = tools[toolName];
173+
const tool = getOwn(tools, toolName);
173174

174175
if (tool == null) {
175176
// provider-executed dynamic tools are not part of our list of tools:

‎packages/ai/src/generate-text/resolve-tool-approval.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,70 @@ describe('resolveToolApproval', () => {
338338
});
339339
});
340340

341+
describe('tool names that match inherited object properties', () => {
342+
const createToolCallNamed = (toolName: string) => ({
343+
type: 'tool-call' as const,
344+
toolCallId: 'call-1',
345+
toolName: toolName as never,
346+
input: { city: 'Berlin' },
347+
dynamic: false as const,
348+
});
349+
350+
// Regression guard for the fix: `toolApproval[toolName]` used to resolve
351+
// through the prototype chain, so a tool literally named `constructor`
352+
// would pick up `Object.prototype.constructor` (a function) and be invoked
353+
// as if it were a per-tool approval callback. With an own-property check it
354+
// is treated as unconfigured and honors the configured fallback instead.
355+
for (const toolName of ['constructor', 'toString', 'valueOf']) {
356+
it(`does not execute a tool named "${toolName}" when the fallback is denied`, async () => {
357+
const result = await resolveToolApproval({
358+
tools: {
359+
[toolName]: tool({ inputSchema: z.object({ city: z.string() }) }),
360+
},
361+
// simulates the total map produced by wrapMcpTools
362+
toolApproval: { [toolName]: 'denied' },
363+
toolCall: createToolCallNamed(toolName),
364+
messages: [...messages],
365+
toolsContext: {},
366+
runtimeContext: {},
367+
});
368+
369+
expect(result).toEqual({ type: 'denied' });
370+
});
371+
372+
it(`pauses a tool named "${toolName}" for approval when the fallback is user-approval`, async () => {
373+
const result = await resolveToolApproval({
374+
tools: {
375+
[toolName]: tool({ inputSchema: z.object({ city: z.string() }) }),
376+
},
377+
toolApproval: { [toolName]: 'user-approval' },
378+
toolCall: createToolCallNamed(toolName),
379+
messages: [...messages],
380+
toolsContext: {},
381+
runtimeContext: {},
382+
});
383+
384+
expect(result).toEqual({ type: 'user-approval' });
385+
});
386+
387+
it(`treats an unconfigured tool named "${toolName}" as not-applicable rather than reading the inherited property`, async () => {
388+
const result = await resolveToolApproval({
389+
tools: {
390+
[toolName]: tool({ inputSchema: z.object({ city: z.string() }) }),
391+
},
392+
// plain object map that does NOT list the colliding name
393+
toolApproval: { weather: 'denied' } as never,
394+
toolCall: createToolCallNamed(toolName),
395+
messages: [...messages],
396+
toolsContext: {},
397+
runtimeContext: {},
398+
});
399+
400+
expect(result).toEqual({ type: 'not-applicable' });
401+
});
402+
}
403+
});
404+
341405
it('normalizes a user-defined static string approval value before checking tool-defined approval', async () => {
342406
const toolDefinedNeedsApproval = vi.fn(() => true);
343407

0 commit comments

Comments
 (0)