fix: fail closed around Devin ACP execution

This commit is contained in:
Lucas Israel
2026-07-27 09:25:04 -03:00
parent 9ca5a1ad42
commit a52d5fb31f
4 changed files with 143 additions and 39 deletions

View File

@@ -31,7 +31,9 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin
}
if (Array.isArray(schema.enum) && !schema.enum.some((item) => item === value)) {
errors.push(`${path} must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}`);
errors.push(
`${path} must be one of ${schema.enum.map((item) => JSON.stringify(item)).join(", ")}`
);
}
if (schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value))) {
@@ -43,7 +45,8 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin
const properties = asRecord(schema.properties);
for (const [key, propSchema] of Object.entries(properties)) {
if (key in record) errors.push(...validateSchema(record[key], asRecord(propSchema), `${path}.${key}`));
if (key in record)
errors.push(...validateSchema(record[key], asRecord(propSchema), `${path}.${key}`));
}
if (schema.additionalProperties === false) {
@@ -55,7 +58,9 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin
if (Array.isArray(value) && schema.items) {
const itemSchema = asRecord(schema.items);
value.forEach((item, index) => errors.push(...validateSchema(item, itemSchema, `${path}[${index}]`)));
value.forEach((item, index) =>
errors.push(...validateSchema(item, itemSchema, `${path}[${index}]`))
);
}
return errors;
@@ -71,6 +76,13 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) {
);
}
if (text.trim() !== matches[0][0].trim()) {
throw new DevinAgenticBridgeError(
"Devin tool request must be a standalone tool envelope without narrative text",
"mixed_tool_narrative"
);
}
let payload: JsonRecord;
try {
payload = asRecord(JSON.parse(matches[0][1] || "{}"));
@@ -79,7 +91,8 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) {
}
const name = typeof payload.name === "string" ? payload.name.trim() : "";
if (!name) throw new DevinAgenticBridgeError("Devin tool request is missing name", "missing_tool_name");
if (!name)
throw new DevinAgenticBridgeError("Devin tool request is missing name", "missing_tool_name");
const tool = tools.find((candidate) => candidate.name === name);
if (!tool) {
@@ -96,7 +109,9 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) {
);
}
const digest = createHash("sha256").update(`${name}:${stableJson(input)}`).digest("hex").slice(0, 16);
const digest = createHash("sha256")
.update(`${name}:${stableJson(input)}`)
.digest("hex")
.slice(0, 16);
return { id: `tool_devin_${digest}`, name, input };
}

View File

