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.
This commit is contained in:
morluto committed 2026-10-26 17:52:32 +08:00
1 parent 721dd3d1aa
commit da526fc014
18 files changed
+508 -95

No files matched your search

+28
View File
@@ -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),
});
},
);
+17
View File
@@ -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,
+5 -2
View File
@@ -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;
+4 -6
View File
@@ -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,
+50
View File
@@ -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 });
});
+17 -10
View File
@@ -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<typeof toolResultSchema>;
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<NodeJS.ProcessEnv>,
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);
+16
View File
@@ -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));
},
);
+9 -3
View File
@@ -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<typeof toolResultSchema>;
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(
+5 -2
View File
@@ -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;
+4 -1
View File
@@ -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 &&
@@ -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) {
@@ -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<string, unknown>,
run: (client: StructuredContentMcpClient) => Promise<void>,
) => {
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({});
});
});
@@ -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<unknown, CallToolResult> = {
"~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<Method extends RequestMethod>(
request: { method: Method; params?: Record<string, unknown> },
options?: RequestOptions,
): Promise<ResultTypeMap[Method]>;
override request<Schema extends StandardSchemaV1>(
request: Request,
resultSchema: Schema,
options?: RequestOptions,
): Promise<StandardSchemaV1.InferOutput<Schema>>;
override request(
request: Request,
schemaOrOptions?: RequestOptions | StandardSchemaV1,
options?: RequestOptions,
): Promise<unknown> {
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<string, unknown> },
schemaOrOptions,
);
}
}