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) [Ghidra first-query deadlines](../reference/mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
for import, client timeouts 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 one-shot command attempts provider-session and runtime cleanup before reporting
a failure. If cleanup remains incomplete, inspect the reported resources and 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), 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, type MachoLoadCommandTableHeader,
} from "../domain/apple/machoLoadCommands.js"; } from "../domain/apple/machoLoadCommands.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { localPathStringSchema } from "../domain/localPath.js";
import type { JsonValue } from "../domain/jsonValue.js"; import type { JsonValue } from "../domain/jsonValue.js";
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024; const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
@@ -173,9 +174,25 @@ export const parseBinaryTarget = async (
): Promise< ): Promise<
Result<BinaryTarget, BinaryTargetError | AnalysisCancelledError> Result<BinaryTarget, BinaryTargetError | AnalysisCancelledError>
> => { > => {
const cwd = options.cwd ?? process.cwd();
const { signal } = options; 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 before canonicalization concern the selected path itself; later
// failures concern the canonical path opened with symbolic links rejected. // failures concern the canonical path opened with symbolic links rejected.
let stage: TargetFilesystemStage = "resolve"; 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 { admitCompletedAnalysis } from "./investigation/admitCompletedAnalysis.js";
import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js"; import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js";
import type { ExecutableFormatHint } from "../domain/dosCom.js"; import type { ExecutableFormatHint } from "../domain/dosCom.js";
@@ -96,30 +100,6 @@ import {
import { completeProviderEvidence } from "./ProviderOperationExecution.js"; import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.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. // These one-shot CLI questions require native utilities, not a deep engine.
// Runtime observation retains the ordinary session and authority checks. // Runtime observation retains the ordinary session and authority checks.
const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([ const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
@@ -158,20 +138,30 @@ export const runDirectAnalysis = async (
readonly includeEvidenceSources?: boolean; readonly includeEvidenceSources?: boolean;
} = {}, } = {},
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => ): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
withProcessCancellation(options.signal, (signal) => withProcessCancellation(options.signal, (signal) => {
runAnalysis(dependencies, path, tool, arguments_, { const admitted = admitOneShotOperation(tool, arguments_, signal);
logger: options.logger ?? silentLogger, if (!admitted.ok)
snapshotPath: options.snapshotPath, return Promise.resolve(err(cliError(admitted.error, path)));
signal, return runAnalysis(
...(options.includeEvidenceSources === true dependencies,
? { includeEvidenceSources: true } path,
: {}), admitted.value.name,
...targetSelection(options), admitted.value.input,
...(options.providerId === undefined {
? {} evidenceParameters: arguments_,
: { providerId: options.providerId }), 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. */ /** How the caller asks an artifact target to be read. */
export interface ArtifactTargetSelection { export interface ArtifactTargetSelection {
@@ -268,8 +258,7 @@ export const runProviderAnalysis = async (
), ),
); );
return withProcessCancellation(signal, async (operationSignal) => { return withProcessCancellation(signal, async (operationSignal) => {
// The selected CLI path is a request fact. Keep it out of provider inputs // Preserve the selected path and original arguments as request facts.
// and target identity, which may use the resolved executable instead.
const evidenceParameters = { ...arguments_, input_path: path }; const evidenceParameters = { ...arguments_, input_path: path };
if (isManagedToolName(tool)) { if (isManagedToolName(tool)) {
const original = await runManagedProviderAnalysis( const original = await runManagedProviderAnalysis(
@@ -309,18 +298,26 @@ export const runProviderAnalysis = async (
); );
return result.ok ? ok(result.value) : err(cliError(result.error, path)); return result.ok ? ok(result.value) : err(cliError(result.error, path));
} }
return runAnalysis(dependencies, path, tool, arguments_, { const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
logger, if (!admitted.ok) return err(cliError(admitted.error, path));
snapshotPath: undefined, return runAnalysis(
signal: operationSignal, dependencies,
evidenceParameters, path,
...targetSelection(selection ?? {}), admitted.value.name,
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool) admitted.value.input,
? { createSession: dependencies.createArtifactBinarySession } {
: NATIVE_INSPECTION_OPERATIONS.has(tool) logger,
? { createSession: dependencies.createNativeBinarySession } 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>>, recoveryParameters?: Readonly<Record<string, JsonValue>>,
): Promise<Result<AnalysisExecution, AnalysisError>> => ): Promise<Result<AnalysisExecution, AnalysisError>> =>
withProcessCancellation(signal, async (operationSignal) => { withProcessCancellation(signal, async (operationSignal) => {
const admitted = admitOneShotOperation(tool, parameters, operationSignal);
if (!admitted.ok) return admitted;
const session = await dependencies.createManagedBinarySession(); const session = await dependencies.createManagedBinarySession();
return withSessionCleanup( return withSessionCleanup(
session, session,
@@ -341,10 +340,12 @@ export const runManagedProviderExecution = async (
const opened = await session.open(path, { signal: operationSignal }); const opened = await session.open(path, { signal: operationSignal });
if (!opened.ok) return opened; if (!opened.ok) return opened;
return recoveryParameters === undefined return recoveryParameters === undefined
? await session.execute(tool, parameters, { signal: operationSignal }) ? await session.execute(admitted.value.name, admitted.value.input, {
signal: operationSignal,
})
: await session.executeForDirectObservation( : await session.executeForDirectObservation(
tool, admitted.value.name,
parameters, admitted.value.input,
recoveryParameters, recoveryParameters,
{ signal: operationSignal }, { signal: operationSignal },
); );
@@ -13,6 +13,9 @@ import { err, ok } from "../../domain/result.js";
import { EvidenceFileError } from "../../domain/evidenceErrors.js"; import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.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 = { const identity = {
id: "fixture-il2cpp", id: "fixture-il2cpp",
@@ -21,6 +24,29 @@ const identity = {
}; };
const report = createEmptyIl2cppMetadataFixture(); 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 }) => const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
new Il2cppMetadataService({ new Il2cppMetadataService({
close: async () => {}, close: async () => {},
@@ -106,32 +106,6 @@ export class Il2cppMetadataService {
options?.signal, options?.signal,
); );
if (!input.ok) return input; 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 (output !== undefined) {
if (input.value.binary_path === undefined) if (input.value.binary_path === undefined)
return err( 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", () => { it("decodes CPython marshal Unicode with surrogatepass", () => {
expect( expect(
readMarshalSubset( readMarshalSubset(
+8 -2
View File
@@ -168,8 +168,14 @@ const readAsciiBytes = (
): Result<string, PyInstallerFailure> => { ): Result<string, PyInstallerFailure> => {
const bytes = readBytes(cursor, length); const bytes = readBytes(cursor, length);
if (!bytes.ok) return bytes; if (!bytes.ok) return bytes;
// These tags use CPython's one-byte Unicode constructor, not an ASCII check. if (bytes.value.some((byte) => byte > 0x7f))
return ok(bytes.value.toString("latin1")); return err(
pyInstallerFailure(
"format",
"PYZ marshal ASCII string contains a non-ASCII byte",
),
);
return ok(bytes.value.toString("ascii"));
}; };
const readUtf8 = ( const readUtf8 = (
+1 -2
View File
@@ -215,8 +215,7 @@ export const registerBinaryDiagnosticsCommands = (
maxIntegerTextBytes: cliDecimalField( maxIntegerTextBytes: cliDecimalField(
inspectAppleIpsInputSchema.shape.max_integer_text_bytes, inspectAppleIpsInputSchema.shape.max_integer_text_bytes,
), ),
joinPath: z joinPath: localPathStringSchema
.string()
.optional() .optional()
.describe( .describe(
"Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images", "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"], path: ["all"],
message: "all and limit are mutually exclusive", message: "all and limit are mutually exclusive",
}); });
})
.meta({
not: {
required: ["all", "limit"],
properties: { all: { const: true } },
},
}), }),
run: ({ options: { all, producerLimits, ...options } }) => run: ({ options: { all, producerLimits, ...options } }) =>
withCommandCancellation((signal) => withCommandCancellation((signal) =>
@@ -119,7 +125,13 @@ export const registerCaptureCommands = (
.refine( .refine(
withinPacketCaptureSelectorBudget, withinPacketCaptureSelectorBudget,
packetCaptureSelectorBudgetIssue, packetCaptureSelectorBudgetIssue,
), )
.meta({
not: {
required: ["all_matches", "match_limit"],
properties: { all_matches: { const: true } },
},
}),
run: ({ args, options: { all_matches, producerLimits, ...options } }) => run: ({ args, options: { all_matches, producerLimits, ...options } }) =>
withCommandCancellation((signal) => withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => { 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 { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js"; import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js";
import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js"; import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js";
@@ -395,7 +397,9 @@ export const registerCoreNativeCommands = (
path: localPathStringSchema.describe( path: localPathStringSchema.describe(
"Local native executable or app path", "Local native executable or app path",
), ),
address: z.string().describe("Exact native address"), address: nativeInstructionInputSchema.shape.address.describe(
"Exact native address",
),
}), }),
options: z.object({ options: z.object({
...nativeOpenOptionFields, ...nativeOpenOptionFields,
@@ -557,11 +561,9 @@ const registerAnnotationCommand = (
description: "Edit function annotations and return refreshed analysis", description: "Edit function annotations and return refreshed analysis",
args: z.object({ args: z.object({
path: localPathStringSchema.describe("Local executable path"), path: localPathStringSchema.describe("Local executable path"),
procedure: z procedure: nativeFunctionAnnotationsInputSchema.shape.procedure.describe(
.string() "Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
.describe( ),
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
),
}), }),
options: z.object({ options: z.object({
name: z.string().min(1).optional().describe("Analyst function name"), name: z.string().min(1).optional().describe("Analyst function name"),
+4 -8
View File
@@ -263,14 +263,12 @@ const withheldExportOptions = {
.describe( .describe(
"Seed the withheld files after all, which needs a seed_sign_in consent in --consent", "Seed the withheld files after all, which needs a seed_sign_in consent in --consent",
), ),
consent: z consent: localPathStringSchema
.string()
.optional() .optional()
.describe( .describe(
"consent.json holding the person's consents, which the bundle ships", "consent.json holding the person's consents, which the bundle ships",
), ),
withheldCopy: z withheldCopy: localPathStringSchema
.string()
.optional() .optional()
.describe( .describe(
"New or empty folder outside the bundle the withheld files are copied into, for whoever supplies them at run time", "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( .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", "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 downloadInstall: localPathStringSchema
.string()
.optional() .optional()
.describe( .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", "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( .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", "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 screens: localPathStringSchema
.string()
.optional() .optional()
.describe( .describe(
"Folder holding checkpoint screenshots for screen-judge examples.", "Folder holding checkpoint screenshots for screen-judge examples.",
+8 -6
View File
@@ -1,9 +1,14 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js"; import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js";
import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js"; import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js";
import { isAbsolute, resolve, sep } from "node:path"; import { isAbsolute, resolve, sep } from "node:path";
import { Cli, z } from "incur"; import { Cli, z } from "incur";
import {
localPathStringSchema,
nulFreeStringSchema,
} from "../domain/localPath.js";
import { logCliCommand } from "../cliLogging.js"; import { logCliCommand } from "../cliLogging.js";
import { import {
@@ -37,7 +42,6 @@ import {
evidenceBundleOption, evidenceBundleOption,
runEvidenceInputCommand, runEvidenceInputCommand,
} from "./evidenceInputCommand.js"; } from "./evidenceInputCommand.js";
import { localPathStringSchema } from "../domain/localPath.js";
/** Register CLI equivalents of the Electron MCP tools. */ /** Register CLI equivalents of the Electron MCP tools. */
export const registerElectronCommands = ( export const registerElectronCommands = (
@@ -65,11 +69,9 @@ const registerElectronFuseCommand = (
description: description:
"Inspect packaged Electron fuse bytes without launching the application", "Inspect packaged Electron fuse bytes without launching the application",
args: z.object({ args: z.object({
path: z path: nulFreeStringSchema.describe(
.string() "Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
.describe( ),
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
),
}), }),
run: ({ args }) => run: ({ args }) =>
withCommandCancellation((signal) => withCommandCancellation((signal) =>
+2 -3
View File
@@ -1,6 +1,7 @@
import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js"; import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur"; import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js"; import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
import type { import type {
@@ -166,9 +167,7 @@ export const registerFridaCommands = (
.string() .string()
.optional() .optional()
.describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`), .describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`),
scriptFile: z scriptFile: localPathStringSchema
.string()
.min(1)
.optional() .optional()
.describe( .describe(
`Path to JavaScript source file. ${exactlyOneScriptSource}`, `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)", "Local global-metadata.dat (version 24, 27, 29, 31 or 39)",
), ),
}), }),
options: z.object({ options: z
...analysisEvidenceDeliveryOptions, .object({
binary: localPathStringSchema ...analysisEvidenceDeliveryOptions,
.optional() binary: localPathStringSchema
.describe( .optional()
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only", .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"]) architecture: z
.optional() .enum(["arm64", "amd64"])
.describe("Slice of a universal Mach-O binary"), .optional()
genericInstances: z .describe("Slice of a universal Mach-O binary"),
.boolean() genericInstances: z
.optional() .boolean()
.describe( .optional()
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary", .describe(
), "Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
ghidraSeeds: localPathStringSchema ),
.optional() ghidraSeeds: localPathStringSchema
.describe( .optional()
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary", .describe(
), "Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
seedBase: z ),
.string() seedBase: z
.regex(/^0x[0-9a-fA-F]{1,16}$/u) .string()
.optional() .regex(/^0x[0-9a-fA-F]{1,16}$/u)
.describe( .optional()
"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)", .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 }) => run: ({ args, options }) =>
withCommandCancellation((signal) => withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => { logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => {
+3 -4
View File
@@ -18,6 +18,7 @@ import {
import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { CliInstance } from "./types.js"; import type { CliInstance } from "./types.js";
import { parseNativeMatchingAllowance } from "../config/nativeMatchingAllowance.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. */ /** Resolve portable input bundles, then use the same read-only matching service as MCP. */
export const registerNativeFunctionMatchingCommands = ( export const registerNativeFunctionMatchingCommands = (
@@ -37,14 +38,12 @@ export const registerNativeFunctionMatchingCommands = (
}), }),
options: z.object({ options: z.object({
source_bundle: z source_bundle: z
.array(z.string()) .array(localPathStringSchema)
.default([]) .default([])
.describe( .describe(
"Canonical source Evidence bundle for retained references; repeat for each input", "Canonical source Evidence bundle for retained references; repeat for each input",
), ),
evidence_output: z evidence_output: localPathStringSchema
.string()
.min(1)
.optional() .optional()
.describe( .describe(
"Save full matching Evidence and selected sources; required for default summary; never overwrites", "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), fillsOmission(field),
); );
if (omittable.length === 0) return schema; if (omittable.length === 0) return schema;
return schema.safeExtend( const metadata = schema.meta();
const result = schema.safeExtend(
Object.fromEntries( Object.fromEntries(
omittable.map(([key, field]) => [key, optionalWithMetadata(field)]), omittable.map(([key, field]) => [key, optionalWithMetadata(field)]),
), ),
); );
return metadata === undefined ? result : result.meta(metadata);
}; };
/** Let the object parser decide omission; Incur advertises required outputs. */ /** Let the object parser decide omission; Incur advertises required outputs. */
+4 -4
View File
@@ -1,5 +1,6 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur"; import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js"; import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js"; import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { createPcapAnalysisProvider } from "../composition/pcap.js"; import { createPcapAnalysisProvider } from "../composition/pcap.js";
@@ -53,10 +54,9 @@ export const registerPcapCommands = (
description: description:
"Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution", "Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution",
args: z.object({ args: z.object({
path: z path: localPathStringSchema.describe(
.string() "Local pcap or pcapng capture file to inspect",
.min(1) ),
.describe("Local pcap or pcapng capture file to inspect"),
}), }),
run: ({ args }) => run: ({ args }) =>
execute( execute(
+10 -9
View File
@@ -1,4 +1,5 @@
import { z } from "incur"; import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js"; import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js"; import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
@@ -151,10 +152,9 @@ export const registerReverseEngineeringCommands = (
description: description:
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence", "Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
args: z.object({ args: z.object({
path: z path: localPathStringSchema.describe(
.string() "Path to the local artifact to inspect.",
.min(1) ),
.describe("Path to the local artifact to inspect."),
operation: z operation: z
.enum(OBJDUMP_ARTIFACT_OPERATIONS) .enum(OBJDUMP_ARTIFACT_OPERATIONS)
.describe("GNU-style objdump operation to run."), .describe("GNU-style objdump operation to run."),
@@ -274,10 +274,9 @@ export const registerReverseEngineeringCommands = (
cli.command(CLI_COMMANDS.executeRizinCommand, { cli.command(CLI_COMMANDS.executeRizinCommand, {
description: "Execute one Rizin command against a local artifact", description: "Execute one Rizin command against a local artifact",
args: z.object({ args: z.object({
path: z path: localPathStringSchema.describe(
.string() "Path to the local artifact to analyze.",
.min(1) ),
.describe("Path to the local artifact to analyze."),
command: z.string().min(1).describe("One caller-selected Rizin command."), command: z.string().min(1).describe("One caller-selected Rizin command."),
}), }),
options: z.object({ options: z.object({
@@ -407,7 +406,9 @@ export const registerReverseEngineeringCommands = (
description: description:
"Run one unrestricted Rizin debugger command in an ephemeral session", "Run one unrestricted Rizin debugger command in an ephemeral session",
args: z.object({ 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 command: z
.string() .string()
.min(1) .min(1)
+13 -1
View File
@@ -61,6 +61,12 @@ export const registerSignalCommands = (
path: ["all"], path: ["all"],
message: "all and limit are mutually exclusive", message: "all and limit are mutually exclusive",
}); });
})
.meta({
not: {
required: ["all", "limit"],
properties: { all: { const: true } },
},
}), }),
run: ({ options: { all, producerLimits, ...options } }) => run: ({ options: { all, producerLimits, ...options } }) =>
withCommandCancellation((signal) => withCommandCancellation((signal) =>
@@ -121,7 +127,13 @@ export const registerSignalCommands = (
.refine( .refine(
withinSignalRecordingSelectorBudget, withinSignalRecordingSelectorBudget,
signalRecordingSelectorBudgetIssue, signalRecordingSelectorBudgetIssue,
), )
.meta({
not: {
required: ["all_lines", "line_limit"],
properties: { all_lines: { const: true } },
},
}),
run: ({ args, options: { all_lines, producerLimits, ...options } }) => run: ({ args, options: { all_lines, producerLimits, ...options } }) =>
withCommandCancellation((signal) => withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => { logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => {
+38 -42
View File
@@ -347,29 +347,45 @@ const registerReferenceSourceCommand = (
cli.command(CLI_COMMANDS.importReferenceSource, { cli.command(CLI_COMMANDS.importReferenceSource, {
description: "Import a source tree as historical reference only", description: "Import a source tree as historical reference only",
args: z.object({ args: z.object({
root: z root: localPathStringSchema.describe(
.string() "Local source directory to import as historical reference",
.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",
),
}), }),
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 }) => run: ({ args, options }) =>
logCliCommand( logCliCommand(
logger, logger,
@@ -379,26 +395,6 @@ const registerReferenceSourceCommand = (
options.excludePaths, options.excludePaths,
); );
if (!excludePaths.ok) return excludePaths.failure; 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); const config = parseConfig(environment);
if (!config.ok) if (!config.ok)
return { return {
@@ -68,3 +68,321 @@ it.each(["draft-2020-12", "draft-07"] as const)(
).toBe(false); ).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]; schema.default = defaults[0];
}; };
const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { /** Schema children, excluding definitions when following validation dependencies. */
if (!isObject(value)) return { schema: value, changed: false }; 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 }; const node: Record<string, unknown> = { ...value };
let changed = false; const changed = owner.changed(value, scope);
const normalizeMap = (map: Readonly<Record<string, unknown>>) => const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries( Object.fromEntries(
Object.entries(map).map(([name, child]) => { Object.entries(map).map(([name, child]) => [
const result = normalizeAdvertisedSchema(child); name,
changed ||= result.changed; normalize(child).schema,
return [name, result.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)) { for (const [key, child] of Object.entries(value)) {
if (schemaMaps.has(key) && isObject(child)) { if (schemaMaps.has(key) && isObject(child)) {
@@ -225,7 +394,7 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
key !== "oneOf" && key !== "oneOf" &&
Array.isArray(child) Array.isArray(child)
) { ) {
node[key] = normalizeList(child).map((result) => result.schema); node[key] = child.map(normalize).map((result) => result.schema);
} else if ( } else if (
schemaChildren.has(key) && schemaChildren.has(key) &&
key !== "not" && key !== "not" &&
@@ -234,23 +403,20 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
key !== "else" key !== "else"
) { ) {
const results = Array.isArray(child) const results = Array.isArray(child)
? normalizeList(child) ? child.map(normalize)
: [normalizeAdvertisedSchema(child)]; : [normalize(child)];
const nestedChanged = results.some((result) => result.changed); const nestedChanged = results.some((result) => result.changed);
if ( if (
key === "contains" && key === "contains" &&
nestedChanged && nestedChanged &&
value.maxContains !== undefined value.maxContains !== undefined
) { ) {
delete node.contains;
delete node.minContains;
delete node.maxContains; delete node.maxContains;
changed = true; node.contains = results[0]?.schema;
} else { } else {
node[key] = Array.isArray(child) node[key] = Array.isArray(child)
? results.map((result) => result.schema) ? results.map((result) => result.schema)
: results[0]?.schema; : results[0]?.schema;
changed ||= nestedChanged;
} }
} }
} }
@@ -258,12 +424,10 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (typeof node.const === "boolean") { if (typeof node.const === "boolean") {
delete node.const; delete node.const;
if (node.type === undefined) node.type = "boolean"; if (node.type === undefined) node.type = "boolean";
changed = true;
} }
if (node.const === "") { if (node.const === "") {
delete node.const; delete node.const;
if (node.type === undefined) node.type = "string"; if (node.type === undefined) node.type = "string";
changed = true;
} }
if ( if (
Array.isArray(node.enum) && Array.isArray(node.enum) &&
@@ -272,7 +436,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
) { ) {
delete node.enum; delete node.enum;
if (node.type === undefined) node.type = "boolean"; if (node.type === undefined) node.type = "boolean";
changed = true;
} }
if (Array.isArray(node.enum) && node.enum.includes("")) { if (Array.isArray(node.enum) && node.enum.includes("")) {
// Removing only the empty member would reject a canonical-valid input. // 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") values.every((item) => typeof item === "string")
) )
node.type = "string"; node.type = "string";
changed = true;
} }
const negated = Object.hasOwn(value, "not") const negated = Object.hasOwn(value, "not")
? normalizeAdvertisedSchema(value.not) ? normalize(value.not)
: undefined; : undefined;
if (negated?.changed) { if (negated?.changed) {
// Widening a predicate under `not` narrows the containing schema. // Widening a predicate under `not` narrows the containing schema.
changed = true;
delete node.not; delete node.not;
} else if (negated !== undefined) { } else if (negated !== undefined) {
node.not = negated.schema; node.not = negated.schema;
} }
const conditional = Object.hasOwn(value, "if") const conditional = Object.hasOwn(value, "if")
? normalizeAdvertisedSchema(value.if) ? normalize(value.if)
: undefined; : undefined;
const thenBranch = Object.hasOwn(value, "then") const thenBranch = Object.hasOwn(value, "then")
? normalizeAdvertisedSchema(value.then) ? normalize(value.then)
: undefined; : undefined;
const elseBranch = Object.hasOwn(value, "else") const elseBranch = Object.hasOwn(value, "else")
? normalizeAdvertisedSchema(value.else) ? normalize(value.else)
: undefined; : undefined;
if (conditional?.changed) { if (conditional?.changed) {
// Changing the predicate can route canonical-valid inputs into a rejecting branch. // Changing the predicate can route canonical-valid inputs into a rejecting branch.
changed = true;
delete node.if; delete node.if;
delete node.then; delete node.then;
delete node.else; delete node.else;
@@ -317,18 +477,16 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
if (conditional !== undefined) node.if = conditional.schema; if (conditional !== undefined) node.if = conditional.schema;
if (thenBranch !== undefined) { if (thenBranch !== undefined) {
node.then = thenBranch.schema; node.then = thenBranch.schema;
changed ||= thenBranch.changed;
} }
if (elseBranch !== undefined) { if (elseBranch !== undefined) {
node.else = elseBranch.schema; node.else = elseBranch.schema;
changed ||= elseBranch.changed;
} }
} }
for (const combinator of ["anyOf", "oneOf"] as const) { for (const combinator of ["anyOf", "oneOf"] as const) {
const branches = value[combinator]; const branches = value[combinator];
if (!Array.isArray(branches)) continue; if (!Array.isArray(branches)) continue;
const normalized = normalizeList(branches); const normalized = branches.map(normalize);
const schemas = normalized.map((result) => result.schema); const schemas = normalized.map((result) => result.schema);
const branchesChanged = normalized.some((result) => result.changed); const branchesChanged = normalized.some((result) => result.changed);
if (combinator === "oneOf" && branchesChanged) { if (combinator === "oneOf" && branchesChanged) {
@@ -337,7 +495,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
node.type = "boolean"; node.type = "boolean";
preserveSharedDefault(node, schemas); preserveSharedDefault(node, schemas);
delete node.oneOf; delete node.oneOf;
changed = true;
continue; continue;
} }
// `anyOf` retains branch guidance without allowing widened branches to // `anyOf` retains branch guidance without allowing widened branches to
@@ -349,7 +506,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
} else { } else {
node.anyOf = schemas; node.anyOf = schemas;
} }
changed = true;
continue; continue;
} }
if ( if (
@@ -360,13 +516,43 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
node.type = "boolean"; node.type = "boolean";
preserveSharedDefault(node, schemas); preserveSharedDefault(node, schemas);
delete node[combinator]; delete node[combinator];
changed = true;
continue; continue;
} }
node[combinator] = schemas; 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 }; return { schema: node, changed };
}; };
+2 -5
View File
@@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { processArgumentSchema } from "../process/processArgument.js";
import { fixedTupleSchema } from "../fixedTupleSchema.js"; import { fixedTupleSchema } from "../fixedTupleSchema.js";
import { import {
boundedProducerLimitsSchema, boundedProducerLimitsSchema,
@@ -9,11 +10,7 @@ import {
localPathStringSchema, localPathStringSchema,
} from "../localPath.js"; } from "../localPath.js";
const argument = z const argument = processArgumentSchema.min(1).max(8192);
.string()
.min(1)
.max(8192)
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
const captureInputShape = z.strictObject({ const captureInputShape = z.strictObject({
producer_limits: boundedProducerLimitsSchema, producer_limits: boundedProducerLimitsSchema,
path: localPathStringSchema.describe("Absolute local capture path"), 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)]); const indexWidth = z.union([z.literal(1), z.literal(2), z.literal(4)]);
/** One explicit local file; detail selects delivery, never decoding coverage. */ /** One explicit local file; detail selects delivery, never decoding coverage. */
export const inspectIl2cppMetadataInputSchema = z.strictObject({ export const inspectIl2cppMetadataInputSchema = z
path: absoluteLocalPathStringSchema.describe( .strictObject({
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file", 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",
), ),
binary_path: absoluteLocalPathStringSchema detail: z
.optional() .enum(["complete", "summary"])
.describe( .default("summary")
"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", .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_architecture: z ),
.enum(["arm64", "amd64"]) binary_path: absoluteLocalPathStringSchema
.optional() .optional()
.describe( .describe(
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise", "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",
), ),
generic_instances: z binary_architecture: z
.boolean() .enum(["arm64", "amd64"])
.optional() .optional()
.describe( .describe(
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path", "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< export type IL2cppMetadataSectionName = z.infer<
typeof il2cppMetadataSectionSchema 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() .string()
.regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL"); .regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL");
/** A selected local filesystem path: nonempty and free of NUL bytes. */ /** A selected local filesystem path: nonempty, NUL-free, and representable without replacement. */
export const localPathStringSchema = nulFreeStringSchema.min(1); 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 * 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. */ /** Selected procedure for function dossier and native API analysis. */
export const nativeProcedureInputSchema = z.strictObject({ 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 { z } from "zod";
import { processArgumentSchema } from "../process/processArgument.js";
import { import {
absoluteLocalPathStringSchema, absoluteLocalPathStringSchema,
localPathStringSchema, localPathStringSchema,
@@ -10,11 +11,7 @@ import {
} from "../producerResourceLimits.js"; } from "../producerResourceLimits.js";
// These strings are single sigrok arguments, never shell fragments. // These strings are single sigrok arguments, never shell fragments.
const selection = z const selection = processArgumentSchema.min(1).max(8192);
.string()
.min(1)
.max(8192)
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
const signalInputShape = z.strictObject({ const signalInputShape = z.strictObject({
producer_limits: boundedProducerLimitsSchema, producer_limits: boundedProducerLimitsSchema,
path: localPathStringSchema.describe("Absolute local recording path"), 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", () => { it("decodes marshal strings with CPython surrogatepass rules", () => {
const report = parse( const report = parse(
withConsts([marshalUnicode(Buffer.from("efbbbf73eda080efbbbf", "hex"))]), 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); 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", () => { it("rejects code CPython treats as corrupt instead of complete coverage", () => {
const file = ( const file = (
layout: "py37" | "py38" | "py311", layout: "py37" | "py38" | "py311",
+17 -83
View File
@@ -332,7 +332,7 @@ class MarshalReader {
readonly #codeKeys = new WeakMap<CodeNode, string>(); readonly #codeKeys = new WeakMap<CodeNode, string>();
readonly #codeIdentities = new WeakMap<CodeNode, number>(); readonly #codeIdentities = new WeakMap<CodeNode, number>();
readonly #codeEquality = new WeakMap<CodeNode, CodeEquality>(); 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>(); readonly #codeHashable = new WeakMap<CodeNode, boolean>();
/** Tuples whose items are still being read; CPython cannot hash them yet. */ /** Tuples whose items are still being read; CPython cannot hash them yet. */
readonly #readingTuples = new WeakSet<MarshalValue>(); readonly #readingTuples = new WeakSet<MarshalValue>();
@@ -436,19 +436,13 @@ class MarshalReader {
}; };
case TYPE_INTERNED: case TYPE_INTERNED:
case TYPE_UNICODE: case TYPE_UNICODE:
return this.string( return this.utf8(this.nonNegativeInt32("Marshal string length"), false);
this.nonNegativeInt32("Marshal string length"),
"utf8",
);
case TYPE_ASCII: case TYPE_ASCII:
case TYPE_ASCII_INTERNED: case TYPE_ASCII_INTERNED:
return this.string( return this.utf8(this.nonNegativeInt32("Marshal string length"), true);
this.nonNegativeInt32("Marshal string length"),
"latin1",
);
case TYPE_SHORT_ASCII: case TYPE_SHORT_ASCII:
case TYPE_SHORT_ASCII_INTERNED: case TYPE_SHORT_ASCII_INTERNED:
return this.string(this.u8(), "latin1"); return this.utf8(this.u8(), true);
case TYPE_TUPLE: case TYPE_TUPLE:
return this.sequenceValue( return this.sequenceValue(
"tuple", "tuple",
@@ -1315,70 +1309,13 @@ class MarshalReader {
#isCodeHashable(node: CodeNode): boolean { #isCodeHashable(node: CodeNode): boolean {
const known = this.#codeHashable.get(node); const known = this.#codeHashable.get(node);
if (known !== undefined) return known; if (known !== undefined) return known;
const frames: { value: MarshalValue; finish: boolean }[] = [ this.#codeHashable.set(node, false);
{ value: { kind: "code", value: node }, finish: false }, const material =
]; this.#codeEquality.get(node) ??
const gray = new Set<MarshalValue | CodeNode>(); malformed("Code object is missing equality material.");
const complete = new Set<MarshalValue>(); const hashable = this.#equalityKey(material.constsTuple, "hash") !== null;
const reject = (): false => { this.#codeHashable.set(node, hashable);
this.#codeHashable.set(node, false); return hashable;
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;
} }
#codeIdentity(node: CodeNode): number { #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); const bytes = this.blob(length);
if (encoding === "latin1") { if (ascii) {
// CPython reads the ASCII-tagged forms as one-byte Unicode, even when if (bytes.some((byte) => byte > 0x7f))
// high bytes appear. Identity must agree with the UTF-8 string form. malformed("Marshal ASCII string contains a non-ASCII byte.");
const value = bytes.toString("latin1");
return { return {
kind: "str", kind: "str",
value, value: bytes.toString("latin1"),
previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT), previewEnd: Math.min(length, PYTHON_PYC_CONST_PREVIEW_LIMIT),
bytes: length, bytes: length,
raw: bytes.some((byte) => byte > 0x7f) raw: bytes,
? Buffer.from(value, "utf8")
: bytes,
}; };
} }
const decoded = decodeMarshalUtf8(bytes, PYTHON_PYC_CONST_PREVIEW_LIMIT); 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({ .object({
args: z args: z
.object({ properties: z.record(z.string(), z.unknown()) }) .object({ properties: z.record(z.string(), z.unknown()) })
.passthrough()
.optional(), .optional(),
options: z options: z
.object({ properties: z.record(z.string(), z.unknown()) }) .object({ properties: z.record(z.string(), z.unknown()) })
.passthrough()
.optional(), .optional(),
}) })
.parse(JSON.parse(stdout)); .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, parser.args.safeParse({ path: "app.apk", className: "" }).success,
).toBe(false); ).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], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER],
] as const; ] as const;
for (const [token, expected] of parsedValues) { 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); expect(parsed?.success).toBe(true);
if (parsed === undefined || !parsed.success) if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`); 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"]) { 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 ajv = new Ajv2020({ strict: false });
const validate = ajv.compile({ const validate = ajv.compile({
@@ -719,13 +731,18 @@ describe("reference-source outline-depth input schema", () => {
$schema: "https://json-schema.org/draft/2020-12/schema", $schema: "https://json-schema.org/draft/2020-12/schema",
}); });
for (const [token] of parsedValues) { for (const [token] of parsedValues) {
const parsed = parser?.safeParse({ outlineDepth: token }); const parsed = parser?.safeParse({
outlineDepth: token,
detail: "summary",
});
if (parsed === undefined || !parsed.success) if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`); throw new Error(`Expected outline-depth token ${token} to parse`);
expect(validate(parsed.data)).toBe(true); expect(validate(parsed.data)).toBe(true);
} }
expect(validate({ outlineDepth: -1 })).toBe(false); expect(validate({ outlineDepth: -1 })).toBe(false);
expect(validate({ outlineDepth: 9007199254740992 })).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" }, 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 }; 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])( it.each([false, true])(
"retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)", "retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)",
async (closeFails) => { async (closeFails) => {
@@ -266,9 +318,9 @@ it.each([false, true])(
...(closeFails ? { cleanupError } : {}), ...(closeFails ? { cleanupError } : {}),
}); });
const result = analysisCliErrorEnvelopeSchema.parse( const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {}).then( await runDirectAnalysis(dependencies, callerPath, "read_bytes", {
projectCliAnalysisResult, address: "0x1000",
), }).then(projectCliAnalysisResult),
); );
const observation = parseEvidence( const observation = parseEvidence(
jsonObjectSchema.parse(result.details).partial_observation, jsonObjectSchema.parse(result.details).partial_observation,
@@ -342,7 +394,9 @@ describe("one-shot finalization cancellation", () => {
async (tool) => { async (tool) => {
const [path] = await createBinarySessionTargets(); const [path] = await createBinarySessionTargets();
const parameters = const parameters =
tool === "analyze_function" ? { procedure: "fixture" } : {}; tool === "analyze_function"
? { procedure: "fixture" }
: { address: "0x1000" };
const original = parseEvidence( const original = parseEvidence(
await runDirectAnalysis( await runDirectAnalysis(
factories().dependencies, factories().dependencies,
@@ -393,7 +447,7 @@ describe("one-shot finalization cancellation", () => {
dependencies, dependencies,
path, path,
"read_bytes", "read_bytes",
{}, { address: "0x1000" },
{ signal: controller.signal }, { signal: controller.signal },
).then(projectCliAnalysisResult); ).then(projectCliAnalysisResult);
expect(result).toMatchObject({ expect(result).toMatchObject({
@@ -420,7 +474,7 @@ describe("one-shot finalization cancellation", () => {
dependencies, dependencies,
path, path,
"read_bytes", "read_bytes",
{}, { address: "0x1000" },
{ signal: controller.signal }, { signal: controller.signal },
).then(projectCliAnalysisResult); ).then(projectCliAnalysisResult);
expect(result).toMatchObject({ expect(result).toMatchObject({
@@ -477,7 +531,7 @@ describe("snapshot publication finalization", () => {
dependencies, dependencies,
path, path,
"read_bytes", "read_bytes",
{}, { address: "0x1000" },
{ signal: controller.signal, snapshotPath }, { signal: controller.signal, snapshotPath },
).then(projectCliAnalysisResult); ).then(projectCliAnalysisResult);
const loaded = await readAnalysisSnapshot(snapshotPath); const loaded = await readAnalysisSnapshot(snapshotPath);
@@ -539,7 +593,7 @@ describe("snapshot publication finalization", () => {
dependencies, dependencies,
path, path,
"read_bytes", "read_bytes",
{}, { address: "0x1000" },
{ signal: controller.signal, snapshotPath: failure.path }, { signal: controller.signal, snapshotPath: failure.path },
).then(projectCliAnalysisResult), ).then(projectCliAnalysisResult),
); );
@@ -574,9 +628,9 @@ it.each([false, true])(
...(closeFails ? { cleanupError } : {}), ...(closeFails ? { cleanupError } : {}),
}); });
const result = analysisCliErrorEnvelopeSchema.parse( const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, path, "read_bytes", {}).then( await runDirectAnalysis(dependencies, path, "read_bytes", {
projectCliAnalysisResult, address: "0x1000",
), }).then(projectCliAnalysisResult),
); );
expect(result).toMatchObject({ expect(result).toMatchObject({
code: "cleanup_incomplete", 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 () => { it("retains successful evidence and reports CLI failure when close fails", async () => {
const [path] = await createBinarySessionTargets(); const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories({ cleanupError }); const { dependencies, session } = factories({ cleanupError });
const result = await runDirectAnalysis( const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
dependencies, address: "0x1000",
path, }).then(projectCliAnalysisResult);
"read_bytes",
{},
).then(projectCliAnalysisResult);
expect(result).toMatchObject({ expect(result).toMatchObject({
error: "Analysis failed", error: "Analysis failed",
code: "cleanup_incomplete", code: "cleanup_incomplete",
@@ -631,12 +682,9 @@ describe("one-shot analysis cleanup outcomes", () => {
cleanupError, cleanupError,
}); });
const primary = projectAnalysisError(primaryError); const primary = projectAnalysisError(primaryError);
const result = await runDirectAnalysis( const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
dependencies, address: "0x1000",
path, }).then(projectCliAnalysisResult);
"read_bytes",
{},
).then(projectCliAnalysisResult);
const callerError = analysisCliErrorEnvelopeSchema.parse(result); const callerError = analysisCliErrorEnvelopeSchema.parse(result);
expect(callerError).toMatchObject({ expect(callerError).toMatchObject({
code: "cleanup_incomplete", code: "cleanup_incomplete",
@@ -663,12 +711,9 @@ describe("one-shot analysis cleanup outcomes", () => {
const { dependencies } = factories({ const { dependencies } = factories({
thrownCleanup: new Error("unexpected close rejection"), thrownCleanup: new Error("unexpected close rejection"),
}); });
const result = await runDirectAnalysis( const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
dependencies, address: "0x1000",
path, }).then(projectCliAnalysisResult);
"read_bytes",
{},
).then(projectCliAnalysisResult);
expect(result).toMatchObject({ expect(result).toMatchObject({
code: "cleanup_incomplete", code: "cleanup_incomplete",
details: { details: {
@@ -686,9 +731,9 @@ describe("one-shot analysis cleanup outcomes", () => {
const thrownExecution = new Error("unexpected execution rejection"); const thrownExecution = new Error("unexpected execution rejection");
const { dependencies } = factories({ thrownExecution, cleanupError }); const { dependencies } = factories({ thrownExecution, cleanupError });
await expect( await expect(
runDirectAnalysis(dependencies, path, "read_bytes", {}).then( runDirectAnalysis(dependencies, path, "read_bytes", {
projectCliAnalysisResult, address: "0x1000",
), }).then(projectCliAnalysisResult),
).rejects.toMatchObject({ ).rejects.toMatchObject({
cause: thrownExecution, cause: thrownExecution,
errors: [thrownExecution, cleanupError], errors: [thrownExecution, cleanupError],
@@ -698,12 +743,9 @@ describe("one-shot analysis cleanup outcomes", () => {
it("keeps successful evidence unchanged after confirmed cleanup", async () => { it("keeps successful evidence unchanged after confirmed cleanup", async () => {
const [path] = await createBinarySessionTargets(); const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories(); const { dependencies, session } = factories();
const result = await runDirectAnalysis( const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
dependencies, address: "0x1000",
path, }).then(projectCliAnalysisResult);
"read_bytes",
{},
).then(projectCliAnalysisResult);
expect(parseEvidence(result)).toMatchObject({ expect(parseEvidence(result)).toMatchObject({
operation: "read_bytes", operation: "read_bytes",
normalized_result: { observation: "retained result" }, normalized_result: { observation: "retained result" },
@@ -65,6 +65,28 @@ const recordingFactories = (
return { dependencies, sessions, clientsClosed: () => clientsClosed }; 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 () => { it("requires a durable summary destination before acquiring a managed session", async () => {
const factories = recordingFactories(); const factories = recordingFactories();
const result = await runProviderAnalysis( const result = await runProviderAnalysis(
@@ -125,11 +147,13 @@ describe("one-shot analysis factory boundary", () => {
const factories = recordingFactories(failure); const factories = recordingFactories(failure);
const interruptListeners = process.listenerCount("SIGINT"); const interruptListeners = process.listenerCount("SIGINT");
const terminateListeners = process.listenerCount("SIGTERM"); const terminateListeners = process.listenerCount("SIGTERM");
await expect( const execution = runDirectAnalysis(
runDirectAnalysis(factories.dependencies, path, "read_bytes", {}).then( factories.dependencies,
projectCliAnalysisResult, path,
), "read_bytes",
).rejects.toBe(failure); admittedReadBytes,
).then(projectCliAnalysisResult);
await expect(execution).rejects.toBe(failure);
expect(factories.clientsClosed()).toBe(1); expect(factories.clientsClosed()).toBe(1);
expect(process.listenerCount("SIGINT")).toBe(interruptListeners); expect(process.listenerCount("SIGINT")).toBe(interruptListeners);
expect(process.listenerCount("SIGTERM")).toBe(terminateListeners); expect(process.listenerCount("SIGTERM")).toBe(terminateListeners);
@@ -247,33 +271,33 @@ describe("one-shot analysis factory boundary", () => {
expect(factories.sessions[0]?.activeTarget()).toBeUndefined(); 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 () => { it("preserves a managed execution profile in direct analysis Evidence", async () => {
const [path] = await createBinarySessionTargets(); const [path] = await createBinarySessionTargets();
const profile = createAnalysisProfile( const profile = createAnalysisProfile(
@@ -307,7 +331,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies, factories.dependencies,
path, path,
"read_bytes", "read_bytes",
{}, admittedReadBytes,
{ snapshotPath }, { snapshotPath },
).then(projectCliAnalysisResult), ).then(projectCliAnalysisResult),
).not.toHaveProperty("code"); ).not.toHaveProperty("code");
@@ -315,7 +339,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies, factories.dependencies,
other, other,
"read_bytes", "read_bytes",
{}, admittedReadBytes,
{ snapshotPath }, { snapshotPath },
).then(projectCliAnalysisResult); ).then(projectCliAnalysisResult);
const sha = async (input: string) => const sha = async (input: string) =>
@@ -344,7 +368,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies, factories.dependencies,
path, path,
"read_bytes", "read_bytes",
{}, admittedReadBytes,
{ snapshotPath }, { snapshotPath },
).then(projectCliAnalysisResult), ).then(projectCliAnalysisResult),
).not.toHaveProperty("code"); ).not.toHaveProperty("code");
@@ -385,7 +409,7 @@ it("retries snapshot file cleanup before final close even when a published ident
dependencies, dependencies,
path, path,
"read_bytes", "read_bytes",
{}, admittedReadBytes,
{ snapshotPath: join(dirname(path), "excluded.snapshot") }, { snapshotPath: join(dirname(path), "excluded.snapshot") },
).then(projectCliAnalysisResult); ).then(projectCliAnalysisResult);
expect(events[0]).toBe("file-cleanup"); expect(events[0]).toBe("file-cleanup");
+26 -6
View File
@@ -101,12 +101,32 @@ const fixture = (
}; };
describe("direct CLI cross-reference selectors", () => { 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 [path] = await createBinarySessionTargets();
const issues = [ const provider = fixture({
{ path: ["address"], reason: "invalid_format" }, xrefsError: new AnalysisInputError("xrefs", undefined, [
{ path: ["extra"], reason: "unknown_argument" }, { path: ["address"], reason: "invalid_format" },
] as const; ]),
});
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({ const provider = fixture({
xrefsError: new AnalysisInputError("xrefs", undefined, issues), xrefsError: new AnalysisInputError("xrefs", undefined, issues),
}); });
@@ -114,7 +134,7 @@ describe("direct CLI cross-reference selectors", () => {
provider.dependencies, provider.dependencies,
path, path,
"xrefs", "xrefs",
{ address: "table_only", extra: true }, { address: "table_only" },
).then(projectCliAnalysisResult); ).then(projectCliAnalysisResult);
expect(result).toMatchObject({ expect(result).toMatchObject({
code: "invalid_request", code: "invalid_request",