fix(json): consolidate dictionary admission across analysis and verification

This commit is contained in:
morluto
2026-10-26 18:14:43 +08:00
parent c06dc2d356
commit e7f986c994
18 changed files with 369 additions and 99 deletions
@@ -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<Record<string, unknown>>;
readonly evidence: {
readonly authority: string;
readonly state: string;
readonly confidence: string;
};
}
export const structuredScopeExpectationsSchema: z.ZodType<
Record<string, StructuredScopeExpectation>
>;
export function matchesStructuredScopeExpectation(
scopes: readonly StructuredProcessingScope[],
expected: StructuredScopeExpectation | undefined,
): boolean;
@@ -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<Record<string, unknown>>;
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(
@@ -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: {
+2 -1
View File
@@ -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",
@@ -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);
});
@@ -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.",
+29
View File
@@ -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);
},
);
+2 -2
View File
@@ -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);
+51
View File
@@ -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) => {
+25 -7
View File
@@ -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 = <Value extends JsonValue>(
valueSchema: z.ZodType<Value>,
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<string, Value> => {
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 = <Value extends JsonValue>(
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<typeof schema>(
"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);
};
+3 -22
View File
@@ -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<string, string>);
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.
+25 -1
View File
@@ -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);
});
+3 -21
View File
@@ -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,
+22
View File
@@ -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) => {
+2 -1
View File
@@ -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(
@@ -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);
});
+34
View File
@@ -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({
@@ -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);
});