Skip to content

fix: tie a codex MCP approval to the call it is actually about - #374

Merged
saucam merged 3 commits into
fix/codex-approval-decisions-369from
fix/codex-mcp-correlation-368
Oct 11, 2026
Merged

saucam merged 3 commits into
fix/codex-approval-decisions-369from
fix/codex-mcp-correlation-368

Conversation

@saucam

@saucam saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #373. This shows only #368's change. Once #373 merges, I'll retarget it to main and rebase it, and CI will run then.

What this fixes

When codex wants to use a tool on an MCP server (GitHub, say), it asks for approval naming only the server, not the call. codeoid remembered one pending call per server. So with two calls to the same server in flight, for example a delete_repo and a get_issue on GitHub, the approval could show the other call: approving what looked like get_issue could let delete_repo through.

Now:

  • When codex says which call it means (it usually does, in its message and request details), the approval shows exactly that call, and the result is paired back to it.
  • When it doesn't and several different calls to that server are waiting, codeoid declines without asking and puts the reason on the card. It never guesses.
  • An MCP server's own questions are no longer mistaken for tool approvals. These are a sign-in link or a form from the server itself, and they arrive the same way as codex's approvals. Before, one could be accepted automatically with its link never shown. Now it's shown, with its message and link, and declined; answering those properly is a follow-up.
  • Approvals codex can't tie to a started call get unique ids. Before, they all shared one id per turn, so answering one could settle another.

Verification

Ran against a real codex-cli 0.154.0 with codeoid's memory MCP server mounted. A recall call went through codex's approval request and reached codeoid's approval gate (audited as session.auto_approve tool=mcp__codeoid_memory__recall). It ran and returned its real result.

This confirms that real codex marks its tool approvals the way the new check expects, so genuine MCP approvals still work.

Tests (the fake codex follows codex 0.154's message order):

  • Two calls to one server: the right one is shown and its result paired.
  • Two different calls when codex doesn't say which: declined without asking.
  • A server's own sign-in link: shown and declined.
  • A tool name containing a quote: read in full.
  • Unpaired approvals: distinct ids.
  • An unmarked form request: declined.
  • All of these fail against the code before this change. Full suite: 3000 pass, 0 fail. Typecheck and lint are clean.

Not run: no end-to-end regression tier; codeoid has none.

Review: two rounds of security review.

  • Round 1: one High (the shared fallback id), one Medium (server questions treated as tool approvals) and Lows. All fixed with tests.
  • Round 2: clean.

Closes #368

For agents: implementation detail
  • src/daemon/providers/codex/index.ts.
    • Pending calls. #pendingMcpCalls is now Map<server, Array<{itemId, tool, input}>>. It is filled from item/started (mcpToolCall) and removed by item id on item/completed. It's cleared in runTurn and teardown.
    • Which elicitations count as tool approvals. Only (mode undefined | "form") && _meta.codex_approval_kind === "mcp_tool_call". Anything else gets a mcp__<server>__elicitation card showing {message, url, mode} and a tool_complete{success:false, declinedByBackend:true}, and is answered with {action:"decline"}. canUseTool is never called for it.
    • #resolveElicitedCall(server, params). The tool name comes from mcpToolFromMessage(message, server) (the anchored codex template, with the server checked) and the arguments from elicitedToolParams (_meta.tool_params; undefined when absent).
      • Both known: show exactly those, and take the first matching started item only for pairing (otherwise mcp-<uuid>).
      • Otherwise: filter the candidates. One candidate, or all identical, is used. Several different ones, or none with nothing to show, are declined as unshowable.
  • Fixtures. fake-codex.ts adds mcp-two (named and unnamed), mcp-server-asks, mcp-quoted and mcp-nokind.
  • Follow-up, not in this PR. Answer MCP servers' own elicitations (form/url) through codeoid's dialog surface instead of declining them.

🤖 Generated with Claude Code

saucam and others added 3 commits October 11, 2026 19:43
codex asks to approve an MCP tool call with an elicitation that names
only the SERVER. codeoid kept one pending call per server and gated
whatever was stored under that name, so with two calls to one server
in flight (a delete_repo and a get_issue on github) the approval could
show the other call's tool and arguments.

- Pending MCP calls are a per-server list, removed by item id on
  completion.
- When the elicitation names the tool (message) and carries the
  arguments (_meta.tool_params), that is what's shown — it is what codex
  will run — and the started item is only used to pair the result.
- Otherwise the one matching call is used (several identical ones are
  the same call); several DIFFERENT candidates, or none with nothing to
  show, are declined without asking, with the reason on the card.

Closes #368

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ions as tool calls

Security review of the #368 change:

- An MCP approval that couldn't be paired to a started call used the
  turn id as its id — shared by every request in the turn, so two such
  approvals collided and one answer could settle the other. Each gets a
  unique id now.
- The elicitation method also carries an MCP server's OWN questions (a
  form, an OAuth url). They were treated as tool-call approvals — in
  autonomous mode one could be accepted with its url never shown. Only
  codex's `mcp_tool_call` approval (form mode, codex_approval_kind) is a
  tool approval now; anything else is shown with its message and url and
  declined.
- The tool name is read from codex's whole message template, anchored at
  the end, so a quote in a tool name doesn't cut it short.
- The pending-call list is cleared each turn and on teardown.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

@highflame-oracle highflame-oracle Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔮 Oracle Review

🎯 Start Here

src/daemon/providers/codex/index.ts (~31 min) — Security changes in index.ts


📋 PR Summary

What this PR does: Fixes a security vulnerability where MCP tool approvals could be mismatched to the wrong pending call when multiple calls to the same server were in flight, allowing a benign-looking approval to authorize a different, potentially dangerous operation.

Key changes:

  • Approvals are now tied to the specific MCP call they reference (via tool name and params from codex's message), not just the server
  • Ambiguous or unresolvable approvals fail closed — declined without prompting — rather than guessing which call they belong to
  • Server-originated elicitations (sign-in links, forms) are no longer mistaken for tool approvals; they are shown and declined explicitly
  • Unpaired approvals receive unique IDs instead of sharing one per turn, preventing cross-settlement of separate approvals

Areas affected: MCP approval resolution logic in codex provider, Pending call tracking (now per-item arrays instead of single entry per server), Elicitation classification and handling

Testing notes: Verified against real codex-cli 0.154.0 with a memory MCP server. Test suite covers: correct pairing with two calls to one server, decline-on-ambiguity when codex doesn't specify, server sign-in link handling, quoted tool names, distinct IDs for unpaired approvals, and unmarked form request decline. Two rounds of security review completed clean.


🔍 Code Review

This is a well-executed security fix that addresses a genuine authorization bypass risk with correct fail-closed semantics. The implementation is disciplined — it never guesses when multiple candidates exist, and it cleanly separates server-originated questions from tool approvals rather than conflating them. The two rounds of security review and comprehensive test coverage give strong confidence in the fix.

What's good:

  • ✨ Fail-closed design for ambiguous approvals is the right security posture — declining without guessing prevents silent authorization of dangerous operations
  • ✨ Clear separation of concerns between tool approvals (codex-driven) and server elicitations (server-driven) eliminates a subtle class of confusion bugs
  • ✨ Thorough adversarial testing: the test cases directly target the failure modes identified in the bug (mismatched pairing, server questions, quoted tool names, shared IDs)

Review Stats: suggestion:2


Generated by Oracle - Highflame's AI Code Reviewer

Comment thread src/daemon/providers/codex/index.ts
Comment thread src/daemon/providers/codex/index.ts
@saucam
saucam merged commit f75b515 into fix/codex-approval-decisions-369 Oct 11, 2026
2 checks passed
saucam added a commit that referenced this pull request Oct 11, 2026
Brings in #371 (refuse hook-rewritten calls on backends that run their
own input). One conflict, in MockSessionProvider: both sides added a
test field (appliesToolInputRewrite, ungatedToolIds) — kept both. The
session.approvals note no longer lists #368 as a known limit; it is
fixed on this branch (#374).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
saucam added a commit that referenced this pull request Oct 11, 2026
* fix: codex runs what you approve — reply in its v2 decision shape

codeoid answered codex's item/* approval requests with the legacy
ReviewDecision words ("approved" / "denied"). codex's app-server
protocol for item/commandExecution and item/fileChange approvals takes
"accept" / "decline", so it rejected every reply as "approval request
failed": an approved command or edit never ran. item/permissions
expects a permissions GRANT, not a decision, and got the same wrong
shape.

- Reply per method: accept / decline for commands and file changes; the
  requested profile (scope: turn) or an empty grant for permissions.
- A codex item that ends `declined` was reported as a SUCCESS (only
  `failed` counted as failure). Only `completed` succeeds now; when
  codeoid's gate declined the item its card is already cancelled and
  codex's own completion no longer overwrites it.
- The fake codex validates replies against the decision enums in codex
  0.154's published schema and behaves like the real one on an invalid
  reply, so the old words can't pass the tests again. It also covers
  file-change and permissions approvals.

Verified against a real codex-cli 0.154.0: an approved `rm` now runs; a
denied one does not, and its card stays cancelled.

Closes #369

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: keep approvalToTool's doc comment on it

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: show codex's real change before approving it; never approve blind

Security review of the #369 change, which made codex approvals take
effect for the first time:

- codex 0.154's file-change approval carries no diff; it only rides the
  item's item/started. The approval showed an empty change, and
  approving applied a patch nobody saw. Command and file-change items
  are now recorded from item/started and the approval shows their real
  diff / command. A file change with no diff to show, or one that also
  asks for session-wide write access (grantRoot), is declined without
  asking, with the reason on the card.
- A command approval for a stdin write to a running terminal (its input
  isn't in the request) or with no command at all is declined without
  asking; a managed-network approval shows its host.
- Several approvals can share one item (zsh-exec-bridge subcommands):
  codex's per-callback approvalId is now part of codeoid's id, so two
  pending approvals no longer collide.
- A call a backend settles itself left the session on "waiting
  approval" for the rest of the turn; completing it with nothing parked
  moves the session on.
- The fake codex now follows 0.154's message order (item/started first,
  no diff in the request).

Verified against a real codex-cli 0.154.0: each file-change approval
showed the exact change (delete, then add) and approving applied it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: follow codex's streamed patches; close approved sub-commands

Second security review of the #369 change:

- With codex's apply_patch_streaming_events, item/started can carry
  none or part of a patch and the rest arrives as
  item/fileChange/patchUpdated. The approval now shows the latest diff.
- A sub-command approval (approvalId set) with no command fell back to
  the PARENT's command. It is declined without asking instead.
- codex completes only the parent item, so approved sub-command cards
  never closed (stuck "executing", watchdog paused). The parent's
  completion now closes them.
- Only an explicit `command` kind is approvable; an unknown future kind
  is declined. Network access with no command is `codex_network_access`
  so Bash hooks and Bash auto-approval don't cover it.
- A call the backend settled itself drops its approval mapping (a late
  click is "stale", never a buffered early decision) and is audited as
  session.backend_settled. The waiting-approval fallback also respects a
  parked provider dialog.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: decline an approval whose change moved; gate codex's network grants

Third security review of the #369 change:

- A patch that changes while its approval is being decided is declined
  even on a yes: the yes covered the diff that was shown.
- A sibling approval answered after its parent command finished is
  declined (and one arriving after it, without asking).
- A composite id is built only when codex sends both an item id and a
  callback id.
- Any network grant, alone or with a command, is codex_network_access
  and must name its host. Capability roles now cover codex's tools:
  codex_file_change is a write; codex_network_access needs network:true
  (read-only does not cover open egress).
- A call a backend settled itself is audited when it was asking for
  approval or didn't run (with success=...), and its early-click and
  form leftovers are cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: keep Bash hooks on a command with a network grant; attribute declines

Fourth security review of the #369 change:

- Naming a command-plus-network approval codex_network_access hid the
  command from Bash-scoped tool_call hooks. With a command it is Bash
  again (host shown alongside); only a bare network grant is
  codex_network_access.
- Approvals in flight on one item are counted, not flagged, and the
  "patch changed" mark is cleared in finally, so concurrent or failed
  approvals can't consume or leave each other's state.
- A sibling approval arriving after its parent finished gets a card with
  the reason instead of a silent decline.
- Providers mark a call they refused themselves (declinedByBackend);
  that, or a card that was asking for approval, is what gets audited as
  session.backend_settled — not any tool that merely failed on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: tie a codex MCP approval to the call it is actually about (#374)

* fix: tie a codex MCP approval to the call it is actually about

codex asks to approve an MCP tool call with an elicitation that names
only the SERVER. codeoid kept one pending call per server and gated
whatever was stored under that name, so with two calls to one server
in flight (a delete_repo and a get_issue on github) the approval could
show the other call's tool and arguments.

- Pending MCP calls are a per-server list, removed by item id on
  completion.
- When the elicitation names the tool (message) and carries the
  arguments (_meta.tool_params), that is what's shown — it is what codex
  will run — and the started item is only used to pair the result.
- Otherwise the one matching call is used (several identical ones are
  the same call); several DIFFERENT candidates, or none with nothing to
  show, are declined without asking, with the reason on the card.

Closes #368

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: give unpaired MCP approvals unique ids; don't treat server questions as tool calls

Security review of the #368 change:

- An MCP approval that couldn't be paired to a started call used the
  turn id as its id — shared by every request in the turn, so two such
  approvals collided and one answer could settle the other. Each gets a
  unique id now.
- The elicitation method also carries an MCP server's OWN questions (a
  form, an OAuth url). They were treated as tool-call approvals — in
  autonomous mode one could be accepted with its url never shown. Only
  codex's `mcp_tool_call` approval (form mode, codex_approval_kind) is a
  tool approval now; anything else is shown with its message and url and
  declined.
- The tool name is read from codex's whole message template, anchored at
  the end, so a quote in a tool name doesn't cut it short.
- The pending-call list is cleared each turn and on teardown.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: pin that an unmarked MCP form elicitation is declined

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
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