Skip to content
11 changes: 11 additions & 0 deletions .changeset/approval-gate-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@openrouter/agent': patch
---

Fix two ways the tool-approval gate could be bypassed.

**`allowFinalResponse` executed pending tool calls with no approval check.** When a `stopWhen` condition halted the loop on a turn that still carried tool calls, the final-response path ran those calls directly — skipping the approval gate the normal loop applies on every round. A tool marked `requireApproval: true` (or gated by a predicate) would execute unguarded, and because the `PermissionRequest` hook's deny bookkeeping lives inside the approval check, hook-based `deny` never fired on this path either. That path now runs the same check as the in-loop call sites, so the run pauses with `status: 'awaiting_approval'` and the gated calls on `pendingToolCalls` instead of executing them.

**Function-based `requireApproval` received unvalidated arguments.** Tool-level and call-level predicates were called with the raw JSON-parsed tool arguments, while `execute` receives the arguments *after* the tool's Zod `inputSchema` runs. Any default, coercion, or transform in the schema made them disagree — e.g. with `inputSchema: z.object({ dangerous: z.boolean().default(true) })`, a model emitting `{}` showed a predicate `dangerous: undefined` (no approval required) and then executed with `dangerous: true`. Predicates now see a parsed copy, so they decide on exactly what `execute` will receive without mutating the original executable call or parsing transformed output a second time. Call-level checks remain unconditional and receive raw arguments when parsing fails. Tool-level checks do not gate schema-invalid engine-executed tools because every execute path revalidates and returns a tool error; manual tools still fail closed because they have no engine-side validation.

