Codex bridge approval (QA-E F1) and audience-correct bridged results (QA-E F4) - #228
Open
Broccolito wants to merge 2 commits into
Open
Codex bridge approval (QA-E F1) and audience-correct bridged results (QA-E F4)#228Broccolito wants to merge 2 commits into
Broccolito wants to merge 2 commits into
Conversation
codex-cli 0.148.0 began answering an MCP tool call's approval inside the CLI when the thread's policy is `never`: `never` auto-approves only with full disk write, and Biorouter's child is read-only, so every bridged call failed with "MCP tool call requires approval, but approval policy is never" before Biorouter was asked anything. On 0.153.4 a Codex child reached no Biorouter tool at all. - thread/start now passes Codex's granular policy: mcp_elicitations true, sandbox_approval / rules / skill_approval / request_permissions false. The four child-local categories are still rejected inside the CLI with no round trip, exactly as under `never`. The variant is experimental in the app-server protocol, so it relies on the experimentalApi capability initialize already declares; a test pins the pair. - The approval arrives as mcpServer/elicitation/request marked _meta.codex_approval_kind "mcp_tool_call" (captured from a live 0.153.4 app-server; the capture is the test fixture). decide() accepts it once, and only for the `biorouter` bridge server. Any other server's tool call, and every other elicitation, is now declined instead of blanket-accepted: with mcp_elicitations allowed they reach Biorouter instead of being declined inside Codex. - item/tool/requestUserInput, the approval's fallback channel, is refused in its own response shape rather than the catch-all's. - The live Codex bridge test asserted only that the answer named the tool, which Codex's own refusal also does. It now requires a marker only a call that ran on Biorouter's side can produce.
…t (QA-E F4)
The tool bridge returned every content block a tool produced. Biorouter's
tools address the same output to two readers: developer__shell returns it
once with audience [assistant] and once, reformatted, with audience [user]
and priority 0.0. Every provider formatter sends a model only the blocks
addressed to it. Measured on 2026-09-11 (claude 2.1.266, codex-cli 0.153.4):
- neither CLI filters by audience, so the child's model read every shell
result twice in the live turn;
- Claude Code's echo of a result drops every annotation, so the mirror
stored two unlabelled blocks: "2 results" on the card, and the output
twice again in the next turn's flattened prompt;
- codex-cli cannot parse a result block carrying `priority` ("Unexpected
response type"; `audience` alone parses), so with F1 fixed every
developer__shell and text_editor view call on Codex still failed.
The bridge now answers the child with bridge::child_view: the model-facing
blocks (formats::audience::is_for_model), annotations removed. The full
result is kept on the grant under the child's own id for the call, which
both CLIs send in the tools/call _meta (claudecode/toolUseId and callId,
the same ids their result frames carry). Both mirrors store that record
instead of the lossy echo when the echo shows the child received it: the
error flag agrees and every text sent appears in the echo. A child-side
timeout or a CLI-truncated result is therefore still stored as what the
child actually saw. The transcript flattener applies the formatters'
audience filter and reads text resources, so an annotated result reaches
the next prompt once.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes F1 (HIGH) and F4 (MEDIUM) from the 2026-09-10 provider QA run on merged
main7c96d79 (~/biorouter-runs/test-drive/qa-e/report.md). There are two commits, one per finding, but they are not independent: on Codex the F1 fix alone moves the failure from an approval refusal to a parse error, and only F4's bridge change clears that. So they ship as one PR. Everything below was measured against the CLIs on this machine:claude2.1.266 andcodex-cli0.153.4, both signed in.F1: Codex could not run any BioRouter tool (
3b296785)Root cause, measured. Codex refuses the call inside the CLI, before BioRouter is asked anything. Read in the codex-cli source at the matching tags, then reproduced with a raw
codex app-serverdriver and a stdio MCP server (12 s, no BioRouter build):readOnlyHint. An unannotated tool counts as destructive.approvalPolicy: "never"auto-approves that ask only when the sandbox has full disk write. BioRouter's child is read-only.nevergetsReviewDecision::denied("MCP tool call requires approval, but approval policy is never")inside the CLI. No request is sent, so nodecidearm could ever have helped.The report's premise needs one correction. The approval is not a separate request type.
mcp_tool_call_approvalis a question-id prefix. The approval rides the existingmcpServer/elicitation/requestmethod, marked_meta.codex_approval_kind: "mcp_tool_call", and the old blanket-accept elicitation arm would have accepted it had it ever arrived. Captured from 0.153.4 under the new policy:{"method":"mcpServer/elicitation/request","id":0,"params":{"threadId":"…","turnId":"…","serverName":"biorouter","mode":"form", "_meta":{"codex_approval_kind":"mcp_tool_call","persist":["session","always"],"tool_params":{"text":"hi"},"tool_params_display":[…]}, "message":"Allow the biorouter MCP server to run tool \"echo\"?","requestedSchema":{"type":"object","properties":{}}}}Fix.
thread/startnow sends Codex's own per-category form ofnever:{"granular": {"mcp_elicitations": true, "sandbox_approval": false, "rules": false, "skill_approval": false, "request_permissions": false}}. Afalsecategory is "automatically rejected instead of shown to the user", exactly as undernever. So the child's own command execution, file changes, rules, skills and permission requests stay refused inside the CLI with no round trip. The sandbox, feature disables and isolatedCODEX_HOMEare untouched. The variant is#[experimental]in the app-server protocol and identical in 0.147.0, so it depends on theexperimentalApicapabilityinitializealready declares. A test pins the two together.decidegains an explicit arm. It accepts the tool-call approval once (nopersist), and only for thebiorouterbridge server, whose calls BioRouter inspects and gates again. Another server's tool call and every other elicitation are now declined. Withmcp_elicitationsallowed, those reach BioRouter instead of being declined inside Codex, and an empty accept would submit a form nobody saw.item/tool/requestUserInput, the approval's fallback channel whentool_call_mcp_elicitationis off, is refused in its own schema shape ({"answers":{}}) rather than the catch-all's.F4: every bridged result reached the child twice (
2f61d274)Root cause, measured. The report blamed the mirror, but the duplicate is created one step earlier: the bridge handed the child every content block.
developer__shellreturns its output once withaudience:["assistant"]and once withaudience:["user"], priority:0.0, and every provider formatter sends a model only the first. Three measurements:audiencetool_result.contentandtool_use_resultalike) and rewrites an embedded resource as[Resource from biorouter at <uri>] <text>priority. In one turn over three variants,audiencealone completed, whilepriority: 0.0alone and the shell's exact shape both failed withUnexpected response typedeveloper__shell/text_editor viewcall would still have failed.Fix.
bridge::child_view(result): onlyaudience::is_for_modelblocks (the formatters' own rule), with annotations removed.tools/call_meta. Claude sendsclaudecode/toolUseId(the same id as itstool_use); Codex sendscallId(the same id as itsmcpToolCallitem). Both measured.transcript.rsapplies the formatters' audience filter and reads text resources, so an annotated result reaches the next prompt once.Runtime (own sandboxed
biorouterdon :53211, driven over HTTP, prompt verbatim from QA)"Use the shell tool to print the current date, nothing else."Codex /
gpt-6-astra: the answer wasFri Sep 11 02:16:16 PDT 2026and the card reads "1 result ready". Stored response:{"type":"toolResponse","id":"exec-7f17d205-334f-4636-a6fb-d8a26bd90491","toolResult":{"status":"success","value":{"content":[ {"type":"text","text":"Fri Sep 11 02:16:16 PDT 2026\n","annotations":{"audience":["assistant"]}}, {"type":"text","text":"Fri Sep 11 02:16:16 PDT 2026\n","annotations":{"audience":["user"],"priority":0.0}}], "isError":false}},"metadata":{"biorouterProviderExecuted":"bridged"}}Claude Code /
claude-fable-5-1: the answer wasFri Sep 11 02:19:34 PDT 2026and the card reads "1 result ready" (QA-E measured "2 results ready"). Stored response:{"type":"toolResponse","id":"toolu_01FpuDMcXwkdRAHzRVaDFLtR","toolResult":{"status":"success","value":{"content":[ {"type":"text","text":"Fri Sep 11 02:19:34 PDT 2026\n","annotations":{"audience":["assistant"]}}, {"type":"text","text":"Fri Sep 11 02:19:34 PDT 2026\n","annotations":{"audience":["user"],"priority":0.0}}], "isError":false}},"metadata":{"biorouterProviderExecuted":"bridged"}}Claude's echo cannot carry annotations, so their presence shows the bridge's record was stored. For a baseline, the ordinary path (
versa_azure, withcode_executionremoved so the model callsdeveloper__shelldirectly) stores the same two-block shape, which is what the card's "1 result" counts. The one remaining difference is noted under Follow-up.Tests
The brief's
cargo test -p biorouter --lib a b cform errors out, so filters go after--. All runs useBIOROUTER_DISABLE_KEYRING=true.cargo test -p biorouter --lib providers::codex(F1)"never"; a foreign server's tool-call approval and a form elicitation were accepted;requestUserInputgot{"decision":"decline"}cargo test -p biorouter --lib -- providers::coding_agent providers::codex providers::claude_code(F4)cargo test -p biorouter-server --test tool_bridge_routes…"annotations":{"audience":["user"],"priority":0.0}-- --ignored, real CLIs): the tightened Codex marker test, Codex approval + realtext_editor view, and Claude Code + real extensionCodex refused the call itself instead of asking Biorouter: MCP tool call requires approval, but approval policy is never"Fail-before" means the pre-fix behaviour behind the new signatures: stage-1 plumbing with the old bodies, then the real bodies. Among the new tests is the one F4 asks for:
mirror::recorded_result_tests::a_two_block_annotated_result_mirrors_to_one_model_facing_block_with_audience_preserved, fed the QA run's own two-identical-block echo.cargo fmtis clean.cargo clippy -p biorouter -p biorouter-server --all-targets -- -D warningsis clean. Thetoo_many_linesbaseline pass (thescripts/clippy-baseline.shparser) finds no new entries: 4 pre-existing, 0 new. Other workspace crates are unchanged.Follow-up, not in this PR
Bridged tool output is never wrapped in the
<tool-output untrusted="true">frame or scanned for injection/PII (Agent::integrate_tool_result→guardrails::tool_output::guard_tool_result), either for the child or in the stored transcript. The samedatecall stores framed text onversa_azureand raw text on both coding agents. This predates the PR and is filed as a separate task.Docs:
docs/providers/coding-agents/{README,child-agent-isolation,how-it-works,tool-bridge}.md.🤖 Generated with Claude Code