@@ -55,18 +55,49 @@ function rpc(method: string, params: unknown, id: number): string {
return JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
}
function buildChildEnv(credentials: ExecuteInput["credentials"]): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
export function assertLocalAcpUrl(url: string): void {
if (url !== "devin://acp/stdio") {
throw new DevinAgenticBridgeError(
"devin-cli-agentic accepts only the local Devin ACP stdio upstream",
"invalid_acp_upstream",
500
);
}
}
function isIsolatedHome(value: string): boolean {
return value === "/home/bridge" || value.includes("/.sandbox/");
}
export function buildDevinChildEnv(
_credentials: ExecuteInput["credentials"],
source: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
const home = source.DEVIN_AGENTIC_HOME?.trim() || "";
if (!home || !path.isAbsolute(home) || !isIsolatedHome(home)) {
throw new DevinAgenticBridgeError(
"DEVIN_AGENTIC_HOME must be an absolute path inside the bridge sandbox",
"unsafe_devin_home",
500
);
}
const env: NodeJS.ProcessEnv = {
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local", "share"),
XDG_CACHE_HOME: path.join(home, ".cache"),
PATH: source.PATH || "/usr/local/bin:/usr/bin:/bin",
LANG: source.LANG || "C.UTF-8",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
DISABLE_TELEMETRY: "1",
DISABLE_ERROR_REPORTING: "1",
DISABLE_AUTOUPDATER: "1",
};
if (source.LC_ALL) env.LC_ALL = source.LC_ALL;
if (source.TERM) env.TERM = source.TERM;
for (const key of CLAUDE_ENV_BLOCKLIST) delete env[key];
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1";
env.DISABLE_ERROR_REPORTING = "1";
env.DISABLE_AUTOUPDATER = "1";
const apiKey =
credentials.apiKey || credentials.accessToken || process.env.WINDSURF_API_KEY || "";
if (apiKey) env.WINDSURF_API_KEY = apiKey;
return env;
}
@@ -93,7 +124,7 @@ async function runAcpTurn(args: {
const child = spawn(args.devinBin, ["acp"], {
env: args.env,
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32",
shell: false,
});
let nextId = 1;
@@ -120,11 +151,7 @@ async function runAcpTurn(args: {
const timer = setTimeout(() => {
finish(
new DevinAgenticBridgeError(
`Devin ACP timed out after ${timeoutMs}ms`,
"acp_timeout",
504
)
new DevinAgenticBridgeError(`Devin ACP timed out after ${timeoutMs}ms`, "acp_timeout", 504)
);
}, timeoutMs);
timer.unref?.();
@@ -190,7 +217,13 @@ async function runAcpTurn(args: {
if (initialized && !sessionCreated && msg.result !== undefined && !msg.method) {
sessionId = String(asRecord(msg.result).sessionId || "");
if (!sessionId) {
finish(new DevinAgenticBridgeError("Devin ACP session/new returned no sessionId", "missing_session_id", 502));
finish(
new DevinAgenticBridgeError(
"Devin ACP session/new returned no sessionId",
"missing_session_id",
502
)
);
return;
}
sessionCreated = true;
@@ -207,14 +240,19 @@ async function runAcpTurn(args: {
const kind = String(update.sessionUpdate || params.type || "");
if (kind === "agent_message_chunk") {
text += extractText(update.content);
} else if (kind === "message_delta" || kind === "text_delta" || kind === "content_delta") {
} else if (
kind === "message_delta" ||
kind === "text_delta" ||
kind === "content_delta"
) {
text += String(params.content || params.delta || params.text || "");
}
continue;
}
if (sessionCreated && msg.id === promptRequestId && msg.result !== undefined) {
const resultText = extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message);
const resultText =
extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message);
finish(null, text || resultText);
}
}
@@ -223,7 +261,14 @@ async function runAcpTurn(args: {
child.on("close", (code) => {
if (settled) return;
if (code === 0 && text) finish(null, text);
else finish(new DevinAgenticBridgeError(`Devin CLI exited before completing the turn with code ${code}`, "acp_early_exit", 502));
else
finish(
new DevinAgenticBridgeError(
`Devin CLI exited before completing the turn with code ${code}`,
"acp_early_exit",
502
)
);
});
send("initialize", {
@@ -249,7 +294,9 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
}
buildUrl(): string {
return "devin://acp/stdio";
const url = "devin://acp/stdio";
assertLocalAcpUrl(url);
return url;
}
buildHeaders(): Record<string, string> {
@@ -268,7 +315,7 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
const text = await runAcpTurn({
devinBin,
env: buildChildEnv(credentials),
env: buildDevinChildEnv(credentials),
model,
promptText: prompt.text,
signal,
@@ -321,4 +368,3 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
}
}
}

View File

@@ -5,12 +5,50 @@ import os from "node:os";
import path from "node:path";
import { writeFileSync } from "node:fs";
import { DevinCliAgenticExecutor } from "../../open-sse/executors/devin-cli-agentic.ts";
import {
assertLocalAcpUrl,
buildDevinChildEnv,
DevinCliAgenticExecutor,
} from "../../open-sse/executors/devin-cli-agentic.ts";
process.env.DEVIN_AGENTIC_HOME = path.join(process.cwd(), ".sandbox", "unit-home");
async function readResponseText(response: Response) {
return await response.text();
}
test("Devin child environment is allowlisted and requires an isolated home", () => {
const isolatedHome = path.join(process.cwd(), ".sandbox", "unit-home");
const env = buildDevinChildEnv(
{ apiKey: "devin-test" },
{
HOME: "/Users/example",
PATH: "/usr/bin:/bin",
ANTHROPIC_AUTH_TOKEN: "must-not-leak",
AWS_ACCESS_KEY_ID: "must-not-leak",
GITHUB_TOKEN: "must-not-leak",
DEVIN_AGENTIC_HOME: isolatedHome,
}
);
assert.equal(env.HOME, isolatedHome);
assert.equal(env.PATH, "/usr/bin:/bin");
assert.equal(env.WINDSURF_API_KEY, undefined);
assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined);
assert.equal(env.AWS_ACCESS_KEY_ID, undefined);
assert.equal(env.GITHUB_TOKEN, undefined);
assert.throws(
() => buildDevinChildEnv({}, { PATH: "/usr/bin", DEVIN_AGENTIC_HOME: "/tmp/outside" }),
/inside the bridge sandbox/
);
});
test("Devin agentic upstream is fixed to local ACP stdio", () => {
assert.doesNotThrow(() => assertLocalAcpUrl("devin://acp/stdio"));
assert.throws(() => assertLocalAcpUrl("https://api.anthropic.com"), /ACP stdio/);
assert.throws(() => assertLocalAcpUrl("http://localhost:9999"), /ACP stdio/);
});
function writeMockDevin(tmpDir: string, responseText: string) {
const framesFile = path.join(tmpDir, "frames.json");
const scriptFile = path.join(tmpDir, "mock-devin");
@@ -118,4 +156,3 @@ test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

View File

@@ -26,9 +26,7 @@ test("devin agentic serializer preserves Anthropic tool history and schemas", ()
{ role: "user", content: [{ type: "text", text: "Inspect the file" }] },
{
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "a.ts" } },
],
content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "a.ts" } }],
},
{
role: "user",
@@ -88,15 +86,24 @@ test("devin agentic parser leaves narrative text as text, not a tool", () => {
assert.equal(parseDevinToolRequest("I read the file and it passes.", [readTool]), null);
});
test("devin agentic parser rejects mixed narrative and tool action", () => {
assert.throws(
() =>
parseDevinToolRequest(
'I will read it. <tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>',
[readTool]
),
/standalone tool envelope/
);
});
test("devin agentic SSE renders Anthropic tool lifecycle", () => {
const sse = buildClaudeSseFrames({
id: "msg_1",
type: "message",
role: "assistant",
model: "swe-1-7",
content: [
{ type: "tool_use", id: "tool_devin_1", name: "Read", input: { file_path: "a.ts" } },
],
content: [{ type: "tool_use", id: "tool_devin_1", name: "Read", input: { file_path: "a.ts" } }],
stop_reason: "tool_use",
stop_sequence: null,
usage: { input_tokens: 10, output_tokens: 2 },
@@ -109,4 +116,3 @@ test("devin agentic SSE renders Anthropic tool lifecycle", () => {
assert.match(sse, /"stop_reason":"tool_use"/);
assert.match(sse, /event: message_stop/);
});