fix(agent): enforce approval gate on allowFinalResponse path and validate predicate args (#54) - #94
fix(agent): enforce approval gate on allowFinalResponse path and validate predicate args (#54)#94LukasParke wants to merge 2 commits into
Conversation
| if (await this.handleApprovalCheck(pendingToolCalls, turnNumber, currentResponse)) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
🟡 Permission prompt can be raised twice for the same tool call
The pending tool calls are re-checked for approval (handleApprovalCheck(pendingToolCalls, ...) at packages/agent/src/lib/model-result.ts:6130) even when the very same calls were already checked before the loop, so a permission handler can be asked about — and a user prompted for — the same call twice.
Impact: Integrations that log or surface an interactive prompt from the permission handler see duplicate prompts/audit entries for one tool call.
Why the initial response's calls get gated twice when the stop condition fires on the first iteration
The pre-loop gate at packages/agent/src/lib/model-result.ts:5858 runs handleApprovalCheck(toolCalls, 0, currentResponse) on the initial response's calls. If a PermissionRequest hook returns allow or deny, handleApprovalCheck returns false (see packages/agent/src/lib/model-result.ts:2892-2900) and the loop is entered. If shouldStopExecution() fires on the very first iteration (e.g. stepCountIs(0), or a token/time-based condition already satisfied), the loop breaks with stoppedByStopWhen = true before any follow-up request, so currentResponse is still the initial response. The post-loop path then extracts the same tool calls (packages/agent/src/lib/model-result.ts:6104-6106) and calls handleApprovalCheck again, which re-runs partitionToolCalls and emitPermissionRequest for each gated call — emitPermissionRequest has no per-call memoization (packages/agent/src/lib/model-result.ts:2543-2590). The second test in the new suite (stepCountIs(0)) exercises exactly this double-gating path, but with no gated tool so the duplication isn't observed.
Mid-loop rounds are unaffected because makeFollowupRequest replaces currentResponse with a response whose calls have not yet been gated.
Prompt for agents
In packages/agent/src/lib/model-result.ts, the new approval gate on the allowFinalResponse path (around line 6130) can re-gate tool calls that were already gated by the pre-loop gate at line 5858. This happens when the stop condition fires on the first loop iteration: the loop breaks before any follow-up request, so `currentResponse` is still the response whose calls were already passed through `handleApprovalCheck`. Re-gating re-emits the `PermissionRequest` hook for the same call (emitPermissionRequest has no memoization), producing duplicate permission prompts/audit records, and re-runs any function-based requireApproval predicate. Consider tracking which tool call ids (or which response id) have already been through handleApprovalCheck on this run and skipping the re-check for those, or only gating calls that have not yet been gated.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const parsed = z4.safeParse(tool.function.inputSchema, toolCall.arguments); | ||
| if (!parsed.success || !isRecord(parsed.data)) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🟡 Malformed tool arguments now stall or abort the run instead of returning an error to the model
When a tool's arguments don't match its schema the call is now unconditionally marked as needing human approval (return true at packages/agent/src/lib/conversation-state.ts:321), so a run that has no place to store a pause fails outright instead of letting the model see the validation error and retry.
Impact: A single badly-formed tool call from the model can abort the whole run, or pause it waiting for a human to approve a call that can never succeed.
Fail-closed path interacts badly with the pause requirements
For a tool whose requireApproval is a function, toolRequiresApproval now safeParses toolCall.arguments against tool.function.inputSchema and returns true on failure. That verdict flows into partitionToolCalls → handleApprovalCheck, which, when no StateAccessor is configured, throws Tool(s) require approval but no state accessor is configured: ... (packages/agent/src/lib/model-result.ts:2902-2909) — the run rejects. Previously the predicate was consulted on the raw arguments and, if it returned false, the executor's own validateToolInput (packages/agent/src/lib/tool-executor.ts:265) threw a Zod error that was converted into a tool error output the model could recover from.
Even with a state accessor, the run pauses and asks the user to approve a call whose arguments will fail validation the moment it is executed.
A narrower fail-closed rule (e.g. only fail closed for calls that would otherwise be auto-approved and would actually execute, or letting schema-invalid calls fall through to the executor's normal validation error) would preserve the security intent without turning malformed model output into a hard failure.
Was this helpful? React with 👍 or 👎 to provide feedback.
…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>
808690d to
3ae034e
Compare
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.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. Fails closed: if the arguments don't satisfy the schema, approval is required rather than judging a valueexecutewould never see. 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.Test coverage
New
packages/agent/tests/unit/approval-gate-regressions.test.ts(5 tests). All 4 regression tests were verified red against unmodified code before implementing, then green after — confirmed by stashing the fixes and re-running (4 failed / 1 passed → 5 passed).{}→{ dangerous: true }, approval required){ amount: '500' }→{ amount: 500 }, so> 100compares numerically rather than lexicographically)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).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: 648 tests / 52 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