Compare commits

..
41 changed files with 592 additions and 1402 deletions

No files matched your search

-7
View File
@@ -651,13 +651,6 @@ be verified. See [MCP contracts](../reference/mcp-contracts.md) for session life
[Ghidra first-query deadlines](../reference/mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
for import, client timeouts and recovery.
One-shot binary analysis validates the selected operation's input before creating
a provider session, opening the target, or replaying a snapshot. Invalid selectors
and option combinations report their input errors before target or provider
availability errors. An already-cancelled request keeps its cancellation reason.
Caller-selected paths and request parameters remain separate from parsed execution
arguments in Evidence.
A one-shot command attempts provider-session and runtime cleanup before reporting
a failure. If cleanup remains incomplete, inspect the reported resources and
follow the error's remediation. [Same-session cleanup retry](../reference/mcp-contracts.md#retained-cleanup-retry),
+2 -19
View File
@@ -143,7 +143,6 @@ import {
type MachoLoadCommandTableHeader,
} from "../domain/apple/machoLoadCommands.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { localPathStringSchema } from "../domain/localPath.js";
import type { JsonValue } from "../domain/jsonValue.js";
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
@@ -174,25 +173,9 @@ export const parseBinaryTarget = async (
): Promise<
Result<BinaryTarget, BinaryTargetError | AnalysisCancelledError>
> => {
const { signal } = options;
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 });
const { signal } = options;
const candidate = absoluteCallerPath(input, { cwd });
// Failures before canonicalization concern the selected path itself; later
// failures concern the canonical path opened with symbolic links rejected.
let stage: TargetFilesystemStage = "resolve";
+55 -56
View File
@@ -1,7 +1,3 @@
import {
admitOneShotOperation,
type DirectAnalysisTool,
} from "./oneShotOperationInput.js";
import { admitCompletedAnalysis } from "./investigation/admitCompletedAnalysis.js";
import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js";
import type { ExecutableFormatHint } from "../domain/dosCom.js";
@@ -100,6 +96,30 @@ import {
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js";
type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "search_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
| "procedure_references"
| "read_function_instructions"
| "inspect_native_instruction"
| "inspect_native_data_type"
| "resolve_native_call_targets"
| "analyze_function"
| "inspect_native_api"
| "inspect_native_dispatch_metadata"
| "search_strings"
| "search_procedures"
| "xrefs"
| "trace_feature"
| "trace_native_ui_action"
| "trace_native_values"
| "decompile_all";
// These one-shot CLI questions require native utilities, not a deep engine.
// Runtime observation retains the ordinary session and authority checks.
const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
@@ -138,30 +158,20 @@ export const runDirectAnalysis = async (
readonly includeEvidenceSources?: boolean;
} = {},
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> =>
withProcessCancellation(options.signal, (signal) => {
const admitted = admitOneShotOperation(tool, arguments_, signal);
if (!admitted.ok)
return Promise.resolve(err(cliError(admitted.error, path)));
return runAnalysis(
dependencies,
path,
admitted.value.name,
admitted.value.input,
{
evidenceParameters: arguments_,
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.includeEvidenceSources === true
? { includeEvidenceSources: true }
: {}),
...targetSelection(options),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
},
);
});
withProcessCancellation(options.signal, (signal) =>
runAnalysis(dependencies, path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.includeEvidenceSources === true
? { includeEvidenceSources: true }
: {}),
...targetSelection(options),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
}),
);
/** How the caller asks an artifact target to be read. */
export interface ArtifactTargetSelection {
@@ -258,7 +268,8 @@ export const runProviderAnalysis = async (
),
);
return withProcessCancellation(signal, async (operationSignal) => {
// Preserve the selected path and original arguments as request facts.
// The selected CLI path is a request fact. Keep it out of provider inputs
// and target identity, which may use the resolved executable instead.
const evidenceParameters = { ...arguments_, input_path: path };
if (isManagedToolName(tool)) {
const original = await runManagedProviderAnalysis(
@@ -298,26 +309,18 @@ export const runProviderAnalysis = async (
);
return result.ok ? ok(result.value) : err(cliError(result.error, path));
}
const admitted = admitOneShotOperation(tool, arguments_, operationSignal);
if (!admitted.ok) return err(cliError(admitted.error, path));
return runAnalysis(
dependencies,
path,
admitted.value.name,
admitted.value.input,
{
logger,
snapshotPath: undefined,
signal: operationSignal,
evidenceParameters,
...targetSelection(selection ?? {}),
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
? { createSession: dependencies.createArtifactBinarySession }
: NATIVE_INSPECTION_OPERATIONS.has(tool)
? { createSession: dependencies.createNativeBinarySession }
: {}),
},
);
return runAnalysis(dependencies, path, tool, arguments_, {
logger,
snapshotPath: undefined,
signal: operationSignal,
evidenceParameters,
...targetSelection(selection ?? {}),
...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool)
? { createSession: dependencies.createArtifactBinarySession }
: NATIVE_INSPECTION_OPERATIONS.has(tool)
? { createSession: dependencies.createNativeBinarySession }
: {}),
});
});
};
@@ -331,8 +334,6 @@ export const runManagedProviderExecution = async (
recoveryParameters?: Readonly<Record<string, JsonValue>>,
): Promise<Result<AnalysisExecution, AnalysisError>> =>
withProcessCancellation(signal, async (operationSignal) => {
const admitted = admitOneShotOperation(tool, parameters, operationSignal);
if (!admitted.ok) return admitted;
const session = await dependencies.createManagedBinarySession();
return withSessionCleanup(
session,
@@ -340,12 +341,10 @@ export const runManagedProviderExecution = async (
const opened = await session.open(path, { signal: operationSignal });
if (!opened.ok) return opened;
return recoveryParameters === undefined
? await session.execute(admitted.value.name, admitted.value.input, {
signal: operationSignal,
})
? await session.execute(tool, parameters, { signal: operationSignal })
: await session.executeForDirectObservation(
admitted.value.name,
admitted.value.input,
tool,
parameters,
recoveryParameters,
{ signal: operationSignal },
);
@@ -5,6 +5,8 @@ import { expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { apiContractSchema } from "../../domain/apiContract/apiContract.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { parseEvidence } from "../../domain/evidence.js";
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { inspectApiContractEvidence } from "./ApiContractService.js";
import * as readOwner from "./ApiContractRead.js";
@@ -317,3 +319,89 @@ it("cancels a cooperative document traversal after the actual verified file read
await resources.close();
}
});
it.each(["between-paths", "after-projection"] as const)(
"preserves authenticated OpenAPI facts on cancellation %s",
async (stage) => {
const bytes = JSON.stringify({
openapi: "3.1.2",
info: { title: "Cancellation", version: "1" },
paths: { "/first": { get: {} }, "/second": { post: {} } },
});
const { path } = await inputFile(bytes);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const realParse = parser.parseApiContractSteps;
try {
const baseline = await inspectApiContractEvidence({ path }, resources);
if (!baseline.ok) throw baseline.error;
const complete = apiContractSchema.parse(
baseline.value.normalized_result,
);
const scheduled = vi
.spyOn(parser, "parseApiContractSteps")
.mockImplementationOnce(function* (...args) {
const steps = realParse(...args);
if (stage === "between-paths") {
expect(steps.next().done).toBe(false);
expect(steps.next().done).toBe(false);
setImmediate(() => controller.abort(stage));
return yield* steps;
}
const result = yield* steps;
setImmediate(() => controller.abort(stage));
return result;
});
try {
const result = await inspectApiContractEvidence({ path }, resources, {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(stage);
const evidence = parseEvidence(result.error.partialObservation);
const retained = apiContractSchema.parse(evidence.normalized_result);
expect(evidence.subject).toEqual(baseline.value.subject);
expect(evidence.parameters).toEqual(baseline.value.parameters);
expect(retained.artifact).toEqual(complete.artifact);
expect(retained.contract_id).toBe(complete.contract_id);
if (stage === "between-paths") {
expect(retained.operations).toEqual(complete.operations.slice(0, 1));
expect(retained.source_coverage.status).toBe("partial");
expect(evidence.evidence_id).not.toBe(baseline.value.evidence_id);
const page = projectAnalysisView(
{
evidenceId: evidence.evidence_id,
operation: evidence.operation,
predicateType: evidence.predicate_type,
normalizedResult: evidence.normalized_result,
limitations: evidence.limitations,
},
{
kind: "api-contract",
collection: "operations",
offset: 0,
limit: 1,
},
);
expect(page).toMatchObject({
ok: true,
value: {
parent_evidence_id: evidence.evidence_id,
items: [{ path: "/first", state: "declared" }],
coverage: { exhausted: true },
source_coverage: { status: "partial" },
},
});
} else {
expect(retained).toEqual(complete);
expect(evidence.evidence_id).toBe(baseline.value.evidence_id);
}
} finally {
scheduled.mockRestore();
}
} finally {
await resources.close();
}
},
);
@@ -11,6 +11,8 @@ import { err, ok, type Result } from "../../domain/result.js";
import {
API_CONTRACT_OPERATION as operation,
API_CONTRACT_PROVIDER,
apiContractSchema,
type ApiContract,
type ApiContractArtifact,
} from "../../domain/apiContract/apiContract.js";
import {
@@ -70,8 +72,32 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) {
return yield* parseApiContractSteps(document, artifact);
}
type OpenApiInput = Extract<
InspectApiContractInput,
{ format: "openapi-json" }
>;
const documentEvidence = (input: OpenApiInput, result: ApiContract): Evidence =>
createEvidence(
{ path: input.path, format: "file", sha256: result.artifact.sha256 },
API_CONTRACT_PROVIDER,
{
operation,
predicateType: "rea.api-contract-inventory",
parameters: {
path: input.path,
format: input.format,
max_file_bytes: input.max_file_bytes,
},
result,
confidence: "observed",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: input.path }],
},
);
const importDocument = async (
input: Extract<InspectApiContractInput, { format: "openapi-json" }>,
input: OpenApiInput,
bytes: Buffer,
sha256: string,
signal?: AbortSignal,
@@ -86,26 +112,16 @@ const importDocument = async (
}),
signal,
);
if (!parsed.ok) return parsed;
const result = parsed.value;
if (!parsed.ok) {
const partial = apiContractSchema.safeParse(
parsed.error.partialObservation,
);
if (partial.success)
parsed.error.partialObservation = documentEvidence(input, partial.data);
return parsed;
}
return completeProviderEvidence(
createEvidence(
{ path: input.path, format: "file", sha256 },
API_CONTRACT_PROVIDER,
{
operation,
predicateType: "rea.api-contract-inventory",
parameters: {
path: input.path,
format: input.format,
max_file_bytes: input.max_file_bytes,
},
result,
confidence: "observed",
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: input.path }],
},
),
documentEvidence(input, parsed.value),
signal,
);
};
@@ -13,9 +13,6 @@ import { err, ok } from "../../domain/result.js";
import { EvidenceFileError } from "../../domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.js";
import { toolContract } from "../../contracts/toolContracts.js";
import { z } from "zod";
import { Ajv2020 } from "ajv/dist/2020.js";
const identity = {
id: "fixture-il2cpp",
@@ -24,29 +21,6 @@ const identity = {
};
const report = createEmptyIl2cppMetadataFixture();
it.each([{ binary_architecture: "arm64" }, { generic_instances: true }])(
"binds binary-dependent selectors in the named input contract: %j",
(selection) => {
const schema = toolContract("inspect_il2cpp_metadata").inputSchema;
const validate = new Ajv2020({ strict: false }).compile(
z.toJSONSchema(schema, { io: "input" }),
);
const missingBinary = { path: report.artifact.path, ...selection };
const selectedBinary = {
...missingBinary,
binary_path: "/artifacts/GameAssembly.dll",
};
expect(schema.safeParse(missingBinary).success).toBe(false);
expect(validate(missingBinary)).toBe(false);
expect(schema.safeParse(selectedBinary).success).toBe(true);
expect(validate(selectedBinary)).toBe(true);
expect(
schema.safeParse({ path: report.artifact.path, generic_instances: false })
.success,
).toBe(true);
},
);
const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) =>
new Il2cppMetadataService({
close: async () => {},
@@ -106,6 +106,32 @@ export class Il2cppMetadataService {
options?.signal,
);
if (!input.ok) return input;
if (
input.value.binary_architecture !== undefined &&
input.value.binary_path === undefined
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["binary_architecture"],
reason: "invalid_value",
message: "binary_architecture selects a slice of binary_path.",
},
]),
);
if (
input.value.generic_instances === true &&
input.value.binary_path === undefined
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["generic_instances"],
reason: "invalid_value",
message: "generic_instances links generic methods in binary_path.",
},
]),
);
if (output !== undefined) {
if (input.value.binary_path === undefined)
return err(
-111
View File
@@ -1,111 +0,0 @@
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 });
};
+2 -1
View File
@@ -215,7 +215,8 @@ export const registerBinaryDiagnosticsCommands = (
maxIntegerTextBytes: cliDecimalField(
inspectAppleIpsInputSchema.shape.max_integer_text_bytes,
),
joinPath: localPathStringSchema
joinPath: z
.string()
.optional()
.describe(
"Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images",
+1 -13
View File
@@ -59,12 +59,6 @@ export const registerCaptureCommands = (
path: ["all"],
message: "all and limit are mutually exclusive",
});
})
.meta({
not: {
required: ["all", "limit"],
properties: { all: { const: true } },
},
}),
run: ({ options: { all, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
@@ -125,13 +119,7 @@ export const registerCaptureCommands = (
.refine(
withinPacketCaptureSelectorBudget,
packetCaptureSelectorBudgetIssue,
)
.meta({
not: {
required: ["all_matches", "match_limit"],
properties: { all_matches: { const: true } },
},
}),
),
run: ({ args, options: { all_matches, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => {
+6 -8
View File
@@ -1,5 +1,3 @@
import { nativeInstructionInputSchema } from "../domain/native/nativeInstruction.js";
import { nativeFunctionAnnotationsInputSchema } from "../domain/native/nativeFunctionAnnotations.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js";
import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js";
@@ -397,9 +395,7 @@ export const registerCoreNativeCommands = (
path: localPathStringSchema.describe(
"Local native executable or app path",
),
address: nativeInstructionInputSchema.shape.address.describe(
"Exact native address",
),
address: z.string().describe("Exact native address"),
}),
options: z.object({
...nativeOpenOptionFields,
@@ -561,9 +557,11 @@ const registerAnnotationCommand = (
description: "Edit function annotations and return refreshed analysis",
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
procedure: nativeFunctionAnnotationsInputSchema.shape.procedure.describe(
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
),
procedure: z
.string()
.describe(
"Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry",
),
}),
options: z.object({
name: z.string().min(1).optional().describe("Analyst function name"),
+8 -4
View File
@@ -263,12 +263,14 @@ const withheldExportOptions = {
.describe(
"Seed the withheld files after all, which needs a seed_sign_in consent in --consent",
),
consent: localPathStringSchema
consent: z
.string()
.optional()
.describe(
"consent.json holding the person's consents, which the bundle ships",
),
withheldCopy: localPathStringSchema
withheldCopy: z
.string()
.optional()
.describe(
"New or empty folder outside the bundle the withheld files are copied into, for whoever supplies them at run time",
@@ -338,7 +340,8 @@ const registerDesktopExportCommand = (
.describe(
"http or https URL the computer downloads the captured --package-file from when the bundle is put, for an application installed from a .deb file",
),
downloadInstall: localPathStringSchema
downloadInstall: z
.string()
.optional()
.describe(
"JSON file naming the AppImage or tarball a build computer unpacked the application from: kind, desktop_entry, url, sha256, architecture, and a tarball's program and name",
@@ -355,7 +358,8 @@ const registerDesktopExportCommand = (
.describe(
"The seed was made for the task, as in a build computer, not taken from the user's own data; the README says so instead of keeping the bundle local",
),
screens: localPathStringSchema
screens: z
.string()
.optional()
.describe(
"Folder holding checkpoint screenshots for screen-judge examples.",
+6 -8
View File
@@ -1,14 +1,9 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js";
import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js";
import { isAbsolute, resolve, sep } from "node:path";
import { Cli, z } from "incur";
import {
localPathStringSchema,
nulFreeStringSchema,
} from "../domain/localPath.js";
import { logCliCommand } from "../cliLogging.js";
import {
@@ -42,6 +37,7 @@ import {
evidenceBundleOption,
runEvidenceInputCommand,
} from "./evidenceInputCommand.js";
import { localPathStringSchema } from "../domain/localPath.js";
/** Register CLI equivalents of the Electron MCP tools. */
export const registerElectronCommands = (
@@ -69,9 +65,11 @@ const registerElectronFuseCommand = (
description:
"Inspect packaged Electron fuse bytes without launching the application",
args: z.object({
path: nulFreeStringSchema.describe(
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
),
path: z
.string()
.describe(
"Local Electron .app, Electron Framework binary, PE executable, or ELF executable",
),
}),
run: ({ args }) =>
withCommandCancellation((signal) =>
+3 -2
View File
@@ -1,7 +1,6 @@
import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js";
import type {
@@ -167,7 +166,9 @@ export const registerFridaCommands = (
.string()
.optional()
.describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`),
scriptFile: localPathStringSchema
scriptFile: z
.string()
.min(1)
.optional()
.describe(
`Path to JavaScript source file. ${exactlyOneScriptSource}`,
+30 -43
View File
@@ -27,49 +27,36 @@ export const registerIl2cppCommands = (
"Local global-metadata.dat (version 24, 27, 29, 31 or 39)",
),
}),
options: z
.object({
...analysisEvidenceDeliveryOptions,
binary: localPathStringSchema
.optional()
.describe(
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
),
architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe("Slice of a universal Mach-O binary"),
genericInstances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
),
ghidraSeeds: localPathStringSchema
.optional()
.describe(
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
),
seedBase: z
.string()
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
.optional()
.describe(
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
),
})
.meta({
dependentRequired: {
architecture: ["binary"],
ghidraSeeds: ["binary"],
seedBase: ["ghidraSeeds"],
},
if: {
properties: { genericInstances: { const: true } },
required: ["genericInstances"],
},
then: { required: ["binary"] },
}),
options: z.object({
...analysisEvidenceDeliveryOptions,
binary: localPathStringSchema
.optional()
.describe(
"Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only",
),
architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe("Slice of a universal Mach-O binary"),
genericInstances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (Type<Args>$$Method<Args>) and include them in --ghidra-seeds; needs --binary",
),
ghidraSeeds: localPathStringSchema
.optional()
.describe(
"Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary",
),
seedBase: z
.string()
.regex(/^0x[0-9a-fA-F]{1,16}$/u)
.optional()
.describe(
"Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => {
+4 -3
View File
@@ -18,7 +18,6 @@ import {
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { CliInstance } from "./types.js";
import { parseNativeMatchingAllowance } from "../config/nativeMatchingAllowance.js";
import { localPathStringSchema } from "../domain/localPath.js";
/** Resolve portable input bundles, then use the same read-only matching service as MCP. */
export const registerNativeFunctionMatchingCommands = (
@@ -38,12 +37,14 @@ export const registerNativeFunctionMatchingCommands = (
}),
options: z.object({
source_bundle: z
.array(localPathStringSchema)
.array(z.string())
.default([])
.describe(
"Canonical source Evidence bundle for retained references; repeat for each input",
),
evidence_output: localPathStringSchema
evidence_output: z
.string()
.min(1)
.optional()
.describe(
"Save full matching Evidence and selected sources; required for default summary; never overwrites",
+1 -3
View File
@@ -33,13 +33,11 @@ const withOmittableDefaults = (schema: z.ZodObject): z.ZodObject => {
fillsOmission(field),
);
if (omittable.length === 0) return schema;
const metadata = schema.meta();
const result = schema.safeExtend(
return schema.safeExtend(
Object.fromEntries(
omittable.map(([key, field]) => [key, optionalWithMetadata(field)]),
),
);
return metadata === undefined ? result : result.meta(metadata);
};
/** Let the object parser decide omission; Incur advertises required outputs. */
+4 -4
View File
@@ -1,6 +1,5 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { createPcapAnalysisProvider } from "../composition/pcap.js";
@@ -54,9 +53,10 @@ export const registerPcapCommands = (
description:
"Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution",
args: z.object({
path: localPathStringSchema.describe(
"Local pcap or pcapng capture file to inspect",
),
path: z
.string()
.min(1)
.describe("Local pcap or pcapng capture file to inspect"),
}),
run: ({ args }) =>
execute(
+9 -10
View File
@@ -1,5 +1,4 @@
import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import type { Logger } from "pino";
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
@@ -152,9 +151,10 @@ export const registerReverseEngineeringCommands = (
description:
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
args: z.object({
path: localPathStringSchema.describe(
"Path to the local artifact to inspect.",
),
path: z
.string()
.min(1)
.describe("Path to the local artifact to inspect."),
operation: z
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
.describe("GNU-style objdump operation to run."),
@@ -274,9 +274,10 @@ export const registerReverseEngineeringCommands = (
cli.command(CLI_COMMANDS.executeRizinCommand, {
description: "Execute one Rizin command against a local artifact",
args: z.object({
path: localPathStringSchema.describe(
"Path to the local artifact to analyze.",
),
path: z
.string()
.min(1)
.describe("Path to the local artifact to analyze."),
command: z.string().min(1).describe("One caller-selected Rizin command."),
}),
options: z.object({
@@ -406,9 +407,7 @@ export const registerReverseEngineeringCommands = (
description:
"Run one unrestricted Rizin debugger command in an ephemeral session",
args: z.object({
path: localPathStringSchema.describe(
"Path to the local artifact to debug.",
),
path: z.string().min(1).describe("Path to the local artifact to debug."),
command: z
.string()
.min(1)
+1 -13
View File
@@ -61,12 +61,6 @@ export const registerSignalCommands = (
path: ["all"],
message: "all and limit are mutually exclusive",
});
})
.meta({
not: {
required: ["all", "limit"],
properties: { all: { const: true } },
},
}),
run: ({ options: { all, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
@@ -127,13 +121,7 @@ export const registerSignalCommands = (
.refine(
withinSignalRecordingSelectorBudget,
signalRecordingSelectorBudgetIssue,
)
.meta({
not: {
required: ["all_lines", "line_limit"],
properties: { all_lines: { const: true } },
},
}),
),
run: ({ args, options: { all_lines, producerLimits, ...options } }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => {
+42 -38
View File
@@ -347,45 +347,29 @@ const registerReferenceSourceCommand = (
cli.command(CLI_COMMANDS.importReferenceSource, {
description: "Import a source tree as historical reference only",
args: z.object({
root: localPathStringSchema.describe(
"Local source directory to import as historical reference",
),
root: z
.string()
.describe("Local source directory to import as historical reference"),
}),
options: z.object({
excludePaths: z
.string()
.optional()
.describe(
"JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.",
),
detail: z
.enum(["full", "summary"])
.default("full")
.describe(
"Complete committed graph or compact historical-source summary",
),
outlineDepth: cliIntegerArgument(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
}),
options: z
.object({
excludePaths: z
.string()
.optional()
.describe(
"JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.",
),
detail: z
.enum(["full", "summary"])
.default("full")
.describe(
"Complete committed graph or compact historical-source summary",
),
outlineDepth: cliIntegerArgument(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
})
.superRefine((options, context) => {
if (options.outlineDepth !== undefined && options.detail !== "summary")
context.addIssue({
code: "custom",
path: ["outlineDepth"],
message: "--outline-depth requires --detail summary",
});
})
.meta({
if: { required: ["outlineDepth"] },
then: {
required: ["detail"],
properties: { detail: { const: "summary" } },
},
}),
run: ({ args, options }) =>
logCliCommand(
logger,
@@ -395,6 +379,26 @@ const registerReferenceSourceCommand = (
options.excludePaths,
);
if (!excludePaths.ok) return excludePaths.failure;
if (
options.outlineDepth !== undefined &&
options.detail !== "summary"
)
return {
error: "Import failed",
...projectAnalysisError(
new AnalysisInputError(
"import-reference-source",
{ userMessage: "--outline-depth requires --detail summary" },
[
{
path: ["outlineDepth"],
reason: "invalid_value",
message: "--outline-depth requires --detail summary",
},
],
),
),
};
const config = parseConfig(environment);
if (!config.ok)
return {
@@ -68,321 +68,3 @@ it.each(["draft-2020-12", "draft-07"] as const)(
).toBe(false);
},
);
const referenceWideningCases: readonly {
name: string;
schema: Record<string, unknown>;
input: unknown;
unchanged?: boolean;
}[] = [
{
name: "oneOf",
schema: {
definitions: { off: { const: false } },
properties: { value: { oneOf: [{ $ref: "#/definitions/off" }, {}] } },
},
input: { value: true },
},
{
name: "not",
schema: {
definitions: { off: { const: false } },
properties: { value: { not: { $ref: "#/definitions/off" } } },
},
input: { value: true },
},
{
name: "if",
schema: {
definitions: { off: { const: false } },
properties: {
value: { if: { $ref: "#/definitions/off" }, then: false, else: true },
},
},
input: { value: true },
},
{
name: "chain",
schema: {
definitions: {
off: { const: false },
alias: { $ref: "#/definitions/off" },
},
properties: { value: { not: { $ref: "#/definitions/alias" } } },
},
input: { value: true },
},
{
name: "recursive affected",
schema: {
definitions: {
node: {
type: "object",
properties: {
flag: { const: false },
next: { $ref: "#/definitions/node" },
},
required: ["flag"],
},
},
properties: { value: { not: { $ref: "#/definitions/node" } } },
},
input: { value: { flag: true } },
},
{
name: "literal target unchanged",
schema: {
default: { predicate: { const: false } },
properties: { value: { not: { $ref: "#/default/predicate" } } },
},
input: { value: true },
unchanged: true,
},
{
name: "nested resource unaffected",
schema: {
$id: "https://example.test/root",
definitions: { flag: { const: false } },
properties: {
value: {
$id: "child",
definitions: { flag: { const: 7 } },
not: { $ref: "#/definitions/flag" },
},
},
},
input: { value: 8 },
unchanged: true,
},
{
name: "recursive unaffected",
schema: {
definitions: {
node: {
type: "object",
properties: {
flag: { const: 7 },
next: { $ref: "#/definitions/node" },
},
required: ["flag"],
},
},
properties: { value: { not: { $ref: "#/definitions/node" } } },
},
input: { value: { flag: 8 } },
unchanged: true,
},
];
it.each(
referenceWideningCases.flatMap((entry) =>
(["draft-2020-12", "draft-07"] as const).map((dialect) => ({
...entry,
dialect,
})),
),
)(
"preserves $name through scoped normalization references in $dialect",
({ name, schema, input, unchanged, dialect }) => {
const canonical: Record<string, unknown> = {
$schema:
dialect === "draft-07"
? "http://json-schema.org/draft-07/schema#"
: "https://json-schema.org/draft/2020-12/schema",
type: "object",
...schema,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const create = () =>
dialect === "draft-07"
? new Ajv({ strict: false })
: new Ajv2020({ strict: false });
expect(create().compile(canonical)(input), `${name} canonical`).toBe(true);
expect(create().compile(advertised)(input), `${name} advertised`).toBe(
true,
);
expect(advertised.default, `${name} literal data`).toEqual(
canonical.default,
);
if (unchanged)
expect(advertised.properties, name).toEqual(canonical.properties);
},
);
it("keeps an upper contains bound from rejecting canonical items after referenced literal widening", () => {
const canonical = {
type: "object",
definitions: { off: { const: false } },
properties: {
values: {
type: "array",
contains: { $ref: "#/definitions/off" },
minContains: 0,
maxContains: 1,
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const input = { values: [false, true] };
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true);
});
it.each([false, true])(
"preserves conditional evaluation annotations (referenced: %s)",
(referenced) => {
const conditional = {
if: { properties: { flag: { const: false } } },
then: { properties: { extra: { type: "string" } } },
};
const canonical = {
type: "object",
definitions: { conditional },
...(referenced ? { $ref: "#/definitions/conditional" } : conditional),
unevaluatedProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const input = { flag: false, extra: "kept" };
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(
true,
);
},
);
it("retains item annotations when only the unsafe contains maximum leaves", () => {
const canonical = {
type: "object",
properties: {
values: {
type: "array",
contains: { const: false },
maxContains: 1,
unevaluatedItems: false,
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const input = { values: [false] };
expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true);
expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true);
expect(advertised).toHaveProperty("properties.values.contains", {
type: "boolean",
});
expect(advertised).toHaveProperty(
"properties.values.unevaluatedItems",
false,
);
});
it("keeps unaffected root property annotations strict when a child conditional widens", () => {
const canonical = {
type: "object",
properties: {
child: {
if: { const: false },
then: { type: "string" },
else: { type: "boolean" },
},
},
unevaluatedProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const validate = new Ajv2020({ strict: false }).compile(advertised);
expect(validate({ child: true })).toBe(true);
expect(validate({ child: true, unknown: "reject" })).toBe(false);
expect(advertised.unevaluatedProperties).toBe(false);
});
it.each(["draft-07", "draft-2020-12"] as const)(
"observes reference sibling semantics in %s",
(dialect) => {
const canonical = {
$schema:
dialect === "draft-07"
? "http://json-schema.org/draft-07/schema#"
: "https://json-schema.org/draft/2020-12/schema",
type: "object",
definitions: {
number: { type: "number" },
alias: { $ref: "#/definitions/number", const: false },
},
properties: { value: { not: { $ref: "#/definitions/alias" } } },
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const create = () =>
dialect === "draft-07"
? new Ajv({ strict: false, ignoreKeywordsWithRef: true })
: new Ajv2020({ strict: false });
// Under the specification's draft7 rules alias is number; in2020 it accepts no value.
const input = { value: "kept" };
expect(create().compile(canonical)(input)).toBe(true);
expect(create().compile(advertised)(input)).toBe(true);
if (dialect === "draft-07")
expect(advertised.properties).toEqual(canonical.properties);
},
);
it.each(["draft-07", "draft-2020-12"] as const)(
"reduces only an affected reference to opaque data in %s",
(dialect) => {
const literal = {
conditional: {
if: { $ref: "#/definitions/off" },
then: false,
else: true,
},
unaffected: { type: "string", minLength: 2 },
};
const canonical = {
$schema:
dialect === "draft-07"
? "http://json-schema.org/draft-07/schema#"
: "https://json-schema.org/draft/2020-12/schema",
type: "object",
definitions: { off: { const: false } },
default: literal,
properties: {
value: { $ref: "#/default/conditional" },
stable: { $ref: "#/default/unaffected" },
},
unevaluatedProperties: false,
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
const create = () =>
dialect === "draft-07"
? new Ajv({ strict: false })
: new Ajv2020({ strict: false });
const input = { value: true, stable: "kept" };
expect(create().compile(canonical)(input)).toBe(true);
const validate = create().compile(advertised);
expect(validate(input)).toBe(true);
expect(validate({ value: true, stable: 1 })).toBe(false);
expect(advertised.default).toEqual(literal);
expect(advertised).toHaveProperty(
"properties.stable",
canonical.properties.stable,
);
expect(advertised).toHaveProperty(
"properties.value.description",
expect.stringContaining("reduced form"),
);
expect(advertised.unevaluatedProperties).toBe(false);
},
);
it("does not treat unused definitions as validation dependencies", () => {
const canonical = {
type: "object",
properties: {
value: {
not: { type: "number", definitions: { unused: { const: false } } },
},
},
};
const advertised = presentInputJsonSchema(canonical, () => undefined);
expect(advertised).toHaveProperty("properties.value.not.type", "number");
const validate = new Ajv2020({ strict: false }).compile(advertised);
expect(validate({ value: "kept" })).toBe(true);
expect(validate({ value: 1 })).toBe(false);
});
+38 -224
View File
@@ -196,194 +196,25 @@ const preserveSharedDefault = (
schema.default = defaults[0];
};
/** Schema children, excluding definitions when following validation dependencies. */
const normalizationChildren = (
schema: Readonly<Record<string, unknown>>,
definitions: boolean,
): unknown[] =>
Object.entries(schema).flatMap(([key, value]) => {
if (schemaMaps.has(key) && isObject(value))
return !definitions && (key === "$defs" || key === "definitions")
? []
: Object.values(value);
if (schemaArrays.has(key) && Array.isArray(value)) return value;
if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value];
return [];
});
const hasNormalizedLiteral = (schema: Readonly<Record<string, unknown>>) =>
typeof schema.const === "boolean" ||
schema.const === "" ||
(Array.isArray(schema.enum) &&
((schema.enum.length > 0 &&
schema.enum.every((item) => typeof item === "boolean")) ||
schema.enum.includes("")));
/** One projection owns actual rewrite sites and their scoped validation dependencies. */
const normalizationContext = (root: Readonly<Record<string, unknown>>) => {
const references = new JsonSchemaReferences(root);
type Fact = {
changed: boolean;
lostEvaluation: boolean;
dependents: Set<Fact>;
evaluationDependents: Set<Fact>;
opaqueReference?: Fact;
};
const sites = new Map<JsonSchemaScope | undefined, WeakSet<object>>();
const pending: { schema: unknown; scope: JsonSchemaScope | undefined }[] = [
{ schema: root, scope: references.rootScope },
];
const entries: {
schema: Readonly<Record<string, unknown>>;
scope: JsonSchemaScope | undefined;
}[] = [];
// Literal payloads are not rewritten, even when a reference evaluates them.
while (pending.length > 0) {
const entry = pending.pop()!;
if (!isObject(entry.schema)) continue;
const scope = references.enter(entry.schema, entry.scope);
let seen = sites.get(scope);
if (seen === undefined) sites.set(scope, (seen = new WeakSet()));
if (seen.has(entry.schema)) continue;
seen.add(entry.schema);
entries.push({ schema: entry.schema, scope });
for (const schema of normalizationChildren(entry.schema, true))
pending.push({ schema, scope });
}
const indexed = new Map<JsonSchemaScope | undefined, WeakMap<object, Fact>>();
const work: {
schema: Readonly<Record<string, unknown>>;
scope: JsonSchemaScope | undefined;
fact: Fact;
}[] = [];
const changed: Fact[] = [];
const factAt = (
schema: unknown,
inherited: JsonSchemaScope | undefined,
): Fact | undefined => {
if (!isObject(schema)) return undefined;
const scope = references.enter(schema, inherited);
let facts = indexed.get(scope);
if (facts === undefined) indexed.set(scope, (facts = new WeakMap()));
const existing = facts.get(schema);
if (existing !== undefined) return existing;
const fact: Fact = {
changed: false,
lostEvaluation: false,
dependents: new Set(),
evaluationDependents: new Set(),
};
facts.set(schema, fact);
work.push({ schema, scope, fact });
return fact;
};
for (const { schema, scope } of entries) factAt(schema, scope);
for (let index = 0; index < work.length; index++) {
const { schema, scope, fact } = work[index]!;
const ignoresSiblings =
scope?.dialect === "draft-07" && typeof schema.$ref === "string";
if (
!ignoresSiblings &&
sites.get(scope)?.has(schema) &&
hasNormalizedLiteral(schema)
) {
fact.changed = true;
changed.push(fact);
}
if (typeof schema.$ref === "string") {
const target = references.resolve(scope, schema.$ref);
if (target.kind === "target") {
const dependency = factAt(target.value, scope);
dependency?.dependents.add(fact);
dependency?.evaluationDependents.add(fact);
if (
dependency !== undefined &&
isObject(target.value) &&
!sites.get(references.enter(target.value, scope))?.has(target.value)
)
fact.opaqueReference = dependency;
}
}
if (!ignoresSiblings)
for (const child of normalizationChildren(schema, false))
factAt(child, scope)?.dependents.add(fact);
if (!ignoresSiblings)
for (const key of [
"allOf",
"anyOf",
"oneOf",
"if",
"then",
"else",
"dependentSchemas",
"dependencies",
]) {
const value = schema[key];
const children = Array.isArray(value)
? value
: schemaMaps.has(key) && isObject(value)
? Object.values(value)
: [value];
for (const child of children)
factAt(child, scope)?.evaluationDependents.add(fact);
}
}
for (let index = 0; index < changed.length; index++)
for (const dependent of changed[index]!.dependents)
if (!dependent.changed) {
dependent.changed = true;
changed.push(dependent);
}
const lost: Fact[] = [];
for (const { schema, scope, fact } of work)
if (
sites.get(scope)?.has(schema) &&
(fact.opaqueReference?.changed ||
(!(scope?.dialect === "draft-07" && typeof schema.$ref === "string") &&
factAt(schema.if, scope)?.changed))
) {
fact.lostEvaluation = true;
lost.push(fact);
}
for (let index = 0; index < lost.length; index++)
for (const dependent of lost[index]!.evaluationDependents)
if (!dependent.lostEvaluation) {
dependent.lostEvaluation = true;
lost.push(dependent);
}
return {
references,
changed: (schema: object, scope: JsonSchemaScope | undefined) =>
indexed.get(scope)?.get(schema)?.changed === true,
lostEvaluation: (schema: object, scope: JsonSchemaScope | undefined) =>
indexed.get(scope)?.get(schema)?.lostEvaluation === true,
reduceReference: (schema: object, scope: JsonSchemaScope | undefined) =>
indexed.get(scope)?.get(schema)?.opaqueReference?.changed === true,
};
};
const normalizeAdvertisedSchema = (
value: unknown,
context?: ReturnType<typeof normalizationContext>,
inherited?: JsonSchemaScope,
): SchemaNormalization => {
const normalizeAdvertisedSchema = (value: unknown): 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 changed = owner.changed(value, scope);
let changed = false;
const normalizeMap = (map: Readonly<Record<string, unknown>>) =>
Object.fromEntries(
Object.entries(map).map(([name, child]) => [
name,
normalize(child).schema,
]),
Object.entries(map).map(([name, child]) => {
const result = normalizeAdvertisedSchema(child);
changed ||= result.changed;
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)) {
if (schemaMaps.has(key) && isObject(child)) {
@@ -394,7 +225,7 @@ const normalizeAdvertisedSchema = (
key !== "oneOf" &&
Array.isArray(child)
) {
node[key] = child.map(normalize).map((result) => result.schema);
node[key] = normalizeList(child).map((result) => result.schema);
} else if (
schemaChildren.has(key) &&
key !== "not" &&
@@ -403,20 +234,23 @@ const normalizeAdvertisedSchema = (
key !== "else"
) {
const results = Array.isArray(child)
? child.map(normalize)
: [normalize(child)];
? normalizeList(child)
: [normalizeAdvertisedSchema(child)];
const nestedChanged = results.some((result) => result.changed);
if (
key === "contains" &&
nestedChanged &&
value.maxContains !== undefined
) {
delete node.contains;
delete node.minContains;
delete node.maxContains;
node.contains = results[0]?.schema;
changed = true;
} else {
node[key] = Array.isArray(child)
? results.map((result) => result.schema)
: results[0]?.schema;
changed ||= nestedChanged;
}
}
}
@@ -424,10 +258,12 @@ const normalizeAdvertisedSchema = (
if (typeof node.const === "boolean") {
delete node.const;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (node.const === "") {
delete node.const;
if (node.type === undefined) node.type = "string";
changed = true;
}
if (
Array.isArray(node.enum) &&
@@ -436,6 +272,7 @@ const normalizeAdvertisedSchema = (
) {
delete node.enum;
if (node.type === undefined) node.type = "boolean";
changed = true;
}
if (Array.isArray(node.enum) && node.enum.includes("")) {
// Removing only the empty member would reject a canonical-valid input.
@@ -447,29 +284,32 @@ const normalizeAdvertisedSchema = (
values.every((item) => typeof item === "string")
)
node.type = "string";
changed = true;
}
const negated = Object.hasOwn(value, "not")
? normalize(value.not)
? normalizeAdvertisedSchema(value.not)
: undefined;
if (negated?.changed) {
// Widening a predicate under `not` narrows the containing schema.
changed = true;
delete node.not;
} else if (negated !== undefined) {
node.not = negated.schema;
}
const conditional = Object.hasOwn(value, "if")
? normalize(value.if)
? normalizeAdvertisedSchema(value.if)
: undefined;
const thenBranch = Object.hasOwn(value, "then")
? normalize(value.then)
? normalizeAdvertisedSchema(value.then)
: undefined;
const elseBranch = Object.hasOwn(value, "else")
? normalize(value.else)
? normalizeAdvertisedSchema(value.else)
: undefined;
if (conditional?.changed) {
// Changing the predicate can route canonical-valid inputs into a rejecting branch.
changed = true;
delete node.if;
delete node.then;
delete node.else;
@@ -477,16 +317,18 @@ const normalizeAdvertisedSchema = (
if (conditional !== undefined) node.if = conditional.schema;
if (thenBranch !== undefined) {
node.then = thenBranch.schema;
changed ||= thenBranch.changed;
}
if (elseBranch !== undefined) {
node.else = elseBranch.schema;
changed ||= elseBranch.changed;
}
}
for (const combinator of ["anyOf", "oneOf"] as const) {
const branches = value[combinator];
if (!Array.isArray(branches)) continue;
const normalized = branches.map(normalize);
const normalized = normalizeList(branches);
const schemas = normalized.map((result) => result.schema);
const branchesChanged = normalized.some((result) => result.changed);
if (combinator === "oneOf" && branchesChanged) {
@@ -495,6 +337,7 @@ const normalizeAdvertisedSchema = (
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node.oneOf;
changed = true;
continue;
}
// `anyOf` retains branch guidance without allowing widened branches to
@@ -506,6 +349,7 @@ const normalizeAdvertisedSchema = (
} else {
node.anyOf = schemas;
}
changed = true;
continue;
}
if (
@@ -516,43 +360,13 @@ const normalizeAdvertisedSchema = (
node.type = "boolean";
preserveSharedDefault(node, schemas);
delete node[combinator];
changed = true;
continue;
}
node[combinator] = schemas;
changed ||= branchesChanged;
}
if (owner.lostEvaluation(value, scope)) {
delete node.unevaluatedProperties;
delete node.unevaluatedItems;
}
if (owner.reduceReference(value, scope)) {
// The target is also caller literal data. Keep that data intact and relax
// this affected reference rather than copy or mutate its executable view.
if (scope?.dialect === "draft-07") {
for (const key of Object.keys(node))
if (
![
"$id",
"$schema",
"$anchor",
"$defs",
"definitions",
"description",
"title",
"default",
"examples",
"$comment",
].includes(key)
)
delete node[key];
} else delete node.$ref;
node.description = [
node.description,
"This referenced input schema is advertised in reduced form; the server validates the complete canonical input.",
]
.filter((text) => typeof text === "string")
.join(" ");
}
return { schema: node, changed };
};
+2
View File
@@ -1,6 +1,7 @@
import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js";
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
import type { ApiContract } from "./apiContract/apiContract.js";
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
import type { AnalysisSettingLimit } from "./analysisSettings.js";
@@ -498,6 +499,7 @@ export type AnalysisPartialObservation =
}
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
| ApiContract
| GraphqlContract
| PlaywrightTrace
| SavedAnalysisSnapshotPartialObservation
+1 -1
View File
@@ -185,7 +185,7 @@ export const projectApiContractView = (
unknowns:
context.source_coverage.status === "partial"
? [
"Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.",
"This inventory is partial. Inspect limitations for coverage and query path-item-references and diagnostics for any retained source pointers.",
]
: [],
};
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js";
import { parseApiContractSteps } from "./parseApiContract.js";
import { AnalysisCancelledError } from "../analysisErrorCore.js";
const artifact: ApiContractArtifact = {
path: "/contract.json",
@@ -305,9 +306,39 @@ it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid
it("bounds expanded identities and source pointers before building a giant row", () => {
const result = parse(
document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }),
document({
"/retained": { get: {} },
[`/${"a".repeat(1_100_000)}`]: { get: {} },
}),
);
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
if (result.ok) throw new Error("Expected a retained-facts refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
const partial = apiContractSchema.parse(result.error.partialObservation);
expect(partial.operations.map((row) => row.path)).toEqual(["/retained"]);
expect(partial.source_coverage.status).toBe("partial");
});
it("retains an interpreted path when cancellation interrupts traversal", () => {
const input = document({
"/first": { get: { operationId: "first" } },
"/second": { get: { operationId: "second" } },
});
const steps = parseApiContractSteps(input, artifact);
for (let checkpoint = 0; checkpoint < 3; checkpoint++)
expect(steps.next().done).toBe(false);
const cancellation = new AnalysisCancelledError("inspect_api_contract");
const stopped = steps.throw(cancellation);
if (!stopped.done || stopped.value.ok)
throw new Error("Expected cancellation");
expect(stopped.value.error).toBe(cancellation);
const partial = apiContractSchema.parse(cancellation.partialObservation);
const complete = report(input);
expect(partial.operations).toEqual(complete.operations.slice(0, 1));
expect(partial.summary.operation_slots).toBe(1);
expect(partial.source_coverage).toMatchObject({
status: "partial",
path_entries_examined: 1,
});
expect(complete.source_coverage.status).toBe("complete-within-scope");
});
+72 -39
View File
@@ -1,4 +1,5 @@
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
AnalysisUnsupportedTargetError,
@@ -316,14 +317,17 @@ function* pathItem(
return ok(undefined);
}
const completedInventory = (
interface ContractReportContext {
readonly openapi: string;
readonly info: ApiContract["info"];
readonly pathsState: ApiContract["source_coverage"]["paths_state"];
examined: number;
}
const contractReport = (
inventory: ContractInventory,
context: {
openapi: string;
info: ApiContract["info"];
pathsState: ApiContract["source_coverage"]["paths_state"];
examined: number;
},
context: ContractReportContext,
incomplete = false,
): ApiContract => {
const artifact = inventory.artifact;
let declared = 0;
@@ -348,7 +352,9 @@ const completedInventory = (
source_coverage: {
scope: "inline-path-operations",
status:
inventory.references.length > 0 || inventory.diagnostics.length > 0
incomplete ||
inventory.references.length > 0 ||
inventory.diagnostics.length > 0
? "partial"
: "complete-within-scope",
paths_state: context.pathsState,
@@ -358,6 +364,11 @@ const completedInventory = (
duplicate_json_members: "last-value-wins",
},
limitations: [
...(incomplete
? [
"Traversal stopped before the inventory was complete. Counts describe the retained rows.",
]
: []),
"Operations are document declarations, not evidence of runtime implementation, reachability or authorization.",
"Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.",
"No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.",
@@ -368,6 +379,32 @@ const completedInventory = (
};
};
function* collectPaths(
sourcePaths: Record<string, unknown>,
inventory: ContractInventory,
context: ContractReportContext,
modern: boolean,
): Generator<void, Result<void, AnalysisError>> {
for (const path in sourcePaths) {
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
yield;
context.examined++;
// Reserve expanded pointer/path strings before escaping or building a row.
const admitted = inventory.reserve(512 + path.length * 16);
if (!admitted.ok) return admitted;
const pointer = `/paths/${pointerPart(path)}`;
const read = path.startsWith("/")
? yield* pathItem(sourcePaths[path], { inventory, path, pointer, modern })
: inventory.diagnostic(
"invalid-path-key",
pointer,
"A Paths entry must start with /; it was not interpreted as an endpoint.",
);
if (!read.ok) return read;
}
return ok(undefined);
}
/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */
export function* parseApiContractSteps(
value: unknown,
@@ -417,36 +454,32 @@ export function* parseApiContractSteps(
);
if (!diagnosed.ok) return diagnosed;
}
let examined = 0;
const sourcePaths = paths ?? {};
for (const path in sourcePaths) {
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
yield;
examined++;
// Reserve expanded pointer/path strings before escaping or building a row.
const admitted = inventory.reserve(512 + path.length * 16);
if (!admitted.ok) return admitted;
const pointer = `/paths/${pointerPart(path)}`;
const read = path.startsWith("/")
? yield* pathItem(sourcePaths[path], {
inventory,
path,
pointer,
modern: version[1] === "2",
})
: inventory.diagnostic(
"invalid-path-key",
pointer,
"A Paths entry must start with /; it was not interpreted as an endpoint.",
);
if (!read.ok) return read;
const context: ContractReportContext = {
openapi: root.openapi,
info: info.value,
pathsState,
examined: 0,
};
const interrupted = (error: AnalysisError) => {
const retainedRows =
inventory.operations.length +
inventory.references.length +
inventory.diagnostics.length;
if (retainedRows > 0)
error.retainPartialObservation(contractReport(inventory, context, true));
return err(error);
};
try {
const read = yield* collectPaths(
paths ?? {},
inventory,
context,
version[1] === "2",
);
if (!read.ok) return interrupted(read.error);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return interrupted(cause);
throw cause;
}
return ok(
completedInventory(inventory, {
openapi: root.openapi,
info: info.value,
pathsState,
examined,
}),
);
return ok(contractReport(inventory, context));
}
+5 -2
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { processArgumentSchema } from "../process/processArgument.js";
import { fixedTupleSchema } from "../fixedTupleSchema.js";
import {
boundedProducerLimitsSchema,
@@ -10,7 +9,11 @@ import {
localPathStringSchema,
} from "../localPath.js";
const argument = processArgumentSchema.min(1).max(8192);
const argument = z
.string()
.min(1)
.max(8192)
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
const captureInputShape = z.strictObject({
producer_limits: boundedProducerLimitsSchema,
path: localPathStringSchema.describe("Absolute local capture path"),
+27 -51
View File
@@ -89,58 +89,34 @@ const optionalIndex = z.number().int().min(-1);
const indexWidth = z.union([z.literal(1), z.literal(2), z.literal(4)]);
/** One explicit local file; detail selects delivery, never decoding coverage. */
export const inspectIl2cppMetadataInputSchema = z
.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
export const inspectIl2cppMetadataInputSchema = z.strictObject({
path: absoluteLocalPathStringSchema.describe(
"Absolute filesystem path to a Unity IL2CPP global-metadata.dat file",
),
detail: z
.enum(["complete", "summary"])
.default("summary")
.describe(
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
),
detail: z
.enum(["complete", "summary"])
.default("summary")
.describe(
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
),
binary_path: absoluteLocalPathStringSchema
.optional()
.describe(
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
),
binary_architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe(
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
),
generic_instances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path",
),
})
.superRefine((input, context) => {
if (input.binary_path !== undefined) return;
if (input.binary_architecture !== undefined)
context.addIssue({
code: "custom",
path: ["binary_architecture"],
message: "binary_architecture selects a slice of binary_path.",
});
if (input.generic_instances === true)
context.addIssue({
code: "custom",
path: ["generic_instances"],
message: "generic_instances links generic methods in binary_path.",
});
})
.meta({
dependentRequired: { binary_architecture: ["binary_path"] },
if: {
properties: { generic_instances: { const: true } },
required: ["generic_instances"],
},
then: { required: ["binary_path"] },
});
binary_path: absoluteLocalPathStringSchema
.optional()
.describe(
"Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed",
),
binary_architecture: z
.enum(["arm64", "amd64"])
.optional()
.describe(
"Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise",
),
generic_instances: z
.boolean()
.optional()
.describe(
"Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type<Args>$$Method<Args> names; needs binary_path",
),
});
export type IL2cppMetadataSectionName = z.infer<
typeof il2cppMetadataSectionSchema
-13
View File
@@ -64,16 +64,3 @@ 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,
);
}
});
+2 -7
View File
@@ -16,13 +16,8 @@ export const nulFreeStringSchema = z
.string()
.regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL");
/** A selected local filesystem path: nonempty, NUL-free, and representable without replacement. */
export const localPathStringSchema = nulFreeStringSchema
.min(1)
.regex(
/^[^\uD800-\uDFFF]*$/u,
"Local filesystem paths must contain well-formed Unicode",
);
/** A selected local filesystem path: nonempty and free of NUL bytes. */
export const localPathStringSchema = nulFreeStringSchema.min(1);
/**
* A Windows absolute path: rooted, UNC, or drive-qualified. The classes spell
+1 -1
View File
@@ -2,5 +2,5 @@ import { z } from "zod";
/** Selected procedure for function dossier and native API analysis. */
export const nativeProcedureInputSchema = z.strictObject({
procedure: z.string().min(1).describe("A procedure name or address"),
procedure: z.string().describe("A procedure name or address"),
});
-6
View File
@@ -1,6 +0,0 @@
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");
+5 -2
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { processArgumentSchema } from "../process/processArgument.js";
import {
absoluteLocalPathStringSchema,
localPathStringSchema,
@@ -11,7 +10,11 @@ import {
} from "../producerResourceLimits.js";
// These strings are single sigrok arguments, never shell fragments.
const selection = processArgumentSchema.min(1).max(8192);
const selection = z
.string()
.min(1)
.max(8192)
.refine((value) => !value.includes("\0"), "NUL is not an argument character");
const signalInputShape = z.strictObject({
producer_limits: boundedProducerLimitsSchema,
path: localPathStringSchema.describe("Absolute local recording path"),
@@ -189,34 +189,3 @@ 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,11 +27,9 @@ const serve = async (cli: CliInstance, argv: readonly string[]) => {
.object({
args: z
.object({ properties: z.record(z.string(), z.unknown()) })
.passthrough()
.optional(),
options: z
.object({ properties: z.record(z.string(), z.unknown()) })
.passthrough()
.optional(),
})
.parse(JSON.parse(stdout));
@@ -165,115 +163,3 @@ it("advertises the domain constraints for android, adb settings, and browser sel
parser.args.safeParse({ path: "app.apk", className: "" }).success,
).toBe(false);
});
it("advertises selection dependencies after omittable-default projection", async () => {
const cli = createCli({});
const ajv = new Ajv2020({ strict: false, validateFormats: false });
for (const [command, invalid, valid] of [
[
"inspect-packet-fields",
{ all: true, limit: 1 },
{ all: false, limit: 1 },
],
[
"inspect-signal-decoders",
{ all: true, limit: 1 },
{ all: false, limit: 1 },
],
[
"inspect-packet-capture",
{ fields: ["ip.src"], all_matches: true, match_limit: 1 },
{ fields: ["ip.src"], all_matches: false, match_limit: 1 },
],
[
"decode-signal-recording",
{ decoders: "uart", all_lines: true, line_limit: 1 },
{ decoders: "uart", all_lines: false, line_limit: 1 },
],
[
"inspect-il2cpp-metadata",
{ genericInstances: true },
{ genericInstances: false },
],
[
"inspect-il2cpp-metadata",
{ architecture: "arm64" },
{ binary: "lib.dylib", architecture: "arm64" },
],
[
"inspect-il2cpp-metadata",
{ ghidraSeeds: "seeds.txt" },
{ binary: "lib.so", ghidraSeeds: "seeds.txt" },
],
[
"inspect-il2cpp-metadata",
{ seedBase: "0x1000" },
{ binary: "lib.so", ghidraSeeds: "seeds.txt", seedBase: "0x1000" },
],
[
"import-reference-source",
{ outlineDepth: 2, detail: "full" },
{ outlineDepth: 2, detail: "summary" },
],
] as const) {
const schema = (await serve(cli, [command, "--schema", "--json"])).options;
expect(schema, command).toBeDefined();
const validate = ajv.compile(schema as AnySchema);
expect(
validate(invalid),
`${command} invalid ${JSON.stringify(invalid)}`,
).toBe(false);
expect(validate(valid), `${command} valid ${JSON.stringify(valid)}`).toBe(
true,
);
}
});
it("rejects invalid selected paths before inspecting a target", async () => {
const cli = createCli({});
for (const [args, field, code] of [
[
["inspect-apple-ips", "missing.ips", "--join-path="],
"joinPath",
"too_small",
],
[
["match-native-functions", "missing.json", "--evidence_output="],
"evidence_output",
"too_small",
],
[
["match-native-functions", "missing.json", "--evidence_output=bad\0path"],
"evidence_output",
"invalid_format",
],
[
[
"match-native-functions",
"missing.json",
"--evidence_output=bad\ud800path",
],
"evidence_output",
"invalid_format",
],
[
["match-native-functions", "missing.json", "--source_bundle="],
"source_bundle.0",
"too_small",
],
[["import-reference-source", ""], "root", "too_small"],
] as const) {
let stdout = "";
await cli.serve([...args, "--json"], {
env: {},
exit: () => {},
stdout: (text) => {
stdout += text;
},
});
expect(JSON.parse(stdout), args.join(" ")).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [{ path: field, code }],
});
}
});
@@ -703,27 +703,15 @@ describe("reference-source outline-depth input schema", () => {
[String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER],
] as const;
for (const [token, expected] of parsedValues) {
const parsed = parser?.safeParse({
outlineDepth: token,
detail: "summary",
});
const parsed = parser?.safeParse({ outlineDepth: token });
expect(parsed?.success).toBe(true);
if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`);
expect(parsed.data).toMatchObject({
detail: "summary",
outlineDepth: expected,
});
expect(parsed.data).toMatchObject({ outlineDepth: expected });
}
for (const token of ["", "-1", "9007199254740992"]) {
expect(
parser?.safeParse({ outlineDepth: token, detail: "summary" }).success,
).toBe(false);
expect(parser?.safeParse({ outlineDepth: token }).success).toBe(false);
}
expect(parser?.safeParse({ outlineDepth: "2" }).success).toBe(false);
expect(
parser?.safeParse({ outlineDepth: "2", detail: "full" }).success,
).toBe(false);
const ajv = new Ajv2020({ strict: false });
const validate = ajv.compile({
@@ -731,18 +719,13 @@ describe("reference-source outline-depth input schema", () => {
$schema: "https://json-schema.org/draft/2020-12/schema",
});
for (const [token] of parsedValues) {
const parsed = parser?.safeParse({
outlineDepth: token,
detail: "summary",
});
const parsed = parser?.safeParse({ outlineDepth: token });
if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`);
expect(validate(parsed.data)).toBe(true);
}
expect(validate({ outlineDepth: -1 })).toBe(false);
expect(validate({ outlineDepth: 9007199254740992 })).toBe(false);
expect(validate({ outlineDepth: 2 })).toBe(false);
expect(validate({ outlineDepth: 2, detail: "full" })).toBe(false);
});
});
@@ -1818,20 +1818,3 @@ it("refuses a declared DOS relocation header that is not present in the file", a
error: { reason: "truncated DOS MZ load module" },
});
});
it("does not resolve malformed Unicode paths to a replacement-character sibling", async () => {
const directory = await createTestTempDirectory("rea-target-unicode-");
const actual = join(directory, "target-�");
const selected = join(directory, "target-\ud800");
await writeFile(actual, pe(0xaa64));
const refused = await parseBinaryTarget(selected);
expect(refused).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
path: selected,
reason: "Local filesystem paths must contain well-formed Unicode",
},
});
expect(await parseBinaryTarget(actual)).toMatchObject({ ok: true });
});
+38 -80
View File
@@ -121,58 +121,6 @@ const factories = (
return { dependencies, session };
};
it("rejects malformed one-shot operation inputs before creating a session", async () => {
const { dependencies, session } = factories();
const create = vi.fn(() => session);
const guarded = {
...dependencies,
createBinarySession: create,
createNativeBinarySession: create,
createArtifactBinarySession: create,
createManagedBinarySession: create,
};
for (const result of [
await runDirectAnalysis(guarded, "/missing", "inspect_native_instruction", {
address: "",
}),
await runDirectAnalysis(guarded, "/missing", "annotate_native_function", {
procedure: "main",
}),
await runDirectAnalysis(guarded, "/missing", "trace_feature", {
query: "",
}),
await runDirectAnalysis(guarded, "/missing", "analyze_function", {
procedure: "",
}),
await runDirectAnalysis(guarded, "/missing", "inspect_native_api", {
procedure: "",
}),
await runProviderAnalysis(guarded, "/missing", "observe_native_calls", {
breakpoints: [],
}),
await runProviderAnalysis(guarded, "/missing", "inspect_asset_catalog", {
limit: -1,
}),
]) {
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toMatchObject({ code: "invalid_request" });
}
const managed = await runManagedProviderExecution(
guarded,
"/missing",
"inspect_managed_members",
undefined,
{ type_names: ["Duplicate", "Duplicate"] },
);
expect(managed.ok).toBe(false);
if (!managed.ok)
expect(projectAnalysisError(managed.error)).toMatchObject({
code: "invalid_request",
});
expect(create).not.toHaveBeenCalled();
});
it.each([false, true])(
"retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)",
async (closeFails) => {
@@ -318,9 +266,9 @@ it.each([false, true])(
...(closeFails ? { cleanupError } : {}),
});
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult),
await runDirectAnalysis(dependencies, callerPath, "read_bytes", {}).then(
projectCliAnalysisResult,
),
);
const observation = parseEvidence(
jsonObjectSchema.parse(result.details).partial_observation,
@@ -394,9 +342,7 @@ describe("one-shot finalization cancellation", () => {
async (tool) => {
const [path] = await createBinarySessionTargets();
const parameters =
tool === "analyze_function"
? { procedure: "fixture" }
: { address: "0x1000" };
tool === "analyze_function" ? { procedure: "fixture" } : {};
const original = parseEvidence(
await runDirectAnalysis(
factories().dependencies,
@@ -447,7 +393,7 @@ describe("one-shot finalization cancellation", () => {
dependencies,
path,
"read_bytes",
{ address: "0x1000" },
{},
{ signal: controller.signal },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
@@ -474,7 +420,7 @@ describe("one-shot finalization cancellation", () => {
dependencies,
path,
"read_bytes",
{ address: "0x1000" },
{},
{ signal: controller.signal },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
@@ -531,7 +477,7 @@ describe("snapshot publication finalization", () => {
dependencies,
path,
"read_bytes",
{ address: "0x1000" },
{},
{ signal: controller.signal, snapshotPath },
).then(projectCliAnalysisResult);
const loaded = await readAnalysisSnapshot(snapshotPath);
@@ -593,7 +539,7 @@ describe("snapshot publication finalization", () => {
dependencies,
path,
"read_bytes",
{ address: "0x1000" },
{},
{ signal: controller.signal, snapshotPath: failure.path },
).then(projectCliAnalysisResult),
);
@@ -628,9 +574,9 @@ it.each([false, true])(
...(closeFails ? { cleanupError } : {}),
});
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult),
await runDirectAnalysis(dependencies, path, "read_bytes", {}).then(
projectCliAnalysisResult,
),
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
@@ -655,9 +601,12 @@ describe("one-shot analysis cleanup outcomes", () => {
it("retains successful evidence and reports CLI failure when close fails", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories({ cleanupError });
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
error: "Analysis failed",
code: "cleanup_incomplete",
@@ -682,9 +631,12 @@ describe("one-shot analysis cleanup outcomes", () => {
cleanupError,
});
const primary = projectAnalysisError(primaryError);
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
const callerError = analysisCliErrorEnvelopeSchema.parse(result);
expect(callerError).toMatchObject({
code: "cleanup_incomplete",
@@ -711,9 +663,12 @@ describe("one-shot analysis cleanup outcomes", () => {
const { dependencies } = factories({
thrownCleanup: new Error("unexpected close rejection"),
});
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
code: "cleanup_incomplete",
details: {
@@ -731,9 +686,9 @@ describe("one-shot analysis cleanup outcomes", () => {
const thrownExecution = new Error("unexpected execution rejection");
const { dependencies } = factories({ thrownExecution, cleanupError });
await expect(
runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult),
runDirectAnalysis(dependencies, path, "read_bytes", {}).then(
projectCliAnalysisResult,
),
).rejects.toMatchObject({
cause: thrownExecution,
errors: [thrownExecution, cleanupError],
@@ -743,9 +698,12 @@ describe("one-shot analysis cleanup outcomes", () => {
it("keeps successful evidence unchanged after confirmed cleanup", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories();
const result = await runDirectAnalysis(dependencies, path, "read_bytes", {
address: "0x1000",
}).then(projectCliAnalysisResult);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
).then(projectCliAnalysisResult);
expect(parseEvidence(result)).toMatchObject({
operation: "read_bytes",
normalized_result: { observation: "retained result" },
@@ -65,28 +65,6 @@ const recordingFactories = (
return { dependencies, sessions, clientsClosed: () => clientsClosed };
};
/** Schema-valid read_bytes input so lifecycle tests reach the session. */
const admittedReadBytes = { address: "0x1000" } as const;
/** Schema-valid inputs for the ordinary native session route. */
const admittedNativeSessionInput = {
observe_native_ui: { pid: 123, window_id: 456 },
capture_native_ui_scenario: {
pid: 123,
window_id: 456,
steps: [{ kind: "wait", milliseconds: 100 }],
},
observe_native_calls: {
breakpoints: [
{
kind: "objc-method",
class_name: "NSURLSession",
selector: "dataTaskWithRequest:completionHandler:",
},
],
},
};
it("requires a durable summary destination before acquiring a managed session", async () => {
const factories = recordingFactories();
const result = await runProviderAnalysis(
@@ -147,13 +125,11 @@ describe("one-shot analysis factory boundary", () => {
const factories = recordingFactories(failure);
const interruptListeners = process.listenerCount("SIGINT");
const terminateListeners = process.listenerCount("SIGTERM");
const execution = runDirectAnalysis(
factories.dependencies,
path,
"read_bytes",
admittedReadBytes,
).then(projectCliAnalysisResult);
await expect(execution).rejects.toBe(failure);
await expect(
runDirectAnalysis(factories.dependencies, path, "read_bytes", {}).then(
projectCliAnalysisResult,
),
).rejects.toBe(failure);
expect(factories.clientsClosed()).toBe(1);
expect(process.listenerCount("SIGINT")).toBe(interruptListeners);
expect(process.listenerCount("SIGTERM")).toBe(terminateListeners);
@@ -271,32 +247,32 @@ describe("one-shot analysis factory boundary", () => {
expect(factories.sessions[0]?.activeTarget()).toBeUndefined();
},
);
});
it.each([
"observe_native_ui",
"capture_native_ui_scenario",
"observe_native_calls",
] satisfies readonly NativeToolName[])(
"keeps %s on the ordinary session route",
async (tool) => {
const [path] = await createBinarySessionTargets();
const factories = recordingFactories();
const result = await runProviderAnalysis(
{
...factories.dependencies,
createNativeBinarySession: () => {
throw new Error("static inspection factory must stay unused");
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);
},
);
path,
tool,
{},
).then(projectCliAnalysisResult);
expect(result).toMatchObject({ operation: tool });
expect(factories.clientsClosed()).toBe(1);
},
);
});
it("preserves a managed execution profile in direct analysis Evidence", async () => {
const [path] = await createBinarySessionTargets();
@@ -331,7 +307,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies,
path,
"read_bytes",
admittedReadBytes,
{},
{ snapshotPath },
).then(projectCliAnalysisResult),
).not.toHaveProperty("code");
@@ -339,7 +315,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies,
other,
"read_bytes",
admittedReadBytes,
{},
{ snapshotPath },
).then(projectCliAnalysisResult);
const sha = async (input: string) =>
@@ -368,7 +344,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover
factories.dependencies,
path,
"read_bytes",
admittedReadBytes,
{},
{ snapshotPath },
).then(projectCliAnalysisResult),
).not.toHaveProperty("code");
@@ -409,7 +385,7 @@ it("retries snapshot file cleanup before final close even when a published ident
dependencies,
path,
"read_bytes",
admittedReadBytes,
{},
{ snapshotPath: join(dirname(path), "excluded.snapshot") },
).then(projectCliAnalysisResult);
expect(events[0]).toBe("file-cleanup");
+6 -26
View File
@@ -101,32 +101,12 @@ const fixture = (
};
describe("direct CLI cross-reference selectors", () => {
it("rejects unknown arguments before opening a session", async () => {
it("retains validation issues unrelated to symbol resolution", async () => {
const [path] = await createBinarySessionTargets();
const provider = fixture({
xrefsError: new AnalysisInputError("xrefs", undefined, [
{ path: ["address"], reason: "invalid_format" },
]),
});
const result = await runDirectAnalysis(
provider.dependencies,
path,
"xrefs",
{ address: "table_only", extra: true },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
code: "invalid_request",
details: {
operation: "xrefs",
issues: [{ path: ["extra"], reason: "unknown_argument" }],
},
});
expect(provider.closed()).toBe(0);
});
it("retains provider validation issues unrelated to symbol resolution", async () => {
const [path] = await createBinarySessionTargets();
const issues = [{ path: ["address"], reason: "invalid_format" }] as const;
const issues = [
{ path: ["address"], reason: "invalid_format" },
{ path: ["extra"], reason: "unknown_argument" },
] as const;
const provider = fixture({
xrefsError: new AnalysisInputError("xrefs", undefined, issues),
});
@@ -134,7 +114,7 @@ describe("direct CLI cross-reference selectors", () => {
provider.dependencies,
path,
"xrefs",
{ address: "table_only" },
{ address: "table_only", extra: true },
).then(projectCliAnalysisResult);
expect(result).toMatchObject({
code: "invalid_request",