Skip to content

SEC-770: fix(call-model): refuse tool batches with duplicate call IDs and match approvals per row - #1355

Open
alexb-openrouter wants to merge 1 commit into
mainfrom
devin/1793837507-sec-770-duplicate-call-ids
Open

alexb-openrouter wants to merge 1 commit into
mainfrom
devin/1793837507-sec-770-duplicate-call-ids

Conversation

@alexb-openrouter

@alexb-openrouter alexb-openrouter commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

The risk in plain terms

  • Who can trigger it: whoever controls the JSON of a model response: a broken or malicious upstream provider, or a proxy between the app and OpenRouter. The model's text alone has not been shown to pick tool-call IDs.
  • What goes wrong: in legacy callModel, tool calls and approvals are matched by call_id. If one response has two function_call items with the same call_id, both are saved as pending. When the app approves the one it shows as harmless (read_file), processApprovalDecisions runs the first pending row with that ID, which can be the protected one (delete_repo). Then both rows are cleared.

How this PR fixes it

  • Fail closed per round: before the approval check, a round whose executable calls share a call_id throws. No tool runs, no pending state is saved, no follow-up request is sent. Same semantics and error text as @openrouter/agent (assertUniqueCallIds, typescript-agent PR App attribution headers don't work #133).
  • Fail closed on resume: saved state can come from an older SDK or an external store, so processApprovalDecisions re-checks before any decision runs a tool. It throws if two pending calls share a call_id, or a pending call shares one with an already-executed unsentToolResults entry.
  • Per-row matching: each decision handles one specific pending row (tracked by object, not by ID), and only the handled rows are removed from pendingToolCalls. Before, every row with a matching ID was removed.

What changed

  • src/lib/model-result.ts
    • New assertUniqueCallIds(), called before handleApprovalCheck in the initial round and in each loop round.
    • processApprovalDecisions(): duplicate-ID check at the start, plus a handled set used for matching and removal:
      const toolCall = pendingCalls.find(tc => tc.id === callId && !handled.has(tc));
      handled.add(toolCall);
      // ...
      const remainingPending = pendingCalls.filter(tc => !handled.has(tc));
  • tests/unit/duplicate-call-ids.test.ts: new regression tests.

Decisions:

Testing

  • tests/unit/duplicate-call-ids.test.ts: 6 of its 7 tests fail on main and all pass with the fix. The primary regression test is duplicate call ids in one model response > approving the harmless call cannot run a protected call that shares its call_id. The manual-tool test passes on both, as expected.
  • Runtime repro against the built esm/ output, using a 127.0.0.1 http.createServer that returns Responses-API JSON, with public OpenRouter.callModel and a memory StateAccessor:
    • main: turn 1 pauses with pending [["call_x","delete_repo"],["call_x","read_file"]]. Approving call_x runs ["delete_repo:prod"].
    • This branch: turn 1 throws Response contains more than one function_call with call_id "call_x"; .... Nothing runs, and only 1 request is sent.
  • pnpm run lint, pnpm run typecheck, pnpm run build, and pnpm test (217 passed, 1 skipped) all pass locally.
  • CI validate: build, tsc --noEmit, and the unit suite pass, including the new file. The job fails in the live-API e2e step at tests/e2e/image-generation.test.ts (message.images is undefined). That failure is not caused by this PR: the same test fails on the main-based Speakeasy regen PR (run 41930964842) and on an unrelated PR opened at the same time (run 41940057234). This PR does not touch chat or image code.

Existing-PR search (open, plus closed in the last 90 days, for duplicate call IDs, approvals, and handleApprovalCheck): no PR fixes duplicate call IDs in the model response. #1344 and #1180 fix duplicate or conflicting IDs in the developer's own approveToolCalls/rejectToolCalls lists, plus predicate parsing and resume without decisions. That is a different bug, and they touch different lines.

Fixes SEC-770

Link to Devin session: https://openrouter.devinenterprise.com/sessions/139eac9fcef74e0c89373b59c3966f8e
Open in Devin Desktop: https://openrouter.devinenterprise.com/desktop/session/139eac9fcef74e0c89373b59c3966f8e?variant=devin
Requested by: @alexb-openrouter

@devin-ai-integration

devin-ai-integration Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access that start with 'Devin'.

  • Disable automatic comment, CI, and merge conflict monitoring

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant