From da526fc014b408ddef5b3faff246c622aeffddca Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:52:11 +0800 Subject: [PATCH] fix(providers): preserve reply bytes and structured JSON ownership Share strict UTF-8 JSON admission across bounded worker reports. Preserve IDA/JEB structured dictionaries through negotiated SDK validation and adapter decoding, retaining inherited tool validation and protocol handling. Validated 240 tests, including real SDK/HTTP exchanges and firmware file reads; 83 host-specific cases skipped. Typecheck, lint, and discovery passed. Real-provider execution and modern MCP protocol behavior remain unverified. --- scripts/lib/test-projects.mjs | 2 +- src/domain/safeJson.test.ts | 28 +++++ src/domain/safeJson.ts | 17 +++ src/evm/EvmoleInterfaceProvider.ts | 7 +- src/firmware/FirmwareFiles.ts | 10 +- src/ida/IdaMcpConnection.test.ts | 50 ++++++++ src/ida/IdaMcpConnection.ts | 27 +++-- src/jeb/JebMcpConnection.test.ts | 16 +++ src/jeb/JebMcpConnection.ts | 12 +- src/jeb/JebMcpConnectionOwner.ts | 7 +- src/native/pwntools/PwntoolsDecoder.ts | 5 +- src/native/pwntools/PwntoolsResourceLimits.ts | 9 +- .../StructuredContentMcpClient.test.ts | 111 ++++++++++++++++++ src/providers/StructuredContentMcpClient.ts | 77 ++++++++++++ .../pwntoolsLayoutProvider.test.ts | 80 ++++++++----- .../evm/evmoleInterfaceProvider.test.ts | 105 +++++++++++------ tests/boundary/mcp/idaTransport.test.ts | 14 +++ .../boundary/providers/jebMcpReplies.test.ts | 26 ++++ 18 files changed, 508 insertions(+), 95 deletions(-) create mode 100644 src/domain/safeJson.test.ts create mode 100644 src/providers/StructuredContentMcpClient.test.ts create mode 100644 src/providers/StructuredContentMcpClient.ts create mode 100644 tests/boundary/providers/jebMcpReplies.test.ts diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index 89273948d4..627254fd42 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -19,7 +19,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,providers,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/domain/safeJson.test.ts b/src/domain/safeJson.test.ts new file mode 100644 index 0000000000..755e72c063 --- /dev/null +++ b/src/domain/safeJson.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from "vitest"; +import { safeParseUtf8Json } from "./safeJson.js"; + +it("distinguishes malformed UTF-8 from a literal Unicode replacement character", () => { + const invalid = Buffer.concat([ + Buffer.from('{"message":"'), + Buffer.from([0xff]), + Buffer.from('"}'), + ]); + expect(safeParseUtf8Json(invalid)).toMatchObject({ + ok: false, + cause: { code: "ERR_ENCODING_INVALID_ENCODED_DATA" }, + }); + expect(safeParseUtf8Json(Buffer.from('{"message":"valid � 雪"}'))).toEqual({ + ok: true, + value: { message: "valid � 雪" }, + }); +}); + +it.each(["\uFEFF{}", '{"message":', ""])( + "retains strict JSON failure causes for %j", + (text) => { + expect(safeParseUtf8Json(Buffer.from(text))).toMatchObject({ + ok: false, + cause: expect.any(SyntaxError), + }); + }, +); diff --git a/src/domain/safeJson.ts b/src/domain/safeJson.ts index e7a8c1bf02..96439db075 100644 --- a/src/domain/safeJson.ts +++ b/src/domain/safeJson.ts @@ -3,6 +3,23 @@ export type SafeJsonParseResult = | { readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly error: string; readonly cause: unknown }; +/** Decode complete UTF-8 JSON without replacing malformed bytes or stripping a BOM. */ +export const safeParseUtf8Json = (bytes: Uint8Array): SafeJsonParseResult => { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch (cause: unknown) { + return { + ok: false, + error: cause instanceof Error ? cause.message : String(cause), + cause, + }; + } + return safeParseJson(text); +}; + /** Parse JSON text, returning the failure reason instead of throwing. */ export const safeParseJson = ( value: string, diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 8bce6fc32d..a7b9f54001 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -1,3 +1,4 @@ +import { safeParseUtf8Json } from "../domain/safeJson.js"; import { createHash, randomUUID } from "node:crypto"; import { access, readFile, stat, writeFile } from "node:fs/promises"; import { constants } from "node:fs"; @@ -42,8 +43,8 @@ import { evmInterfaceEvidence } from "../domain/evm/evmInterfaceEvidence.js"; import { DEFAULT_EVM_TIMEOUT_MS, type InspectEvmInterfaceInput, + evmInterfaceSchema, } from "../domain/evm/evmInterface.js"; -import { evmInterfaceSchema } from "../domain/evm/evmInterface.js"; import { solidityMetadataSchema } from "../domain/evm/solidityMetadata.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -569,7 +570,9 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { undefined, this.#resources, ); - reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + const decoded = safeParseUtf8Json(file.bytes); + if (!decoded.ok) throw decoded.cause; + reply = replySchema.parse(decoded.value); } catch (cause: unknown) { const cleanup = cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 8bf429a95b..f71e17ca15 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -1,3 +1,4 @@ +import { safeParseUtf8Json } from "../domain/safeJson.js"; import { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; import { chmod, lstat, opendir, readlink, realpath } from "node:fs/promises"; @@ -264,12 +265,9 @@ export class FirmwareFileAccess { if (bytes.length === 0) throw firmwareReportFailure(operation, "empty", "Report is empty"); try { - const value: unknown = JSON.parse( - new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - bytes, - ), - ); - return value; + const decoded = safeParseUtf8Json(bytes); + if (!decoded.ok) throw decoded.cause; + return decoded.value; } catch (cause: unknown) { throw firmwareReportFailure( operation, diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index ce1fefba78..ba1b8afea7 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -645,3 +645,53 @@ it("keeps a real SDK physical failure when abort arrives during its outer unwind await connection.close(); } }); + +it("preserves observed own structured members before deciding whether to unwrap a result", () => { + const structured: unknown = JSON.parse( + '{"__proto__":{"observed":true},"result":"field","nested":[{"__proto__":"nested fact"}]}', + ); + const decoded = decodeIdaToolResult({ + content: [], + structuredContent: structured, + }); + expect(Object.hasOwn(decoded as object, "__proto__")).toBe(true); + expect(JSON.stringify(decoded)).toBe(JSON.stringify(structured)); + const wrapped: unknown = JSON.parse( + '{"result":{"__proto__":{"observed":true},"nested":{"__proto__":"nested fact"}}}', + ); + const value = decodeIdaToolResult({ + content: [], + structuredContent: wrapped, + }); + expect(Object.hasOwn(value as object, "__proto__")).toBe(true); + expect(JSON.stringify(value)).toBe( + '{"__proto__":{"observed":true},"nested":{"__proto__":"nested fact"}}', + ); +}); + +it("keeps error, truncation and native-text precedence ahead of structured JSON admission", () => { + const structuredContent = { result: Infinity }; + expect(() => + decodeIdaToolResult({ + isError: true, + structuredContent, + content: [{ type: "text", text: "engine refused" }], + }), + ).toThrow(/IDA MCP tool failed: \s*engine refused/u); + expect(() => + decodeIdaToolResult({ + structuredContent, + content: [], + _meta: { ida_mcp: { output_truncated: true } }, + }), + ).toThrow("IDA MCP truncated its output"); + expect( + decodeIdaToolResult( + { + structuredContent, + content: [{ type: "text", text: '{"complete":true}' }], + }, + "native", + ), + ).toEqual({ complete: true }); +}); diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index 98fcc77f41..3d20dd2f85 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -1,3 +1,4 @@ +import { StructuredContentMcpClient } from "../providers/StructuredContentMcpClient.js"; import { createIdaDiagnosticRedactor, IDA_DIAGNOSTIC_SOURCE_BYTES, @@ -8,7 +9,6 @@ import { ChildProcess } from "node:child_process"; import { Socket } from "node:net"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { - Client, ProtocolError, SdkError, SdkErrorCode, @@ -24,7 +24,11 @@ import { idaRegistrationTimeoutGuidance, type IdaConfiguration, } from "./IdaConfiguration.js"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + type JsonValue, +} from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { AnalysisTimeoutError, @@ -106,6 +110,8 @@ export const decodeIdaToolResult = ( diagnostic: IdaDiagnosticRedactor = createIdaDiagnosticRedactor(), ): JsonValue => { const result = toolResultSchema.parse(input); + // Shape validation's record copy can omit own __proto__ members; JSON owns the original dictionary. + const source = input as z.input; const blocks = result.content.flatMap((block) => block.type === "text" && block.text !== undefined ? [block.text] : [], ); @@ -138,13 +144,11 @@ export const decodeIdaToolResult = ( ); return parseIdaJsonText(text); } - if (result.structuredContent !== undefined) { - const value = result.structuredContent; - return jsonValueSchema.parse( - Object.keys(value).length === 1 && "result" in value - ? value.result - : value, - ); + if (source.structuredContent !== undefined) { + const value = jsonObjectSchema.parse(source.structuredContent); + return Object.keys(value).length === 1 && Object.hasOwn(value, "result") + ? value.result! + : value; } if (text === undefined) throw new AnalysisProtocolError( @@ -360,7 +364,10 @@ export const createIdaMcpConnection = ( environment: Readonly, dependencies: IdaMcpConnectionDependencies = {}, ): IdaMcpConnection => { - const client = new Client({ name: "rea-ida-provider", version: "1" }); + const client = new StructuredContentMcpClient({ + name: "rea-ida-provider", + version: "1", + }); const redactor = ( dependencies.createDiagnosticRedactor ?? createIdaDiagnosticRedactor )(config); diff --git a/src/jeb/JebMcpConnection.test.ts b/src/jeb/JebMcpConnection.test.ts index 59706d5449..99b1d58299 100644 --- a/src/jeb/JebMcpConnection.test.ts +++ b/src/jeb/JebMcpConnection.test.ts @@ -446,3 +446,19 @@ it("passes a discovery budget above the SDK default to connect and every catalog } } }); + +it.each([false, true])( + "retains structured own members and nested dictionaries in a JEB reply (isError=%s)", + (isError) => { + const structured: unknown = JSON.parse( + '{"__proto__":{"observed":true},"success":false,"message":"missing","nested":[{"__proto__":"nested fact"}]}', + ); + const decoded = decodeJebToolResult({ + isError, + structuredContent: structured, + content: [], + }); + expect(Object.hasOwn(decoded as object, "__proto__")).toBe(true); + expect(JSON.stringify(decoded)).toBe(JSON.stringify(structured)); + }, +); diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index c99c9b5e82..2da9c29c4f 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -3,7 +3,11 @@ import { DEFAULT_REQUEST_TIMEOUT_MSEC, } from "@modelcontextprotocol/client"; import { z } from "zod"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + type JsonValue, +} from "../domain/jsonValue.js"; import { AnalysisCancelledError, AnalysisProtocolError, @@ -57,12 +61,14 @@ const toolResultSchema = z.object({ /** Decode a JEB MCP tool result without inventing an empty observation. */ export const decodeJebToolResult = (input: unknown): JsonValue => { const result = toolResultSchema.parse(input); + // Shape validation's record copy can omit own __proto__ members; JSON owns the original dictionary. + const source = input as z.input; const blocks = result.content.flatMap((block) => block.type === "text" && block.text !== undefined ? [block.text] : [], ); let decoded: JsonValue; - if (result.structuredContent !== undefined) { - decoded = jsonValueSchema.parse(result.structuredContent); + if (source.structuredContent !== undefined) { + decoded = jsonObjectSchema.parse(source.structuredContent); } else { if (blocks.length !== 1) throw new AnalysisProtocolError( diff --git a/src/jeb/JebMcpConnectionOwner.ts b/src/jeb/JebMcpConnectionOwner.ts index 4f41760fc5..b971592a33 100644 --- a/src/jeb/JebMcpConnectionOwner.ts +++ b/src/jeb/JebMcpConnectionOwner.ts @@ -1,5 +1,5 @@ +import { StructuredContentMcpClient } from "../providers/StructuredContentMcpClient.js"; import { - Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport, @@ -53,7 +53,10 @@ export class JebMcpAcquisitionCleanupFailure extends ProviderCleanupError { /** One SDK client owns its attached transport; failed cleanup retains both identities. */ export class JebMcpConnectionOwner { - readonly client = new Client({ name: "rea-jeb", version: "0.0.0" }); + readonly client = new StructuredContentMcpClient({ + name: "rea-jeb", + version: "0.0.0", + }); readonly transport: StreamableHTTPClientTransport; readonly signal = new AbortController(); tools: readonly string[] | undefined; diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 557461efd5..c26027033b 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -1,3 +1,4 @@ +import { safeParseUtf8Json } from "../../domain/safeJson.js"; import { AnalysisCleanupLease, AnalysisCleanupLeaseGroup, @@ -387,7 +388,9 @@ export class PwntoolsDecoder< undefined, this.#resources, ); - reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + const decoded = safeParseUtf8Json(file.bytes); + if (!decoded.ok) throw decoded.cause; + reply = replySchema.parse(decoded.value); } catch (cause: unknown) { if ( cause instanceof ArtifactReaderFailure && diff --git a/src/native/pwntools/PwntoolsResourceLimits.ts b/src/native/pwntools/PwntoolsResourceLimits.ts index 9d06448c02..8193f48abb 100644 --- a/src/native/pwntools/PwntoolsResourceLimits.ts +++ b/src/native/pwntools/PwntoolsResourceLimits.ts @@ -1,12 +1,13 @@ +import { safeParseUtf8Json } from "../../domain/safeJson.js"; import { join } from "node:path"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; import { PWNTOOLS_MEMORY_FAILURE_EXIT, PWNTOOLS_FILE_SIZE_FAILURE_EXIT, + PWNTOOLS_LIMITS, } from "./PwntoolsRelease.js"; import { z } from "zod"; -import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; /** Actual limits reported by the owned bridge after lowering inherited soft limits. */ @@ -94,10 +95,10 @@ export const readPwntoolsFailureEvidence = async ( undefined, resources, ); + const decoded = safeParseUtf8Json(reported.bytes); + if (!decoded.ok) throw decoded.cause; limitReport = { - limits: pwntoolsResourceLimitsSchema.parse( - JSON.parse(reported.bytes.toString("utf8")), - ), + limits: pwntoolsResourceLimitsSchema.parse(decoded.value), failure: null, }; } catch (reportFailure: unknown) { diff --git a/src/providers/StructuredContentMcpClient.test.ts b/src/providers/StructuredContentMcpClient.test.ts new file mode 100644 index 0000000000..92e9cd39b1 --- /dev/null +++ b/src/providers/StructuredContentMcpClient.test.ts @@ -0,0 +1,111 @@ +import { InMemoryTransport, type Tool } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; +import { StructuredContentMcpClient } from "./StructuredContentMcpClient.js"; + +/** Raw peer replies pass through the actual SDK protocol and era validator. */ +const exchange = async ( + reply: Record, + run: (client: StructuredContentMcpClient) => Promise, +) => { + const client = new StructuredContentMcpClient({ + name: "structured-content-test", + version: "1", + }); + const [local, peer] = InMemoryTransport.createLinkedPair(); + peer.onmessage = async (message) => { + if ( + !("method" in message) || + !("id" in message) || + message.id === undefined + ) + return; + const result = + message.method === "initialize" + ? { + protocolVersion: "2025-11-25", + capabilities: { tools: {} }, + serverInfo: { name: "raw-peer", version: "1" }, + } + : message.method === "tools/list" + ? { tools: [] } + : message.method === "ping" + ? {} + : reply; + await peer.send({ jsonrpc: "2.0", id: message.id, result }); + }; + try { + await peer.start(); + await client.connect(local); + await run(client); + } finally { + await client.close(); + await peer.close(); + } +}; + +it("preserves structured own properties while retaining legacy default content", async () => { + const structuredContent: unknown = JSON.parse( + '{"__proto__":{"observed":true},"result":"field"}', + ); + await exchange({ structuredContent }, async (client) => { + const result = await client.callTool({ name: "observed", arguments: {} }); + expect(result.content).toEqual([]); + expect(Object.hasOwn(result.structuredContent as object, "__proto__")).toBe( + true, + ); + expect(JSON.stringify(result.structuredContent)).toBe( + JSON.stringify(structuredContent), + ); + }); +}); + +it.each(["task", "inputRequests", "requestState"])( + "retains the SDK's legacy contentless foreign-family rejection for %s", + async (key) => { + await exchange({ [key]: {} }, async (client) => { + await expect( + client.callTool({ name: "foreign", arguments: {} }), + ).rejects.toThrow(/content is required/u); + }); + }, +); + +it("retains inherited callTool output-schema validation", async () => { + const tool: Tool = { + name: "typed", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { count: { type: "integer" } }, + required: ["count"], + }, + }; + await exchange( + { content: [], structuredContent: { count: "wrong" } }, + async (client) => { + await expect( + client.callTool( + { name: tool.name, arguments: {} }, + { toolDefinition: tool }, + ), + ).rejects.toThrow(/does not match the tool's output schema/u); + }, + ); +}); + +it("delegates explicitly supplied result schemas and other method requests unchanged", async () => { + const reply = { observed: "custom result" }; + const schema = z + .strictObject({ observed: z.string() }) + .transform(({ observed }) => observed); + await exchange(reply, async (client) => { + expect( + await client.request( + { method: "tools/call", params: { name: "custom", arguments: {} } }, + schema, + ), + ).toBe("custom result"); + expect(await client.ping()).toEqual({}); + }); +}); diff --git a/src/providers/StructuredContentMcpClient.ts b/src/providers/StructuredContentMcpClient.ts new file mode 100644 index 0000000000..6a0d6f6aa0 --- /dev/null +++ b/src/providers/StructuredContentMcpClient.ts @@ -0,0 +1,77 @@ +import { + Client, + type CallToolResult, + type Request, + type RequestMethod, + type RequestOptions, + type ResultTypeMap, + type StandardSchemaV1, +} from "@modelcontextprotocol/client"; + +const isSchema = ( + value: RequestOptions | StandardSchemaV1 | undefined, +): value is StandardSchemaV1 => + typeof value === "object" && value !== null && "~standard" in value; + +/** Preserve observed structured dictionaries across the SDK's era-specific result validation. */ +export class StructuredContentMcpClient extends Client { + readonly #toolResult: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "rea-mcp-structured-content", + validate: (input) => { + const parsed = this._wireCodec().validateResult("tools/call", input); + if (!parsed.ok) + return { + issues: [ + { + message: + parsed.reason === "invalid" + ? parsed.message + : "not-in-era: tools/call", + }, + ], + }; + // Retain the SDK's envelope normalization, replacing only the dictionary + // its legacy record parser may clone without own __proto__ members. + if ( + typeof input === "object" && + input !== null && + Object.hasOwn(input, "structuredContent") + ) + return { + value: { + ...parsed.value, + structuredContent: (input as { structuredContent: unknown }) + .structuredContent, + }, + }; + return { value: parsed.value }; + }, + }, + }; + + override request( + request: { method: Method; params?: Record }, + options?: RequestOptions, + ): Promise; + override request( + request: Request, + resultSchema: Schema, + options?: RequestOptions, + ): Promise>; + override request( + request: Request, + schemaOrOptions?: RequestOptions | StandardSchemaV1, + options?: RequestOptions, + ): Promise { + if (isSchema(schemaOrOptions)) + return super.request(request, schemaOrOptions, options); + if (request.method === "tools/call") + return super.request(request, this.#toolResult, schemaOrOptions); + return super.request( + request as { method: RequestMethod; params?: Record }, + schemaOrOptions, + ); + } +} diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index c860d20946..99950d4545 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -519,6 +519,9 @@ it "output-limit", "wrong-profile", "malformed-reply", + "invalid-utf8", + "literal-replacement", + "bom-reply", ])( "preserves process/reply boundaries and cleans private snapshots: %s", async (scenario) => { @@ -542,32 +545,50 @@ it diagnostics: _diagnostics, ...value } = binaryLayoutFixture(path); - await writeFile( - request.reply_path, - scenario === "malformed-reply" - ? "broken-json" - : JSON.stringify( - [ - "format", - "unavailable", - "unsupported", - "output-limit", - ].includes(scenario) - ? { - ok: false, - reason: scenario, - message: "ELF table is truncated.", - } - : { - ok: true, - profile: - scenario === "wrong-profile" - ? "pwntools@other" - : PWNTOOLS_PROVIDER_IDENTITY.version, - value, - }, - ), - ); + const encodedReplies: Partial> = { + "invalid-utf8": Buffer.concat([ + Buffer.from('{"ok":false,"reason":"format","message":"'), + Buffer.from([0xff]), + Buffer.from('"}'), + ]), + "literal-replacement": JSON.stringify({ + ok: false, + reason: "format", + message: "valid �", + }), + "bom-reply": '\uFEFF{"ok":false,"reason":"format","message":"valid"}', + }; + const encodedReply = encodedReplies[scenario]; + if (encodedReply !== undefined) + await writeFile(request.reply_path, encodedReply); + else { + await writeFile( + request.reply_path, + scenario === "malformed-reply" + ? "broken-json" + : JSON.stringify( + [ + "format", + "unavailable", + "unsupported", + "output-limit", + ].includes(scenario) + ? { + ok: false, + reason: scenario, + message: "ELF table is truncated.", + } + : { + ok: true, + profile: + scenario === "wrong-profile" + ? "pwntools@other" + : PWNTOOLS_PROVIDER_IDENTITY.version, + value, + }, + ), + ); + } return spawnOwnedProviderProcess({ ...spawn, command: process.execPath, @@ -576,6 +597,11 @@ it }, ); const result = await provider.inspect({ path }); + if (scenario === "literal-replacement") + expect(result).toMatchObject({ + ok: false, + error: { issues: [{ message: "valid �" }] }, + }); if (scenario === "success") { if (!result.ok) throw result.error; expect(result.value.artifact).toMatchObject({ @@ -589,7 +615,7 @@ it ok: false, error: { _tag: - scenario === "format" + scenario === "format" || scenario === "literal-replacement" ? "AnalysisInputError" : scenario === "unavailable" ? "ProviderSelectionError" diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 6191079291..b7b0611aa8 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -381,6 +381,9 @@ it "wrong-version", "wrong-decoded-carrier", "broken-reply", + "invalid-utf8", + "literal-replacement", + "bom-reply", "out-of-range-body", ])( "binds source carrier/worker representation and releases owned files: %s", @@ -405,45 +408,64 @@ it ); const hex = scenario === "wrong-decoded-carrier" ? "00" : "60006000f3"; const bytes = Buffer.from(hex, "hex"); - await writeFile( - request.reply_path, - scenario === "broken-reply" - ? "broken" - : JSON.stringify( - ["format", "unsupported", "output-limit"].includes(scenario) - ? { - ok: false, - reason: scenario, - message: - scenario === "output-limit" - ? "Complete interface evidence exceeds the 16 MiB reply budget." - : "Malformed hex carrier.", - } - : { - ok: true, - version: scenario === "wrong-version" ? "0.0.0" : "0.9.3", - bytecode: { - sha256: createHash("sha256") - .update(bytes) - .digest("hex"), - bytes: bytes.length, - hex, + const encodedReplies: Partial> = { + "invalid-utf8": Buffer.concat([ + Buffer.from('{"ok":false,"reason":"format","message":"'), + Buffer.from([0xff]), + Buffer.from('"}'), + ]), + "literal-replacement": JSON.stringify({ + ok: false, + reason: "format", + message: "valid �", + }), + "bom-reply": '\uFEFF{"ok":false,"reason":"format","message":"valid"}', + }; + const encodedReply = encodedReplies[scenario]; + if (encodedReply !== undefined) + await writeFile(request.reply_path, encodedReply); + else { + await writeFile( + request.reply_path, + scenario === "broken-reply" + ? "broken" + : JSON.stringify( + ["format", "unsupported", "output-limit"].includes(scenario) + ? { + ok: false, + reason: scenario, + message: + scenario === "output-limit" + ? "Complete interface evidence exceeds the 16 MiB reply budget." + : "Malformed hex carrier.", + } + : { + ok: true, + version: + scenario === "wrong-version" ? "0.0.0" : "0.9.3", + bytecode: { + sha256: createHash("sha256") + .update(bytes) + .digest("hex"), + bytes: bytes.length, + hex, + }, + raw: { + functions: + scenario === "out-of-range-body" + ? [ + { + selector: "aabbccdd", + bytecodeOffset: 5, + dispatch: "abi", + }, + ] + : [], + }, }, - raw: { - functions: - scenario === "out-of-range-body" - ? [ - { - selector: "aabbccdd", - bytecodeOffset: 5, - dispatch: "abi", - }, - ] - : [], - }, - }, - ), - ); + ), + ); + } return spawnOwnedProviderProcess({ ...spawn, command: process.execPath, @@ -452,6 +474,11 @@ it }, ); const result = await provider.inspect({ path, encoding: "hex" }); + if (scenario === "literal-replacement") + expect(result).toMatchObject({ + ok: false, + error: { issues: [{ message: "valid �" }] }, + }); if (scenario === "success") { if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ @@ -465,7 +492,7 @@ it ok: false, error: { _tag: - scenario === "format" + scenario === "format" || scenario === "literal-replacement" ? "AnalysisInputError" : scenario === "unsupported" ? "AnalysisUnsupportedTargetError" diff --git a/tests/boundary/mcp/idaTransport.test.ts b/tests/boundary/mcp/idaTransport.test.ts index b53d2e422f..73e8684aac 100644 --- a/tests/boundary/mcp/idaTransport.test.ts +++ b/tests/boundary/mcp/idaTransport.test.ts @@ -214,6 +214,20 @@ it("uses the pinned SDK for a local Streamable HTTP handshake, paged discovery, ).toBe(true); }); +it("retains own structured fields through the SDK before scalar-wrapper selection", async () => { + const structuredContent = { + ["__proto__"]: { observed: true }, + result: "ordinary field", + nested: [{ ["__proto__"]: "nested fact" }], + }; + const { connection } = await fixture({ + toolResult: { content: [], structuredContent }, + }); + await connection.connect(); + const result = await connection.call("first", {}); + expect(JSON.stringify(result)).toBe(JSON.stringify(structuredContent)); +}); + it("omits an oversized HTTP error body before retaining or redacting it", async () => { const { connection } = await fixture({ toolStatus: 500, diff --git a/tests/boundary/providers/jebMcpReplies.test.ts b/tests/boundary/providers/jebMcpReplies.test.ts new file mode 100644 index 0000000000..63295a9d22 --- /dev/null +++ b/tests/boundary/providers/jebMcpReplies.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import { createStreamableHttpJebMcpConnection } from "../../../src/jeb/JebMcpConnection.js"; +import { startJebMcpFixture } from "../../fixtures/jebMcp.js"; + +it("retains own structured fields through the JEB SDK transport", async () => { + const expected = { + ["__proto__"]: { observed: true }, + success: true, + nested: [{ ["__proto__"]: "nested fact" }], + }; + const fixture = await startJebMcpFixture(() => expected); + const connection = createStreamableHttpJebMcpConnection( + new URL(fixture.endpoint), + ); + try { + await connection.connect(); + const result = await connection.call("get_client_information", {}); + expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); + } finally { + try { + await connection.close(); + } finally { + await fixture.close(); + } + } +});