Summary
@openrouter/agent has two places where a configured approval gate may not engage the way an integrator expects. Both only matter for applications that use approval gates (tool-level requireApproval or a call-level approval predicate); applications without approval gates are unaffected.
- A function-based
requireApproval predicate receives the raw model-produced arguments, while the tool's execute receives a different value — the arguments after Zod has applied defaults and coercions.
- When
stopWhen fires on a turn that still has pending tool calls and allowFinalResponse is enabled, those calls are executed without the approval partitioning the normal loop runs before every other round.
Affected code
(1) packages/agent/src/lib/conversation-state.ts (on main, adc7939) — predicate sees raw args:
if (typeof requireApproval === 'function') {
const rawArgs: unknown = toolCall.arguments;
...
return requireApproval(rawArgs, context);
}
packages/agent/src/lib/tool-executor.ts — execution uses a separately validated value:
const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments);
...
const result = await Promise.resolve(tool.function.execute(validatedInput, executeContext));
validateToolInput runs z4.parse(...), which applies defaults/coercions. So a predicate that branches on argument values can decide on inputs that differ from what the tool actually runs with (e.g. a field the model omitted but a schema default fills in, or a value that gets coerced/transformed).
(2) packages/agent/src/lib/model-result.ts (~line 2002) — the post-stopWhen final-response block calls executeToolRound(pendingToolCalls, ...) directly, with no preceding handleApprovalCheck. The normal loop guards every round via handleApprovalCheck (~lines 1876 and 1908), and executeToolRound itself only handles human-in-the-loop pauses — it does not run the partitionToolCalls/requireApproval logic. So an approval-required tool bundled into that turn can run without the gate.
Impact
For an application relying on approval to hold back sensitive tools, the control may not engage as configured: in (1) the predicate judges values that differ from what executes, and in (2) the final-response path skips the gate. Either way an approval-gated, potentially side-effecting tool can run in a way the integrator did not intend.
How to reproduce / confirm
- (1) Define a tool with a Zod schema that has a default (or a coercion) on a field, and a function
requireApproval that returns true/false based on that field. Have the model emit a call that omits the field. The predicate sees the value without the default applied, while execute receives the defaulted/coerced value — the two diverge.
- (2) Enable
allowFinalResponse, configure a stopWhen that fires on a turn where the model still emits an approval-required tool call. That call executes through the final-response path without an approval check.
Suggested fix
- Run the approval predicate against the same validated/coerced arguments the tool will execute with (or clearly document that predicates see raw arguments).
- Run the normal approval partitioning before executing pending tool calls in the
allowFinalResponse path.
Summary
@openrouter/agenthas two places where a configured approval gate may not engage the way an integrator expects. Both only matter for applications that use approval gates (tool-levelrequireApprovalor a call-level approval predicate); applications without approval gates are unaffected.requireApprovalpredicate receives the raw model-produced arguments, while the tool'sexecutereceives a different value — the arguments after Zod has applied defaults and coercions.stopWhenfires on a turn that still has pending tool calls andallowFinalResponseis enabled, those calls are executed without the approval partitioning the normal loop runs before every other round.Affected code
(1)
packages/agent/src/lib/conversation-state.ts(onmain,adc7939) — predicate sees raw args:packages/agent/src/lib/tool-executor.ts— execution uses a separately validated value:validateToolInputrunsz4.parse(...), which applies defaults/coercions. So a predicate that branches on argument values can decide on inputs that differ from what the tool actually runs with (e.g. a field the model omitted but a schema default fills in, or a value that gets coerced/transformed).(2)
packages/agent/src/lib/model-result.ts(~line 2002) — the post-stopWhenfinal-response block callsexecuteToolRound(pendingToolCalls, ...)directly, with no precedinghandleApprovalCheck. The normal loop guards every round viahandleApprovalCheck(~lines 1876 and 1908), andexecuteToolRounditself only handles human-in-the-loop pauses — it does not run thepartitionToolCalls/requireApprovallogic. So an approval-required tool bundled into that turn can run without the gate.Impact
For an application relying on approval to hold back sensitive tools, the control may not engage as configured: in (1) the predicate judges values that differ from what executes, and in (2) the final-response path skips the gate. Either way an approval-gated, potentially side-effecting tool can run in a way the integrator did not intend.
How to reproduce / confirm
requireApprovalthat returnstrue/falsebased on that field. Have the model emit a call that omits the field. The predicate sees the value without the default applied, whileexecutereceives the defaulted/coerced value — the two diverge.allowFinalResponse, configure astopWhenthat fires on a turn where the model still emits an approval-required tool call. That call executes through the final-response path without an approval check.Suggested fix
allowFinalResponsepath.