mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
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:
1 parent
721dd3d1aa
commit
da526fc014
18 files changed
+508
-95
No files matched your search
@@ -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),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user