Skip to content

fix(qoder): refuse vendor CLI scaffolding in routed output - #4234

Merged
lidge-jun merged 3 commits into
devfrom
codex/260911-l6-qoder-scaffold
Sep 11, 2026
Merged

fix(qoder): refuse vendor CLI scaffolding in routed output#4234
lidge-jun merged 3 commits into
devfrom
codex/260911-l6-qoder-scaffold

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

The qoder route is contractually a text and reasoning surface. buildQoderArgs launches the CLI with --tools "" --strict-mcp-config --setting-sources "" --max-turns 1 --no-session-persistence, and both coding-agent/protocol.ts and coding-agent/turn.ts state that Codex retains tool control and vendor tools are never invoked.

The vendor CLI does not always honour that. Two things reached the client as assistant text: an MCP lazy-loading <system-reminder> block enumerating the operator's configured MCP servers from ~/.qoder/mcp.json and from plugins, and vendor tool-call markup opened as <functions.exec> and closed as </invoke> — mismatched, which is what a model emitting remembered markup looks like rather than a serializer's output.

The proxy-side hole is one line of trust: mapRawStreamEvent filters frame types and forwards a text delta's contents unread, so whatever the vendor's agent layer puts in the text channel is relayed. The strongest version of this leak publishes the operator's MCP server inventory to whoever is reading the turn.

This adds a streaming scaffolding filter in src/adapters/qoder/scaffold-guard.ts and wraps the emit callback the qoder adapter hands to runCodingAgentTurn. That wrapper is the last qoder-specific point in the path, which is why the guard sits there rather than in the parser every coding-agent CLI shares.

Two shapes, two answers:

  • A complete <system-reminder>…</system-reminder> block is recognizable and self-delimiting, so it is removed and the answer around it survives.
  • Anything else carrying a scaffolding signature — <functions., <invoke name=, </invoke>, or a </system-reminder> with no opener — fails the turn closed. A partial tool-call block has no reliable end, and the prose around it may be the vendor's own agent narration rather than the model's answer, so repairing it would be guesswork. The error names the marker class and nothing else: echoing the offending text back, into a message a user may paste into an issue, would publish the thing the guard exists to contain.

It is a stream filter, not a regex over a finished string. A marker can be split across deltas, so a tail that is still a possible marker prefix is held rather than emitted, and the terminal event flushes both channels first. That flush is load-bearing in a non-obvious way: isContentEvent in empty-completion-guard.ts counts only non-empty text_deltas as content, so an answer swallowed in full and followed by done would reach the client as a successful but empty turn instead of the refusal it is. The suppressed block is discarded as it arrives, retaining only enough trailing bytes to spot a split closer, with a 64 KiB ceiling on how much one unterminated block may swallow.

Decisions the issue left open, and how they were called:

  • Qoder only, not the shared coding-agent path. The leak is reported only for Qoder; CodeBuddy runs the same turn code and is not part of this report. The filter is a pure module, so lifting it later is small — but it should be driven by a report, not by symmetry.
  • Fail closed rather than silently strip, for tool-call markup. Deleting markup leaves a mutilated answer and no signal that the route's contract was violated.
  • Not retryable. The leak is intermittent, so a retry would often succeed. An automatic retry spends the operator's vendor credits on a contract violation the proxy cannot influence, and hiding an intermittent violation is how it stays unfixed.
  • Vendor tool_use frames are left alone. mapRawStreamEvent maps them to tool_call_start. That is a typed frame rather than leaked text, the issue reports the text channel, and protocol.ts documents that seam as deliberately prepared for a future tool bridge.
  • Known false positive. A turn that legitimately discusses <system-reminder> or <functions.…> syntax will be stripped or refused. That is the accepted cost of failing closed on a route whose leak publishes the operator's MCP inventory.
  • Not addressed here: making the CLI actually run with MCP disabled. The installed @qoder-ai/qodercli bundle still contains the reminder builder and appears to initialize it despite the flags, so that fix lives in the vendor. This guard is the containment that does not depend on the vendor agreeing.

Design notes are in devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md.

Closes #4190

Verification

  • bun run test, bun run test:changed, bun run typecheck and bun run build:gui: NOT RUN, by operator instruction for this dispatch round. Hosted CI on the exact pushed head is the only product evidence this change offers.
  • New focused regression test: tests/providers/qoder-scaffold-guard.test.ts, beside the existing qoder-adapter.test.ts, registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. It covers block removal, a marker split across three deltas, the held tail released on flush, fail-closed on tool-call markup and on a stray closer, the unterminated-block case, the latch after a trip, and the wrapper's terminal handling including the flush-before-done rule and forwarding a vendor error rather than replacing it. Two cases assert that neither the leaked server list nor the leaked shell command appears in the refusal message.
  • The emit seam was confirmed by reading coding-agent/turn.ts: every client-visible event for this route leaves through the callback passed to runCodingAgentTurn, including the early failures before spawn and the post-reap terminals.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Stacked on #4232 (targets codex/260911-l6-streaming-tools); retarget to dev once the parent lands.

