Compare commits

..
Author SHA1 Message Date
Cursor Agent 3b13dfec54 chore: merge main into codex/audit-input-admission 2026-11-11 02:26:35 +00:00
Cursor Agent e7cbc046d1 fix(cli): reject outline-depth unless detail is summary
The advertised schema already required detail summary whenever outline-depth
was present. The parser still applied the full default and accepted that
combination, so its output failed the same schema. Parsing now rejects it
before the command runs.
2026-11-11 02:24:23 +00:00
Cursor Agent d1e7e4d375 fix(cli): preserve Electron fuse path spelling
Anchor relative fuse paths without stripping dot segments, and let an empty path reach the operation so it still reports invalid_request.
2026-11-11 00:18:14 +00:00
Cursor Agent ac76567498 test(inputs): reach session behavior with admitted inputs
One-shot admission now rejects incomplete requests before a session exists. Lifecycle tests supply the required fields, and unknown arguments are rejected before provider issues are retained.
2026-11-10 23:07:21 +00:00
morluto 883f3460d6 fix(mcp): preserve valid inputs when advertised schema references widen 2026-11-10 17:32:52 -05:00
morluto b491c5d7dd fix(cli): validate named inputs before target admission 2026-11-10 17:32:47 -05:00
39 changed files with 1365 additions and 504 deletions

No files matched your search

+7
View File
@@ -651,6 +651,13 @@ be verified. See [MCP contracts](../reference/mcp-contracts.md) for session life
[Ghidra first-query deadlines](../reference/mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
for import, client timeouts and recovery.
One-shot binary analysis validates the selected operation's input before creating
a provider session, opening the target, or replaying a snapshot. Invalid selectors
and option combinations report their input errors before target or provider
availability errors. An already-cancelled request keeps its cancellation reason.
Caller-selected paths and request parameters remain separate from parsed execution
arguments in Evidence.
A one-shot command attempts provider-session and runtime cleanup before reporting
a failure. If cleanup remains incomplete, inspect the reported resources and
follow the error's remediation. [Same-session cleanup retry](../reference/mcp-contracts.md#retained-cleanup-retry),
+19 -2
View File
@@ -143,6 +143,7 @@ import {
type MachoLoadCommandTableHeader,
} from "../domain/apple/machoLoadCommands.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { localPathStringSchema } from "../domain/localPath.js";
import type { JsonValue } from "../domain/jsonValue.js";
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
@@ -173,9 +174,25 @@ export const parseBinaryTarget = async (
): Promise<
Result<BinaryTarget, BinaryTargetError | AnalysisCancelledError>
> => {
const cwd = options.cwd ?? process.cwd();
const { signal } = options;
const candidate = absoluteCallerPath(input, { cwd });
const admitted = localPathStringSchema.safeParse(input);
if (!admitted.success) {
if (signal?.aborted === true)
return err(
new AnalysisCancelledError("resolve_binary_target", {
cause: signal.reason,
}),
);
return err(
new BinaryTargetError(
input,
admitted.error.issues[0]?.message ?? "Invalid local path",
{ cause: admitted.error },
),
);
}
const cwd = options.cwd ?? process.cwd();
const candidate = absoluteCallerPath(admitted.data, { cwd });
// Failures before canonicalization concern the selected path itself; later
// failures concern the canonical path opened with symbolic links rejected.
let stage: TargetFilesystemStage = "resolve";
+56 -55
View File
@@ -1,3 +1,7 @@
import {
admitOneShotOperation,
type DirectAnalysisTool,
} from "./oneShotOperationInput.js";
import { admitCompletedAnalysis } from "./investigation/admitCompletedAnalysis.js";
import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js";
import type { ExecutableFormatHint } from "../domain/dosCom.js";
@@ -96,30 +100,6 @@ import {
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js";
type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "search_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
| "procedure_references"
| "read_function_instructions"
| "inspect_native_instruction"
| "inspect_native_data_type"
| "resolve_native_call_targets"
| "analyze_function"
| "inspect_native_api"
| "inspect_native_dispatch_metadata"
| "search_strings"
| "search_procedures"
| "xrefs"
| "trace_feature"
| "trace_native_ui_action"
| "trace_native_values"
| "decompile_all";
// These one-shot CLI questions require native utilities, not a deep engine.
// Runtime observation retains the ordinary session and authority checks.
const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
@@ -158,20 +138,30 @@ export const runDirectAnalysis = async (
readonly includeEvidenceSources?: boolean;
} = {},
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
withProcessCancellation(options.signal, (signal) =>
runAnalysis(dependencies, path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.includeEvidenceSources === true
? { includeEvidenceSources: true }
: {}),
...targetSelection(options),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
}),
);
withProcessCancellation(options.signal, (signal) => {
const admitted = admitOneShotOperation(tool, arguments_, signal);
if (!admitted.ok)
return Promise.resolve(err(cliError(admitted.error, path)));
return runAnalysis(
dependencies,
path,
admitted.value.name,
admitted.value.input,
{
evidenceParameters: arguments_,
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.includeEvidenceSources === true
? { includeEvidenceSources: true }
: {}),
...targetSelection(options),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
},
);
});
/** How the caller asks an artifact target to be read. */
export interface ArtifactTargetSelection {
@@ -268,8 +258,7 @@ export const runProviderAnalysis = async (
),
);
return withProcessCancellation(signal, async (operationSignal) => {
// The selected CLI path is a request fact. Keep it out of provider inputs
// and target identity, which may use the resolved executable instead.
// Preserve the selected path and original arguments as request facts.
const evidenceParameters = { ...arguments_, input_path: path };
if (isManagedToolName(tool)) {
const original = await runManagedProviderAnalysis(
@@ -309,18 +298,26 @@ export const runProviderAnalysis = async (
);
return result.ok ? ok(result.value) : err(cliError(result.error, path));
}
return runAnalysis(dependencies, path, tool, arguments_, {
logger,
snapshotPath: undefined,
signal: operationSignal,
evidenceParameters,
...targetSelection(selection ?? {}),
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
? { createSession: dependencies.createArtifactBinarySession }
: NATIVE_INSPECTION_OPERATIONS.has(tool)
? { createSession: dependencies.createNativeBinarySession }
: {}),
});
const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
if (!admitted.ok) return err(cliError(admitted.error, path));
return runAnalysis(
dependencies,
path,
admitted.value.name,
admitted.value.input,
{
logger,
snapshotPath: undefined,
signal: operationSignal,
evidenceParameters,
...targetSelection(selection ?? {}),
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
? { createSession: dependencies.createArtifactBinarySession }
: NATIVE_INSPECTION_OPERATIONS.has(tool)
? { createSession: dependencies.createNativeBinarySession }
: {}),
},
);
});
};
@@ -334,6 +331,8 @@ export const runManagedProviderExecution = async (
recoveryParameters?: Readonly<Record<string, JsonValue>>,
): Promise<Result<AnalysisExecution, AnalysisError>> =>
withProcessCancellation(signal, async (operationSignal) => {
const admitted = admitOneShotOperation(tool, parameters, operationSignal);
if (!admitted.ok) return admitted;
const session = await dependencies.createManagedBinarySession();
return withSessionCleanup(
session,
@@ -341,10 +340,12 @@ export const runManagedProviderExecution = async (
const opened = await session.open(path, { signal: operationSignal });
if (!opened.ok) return opened;
return recoveryParameters === undefined
? await session.execute(tool, parameters, { signal: operationSignal })
? await session.execute(admitted.value.name, admitted.value.input, {
signal: operationSignal,
})
: await session.executeForDirectObservation(
tool,
parameters,
admitted.value.name,
admitted.value.input,
recoveryParameters,
{ signal: operationSignal },
);
@@ -13,6 +13,9 @@ import { err, ok } from "../../domain/result.js";
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.js";
import { toolContract } from "../../contracts/toolContracts.js";
import { z } from "zod";
import { Ajv2020 } from "ajv/dist/2020.js";
const identity = {
id: "fixture-il2cpp",
@@ -21,6 +24,29 @@ const identity = {
};
const report = createEmptyIl2cppMetadataFixture();
it.each([{ binary_architecture: "arm64" }, { generic_instances: true }])(
"binds binary-dependent selectors in the named input contract: %j",
(selection) => {
const schema = toolContract("inspect_il2cpp_metadata").inputSchema;
const validate = new Ajv2020({ strict: false }).compile(
z.toJSONSchema(schema, { io: "input" }),
);
const missingBinary = { path: report.artifact.path, ...selection };
const selectedBinary = {
...missingBinary,
binary_path: "/artifacts/GameAssembly.dll",
};
expect(schema.safeParse(missingBinary).success).toBe(false);
expect(validate(missingBinary)).toBe(false);
expect(schema.safeParse(selectedBinary).success).toBe(true);
expect(validate(selectedBinary)).toBe(true);
expect(
schema.safeParse({ path: report.artifact.path, generic_instances: false })
.success,
).toBe(true);
},
);
const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
new Il2cppMetadataService({
close: async () => {},
@@ -106,32 +106,6 @@ export class Il2cppMetadataService {
options?.signal,
);
if (!input.ok) return input;
if (
input.value.binary_architecture !== undefined &&
input.value.binary_path === undefined
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["binary_architecture"],
reason: "invalid_value",
message: "binary_architecture selects a slice of binary_path.",
},
]),
);
if (
input.value.generic_instances === true &&
input.value.binary_path === undefined
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["generic_instances"],
reason: "invalid_value",
message: "generic_instances links generic methods in binary_path.",
},
]),
);
if (output !== undefined) {
if (input.value.binary_path === undefined)
return err(
+111
View File
@@ -0,0 +1,111 @@
import type { z } from "zod";
import { toolContract } from "../contracts/toolContracts.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
import {
ARTIFACT_TOOL_CONTRACTS,
artifactInventoryInputSchema,
type ArtifactAnalysisOperation,
} from "../contracts/artifactToolContracts.js";
import {
MANAGED_TOOL_CONTRACTS,
managedArtifactInputSchema,
managedMembersInputSchema,
type ManagedToolName,
} from "../contracts/managed/managedToolContracts.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { parseAnalysisInput } from "./AnalysisInput.js";
import { ok } from "../domain/result.js";
/** Operations the direct workflow can perform without a persistent session. */
export type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "search_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
| "procedure_references"
| "read_function_instructions"
| "inspect_native_instruction"
| "inspect_native_data_type"
| "resolve_native_call_targets"
| "analyze_function"
| "inspect_native_api"
| "inspect_native_dispatch_metadata"
| "search_strings"
| "search_procedures"
| "xrefs"
| "trace_feature"
| "trace_native_ui_action"
| "trace_native_values"
| "decompile_all";
type OneShotOperation =
| DirectAnalysisTool
| NativeToolName
| ArtifactAnalysisOperation
| ManagedToolName;
type Schema = z.ZodType<Readonly<Record<string, unknown>>>;
type ContractSchemas<
Contracts extends readonly {
readonly name: string;
readonly inputSchema: Schema;
}[],
> = {
[Contract in Contracts[number] as Contract["name"]]: Contract["inputSchema"];
};
const schemasByName = <
const Contracts extends readonly {
readonly name: string;
readonly inputSchema: Schema;
}[],
>(
contracts: Contracts,
): ContractSchemas<Contracts> =>
Object.fromEntries(
contracts.map(({ name, inputSchema }) => [name, inputSchema]),
) as ContractSchemas<Contracts>;
const schemas = {
...schemasByName(OFFICIAL_TOOL_CONTRACTS),
...schemasByName(NATIVE_TOOL_CONTRACTS),
...schemasByName(ARTIFACT_TOOL_CONTRACTS),
...schemasByName(MANAGED_TOOL_CONTRACTS),
...enhancedInputSchemas,
inspect_native_load_image: toolContract(
"inspect_native_load_image",
).inputSchema.omit({ detail: true }),
inspect_managed_artifact: managedArtifactInputSchema.omit({ detail: true }),
inspect_managed_members: managedMembersInputSchema.omit({ detail: true }),
inventory_artifact: artifactInventoryInputSchema,
} satisfies Record<OneShotOperation, Schema>;
/** Parse the named operation before target selection or snapshot replay. */
export const admitOneShotOperation = <Name extends OneShotOperation>(
name: Name,
input: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
) => {
type SelectedInput = Partial<z.output<(typeof schemas)[Name]>> &
Readonly<Record<string, JsonValue>>;
const parsed = parseAnalysisInput(
name,
input,
schemas[name] as Schema,
signal,
);
if (!parsed.ok) return parsed;
// Admission owns the selected request: preserve top-level omissions for
// Evidence and snapshot identity while carrying each selected parsed value.
// Nested defaults remain in the owning field's parsed value.
const selected = Object.fromEntries(
Object.entries(parsed.value).filter(([key]) => Object.hasOwn(input, key)),
) as SelectedInput;
return ok({ name, input: selected });
};
@@ -20,11 +20,6 @@ it("reads a Python 3.9 reference-flagged PYZ table", () => {
]);
});
it("reads marshal one-byte string tags as Latin-1", () => {
for (const hex of ["6101000000e9", "4101000000e9", "7a01e9", "5a01e9"])
expect(readMarshalSubset(bytes(hex))).toEqual({ ok: true, value: "é" });
});
it("decodes CPython marshal Unicode with surrogatepass", () => {
expect(
readMarshalSubset(
+8 -2
View File
@@ -168,8 +168,14 @@ const readAsciiBytes = (
): Result<string, PyInstallerFailure> => {
const bytes = readBytes(cursor, length);
if (!bytes.ok) return bytes;
// These tags use CPython's one-byte Unicode constructor, not an ASCII check.
return ok(bytes.value.toString("latin1"));
if (bytes.value.some((byte) => byte > 0x7f))
return err(
pyInstallerFailure(
"format",
"PYZ marshal ASCII string contains a non-ASCII byte",
),
);
return ok(bytes.value.toString("ascii"));
};
const readUtf8 = (
+1 -2
View File
@@ -215,8 +215,7 @@ export const registerBinaryDiagnosticsCommands = (
maxIntegerTextBytes: cliDecimalField(
inspectAppleIpsInputSchema.shape.max_integer_text_bytes,
),
joinPath: z
.string()
joinPath: localPathStringSchema
.optional()
.describe(
"Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images",
+13 -1
View File
@@ -59,6 +59,12 @@ export const registerCaptureCommands = (
path: ["all"],
message: "all and limit are mutually exclusive",
});
})
.meta({
not: {
required: ["all", "limit"],
properties: { all: { const: true } },
},
}),
run: ({ options: { all, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
@@ -119,7 +125,13 @@ export const registerCaptureCommands = (
.refine(
withinPacketCaptureSelectorBudget,
packetCaptureSelectorBudgetIssue,
),
)
.meta({
not: {
required: ["all_matches", "match_limit"],
properties: { all_matches: { const: true } },
},
}),
run: ({ args, options: { all_matches, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => {
+8 -6
View File
@@ -1,3 +1,5 @@
import { nativeInstructionInputSchema } from "../domain/native/nativeInstruction.js";
import { nativeFunctionAnnotationsInputSchema } from "../domain/native/nativeFunctionAnnotations.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js";
import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js";
@@ -395,7 +397,9 @@ export const registerCoreNativeCommands = (
path: localPathStringSchema.describe(
"Local native executable or app path",
),
address: z.string().describe("Exact native address"),
address: nativeInstructionInputSchema.shape.address.describe(
"Exact native address",
),
}),
options: z.object({
...nativeOpenOptionFields,
@@ -557,11 +561,9 @@ const registerAnnotationCommand = (
description: "Edit function annotations and return refreshed analysis",
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
procedure: z
.string()
.describe(
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
),
procedure: nativeFunctionAnnotationsInputSchema.shape.procedure.describe(
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
),
}),
options: z.object({
name: z.string().min(1).optional().describe("Analyst function name"),
+4 -8
View File
@@ -263,14 +263,12 @@ const withheldExportOptions = {
.describe(
"Seed the withheld files after all, which needs a seed_sign_in consent in --consent",
),
consent: z
.string()
consent: localPathStringSchema
.optional()
.describe(
"consent.json holding the person's consents, which the bundle ships",
),
withheldCopy: z
.string()
withheldCopy: localPathStringSchema
.optional()
.describe(
"New or empty folder outside the bundle the withheld files are copied into, for whoever supplies them at run time",
@@ -340,8 +338,7 @@ const registerDesktopExportCommand = (
.describe(
"http or https URL the computer downloads the captured --package-file from when the bundle is put, for an application installed from a .deb file",
),
downloadInstall: z
.string()
downloadInstall: localPathStringSchema
.optional()
.describe(
"JSON file naming the AppImage or tarball a build computer unpacked the application from: kind, desktop_entry, url, sha256, architecture, and a tarball's program and name",
@@ -358,8 +355,7 @@ const registerDesktopExportCommand = (
.describe(
"The seed was made for the task, as in a build computer, not taken from the user's own data; the README says so instead of keeping the bundle local",
),
screens: z
.string()
screens: localPathStringSchema
.optional()
.describe(
"Folder holding checkpoint screenshots for screen-judge examples.",
+8 -6
View File
@@ -1,9 +1,14 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js";
import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js";
import { isAbsolute, resolve, sep } from "node:path";
import { Cli, z } from "incur";
import {
localPathStringSchema,
nulFreeStringSchema,
} from "../domain/localPath.js";
import { logCliCommand } from "../cliLogging.js";
import {
@@ -37,7 +42,6 @@ import {
evidenceBundleOption,
runEvidenceInputCommand,
} from "./evidenceInputCommand.js";
import { localPathStringSchema } from "../domain/localPath.js";
/** Register CLI equivalents of the Electron MCP tools. */
export const registerElectronCommands = (
@@ -65,11 +69,9 @@ const registerElectronFuseCommand = (
description:
"Inspect packaged Electron fuse bytes without launching the application",
args: z.object({
path: z
.string()
.describe(
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
),
path: nulFreeStringSchema.describe(
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
),
}),
run: ({ args }) =>
withCommandCancellation((signal) =>
+2 -3
View File
@@ -1,6 +1,7 @@
import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
import type {
@@ -166,9 +167,7 @@ export const registerFridaCommands = (
.string()
.optional()
.describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`),
scriptFile: z
.string()
.min(1)
scriptFile: localPathStringSchema
.optional()
.describe(
`Path to JavaScript source file. ${exactlyOneScriptSource}`,
+43 -30
View File
@@ -27,36 +27,49 @@ export const registerIl2cppCommands = (
"Local global-metadata.dat (version 24, 27, 29, 31 or 39)",
),
}),
options: z.object({
...analysisEvidenceDeliveryOptions,
binary: localPathStringSchema
.optional()
.describe(
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
),
architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe("Slice of a universal Mach-O binary"),
genericInstances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
),
ghidraSeeds: localPathStringSchema
.optional()
.describe(
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
),
seedBase: z
.string()
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
.optional()
.describe(
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
),
}),
options: z
.object({
...analysisEvidenceDeliveryOptions,
binary: localPathStringSchema
.optional()
.describe(
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
),
architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe("Slice of a universal Mach-O binary"),
genericInstances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
),
ghidraSeeds: localPathStringSchema
.optional()
.describe(
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
),
seedBase: z
.string()
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
.optional()
.describe(
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
),
})
.meta({
dependentRequired: {
architecture: ["binary"],
ghidraSeeds: ["binary"],
seedBase: ["ghidraSeeds"],
},
if: {
properties: { genericInstances: { const: true } },
required: ["genericInstances"],
},
then: { required: ["binary"] },
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => {
+3 -4
View File
@@ -18,6 +18,7 @@ import {
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { CliInstance } from "./types.js";
import { parseNativeMatchingAllowance } from "../config/nativeMatchingAllowance.js";
import { localPathStringSchema } from "../domain/localPath.js";
/** Resolve portable input bundles, then use the same read-only matching service as MCP. */
export const registerNativeFunctionMatchingCommands = (
@@ -37,14 +38,12 @@ export const registerNativeFunctionMatchingCommands = (
}),
options: z.object({
source_bundle: z
.array(z.string())
.array(localPathStringSchema)
.default([])
.describe(
"Canonical source Evidence bundle for retained references; repeat for each input",
),
evidence_output: z
.string()
.min(1)
evidence_output: localPathStringSchema
.optional()
.describe(
"Save full matching Evidence and selected sources; required for default summary; never overwrites",
+3 -1
View File
@@ -33,11 +33,13 @@ const withOmittableDefaults = (schema: z.ZodObject): z.ZodObject => {
fillsOmission(field),
);
if (omittable.length === 0) return schema;
return schema.safeExtend(
const metadata = schema.meta();
const result = schema.safeExtend(
Object.fromEntries(
omittable.map(([key, field]) => [key, optionalWithMetadata(field)]),
),
);
return metadata === undefined ? result : result.meta(metadata);
};
/** Let the object parser decide omission; Incur advertises required outputs. */
+4 -4
View File
@@ -1,5 +1,6 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { createPcapAnalysisProvider } from "../composition/pcap.js";
@@ -53,10 +54,9 @@ export const registerPcapCommands = (
description:
"Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution",
args: z.object({
path: z
.string()
.min(1)
.describe("Local pcap or pcapng capture file to inspect"),
path: localPathStringSchema.describe(
"Local pcap or pcapng capture file to inspect",
),
}),
run: ({ args }) =>
execute(
+10 -9
View File
@@ -1,4 +1,5 @@
import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import type { Logger } from "pino";
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
@@ -151,10 +152,9 @@ export const registerReverseEngineeringCommands = (
description:
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
args: z.object({
path: z
.string()
.min(1)
.describe("Path to the local artifact to inspect."),
path: localPathStringSchema.describe(
"Path to the local artifact to inspect.",
),
operation: z
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
.describe("GNU-style objdump operation to run."),
@@ -274,10 +274,9 @@ export const registerReverseEngineeringCommands = (
cli.command(CLI_COMMANDS.executeRizinCommand, {
description: "Execute one Rizin command against a local artifact",
args: z.object({
path: z
.string()
.min(1)
.describe("Path to the local artifact to analyze."),
path: localPathStringSchema.describe(
"Path to the local artifact to analyze.",
),
command: z.string().min(1).describe("One caller-selected Rizin command."),
}),
options: z.object({
@@ -407,7 +406,9 @@ export const registerReverseEngineeringCommands = (
description:
"Run one unrestricted Rizin debugger command in an ephemeral session",
args: z.object({
path: z.string().min(1).describe("Path to the local artifact to debug."),
path: localPathStringSchema.describe(
"Path to the local artifact to debug.",
),
command: z
.string()
.min(1)
+13 -1
View File
@@ -61,6 +61,12 @@ export const registerSignalCommands = (
path: ["all"],
message: "all and limit are mutually exclusive",
});
})
.meta({
not: {
required: ["all", "limit"],
properties: { all: { const: true } },
},
}),
run: ({ options: { all, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
@@ -121,7 +127,13 @@ export const registerSignalCommands = (
.refine(
withinSignalRecordingSelectorBudget,
signalRecordingSelectorBudgetIssue,
),
)
.meta({
not: {
required: ["all_lines", "line_limit"],
properties: { all_lines: { const: true } },
},
}),
run: ({ args, options: { all_lines, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => {
+38 -42
View File
@@ -347,29 +347,45 @@ const registerReferenceSourceCommand = (
cli.command(CLI_COMMANDS.importReferenceSource, {
description: "Import a source tree as historical reference only",
args: z.object({
root: z
.string()
.describe("Local source directory to import as historical reference"),
}),
options: z.object({
excludePaths: z
.string()
.optional()
.describe(
"JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.",
),
detail: z
.enum(["full", "summary"])
.default("full")
.describe(
"Complete committed graph or compact historical-source summary",
),
outlineDepth: cliIntegerArgument(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
root: localPathStringSchema.describe(
"Local source directory to import as historical reference",
),
}),
options: z
.object({
excludePaths: z
.string()
.optional()
.describe(
"JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.",
),
detail: z
.enum(["full", "summary"])
.default("full")
.describe(
"Complete committed graph or compact historical-source summary",
),
outlineDepth: cliIntegerArgument(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
})
.superRefine((options, context) => {
if (options.outlineDepth !== undefined && options.detail !== "summary")
context.addIssue({
code: "custom",
path: ["outlineDepth"],
message: "--outline-depth requires --detail summary",
});
})
.meta({
if: { required: ["outlineDepth"] },
then: {
required: ["detail"],
properties: { detail: { const: "summary" } },
},
}),
run: ({ args, options }) =>
logCliCommand(
logger,
@@ -379,26 +395,6 @@ const registerReferenceSourceCommand = (
options.excludePaths,
);
if (!excludePaths.ok) return excludePaths.failure;
if (
options.outlineDepth !== undefined &&
options.detail !== "summary"
)
return {
error: "Import failed",
...projectAnalysisError(
new AnalysisInputError(
"import-reference-source",
{ userMessage: "--outline-depth requires --detail summary" },
[
{
path: ["outlineDepth"],
reason: "invalid_value",
message: "--outline-depth requires --detail summary",
},
],
),
),
};
const config = parseConfig(environment);
if (!config.ok)
return {
@@ -68,3 +68,321 @@ it.each(["draft-2020-12", "draft-07"] as const)(
).toBe(false);
},
);
const referenceWideningCases: readonly {
name: string;
schema: Record<string, unknown>;
input: unknown;
unchanged?: boolean;
}[] = [
{
name: "oneOf",
schema: {
definitions: { off: { const: false } },
properties: { value: { oneOf: [{ $ref: "#/definitions/off" }, {}] } },
},
input: { value: true },
},
{
name: "not",
schema: {
definitions: { off: { const: false } },
properties: { value: { not: { $ref: "#/definitions/off" } } },
},
input: { value: true },
},
{
name: "if",
schema: {
definitions: { off: { const: false } },
properties: {
value: { if: { $ref: "#/definitions/off" }, then: false, else: true },
},
},
input: { value: true },
},
{
name: "chain",
schema: {
definitions: {
off: { const: false },
alias: { $ref: "#/definitions/off" },
},
properties: { value: { not: { $ref: "#/definitions/alias" } } },
},
input: { value: true },
},
{
name: "recursive affected",
schema: {
definitions: {
node: {
type: "object",
properties: {
flag: { const: false },
next: { $ref: "#/definitions/node" },
},
required: ["flag"],
},
},
properties: { value: { not: { $ref: "#/definitions/node" } } },
},
input: { value: { flag: true } },
},
{
name: "literal target unchanged",
schema: {
default: { predicate: { const: false } },
properties: { value: { not: { $ref: "#/default/predicate" } } },
},
input: { value: true },
unchanged: true,
},
{
name: "nested resource unaffected",
schema: {
$id: "https://example.test/root",
definitions: { flag: { const: false } },
properties: {
value: {
$id: "child",
definitions: { flag: { const: 7 } },
not: { $ref: "#/definitions/flag" },
},
},
},
input: { value: 8 },
unchanged: true,
},
{
name: "recursive unaffected",
schema: {
definitions: {
node: {
type: "object",
properties: {
flag: { const: 7 },
next: { $ref: "#/definitions/node" },
},
required: ["flag"],
},
},
properties: { value: { not: { $ref: "#/definitions/node" } } },
},
input: { value: { flag: 8 } },
unchanged: true,
},
];
it.each(
referenceWideningCases.flatMap((entry) =>
(["draft-2020-12", "draft-07"] as const).map((dialect) => ({
...entry,
dialect,
})),
),
)(
"preserves $name through scoped normalization references in $dialect",
({ name, schema, input, unchanged, dialect }) => {
const canonical: Record<string, unknown> = {
$schema:
dialect === "draft-07"
? "http://json-schema.org/draft-07/schema#"
: "https://json-schema.org/draft/2020-12/schema",
type: "object",
...schema,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const create = () =>
dialect === "draft-07"
? new Ajv({ strict: false })
: new Ajv2020({ strict: false });
expect(create().compile(canonical)(input), `${name} canonical`).toBe(true);
expect(create().compile(advertised)(input), `${name} advertised`).toBe(
true,
);
expect(advertised.default, `${name} literal data`).toEqual(
canonical.default,
);
if (unchanged)
expect(advertised.properties, name).toEqual(canonical.properties);
},
);
it("keeps an upper contains bound from rejecting canonical items after referenced literal widening", () => {
const canonical = {
type: "object",
definitions: { off: { const: false } },
properties: {
values: {
type: "array",
contains: { $ref: "#/definitions/off" },
minContains: 0,
maxContains: 1,
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const input = { values: [false, true] };
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true);
});
it.each([false, true])(
"preserves conditional evaluation annotations (referenced: %s)",
(referenced) => {
const conditional = {
if: { properties: { flag: { const: false } } },
then: { properties: { extra: { type: "string" } } },
};
const canonical = {
type: "object",
definitions: { conditional },
...(referenced ? { $ref: "#/definitions/conditional" } : conditional),
unevaluatedProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const input = { flag: false, extra: "kept" };
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(
true,
);
},
);
it("retains item annotations when only the unsafe contains maximum leaves", () => {
const canonical = {
type: "object",
properties: {
values: {
type: "array",
contains: { const: false },
maxContains: 1,
unevaluatedItems: false,
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const input = { values: [false] };
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true);
expect(advertised).toHaveProperty("properties.values.contains", {
type: "boolean",
});
expect(advertised).toHaveProperty(
"properties.values.unevaluatedItems",
false,
);
});
it("keeps unaffected root property annotations strict when a child conditional widens", () => {
const canonical = {
type: "object",
properties: {
child: {
if: { const: false },
then: { type: "string" },
else: { type: "boolean" },
},
},
unevaluatedProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const validate = new Ajv2020({ strict: false }).compile(advertised);
expect(validate({ child: true })).toBe(true);
expect(validate({ child: true, unknown: "reject" })).toBe(false);
expect(advertised.unevaluatedProperties).toBe(false);
});
it.each(["draft-07", "draft-2020-12"] as const)(
"observes reference sibling semantics in %s",
(dialect) => {
const canonical = {
$schema:
dialect === "draft-07"
? "http://json-schema.org/draft-07/schema#"
: "https://json-schema.org/draft/2020-12/schema",
type: "object",
definitions: {
number: { type: "number" },
alias: { $ref: "#/definitions/number", const: false },
},
properties: { value: { not: { $ref: "#/definitions/alias" } } },
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const create = () =>
dialect === "draft-07"
? new Ajv({ strict: false, ignoreKeywordsWithRef: true })
: new Ajv2020({ strict: false });
// Under the specification's draft7 rules alias is number; in2020 it accepts no value.
const input = { value: "kept" };
expect(create().compile(canonical)(input)).toBe(true);
expect(create().compile(advertised)(input)).toBe(true);
if (dialect === "draft-07")
expect(advertised.properties).toEqual(canonical.properties);
},
);
it.each(["draft-07", "draft-2020-12"] as const)(
"reduces only an affected reference to opaque data in %s",
(dialect) => {
const literal = {
conditional: {
if: { $ref: "#/definitions/off" },
then: false,
else: true,
},
unaffected: { type: "string", minLength: 2 },
};
const canonical = {
$schema:
dialect === "draft-07"
? "http://json-schema.org/draft-07/schema#"
: "https://json-schema.org/draft/2020-12/schema",
type: "object",
definitions: { off: { const: false } },
default: literal,
properties: {
value: { $ref: "#/default/conditional" },
stable: { $ref: "#/default/unaffected" },
},
unevaluatedProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const create = () =>
dialect === "draft-07"
? new Ajv({ strict: false })
: new Ajv2020({ strict: false });
const input = { value: true, stable: "kept" };
expect(create().compile(canonical)(input)).toBe(true);
const validate = create().compile(advertised);
expect(validate(input)).toBe(true);
expect(validate({ value: true, stable: 1 })).toBe(false);
expect(advertised.default).toEqual(literal);
expect(advertised).toHaveProperty(
"properties.stable",
canonical.properties.stable,
);
expect(advertised).toHaveProperty(
"properties.value.description",
expect.stringContaining("reduced form"),
);
expect(advertised.unevaluatedProperties).toBe(false);
},
);
it("does not treat unused definitions as validation dependencies", () => {
const canonical = {
type: "object",
properties: {
value: {
not: { type: "number", definitions: { unused: { const: false } } },
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
expect(advertised).toHaveProperty("properties.value.not.type", "number");
const validate = new Ajv2020({ strict: false }).compile(advertised);
expect(validate({ value: "kept" })).toBe(true);
expect(validate({ value: 1 })).toBe(false);
});
+224 -38
View File
@@ -196,25 +196,194 @@ const preserveSharedDefault = (
schema.default = defaults[0];
};
const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (!isObject(value)) return { schema: value, changed: false };
/** Schema children, excluding definitions when following validation dependencies. */
const normalizationChildren = (
schema: Readonly<Record<string, unknown>>,
definitions: boolean,
): unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (schemaMaps.has(key) && isObject(value))
return !definitions && (key === "$defs" || key === "definitions")
? []
: Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
const hasNormalizedLiteral = (schema: Readonly<Record<string, unknown>>) =>
typeof schema.const === "boolean" ||
schema.const === "" ||
(Array.isArray(schema.enum) &&
((schema.enum.length > 0 &&
schema.enum.every((item) => typeof item === "boolean")) ||
schema.enum.includes("")));
/** One projection owns actual rewrite sites and their scoped validation dependencies. */
const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
const references = new JsonSchemaReferences(root);
type Fact = {
changed: boolean;
lostEvaluation: boolean;
dependents: Set<Fact>;
evaluationDependents: Set<Fact>;
opaqueReference?: Fact;
};
const sites = new Map<JsonSchemaScope | undefined, WeakSet<object>>();
const pending: { schema: unknown; scope: JsonSchemaScope | undefined }[] = [
{ schema: root, scope: references.rootScope },
];
const entries: {
schema: Readonly<Record<string, unknown>>;
scope: JsonSchemaScope | undefined;
}[] = [];
// Literal payloads are not rewritten, even when a reference evaluates them.
while (pending.length > 0) {
const entry = pending.pop()!;
if (!isObject(entry.schema)) continue;
const scope = references.enter(entry.schema, entry.scope);
let seen = sites.get(scope);
if (seen === undefined) sites.set(scope, (seen = new WeakSet()));
if (seen.has(entry.schema)) continue;
seen.add(entry.schema);
entries.push({ schema: entry.schema, scope });
for (const schema of normalizationChildren(entry.schema, true))
pending.push({ schema, scope });
}
const indexed = new Map<JsonSchemaScope | undefined, WeakMap<object, Fact>>();
const work: {
schema: Readonly<Record<string, unknown>>;
scope: JsonSchemaScope | undefined;
fact: Fact;
}[] = [];
const changed: Fact[] = [];
const factAt = (
schema: unknown,
inherited: JsonSchemaScope | undefined,
): Fact | undefined => {
if (!isObject(schema)) return undefined;
const scope = references.enter(schema, inherited);
let facts = indexed.get(scope);
if (facts === undefined) indexed.set(scope, (facts = new WeakMap()));
const existing = facts.get(schema);
if (existing !== undefined) return existing;
const fact: Fact = {
changed: false,
lostEvaluation: false,
dependents: new Set(),
evaluationDependents: new Set(),
};
facts.set(schema, fact);
work.push({ schema, scope, fact });
return fact;
};
for (const { schema, scope } of entries) factAt(schema, scope);
for (let index = 0; index < work.length; index++) {
const { schema, scope, fact } = work[index]!;
const ignoresSiblings =
scope?.dialect === "draft-07" && typeof schema.$ref === "string";
if (
!ignoresSiblings &&
sites.get(scope)?.has(schema) &&
hasNormalizedLiteral(schema)
) {
fact.changed = true;
changed.push(fact);
}
if (typeof schema.$ref === "string") {
const target = references.resolve(scope, schema.$ref);
if (target.kind === "target") {
const dependency = factAt(target.value, scope);
dependency?.dependents.add(fact);
dependency?.evaluationDependents.add(fact);
if (
dependency !== undefined &&
isObject(target.value) &&
!sites.get(references.enter(target.value, scope))?.has(target.value)
)
fact.opaqueReference = dependency;
}
}
if (!ignoresSiblings)
for (const child of normalizationChildren(schema, false))
factAt(child, scope)?.dependents.add(fact);
if (!ignoresSiblings)
for (const key of [
"allOf",
"anyOf",
"oneOf",
"if",
"then",
"else",
"dependentSchemas",
"dependencies",
]) {
const value = schema[key];
const children = Array.isArray(value)
? value
: schemaMaps.has(key) && isObject(value)
? Object.values(value)
: [value];
for (const child of children)
factAt(child, scope)?.evaluationDependents.add(fact);
}
}
for (let index = 0; index < changed.length; index++)
for (const dependent of changed[index]!.dependents)
if (!dependent.changed) {
dependent.changed = true;
changed.push(dependent);
}
const lost: Fact[] = [];
for (const { schema, scope, fact } of work)
if (
sites.get(scope)?.has(schema) &&
(fact.opaqueReference?.changed ||
(!(scope?.dialect === "draft-07" && typeof schema.$ref === "string") &&
factAt(schema.if, scope)?.changed))
) {
fact.lostEvaluation = true;
lost.push(fact);
}
for (let index = 0; index < lost.length; index++)
for (const dependent of lost[index]!.evaluationDependents)
if (!dependent.lostEvaluation) {
dependent.lostEvaluation = true;
lost.push(dependent);
}
return {
references,
changed: (schema: object, scope: JsonSchemaScope | undefined) =>
indexed.get(scope)?.get(schema)?.changed === true,
lostEvaluation: (schema: object, scope: JsonSchemaScope | undefined) =>
indexed.get(scope)?.get(schema)?.lostEvaluation === true,
reduceReference: (schema: object, scope: JsonSchemaScope | undefined) =>
indexed.get(scope)?.get(schema)?.opaqueReference?.changed === true,
};
};
const normalizeAdvertisedSchema = (
value: unknown,
context?: ReturnType<typeof normalizationContext>,
inherited?: JsonSchemaScope,
): SchemaNormalization => {
if (!isObject(value)) return { schema: value, changed: false };
const owner = context ?? normalizationContext(value);
const scope = owner.references.enter(
value,
context === undefined ? owner.references.rootScope : inherited,
);
const normalize = (child: unknown) =>
normalizeAdvertisedSchema(child, owner, scope);
const node: Record<string, unknown> = { ...value };
let changed = false;
const changed = owner.changed(value, scope);
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries(
Object.entries(map).map(([name, child]) => {
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return [name, result.schema];
}),
Object.entries(map).map(([name, child]) => [
name,
normalize(child).schema,
]),
);
const normalizeList = (list: readonly unknown[]) =>
list.map((child) => {
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
return result;
});
for (const [key, child] of Object.entries(value)) {
if (schemaMaps.has(key) && isObject(child)) {
@@ -225,7 +394,7 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
key !== "oneOf" &&
Array.isArray(child)
) {
node[key] = normalizeList(child).map((result) => result.schema);
node[key] = child.map(normalize).map((result) => result.schema);
} else if (
schemaChildren.has(key) &&
key !== "not" &&
@@ -234,23 +403,20 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
key !== "else"
) {
const results = Array.isArray(child)
? normalizeList(child)
: [normalizeAdvertisedSchema(child)];
? child.map(normalize)
: [normalize(child)];
const nestedChanged = results.some((result) => result.changed);
if (
key === "contains" &&
nestedChanged &&
value.maxContains !== undefined
) {
delete node.contains;
delete node.minContains;
delete node.maxContains;
changed = true;
node.contains = results[0]?.schema;
} else {
node[key] = Array.isArray(child)
? results.map((result) => result.schema)
: results[0]?.schema;
changed ||= nestedChanged;
}
}
}
@@ -258,12 +424,10 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (typeof node.const === "boolean") {
delete node.const;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (node.const === "") {
delete node.const;
if (node.type === undefined) node.type = "string";
changed = true;
}
if (
Array.isArray(node.enum) &&
@@ -272,7 +436,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
) {
delete node.enum;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (Array.isArray(node.enum) && node.enum.includes("")) {
// Removing only the empty member would reject a canonical-valid input.
@@ -284,32 +447,29 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
values.every((item) => typeof item === "string")
)
node.type = "string";
changed = true;
}
const negated = Object.hasOwn(value, "not")
? normalizeAdvertisedSchema(value.not)
? normalize(value.not)
: undefined;
if (negated?.changed) {
// Widening a predicate under `not` narrows the containing schema.
changed = true;
delete node.not;
} else if (negated !== undefined) {
node.not = negated.schema;
}
const conditional = Object.hasOwn(value, "if")
? normalizeAdvertisedSchema(value.if)
? normalize(value.if)
: undefined;
const thenBranch = Object.hasOwn(value, "then")
? normalizeAdvertisedSchema(value.then)
? normalize(value.then)
: undefined;
const elseBranch = Object.hasOwn(value, "else")
? normalizeAdvertisedSchema(value.else)
? normalize(value.else)
: undefined;
if (conditional?.changed) {
// Changing the predicate can route canonical-valid inputs into a rejecting branch.
changed = true;
delete node.if;
delete node.then;
delete node.else;
@@ -317,18 +477,16 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (conditional !== undefined) node.if = conditional.schema;
if (thenBranch !== undefined) {
node.then = thenBranch.schema;
changed ||= thenBranch.changed;
}
if (elseBranch !== undefined) {
node.else = elseBranch.schema;
changed ||= elseBranch.changed;
}
}
for (const combinator of ["anyOf", "oneOf"] as const) {
const branches = value[combinator];
if (!Array.isArray(branches)) continue;
const normalized = normalizeList(branches);
const normalized = branches.map(normalize);
const schemas = normalized.map((result) => result.schema);
const branchesChanged = normalized.some((result) => result.changed);
if (combinator === "oneOf" && branchesChanged) {
@@ -337,7 +495,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node.oneOf;
changed = true;
continue;
}
// `anyOf` retains branch guidance without allowing widened branches to
@@ -349,7 +506,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
} else {
node.anyOf = schemas;
}
changed = true;
continue;
}
if (
@@ -360,13 +516,43 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node[combinator];
changed = true;
continue;
}
node[combinator] = schemas;
changed ||= branchesChanged;
}
if (owner.lostEvaluation(value, scope)) {
delete node.unevaluatedProperties;
delete node.unevaluatedItems;
}
if (owner.reduceReference(value, scope)) {
// The target is also caller literal data. Keep that data intact and relax
// this affected reference rather than copy or mutate its executable view.
if (scope?.dialect === "draft-07") {
for (const key of Object.keys(node))
if (
![
"$id",
"$schema",
"$anchor",
"$defs",
"definitions",
"description",
"title",
"default",
"examples",
"$comment",
].includes(key)
)
delete node[key];
} else delete node.$ref;
node.description = [
node.description,
"This referenced input schema is advertised in reduced form; the server validates the complete canonical input.",
]
.filter((text) => typeof text === "string")
.join(" ");
}
return { schema: node, changed };
};
+2 -5
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { processArgumentSchema } from "../process/processArgument.js";
import { fixedTupleSchema } from "../fixedTupleSchema.js";
import {
boundedProducerLimitsSchema,
@@ -9,11 +10,7 @@ import {
localPathStringSchema,
} from "../localPath.js";
const argument = z
.string()
.min(1)
.max(8192)
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
const argument = processArgumentSchema.min(1).max(8192);
const captureInputShape = z.strictObject({
producer_limits: boundedProducerLimitsSchema,
path: localPathStringSchema.describe("Absolute local capture path"),
+51 -27
View File
@@ -89,34 +89,58 @@ const optionalIndex = z.number().int().min(-1);
const indexWidth = z.union([z.literal(1), z.literal(2), z.literal(4)]);
/** One explicit local file; detail selects delivery, never decoding coverage. */
export const inspectIl2cppMetadataInputSchema = z.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
),
detail: z
.enum(["complete", "summary"])
.default("summary")
.describe(
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
export const inspectIl2cppMetadataInputSchema = z
.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
),
binary_path: absoluteLocalPathStringSchema
.optional()
.describe(
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
),
binary_architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe(
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
),
generic_instances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path",
),
});
detail: z
.enum(["complete", "summary"])
.default("summary")
.describe(
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
),
binary_path: absoluteLocalPathStringSchema
.optional()
.describe(
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
),
binary_architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe(
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
),
generic_instances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path",
),
})
.superRefine((input, context) => {
if (input.binary_path !== undefined) return;
if (input.binary_architecture !== undefined)
context.addIssue({
code: "custom",
path: ["binary_architecture"],
message: "binary_architecture selects a slice of binary_path.",
});
if (input.generic_instances === true)
context.addIssue({
code: "custom",
path: ["generic_instances"],
message: "generic_instances links generic methods in binary_path.",
});
})
.meta({
dependentRequired: { binary_architecture: ["binary_path"] },
if: {
properties: { generic_instances: { const: true } },
required: ["generic_instances"],
},
then: { required: ["binary_path"] },
});
export type IL2cppMetadataSectionName = z.infer<
typeof il2cppMetadataSectionSchema
+13
View File
@@ -64,3 +64,16 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => {
]),
);
});
it("rejects unpaired surrogates while preserving representable Unicode paths", () => {
for (const path of ["/tmp/\ud800", "/tmp/\udc00", "/tmp/\ud800x"]) {
expect(absoluteLocalPathStringSchema.safeParse(path).success, path).toBe(
false,
);
}
for (const path of ["/tmp/😀", "/tmp/�", "/tmp/ spaced name "]) {
expect(absoluteLocalPathStringSchema.safeParse(path).success, path).toBe(
true,
);
}
});
+7 -2
View File
@@ -16,8 +16,13 @@ export const nulFreeStringSchema = z
.string()
.regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL");
/** A selected local filesystem path: nonempty and free of NUL bytes. */
export const localPathStringSchema = nulFreeStringSchema.min(1);
/** A selected local filesystem path: nonempty, NUL-free, and representable without replacement. */
export const localPathStringSchema = nulFreeStringSchema
.min(1)
.regex(
/^[^\uD800-\uDFFF]*$/u,
"Local filesystem paths must contain well-formed Unicode",
);
/**
* A Windows absolute path: rooted, UNC, or drive-qualified. The classes spell
+1 -1
View File
@@ -2,5 +2,5 @@ import { z } from "zod";
/** Selected procedure for function dossier and native API analysis. */
export const nativeProcedureInputSchema = z.strictObject({
procedure: z.string().describe("A procedure name or address"),
procedure: z.string().min(1).describe("A procedure name or address"),
});
+6
View File
@@ -0,0 +1,6 @@
import { z } from "zod";
/** One process argument; NUL cannot be represented by process launch APIs. */
export const processArgumentSchema = z
.string()
.regex(/^[^\x00]*$/u, "NUL is not an argument character");
+2 -5
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { processArgumentSchema } from "../process/processArgument.js";
import {
absoluteLocalPathStringSchema,
localPathStringSchema,
@@ -10,11 +11,7 @@ import {
} from "../producerResourceLimits.js";
// These strings are single sigrok arguments, never shell fragments.
const selection = z
.string()
.min(1)
.max(8192)
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
const selection = processArgumentSchema.min(1).max(8192);
const signalInputShape = z.strictObject({
producer_limits: boundedProducerLimitsSchema,
path: localPathStringSchema.describe("Absolute local recording path"),
-54
View File
@@ -407,30 +407,6 @@ it("charges long digits to the string budget and keeps large previews bounded",
);
});
it("reads marshal one-byte string tags as Latin-1 with Unicode identity", () => {
for (const tag of [0x61, 0x41, 0x7a, 0x5a]) {
const encoded = Buffer.concat([
Buffer.from([tag]),
tag === 0x61 || tag === 0x41 ? u32(1) : Buffer.from([1]),
Buffer.from([0xe9]),
]);
const sameUnicode = marshalUnicode(Buffer.from("é", "utf8"));
const set = Buffer.concat([
Buffer.from([0x3c]),
u32(2),
encoded,
sameUnicode,
]);
const report = root(parse(withConsts([encoded, set])));
expect(report.consts[0]).toMatchObject({
kind: "str",
value: "é",
bytes: 1,
});
expect(report.consts[1]).toEqual({ kind: "set", length: 1 });
}
});
it("decodes marshal strings with CPython surrogatepass rules", () => {
const report = parse(
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]),
@@ -1558,36 +1534,6 @@ it("hashes a long TYPE_REF tuple chain without recursing", () => {
expect(pythonPycSchema.safeParse(report).success).toBe(true);
});
it("hashes shallow TYPE_REF code chains without using the JavaScript stack", () => {
const links = 3000;
const codes = Array.from({ length: links }, (_, index) =>
marshalCode("py311", {
flagged: true,
name: "f",
consts: index === 0 ? [Buffer.from([0x4e])] : [marshalRef(index - 1)],
}),
);
const bytes = Buffer.concat([
pythonPycHeader(3531),
marshalCode("py311", {
constsObject: Buffer.concat([
Buffer.from([0x28]),
u32(links + 1),
...codes,
Buffer.from([0x3c]),
u32(1),
marshalRef(links - 1),
]),
}),
]);
const report = parseWith(bytes, { max_objects: 65_536 });
expect(root(report).consts.at(-1)).toEqual({ kind: "set", length: 1 });
expect(report.coverage).toMatchObject({
status: "complete",
code_objects: links + 1,
});
});
it("rejects code CPython treats as corrupt instead of complete coverage", () => {
const file = (
layout: "py37" | "py38" | "py311",
+17 -83
View File
@@ -332,7 +332,7 @@ class MarshalReader {
readonly #codeKeys = new WeakMap<CodeNode, string>();
readonly #codeIdentities = new WeakMap<CodeNode, number>();
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>();
/** Completed Python hashability checks per code object. */
/** Python hashability per code object. False while that check is in progress. */
readonly #codeHashable = new WeakMap<CodeNode, boolean>();
/** Tuples whose items are still being read; CPython cannot hash them yet. */
readonly #readingTuples = new WeakSet<MarshalValue>();
@@ -436,19 +436,13 @@ class MarshalReader {
};
case TYPE_INTERNED:
case TYPE_UNICODE:
return this.string(
this.nonNegativeInt32("Marshal string length"),
"utf8",
);
return this.utf8(this.nonNegativeInt32("Marshal string length"), false);
case TYPE_ASCII:
case TYPE_ASCII_INTERNED:
return this.string(
this.nonNegativeInt32("Marshal string length"),
"latin1",
);
return this.utf8(this.nonNegativeInt32("Marshal string length"), true);
case TYPE_SHORT_ASCII:
case TYPE_SHORT_ASCII_INTERNED:
return this.string(this.u8(), "latin1");
return this.utf8(this.u8(), true);
case TYPE_TUPLE:
return this.sequenceValue(
"tuple",
@@ -1315,70 +1309,13 @@ class MarshalReader {
#isCodeHashable(node: CodeNode): boolean {
const known = this.#codeHashable.get(node);
if (known !== undefined) return known;
const frames: { value: MarshalValue; finish: boolean }[] = [
{ value: { kind: "code", value: node }, finish: false },
];
const gray = new Set<MarshalValue | CodeNode>();
const complete = new Set<MarshalValue>();
const reject = (): false => {
this.#codeHashable.set(node, false);
return false;
};
while (frames.length > 0) {
this.#takeKeyStep();
const frame = frames.pop()!;
const value = frame.value;
const identity = value.kind === "code" ? value.value : value;
if (frame.finish) {
gray.delete(identity);
if (value.kind === "code") {
const material =
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.");
// Child code checks are now complete, so the existing tuple key
// walker preserves its equality and expansion budgets without recursion.
const hashable =
this.#equalityKey(material.constsTuple, "hash") !== null;
this.#codeHashable.set(value.value, hashable);
if (!hashable) return reject();
} else complete.add(value);
continue;
}
if (value.kind === "code") {
const cached = this.#codeHashable.get(value.value);
if (cached === false) return reject();
if (cached === true) continue;
} else {
const cached = this.#keyCache.get(value);
if (cached === null) return reject();
if (cached !== undefined || complete.has(value)) continue;
}
if (
gray.has(identity) ||
this.#readingTuples.has(value) ||
value.kind === "list" ||
value.kind === "set" ||
value.kind === "dict" ||
value.kind === "null"
)
return reject();
const children =
value.kind === "code"
? [
(
this.#codeEquality.get(value.value) ??
malformed("Code object is missing equality material.")
).constsTuple,
]
: value.kind === "tuple" || value.kind === "frozenset"
? value.items
: [];
gray.add(identity);
frames.push({ value, finish: true });
for (let index = children.length - 1; index >= 0; index -= 1)
frames.push({ value: children[index]!, finish: false });
}
return true;
this.#codeHashable.set(node, false);
const material =
this.#codeEquality.get(node) ??
malformed("Code object is missing equality material.");
const hashable = this.#equalityKey(material.constsTuple, "hash") !== null;
this.#codeHashable.set(node, hashable);
return hashable;
}
#codeIdentity(node: CodeNode): number {
@@ -1555,20 +1492,17 @@ class MarshalReader {
);
}
string(length: number, encoding: "latin1" | "utf8"): MarshalValue {
utf8(length: number, ascii: boolean): MarshalValue {
const bytes = this.blob(length);
if (encoding === "latin1") {
// CPython reads the ASCII-tagged forms as one-byte Unicode, even when
// high bytes appear. Identity must agree with the UTF-8 string form.
const value = bytes.toString("latin1");
if (ascii) {
if (bytes.some((byte) => byte > 0x7f))
malformed("Marshal ASCII string contains a non-ASCII byte.");
return {
kind: "str",
value,
value: bytes.toString("latin1"),
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
bytes: length,
raw: bytes.some((byte) => byte > 0x7f)
? Buffer.from(value, "utf8")
: bytes,
raw: bytes,
};
}
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT);
@@ -189,3 +189,34 @@ cliTest(
}
},
);
cliTest(
"validates native operation inputs before opening a target",
async ({ cli }) => {
for (const [selection, code] of [
[
["inspect-native-instruction", "missing-target", ""],
"VALIDATION_ERROR",
],
[
["resolve-native-call-targets", "missing-target", ""],
"VALIDATION_ERROR",
],
[["annotate-native-function", "missing-target", ""], "VALIDATION_ERROR"],
[
["annotate-native-function", "missing-target", "main"],
"invalid_request",
],
[
["annotate-native-function", "missing-target", "main", "--comment="],
"target_unavailable",
],
] as const) {
const result = await cli.run({
arguments: [...selection, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.json, JSON.stringify(selection)).toMatchObject({ code });
}
},
);
@@ -27,9 +27,11 @@ const serve = async (cli: CliInstance, argv: readonly string[]) => {
.object({
args: z
.object({ properties: z.record(z.string(), z.unknown()) })
.passthrough()
.optional(),
options: z
.object({ properties: z.record(z.string(), z.unknown()) })
.passthrough()
.optional(),
})
.parse(JSON.parse(stdout));
@@ -163,3 +165,115 @@ it("advertises the domain constraints for android, adb settings, and browser sel
parser.args.safeParse({ path: "app.apk", className: "" }).success,
).toBe(false);
});
it("advertises selection dependencies after omittable-default projection", async () => {
const cli = createCli({});
const ajv = new Ajv2020({ strict: false, validateFormats: false });
for (const [command, invalid, valid] of [
[
"inspect-packet-fields",
{ all: true, limit: 1 },
{ all: false, limit: 1 },
],
[
"inspect-signal-decoders",
{ all: true, limit: 1 },
{ all: false, limit: 1 },
],
[
"inspect-packet-capture",
{ fields: ["ip.src"], all_matches: true, match_limit: 1 },
{ fields: ["ip.src"], all_matches: false, match_limit: 1 },
],
[
"decode-signal-recording",
{ decoders: "uart", all_lines: true, line_limit: 1 },
{ decoders: "uart", all_lines: false, line_limit: 1 },
],
[
"inspect-il2cpp-metadata",
{ genericInstances: true },
{ genericInstances: false },
],
[
"inspect-il2cpp-metadata",
{ architecture: "arm64" },
{ binary: "lib.dylib", architecture: "arm64" },
],
[
"inspect-il2cpp-metadata",
{ ghidraSeeds: "seeds.txt" },
{ binary: "lib.so", ghidraSeeds: "seeds.txt" },
],
[
"inspect-il2cpp-metadata",
{ seedBase: "0x1000" },
{ binary: "lib.so", ghidraSeeds: "seeds.txt", seedBase: "0x1000" },
],
[
"import-reference-source",
{ outlineDepth: 2, detail: "full" },
{ outlineDepth: 2, detail: "summary" },
],
] as const) {
const schema = (await serve(cli, [command, "--schema", "--json"])).options;
expect(schema, command).toBeDefined();
const validate = ajv.compile(schema as AnySchema);
expect(
validate(invalid),
`${command} invalid ${JSON.stringify(invalid)}`,
).toBe(false);
expect(validate(valid), `${command} valid ${JSON.stringify(valid)}`).toBe(
true,
);
}
});
it("rejects invalid selected paths before inspecting a target", async () => {
const cli = createCli({});
for (const [args, field, code] of [
[
["inspect-apple-ips", "missing.ips", "--join-path="],
"joinPath",
"too_small",
],
[
["match-native-functions", "missing.json", "--evidence_output="],
"evidence_output",
"too_small",
],
[
["match-native-functions", "missing.json", "--evidence_output=bad\0path"],
"evidence_output",
"invalid_format",
],
[
[
"match-native-functions",
"missing.json",
"--evidence_output=bad\ud800path",
],
"evidence_output",
"invalid_format",
],
[
["match-native-functions", "missing.json", "--source_bundle="],
"source_bundle.0",
"too_small",
],
[["import-reference-source", ""], "root", "too_small"],
] as const) {
let stdout = "";
await cli.serve([...args, "--json"], {
env: {},
exit: () => {},
stdout: (text) => {
stdout += text;
},
});
expect(JSON.parse(stdout), args.join(" ")).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [{ path: field, code }],
});
}
});
@@ -703,15 +703,27 @@ describe("reference-source outline-depth input schema", () => {
[String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER],
] as const;
for (const [token, expected] of parsedValues) {
const parsed = parser?.safeParse({ outlineDepth: token });
const parsed = parser?.safeParse({
outlineDepth: token,
detail: "summary",
});
expect(parsed?.success).toBe(true);
if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`);
expect(parsed.data).toMatchObject({ outlineDepth: expected });
expect(parsed.data).toMatchObject({
detail: "summary",
outlineDepth: expected,
});
}
for (const token of ["", "-1", "9007199254740992"]) {
expect(parser?.safeParse({ outlineDepth: token }).success).toBe(false);
expect(
parser?.safeParse({ outlineDepth: token, detail: "summary" }).success,
).toBe(false);
}
expect(parser?.safeParse({ outlineDepth: "2" }).success).toBe(false);
expect(
parser?.safeParse({ outlineDepth: "2", detail: "full" }).success,
).toBe(false);
const ajv = new Ajv2020({ strict: false });
const validate = ajv.compile({
@@ -719,13 +731,18 @@ describe("reference-source outline-depth input schema", () => {
$schema: "https://json-schema.org/draft/2020-12/schema",
});
for (const [token] of parsedValues) {
const parsed = parser?.safeParse({ outlineDepth: token });
const parsed = parser?.safeParse({
outlineDepth: token,
detail: "summary",
});
if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`);
expect(validate(parsed.data)).toBe(true);
}
expect(validate({ outlineDepth: -1 })).toBe(false);
expect(validate({ outlineDepth: 9007199254740992 })).toBe(false);
expect(validate({ outlineDepth: 2 })).toBe(false);
expect(validate({ outlineDepth: 2, detail: "full" })).toBe(false);
});
});
@@ -1818,3 +1818,20 @@ it("refuses a declared DOS relocation header that is not present in the file", a
error: { reason: "truncated DOS MZ load module" },
});
});
it("does not resolve malformed Unicode paths to a replacement-character sibling", async () => {
const directory = await createTestTempDirectory("rea-target-unicode-");
const actual = join(directory, "target-�");
const selected = join(directory, "target-\ud800");
await writeFile(actual, pe(0xaa64));
const refused = await parseBinaryTarget(selected);
expect(refused).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
path: selected,
reason: "Local filesystem paths must contain well-formed Unicode",
},
});
expect(await parseBinaryTarget(actual)).toMatchObject({ ok: true });
});
+80 -38
View File
@@ -121,6 +121,58 @@ const factories = (
return { dependencies, session };
};
it("rejects malformed one-shot operation inputs before creating a session", async () => {
const { dependencies, session } = factories();
const create = vi.fn(() => session);
const guarded = {
...dependencies,
createBinarySession: create,
createNativeBinarySession: create,
createArtifactBinarySession: create,
createManagedBinarySession: create,
};
for (const result of [
await runDirectAnalysis(guarded, "/missing", "inspect_native_instruction", {
address: "",
}),
await runDirectAnalysis(guarded, "/missing", "annotate_native_function", {
procedure: "main",
}),
await runDirectAnalysis(guarded, "/missing", "trace_feature", {
query: "",
}),
await runDirectAnalysis(guarded, "/missing", "analyze_function", {
procedure: "",
}),
await runDirectAnalysis(guarded, "/missing", "inspect_native_api", {
procedure: "",
}),
await runProviderAnalysis(guarded, "/missing", "observe_native_calls", {
breakpoints: [],
}),
await runProviderAnalysis(guarded, "/missing", "inspect_asset_catalog", {
limit: -1,
}),
]) {
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toMatchObject({ code: "invalid_request" });
}
const managed = await runManagedProviderExecution(
guarded,
"/missing",
"inspect_managed_members",
undefined,
{ type_names: ["Duplicate", "Duplicate"] },
);
expect(managed.ok).toBe(false);
if (!managed.ok)
expect(projectAnalysisError(managed.error)).toMatchObject({
code: "invalid_request",
});
expect(create).not.toHaveBeenCalled();
});
it.each([false, true])(
"retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)",
async (closeFails) => {
@@ -266,9 +318,9 @@ it.each([false, true])(
...(closeFails ? { cleanupError } : {}),
});
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {}).then(
projectCliAnalysisResult,
),
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult),
);
const observation = parseEvidence(
jsonObjectSchema.parse(result.details).partial_observation,
@@ -342,7 +394,9 @@ describe("one-shot finalization cancellation", () => {
async (tool) => {
const [path] = await createBinarySessionTargets();
const parameters =
tool === "analyze_function" ? { procedure: "fixture" } : {};
tool === "analyze_function"
? { procedure: "fixture" }
: { address: "0x1000" };
const original = parseEvidence(
await runDirectAnalysis(
factories().dependencies,
@@ -393,7 +447,7 @@ describe("one-shot finalization cancellation", () => {
dependencies,
path,
"read_bytes",
{},
{ address: "0x1000" },
{ signal: controller.signal },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
@@ -420,7 +474,7 @@ describe("one-shot finalization cancellation", () => {
dependencies,
path,
"read_bytes",
{},
{ address: "0x1000" },
{ signal: controller.signal },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
@@ -477,7 +531,7 @@ describe("snapshot publication finalization", () => {
dependencies,
path,
"read_bytes",
{},
{ address: "0x1000" },
{ signal: controller.signal, snapshotPath },
).then(projectCliAnalysisResult);
const loaded = await readAnalysisSnapshot(snapshotPath);
@@ -539,7 +593,7 @@ describe("snapshot publication finalization", () => {
dependencies,
path,
"read_bytes",
{},
{ address: "0x1000" },
{ signal: controller.signal, snapshotPath: failure.path },
).then(projectCliAnalysisResult),
);
@@ -574,9 +628,9 @@ it.each([false, true])(
...(closeFails ? { cleanupError } : {}),
});
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, path, "read_bytes", {}).then(
projectCliAnalysisResult,
),
await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult),
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
@@ -601,12 +655,9 @@ describe("one-shot analysis cleanup outcomes", () => {
it("retains successful evidence and reports CLI failure when close fails", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories({ cleanupError });
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
expect(result).toMatchObject({
error: "Analysis failed",
code: "cleanup_incomplete",
@@ -631,12 +682,9 @@ describe("one-shot analysis cleanup outcomes", () => {
cleanupError,
});
const primary = projectAnalysisError(primaryError);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
const callerError = analysisCliErrorEnvelopeSchema.parse(result);
expect(callerError).toMatchObject({
code: "cleanup_incomplete",
@@ -663,12 +711,9 @@ describe("one-shot analysis cleanup outcomes", () => {
const { dependencies } = factories({
thrownCleanup: new Error("unexpected close rejection"),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
expect(result).toMatchObject({
code: "cleanup_incomplete",
details: {
@@ -686,9 +731,9 @@ describe("one-shot analysis cleanup outcomes", () => {
const thrownExecution = new Error("unexpected execution rejection");
const { dependencies } = factories({ thrownExecution, cleanupError });
await expect(
runDirectAnalysis(dependencies, path, "read_bytes", {}).then(
projectCliAnalysisResult,
),
runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult),
).rejects.toMatchObject({
cause: thrownExecution,
errors: [thrownExecution, cleanupError],
@@ -698,12 +743,9 @@ describe("one-shot analysis cleanup outcomes", () => {
it("keeps successful evidence unchanged after confirmed cleanup", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories();
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
expect(parseEvidence(result)).toMatchObject({
operation: "read_bytes",
normalized_result: { observation: "retained result" },
@@ -65,6 +65,28 @@ const recordingFactories = (
return { dependencies, sessions, clientsClosed: () => clientsClosed };
};
/** Schema-valid read_bytes input so lifecycle tests reach the session. */
const admittedReadBytes = { address: "0x1000" } as const;
/** Schema-valid inputs for the ordinary native session route. */
const admittedNativeSessionInput = {
observe_native_ui: { pid: 123, window_id: 456 },
capture_native_ui_scenario: {
pid: 123,
window_id: 456,
steps: [{ kind: "wait", milliseconds: 100 }],
},
observe_native_calls: {
breakpoints: [
{
kind: "objc-method",
class_name: "NSURLSession",
selector: "dataTaskWithRequest:completionHandler:",
},
],
},
};
it("requires a durable summary destination before acquiring a managed session", async () => {
const factories = recordingFactories();
const result = await runProviderAnalysis(
@@ -125,11 +147,13 @@ describe("one-shot analysis factory boundary", () => {
const factories = recordingFactories(failure);
const interruptListeners = process.listenerCount("SIGINT");
const terminateListeners = process.listenerCount("SIGTERM");
await expect(
runDirectAnalysis(factories.dependencies, path, "read_bytes", {}).then(
projectCliAnalysisResult,
),
).rejects.toBe(failure);
const execution = runDirectAnalysis(
factories.dependencies,
path,
"read_bytes",
admittedReadBytes,
).then(projectCliAnalysisResult);
await expect(execution).rejects.toBe(failure);
expect(factories.clientsClosed()).toBe(1);
expect(process.listenerCount("SIGINT")).toBe(interruptListeners);
expect(process.listenerCount("SIGTERM")).toBe(terminateListeners);
@@ -247,33 +271,33 @@ describe("one-shot analysis factory boundary", () => {
expect(factories.sessions[0]?.activeTarget()).toBeUndefined();
},
);
it.each([
"observe_native_ui",
"capture_native_ui_scenario",
"observe_native_calls",
] satisfies readonly NativeToolName[])(
"keeps %s on the ordinary session route",
async (tool) => {
const [path] = await createBinarySessionTargets();
const factories = recordingFactories();
const result = await runProviderAnalysis(
{
...factories.dependencies,
createNativeBinarySession: () => {
throw new Error("static inspection factory must stay unused");
},
},
path,
tool,
{},
).then(projectCliAnalysisResult);
expect(result).toMatchObject({ operation: tool });
expect(factories.clientsClosed()).toBe(1);
},
);
});
it.each([
"observe_native_ui",
"capture_native_ui_scenario",
"observe_native_calls",
] satisfies readonly NativeToolName[])(
"keeps %s on the ordinary session route",
async (tool) => {
const [path] = await createBinarySessionTargets();
const factories = recordingFactories();
const result = await runProviderAnalysis(
{
...factories.dependencies,
createNativeBinarySession: () => {
throw new Error("static inspection factory must stay unused");
},
},
path,
tool,
admittedNativeSessionInput[tool],
).then(projectCliAnalysisResult);
expect(result).toMatchObject({ operation: tool });
expect(factories.clientsClosed()).toBe(1);
},
);
it("preserves a managed execution profile in direct analysis Evidence", async () => {
const [path] = await createBinarySessionTargets();
const profile = createAnalysisProfile(
@@ -307,7 +331,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies,
path,
"read_bytes",
{},
admittedReadBytes,
{ snapshotPath },
).then(projectCliAnalysisResult),
).not.toHaveProperty("code");
@@ -315,7 +339,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies,
other,
"read_bytes",
{},
admittedReadBytes,
{ snapshotPath },
).then(projectCliAnalysisResult);
const sha = async (input: string) =>
@@ -344,7 +368,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies,
path,
"read_bytes",
{},
admittedReadBytes,
{ snapshotPath },
).then(projectCliAnalysisResult),
).not.toHaveProperty("code");
@@ -385,7 +409,7 @@ it("retries snapshot file cleanup before final close even when a published ident
dependencies,
path,
"read_bytes",
{},
admittedReadBytes,
{ snapshotPath: join(dirname(path), "excluded.snapshot") },
).then(projectCliAnalysisResult);
expect(events[0]).toBe("file-cleanup");
+26 -6
View File
@@ -101,12 +101,32 @@ const fixture = (
};
describe("direct CLI cross-reference selectors", () => {
it("retains validation issues unrelated to symbol resolution", async () => {
it("rejects unknown arguments before opening a session", async () => {
const [path] = await createBinarySessionTargets();
const issues = [
{ path: ["address"], reason: "invalid_format" },
{ path: ["extra"], reason: "unknown_argument" },
] as const;
const provider = fixture({
xrefsError: new AnalysisInputError("xrefs", undefined, [
{ path: ["address"], reason: "invalid_format" },
]),
});
const result = await runDirectAnalysis(
provider.dependencies,
path,
"xrefs",
{ address: "table_only", extra: true },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
code: "invalid_request",
details: {
operation: "xrefs",
issues: [{ path: ["extra"], reason: "unknown_argument" }],
},
});
expect(provider.closed()).toBe(0);
});
it("retains provider validation issues unrelated to symbol resolution", async () => {
const [path] = await createBinarySessionTargets();
const issues = [{ path: ["address"], reason: "invalid_format" }] as const;
const provider = fixture({
xrefsError: new AnalysisInputError("xrefs", undefined, issues),
});
@@ -114,7 +134,7 @@ describe("direct CLI cross-reference selectors", () => {
provider.dependencies,
path,
"xrefs",
{ address: "table_only", extra: true },
{ address: "table_only" },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
code: "invalid_request",