From 71af74bee43eebc23c991412b353dda74c38455b Mon Sep 17 00:00:00 2001 From: Lucas Israel Date: Mon, 27 Jul 2026 15:00:48 -0300 Subject: [PATCH] fix: fail closed on incompatible Devin ACP behavior --- .gitignore | 3 +- docker/devin-bridge/mock-devin.mjs | 32 +++++-- docker/devin-bridge/run-contract.mjs | 20 +++++ .../executors/devin-agentic/serializer.ts | 2 + open-sse/executors/devin-cli-agentic.ts | 37 +++++++- scripts/devin-bridge/common | 2 +- scripts/devin-bridge/runtime-policy.mjs | 14 ++- scripts/devin-bridge/test-contract | 9 +- scripts/devin-bridge/test-live-devin | 17 +++- tests/unit/devin-bridge-live-runtime.test.ts | 17 +++- .../executor-devin-cli-agentic-acp.test.ts | 89 +++++++++++++++++-- .../executor-devin-cli-agentic-core.test.ts | 1 + 12 files changed, 220 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index ec10295b10..e96efddded 100644 --- a/.gitignore +++ b/.gitignore @@ -246,7 +246,8 @@ _artifacts/ # release-green artifacts # CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.) .artifacts/ -.sandbox/ # isolated Devin bridge workspaces, evidence, and test databases +# Isolated Devin bridge workspaces, evidence, and test databases +.sandbox/ # Homologation E2E suite (npm run homolog) — real-environment credentials + report output .env.homolog diff --git a/docker/devin-bridge/mock-devin.mjs b/docker/devin-bridge/mock-devin.mjs index 28919969ed..7c7b15efd3 100755 --- a/docker/devin-bridge/mock-devin.mjs +++ b/docker/devin-bridge/mock-devin.mjs @@ -2,11 +2,7 @@ import fs from "node:fs"; import readline from "node:readline"; -if ( - process.argv[2] !== "acp" || - process.argv[3] !== "--agent-type" || - process.argv[4] !== "summarizer" -) { +if (process.argv[2] !== "acp" || process.argv.length !== 3) { process.exit(64); } @@ -118,6 +114,32 @@ rl.on("line", (line) => { send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); return; } + if (prompt.includes("CONTRACT_NARRATIVE_REPAIR")) { + const isRepair = prompt.includes("[Single Repair Attempt]"); + log({ + provider: "devin-cli-agentic", + scenario: "narrative-repair", + stage: isRepair ? "repair" : "initial", + }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "offline", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: isRepair + ? '{"name":"Read","arguments":{"file_path":"/workspace/math.js"}}' + : "I'll start by reading the math.js file, then run the tests.", + }, + }, + }, + }); + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + return; + } if (prompt.includes("CONTRACT_TOOL")) { log({ provider: "devin-cli-agentic", scenario: "tool" }); send({ diff --git a/docker/devin-bridge/run-contract.mjs b/docker/devin-bridge/run-contract.mjs index b78cb2d510..a1de76f8d1 100644 --- a/docker/devin-bridge/run-contract.mjs +++ b/docker/devin-bridge/run-contract.mjs @@ -70,6 +70,26 @@ assert.equal(toolUse?.type, "tool_use"); assert.equal(toolUse?.name, "Read"); assert.match(toolUse?.id || "", /^tool_devin_/); +const repairedNarrativeReply = await request("CONTRACT_NARRATIVE_REPAIR", { + tools: [ + { + name: "Read", + description: "Read a file", + input_schema: { + type: "object", + properties: { file_path: { type: "string" } }, + required: ["file_path"], + additionalProperties: false, + }, + }, + ], +}); +assert.equal(repairedNarrativeReply.status, 200); +const repairedNarrativeBody = await repairedNarrativeReply.json(); +assert.equal(repairedNarrativeBody.stop_reason, "tool_use"); +assert.equal(repairedNarrativeBody.content?.[0]?.type, "tool_use"); +assert.equal(repairedNarrativeBody.content?.[0]?.name, "Read"); + const continuationReply = await fetch(endpoint, { method: "POST", headers, diff --git a/open-sse/executors/devin-agentic/serializer.ts b/open-sse/executors/devin-agentic/serializer.ts index f993025fcb..0f37bd1d1b 100644 --- a/open-sse/executors/devin-agentic/serializer.ts +++ b/open-sse/executors/devin-agentic/serializer.ts @@ -158,6 +158,8 @@ function serializeToolCatalog(tools: AnthropicTool[]): string[] { "When a tool is required, respond with exactly one XML-wrapped JSON object:", '{"name":"ToolName","arguments":{}}', "Use only the tools listed below. Do not claim that a tool was executed.", + "Do not execute tools inside Devin or emit ACP tool-call events; request them only with the XML envelope.", + "Never describe a future tool action in plain text; emit the tool envelope instead.", ].join("\n"), ...tools.map((tool) => [ diff --git a/open-sse/executors/devin-cli-agentic.ts b/open-sse/executors/devin-cli-agentic.ts index 131da10608..858326034a 100644 --- a/open-sse/executors/devin-cli-agentic.ts +++ b/open-sse/executors/devin-cli-agentic.ts @@ -34,8 +34,15 @@ const REPAIRABLE_TOOL_ERRORS = new Set([ "invalid_tool_arguments", "multiple_tool_requests", "mixed_tool_narrative", + "unexecuted_tool_intent", ]); +function describesUnexecutedToolIntent(text: string): boolean { + return /\b(?:i(?:'ll| will)|let me)\b[^\n.!?]{0,160}\b(?:read|inspect|examine|edit|fix|run|check|test|start)\b/i.test( + text + ); +} + const CLAUDE_ENV_BLOCKLIST = [ "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", @@ -142,7 +149,7 @@ export async function runAcpTurn(args: { log?: ExecuteInput["log"]; }) { const timeoutMs = Number(process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS || 120000); - const child = spawn(args.devinBin, ["acp", "--agent-type", "summarizer"], { + const child = spawn(args.devinBin, ["acp"], { env: args.env, cwd: args.env.HOME, stdio: ["pipe", "pipe", "pipe"], @@ -304,6 +311,16 @@ export async function runAcpTurn(args: { } const update = asRecord(params.update); const kind = String(update.sessionUpdate || params.type || ""); + if (kind === "tool_call" || kind === "tool_call_update") { + finish( + new DevinAgenticBridgeError( + "Devin attempted to execute a tool internally; Claude Code must own all tool execution", + "devin_internal_tool_execution", + 502 + ) + ); + return; + } if (kind === "agent_message_chunk") { text += extractText(update.content); } else if ( @@ -439,6 +456,12 @@ export class DevinCliAgenticExecutor extends BaseExecutor { let text = await generateAgenticOutput(turnArgs, prompt.text); let tool; try { + if (prompt.tools.length > 0 && describesUnexecutedToolIntent(text)) { + throw new DevinAgenticBridgeError( + "The response described a future action without performing it; call exactly one tool now", + "unexecuted_tool_intent" + ); + } tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed); } catch (error) { if ( @@ -447,6 +470,7 @@ export class DevinCliAgenticExecutor extends BaseExecutor { ) { throw error; } + const requiresToolOnRepair = error.code === "unexecuted_tool_intent"; const repairPrompt = [ prompt.text, "", @@ -454,11 +478,20 @@ export class DevinCliAgenticExecutor extends BaseExecutor { "", "[Single Repair Attempt]", `The previous output was rejected: ${sanitizeErrorMessage(error.message)}`, - "Return either plain final text or exactly one standalone JSON envelope.", + requiresToolOnRepair + ? "Plain text is not accepted for this repair. Return exactly one standalone JSON envelope now." + : "Return either plain final text or exactly one standalone JSON envelope.", "Do not narrate a tool action.", ].join("\n"); text = await generateAgenticOutput(turnArgs, repairPrompt); tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed); + if (requiresToolOnRepair && !tool) { + throw new DevinAgenticBridgeError( + "Devin repeated a narrated tool action instead of requesting a tool", + "unexecuted_tool_intent", + 502 + ); + } } const id = `msg_devin_${randomUUID().replaceAll("-", "")}`; diff --git a/scripts/devin-bridge/common b/scripts/devin-bridge/common index 2d5080716e..b39519dad5 100755 --- a/scripts/devin-bridge/common +++ b/scripts/devin-bridge/common @@ -89,8 +89,8 @@ bridge_check_devin_auth() { output="$(bridge_run_devin auth status 2>&1)" exit_status=$? set -e - printf '%s\n' "$output" bridge_assert_devin_auth_status "$exit_status" "$output" + printf 'PASS: Devin authentication confirmed\n' } bridge_assert_zero_claude_egress() { diff --git a/scripts/devin-bridge/runtime-policy.mjs b/scripts/devin-bridge/runtime-policy.mjs index 81952de712..73a287d32b 100644 --- a/scripts/devin-bridge/runtime-policy.mjs +++ b/scripts/devin-bridge/runtime-policy.mjs @@ -23,7 +23,7 @@ export function validateDevinAuthStatus(exitStatus, output) { const lines = String(output) .split(/\r?\n/) .map((line) => line.trim()); - if (!lines.includes("Logged in (via Devin)")) { + if (!lines.some((line) => /^Logged in \(via Devin\)\.?$/.test(line))) { return { ok: false, error: "auth status did not confirm login" }; } if (lines.some((line) => /failed to fetch from server/i.test(line))) { @@ -63,12 +63,22 @@ export function validateClaudeGuardDenials(text) { export function validateDevinGuardAudit(text) { const entries = parseAuditEntries(text); if (!entries.length) return { ok: false, error: "Devin egress audit has no records" }; + let sawAllowedDevinRequest = false; for (const entry of entries) { + if (entry.decision === "deny") { + if (/anthropic|claude\.ai/i.test(normalizedHostname(entry.hostname))) { + return { ok: false, error: `forbidden Devin egress attempt: ${String(entry.hostname)}` }; + } + continue; + } if (entry.decision !== "allow" || !isAllowedDevinAuditHostname(entry.hostname)) { return { ok: false, error: `unexpected Devin egress record: ${String(entry.hostname)}` }; } + sawAllowedDevinRequest = true; } - return { ok: true }; + return sawAllowedDevinRequest + ? { ok: true } + : { ok: false, error: "Devin egress audit has no approved request" }; } export function validateAuditFileStat(stat, expectedUid) { diff --git a/scripts/devin-bridge/test-contract b/scripts/devin-bridge/test-contract index c1749f66e5..91e49448d5 100755 --- a/scripts/devin-bridge/test-contract +++ b/scripts/devin-bridge/test-contract @@ -10,7 +10,14 @@ docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-ex node -e ' const fs = require("node:fs"); const rows = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").map(JSON.parse); - if (rows.length !== 5 || rows.some((row) => row.provider !== "devin-cli-agentic")) { + const repairRows = rows.filter((row) => row.scenario === "narrative-repair"); + if ( + rows.length !== 7 || + rows.some((row) => row.provider !== "devin-cli-agentic") || + repairRows.length !== 2 || + repairRows[0].stage !== "initial" || + repairRows[1].stage !== "repair" + ) { throw new Error("wire contract observed a missing or non-Devin provider"); } ' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl" diff --git a/scripts/devin-bridge/test-live-devin b/scripts/devin-bridge/test-live-devin index 5f0abbeee0..e0458eb1a0 100755 --- a/scripts/devin-bridge/test-live-devin +++ b/scripts/devin-bridge/test-live-devin @@ -8,9 +8,20 @@ bridge_reset_live_fixture "$(dirname "$0")/verify-anthropic-isolation" --static docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard bridge_check_devin_auth -bridge_run_devin models list --format json >"$BRIDGE_SANDBOX/evidence/live-models.json" -devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \ - <"$BRIDGE_SANDBOX/evidence/live-models.json")" +models_file="$BRIDGE_SANDBOX/evidence/live-models.json" +if [[ -n "${DEVIN_BRIDGE_DISCOVERED_MODEL:-}" ]]; then + devin_model="$DEVIN_BRIDGE_DISCOVERED_MODEL" +else + for attempt in 1 2 3; do + if bridge_run_devin models list --format json >"$models_file"; then + break + fi + [[ "$attempt" == 3 ]] && exit 1 + sleep 1 + done + devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \ + <"$models_file")" +fi export DEVIN_BRIDGE_MODEL="devin-cli-agentic/$devin_model" export DEVIN_BRIDGE_SONNET_MODEL="$DEVIN_BRIDGE_MODEL" export DEVIN_BRIDGE_OPUS_MODEL="$DEVIN_BRIDGE_MODEL" diff --git a/tests/unit/devin-bridge-live-runtime.test.ts b/tests/unit/devin-bridge-live-runtime.test.ts index 0b6f3a66ae..91eb6cb1f9 100644 --- a/tests/unit/devin-bridge-live-runtime.test.ts +++ b/tests/unit/devin-bridge-live-runtime.test.ts @@ -122,6 +122,7 @@ test("compose keeps role-separated credentials and proxy settings", () => { test("auth status requires the exact positive line and rejects misleading text", () => { assert.equal(validateDevinAuthStatus(0, "Logged in (via Devin)\n").ok, true); + assert.equal(validateDevinAuthStatus(0, "Logged in (via Devin).\n").ok, true); for (const output of [ "Not Logged in (via Devin)\n", "prefix Logged in (via Devin) suffix\n", @@ -185,7 +186,21 @@ test("audit policies reject forged metadata, missing proof, and unexpected Devin true ); assert.equal( - validateDevinGuardAudit('{"hostname":"o1.ingest.sentry.io","decision":"deny"}\n').ok, + validateDevinGuardAudit( + '{"hostname":"o1.ingest.sentry.io","decision":"deny"}\n' + + '{"hostname":"api.devin.ai","decision":"allow"}\n' + ).ok, + true + ); + assert.equal( + validateDevinGuardAudit('{"hostname":"api.devin.ai","decision":"deny"}\n').ok, + false + ); + assert.equal( + validateDevinGuardAudit( + '{"hostname":"api.anthropic.com","decision":"deny"}\n' + + '{"hostname":"api.devin.ai","decision":"allow"}\n' + ).ok, false ); }); diff --git a/tests/unit/executor-devin-cli-agentic-acp.test.ts b/tests/unit/executor-devin-cli-agentic-acp.test.ts index 529c86365e..87049df628 100644 --- a/tests/unit/executor-devin-cli-agentic-acp.test.ts +++ b/tests/unit/executor-devin-cli-agentic-acp.test.ts @@ -229,11 +229,7 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram const initialize = frames.find((frame: { method?: string }) => frame.method === "initialize"); assert.equal(initialize.params.protocolVersion, 1); assert.deepEqual(initialize.params.clientCapabilities, {}); - assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), [ - "acp", - "--agent-type", - "summarizer", - ]); + assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["acp"]); } finally { if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN; else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin; @@ -299,6 +295,31 @@ test("ACP client handles fragmented frames, multiple chunks, and stderr", async } }); +test("ACP client fails closed when Devin attempts an internal tool call", async () => { + const tmpDir = sandboxTmp("devin-agentic-internal-tool-"); + const scriptFile = writeScenarioMock( + tmpDir, + `rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "internal-tool" } }); + if (msg.method === "session/prompt") { + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "internal-tool", update: { sessionUpdate: "tool_call", toolCallId: "internal-1", title: "Read a.ts" } } }); + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "internal-tool", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done" } } } }); + send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } }); + } +});` + ); + try { + const result = await executeTextRequest(scriptFile); + assert.equal(result.response.status, 502); + const body = JSON.parse(await result.response.text()); + assert.equal(body.error.code, "devin_internal_tool_execution"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + test("ACP client fails closed on protocol errors and early exit", async () => { const cases = [ { @@ -354,7 +375,7 @@ test("ACP client times out, cancels, and terminates a stuck process", async () = } }); -test("tool repair is attempted once and produces a validated tool_use", async () => { +test("premature tool narration is repaired once into a validated tool_use", async () => { const tmpDir = sandboxTmp("devin-agentic-repair-"); const stateFile = path.join(tmpDir, "spawn-count"); const scriptFile = writeScenarioMock( @@ -369,7 +390,7 @@ rl.on("line", (line) => { if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair" } }); if (msg.method === "session/prompt") { const text = count === 1 - ? 'I will read it. {"name":"Read","arguments":{"file_path":"a.ts"}}' + ? 'I will read the file and start by examining its contents.' : '{"name":"Read","arguments":{"file_path":"a.ts"}}'; send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } }); send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } }); @@ -406,3 +427,57 @@ rl.on("line", (line) => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + +test("premature tool narration fails closed when the single repair is still narrative", async () => { + const tmpDir = sandboxTmp("devin-agentic-repair-narrative-"); + const stateFile = path.join(tmpDir, "spawn-count"); + const scriptFile = writeScenarioMock( + tmpDir, + `const fs = require("fs"); +const stateFile = ${JSON.stringify(stateFile)}; +const count = Number(fs.existsSync(stateFile) ? fs.readFileSync(stateFile, "utf8") : "0") + 1; +fs.writeFileSync(stateFile, String(count)); +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair-narrative" } }); + if (msg.method === "session/prompt") { + const text = count === 1 + ? "I'll start by reading the file." + : "I'll read the file now, then run the tests."; + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair-narrative", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } }); + send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } }); + } +});` + ); + const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN; + process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile; + try { + const result = await new DevinCliAgenticExecutor().execute({ + model: "swe-1-7", + stream: false, + credentials: {}, + body: { + tools: [ + { + name: "Read", + input_schema: { + type: "object", + required: ["file_path"], + properties: { file_path: { type: "string" } }, + }, + }, + ], + messages: [{ role: "user", content: "Read a.ts" }], + }, + }); + assert.equal(result.response.status, 502); + const body = JSON.parse(await result.response.text()); + assert.equal(body.error.code, "unexecuted_tool_intent"); + assert.equal(fs.readFileSync(stateFile, "utf8"), "2"); + } finally { + if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN; + else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/executor-devin-cli-agentic-core.test.ts b/tests/unit/executor-devin-cli-agentic-core.test.ts index 5b65465415..defad057ba 100644 --- a/tests/unit/executor-devin-cli-agentic-core.test.ts +++ b/tests/unit/executor-devin-cli-agentic-core.test.ts @@ -40,6 +40,7 @@ test("devin agentic serializer preserves Anthropic tool history and schemas", () assert.match(prompt.text, /\[System\]\nFollow CLAUDE\.md/); assert.match(prompt.text, /\[Tool\] Read/); + assert.match(prompt.text, /Do not execute tools inside Devin/); assert.match(prompt.text, /\[Assistant Tool Use\]/); assert.match(prompt.text, /\[Tool Result\]/); assert.equal(prompt.tools[0].name, "Read");