fix(agent): enforce approval gate on allowFinalResponse path and validate predicate args (#54) - #94
Open
LukasParke wants to merge 14 commits into
Open
fix(agent): enforce approval gate on allowFinalResponse path and validate predicate args (#54)#94LukasParke wants to merge 14 commits into
LukasParke wants to merge 14 commits into
Conversation
LukasParke
marked this pull request as ready for review
August 6, 2026 14:25
…date predicate args (#54) Two ways the tool-approval gate could be bypassed. The allowFinalResponse path 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 called executeToolRound directly, skipping the gate the normal loop applies on every round. A tool marked requireApproval would execute unguarded, and since the PermissionRequest hook's deny bookkeeping lives inside handleApprovalCheck, hook-based deny never fired on this path either. Function-based requireApproval also received unvalidated arguments: the predicate got the raw JSON-parsed wire payload while execute receives the values after the tool's Zod inputSchema runs, so any default, coercion, or transform made the two disagree. The predicate now parses with the same schema the executor uses, and fails closed (requires approval) when the arguments don't satisfy it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a regression test for the PermissionRequest hook returning 'deny' on the post-loop allowFinalResponse path: the denied tool must not execute, the run must not pause for a human, and the hook's reason must be recorded in state as a synthesized rejected output for the call. Verified load-bearing: with the approval-gate fix in model-result.ts reverted, the hook handler is never invoked (0 calls) because that path had no approval check at all, which is where hookDeniedCalls is populated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LukasParke
force-pushed
the
fix/54-approval-gate-validated-args
branch
from
August 6, 2026 21:30
808690d to
3ae034e
Compare
…h executor validation Addresses Devin review on #94: - The new allowFinalResponse gate could re-check calls the pre-loop gate already resolved when a stop condition fired on the first loop iteration (same response object), re-emitting PermissionRequest hooks and re-running requireApproval predicates. handleApprovalCheck now gates each response at most once per run. - Failing closed on schema-invalid arguments converted a recoverable model error into a pause (or a hard throw without a state accessor) for a call that can never execute — the executor validates with the same schema and turns the failure into a tool error output. The gate now lets schema-invalid calls fall through to that validation error; fail-closed is kept only for parses that succeed with a non-record payload, which would break the predicate contract.
Addresses Devin re-review on #94: the schema-invalid fall-through assumed invalid arguments can never execute, which holds only for engine-executed tools (regular/generator/HITL/unified run all validateToolInput first). Manual tools are surfaced via pendingToolCalls and executed by the host application with no engine-side validation, so a malformed call guarded by a function-based requireApproval would bypass the approval pause and the PermissionRequest hook entirely. The fail-open is now gated on isAutoResolvableTool; manual tools fail closed as before.
… gate's fail-open Addresses Devin re-review on #94: the schema-invalid fail-open is sound only while every engine execute path re-validates via validateToolInput before running the tool body. Add one test per execute path (regular, generator, HITL onToolCalled, unified run) asserting an invalid-args call never reaches the tool body and yields an error result. Mutation-checked: stubbing out validateToolInput fails each path's test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent ways the tool-approval gate could be bypassed, letting a tool the user was supposed to approve execute unguarded.
Bug 1 —
allowFinalResponseskipped the approval gate entirelyWhen a
stopWhencondition halted the loop on a turn that still carried tool calls, the final-response path calledexecuteToolRound(pendingToolCalls, turnContext)directly, with nohandleApprovalCheck— unlike the two in-loop call sites, which gate every round.Consequences:
requireApproval: true(or gated by a predicate) executed without approval.denynever fired on this path either:hookDeniedCallsis only populated insidehandleApprovalCheck, and neitherexecuteToolRoundnorexecuteSingleToolCallpartitions on approval internally. So aPermissionRequesthook returningdenywas silently ignored.This is reachable in ordinary use — any run with a
stopWhen(including the default step limit) that halts on a turn carrying a gated call.Bug 2 — the approval predicate saw different arguments than
executeA function-based
requireApprovalwas invoked withtoolCall.arguments, which at that point is onlyJSON.parsed (seeextractToolCallsFromResponse).executereceives the arguments aftervalidateToolInput(z4.parse) runs, which applies the schema's defaults, coercions, and transforms — so the two disagreed whenever the schema does any of those.Concretely, with
inputSchema: z.object({ dangerous: z.boolean().default(true) })and a model emitting{}:dangerous: undefined→ no approval requiredexecutethen ran withdangerous: trueThe predicate was deciding on values that were never the ones used. A stale comment in
conversation-state.tsasserted the arguments were "already parsed and validated against the tool's Zod inputSchema" — that was false, and is corrected here.The fix
model-result.ts: addedif (await this.handleApprovalCheck(pendingToolCalls, turnNumber, currentResponse)) { return; }before the final-responseexecuteToolRound, mirroring the in-loop call sites. On pause,handleApprovalCheckalready persistspendingToolCalls+status: 'awaiting_approval', records auto-approved calls as unsent results, and setsfinalResponse, so the early return is consistent: nothing executed, so there is no round to record, and it correctly skips bothmarkStateComplete()and the final text-coercion request.sessionEndReasonstays'max_turns', matching the sibling HITL pause return in the same block.model-result.ts(review follow-up):handleApprovalChecknow gates each response at most once per run. The same response object could otherwise be checked twice — the pre-loop gate plus the post-loopallowFinalResponsegate when a stop condition fires on the first loop iteration (and, pre-existing, the pre-loop gate plus the first in-loop iteration) — re-emittingPermissionRequesthooks (duplicate prompts/audit records) and re-running predicates for calls already resolved. A repeat visit means the first pass already partitioned the calls, fired the hooks, and recorded any hookdenyinhookDeniedCalls, so skipping is safe.conversation-state.ts: the predicate's arguments are nowz4.safeParsed against the tool'sinputSchema— the same zod entry pointvalidateToolInputuses — so the predicate sees exactly whatexecutewill receive. Zod is imported directly rather than reusingvalidateToolInputbecausetool-executor.tsimportsconversation-state.ts; sharing the helper would create an import cycle. The false comment is replaced with one explaining the actual invariant.conversation-state.ts(review follow-up): schema-invalid arguments are not gated for engine-executed tools. Such a call can never execute — every execute path (regular, generator, HITLonToolCalled, unifiedrun) runs the same schema throughvalidateToolInputand converts the failure into a tool error output the model can recover from — so requiring approval would pause the run for a human to approve a call that can only fail, or throw outright when no state accessor is configured. Fail-closed is kept in two cases: parses that succeed with a non-record payload (which would break the predicate'sRecord<string, unknown>contract), and manual tools (noexecute/onToolCalled/run), which the host application executes without any engine-side validation — the fail-open is gated onisAutoResolvableTool.Test coverage
packages/agent/tests/unit/approval-gate-regressions.test.ts(14 tests). The regression tests were verified red against unmodified code before implementing, then green after — confirmed by stashing the fixes and re-running.{}→{ dangerous: true }, approval required){ amount: '500' }→{ amount: 500 }, so> 100compares numerically rather than lexicographically)Record<string, unknown>)allowFinalResponsegate:stepCountIs(1)firing on a turn carrying arequireApprovalcall — asserts the tool does not execute, the run pauses withawaiting_approval, the gated call is onpendingToolCalls,requiresApproval()istrue, and no final text-coercion request is made. Structured so the first round completes with an ungated tool, ensuring the break lands on the post-loop path rather than being caught by the pre-loop gate.allowFinalResponsepath and the final response is still produced (guards against over-blocking).PermissionRequesthook returningdenyis honored on theallowFinalResponsepath: the denied tool does not execute, the run does not pause, and the rejection is recorded as afunction_call_outputcarrying the hook's reason.status: 'complete', the tool body never executes, and the validation failure is recorded as the call's output (also covers the no-state-accessor throw).PermissionRequesthook returningallowis emitted exactly once for a gated call on the initial response, and the tool executes exactly once on theallowFinalResponsepath.onToolCalled, unifiedrun) asserting schema-invalid calls never reach the tool body — the invariant the gate's fail-open relies on (mutation-checked).Per this repo's practice, I re-audited consumers after the contract change: both
executeToolRoundcall sites are now gated, andpartitionToolCalls/toolRequiresApprovalhave no other non-test callers.Verification
pnpm turbo run build typecheck lint test --filter=@openrouter/agent— all 4 tasks pass. Full unit suite: 843 tests / 68 files passing, no type errors.Judgment call
The call-level
requireApprovaloverride (options.requireApproval) was left as-is. It receives the wholeParsedToolCall, not just the arguments — a deliberately different public contract from the tool-level predicate — and normalizing its arguments would change a published signature's semantics. Worth a follow-up decision, but out of scope for a patch fix; flagging it rather than changing it silently.Fixes #54
🤖 Generated with Claude Code
API example