Skip to content

Codex bridge approval (QA-E F1) and audience-correct bridged results (QA-E F4) - #228

Open
Broccolito wants to merge 2 commits into
mainfrom
fix/codex-mcp-approval-bridge-audience
Open

Codex bridge approval (QA-E F1) and audience-correct bridged results (QA-E F4)#228
Broccolito wants to merge 2 commits into
mainfrom
fix/codex-mcp-approval-bridge-audience

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Fixes F1 (HIGH) and F4 (MEDIUM) from the 2026-09-10 provider QA run on merged main 7c96d79 (~/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: claude 2.1.266 and codex-cli 0.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-server driver and a stdio MCP server (12 s, no BioRouter build):

  • An MCP tool call asks for approval unless its server pre-approved it or its annotations say 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.
  • From codex-cli 0.148.0 (bisected over the tags: absent in 0.147.0, present from 0.148.0 through 0.153.4), an ask under never gets ReviewDecision::denied("MCP tool call requires approval, but approval policy is never") inside the CLI. No request is sent, so no decide arm could ever have helped.

The report's premise needs one correction. The approval is not a separate request type. mcp_tool_call_approval is a question-id prefix. The approval rides the existing mcpServer/elicitation/request method, 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/start now sends Codex's own per-category form of never: {"granular": {"mcp_elicitations": true, "sandbox_approval": false, "rules": false, "skill_approval": false, "request_permissions": false}}. A false category is "automatically rejected instead of shown to the user", exactly as under never. 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 isolated CODEX_HOME are untouched. The variant is #[experimental] in the app-server protocol and identical in 0.147.0, so it depends on the experimentalApi capability initialize already declares. A test pins the two together.
  • decide gains an explicit arm. It accepts the tool-call approval once (no persist), and only for the biorouter bridge server, whose calls BioRouter inspects and gates again. Another server's tool call and every other elicitation are now declined. With mcp_elicitations allowed, 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 when tool_call_mcp_elicitation is off, is refused in its own schema shape ({"answers":{}}) rather than the catch-all's.
  • The live Codex bridge test could not catch F1. It asserted only that the answer named the tool, and Codex's own refusal names the tool too. It now requires a random marker that exists only in BioRouter's dispatcher.

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__shell returns its output once with audience:["assistant"] and once with audience:["user"], priority:0.0, and every provider formatter sends a model only the first. Three measurements:

What Consequence
Neither CLI filters by audience The child's model read each shell result twice in the live turn.
Claude Code's echo drops every annotation (tool_result.content and tool_use_result alike) and rewrites an embedded resource as [Resource from biorouter at <uri>] <text> Annotations cannot be recovered from the echo, so the mirror stored two unlabelled blocks: "2 results", and the output twice again in the next flattened prompt.
codex-cli cannot parse a result block carrying priority. In one turn over three variants, audience alone completed, while priority: 0.0 alone and the shell's exact shape both failed with Unexpected response type With F1 fixed, every Codex developer__shell / text_editor view call would still have failed.

Fix.

  • The route answers the child with bridge::child_view(result): only audience::is_for_model blocks (the formatters' own rule), with annotations removed.
  • The grant keeps the full result under the child's own id for the call, taken from tools/call _meta. Claude sends claudecode/toolUseId (the same id as its tool_use); Codex sends callId (the same id as its mcpToolCall item). Both measured.
  • Both mirrors store that record instead of the lossy echo, but only when the echo shows the child received it: the error flag agrees and every sent text appears in the echo. A child-side timeout (workspace_watch advertises 600-second waits but Claude Code kills bridged calls at 60 seconds #110) or a CLI-truncated result is still stored as what the child actually saw.
  • transcript.rs applies the formatters' audience filter and reads text resources, so an annotated result reaches the next prompt once.

Runtime (own sandboxed biorouterd on :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 was Fri Sep 11 02:16:16 PDT 2026 and 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 was Fri Sep 11 02:19:34 PDT 2026 and 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, with code_execution removed so the model calls developer__shell directly) 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 c form errors out, so filters go after --. All runs use BIOROUTER_DISABLE_KEYRING=true.

Suite Fail-before After
cargo test -p biorouter --lib providers::codex (F1) 4 failed, 40 passed: policy was "never"; a foreign server's tool-call approval and a form elicitation were accepted; requestUserInput got {"decision":"decline"} 44 passed
cargo test -p biorouter --lib -- providers::coding_agent providers::codex providers::claude_code (F4) 12 failed, 220 passed: child view had 2 annotated blocks; no call id read; nothing recorded; mirrors stored the echo; transcript sent both copies 232 passed
cargo test -p biorouter-server --test tool_bridge_routes 1 failed: the child was handed …"annotations":{"audience":["user"],"priority":0.0} 7 passed, 8 ignored
Live (-- --ignored, real CLIs): the tightened Codex marker test, Codex approval + real text_editor view, and Claude Code + real extension the tightened Codex test fails against the old policy: Codex refused the call itself instead of asking Biorouter: MCP tool call requires approval, but approval policy is never 3 passed

"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 fmt is clean. cargo clippy -p biorouter -p biorouter-server --all-targets -- -D warnings is clean. The too_many_lines baseline pass (the scripts/clippy-baseline.sh parser) 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_resultguardrails::tool_output::guard_tool_result), either for the child or in the stored transcript. The same date call stores framed text on versa_azure and 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

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.
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.

1 participant