fix(domain): preserve prototype-named JSON members through Evidence boundaries (#1049)

* fix(domain): preserve prototype-named JSON members through Evidence boundaries

Zod's z.json() record projection drops own __proto__ members as a
prototype-pollution guard, so provider replies carrying them lost evidence
before hashing/serialization and CLI/MCP output. The shared schema now
validates with z.json() as before, then rebuilds the value keeping
prototype-named own members as ordinary data properties (__proto__ via
Object.defineProperty, so the prototype is never mutated).

parseHarJson no longer rejects __proto__ members; the explicit unsupported
case from #992 is replaced with a preservation regression, and the
inspect_web_network_capture contract text is updated to match.

Fixes #1036

* fix(domain): bound JSON depth and oversized payloads at parsing boundaries

jsonValueSchema re-walked hostile input through z.json()'s recursive core, so a deeply nested payload from an MCP tool argument, inline evidence, provider result, or keyed archive threw RangeError: Maximum call stack size exceeded instead of a validation issue. The schema now runs an iterative depth guard first and keeps z.json() for full JSON semantics behind it, which also turns cyclic values into validation errors. The 1000-edge limit stays far below the range where the recursive 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, and the guard narrows with a typed record check instead of any or unchecked casts. The non-recursive JSON Schema advertisement stays intact on both the public and standard projection paths. Two payload sizes get explicit budgets tied to their producer constraints: web text artifacts reject beyond the 16 MiB source budget the web tooling already advertises, and native UI screenshot base64 beyond the 64 MiB helper output budget. Regenerated product catalog, skill catalog digest, and completion-ledger manifest follow the schema-boundary change.

* fix(validation): preserve depth diagnostics across process inputs

---------

Co-authored-by: DeryFerd <deryferdikao125@gmail.com>
Co-authored-by: N0zoM1z0 <1705004398@qq.com>
This commit is contained in:
Saim Shafique 2026-10-08 18:32:48 +05:00 committed by GitHub
parent bfbfc7330a
commit 40e18b2e37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 203 additions and 83 deletions

View File

@ -133,8 +133,6 @@ def project_record(state, secrets):
for key, item in value.items():
if not isinstance(key, str):
raise CaptureFailure("format", "Native record has a non-string dictionary key.", path)
if key == "__proto__":
raise CaptureFailure("unsupported", "Native object contains a __proto__ member that the current JSON schema boundary cannot preserve.", path)
if any(secret in key for secret in secrets):
# Validate the omitted subtree too, then discard all of its
# coordinates rather than inventing a replacement identity.

View File

@ -713,9 +713,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "bbf6d1716e6c40e6214ec639ee55904625e37cb07248a49dd166bc0ed954cad9",
"tools_sha256": "18a0131bdea6a7af17c052bc97d343e1f833e1a35218e0098eba9695e9f821a2",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "adbd48a1533f6d7d1cdfddbe7d7f350205e106e636384537bf078b6daf870f18",
"combined_sha256": "b40971d45fb56386599e5f8e904c21ee491b20ef23b089ceb28750641eb58ae7",
"providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_bfae29da7d32a1f58ae7f87d6bba618590ba476a9a70fbbd0059fe33a65f3cc0",
"manifest_id": "ecm_c39e03fbab9c465463c5c8f729512a712af644971fa1d9cdfd245d583a2aa2e6",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "d62fe4acdd5797ded5f13ef77987d5444fee6517b00e6eda5378069fb501844d"
"sha256": "7542b5f56f36a31aa3a7e75043933747ecf63b6b51ad10ca375fb03b77aca795"
}
],
"claims": [

View File

@ -32,9 +32,9 @@ HAR uses unchanged `har-schema@2.0.0` draft-06 schemas, `ajv@8.20.0`,
`lossless-json@4.3.1` numeric representations, pinned in the npm lockfile.
Only the HAR 1.2 profile is supported. Unknown extensions remain reported
evidence. Duplicate object keys, including equal values and escaped spellings,
are rejected before materialization. A `__proto__` member returns an explicit
unsupported-boundary error in both formats: the current shared JSON schema
cannot preserve this member. Original capture files remain unchanged.
are rejected before materialization. Prototype-named members such as
`__proto__` are preserved as ordinary own JSON members without prototype
mutation. Original capture files remain unchanged.
The observed mitmproxy 12.2.3 `SaveHar` profile can emit `postData.text: null`
for a missing request body. REA preserves that reported null, omitting the
optional field only from its validation copy for this exact producer profile;

View File

@ -260,14 +260,16 @@ try {
);
const original = await readFile(path);
for (const mode of ["cli", "mcp"]) {
const error = await inspect(
mode,
{ capture_path: path, format },
"unsupported_provider",
);
assert.ok(
JSON.stringify(error).includes("JSON schema boundary cannot preserve"),
);
const value = await inspect(mode, { capture_path: path, format });
const reported =
format === "har"
? value.container.reported._extension
: value.records[0].reported.metadata;
assert.equal(Object.hasOwn(reported, "__proto__"), true);
assert.deepEqual(reported["__proto__"], { preserved: 7 });
assert.equal(Object.getPrototypeOf(reported), Object.prototype);
assert.equal(Reflect.get(Object.prototype, "preserved"), undefined);
if (format === "har") assert.equal(reported.constructor, "ordinary");
assert.deepEqual(await readFile(path), original);
cases++;
}

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "33"
tool_count: 139
catalog_digest: "adbd48a1533f6d7d1cdfddbe7d7f350205e106e636384537bf078b6daf870f18"
catalog_digest: "b40971d45fb56386599e5f8e904c21ee491b20ef23b089ceb28750641eb58ae7"
---
# REA

View File

@ -161,14 +161,28 @@ it("rejects identical duplicate core fields before schema validation", () => {
expect(() => decodeHarCapture(text, [])).toThrow("duplicate");
});
it("reports an unsupported JSON schema boundary instead of dropping a producer property", () => {
it("preserves prototype-named producer members through the capture boundary", () => {
const text = JSON.stringify(historicalHar()).replace(
'"cache":{}',
'"cache":{},"_extension":{"__proto__":{"preserved":7},"constructor":"ordinary"}',
);
expect(() => decodeHarCapture(text, [])).toThrow(
"JSON schema boundary cannot preserve",
);
const result = decodeHarCapture(text, []);
const reported = result.records[0]?.reported;
expect(reported).toBeDefined();
if (
reported === undefined ||
reported === null ||
typeof reported !== "object" ||
Array.isArray(reported)
)
throw new Error("No HAR object record was retained");
const extension = reported["_extension"];
expect(extension).toEqual({
["__proto__"]: { preserved: 7 },
constructor: "ordinary",
});
expect(Object.getPrototypeOf(extension)).toBe(Object.prototype);
expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined();
});
it("preserves Unicode, offsets, sizes, duplicate URLs and opaque extensions without inventing bytes", () => {

View File

@ -58,11 +58,9 @@ export const parseHarJson = (text: string): unknown => {
});
},
onObjectProperty: (key) => {
if (key === "__proto__")
throw new CaptureFormatError(
"unsupported",
"HAR object contains a __proto__ member that the current JSON schema boundary cannot preserve.",
);
// Prototype-named members are kept as ordinary own properties: the
// members map keys them without prototype setters, and
// Object.fromEntries materializes them as own data properties.
const frame = frames.at(-1);
if (frame?.kind !== "object")
throw new TypeError(

View File

@ -12,7 +12,7 @@ export const WEB_NETWORK_CAPTURE_TOOL_CONTRACTS = [
name: "inspect_web_network_capture",
...toolContractMetadata("inspect_web_network_capture"),
description:
"Inspect retained local HAR 1.2 or native mitmproxy capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named __proto__ members are unsupported by the current shared JSON boundary. Uses pinned upstream parsers, owned temporary files and bounded offline processes. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.",
"Inspect retained local HAR 1.2 or native mitmproxy capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named members such as __proto__ are preserved as ordinary own JSON members without prototype mutation. Uses pinned upstream parsers, owned temporary files and bounded offline processes. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.",
kind: "browser-provider",
inputSchema: inspectWebNetworkCaptureInputSchema,
outputSchema: evidenceResultOf(webNetworkCaptureSchema),

View File

@ -44,13 +44,27 @@ it("snapshots caller-owned payloads before deriving their identity", () => {
});
describe("analysis evidence identity", () => {
it("normalizes prototype-named parameter keys", () => {
it("preserves prototype-named parameter keys and their semantic identity", () => {
const evidence = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: Object.fromEntries([["__proto__", false]]),
result: true,
});
expect(evidence.parameters).toEqual({ ["__proto__"]: false });
expect(Object.hasOwn(evidence.parameters, "__proto__")).toBe(true);
expect(parseEvidence(evidence)).toEqual(evidence);
const stripped = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: {},
result: true,
});
expect(stripped.evidence_id).not.toBe(evidence.evidence_id);
expect(() =>
parseEvidence({
...evidence,
parameters: { ["__proto__"]: true },
}),
).toThrow(/semantic identifier/u);
});
it.prop([
@ -280,3 +294,27 @@ describe("evidence parameter depth bound", () => {
).toBe(true);
});
});
it("preserves prototype-named raw and normalized results with aligned identity", () => {
const result = { ["__proto__"]: { preserved: 7 }, constructor: "ordinary" };
const evidence = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: {},
result,
rawResult: result,
});
const parsed = parseEvidence(evidence);
expect(parsed.normalized_result).toEqual(result);
expect(parsed.raw_result).toEqual(result);
expect(Object.getPrototypeOf(parsed.normalized_result)).toBe(
Object.prototype,
);
expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined();
const stripped = createEvidence(TARGET, PROVIDER, {
operation: "health",
parameters: {},
result: { constructor: "ordinary" },
rawResult: { constructor: "ordinary" },
});
expect(stripped.evidence_id).not.toBe(evidence.evidence_id);
});

View File

@ -7,7 +7,11 @@ import {
type AnalysisProfileCommitment,
} from "./analysisProfile.js";
import type { BinaryTarget } from "./binaryTarget.js";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "./jsonValue.js";
import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
@ -89,7 +93,7 @@ const evidenceBaseSchema = z
provider: providerSchema,
predicate_type: z.string().min(1),
operation: z.string().min(1),
parameters: z.record(z.string(), jsonValueSchema),
parameters: jsonObjectSchema,
raw_result: jsonValueSchema.nullable(),
normalized_result: jsonValueSchema,
confidence: z.enum(["observed", "derived", "inferred"]),

View File

@ -12,16 +12,21 @@ import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
const semanticNodeIdSchema = prefixedDigestSchema("jsrg_node");
const literalValueSchema = jsonValueSchema.refine(
(value) =>
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean",
"Literal seeds accept only JSON primitive values",
);
// Zod derivations drop the shared schema's JSON Schema projection override;
// re-apply it so MCP catalog generation keeps projecting `{}`.
literalValueSchema._zod.toJSONSchema = () => ({});
const literalSeedSchema = z.strictObject({
kind: z.literal("literal"),
value: jsonValueSchema.refine(
(value) =>
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean",
"Literal seeds accept only JSON primitive values",
),
value: literalValueSchema,
});
/** Authenticated starting points accepted by semantic tracing. */

View File

@ -99,3 +99,34 @@ describe("jsonValueSchema depth bound", () => {
).toBe(true);
});
});
it("preserves prototype-named members without prototype mutation", () => {
const input: unknown = JSON.parse(
'{"__proto__":{"preserved":7},"constructor":"ordinary","prototype":[1],"nested":{"\\u005f\\u005fproto\\u005f\\u005f":"escaped"},"list":[{"__proto__":null}]}',
);
const parsed = jsonObjectSchema.parse(input);
expect(parsed).toEqual(input);
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
expect(Object.hasOwn(parsed, "__proto__")).toBe(true);
expect(parsed["__proto__"]).toEqual({ preserved: 7 });
expect(parsed["constructor"]).toBe("ordinary");
expect(parsed["prototype"]).toEqual([1]);
expect(parsed["nested"]).toEqual({ ["__proto__"]: "escaped" });
expect(parsed["list"]).toEqual([{ ["__proto__"]: null }]);
expect(Object.getPrototypeOf(parsed["nested"])).toBe(Object.prototype);
expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined();
expect(JSON.parse(JSON.stringify(parsed))).toEqual(input);
});
it.each([undefined, Number.NaN, Infinity, 1n, () => 0, new Date(0)])(
"rejects non-JSON values in prototype-named members (%s)",
(value) => {
for (const input of [
{ ["__proto__"]: value },
{ nested: [{ ["__proto__"]: value }] },
]) {
expect(jsonValueSchema.safeParse(input).success).toBe(false);
expect(jsonObjectSchema.safeParse(input).success).toBe(false);
}
},
);

