Skip to content

fix: codex runs what you approve, and shows it first - #373

Merged
saucam merged 8 commits into
mainfrom
fix/codex-approval-decisions-369
Oct 11, 2026
Merged

saucam merged 8 commits into
mainfrom
fix/codex-approval-decisions-369

Conversation

@saucam

@saucam saucam commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Now also contains #374 (codex MCP approvals tied to the right call, #368). #374 was stacked on this branch, so merging it landed its change here. Merging this PR ships both. main has been merged in, which picked up #371; the one conflict was a test field in the mock provider, and both sides were kept.

What this fixes

On codex, anything you approved never actually ran. codeoid answered codex's approval requests with outdated decision words. Current codex rejected every reply as a failed approval, so approved commands and file edits silently didn't happen, and codeoid still marked them completed. Approvals now take effect: approve runs it, deny doesn't, and the record shows what really happened.

Making approvals real exposed what the approver was being shown. This PR also makes sure every codex approval shows what codex will actually do:

  • File edits show the real change. codex sends the diff separately from the approval request, so the approval card used to be empty, and approving would have applied an unseen patch. The card now shows the actual diff, including patches that keep streaming in.
  • Never a blind yes. codeoid declines without asking, with the reason on the card, when it can't show what would happen:
    • a change with no diff;
    • a request that also asks for session-wide write access;
    • input to a running terminal;
    • a command it isn't told;
    • an unknown request type;
    • network access without a host.
  • A changed request isn't covered by a yes. If the patch changes while you're deciding, or the parent command finishes first, the yes is turned into a decline.
  • Sub-commands are tracked one by one. When one command spawns several approvals, each has its own id, and each closes when the command finishes.
  • Network access is its own approval. A bare network grant is codex_network_access, so Bash rules don't auto-approve it. A command that needs the network stays Bash, so Bash hooks still see it, with the host shown alongside.
  • Roles cover codex's tools. A read-only role can't approve codex file edits, and opening network access needs a role that allows network.
  • Status and records. A declined call no longer shows as "completed" or leaves the session showing "waiting approval". A call codex declined on its own is recorded in the audit log.

Verification

Ran against a real codex-cli 0.154.0 (codeoid start --local, the actual CLI):

  • An approved rm a.txt deleted the file. A denied rm b.txt kept it, and its card stayed cancelled.
  • An edit to note.txt produced two approvals. One showed the delete with the old contents, the other the add with the new; approving both left goodbye.
  • Before the fix, with the same setup on main, an approved rm left the file in place. codex's own log recorded Rejected("approval request failed").

Tests:

  • The fake codex used by the tests now checks replies against codex 0.154's published schema and follows its real message order (the old fixture used the same wrong words, which is why CI passed).
  • New provider tests cover: the reply shapes for commands, file changes and permissions; the diff coming from the started item; streamed patches; each decline-without-asking case; sibling approvals; network naming.
  • Session tests cover calls a backend settles itself, and the role-gate additions.
  • All of them fail against the old code.
  • Full suite: 2995 pass, 0 fail. Typecheck and lint are clean.

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

Review: four rounds of security review, the cap. Every round's findings were fixed with tests:

  • Round 1: High (blind file-change approval) and two Medium.
  • Round 2: three Medium.
  • Round 3: Medium/Low.
  • Round 4: one Medium regression from round 3 (Bash hooks lost on a command that needs the network) and Lows.

Interaction with #371: #371 makes codex refuse calls a hook rewrote, since codex runs the input it proposed. That matters more once approvals take effect, which is what this PR does. The two touch the same file in separate places; whichever merges second needs a trivial rebase.

Follow-ups filed: #368 (codex MCP calls matched by server name), #372 (Telegram approvals don't show the input).

Closes #369
Closes #368

For agents: implementation detail
  • src/daemon/providers/codex/index.ts.
    • approvalReply(method, params, allow): {decision:"accept"|"decline"} for item/commandExecution|fileChange/requestApproval. For item/permissions/requestApproval, allow returns {permissions: params.permissions ?? {}, scope:"turn"} and deny returns {permissions:{}}.
    • Recorded items. #startedItems stores command and file-change items from item/started, updated (as a fresh object) from item/fileChange/patchUpdated. It's cleared per turn and on item/completed.
    • approvalToTool(method, params, started) builds the shown input and returns unshowable for each of these:
      • a file change with no diff, or with grantRoot;
      • kind other than command;
      • a sub-command with an empty command (it never falls back to the parent's);
      • an empty command;
      • a network grant without a host.
      • Unshowable requests are declined with tool_start + tool_complete{success:false, declinedByBackend:true}.
    • Composite id ${itemId}:${approvalId}, used only when both are present. #openSubApprovals closes approved siblings on the parent's completion. #completedParents declines late siblings.
    • In-flight tracking. #approvalsInFlight is a refcount, and #patchChangedInFlight turns a yes into a decline when the patch changed during the decision.
    • Completion. success is true only for status === "completed". #declinedItems suppresses codex's declined completion for calls codeoid declined, because the card is already cancelled.
  • src/daemon/session.ts.
    • tool_complete moves waiting_approval to thinking when no approvals or UI requests are pending.
    • A call that completes without reaching the gate drops its approval mapping and leftovers. It is audited as session.backend_settled when its card was waiting or the provider set declinedByBackend.
  • ProviderEvent.tool_complete.declinedByBackend? is a new optional field.
  • tool-safety.ts. codex_file_change is now in WRITE_TOOLS. The new OPEN_NETWORK_TOOLS = {codex_network_access} is denied unless network: true.
  • Fixtures. fake-codex-validate.ts has approvalReplyError (0.154 enums). fake-codex.ts follows the 0.154 message order and has new scenarios. MockSessionProvider.ungatedToolIds lets a test announce a call without gating it.

🤖 Generated with Claude Code

saucam and others added 6 commits October 11, 2026 18:49
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]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
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]>
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]>
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]>
…lines

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]>
@saucam

saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

@highflame-oracle review

@saucam

saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

@highflame-oracle review

@saucam saucam closed this Oct 11, 2026
@saucam saucam reopened this Oct 11, 2026
saucam and others added 2 commits October 11, 2026 22:29
* 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]>
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
saucam merged commit 609b982 into main Oct 11, 2026
4 checks passed
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.

On codex, approved commands and file changes fail instead of running Codex can show the wrong MCP call when two calls to one server are in flight

2 participants