From e0571c281c734374c77340b86d250315cb04cc01 Mon Sep 17 00:00:00 2001 From: Terry Yu Date: Wed, 30 Sep 2026 10:13:43 +0800 Subject: [PATCH 1/5] fix(devin): sanitize Cognition blocklist phrase beyond tool descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cognition's request blocklist also fires on phrases in the #2 system prompt and prompt text — Codex's escalation boilerplate ("asking the user if they want to allow the action in `justification` parameter") is refused with permission_denied, denying every turn of any Codex conversation. Add the phrase to COGNITION_BLOCKLIST_REWRITES with a live-verified replacement and run the sanitizer on the system prompt and each ChatMessagePrompt's joined text, not only tool descriptions. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/adapters/devin/cloud-direct/chat.ts | 41 +++++++++++++++++++++---- structure/providers-and-adapters.md | 2 +- tests/providers/devin-adapter.test.ts | 39 ++++++++++++++++++++++- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 61a07b329ae..51a0ab6f7d4 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -225,7 +225,7 @@ function encodeChatMessagePrompt( ): Buffer { const textParts = content.filter((p): p is { type: 'text'; text: string } => p.type === 'text'); const imageParts = content.filter((p): p is { type: 'image'; mimeType: string; base64Data: string; caption?: string } => p.type === 'image'); - const joined = textParts.map((p) => p.text).join('\n'); + const joined = sanitizeTextForCognition(textParts.map((p) => p.text).join('\n')); const parts: Buffer[] = [ // #1 message_id. The verified turn-1 capture stamps one on every prompt. encodeString(1, crypto.randomUUID()), @@ -617,6 +617,25 @@ const MAX_TOOL_DESC_LEN = 6998; * symptom was the adapter's own blocklist message pointing back at this * table, which is why they are named here rather than left to the next person * to re-bisect. + * + * The fourth entry is not a tool description at all: it is a sentence from + * Codex's `` escalation boilerplate, which Codex + * injects into the system prompt. Binary-search against a live account + * isolated the trigger to the clause "asking the user if they want to allow + * the action in `justification` parameter" — the whole bullet was required + * (every sub-phrase passed alone), matching flexibly on whitespace and case + * like the other Codex entries. The rewrite swaps "if they want to allow" + * for "whether to allow", verified live to clear the filter while preserving + * the instruction's meaning. + * + * Scope note: the first entries only ever appeared in tool descriptions, but + * this one lives in request #2 (the system prompt) and could equally appear + * in conversation history — e.g. a tool result that returns a file quoting + * the Codex prompt. The sanitizer therefore runs on every text the adapter + * puts on the wire: tool descriptions, the #2 system prompt, and each + * ChatMessagePrompt's joined text. Rewriting content the model quoted is + * semantic-preserving, the same trade-off the tool-description rewrites + * already accepted. */ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ [/\bTakes a task_id parameter identifying the task\b/g, "Accepts a task_id parameter identifying the task"], @@ -628,10 +647,14 @@ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ /\bWrites\s+characters\s+to\s+an\s+existing\s+unified\s+exec\s+session\s+and\s+returns\s+recent\s+output\b/gi, "Sends characters to an existing unified exec session and returns recent output", ], + [ + /\basking\s+the\s+user\s+if\s+they\s+want\s+to\s+allow\s+the\s+action\s+in\s+`?justification`?\s+parameter\b/gi, + "asking the user whether to allow the action in the `justification` parameter", + ], ]; -function sanitizeToolDescriptionForCognition(description: string): string { - let out = description; +function sanitizeTextForCognition(text: string): string { + let out = text; for (const [pattern, replacement] of COGNITION_BLOCKLIST_REWRITES) { out = out.replace(pattern, replacement); } @@ -640,12 +663,17 @@ function sanitizeToolDescriptionForCognition(description: string): string { /** Test-only: exercise the Cognition blocklist rewrite directly. */ export function sanitizeToolDescriptionForCognitionForTests(description: string): string { - return sanitizeToolDescriptionForCognition(description); + return sanitizeTextForCognition(description); +} + +/** Text sanitizer applied to every string the adapter puts on the wire. */ +export function sanitizeTextForCognitionForTests(text: string): string { + return sanitizeTextForCognition(text); } /** Description as transmitted on the Cognition wire, also used by overflow estimation. */ export function prepareToolDescriptionForCognition(description: string): string { - const rawDesc = sanitizeToolDescriptionForCognition(description); + const rawDesc = sanitizeTextForCognition(description); return rawDesc.length > MAX_TOOL_DESC_LEN ? rawDesc.slice(0, MAX_TOOL_DESC_LEN - 24) + '\n…(truncated for cloud)' : rawDesc; @@ -690,6 +718,7 @@ function buildGetChatMessageRequest(args: BuildArgs): Buffer { .map((p) => p.text).join('\n')) .filter(Boolean) .join('\n\n'); + const sanitizedSystemPrompt = sanitizeTextForCognition(systemPrompt); const collapsed = collapseSystemIntoUser(args.messages.slice(leadingSystem.length)); const promptParts = collapsed.map((m) => encodeMessage( @@ -734,7 +763,7 @@ function buildGetChatMessageRequest(args: BuildArgs): Buffer { return Buffer.concat([ encodeMessage(1, metadata), // #2 system_prompt is always written, empty when the caller had none. - encodeString(2, systemPrompt), + encodeString(2, sanitizedSystemPrompt), ...promptParts, encodeVarintField(7, args.requestType ?? 5), encodeMessage(8, completion), diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index b8cbdb56e94..d32c4e76d28 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -183,7 +183,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs on tool descriptions and also on the #2 system prompt and each ChatMessagePrompt's joined text, because a trigger can reach the cloud outside tool descriptions (the Codex `` escalation boilerplate is the recorded case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 7bb18ce3974..62325a558fd 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDevinAdapter, mapDevinToolCallStartForTests, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; -import { sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import { buildGetChatMessageRequestForTests, sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; @@ -304,6 +304,43 @@ describe("devin adapter", () => { expect(sanitizeToolDescriptionForCognitionForTests(nearMiss)).toBe(nearMiss); }); + test("rewrites the Codex escalation-instruction phrase in every wire field", () => { + // This trigger lives in Codex's boilerplate, + // which Codex sends as system prompt — not in a tool description. The + // clause was isolated live: the full sentence is refused while every + // sub-phrase passes, so the rewrite edits the verb phrase only. + const trigger = "asking the user if they want to allow the action in `justification` parameter"; + const rewritten = sanitizeToolDescriptionForCognitionForTests( + `Include a short question ${trigger}. e.g. "Do you want to run it?"`, + ); + expect(rewritten).toContain("asking the user whether to allow the action in the `justification` parameter"); + expect(rewritten).not.toContain("if they want to allow the action"); + // Flexible whitespace/case, same as the other Codex entries. + expect(sanitizeToolDescriptionForCognitionForTests(trigger.toUpperCase())) + .toContain("whether to allow the action"); + + // The phrase must be gone from every field of the encoded request, not + // just tool descriptions: it reaches the cloud in #2 (system prompt) when + // Codex injects it, and in a #3 prompt when a tool result or pasted file + // quotes it. + const sys = `\n- Include a short question ${trigger}. e.g. "Do it?"\n`; + const req = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2", + cascadeId: "c", + sessionId: "s", + requestId: 1n, + triggerId: "t", + messages: [ + { role: "system", content: sys }, + { role: "user", content: `here is a quote: ${trigger}` }, + { role: "user", content: "hello" }, + ], + }); + expect(req.includes(Buffer.from(trigger, "utf8"))).toBe(false); + expect(req.includes(Buffer.from("asking the user whether to allow the action", "utf8"))).toBe(true); + }); + test("the catalog parser reads the per-account context window", () => { // ClientModelConfig #18 is the max input tokens, and it is the only // first-party context-window figure Cognition exposes: the Devin CLI and From 6bb107cac9c61ac9ac47b26347b6679344d5dfe8 Mon Sep 17 00:00:00 2001 From: Terry Yu Date: Wed, 30 Sep 2026 10:49:51 +0800 Subject: [PATCH 2/5] test(devin): cover encodeToolDef sanitization in blocklist wire test Address CodeRabbit review on #6281: the request fixture carried no tools, so removing tool-description sanitization would not have been caught by the byte assertions. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/providers/devin-adapter.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 62325a558fd..f7cabd52647 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -336,6 +336,7 @@ describe("devin adapter", () => { { role: "user", content: `here is a quote: ${trigger}` }, { role: "user", content: "hello" }, ], + tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], }); expect(req.includes(Buffer.from(trigger, "utf8"))).toBe(false); expect(req.includes(Buffer.from("asking the user whether to allow the action", "utf8"))).toBe(true); From d45fa9bc2e6ea42d4fb7d5cbf4436b076754f75c Mon Sep 17 00:00:00 2001 From: Terry Yu Date: Wed, 30 Sep 2026 10:53:34 +0800 Subject: [PATCH 3/5] fix(devin): sanitize replayed thinking and tool-call arguments too Review on #6281 noted the sanitizer's documented scope ("every text the adapter puts on the wire") excluded ChatMessagePrompt #11 thinking and ChatToolCall #3 arguments. Extend it to both so the claim is literal: a trigger phrase replayed in reasoning or embedded in patch arguments would otherwise still produce permission_denied. The wire-level test now plants the trigger in an assistant thinking block and a tool call as well. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/adapters/devin/cloud-direct/chat.ts | 13 +++++++++---- structure/providers-and-adapters.md | 2 +- tests/providers/devin-adapter.test.ts | 12 +++++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 51a0ab6f7d4..a54cc247276 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -207,7 +207,11 @@ function encodeChatToolCall(tc: { id: string; name: string; arguments: string }) return Buffer.concat([ encodeString(1, tc.id), encodeString(2, tc.name), - encodeString(3, tc.arguments), + // Replayed tool-call arguments are model-generated text that can carry a + // blocklisted phrase (e.g. a patch quoting the Codex prompt). The rewrite + // is a plain string substitution containing no JSON-breaking characters, + // so the arguments stay valid JSON. + encodeString(3, sanitizeTextForCognition(tc.arguments)), ]); } @@ -254,7 +258,7 @@ function encodeChatMessagePrompt( // reasoning model restarted its chain on every turn of a tool loop. Two // independent clients of the same service write it here: #11 thinking, // #12 signature, #18 signature_type on the assistant prompt. - if (opts?.thinking) parts.push(encodeString(11, opts.thinking)); + if (opts?.thinking) parts.push(encodeString(11, sanitizeTextForCognition(opts.thinking))); if (opts?.signature) parts.push(encodeString(12, opts.signature)); if (opts?.signatureType) parts.push(encodeString(18, opts.signatureType)); return Buffer.concat(parts); @@ -632,8 +636,9 @@ const MAX_TOOL_DESC_LEN = 6998; * this one lives in request #2 (the system prompt) and could equally appear * in conversation history — e.g. a tool result that returns a file quoting * the Codex prompt. The sanitizer therefore runs on every text the adapter - * puts on the wire: tool descriptions, the #2 system prompt, and each - * ChatMessagePrompt's joined text. Rewriting content the model quoted is + * puts on the wire: tool descriptions, the #2 system prompt, each + * ChatMessagePrompt's joined text, replayed thinking (#11), and tool-call + * arguments (#6.3). Rewriting content the model quoted is * semantic-preserving, the same trade-off the tool-description rewrites * already accepted. */ diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index d32c4e76d28..061cd5f247d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -183,7 +183,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs on tool descriptions and also on the #2 system prompt and each ChatMessagePrompt's joined text, because a trigger can reach the cloud outside tool descriptions (the Codex `` escalation boilerplate is the recorded case). | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs on tool descriptions and on every other string the request carries — the #2 system prompt, each ChatMessagePrompt's joined text, replayed thinking (#11), and tool-call arguments — because a trigger can reach the cloud outside tool descriptions (the Codex `` escalation boilerplate is the recorded case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index f7cabd52647..36f902fe85e 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDevinAdapter, mapDevinToolCallStartForTests, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; -import { buildGetChatMessageRequestForTests, sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import { buildGetChatMessageRequestForTests, sanitizeTextForCognitionForTests, sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; @@ -310,13 +310,13 @@ describe("devin adapter", () => { // clause was isolated live: the full sentence is refused while every // sub-phrase passes, so the rewrite edits the verb phrase only. const trigger = "asking the user if they want to allow the action in `justification` parameter"; - const rewritten = sanitizeToolDescriptionForCognitionForTests( + const rewritten = sanitizeTextForCognitionForTests( `Include a short question ${trigger}. e.g. "Do you want to run it?"`, ); expect(rewritten).toContain("asking the user whether to allow the action in the `justification` parameter"); expect(rewritten).not.toContain("if they want to allow the action"); // Flexible whitespace/case, same as the other Codex entries. - expect(sanitizeToolDescriptionForCognitionForTests(trigger.toUpperCase())) + expect(sanitizeTextForCognitionForTests(trigger.toUpperCase())) .toContain("whether to allow the action"); // The phrase must be gone from every field of the encoded request, not @@ -334,6 +334,12 @@ describe("devin adapter", () => { messages: [ { role: "system", content: sys }, { role: "user", content: `here is a quote: ${trigger}` }, + { + role: "assistant", + content: "ok", + thinking: `the prompt says: ${trigger}`, + tool_calls: [{ id: "c1", name: "apply_patch", arguments: `{"patch":"${trigger}"}` }], + }, { role: "user", content: "hello" }, ], tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], From 24b94d2e7f76558f79792fcb02a677085dadd617 Mon Sep 17 00:00:00 2001 From: Terry Yu Date: Wed, 30 Sep 2026 15:22:09 +0800 Subject: [PATCH 4/5] fix(devin): keep data fields byte-exact under blocklist sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #6281: extending the sanitizer to ChatMessagePrompt text, thinking (#11), and tool-call arguments rewrote literal content — patch bodies, exact needles, quoted file bytes — so a replay could describe a different command than the one run, or ask the model to edit a different string. Restrict the sanitizer to instruction surfaces (tool descriptions and the #2 system prompt) and let data fields pass through verbatim; if the cloud still refuses, the caller sees the upstream permission_denied. Tests now pin both halves: instruction surfaces are rewritten, user text/thinking/arguments stay literal. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/adapters/devin/cloud-direct/chat.ts | 26 ++++++++++---------- structure/providers-and-adapters.md | 2 +- tests/providers/devin-adapter.test.ts | 32 +++++++++++++++++++++---- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index a54cc247276..5367ee1927e 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -207,11 +207,7 @@ function encodeChatToolCall(tc: { id: string; name: string; arguments: string }) return Buffer.concat([ encodeString(1, tc.id), encodeString(2, tc.name), - // Replayed tool-call arguments are model-generated text that can carry a - // blocklisted phrase (e.g. a patch quoting the Codex prompt). The rewrite - // is a plain string substitution containing no JSON-breaking characters, - // so the arguments stay valid JSON. - encodeString(3, sanitizeTextForCognition(tc.arguments)), + encodeString(3, tc.arguments), ]); } @@ -229,7 +225,7 @@ function encodeChatMessagePrompt( ): Buffer { const textParts = content.filter((p): p is { type: 'text'; text: string } => p.type === 'text'); const imageParts = content.filter((p): p is { type: 'image'; mimeType: string; base64Data: string; caption?: string } => p.type === 'image'); - const joined = sanitizeTextForCognition(textParts.map((p) => p.text).join('\n')); + const joined = textParts.map((p) => p.text).join('\n'); const parts: Buffer[] = [ // #1 message_id. The verified turn-1 capture stamps one on every prompt. encodeString(1, crypto.randomUUID()), @@ -258,7 +254,7 @@ function encodeChatMessagePrompt( // reasoning model restarted its chain on every turn of a tool loop. Two // independent clients of the same service write it here: #11 thinking, // #12 signature, #18 signature_type on the assistant prompt. - if (opts?.thinking) parts.push(encodeString(11, sanitizeTextForCognition(opts.thinking))); + if (opts?.thinking) parts.push(encodeString(11, opts.thinking)); if (opts?.signature) parts.push(encodeString(12, opts.signature)); if (opts?.signatureType) parts.push(encodeString(18, opts.signatureType)); return Buffer.concat(parts); @@ -634,13 +630,15 @@ const MAX_TOOL_DESC_LEN = 6998; * * Scope note: the first entries only ever appeared in tool descriptions, but * this one lives in request #2 (the system prompt) and could equally appear - * in conversation history — e.g. a tool result that returns a file quoting - * the Codex prompt. The sanitizer therefore runs on every text the adapter - * puts on the wire: tool descriptions, the #2 system prompt, each - * ChatMessagePrompt's joined text, replayed thinking (#11), and tool-call - * arguments (#6.3). Rewriting content the model quoted is - * semantic-preserving, the same trade-off the tool-description rewrites - * already accepted. + * Scope note: the sanitizer only ever touches *instruction surfaces* — + * tool descriptions and the #2 system prompt, where a meaning-preserving + * reword loses nothing. It deliberately does NOT touch data fields: + * message text (#3), replayed thinking (#11), and tool-call arguments + * (#6.3) carry literal content (patches, exact needles, quoted file bytes) + * where a rewrite would silently change what the model did or sees. If a + * blocklisted phrase reaches the cloud inside one of those, the request is + * refused and the caller sees the upstream `permission_denied` — which is + * the correct failure, better than corrupting the data. */ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ [/\bTakes a task_id parameter identifying the task\b/g, "Accepts a task_id parameter identifying the task"], diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 061cd5f247d..0cf8d7529d1 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -183,7 +183,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs on tool descriptions and on every other string the request carries — the #2 system prompt, each ChatMessagePrompt's joined text, replayed thinking (#11), and tool-call arguments — because a trigger can reach the cloud outside tool descriptions (the Codex `` escalation boilerplate is the recorded case). | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 36f902fe85e..6342a19be08 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -319,10 +319,12 @@ describe("devin adapter", () => { expect(sanitizeTextForCognitionForTests(trigger.toUpperCase())) .toContain("whether to allow the action"); - // The phrase must be gone from every field of the encoded request, not - // just tool descriptions: it reaches the cloud in #2 (system prompt) when - // Codex injects it, and in a #3 prompt when a tool result or pasted file - // quotes it. + // The phrase is rewritten on instruction surfaces — #2 (system prompt) + // and tool descriptions — but data fields must stay byte-exact: a user + // message, replayed thinking, or tool-call arguments quoting the phrase + // are literal content (patches, exact needles) where a rewrite would + // silently change what the model did. If the cloud still refuses such a + // request, the caller sees the upstream permission_denied. const sys = `\n- Include a short question ${trigger}. e.g. "Do it?"\n`; const req = buildGetChatMessageRequestForTests({ apiKey: "k", @@ -344,8 +346,28 @@ describe("devin adapter", () => { ], tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], }); - expect(req.includes(Buffer.from(trigger, "utf8"))).toBe(false); + // Instruction surfaces rewritten... expect(req.includes(Buffer.from("asking the user whether to allow the action", "utf8"))).toBe(true); + // ...but data fields preserve the literal bytes (user text, thinking, + // tool-call arguments all still carry the verbatim trigger). + expect(req.includes(Buffer.from(`here is a quote: ${trigger}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`the prompt says: ${trigger}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`{"patch":"${trigger}"}`, "utf8"))).toBe(true); + + // A request where the ONLY trigger carrier is a tool description must + // come out clean — pins encodeToolDef specifically. + const toolOnly = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2", + cascadeId: "c", + sessionId: "s", + requestId: 1n, + triggerId: "t", + messages: [{ role: "user", content: "hi" }], + tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], + }); + expect(toolOnly.includes(Buffer.from(trigger, "utf8"))).toBe(false); + expect(toolOnly.includes(Buffer.from("whether to allow the action", "utf8"))).toBe(true); }); test("the catalog parser reads the per-account context window", () => { From b01f166cd7182c07fd93010a4e79df620612681e Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 23:45:51 +0900 Subject: [PATCH 5/5] test(devin): independently verify system blocklist rewriting Decode request field #2 so a sanitized tool description cannot mask a system-prompt regression. Cover flexible whitespace and literal tool results, and align sanitizer comments with instruction-only scope. Co-authored-by: Terry Yu --- src/adapters/devin/cloud-direct/chat.ts | 4 +--- tests/providers/devin-adapter.test.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 5367ee1927e..ed8e12bc6e7 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -628,8 +628,6 @@ const MAX_TOOL_DESC_LEN = 6998; * for "whether to allow", verified live to clear the filter while preserving * the instruction's meaning. * - * Scope note: the first entries only ever appeared in tool descriptions, but - * this one lives in request #2 (the system prompt) and could equally appear * Scope note: the sanitizer only ever touches *instruction surfaces* — * tool descriptions and the #2 system prompt, where a meaning-preserving * reword loses nothing. It deliberately does NOT touch data fields: @@ -669,7 +667,7 @@ export function sanitizeToolDescriptionForCognitionForTests(description: string) return sanitizeTextForCognition(description); } -/** Text sanitizer applied to every string the adapter puts on the wire. */ +/** Test-only: exercise the sanitizer used for system text and tool descriptions. */ export function sanitizeTextForCognitionForTests(text: string): string { return sanitizeTextForCognition(text); } diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 6342a19be08..0482c353e98 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -6,7 +6,7 @@ import { createDevinAdapter, mapDevinToolCallStartForTests, mapOcxMessagesToDevi import { buildGetChatMessageRequestForTests, sanitizeTextForCognitionForTests, sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; -import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { DEPRECATED_OAUTH_PROVIDER_ALIASES, OAUTH_PROVIDERS, resolveRefreshPolicy } from "../../src/oauth"; import { DEVIN_DEFAULT_API_SERVER } from "../../src/oauth/devin"; import { saveCredential } from "../../src/oauth/store"; @@ -304,7 +304,7 @@ describe("devin adapter", () => { expect(sanitizeToolDescriptionForCognitionForTests(nearMiss)).toBe(nearMiss); }); - test("rewrites the Codex escalation-instruction phrase in every wire field", () => { + test("rewrites the Codex escalation phrase on instruction surfaces and preserves data", () => { // This trigger lives in Codex's boilerplate, // which Codex sends as system prompt — not in a tool description. The // clause was isolated live: the full sentence is refused while every @@ -318,6 +318,9 @@ describe("devin adapter", () => { // Flexible whitespace/case, same as the other Codex entries. expect(sanitizeTextForCognitionForTests(trigger.toUpperCase())) .toContain("whether to allow the action"); + expect(sanitizeTextForCognitionForTests(trigger.replaceAll(" ", "\n "))).toBe( + "asking the user whether to allow the action in the `justification` parameter", + ); // The phrase is rewritten on instruction surfaces — #2 (system prompt) // and tool descriptions — but data fields must stay byte-exact: a user @@ -342,17 +345,23 @@ describe("devin adapter", () => { thinking: `the prompt says: ${trigger}`, tool_calls: [{ id: "c1", name: "apply_patch", arguments: `{"patch":"${trigger}"}` }], }, + { role: "tool", tool_call_id: "c1", content: `file bytes: ${trigger}` }, { role: "user", content: "hello" }, ], tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], }); - // Instruction surfaces rewritten... - expect(req.includes(Buffer.from("asking the user whether to allow the action", "utf8"))).toBe(true); + // Decode #2 independently: a rewritten tool description cannot mask an + // unsanitized system prompt in a whole-buffer substring assertion. + const systemField = [...iterFields(req)].find((field) => field.num === 2); + expect(systemField?.value).toEqual(Buffer.from( + `\n- Include a short question asking the user whether to allow the action in the \`justification\` parameter. e.g. "Do it?"\n`, + )); // ...but data fields preserve the literal bytes (user text, thinking, // tool-call arguments all still carry the verbatim trigger). expect(req.includes(Buffer.from(`here is a quote: ${trigger}`, "utf8"))).toBe(true); expect(req.includes(Buffer.from(`the prompt says: ${trigger}`, "utf8"))).toBe(true); expect(req.includes(Buffer.from(`{"patch":"${trigger}"}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`file bytes: ${trigger}`, "utf8"))).toBe(true); // A request where the ONLY trigger carrier is a tool description must // come out clean — pins encodeToolDef specifically.