Repository navigation
fix: tie a codex MCP approval to the call it is actually about - #374
Conversation
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]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
There was a problem hiding this comment.
🔮 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)
Generated by Oracle - Highflame's AI Code Reviewer
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]>
* 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]>
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_repoand aget_issueon GitHub, the approval could show the other call: approving what looked likeget_issuecould letdelete_repothrough.Now:
Verification
Ran against a real codex-cli 0.154.0 with codeoid's memory MCP server mounted. A
recallcall went through codex's approval request and reached codeoid's approval gate (audited assession.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):
Not run: no end-to-end regression tier; codeoid has none.
Review: two rounds of security review.
Closes #368
For agents: implementation detail
src/daemon/providers/codex/index.ts.#pendingMcpCallsis nowMap<server, Array<{itemId, tool, input}>>. It is filled fromitem/started(mcpToolCall) and removed by item id onitem/completed. It's cleared inrunTurnandteardown.(mode undefined | "form") && _meta.codex_approval_kind === "mcp_tool_call". Anything else gets amcp__<server>__elicitationcard showing{message, url, mode}and atool_complete{success:false, declinedByBackend:true}, and is answered with{action:"decline"}.canUseToolis never called for it.#resolveElicitedCall(server, params). The tool name comes frommcpToolFromMessage(message, server)(the anchored codex template, with the server checked) and the arguments fromelicitedToolParams(_meta.tool_params; undefined when absent).mcp-<uuid>).fake-codex.tsaddsmcp-two(named and unnamed),mcp-server-asks,mcp-quotedandmcp-nokind.🤖 Generated with Claude Code