Skip to content

SEC-768: fix(agent): check stopWhen before running auto-approved siblings of an approval-gated call - #143

Merged
alexb-openrouter merged 1 commit into
mainfrom
devin/1793837544-sec-768-stopwhen-before-approval
Nov 5, 2026
Merged

alexb-openrouter merged 1 commit into
mainfrom
devin/1793837544-sec-768-stopwhen-before-approval

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: anyone who controls the model's output (prompt injection, or a malicious response server).
  • What goes wrong: an app sets a stopWhen that is already true and allowFinalResponse: false, meaning "do not run any tools". If the model's first response contains one auto-executable tool call plus one call that needs approval, the agent runs the auto-executable tool once before it checks stopWhen.
  • The approval-gated tool itself never ran. Only apps that rely on stopWhen this way are affected.

How this PR fixes it

On the first response, ModelResult now evaluates shouldStopExecution() before handleApprovalCheck() when allowFinalResponse === false, and passes the result in. When it is met, handleApprovalCheck() still saves the approval-gated calls as pending (status: 'awaiting_approval'), but it does not execute the auto-approved calls.

const skipAutoExecution =
  this.options.allowFinalResponse === false && (await this.shouldStopExecution());
if (await this.handleApprovalCheck(toolCalls, 0, currentResponse, skipAutoExecution)) return;

// in handleApprovalCheck
const unsentResults = skipAutoExecution
  ? executableNow.map((call) => createRejectedResult(call.id, call.name, 'Tool execution skipped: stop condition met'))
  : await this.executeAutoApproveTools(executableNow, turnContext);

What changed

  • packages/agent/src/lib/model-result.ts: new optional skipAutoExecution param on handleApprovalCheck(); first-response call site checks stopWhen first.
  • packages/agent/tests/unit/stop-when-before-approval.test.ts: new regression test.
  • .changeset/stop-when-before-approval.md: patch changeset.

Decisions:

  • The check only applies when allowFinalResponse is false. With the default (allowFinalResponse on), a stop triggered by stopWhen already runs the pending calls of the last response (through handleApprovalCheck()) before the final toolChoice: 'none' request. So with the default, running the auto sibling when the stop condition is met is existing behavior (the final-response path executes them so they have matching outputs), not a bypass. Gating it there would change the behavior only on the first response and make it inconsistent with later rounds.
  • Skipped calls are saved as error results in unsentToolResults (not dropped). On resume, every function call in the paused response still gets a matching output, so the follow-up request stays valid.

Testing

  • Regression test stopWhen is evaluated before the approval check on the first response > does not run an auto-executable sibling of an approval-gated call when stopWhen is already met: always-true stopWhen + allowFinalResponse: false + one auto-executable tool + one requireApproval sibling in the same response. Asserts the auto tool runs 0 times and the approval call is saved pending. Fails on main (expected "vi.fn()" to not be called at all, but actually been called 1 times), passes with this change.
  • Locally in packages/agent: biome check src tests, pnpm run typecheck, pnpm run build and vitest --run --project unit (108 files, 1246 tests) all pass. Transport is mocked; no network calls.
  • Not covered: the resume-after-approval path is unchanged by this PR and not separately tested here.

Existing-PR search: searched open and closed PRs from the last 90 days (stopWhen approval, shouldStopExecution, stop condition auto-approve, SEC-768) and read #131 and #133. Neither changes this ordering; both touch approval/batch validation in the same file, so a small textual rebase may be needed but there is no logic overlap.

Security hardening fix. Fixes SEC-768

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

…n approval-gated call

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@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

@alexb-openrouter
alexb-openrouter merged commit 4428cb4 into main Nov 5, 2026
7 checks passed
LukasParke added a commit to OpenRouterTeam/typescript-sdk that referenced this pull request Nov 5, 2026
…outer/agent (#1356)

## Summary

SEC-768 (`stopWhen` checked after auto-approved siblings of an
approval-gated call ran) is in the SDK's old copy of `callModel`.
`callModel` and its tool system moved to `@openrouter/agent` (see the
README's migration note), and the copy here is not exported from the
package entry points. So this PR deletes that code instead of fixing it.
The agent-side fix is OpenRouterTeam/typescript-agent#143.

Removed:

- `src/funcs/call-model.ts` and the 19 hand-written `src/lib/` files
that only it used: `model-result`, `tool`, `tool-types`,
`tool-executor`, `tool-orchestrator`, `tool-context`,
`tool-event-broadcaster`, `conversation-state`, `stop-conditions`,
`next-turn-params`, `async-params`, `turn-context`, `reusable-stream`,
`stream-transformers`, `stream-type-guards`, `anthropic-compat`,
`chat-compat`, `claude-type-guards`, `claude-constants`. No generated
file imports any of them.
- The `OpenRouter#callModel` method and the `ToolType` re-export in the
custom regions of `src/sdk/sdk.ts`.
- The 11 unit tests and 5 examples that used this code. Also removed the
`callModel` architecture section from `CLAUDE.md`.

Breaking-change note: `OpenRouter#callModel` and `ToolType` were still
on the class and in the 1.5.56 typings. `Tool`, `tool()`, stop
conditions and `ModelResult` were not exported from any entry point.
Anyone who still calls `client.callModel()` must move to
`@openrouter/agent`.

## Testing

- `pnpm run lint`, `pnpm run typecheck`, `pnpm run build` pass.
`esm/sdk/sdk.d.ts` no longer mentions `callModel`.
- `pnpm test`: 7 files, 65 tests pass.
- CI `validate` has been failing on the live-API e2e test
`tests/e2e/image-generation.test.ts`. The same failure shows up on other
open PRs, so this change didn't cause it.

Fixes SEC-768

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Luke Parke <5702154+LukasParke@users.noreply.github.com>
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.

2 participants