View File

@ -1,67 +1,87 @@
import { z } from "zod";
/**
* 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.
*/
/** Maximum JSON nesting accepted before recursive result cloning. */
export const MAX_JSON_DEPTH = 1000;
/** JSON-safe value shared by domain, application, and adapter boundaries. */
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const recordShape = z.record(z.string(), z.unknown());
const exceedsMaxDepth = (root: unknown): boolean => {
// Depth counts edges from the root.
const jsonValidationIssue = (root: unknown): string | undefined => {
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;
if (item.depth > MAX_JSON_DEPTH)
return `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`;
const value = item.value;
if (Array.isArray(value)) {
if (
value === null ||
typeof value === "string" ||
typeof value === "boolean"
)
continue;
if (typeof value === "number") {
if (!Number.isFinite(value)) return "JSON numbers must be finite";
} else 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 });
}
} else if (isRecord(value) && recordShape.safeParse(value).success) {
// Inspect every own member, including keys Zod's record parser skips.
for (const child of Object.values(value))
pending.push({ value: child, depth: item.depth + 1 });
} else return "Invalid JSON value";
}
return false;
return undefined;
};
/**
* 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 = () => ({});
const preserveJsonValue = (value: JsonValue): JsonValue => {
if (Array.isArray(value)) return value.map(preserveJsonValue);
if (value !== null && typeof value === "object")
return Object.fromEntries<JsonValue>(
Object.entries(value).map(([key, child]) => [
key,
preserveJsonValue(child),
]),
);
return value;
};
const validatedJsonValue = z.custom<JsonValue>(
(value) => jsonValidationIssue(value) === undefined,
{
error: (issue) => jsonValidationIssue(issue.input) ?? "Invalid JSON value",
},
);
// Keep both stages nonrecursive in public and Standard Schema projections.
validatedJsonValue._zod.toJSONSchema = () => ({});
/** Parse bounded JSON and retain all own data members without prototype setters. */
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);
.nonoptional()
.pipe(validatedJsonValue)
.transform(preserveJsonValue);
jsonValueSchema._zod.toJSONSchema = () => ({});
/** JSON object boundary for caller-visible parameter maps. */
export const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
const objectValidation = z.custom<Record<string, JsonValue>>(
isRecord,
"Expected JSON object",
);
const objectProjection = () => ({ type: "object", additionalProperties: {} });
objectValidation._zod.toJSONSchema = objectProjection;
export type JsonValue = z.infer<typeof jsonValueSchema>;
/** JSON object boundary for caller-visible parameter maps. */
export const jsonObjectSchema = jsonValueSchema.pipe(objectValidation);
jsonObjectSchema._zod.toJSONSchema = objectProjection;

View File

@ -5,6 +5,7 @@ import { WebNetworkCaptureService } from "../../../src/application/WebNetworkCap
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ok } from "../../../src/domain/result.js";
import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
@ -17,7 +18,7 @@ it("publishes valid SDK schemas and retains historical inline Evidence with the
format: "har",
decoder: { id: "test-capture-port", name: "test port", version: "1" },
container: {
reported: { creator: "fixture" },
reported: { creator: "fixture", ["__proto__"]: { preserved: 7 } },
numeric_literals: [],
redactions: [],
records_pointer: "/log/entries",
@ -72,6 +73,15 @@ it("publishes valid SDK schemas and retains historical inline Evidence with the
);
const evidence = parseEvidence(parsed.evidence);
expect(parsed.result).toEqual(evidence.normalized_result);
const capture = webNetworkCaptureSchema.parse(parsed.result);
expect(capture.container.reported).toEqual({
creator: "fixture",
["__proto__"]: { preserved: 7 },
});
expect(Object.getPrototypeOf(capture.container.reported)).toBe(
Object.prototype,
);
expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined();
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
const invalid = await client.callTool({
name: "inspect_web_network_capture",