From e7f986c99485f48cb34d9e1d5cbf2f291a2d45ca Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:14:43 +0800 Subject: [PATCH] fix(json): consolidate dictionary admission across analysis and verification --- .../lib/javascript-file-scope-oracle.d.mts | 28 -------- ...e.mjs => javascript-file-scope-oracle.mts} | 23 ++++++- scripts/verify/javascript/native-handoffs.mjs | 10 +-- src/contracts/officialToolContracts.ts | 3 +- .../analysisView/environmentStateView.test.ts | 65 +++++++++++++++++++ .../analysisView/environmentStateView.ts | 15 ++--- src/domain/hopperValues.test.ts | 29 +++++++++ src/domain/hopperValues.ts | 4 +- src/domain/jsonRecord.test.ts | 51 +++++++++++++++ src/domain/jsonRecord.ts | 32 +++++++-- src/domain/native/nativeCallObservation.ts | 25 +------ src/domain/process/processScenario.test.ts | 26 +++++++- src/domain/process/processScenario.ts | 24 +------ src/ghidra/GhidraInventoryValues.test.ts | 22 +++++++ src/ghidra/GhidraInventoryValues.ts | 3 +- .../mcp/enhancedTools.limitations.test.ts | 47 ++++++++++++++ tests/boundary/mcp/officialTools.test.ts | 34 ++++++++++ .../javascriptVerifierScopeOracle.test.ts | 27 ++++++++ 18 files changed, 369 insertions(+), 99 deletions(-) delete mode 100644 scripts/lib/javascript-file-scope-oracle.d.mts rename scripts/lib/{javascript-file-scope-oracle.mjs => javascript-file-scope-oracle.mts} (56%) diff --git a/scripts/lib/javascript-file-scope-oracle.d.mts b/scripts/lib/javascript-file-scope-oracle.d.mts deleted file mode 100644 index 89676bb6eb..0000000000 --- a/scripts/lib/javascript-file-scope-oracle.d.mts +++ /dev/null @@ -1,28 +0,0 @@ -import type { z } from "zod"; - -export interface StructuredScopeExpectation { - readonly operation: - | "parse-package-json" - | "parse-json-module" - | "parse-html-script-references" - | "parse-local-source-map"; - readonly analysis_status: "interrupted" | "not-started"; -} - -export interface StructuredProcessingScope { - readonly properties: Readonly>; - readonly evidence: { - readonly authority: string; - readonly state: string; - readonly confidence: string; - }; -} - -export const structuredScopeExpectationsSchema: z.ZodType< - Record ->; - -export function matchesStructuredScopeExpectation( - scopes: readonly StructuredProcessingScope[], - expected: StructuredScopeExpectation | undefined, -): boolean; diff --git a/scripts/lib/javascript-file-scope-oracle.mjs b/scripts/lib/javascript-file-scope-oracle.mts similarity index 56% rename from scripts/lib/javascript-file-scope-oracle.mjs rename to scripts/lib/javascript-file-scope-oracle.mts index f2f85906e9..db65fab297 100644 --- a/scripts/lib/javascript-file-scope-oracle.mjs +++ b/scripts/lib/javascript-file-scope-oracle.mts @@ -1,8 +1,8 @@ import { z } from "zod"; +import { jsonRecordSchema } from "../../src/domain/jsonRecord.js"; /** Independent expected outcomes supplied by the verification scenario. */ -export const structuredScopeExpectationsSchema = z.record( - z.string().min(1), +export const structuredScopeExpectationsSchema = jsonRecordSchema( z.strictObject({ operation: z.enum([ "parse-package-json", @@ -12,10 +12,27 @@ export const structuredScopeExpectationsSchema = z.record( ]), analysis_status: z.enum(["interrupted", "not-started"]), }), + z.string().min(1), ); +export type StructuredScopeExpectation = z.output< + typeof structuredScopeExpectationsSchema +>[string]; + +export interface StructuredProcessingScope { + readonly properties: Readonly>; + readonly evidence: { + readonly authority: string; + readonly state: string; + readonly confidence: string; + }; +} + /** Every retained gap must match the named phase and its unavailable status. */ -export const matchesStructuredScopeExpectation = (scopes, expected) => +export const matchesStructuredScopeExpectation = ( + scopes: readonly StructuredProcessingScope[], + expected: StructuredScopeExpectation | undefined, +): boolean => expected !== undefined && scopes.length > 0 && scopes.every( diff --git a/scripts/verify/javascript/native-handoffs.mjs b/scripts/verify/javascript/native-handoffs.mjs index 302fdabafb..81ce1224e2 100644 --- a/scripts/verify/javascript/native-handoffs.mjs +++ b/scripts/verify/javascript/native-handoffs.mjs @@ -17,10 +17,12 @@ import { runOwnedCommand, } from "../../../dist/process/OwnedCommand.js"; import { withVerifierCleanup } from "../../lib/verifier-cleanup.mjs"; -import { - matchesStructuredScopeExpectation, - structuredScopeExpectationsSchema, -} from "../../lib/javascript-file-scope-oracle.mjs"; +import { importVerifierSource } from "../../lib/verifier-source-loader.mjs"; + +const { matchesStructuredScopeExpectation, structuredScopeExpectationsSchema } = + await importVerifierSource( + new URL("../../lib/javascript-file-scope-oracle.mts", import.meta.url).href, + ); const { values } = parseArgs({ options: { diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index ba9b8ab30f..1faa17bfd4 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -1,5 +1,6 @@ import { analysisDetailSchema } from "./analysisDetail.js"; import { z } from "zod"; +import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { nativeFunctionAnnotationsInputSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { officialOutputSchemas } from "./toolOutputSchemaGroups.js"; @@ -220,7 +221,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ official( "set_addresses_names", "Assign analyst names to multiple mapped addresses in one call and return per-address edit/readback success booleans. Hopper validates mapped destinations, native string representations, its 1024 UTF-16 code-unit symbol-name limit, and unique destinations/names before applying edits. Existing labels may move or swap only among explicitly selected addresses; unselected label owners remain unchanged. This mutates analysis metadata; verify failures individually.", - z.object({ names: z.record(z.string(), z.string()), document }), + z.object({ names: jsonRecordSchema(z.string()), document }), ), official( "set_bookmark", diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 18f6ef1176..970d429001 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -15,6 +15,7 @@ import type { Evidence } from "../evidence.js"; import type { JsonValue } from "../jsonValue.js"; import { webNetworkCaptureSchema } from "../webNetworkCapture.js"; import { + analysisViewResultSchema, projectAnalysisView, type AnalysisViewParent, type AnalysisViewResult, @@ -29,6 +30,8 @@ import { type JsonObject, type StateRecordFacts, } from "./environmentStateRules.js"; +import { evaluateQuery } from "./environmentStateListQuery.js"; +import { singleQueryValues } from "../recordedTransportUrl.js"; import { environmentStateFields } from "./environmentStateView.js"; import { lineShapes, @@ -1725,3 +1728,65 @@ it.each(["+00:60", "+24:00", "+99:99"])( expect(environmentStateFields.views.safeParse([view]).success).toBe(false); }, ); + +it("keeps learned prototype-named query roles through public result normalization", () => { + const books = [ + { id: 1, shelf: "a" }, + { id: 2, shelf: "b" }, + ]; + const state = stateOf( + captureOf([ + ["GET", "/api/books", books], + ["GET", "/api/books?__proto__=x&shelf=a", [books[0]!]], + ]), + ); + const normalized = analysisViewResultSchema.parse( + JSON.parse(JSON.stringify(state)), + ); + if (normalized.kind !== "environment-state") + throw new Error("Expected state"); + const view = normalized.views.find((entry) => entry.shape === "list"); + if (view?.shape !== "list" || view.query === undefined) + throw new Error("Expected learned query"); + expect( + Object.getOwnPropertyDescriptor(view.query.parameters, "__proto__")?.value, + ).toEqual({ role: "inert", values: ["x"] }); + expect( + evaluateQuery( + view.query.parameters, + singleQueryValues("?__proto__=x&shelf=a")!, + normalized.entities, + )?.page.map((entity) => entity.id), + ).toEqual(["1"]); +}); + +it("validates every own render-metadata dictionary entry through the public schema", () => { + const state = stateOf(); + const item = state.views.find((view) => view.shape === "item")!; + const parse = (clock: unknown, nested: unknown) => + analysisViewResultSchema.safeParse({ + ...state, + views: [ + { + ...item, + clock_formats: Object.fromEntries([["__proto__", clock]]), + nested_fields: Object.fromEntries([["__proto__", nested]]), + }, + ], + }); + const clock = { kind: "epoch", unit: "ms" }; + const parsed = parse(clock, ["id"]); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.kind !== "environment-state") + throw new Error("Expected state"); + const view = parsed.data.views[0]!; + if (view.shape !== "item") throw new Error("Expected item"); + expect( + Object.getOwnPropertyDescriptor(view.clock_formats, "__proto__")?.value, + ).toEqual(clock); + expect( + Object.getOwnPropertyDescriptor(view.nested_fields, "__proto__")?.value, + ).toEqual(["id"]); + expect(parse({ kind: "epoch", unit: "invalid" }, ["id"]).success).toBe(false); + expect(parse(clock, [1]).success).toBe(false); +}); diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index 52a03ceb20..cf1b4e09ba 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { jsonValueSchema } from "../jsonValue.js"; +import { jsonRecordSchema } from "../jsonRecord.js"; import { ok, type Result } from "../result.js"; import type { AnalysisViewParent, @@ -73,11 +74,9 @@ const queryParameterSchema = z.discriminatedUnion("role", [ }), ]); const listQuerySchema = z.strictObject({ - parameters: z - .record(z.string(), queryParameterSchema) - .describe( - "What each query parameter does. A call whose every parameter has a role is answered by evaluating them over the state.", - ), + parameters: jsonRecordSchema(queryParameterSchema).describe( + "What each query parameter does. A call whose every parameter has a role is answered by evaluating them over the state.", + ), record_ordinals: z .array(z.number().int().nonnegative()) .describe("Checked lines the query reproduces, answered by evaluating it."), @@ -122,14 +121,12 @@ const entityViewFields = { required_fields: z.array(z.string()), checked_record_ordinals: checkedSchema, per_response_members: perResponseSchema, - clock_formats: z - .record(z.string(), clockFormatSchema) + clock_formats: jsonRecordSchema(clockFormatSchema) .optional() .describe( "How the view spells clock fields it always spells otherwise than the entities' stored values; a stored value the view never recorded is spelled this way.", ), - nested_fields: z - .record(z.string(), z.array(z.string())) + nested_fields: jsonRecordSchema(z.array(z.string())) .optional() .describe( "The keys the view shows of object fields it always shows with fewer keys than an entity stores, such as a parent listed without its own nested type; a stored object the view never recorded shows those keys.", diff --git a/src/domain/hopperValues.test.ts b/src/domain/hopperValues.test.ts index b612a7f3a4..9eaff335e4 100644 --- a/src/domain/hopperValues.test.ts +++ b/src/domain/hopperValues.test.ts @@ -233,3 +233,32 @@ describe("provider-neutral inclusive function body evidence", () => { ).toBe(false); }); }); + +it.each(["procedures", "strings"])( + "counts every own entry of direct and wrapped %s maps", + (property) => { + for (const text of [ + '{"__proto__":"observed"}', + '{"__proto__":"observed","0x1000":"one"}', + ]) { + const source = JSON.parse(text); + const count = Object.keys(source).length; + expect(parseListCount(source, property)).toEqual({ + ok: true, + value: count, + }); + expect(parseListCount({ [property]: source }, property)).toEqual({ + ok: true, + value: count, + }); + expect(JSON.stringify(source)).toBe(text); + } + expect(parseListCount({ [property]: [] }, property)).toEqual({ + ok: true, + value: 0, + }); + expect(parseListCount({}, property)).toEqual({ ok: true, value: 0 }); + for (const value of [null, "not a list", 3, false]) + expect(parseListCount(value, property).ok).toBe(false); + }, +); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 41cd06e858..9d5c4eb520 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { fixedTupleSchema } from "./fixedTupleSchema.js"; import { addressCoordinate } from "./native/addressCoordinate.js"; -import type { JsonValue } from "./jsonValue.js"; +import { jsonObjectSchema, type JsonValue } from "./jsonValue.js"; import { err, ok, type Result } from "./result.js"; import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js"; @@ -577,7 +577,7 @@ export const parseListCount = ( const unwrapped = unwrapProperty(value, property); const list = z.array(z.unknown()).safeParse(unwrapped); if (list.success) return ok(list.data.length); - const map = z.record(z.string(), z.unknown()).safeParse(unwrapped); + const map = jsonObjectSchema.safeParse(unwrapped); return map.success ? ok(Object.keys(map.data).length) : invalid(operation, `${property} list`, map.error); diff --git a/src/domain/jsonRecord.test.ts b/src/domain/jsonRecord.test.ts index baa8c2b1f1..9d615cbf9d 100644 --- a/src/domain/jsonRecord.test.ts +++ b/src/domain/jsonRecord.test.ts @@ -35,6 +35,34 @@ it("prefixes value failures with every original key and nested value path", () = ]); }); +it("validates every original key before projecting a constrained record", () => { + const key = z.string().regex(/^0x[0-9a-f]+$/u); + const schema = jsonRecordSchema(z.string(), key); + const parsed = schema.safeParse( + JSON.parse('{"0x1000":"entry","__proto__":"other"}'), + ); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("Expected an invalid address key"); + expect(parsed.error.issues).toMatchObject([ + { code: "invalid_key", path: ["__proto__"] }, + ]); + expect(z.toJSONSchema(schema)).toEqual( + z.toJSONSchema(z.record(key, z.string())), + ); +}); + +it("retains normalized keys as own properties without mutating their source", () => { + const source = { " __proto__ ": "observed", " ordinary ": "value" }; + const schema = jsonRecordSchema(z.string(), z.string().trim().min(1)); + const parsed = schema.parse(source); + expect(JSON.stringify(parsed)).toBe( + '{"__proto__":"observed","ordinary":"value"}', + ); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype); + expect(Object.keys(source)).toEqual([" __proto__ ", " ordinary "]); +}); + it("retains defaults produced within JSON entries without mutating their source", () => { const source: unknown = JSON.parse( '{"__proto__":{},"ordinary":{"name":"given"}}', @@ -90,6 +118,29 @@ it("preserves input schema projection for a normalizing transform", () => { ).toThrow(); }); +it("preserves record projection and runtime ownership through schema clones", () => { + const schema = jsonRecordSchema(z.string(), z.string().min(1)); + const ordinary = z.record(z.string().min(1), z.string()); + const variants = [ + [schema.clone(), ordinary.clone()], + [schema.describe("caller fields"), ordinary.describe("caller fields")], + [schema.meta({ title: "Fields" }), ordinary.meta({ title: "Fields" })], + [ + schema.describe("caller fields").meta({ title: "Fields" }).clone(), + ordinary.describe("caller fields").meta({ title: "Fields" }).clone(), + ], + ] as const; + for (const [actual, expected] of variants) { + for (const io of ["input", "output"] as const) + expect(z.toJSONSchema(actual, { io })).toEqual( + z.toJSONSchema(expected, { io }), + ); + expect( + JSON.stringify(actual.parse(JSON.parse('{"__proto__":"observed"}'))), + ).toBe('{"__proto__":"observed"}'); + } +}); + it.each(["draft-04", "draft-07", "draft-2020-12", "openapi-3.0"] as const)( "preserves shared schema references through Standard Schema export for %s", (target) => { diff --git a/src/domain/jsonRecord.ts b/src/domain/jsonRecord.ts index ef9f71692f..2a836d8d09 100644 --- a/src/domain/jsonRecord.ts +++ b/src/domain/jsonRecord.ts @@ -4,15 +4,27 @@ import { jsonObjectSchema, type JsonValue } from "./jsonValue.js"; /** Own every JSON map key, then validate and retain each value's normalized facts. */ export const jsonRecordSchema = ( valueSchema: z.ZodType, + keySchema: z.ZodString = z.string(), ) => { - const projection = z.record(z.string(), valueSchema); + const projection = z.record(keySchema, valueSchema); const schema = jsonObjectSchema.transform( (value, context): Record => { const entries: [string, Value][] = []; let invalid = false; for (const [key, entry] of Object.entries(value)) { + const parsedKey = keySchema.safeParse(key); + if (!parsedKey.success) { + invalid = true; + context.addIssue({ + code: "invalid_key", + origin: "record", + issues: parsedKey.error.issues, + path: [key], + }); + continue; + } const parsed = valueSchema.safeParse(entry); - if (parsed.success) entries.push([key, parsed.data]); + if (parsed.success) entries.push([parsedKey.data, parsed.data]); else { invalid = true; for (const issue of parsed.error.issues) @@ -22,9 +34,15 @@ export const jsonRecordSchema = ( return invalid ? z.NEVER : Object.fromEntries(entries); }, ); - // Delegate to Zod's own record projection with the caller's dialect, IO and - // reference context; a context-free JSON override would lose those choices. - schema._zod.processJSONSchema = (context, json, params) => - projection._zod.processJSONSchema!(context, json, params); - return schema; + // Zod clones reconstruct from the definition. Install projection in the + // constructor so descriptions, metadata and checks retain the same owner. + const JsonRecord = z.core.$constructor( + "JsonRecord", + (instance, definition) => { + z.ZodPipe.init(instance, definition); + instance._zod.processJSONSchema = (context, json, params) => + projection._zod.processJSONSchema!(context, json, params); + }, + ); + return new JsonRecord(schema._zod.def); }; diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index e949785c79..e0558ec007 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { jsonObjectSchema } from "../jsonValue.js"; +import { jsonRecordSchema } from "../jsonRecord.js"; import { localPathStringSchema } from "../localPath.js"; const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u); @@ -66,30 +66,11 @@ const environmentNameSchema = z const environmentValueSchema = z .string() .regex(/^[^\x00]*$/u, "Environment values cannot contain NUL"); -const environmentRecordProjection = z.record( - environmentNameSchema, +const nativeCallEnvironmentSchema = jsonRecordSchema( environmentValueSchema, + environmentNameSchema, ); -// z.record skips __proto__. Own the JSON map before validating entries so every -// selected environment name survives input parsing, readback and partial Evidence. -const nativeCallEnvironmentSchema = jsonObjectSchema - .superRefine((value, context) => { - for (const [name, entry] of Object.entries(value)) { - for (const parsed of [ - environmentNameSchema.safeParse(name), - environmentValueSchema.safeParse(entry), - ]) { - if (!parsed.success) - for (const issue of parsed.error.issues) - context.addIssue({ ...issue, path: [name, ...issue.path] }); - } - } - }) - .transform((value) => value as Record); -nativeCallEnvironmentSchema._zod.toJSONSchema = () => - z.toJSONSchema(environmentRecordProjection, { io: "input" }); - /** * Launch the active Mach-O as an owned process under LLDB and observe calls. * Duration and event bounds keep runtime observation finite. diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts index 69141e5ff9..3aa5a00664 100644 --- a/src/domain/process/processScenario.test.ts +++ b/src/domain/process/processScenario.test.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { Ajv2020 } from "ajv/dist/2020.js"; import { SESSION_TOOL_CONTRACTS } from "../../contracts/sessionToolContracts.js"; +import { projectInputIssues } from "../inputIssueProjection.js"; +import { nativeCallObservationInputSchema } from "../native/nativeCallObservation.js"; import { digestProcessCommitment, PROCESS_TERMINAL_CELL_BUDGET, @@ -295,7 +297,9 @@ it.each(["selected-value", 7])( expect(parsed.success).toBe(false); if (parsed.success) throw new Error("Expected the unsupported PTY name to be rejected"); - expect(parsed.error.issues).toContainEqual( + expect( + projectInputIssues(parsed.error.issues, { ...baseScenario, environment }), + ).toContainEqual( expect.objectContaining({ path: ["environment", "__proto__"], message: expect.stringContaining("PTY backend cannot preserve"), @@ -303,3 +307,23 @@ it.each(["selected-value", 7])( ); }, ); + +it("keeps native and PTY environment admission distinct through Standard Schema export", () => { + const environment = Object.fromEntries([ + ["__proto__", "selected"], + ["constructor", "ordinary"], + ]); + const native = nativeCallObservationInputSchema.shape.environment; + const parsed = native.parse(environment); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(JSON.stringify(parsed)).toBe(JSON.stringify(environment)); + const ajv = new Ajv2020({ strict: false }); + const options = { target: "draft-2020-12" as const }; + const nativePublished = native["~standard"].jsonSchema.input(options); + const ptyPublished = + processScenarioSchema.shape.environment["~standard"].jsonSchema.input( + options, + ); + expect(ajv.compile(nativePublished)(environment)).toBe(true); + expect(ajv.compile(ptyPublished)(environment)).toBe(false); +}); diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 2f5f8bd2f5..0bdd2ee81b 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; +import { jsonRecordSchema } from "../jsonRecord.js"; const positiveBudget = z.int().safe().positive(); /** Read the explicitly committed output budget without applying scenario defaults. */ @@ -59,29 +60,10 @@ const childProcessString = z /^[^\x00]*$/u, "Values passed to operating-system APIs cannot contain NUL", ); -const environmentRecordSchema = z.record( - ptyEnvironmentName, +const processEnvironmentSchema = jsonRecordSchema( childProcessString, + ptyEnvironmentName, ); -// z.record intentionally skips this key, so validate its presence before parsing. -const processEnvironmentSchema = z - .unknown() - .superRefine((value, context) => { - if ( - value !== null && - typeof value === "object" && - Object.hasOwn(value, unsupportedPtyEnvironmentName) - ) - context.addIssue({ - code: "custom", - path: [unsupportedPtyEnvironmentName], - message: - "The installed PTY backend cannot preserve the __proto__ environment name; pass it through /usr/bin/env instead.", - }); - }) - .pipe(environmentRecordSchema); -processEnvironmentSchema._zod.toJSONSchema = () => - z.toJSONSchema(environmentRecordSchema, { io: "input" }); const fitsProcessTerminalCellBudget = ( columns: number, diff --git a/src/ghidra/GhidraInventoryValues.test.ts b/src/ghidra/GhidraInventoryValues.test.ts index 1a86ac08ca..0810d43659 100644 --- a/src/ghidra/GhidraInventoryValues.test.ts +++ b/src/ghidra/GhidraInventoryValues.test.ts @@ -5,6 +5,28 @@ import { parseGhidraInventoryResult, } from "./GhidraInventoryValues.js"; +it.each(["other", 42, null, {}])( + "rejects an invalid own destination with value %j before admitting any batch naming subset", + (value) => { + const input = { + names: Object.fromEntries([ + ["0x1000", "entry"], + ["__proto__", value], + ]), + }; + expect( + parseGhidraInventoryInput("set_addresses_names", input), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [{ path: ["names", "__proto__"] }], + }, + }); + expect(Object.keys(input.names)).toEqual(["0x1000", "__proto__"]); + }, +); + it.each(["list_procedures", "search_procedures"] as const)( "preserves real bridge symbol provenance in %s", (operation) => { diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 50471ca5bb..7fbe4f4a28 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -2,6 +2,7 @@ import { nativeSymbolMetadataSchema } from "../domain/native/nativeSymbol.js"; import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; import { z } from "zod"; +import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { bytePatternFields, validateBytePattern, @@ -125,7 +126,7 @@ const inputSchemas = { set_addresses_names: z .object({ document, - names: z.record(explicitAddress, z.string().min(1)), + names: jsonRecordSchema(z.string().min(1), explicitAddress), }) .strict() .refine( diff --git a/tests/boundary/mcp/enhancedTools.limitations.test.ts b/tests/boundary/mcp/enhancedTools.limitations.test.ts index 871e6f5735..b361c80964 100644 --- a/tests/boundary/mcp/enhancedTools.limitations.test.ts +++ b/tests/boundary/mcp/enhancedTools.limitations.test.ts @@ -49,3 +49,50 @@ it("retains provider coverage caveats in a composed MCP overview without repeate }); expect(calls.length).toBe(new Set(calls).size); }); + +it("counts all own inventory dictionary entries in the composed overview", async () => { + const procedures = JSON.parse( + '{"__proto__":"observed procedure","0x1000":"ordinary procedure"}', + ); + const strings = JSON.parse('{"__proto__":"observed string"}'); + const before = [JSON.stringify(procedures), JSON.stringify(strings)]; + const client = await connect({ + execute: async (operation) => { + if (operation === "read_binary_inventory_summary") + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "No native overview", + ), + ); + const value = + operation === "current_document" + ? "fixture" + : operation === "list_procedures" + ? { procedures } + : operation === "list_strings" + ? strings + : []; + return ok( + createAnalysisExecution(value, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); + }, + }); + const response = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + document: "fixture", + procedure_count: Object.keys(procedures).length, + string_count: Object.keys(strings).length, + }); + expect([JSON.stringify(procedures), JSON.stringify(strings)]).toEqual(before); +}); diff --git a/tests/boundary/mcp/officialTools.test.ts b/tests/boundary/mcp/officialTools.test.ts index e28639dd98..04187b0c9a 100644 --- a/tests/boundary/mcp/officialTools.test.ts +++ b/tests/boundary/mcp/officialTools.test.ts @@ -150,6 +150,40 @@ const connect = async (analysis: AnalysisOperationPort) => { }; describe("official tool input contracts", () => { + it("preserves every batch naming destination and rejects malformed own values before dispatch", async () => { + const invocations: Invocation[] = []; + const client = await connect({ + execute: (name, arguments_) => { + invocations.push({ name, arguments_ }); + return Promise.resolve(ok(null)); + }, + }); + const names = JSON.parse('{"0x1000":"entry","__proto__":"other"}'); + await client.callTool({ + name: "set_addresses_names", + arguments: { names }, + }); + expect(invocations).toEqual([ + { name: "set_addresses_names", arguments_: { names } }, + ]); + expect(JSON.stringify(invocations[0]!.arguments_)).toContain( + '"__proto__":"other"', + ); + for (const value of [42, null, {}]) { + const result = await client.callTool({ + name: "set_addresses_names", + arguments: { + names: Object.fromEntries([ + ["0x1000", "entry"], + ["__proto__", value], + ]), + }, + }); + expect(result.isError).toBe(true); + } + expect(invocations).toHaveLength(1); + }); + it("rejects misspelled top-level inputs before dispatch and keeps record keys open", async () => { const invocations: Invocation[] = []; const client = await connect({ diff --git a/tests/boundary/process/javascriptVerifierScopeOracle.test.ts b/tests/boundary/process/javascriptVerifierScopeOracle.test.ts index d359624431..63367625f4 100644 --- a/tests/boundary/process/javascriptVerifierScopeOracle.test.ts +++ b/tests/boundary/process/javascriptVerifierScopeOracle.test.ts @@ -114,3 +114,30 @@ it("parses explicit per-path expectations without borrowing the production phase }).success, ).toBe(false); }); + +it("retains prototype-named source paths in independent expectations", () => { + const input = Object.fromEntries([ + ["__proto__", expected], + ["constructor", expected], + ]); + const parsed = structuredScopeExpectationsSchema.parse(input); + expect(Object.keys(parsed)).toEqual(["__proto__", "constructor"]); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(parsed["__proto__"]).toEqual(expected); +}); + +it("validates prototype-named expectations and retains the nonempty path policy", () => { + for (const entry of [ + null, + { ...expected, operation: "wrong" }, + { ...expected, analysis_status: "wrong" }, + ]) + expect( + structuredScopeExpectationsSchema.safeParse( + Object.fromEntries([["__proto__", entry]]), + ).success, + ).toBe(false); + expect( + structuredScopeExpectationsSchema.safeParse({ "": expected }).success, + ).toBe(false); +});