The qoder route is contractually a text and reasoning surface: the CLI is
launched with its own tools and MCP servers disabled and Codex owns tool
control. The vendor CLI does not always honour that. An MCP lazy-loading
<system-reminder> enumerating the operator's configured MCP servers, and
tool-call markup opened as <functions.exec> and closed as </invoke>, have both
reached the client as assistant text, because the shared stream-json parser
filters frame types and forwards a text delta's contents unread.

Add a streaming scaffolding filter and wrap the emit callback the qoder adapter
hands to runCodingAgentTurn. A complete <system-reminder> block is recognizable
and self-delimiting, so it is removed and the answer around it survives.
Anything else carrying a scaffolding signature is not repairable by guesswork,
so the turn fails closed with an error that names the marker class and nothing
else -- echoing the offending text back would publish the server inventory this
guard exists to contain.

Markers can be split across deltas, so a tail that is still a possible marker
prefix is held and released on flush. The terminal event flushes first: an
answer swallowed in full and followed by done would otherwise reach the
empty-completion guard as a successful but empty turn rather than a refusal.

The suppressed block is discarded as it arrives, retaining only enough trailing
bytes to spot a split closer, with a 64 KiB ceiling on how much one unterminated
block may swallow before the turn is refused.

Closes #4190
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 10, 2026 23:04
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 2eb3ded3-f72f-4d91-97fb-ebdf7c712998

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T23:09:04.173269Z 785a3a2 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 10, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 785a3a2985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adapters/qoder/scaffold-guard.ts Outdated
Comment on lines +116 to +118
const lowered = buffer.toLowerCase();
for (const marker of ALL_MARKERS) {
const at = lowered.indexOf(marker);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve source offsets during case-insensitive scanning

When a Qoder delta contains a length-expanding Unicode character before a marker, such as U+0130 (İ), lowered.indexOf() returns an offset into the longer lowercased string, but that offset is then applied to the original buffer. For example, a run of İ characters before <system-reminder> causes cleared to include the opening tag and part or all of the protected MCP inventory before the turn is refused. Use a case-insensitive search that preserves original-string offsets, or explicitly map matched offsets back to the source string. This defeats the secret-containment purpose of the guard and is therefore release-blocking.

AGENTS.md reference: AGENTS.md:L357-L363

Useful? React with 👍 / 👎.

Comment on lines +83 to +86
const tail = textFilter.flush();
const reasoning = thinkingFilter.flush();
if (tail.text) emit({ type: "text_delta", text: tail.text });
if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Flush held reasoning before forwarding later text

When a reasoning delta ends with a possible marker prefix such as < and the next event starts answer text, thinkingFilter retains that character while textFilter immediately emits the answer; the terminal flush then emits the retained reasoning after the answer. The bridge consequently sees reasoning, text, then a second reasoning item, changing the provider's event order and replay semantics. Flush a channel's pending suffix before forwarding a later semantic event from another channel, or retain pending fragments in one ordered event buffer.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 70 / 80

이 PR은 L6 스트리밍/툴 레인에서 #4190을 닫는 작업입니다. 지금 dev HEAD는 df65a2587이고, qoder 쪽 계약은 이미 문서와 런처 플래그로 “텍스트·추론만, 툴은 Codex가 소유”로 고정되어 있습니다. buildQoderArgs--tools "" --strict-mcp-config --setting-sources "" --max-turns 1 --no-session-persistence로 CLI를 띄우고, src/adapters/coding-agent/protocol.ts / turn.ts도 벤더 툴을 부르지 않는다고 말합니다. 그런데 벤더 CLI는 그 계약을 항상 지키지 않습니다. 제보 내용대로 MCP lazy-loading <system-reminder> 블록이 로컬 ~/.qoder/mcp.json과 플러그인에 있는 서버 목록을 어시스턴트 텍스트로 흘렸고, <functions.exec>…</invoke>처럼 짝이 안 맞는 툴콜 마크업도 그대로 클라이언트에 도착했습니다. 구멍은 한 줄입니다. 공유 파서 mapRawStreamEvent는 프레임 타입만 걸러 주고, text_delta 내용은 읽지 않은 채 전달합니다.

이 PR은 그 내용을 qoder 어댑터의 마지막 지점에서 막습니다. 새 src/adapters/qoder/scaffold-guard.ts의 스트림 필터와, adapter.tsguardQoderScaffolding(emit) 래퍼가 runCodingAgentTurn에 넘기는 emit을 감쌉니다. 공유 coding-agent 파서에 넣지 않은 이유도 패킷에 적혀 있습니다. CodeBuddy 등 같은 turn 코드를 쓰는 다른 벤더는 이 제보 범위가 아니고, 레인이 소유하는 건 src/adapters/qoder/입니다. 동작은 두 갈래입니다. 완성된 <system-reminder>…</system-reminder>는 잘라 내고 주변 답은 살립니다. <functions., <invoke name=, </invoke>, 열린 적 없는 </system-reminder>는 복구가 추측이 되므로 턴을 닫고, 에러 코드 vendor_scaffold_detected만 남깁니다. 거절 메시지에 서버 목록이나 셸 명령을 다시 실지 않습니다. 마커는 델타 사이에 잘릴 수 있어서, 아직 마커 prefix일 수 있는 꼬리는 붙잡아 두고, done/error/incomplete 전에 flush합니다. 이 flush가 빠지면 empty-completion-guard가 “성공한 빈 턴”으로 읽을 수 있습니다. 미종료 블록은 64KiB 천장으로 끊습니다. retryable: false도 의도입니다. 간헐적이라 재시도하면 자주 성공하지만, 그건 운영자 벤더 크레딧을 쓰면서 계약 위반을 가리는 일입니다. 테스트는 tests/providers/qoder-scaffold-guard.test.ts가 블록 제거·분할·flush·fail-closed·래퍼 단말을 고정했고 layout에도 등록되었습니다.

베이스 브랜치 (codex/260911-l6-streaming-tools) - dev가 아니라 L6 스택(#4232 헤드) 위입니다. enforce-target이 이미 실패로 찍혀 있습니다. 스택이 맞다면 드래프트/예외 규칙에 맞게 두거나, #4232가 dev에 들어간 뒤 리베이스해야 합니다. 지금 상태로 dev에 직접 머지하면 안 됩니다.

src/adapters/qoder/scaffold-guard.ts REMINDER_OPEN / UNREPAIRABLE_MARKERS - 정상 답변이 마크업 문법을 설명하는 턴은 잘리거나 거절됩니다. 패킷이 그 비용을 인정한 선택이지만, 문서/릴리즈 노트에 “qoder 경로는 이 문자열을 그대로 말하면 거절될 수 있다”를 한 줄 남길지 정해야 합니다.

src/adapters/coding-agent/protocol.ts mapRawStreamEvent의 typed tool_usetool_call_start - 텍스트 채널 누수는 막았지만, 타입 있는 벤더 툴 프레임은 그대로입니다. 패킷도 “이슈 범위 밖·미래 브릿지용”으로 남겨 두었습니다. L6에서 이어서 막을지, 별도 이슈로 둘지 정하면 됩니다.

메인테이너의 판단이 필요한 지점

너의 추천
보안 누수( MCP 인벤토리 유출 )라 방향은 맞고 테스트도 핵심을 고정합니다. #4232(또는 L6 베이스)가 dev에 안정적으로 들어간 뒤 이 PR을 이어 머지하세요. 지금은 베이스/enforce-target부터 정리하는 게 먼저입니다. 머지 시 #4190은 Closes로 같이 닫히게 두면 됩니다.

이 댓글은 grok-bot이 작성했습니다

… a refusal

The guard keeps the text that precedes a scaffolding marker, on the reasoning
that it is the model's own answer. That holds for the first marker in a turn. It
does not hold once a reminder block has already been suppressed on that channel:
the text between that block and the next unrepairable marker is the region the
vendor was narrating in, and in the reported leak it carries the MCP server list.

Two inputs published it and then refused the turn, which is the worst of both:

    <system-reminder>a</system-reminder>\n## Connected MCP servers\n- deploy-keys</system-reminder>
    Status.<reminder block>\n- deploy-keys\n<functions.exec>...</invoke>

The first forwarded "## Connected MCP servers\n- deploy-keys", the second
"\n- deploy-keys\n", before emitting vendor_scaffold_detected.

The prefix is now kept only while the channel has not yet suppressed a block, so
an answer that simply precedes a leak still survives and a post-block region does
not. Both inputs have a case.

Refs #4190
…uard

An adversarial read of the filter found three inputs that still put MCP server
names or a vendor command into the text channel.

A nested reminder ended at the inner closer, so the outer block's remaining body
was forwarded as the model's answer and the turn SUCCEEDED. Suppression now
counts depth and only the closer that unwinds the last level ends it; the
retained tail widened to the longest marker so a split opener is still seen.

A closer with no opener kept the text ahead of it. That text is the lost block's
body, not an answer that happened to precede a leak, so a closing marker no
longer contributes a prefix.

"<invoke name=" did not match a bare "<invoke>", so the command inside such a
block shipped before the refusal. "<invoke>" is now its own marker, which leaves
"<invoked" in prose alone.

Separately, the opener is matched without its ">" so a truncated or self-closed
tag still suppresses, which made "<system-reminders>" in an ordinary answer open
a block and refuse the turn. It now needs a token boundary.

Refs #4190
Base automatically changed from codex/260911-l6-streaming-tools to dev September 11, 2026 00:38
@lidge-jun
lidge-jun merged commit 0589579 into dev Sep 11, 2026
31 checks passed
@lidge-jun
lidge-jun deleted the codex/260911-l6-qoder-scaffold branch September 11, 2026 00:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant