-
Notifications
You must be signed in to change notification settings - Fork 12
fix(agent): enforce approval gate on allowFinalResponse path and validate predicate args (#54) #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b6cd7ce
3ae034e
639e7bb
0418b07
631edb2
bc76be8
9a415f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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'; | ||
|
|
||
|
|
@@ -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]>, | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ▶ Prompt for agents: The tool-level predicate now correctly parses arguments through |
||
| 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; | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines
+334
to
+345
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( 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
That invariant is broken by the PreToolUse hook: Note the asymmetry: with Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+334
to
+345
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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; | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
devin-ai-integration[bot] marked this conversation as resolved.
devin-ai-integration[bot] marked this conversation as resolved.
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; | ||
| } | ||
|
LukasParke marked this conversation as resolved.
|
||
| return requireApproval(rawArgs, context); | ||
| return requireApproval(parsed.data, context); | ||
| } | ||
|
|
||
| // Otherwise treat as boolean | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.