diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 1589ca2f8..d7934eda1 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -713,9 +713,9 @@ "mcp_prompts": 6 }, "digests": { - "tools_sha256": "9ee5392d18f04f25b5c97c1b69a3644a0d81e52c813167a6f140791bc692741d", + "tools_sha256": "bbf6d1716e6c40e6214ec639ee55904625e37cb07248a49dd166bc0ed954cad9", "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", - "combined_sha256": "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a", + "combined_sha256": "adbd48a1533f6d7d1cdfddbe7d7f350205e106e636384537bf078b6daf870f18", "providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5" } } diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index ec344ff8d..2a2197a7a 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_8a67e7a3578e49c8d8d22107eab8f1258094dc40041d14deee93189fd0c514b2", + "manifest_id": "ecm_bfae29da7d32a1f58ae7f87d6bba618590ba476a9a70fbbd0059fe33a65f3cc0", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "ef398cf4b5ee7fb5e0bcf42ca787049ec145e1d911ee7951017fee1cb3980d74" + "sha256": "d62fe4acdd5797ded5f13ef77987d5444fee6517b00e6eda5378069fb501844d" } ], "claims": [ diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index d8e2c9d90..da7a2c98d 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi metadata: version: "33" tool_count: 139 - catalog_digest: "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a" + catalog_digest: "adbd48a1533f6d7d1cdfddbe7d7f350205e106e636384537bf078b6daf870f18" --- # REA diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index 8769edad8..3d0e53749 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { z } from "zod"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; @@ -113,6 +114,12 @@ const parseCaptureEvidence = (input: unknown) => { try { evidence = parseEvidence(input); } catch (cause: unknown) { + if (cause instanceof z.ZodError) + throw new AnalysisInputError( + "compare_process_captures", + { cause }, + projectInputIssues(cause.issues, input), + ); throw invalidCaptureEvidence(); } if ( diff --git a/src/domain/apple/keyedArchive.test.ts b/src/domain/apple/keyedArchive.test.ts new file mode 100644 index 000000000..375729afe --- /dev/null +++ b/src/domain/apple/keyedArchive.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { MAX_JSON_DEPTH } from "../jsonValue.js"; +import { projectKeyedArchive } from "./keyedArchive.js"; + +const deepValue = (depth: number): unknown => { + let value: unknown = 1; + for (let index = 0; index < depth; index += 1) value = { nested: value }; + return value; +}; + +describe("projectKeyedArchive input bound", () => { + it("rejects an over-deep archive value with a validation error instead of crashing", () => { + let error: unknown; + try { + projectKeyedArchive(deepValue(MAX_JSON_DEPTH + 1), { + offset: 0, + limit: 1, + }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(z.ZodError); + expect(error).not.toBeInstanceOf(RangeError); + }); +}); diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index d5173c194..c5c20a521 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "vitest"; import { createAnalysisProfile } from "./analysisProfile.js"; import type { BinaryTarget } from "./binaryTarget.js"; import { createEvidence, evidenceSchema, parseEvidence } from "./evidence.js"; +import { MAX_JSON_DEPTH } from "./jsonValue.js"; import { createEvidenceBundle } from "./evidenceBundle.js"; const TARGET: BinaryTarget = { @@ -255,3 +256,27 @@ describe("DOS analysis evidence identity", () => { ); }); }); + +describe("evidence parameter depth bound", () => { + it("rejects evidence whose parameters nest past the JSON depth limit", () => { + const evidence = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + }); + let value: unknown = 1; + for (let index = 0; index <= MAX_JSON_DEPTH; index += 1) + value = { nested: value }; + const result = evidenceSchema.safeParse({ + ...evidence, + parameters: { attack: value }, + }); + expect(result.success).toBe(false); + if (result.success) return; + expect( + result.error.issues.some((issue) => + issue.message.includes("maximum nesting depth"), + ), + ).toBe(true); + }); +}); diff --git a/src/domain/jsonValue.test.ts b/src/domain/jsonValue.test.ts index 217c44626..48f23a15d 100644 --- a/src/domain/jsonValue.test.ts +++ b/src/domain/jsonValue.test.ts @@ -1,8 +1,12 @@ import { Ajv2020 } from "ajv/dist/2020.js"; -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { jsonObjectSchema, jsonValueSchema } from "./jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + MAX_JSON_DEPTH, +} from "./jsonValue.js"; const values = [ "text", @@ -35,3 +39,63 @@ it("still parses only JSON values at runtime", () => { for (const value of [undefined, Number.NaN, Infinity, () => 0, [undefined]]) expect(jsonValueSchema.safeParse(value).success).toBe(false); }); + +const deepObject = (depth: number): unknown => { + let value: unknown = 1; + for (let index = 0; index < depth; index += 1) value = { nested: value }; + return value; +}; + +describe("jsonValueSchema depth bound", () => { + it("accepts values nested to the maximum depth", () => { + expect(jsonValueSchema.safeParse(deepObject(MAX_JSON_DEPTH)).success).toBe( + true, + ); + }); + + it("rejects values nested past the maximum depth without throwing", () => { + const result = jsonValueSchema.safeParse(deepObject(MAX_JSON_DEPTH + 1)); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0]?.message).toContain("maximum nesting depth"); + }); + + it.each(["object", "array"])( + "rejects the original 10000-level %s crash without throwing", + (kind) => { + let value: unknown = 1; + for (let index = 0; index < 10_000; index += 1) + value = kind === "object" ? { nested: value } : [value]; + const result = jsonValueSchema.safeParse(value); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0]?.message).toContain( + "maximum nesting depth", + ); + }, + ); + + it("rejects cyclic values instead of recursing forever", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const result = jsonValueSchema.safeParse(cyclic); + expect(result.success).toBe(false); + }); + + it("keeps json semantics for non-JSON values", () => { + expect(jsonValueSchema.safeParse(undefined).success).toBe(false); + expect(jsonValueSchema.safeParse({ a: Number.NaN }).success).toBe(false); + expect( + jsonValueSchema.safeParse({ a: Number.POSITIVE_INFINITY }).success, + ).toBe(false); + expect(jsonValueSchema.safeParse({ a: 1n }).success).toBe(false); + }); + + it("accepts ordinary nested JSON", () => { + expect( + jsonValueSchema.safeParse({ + list: [1, "two", null, true, { deep: { further: [{}] } }], + }).success, + ).toBe(true); + }); +}); diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index 1917eac31..ccfb3238f 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -1,10 +1,64 @@ import { z } from "zod"; -/** JSON-safe value shared by domain, application, and adapter boundaries. */ -export const jsonValueSchema = z.json(); +/** + * Maximum nesting depth accepted for JSON values. + * + * Sits well below the point where the recursive `z.json()` core overflows its + * stack (measured between 2000 and 3000 on Node 24) while leaving room for + * legitimately deep target-derived trees such as compiled NIB view hierarchies. + */ +export const MAX_JSON_DEPTH = 1000; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const exceedsMaxDepth = (root: unknown): boolean => { + // Depth counts edges from the root. + const pending: { value: unknown; depth: number }[] = [ + { value: root, depth: 0 }, + ]; + while (pending.length > 0) { + const item = pending.pop(); + if (item === undefined) break; + if (item.depth > MAX_JSON_DEPTH) return true; + const value = item.value; + if (Array.isArray(value)) { + for (const child of value) + pending.push({ value: child, depth: item.depth + 1 }); + } else if (isRecord(value)) { + for (const key of Object.keys(value)) + pending.push({ value: value[key], depth: item.depth + 1 }); + } + } + return false; +}; + +/** + * JSON-safe value shared by domain, application, and adapter boundaries. + * + * An iterative depth guard runs before the recursive `z.json()` core so + * hostile nested payloads surface as validation issues instead of RangeErrors. + * The guard only refuses over-deep values; ordinary JSON parsing semantics + * (including which members `z.json()` keeps) are unchanged. + */ +const jsonCore = z.json(); // Every JSON value satisfies `{}`. Advertise that instead of Zod's // self-referential `$defs` projection, which MCP clients and model APIs that // reject recursive JSON Schemas refuse. Runtime parsing still admits only JSON. +// The override sits on the core as well as on the pipe because the standard +// JSON Schema projection walks into pipe stages without consulting the outer +// node, while the public toJSONSchema projection stops at the pipe. +jsonCore._zod.toJSONSchema = () => ({}); +export const jsonValueSchema = z + .unknown() + .superRefine((value, context) => { + if (exceedsMaxDepth(value)) + context.addIssue({ + code: "custom", + message: `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`, + }); + }) + .pipe(jsonCore); jsonValueSchema._zod.toJSONSchema = () => ({}); /** JSON object boundary for caller-visible parameter maps. */ diff --git a/tests/boundary/cli/processCli.test.ts b/tests/boundary/cli/processCli.test.ts index fd796634e..2b99cd240 100644 --- a/tests/boundary/cli/processCli.test.ts +++ b/tests/boundary/cli/processCli.test.ts @@ -16,6 +16,7 @@ import { } from "../../../src/application/process/ProcessCli.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { PROCESS_PROVIDER } from "../../../src/application/process/ProcessEvidence.js"; +import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; const roots: string[] = []; const execFileAsync = promisify(execFile); @@ -292,11 +293,62 @@ describe("process CLI evidence validation", () => { const malformed = join(root, "malformed-evidence.json"); await writeFile(malformed, "{}"); - expect(await compareProcessEvidenceFiles(malformed, malformed)).toEqual({ + expect( + await compareProcessEvidenceFiles(malformed, malformed), + ).toMatchObject({ error: "Process command failed", category: "invalid_input", - message: - "Capture evidence is malformed. Create new capture evidence, then try again.", + code: "invalid_request", + details: { + operation: "compare_process_captures", + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["parameters"], + reason: "missing_argument", + }), + ]), + }, }); }); }); + +it( + "reports the JSON depth constraint through the compiled comparison CLI", + async () => { + const root = await fixture(); + const left = join(root, "left.json"); + const right = join(root, "right.json"); + const input = INVESTIGATION_EXAMPLES.compare_process_captures.input; + await writeFile(left, JSON.stringify(input.left)); + await writeFile(right, JSON.stringify(input.right)); + const cli = fileURLToPath( + new URL("../../../scripts/rea.mjs", import.meta.url), + ); + const cliArguments = [ + cli, + "compare-process-captures", + left, + right, + "--json", + ]; + const baseline = await execFileAsync(process.execPath, cliArguments); + expect(JSON.parse(baseline.stdout)).toMatchObject({ + operation: "compare_process_captures", + }); + const nested = '{"nested":'.repeat(10_000) + "1" + "}".repeat(10_000); + await writeFile( + left, + JSON.stringify({ + ...input.left, + parameters: { attack: "depth-placeholder" }, + }).replace('"depth-placeholder"', nested), + ); + await expect( + execFileAsync(process.execPath, cliArguments), + ).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining("maximum nesting depth"), + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, +); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index cc392308e..cb3b0cd4f 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -21,6 +21,8 @@ import { createServer } from "../../../src/server/createServer.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { silentLogger } from "../../../src/logger.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { MAX_JSON_DEPTH } from "../../../src/domain/jsonValue.js"; +import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; import { ok as resultOk } from "../../../src/domain/result.js"; import { createSessionMcpHarness, @@ -260,6 +262,40 @@ describe("session filesystem path boundaries over MCP", () => { }, 10_000); }); +describe("json depth bounds over MCP", () => { + it("classifies deeply nested evidence parameters as an input validation error", async () => { + directory = await createTestTempDirectory("rea-mcp-depth-bound-"); + const { mcp } = await createSessionMcpHarness( + directory, + provider, + resources, + ); + const input = INVESTIGATION_EXAMPLES.compare_process_captures.input; + const baseline = await mcp.callTool({ + name: "compare_process_captures", + arguments: input, + }); + expect(baseline.isError, JSON.stringify(baseline)).not.toBe(true); + let value: unknown = 1; + for (let index = 0; index <= MAX_JSON_DEPTH; index += 1) + value = { nested: value }; + const result = await mcp.callTool({ + name: "compare_process_captures", + arguments: { + left: { + ...input.left, + parameters: { attack: value }, + }, + right: input.right, + }, + }); + const text = JSON.stringify(result.content); + expect(result.isError, text).toBe(true); + expect(text).toContain("Input validation"); + expect(text).toContain("maximum nesting depth"); + }, 10_000); +}); + describe("process residuals over MCP", () => { it("records process residuals linked to capture Evidence", async () => { if (!(await probeProcessCaptureCapability()).available) return;