**Duplicate approval prompts for the same tool call.** The approval gate could run more than once over the same response — e.g. the pre-loop check plus the post-loop `allowFinalResponse` gate when a stop condition fired on the first iteration — re-emitting the `PermissionRequest` hook and re-running `requireApproval` predicates for calls that were already resolved. Each response is now gated at most once per run.
2 changes: 1 addition & 1 deletion packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ type BaseCallModelInput<
context?: ContextInput<ToolContextMapWithShared<TTools, TShared>>;
/**
* Call-level approval check - overrides tool-level requireApproval setting
* Receives the tool call and turn context, can be sync or async
* Receives normalized arguments when schema parsing succeeds and raw arguments otherwise
*/
requireApproval?: (
toolCall: ParsedToolCall<TTools[number]>,
Expand Down
86 changes: 68 additions & 18 deletions packages/agent/src/lib/conversation-state.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import type * as models from '@openrouter/sdk/models';
// Same zod entry point the executor validates through (see
// `validateToolInput` in tool-executor.ts) so the approval predicate and
// `execute` agree on parse semantics. Imported directly rather than reusing
// that helper because tool-executor.ts imports this module — sharing it would
// create an import cycle.
import * as z4 from 'zod/v4';
import type {
ConversationState,
ParsedToolCall,
Tool,
TurnContext,
UnsentToolResult,
} from './tool-types.js';
import { isClientTool } from './tool-types.js';
import { isAutoResolvableTool, isClientTool } from './tool-types.js';

import { normalizeInputToArray } from './turn-context.js';

Expand Down Expand Up @@ -260,7 +266,7 @@ export function appendToMessages(
* @param toolCall - The tool call to check
* @param tools - Available tools
* @param context - Turn context for the approval check
* @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async
* @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async. Receives normalized arguments when schema parsing succeeds and raw arguments otherwise.
*/
export async function toolRequiresApproval<TTools extends readonly Tool[]>(
toolCall: ParsedToolCall<TTools[number]>,
Expand All @@ -271,12 +277,6 @@ export async function toolRequiresApproval<TTools extends readonly Tool[]>(
context: TurnContext,
) => boolean | Promise<boolean>,
): Promise<boolean> {
// Call-level check takes precedence
if (callLevelCheck) {
return callLevelCheck(toolCall, context);
}

// Fall back to tool-level setting (server tools never require approval)
const tool = tools.find(
(
t,
Expand All @@ -287,25 +287,75 @@ export async function toolRequiresApproval<TTools extends readonly Tool[]>(
}
> => isClientTool(t) && t.function.name === toolCall.name,
);
// Call-level checks always take precedence. Pass a normalized copy when
// parsing succeeds, or the raw call when it does not.
if (callLevelCheck) {
if (!tool) {
return callLevelCheck(toolCall, context);
}

const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments);
if (!parsed.success) {
return callLevelCheck(toolCall, context);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

return callLevelCheck(
{
...toolCall,
arguments: parsed.data,
} as ParsedToolCall<TTools[number]>,
context,
);
}

// Fall back to the tool-level setting (server tools never require approval).
if (!tool) {
return false;
}

const requireApproval = tool.function.requireApproval;

// If it's a function, call it with the tool's arguments and context.
// Arguments have already been parsed and validated against the tool's
// Zod inputSchema (a ZodObject), so the runtime shape is always a
// record here. A non-record value signals a real upstream bug — surface
// it rather than substituting an empty object.
//
// `toolCall.arguments` at this point is only the JSON-parsed wire payload
// (see extractToolCallsFromResponse) — it has NOT been validated against
// the tool's Zod inputSchema. The executor validates separately, right
// before calling `execute` (see validateToolInput in tool-executor.ts), so
// handing the raw payload to the predicate would let the two see different
// values whenever the schema applies a default, coercion, or transform
// (e.g. schema `{ dangerous: z.boolean().default(true) }` + model emits
// `{}`: the predicate sees `undefined` and waves the call through, then
// `execute` runs with `dangerous: true`).
//
// Parse with the same schema the executor uses so the predicate decides on
// exactly the values `execute` will receive.
if (typeof requireApproval === 'function') {
const rawArgs: unknown = toolCall.arguments;
if (!isRecord(rawArgs)) {
throw new Error(
`toolCall.arguments for "${toolCall.name}" must be an object after Zod validation, got ${rawArgs === null ? 'null' : Array.isArray(rawArgs) ? 'array' : typeof rawArgs}`,
);
const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

▶ Prompt for agents: The tool-level predicate now correctly parses arguments through inputSchema here. Worth noting in a comment at the callLevelCheck branch above (line ~280, outside this diff) that it still receives the raw JSON-parsed toolCall.arguments — not schema-validated — unlike this path. The PR description explicitly scopes normalizing that out, but a brief note there would prevent a future reader from assuming both paths parse arguments the same way.

if (!parsed.success) {
if (isAutoResolvableTool(tool)) {
// Engine-executed tools validate before running: every execute path
// (regular, generator, HITL onToolCalled, unified run) runs the same
// schema through validateToolInput first and converts the failure
// into a tool error output the model can recover from. Arguments
// that don't satisfy the schema can never execute, so gating them
// would pause the run so a human can approve a call that can only
// fail (or throw outright when no state accessor is configured) —
// let them through the gate to the executor's validation error.
return false;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +334 to +345

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A tool whose arguments a hook rewrites can run without the approval it needs

Calls whose arguments do not match the tool's schema are waved past the approval gate (return false at packages/agent/src/lib/conversation-state.ts:344) on the assumption that they can never run, but a rewrite step later in the pipeline can make such a call valid, so a tool the user was supposed to approve executes unapproved.

Impact: In runs that use an input-rewriting hook, a tool guarded by an approval predicate can execute silently without ever asking the user.

Mechanism: PreToolUse `mutatedInput` runs after the approval gate and before validation

toolRequiresApproval now safeParses the wire arguments; on failure it returns false for any isAutoResolvableTool tool, with the justification that "every execute path runs the same schema through validateToolInput first" and so the call "can never execute".

That invariant is broken by the PreToolUse hook: runToolWithHooks (packages/agent/src/lib/model-result.ts:1485-1520) emits PreToolUse with the tool input and replaces effectiveToolCall.arguments when a handler returns mutatedInput; only then does executeToolvalidateToolInput run (packages/agent/src/lib/tool-executor.ts:265). So a call the model emitted with schema-invalid arguments (e.g. missing a required field) that a hook fills in becomes valid and executes — but the approval gate already decided, on the pre-mutation arguments, that no approval was required and never invoked the tool's requireApproval predicate.

Note the asymmetry: with requireApproval: true (boolean) the same call is still gated; only function-based predicates fail open.

Prompt for agents
In `toolRequiresApproval` (packages/agent/src/lib/conversation-state.ts), schema-invalid arguments now fail open (return false, predicate never invoked) for engine-executed tools, justified by the claim that such a call can never reach the tool body because every execute path validates first. That claim does not hold when a PreToolUse hook supplies `mutatedInput`: `runToolWithHooks` in packages/agent/src/lib/model-result.ts swaps in the mutated arguments and only then does `executeTool`/`validateToolInput` run, so a previously-invalid call can become valid and execute — with the approval predicate never consulted. Consider either (a) invoking the predicate on the raw arguments when the parse fails (documenting the parity caveat) instead of skipping the gate, or (b) re-running the approval gate on the effective (post-mutation) tool call before execution when PreToolUse mutated the input, so a gated tool can never execute unapproved.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +334 to +345

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Approval gate fails open for schema-invalid arguments, bypassable via PreToolUse input mutation

toolRequiresApproval now skips the approval check entirely (return false) whenever the tool call's arguments fail the tool's Zod inputSchema, for any engine-executed tool. The stated justification is that such a call can never execute. That assumption is invalidated by the PreToolUse hook path: runToolWithHooks (packages/agent/src/lib/model-result.ts:1485-1520) replaces the call's arguments with a handler-supplied mutatedInput before validateToolInput runs (packages/agent/src/lib/tool-executor.ts:265), so an initially-invalid call can become valid and execute with the tool's requireApproval predicate never having been consulted.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

// Manual tools (no execute / onToolCalled / run) are surfaced to the
// host application via pendingToolCalls and executed WITHOUT any
// engine-side validation, so the "can never execute" argument does not
// hold — fail closed rather than wave a malformed call past the gate.
return true;
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if (!isRecord(parsed.data)) {
// Valid per the schema but not an object — the predicate contract is
// Record<string, unknown>, so there is no trustworthy value to judge.
// Fail closed.
return true;
}
Comment thread
LukasParke marked this conversation as resolved.
return requireApproval(rawArgs, context);
return requireApproval(parsed.data, context);
}

// Otherwise treat as boolean
Expand Down
41 changes: 40 additions & 1 deletion packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ export interface GetResponseOptions<

/**
* Call-level approval check - overrides tool-level requireApproval setting
* Receives the tool call and turn context, can be sync or async
* Receives normalized arguments when schema parsing succeeds and raw arguments otherwise
*/
requireApproval?: (
toolCall: ParsedToolCall<TTools[number]>,
Expand Down Expand Up @@ -645,6 +645,14 @@ export class ModelResult<
// normal tool round consults this to synthesize rejected outputs instead of
// executing the calls.
private readonly hookDeniedCalls = new Map<string, string>();
// The response most recently passed through handleApprovalCheck on this
// run. The same response object can reach the gate more than once — the
// pre-loop check plus the first loop iteration, or the pre-loop check plus
// the post-loop allowFinalResponse gate when a stop condition fires before
// any follow-up request. Re-gating would re-emit PermissionRequest hooks
// (duplicate prompts/audit records) and re-run requireApproval predicates
// for calls already resolved, so the gate runs at most once per response.
private lastApprovalGatedResponse: models.OpenResponsesResult | null = null;
// Telemetry for the PostModelCall hook: the initial/resume request is
// dispatched in initStream but its response is materialized later (stream
// consumption), so the dispatch time and turn labeling are parked here
Expand Down Expand Up @@ -2840,6 +2848,17 @@ export class ModelResult<
return false;
}

// Each response is gated at most once per run (see the field doc). A
// repeat visit for the same response object means the calls were already
// partitioned, the hooks already fired, and any hook 'deny' results are
// already recorded in hookDeniedCalls — and it did not pause (a pause
// returns out of the run). Skipping is therefore both safe and required
// to avoid duplicate permission prompts.
if (currentResponse === this.lastApprovalGatedResponse) {
return false;
}
this.lastApprovalGatedResponse = currentResponse;

const turnContext: TurnContext = {
numberOfTurns: currentRound,
// context is handled via contextStore, not on TurnContext
Expand Down Expand Up @@ -6132,6 +6151,26 @@ export class ModelResult<
this.hasExecutableToolCalls(pendingToolCalls)
) {
const turnNumber = currentRound + 1;

// Gate these calls exactly like a normal round would. This path
// executes real tools, so it needs the same approval check as the
// in-loop call sites above — without it, `stopWhen` firing on a turn
// that carries a `requireApproval` call would run that call
// unguarded, and hook-based 'deny' would never fire either (the
// deny bookkeeping lives inside handleApprovalCheck).
//
// On pause, handleApprovalCheck persists `pendingToolCalls` +
// status 'awaiting_approval', executes any auto-approved calls as
// unsent results, and sets `finalResponse` — so returning here is
// safe: nothing executed, so there is no round to record, and we
// must NOT fall through to markStateComplete() or the final
// text-coercion request. `sessionEndReason` stays 'max_turns' —
// accurate (the loop did stop on the stop condition) and consistent
// with the HITL pause return further down this same block.
if (await this.handleApprovalCheck(pendingToolCalls, turnNumber, currentResponse)) {
Comment thread
LukasParke marked this conversation as resolved.
return;
}
Comment thread
LukasParke marked this conversation as resolved.

const turnContext: TurnContext = {
numberOfTurns: turnNumber,
};
Expand Down
Loading
Loading