From f3b697da5a698a5548afd3b19d044ec623bdda0b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 04:30:18 +0800 Subject: [PATCH] refactor(cli): derive contract-backed command options from the tool contract (#10571) * refactor(cli): derive contract-backed command options from the tool contract Each contract-backed CLI command restated its contract's input fields as hand-written incur options and mapped them back to snake_case input with conditional spreads. Nothing checked that the two schemas agreed. Add registerContractCommand: a per-command projection names only what the CLI spells differently (positionals, caller-relative path fields, option renames, CLI help wording, omitted optional fields with a reason). Option schemas, decimal argv tokens and the contract's cross-field rules are derived from the contract input schema, and parsed options map back to the contract input mechanically. Migrate inspect-sqlite-database and the thirteen flat binary-diagnostics inspectors. Their --schema output is unchanged apart from property order, and help lists options in contract order. Delete the SQLite superRefine that mirrored the contract and the per-command spreads. Add a parity test: every projected command exposes each contract field or declares its omission, and each field admits exactly what the contract admits for empty, NUL, relative-path, bound and numeric-spelling probes. * refactor(cli): refuse unprojected contract input forms at registration Address review findings on the contract projection: - Bind inspect-sqlite-database to its contract by name instead of catalog position. - Accept only field forms argv can carry (string, number, boolean, enum, and arrays of strings or enums) instead of rejecting a short list, so arrays of objects, tuples and other forms fail at registration. - Refuse root input keywords other than the projected object constraints and presentation annotations, so a contract rule such as `not` cannot disappear from --schema. * refactor(cli): describe contract commands independently of the CLI framework Split the contract projection into a framework-neutral description and a thin Incur adapter, so a later REA-owned CLI layer (#10583) can render the same description. - contractCliDescription.ts derives each positional and option from the contract and projection: key and short flag, help, argv admission schema, boolean-flag/repeatable/decimal/caller-path facts, object constraints renamed to CLI keys, the optional argv-time cross-field check, and the mapping back to contract input. - contractCommand.ts only renders that description as Incur args and options and runs the handler. Cross-field rules now refuse at argv parsing only when a projection asks (inspect-sqlite-database does, keeping its behavior); otherwise the service's contract parse refuses them as before. Exact-optional fields are projected as optional because argv cannot carry an explicit undefined and Incur's help reads flag and choice types only through optional. The parity guard reads the descriptions, and also checks that boolean fields parse as bare flags and that no projected field stays exact-optional. --- docs/development/tool-design.md | 12 + src/cli/binaryDiagnosticsCommands.ts | 849 +++++------------- src/cli/contractCliDescription.ts | 414 +++++++++ src/cli/contractCommand.ts | 167 ++++ src/cli/sqliteCommands.ts | 169 +--- .../cli/cliContractCommandParity.test.ts | 265 ++++++ 6 files changed, 1145 insertions(+), 731 deletions(-) create mode 100644 src/cli/contractCliDescription.ts create mode 100644 src/cli/contractCommand.ts create mode 100644 tests/boundary/cli/cliContractCommandParity.test.ts diff --git a/docs/development/tool-design.md b/docs/development/tool-design.md index 3159dadb75..3e7e3f4adf 100644 --- a/docs/development/tool-design.md +++ b/docs/development/tool-design.md @@ -578,6 +578,18 @@ which some clients require even alongside `prefixItems`. Tuples with a rest schema also need explicit minimum lengths. Check parser/schema parity for empty, short, overlong, and malformed values in both input and output projections. +A CLI command for a tool contract is described by `describeContractCommand` in +[`contractCliDescription.ts`](https://github.com/morluto/rea/blob/main/src/cli/contractCliDescription.ts) +and rendered for the CLI framework by `registerContractCommand` in +[`contractCommand.ts`](https://github.com/morluto/rea/blob/main/src/cli/contractCommand.ts). +The projection names only what the CLI spells differently: positionals, +caller-relative path fields, option renames and short flags, CLI help wording, +omitted optional fields with a reason, and whether the contract's cross-field +rules refuse at argv parsing or at the service's contract parse. Field schemas +and decimal argv tokens are derived, and +`tests/boundary/cli/cliContractCommandParity.test.ts` checks that each projected +command admits what its contract admits. + CLI translations must reuse the canonical semantic fields, enum membership and list bounds. Document transport differences such as relative path spelling and JSON text decoding; reject empty selections before path normalization. Preserve diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 8209bda6cf..2d047440a0 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -1,8 +1,4 @@ -import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { inspectDevicetreeInputSchema } from "../domain/firmware/devicetree.js"; -import { inspectPeLoadConfigInputSchema } from "../domain/native/peLoadConfig.js"; -import { inspectBinaryHardeningInputSchema } from "../domain/native/binaryHardening.js"; -import { inspectByteEntropyInputSchema } from "../domain/native/byteEntropy.js"; import { z } from "incur"; import { cliDecimalField } from "./options.js"; import { producerLimitsOption } from "./producerLimitsOption.js"; @@ -33,20 +29,8 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js"; import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js"; -import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js"; -import { inspectPeCertificatesInputSchema } from "../domain/native/peCertificates.js"; -import { inspectPeImportsInputSchema } from "../domain/native/peImports.js"; -import { inspectWindowsShortcutInputSchema } from "../domain/native/windowsShortcut.js"; import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; -import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; -import { inspectNativeInitializersInputSchema } from "../domain/native/nativeInitializers.js"; -import { machoSliceArchitectureSchema } from "../domain/apple/machoTargetSelection.js"; -import { inspectElfUnwindRangesInputSchema } from "../domain/native/elfUnwindRanges.js"; -import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js"; -import { inspectKernelModuleInputSchema } from "../domain/native/kernelModule.js"; -import { inspectKernelBuildConfigInputSchema } from "../domain/native/kernelBuildConfig.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { withEvidenceCallerInputPath } from "../domain/evidence.js"; import { BYTE_ENTROPY_PROVIDER } from "../domain/investigationProviders.js"; import { localPathStringSchema } from "../domain/localPath.js"; @@ -55,6 +39,18 @@ import { analysisEvidenceDeliveryOptions, deliverCliAnalysisEvidence, } from "./analysisEvidenceDelivery.js"; +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../contracts/native/binaryDiagnosticsToolContracts.js"; +import { contractNamed } from "./contractCliDescription.js"; +import { + inspectWithService, + registerContractCommand, +} from "./contractCommand.js"; + +type BinaryDiagnosticsOperation = + (typeof BINARY_DIAGNOSTICS_TOOL_CONTRACTS)[number]["name"]; + +const contract = (name: Name) => + contractNamed(BINARY_DIAGNOSTICS_TOOL_CONTRACTS, name); /** Inspect operator-selected files through the same shared workflow as MCP. */ export const registerBinaryDiagnosticsCommands = ( @@ -62,600 +58,235 @@ export const registerBinaryDiagnosticsCommands = ( logger: Logger, environment: Readonly, ): void => { - cli.command(CLI_COMMANDS.inspectElfNotes, { - description: - "Inspect ELF PT_NOTE and SHT_NOTE identities without executing the image", - args: z.object({ - path: localPathStringSchema.describe("Local ELF32/ELF64 image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectElfNotesInputSchema.shape.max_file_bytes, - ), - maxEntries: cliDecimalField(inspectElfNotesInputSchema.shape.max_entries), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const elf = createElfNotesService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectElfNotes, - async () => - finishCliEvidenceCommand( - args.path, - () => - elf.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_entries: options.maxEntries, - }, - { signal }, - ), - elf, - "rea-elf-notes", - "inspect_elf_notes", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectNativeInitializers, { - description: - "List ELF and Mach-O initializer and finalizer functions without executing the image", - args: z.object({ - path: localPathStringSchema.describe( - "Local ELF32/ELF64, thin Mach-O or universal Mach-O image", - ), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectNativeInitializersInputSchema.shape.max_file_bytes, - ), - offset: cliDecimalField( - inspectNativeInitializersInputSchema.shape.offset, - ), - limit: cliDecimalField(inspectNativeInitializersInputSchema.shape.limit), - architecture: machoSliceArchitectureSchema - .optional() - .describe( - "Slice to read from a universal Mach-O, by CPU family; when several slices share a family, arm64 (arm64 or arm64v8), arm64e (arm64e or arm64e.x1) or x86_64 names the subtype, and a value matching several slices is refused; required for universal files", - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const initializers = createNativeInitializersService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectNativeInitializers, - async () => - finishCliEvidenceCommand( - args.path, - () => - initializers.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - offset: options.offset, - limit: options.limit, - ...(options.architecture === undefined - ? {} - : { architecture: options.architecture }), - }, - { signal }, - ), - initializers, - "rea-native-initializers", - "inspect_native_initializers", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - - cli.command(CLI_COMMANDS.inspectElfUnwindRanges, { - description: - "Inspect ELF unwind function ranges without executing the image", - args: z.object({ - path: localPathStringSchema.describe("Local ELF32/ELF64 image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectElfUnwindRangesInputSchema.shape.max_file_bytes, - ), - maxRanges: cliDecimalField( - inspectElfUnwindRangesInputSchema.shape.max_ranges, - ), - offset: cliDecimalField(inspectElfUnwindRangesInputSchema.shape.offset), - limit: cliDecimalField(inspectElfUnwindRangesInputSchema.shape.limit), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const unwind = createElfUnwindRangesService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectElfUnwindRanges, - async () => - finishCliEvidenceCommand( - args.path, - () => - unwind.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_ranges: options.maxRanges, - offset: options.offset, - limit: options.limit, - }, - { signal }, - ), - unwind, - "rea-elf-unwind-ranges", - "inspect_elf_unwind_ranges", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectAppleIps, { - description: - "Inspect an Apple .ips crash report without executing a target", - args: z.object({ - path: localPathStringSchema.describe("Local Apple .ips crash report"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectAppleIpsInputSchema.shape.max_file_bytes, - ), - maxThreads: cliDecimalField(inspectAppleIpsInputSchema.shape.max_threads), - maxFrames: cliDecimalField(inspectAppleIpsInputSchema.shape.max_frames), - maxImages: cliDecimalField(inspectAppleIpsInputSchema.shape.max_images), - maxRegisters: cliDecimalField( - inspectAppleIpsInputSchema.shape.max_registers, - ), - maxIntegerTextBytes: cliDecimalField( - inspectAppleIpsInputSchema.shape.max_integer_text_bytes, - ), - joinPath: localPathStringSchema - .optional() - .describe( - "Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images", - ), - maxJoinBytes: cliDecimalField( - inspectAppleIpsInputSchema.shape.max_join_bytes, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const ips = createAppleIpsService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectAppleIps, - async () => - finishCliEvidenceCommand( - args.path, - () => - ips.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_threads: options.maxThreads, - max_frames: options.maxFrames, - max_images: options.maxImages, - max_registers: options.maxRegisters, - max_integer_text_bytes: options.maxIntegerTextBytes, - ...(options.joinPath === undefined - ? {} - : { join_path: absoluteCallerPath(options.joinPath) }), - max_join_bytes: options.maxJoinBytes, - }, - { signal }, - ), - ips, - "rea-apple-ips", - "inspect_apple_ips", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectKernelModule, { - description: - "Inspect Linux kernel module metadata without executing the module", - args: z.object({ - path: localPathStringSchema.describe( - "Local ELF32/ELF64 kernel module or compressed .ko container", - ), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectKernelModuleInputSchema.shape.max_file_bytes, - ), - maxEntries: cliDecimalField( - inspectKernelModuleInputSchema.shape.max_entries, - ), - maxModinfoKeyBytes: cliDecimalField( - inspectKernelModuleInputSchema.shape.max_modinfo_key_bytes, - ), - maxModinfoValueBytes: cliDecimalField( - inspectKernelModuleInputSchema.shape.max_modinfo_value_bytes, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const kernelModule = createKernelModuleService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectKernelModule, - async () => - finishCliEvidenceCommand( - args.path, - () => - kernelModule.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_entries: options.maxEntries, - max_modinfo_key_bytes: options.maxModinfoKeyBytes, - max_modinfo_value_bytes: options.maxModinfoValueBytes, - }, - { signal }, - ), - kernelModule, - "rea-kernel-module", - "inspect_kernel_module", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectKernelBuildConfig, { - description: - "Inspect a Linux kernel image's version banner and embedded build config without executing it", - args: z.object({ - path: localPathStringSchema.describe( - "Local vmlinux ELF, raw Image, x86 bzImage/zImage, or configs.ko", - ), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectKernelBuildConfigInputSchema.shape.max_file_bytes, - ), - maxEntries: cliDecimalField( - inspectKernelBuildConfigInputSchema.shape.max_entries, - ), - maxConfigBytes: cliDecimalField( - inspectKernelBuildConfigInputSchema.shape.max_config_bytes, - ), - maxPayloadBytes: cliDecimalField( - inspectKernelBuildConfigInputSchema.shape.max_payload_bytes, - ), - offset: cliDecimalField(inspectKernelBuildConfigInputSchema.shape.offset), - limit: cliDecimalField(inspectKernelBuildConfigInputSchema.shape.limit), - name: inspectKernelBuildConfigInputSchema.shape.name, - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const kernelBuildConfig = createKernelBuildConfigService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectKernelBuildConfig, - async () => - finishCliEvidenceCommand( - args.path, - () => - kernelBuildConfig.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_entries: options.maxEntries, - max_config_bytes: options.maxConfigBytes, - max_payload_bytes: options.maxPayloadBytes, - offset: options.offset, - limit: options.limit, - ...(options.name === undefined - ? {} - : { name: options.name }), - }, - { signal }, - ), - kernelBuildConfig, - "rea-kernel-build-config", - "inspect_kernel_build_config", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectPeLoadConfig, { - description: - "Inspect PE TLS callbacks and load configuration without executing the image", - args: z.object({ - path: localPathStringSchema.describe("Local PE32/PE32+ image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectPeLoadConfigInputSchema.shape.max_file_bytes, - ), - maxCallbacks: cliDecimalField( - inspectPeLoadConfigInputSchema.shape.max_callbacks, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const elf = createPeLoadConfigService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectPeLoadConfig, - async () => - finishCliEvidenceCommand( - args.path, - () => - elf.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_callbacks: options.maxCallbacks, - }, - { signal }, - ), - elf, - "rea-pe-load-config", - "inspect_pe_load_config", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectBinaryHardening, { - description: - "Inspect ELF, PE, and Mach-O hardening without executing the image", - args: z.object({ - path: localPathStringSchema.describe("Local ELF, PE, or Mach-O image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectBinaryHardeningInputSchema.shape.max_file_bytes, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const hardening = createBinaryHardeningService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectBinaryHardening, - async () => - finishCliEvidenceCommand( - args.path, - () => - hardening.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - }, - { signal }, - ), - hardening, - "rea-binary-hardening", - "inspect_binary_hardening", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); + registerContractCommand( + cli, + logger, + contract("inspect_elf_notes"), + { + command: CLI_COMMANDS.inspectElfNotes, + description: + "Inspect ELF PT_NOTE and SHT_NOTE identities without executing the image", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local ELF32/ELF64 image" } }, + }, + inspectWithService(createElfNotesService, "rea-elf-notes"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_native_initializers"), + { + command: CLI_COMMANDS.inspectNativeInitializers, + description: + "List ELF and Mach-O initializer and finalizer functions without executing the image", + positionals: ["path"], + callerPaths: ["path"], + fields: { + path: { + description: + "Local ELF32/ELF64, thin Mach-O or universal Mach-O image", + }, + architecture: { + description: + "Slice to read from a universal Mach-O, by CPU family; when several slices share a family, arm64 (arm64 or arm64v8), arm64e (arm64e or arm64e.x1) or x86_64 names the subtype, and a value matching several slices is refused; required for universal files", + }, + }, + }, + inspectWithService( + createNativeInitializersService, + "rea-native-initializers", + ), + ); + registerContractCommand( + cli, + logger, + contract("inspect_elf_unwind_ranges"), + { + command: CLI_COMMANDS.inspectElfUnwindRanges, + description: + "Inspect ELF unwind function ranges without executing the image", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local ELF32/ELF64 image" } }, + }, + inspectWithService(createElfUnwindRangesService, "rea-elf-unwind-ranges"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_apple_ips"), + { + command: CLI_COMMANDS.inspectAppleIps, + description: + "Inspect an Apple .ips crash report without executing a target", + positionals: ["path"], + callerPaths: ["path", "join_path"], + fields: { + path: { description: "Local Apple .ips crash report" }, + join_path: { + description: + "Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images", + }, + }, + }, + inspectWithService(createAppleIpsService, "rea-apple-ips"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_kernel_module"), + { + command: CLI_COMMANDS.inspectKernelModule, + description: + "Inspect Linux kernel module metadata without executing the module", + positionals: ["path"], + callerPaths: ["path"], + fields: { + path: { + description: + "Local ELF32/ELF64 kernel module or compressed .ko container", + }, + }, + }, + inspectWithService(createKernelModuleService, "rea-kernel-module"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_kernel_build_config"), + { + command: CLI_COMMANDS.inspectKernelBuildConfig, + description: + "Inspect a Linux kernel image's version banner and embedded build config without executing it", + positionals: ["path"], + callerPaths: ["path"], + fields: { + path: { + description: + "Local vmlinux ELF, raw Image, x86 bzImage/zImage, or configs.ko", + }, + }, + }, + inspectWithService( + createKernelBuildConfigService, + "rea-kernel-build-config", + ), + ); + registerContractCommand( + cli, + logger, + contract("inspect_pe_load_config"), + { + command: CLI_COMMANDS.inspectPeLoadConfig, + description: + "Inspect PE TLS callbacks and load configuration without executing the image", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local PE32/PE32+ image" } }, + }, + inspectWithService(createPeLoadConfigService, "rea-pe-load-config"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_binary_hardening"), + { + command: CLI_COMMANDS.inspectBinaryHardening, + description: + "Inspect ELF, PE, and Mach-O hardening without executing the image", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local ELF, PE, or Mach-O image" } }, + }, + inspectWithService(createBinaryHardeningService, "rea-binary-hardening"), + ); const entropy = createByteEntropyService(); - cli.command(CLI_COMMANDS.inspectByteEntropy, { - description: - "Inspect file-wide and block byte entropy without executing the file", - args: z.object({ path: localPathStringSchema.describe("Local file") }), - options: z.object({ - // Decimal tokens only: numeric coercion would read "" as 0 and accept - // "0x10" or "1e2". - blockSize: cliDecimalField( - inspectByteEntropyInputSchema.shape.block_size, - ).describe("Power-of-two block size from 256 to 65536 bytes"), - offset: cliDecimalField( - inspectByteEntropyInputSchema.shape.offset, - ).describe("Window start as a decimal file byte offset"), - length: cliDecimalField( - inspectByteEntropyInputSchema.shape.length, - ).describe("Window length in bytes; omit to extend through EOF"), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectByteEntropy, async () => { - return await finishCliEvidenceCommand( - args.path, - () => - entropy.inspect( - { - path: absoluteCallerPath(args.path), - block_size: options.blockSize, - offset: options.offset, - length: options.length, - }, - { signal }, - ), - entropy, - BYTE_ENTROPY_PROVIDER.id, - "inspect_byte_entropy", - signal, - ).then(projectCliAnalysisResult); - }), - ), - }); - cli.command(CLI_COMMANDS.inspectPeCertificates, { - description: - "Inspect PE Authenticode certificate table identities without verifying signatures", - args: z.object({ - path: localPathStringSchema.describe("Local PE32/PE32+ image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectPeCertificatesInputSchema.shape.max_file_bytes, - ), - maxEntries: cliDecimalField( - inspectPeCertificatesInputSchema.shape.max_entries, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const pe = createPeCertificatesService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectPeCertificates, - async () => - finishCliEvidenceCommand( - args.path, - () => - pe.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_entries: options.maxEntries, - }, - { signal }, - ), - pe, - "rea-pe-certificates", - "inspect_pe_certificates", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectPeImports, { - description: - "Inspect PE import and delay-load table identities without executing the image", - args: z.object({ - path: localPathStringSchema.describe("Local PE32/PE32+ image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectPeImportsInputSchema.shape.max_file_bytes, - ), - maxDlls: cliDecimalField(inspectPeImportsInputSchema.shape.max_dlls), - maxFunctions: cliDecimalField( - inspectPeImportsInputSchema.shape.max_functions, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const pe = createPeImportsService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectPeImports, - async () => - finishCliEvidenceCommand( - args.path, - () => - pe.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_dlls: options.maxDlls, - max_functions: options.maxFunctions, - }, - { signal }, - ), - pe, - "rea-pe-imports", - "inspect_pe_imports", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectWindowsShortcut, { - description: - "Inspect a Windows Shell Link (.lnk) without resolving or executing the target", - args: z.object({ - path: localPathStringSchema.describe( - "Local Windows Shell Link (.lnk) file", - ), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectWindowsShortcutInputSchema.shape.max_file_bytes, - ), - maxIdListItems: cliDecimalField( - inspectWindowsShortcutInputSchema.shape.max_id_list_items, - ), - maxExtraDataBlocks: cliDecimalField( - inspectWindowsShortcutInputSchema.shape.max_extra_data_blocks, - ), - maxStringBytes: cliDecimalField( - inspectWindowsShortcutInputSchema.shape.max_string_bytes, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const shortcut = createWindowsShortcutService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectWindowsShortcut, - async () => - finishCliEvidenceCommand( - args.path, - () => - shortcut.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_id_list_items: options.maxIdListItems, - max_extra_data_blocks: options.maxExtraDataBlocks, - max_string_bytes: options.maxStringBytes, - }, - { signal }, - ), - shortcut, - "rea-windows-shortcut", - "inspect_windows_shortcut", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); - cli.command(CLI_COMMANDS.inspectPeResources, { - description: - "Inspect PE resource identities, payload ranges, hashes and icon-group relationships", - args: z.object({ - path: localPathStringSchema.describe("Local PE32/PE32+ image"), - }), - options: z.object({ - maxFileBytes: cliDecimalField( - inspectPeResourcesInputSchema.shape.max_file_bytes, - ), - maxEntries: cliDecimalField( - inspectPeResourcesInputSchema.shape.max_entries, - ), - }), - run: ({ args, options }) => - withCommandCancellation(async (signal) => { - const pe = createPeResourcesService(); - return await logCliCommand( - logger, - CLI_COMMANDS.inspectPeResources, - async () => - finishCliEvidenceCommand( - args.path, - () => - pe.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_entries: options.maxEntries, - }, - { signal }, - ), - pe, - "rea-pe-resources", - "inspect_pe_resources", - signal, - ).then(projectCliAnalysisResult), - ); - }), - }); + registerContractCommand( + cli, + logger, + contract("inspect_byte_entropy"), + { + command: CLI_COMMANDS.inspectByteEntropy, + description: + "Inspect file-wide and block byte entropy without executing the file", + positionals: ["path"], + callerPaths: ["path"], + fields: { + path: { description: "Local file" }, + block_size: { + description: "Power-of-two block size from 256 to 65536 bytes", + }, + offset: { description: "Window start as a decimal file byte offset" }, + length: { + description: "Window length in bytes; omit to extend through EOF", + }, + }, + omit: { + detail: + "One-shot output is complete Evidence; this command has no summary delivery", + }, + }, + inspectWithService(() => entropy, BYTE_ENTROPY_PROVIDER.id), + ); + registerContractCommand( + cli, + logger, + contract("inspect_pe_certificates"), + { + command: CLI_COMMANDS.inspectPeCertificates, + description: + "Inspect PE Authenticode certificate table identities without verifying signatures", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local PE32/PE32+ image" } }, + }, + inspectWithService(createPeCertificatesService, "rea-pe-certificates"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_pe_imports"), + { + command: CLI_COMMANDS.inspectPeImports, + description: + "Inspect PE import and delay-load table identities without executing the image", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local PE32/PE32+ image" } }, + }, + inspectWithService(createPeImportsService, "rea-pe-imports"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_windows_shortcut"), + { + command: CLI_COMMANDS.inspectWindowsShortcut, + description: + "Inspect a Windows Shell Link (.lnk) without resolving or executing the target", + positionals: ["path"], + callerPaths: ["path"], + fields: { + path: { description: "Local Windows Shell Link (.lnk) file" }, + }, + }, + inspectWithService(createWindowsShortcutService, "rea-windows-shortcut"), + ); + registerContractCommand( + cli, + logger, + contract("inspect_pe_resources"), + { + command: CLI_COMMANDS.inspectPeResources, + description: + "Inspect PE resource identities, payload ranges, hashes and icon-group relationships", + positionals: ["path"], + callerPaths: ["path"], + fields: { path: { description: "Local PE32/PE32+ image" } }, + }, + inspectWithService(createPeResourcesService, "rea-pe-resources"), + ); cli.command(CLI_COMMANDS.inspectDevicetree, { description: "Inspect a flattened devicetree blob without executing it", args: z.object({ diff --git a/src/cli/contractCliDescription.ts b/src/cli/contractCliDescription.ts new file mode 100644 index 0000000000..ac8637edd3 --- /dev/null +++ b/src/cli/contractCliDescription.ts @@ -0,0 +1,414 @@ +import { z } from "zod"; + +import { localPathStringSchema } from "../domain/localPath.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { cliDecimalShape } from "./options.js"; + +/** + * Framework-neutral CLI description of a tool contract. This module owns + * which contract fields become positionals and options, their spellings, + * help and argv admission, and the mapping back to contract input. A CLI + * framework adapter (today `contractCommand.ts` for Incur) only renders it. + */ + +/** The parts of a tool contract that a CLI projection reads. */ +export interface ProjectedContract { + readonly name: Name; + readonly inputSchema: z.ZodObject; +} + +/** A contract input field name. */ +export type ContractField = keyof z.input< + Contract["inputSchema"] +> & + string; + +/** How one contract field is spelled on the CLI. */ +export interface CliFieldProjection { + /** Parameter key, in camelCase. Omit to use the camelCase contract field. */ + readonly name?: string; + /** Single-character short flag. */ + readonly alias?: string; + /** CLI help, when it differs from the contract field's description. */ + readonly description?: string; +} + +/** + * The CLI spelling of one contract. Everything not listed here is derived + * from the contract input schema: each field keeps its own schema, so the + * CLI admits what the contract admits. + */ +export interface CliContractProjection { + readonly command: string; + readonly description: string; + /** Fields taken as positional arguments, in argv order. */ + readonly positionals?: readonly Field[]; + /** + * Absolute-path fields the caller may spell relative to the working + * directory. The CLI admits a nonempty, NUL-free, well-formed path, then + * resolves it with `absoluteCallerPath` before the contract sees it. + */ + readonly callerPaths?: readonly Field[]; + readonly fields?: { readonly [Key in Field]?: CliFieldProjection }; + /** + * Optional fields this one-shot command does not expose, each with the + * reason. The contract's own default applies. + */ + readonly omit?: { readonly [Key in Field]?: string }; + /** + * Where the contract's cross-field rules first refuse a combination. + * `argv` reports them as CLI validation errors naming CLI keys; by default + * the service's contract parse reports them as an `invalid_request`. + */ + readonly crossFieldRules?: "argv"; +} + +/** One positional argument or option of a contract command. */ +export interface ContractCliParameter { + /** The contract input field this parameter supplies. */ + readonly field: string; + /** camelCase key; an option's flag is its kebab-case spelling. */ + readonly key: string; + readonly positional: boolean; + readonly alias: string | undefined; + readonly description: string | undefined; + /** + * Admission of the parsed argv value: the contract field's own schema, + * with decimal integer tokens and caller-relative paths admitted. + */ + readonly schema: z.ZodType; + /** A bare flag sets true; it takes no value. */ + readonly booleanFlag: boolean; + /** The option may repeat; each occurrence adds one array element. */ + readonly repeatable: boolean; + /** Integer values are spelled as decimal digits only. */ + readonly decimal: boolean; + /** Resolved with `absoluteCallerPath` before the contract sees it. */ + readonly callerPath: boolean; +} + +/** One cross-field refusal, naming CLI keys. */ +export interface ContractCliIssue { + readonly path: readonly PropertyKey[]; + readonly message: string; + readonly issue: z.core.$ZodIssue; +} + +/** A complete, framework-neutral CLI command derived from one contract. */ +export interface ContractCliDescription { + readonly command: string; + readonly description: string; + readonly contract: ProjectedContract; + readonly parameters: readonly ContractCliParameter[]; + /** Contract fields the command does not expose, with the reason. */ + readonly omitted: Readonly>; + /** Object-level JSON Schema constraints, renamed to CLI keys. */ + readonly optionConstraints: Readonly> | undefined; + /** + * The contract's cross-field rules over parsed option values, when the + * projection refuses them at argv parsing; undefined otherwise. + */ + readonly checkOptions: + | (( + options: Readonly>, + ) => readonly ContractCliIssue[]) + | undefined; + /** Map parsed values, keyed by parameter key, to the contract input. */ + readonly toContractInput: ( + values: Readonly>, + ) => Record; + /** Each caller-path field as the caller spelled it, before resolution. */ + readonly callerInputs: ( + values: Readonly>, + ) => Readonly>; +} + +/** One contract from a contract group, keeping its exact schema types. */ +export const contractNamed = < + Contracts extends readonly ProjectedContract[], + Name extends Contracts[number]["name"], +>( + contracts: Contracts, + name: Name, +): Extract => { + const contract = contracts.find( + ( + candidate, + ): candidate is Extract => + candidate.name === name, + ); + if (contract === undefined) throw new Error(`Missing tool contract: ${name}`); + return contract; +}; + +const camelCase = (field: string): string => + field.replaceAll(/_([a-z0-9])/gu, (_, letter: string) => + letter.toUpperCase(), + ); + +/** The contract object admits this field's key being absent. */ +const admitsOmission = (field: z.ZodType): boolean => + z.object({ value: field }).safeParse({}).success; + +const core = (value: z.ZodType): z.ZodType => { + let current = value; + while ( + current instanceof z.ZodOptional || + current instanceof z.ZodExactOptional || + current instanceof z.ZodDefault || + current instanceof z.ZodPrefault || + current instanceof z.ZodNullable || + current instanceof z.ZodReadonly + ) + current = current.unwrap() as z.ZodType; + return current; +}; + +/** + * Accept only field forms argv can carry, so a new contract field cannot + * reach the CLI with an unreviewed spelling. + */ +const assertArgvField = ( + contract: string, + field: string, + schema: z.ZodType, +) => { + const current = core(schema); + const scalar = + current instanceof z.ZodString || + current instanceof z.ZodNumber || + current instanceof z.ZodBoolean || + current instanceof z.ZodEnum; + const element = + current instanceof z.ZodArray ? core(current.element as z.ZodType) : null; + if ( + !scalar && + !(element instanceof z.ZodString || element instanceof z.ZodEnum) + ) + throw new Error( + `${contract}.${field} has no argv projection; declare one before exposing it on the CLI`, + ); +}; + +/** + * Argv cannot carry an explicit undefined, so an exact-optional field admits + * the same values as an optional one. Renderers such as Incur's help read + * boolean flags and enum choices only through `optional`. + */ +const argvOptional = (field: z.ZodType): z.ZodType => { + if (!(field instanceof z.ZodExactOptional)) return field; + const optional = (field.unwrap() as z.ZodType).optional(); + return field.description === undefined + ? optional + : optional.describe(field.description); +}; + +const OBJECT_CONSTRAINTS = ["dependentRequired", "if", "then", "else"] as const; + +/** Contract-level presentation that CLI help states in its own words. */ +const ROOT_ANNOTATIONS: ReadonlySet = new Set([ + "description", + "title", + "examples", +]); + +/** Rename the field names inside the object constraints the contract advertises. */ +const renameConstraint = ( + contract: string, + value: unknown, + rename: (field: string) => string, +): unknown => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error(`${contract} has an unsupported object constraint`); + return Object.fromEntries( + Object.entries(value).map(([keyword, inner]) => { + if (keyword === "required" && Array.isArray(inner)) + return [keyword, inner.map((field: string) => rename(field))]; + if (keyword === "properties" && typeof inner === "object" && inner) + return [ + keyword, + Object.fromEntries( + Object.entries(inner).map(([field, schema]) => [ + rename(field), + schema, + ]), + ), + ]; + throw new Error( + `${contract} object constraint keyword ${keyword} has no CLI projection`, + ); + }), + ); +}; + +const projectObjectConstraints = ( + contract: ProjectedContract, + rename: (field: string) => string, +): Record | undefined => { + const metadata = contract.inputSchema.meta(); + if (metadata === undefined) return undefined; + for (const keyword of Object.keys(metadata)) + if ( + !ROOT_ANNOTATIONS.has(keyword) && + !(OBJECT_CONSTRAINTS as readonly string[]).includes(keyword) + ) + throw new Error( + `${contract.name} input keyword ${keyword} has no CLI projection`, + ); + const projected: Record = {}; + for (const keyword of OBJECT_CONSTRAINTS) { + const value = metadata[keyword]; + if (value === undefined) continue; + projected[keyword] = + keyword === "dependentRequired" + ? Object.fromEntries( + Object.entries(value as Record).map( + ([field, required]) => [rename(field), required.map(rename)], + ), + ) + : renameConstraint(contract.name, value, rename); + } + return Object.keys(projected).length === 0 ? undefined : projected; +}; + +/** Derive the CLI parameters of one contract from its input schema and projection. */ +export const describeContractCommand = ( + contract: Contract, + projection: CliContractProjection>, +): ContractCliDescription => { + const shape = contract.inputSchema.shape as Record; + const positionals: readonly string[] = projection.positionals ?? []; + const callerPaths: ReadonlySet = new Set( + projection.callerPaths ?? [], + ); + const fieldProjections: Readonly< + Record + > = projection.fields ?? {}; + const omitted: Readonly> = Object.fromEntries( + Object.entries(projection.omit ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + for (const field of [ + ...positionals, + ...callerPaths, + ...Object.keys(fieldProjections), + ...Object.keys(omitted), + ]) + if (!Object.hasOwn(shape, field)) + throw new Error(`${contract.name} has no input field ${field}`); + for (const field of Object.keys(omitted)) + if (!admitsOmission(shape[field]!)) + throw new Error(`${contract.name}.${field} is required on the CLI`); + + const parameter = (field: string): ContractCliParameter => { + const declared = shape[field]!; + const projected = fieldProjections[field]; + const callerPath = callerPaths.has(field); + let schema: z.ZodType; + if (callerPath) { + schema = admitsOmission(declared) + ? localPathStringSchema.optional() + : localPathStringSchema; + if (declared.description !== undefined) + schema = schema.describe(declared.description); + } else { + assertArgvField(contract.name, field, declared); + schema = cliDecimalShape({ field: argvOptional(declared) }).field; + } + if (projected?.description !== undefined) + schema = schema.describe(projected.description); + const description = schema.description; + const value = core(declared); + return { + field, + key: projected?.name ?? camelCase(field), + positional: positionals.includes(field), + alias: projected?.alias, + description, + schema, + booleanFlag: value instanceof z.ZodBoolean, + repeatable: value instanceof z.ZodArray, + decimal: value instanceof z.ZodNumber, + callerPath, + }; + }; + const parameters = [ + ...positionals, + ...Object.keys(shape).filter( + (field) => !positionals.includes(field) && !Object.hasOwn(omitted, field), + ), + ].map(parameter); + const keys = new Set(parameters.map(({ key }) => key)); + if (keys.size !== parameters.length) + throw new Error(`${contract.name} projects two fields to one CLI key`); + const keyOf = new Map(parameters.map(({ field, key }) => [field, key])); + const rename = (field: string): string => keyOf.get(field) ?? field; + + const toContractInput = ( + values: Readonly>, + ): Record => { + const input: Record = {}; + for (const { field, key, callerPath } of parameters) { + const value = values[key]; + if (value === undefined) continue; + input[field] = + callerPath && typeof value === "string" + ? absoluteCallerPath(value) + : value; + } + return input; + }; + + // Cross-field rules belong to the contract: run its own object checks on + // the options mapped to contract names, then name CLI keys. Positional + // values are not visible here; the service's contract parse still + // enforces every rule. + const checks = + projection.crossFieldRules === "argv" + ? (contract.inputSchema._zod.def.checks ?? []) + : []; + const contractChecks = z + .object({}) + .loose() + .check(...(checks as z.core.$ZodCheck>[])); + const checkOptions = + checks.length === 0 + ? undefined + : (options: Readonly>) => { + const result = contractChecks.safeParse(toContractInput(options)); + return result.success + ? [] + : result.error.issues.map((issue) => { + const [field, ...rest] = issue.path; + return { + path: + typeof field === "string" + ? [rename(field), ...rest] + : issue.path, + message: issue.message, + issue, + }; + }); + }; + + return { + command: projection.command, + description: projection.description, + contract, + parameters, + omitted, + optionConstraints: projectObjectConstraints(contract, rename), + checkOptions, + toContractInput, + callerInputs: (values) => + Object.fromEntries( + parameters.flatMap(({ field, key, callerPath }) => { + const value = values[key]; + return callerPath && typeof value === "string" + ? [[field, value]] + : []; + }), + ), + }; +}; diff --git a/src/cli/contractCommand.ts b/src/cli/contractCommand.ts new file mode 100644 index 0000000000..8db008c1b8 --- /dev/null +++ b/src/cli/contractCommand.ts @@ -0,0 +1,167 @@ +import { z } from "incur"; +import type { Logger } from "pino"; + +import { logCliCommand } from "../cliLogging.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { Evidence } from "../domain/evidence.js"; +import type { Result } from "../domain/result.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + describeContractCommand, + type CliContractProjection, + type ContractCliDescription, + type ContractField, + type ProjectedContract, +} from "./contractCliDescription.js"; +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import type { CliInstance } from "./types.js"; + +/** + * Incur adapter for contract commands. The command itself is described in + * `contractCliDescription.ts`; this module only renders that description as + * Incur args and options and runs the handler. + */ + +/** What a contract command's handler receives besides the contract input. */ +export interface ContractCommandContext { + readonly signal: AbortSignal; + /** The contract name, which is the canonical operation. */ + readonly operation: string; + /** Each caller-path field as the caller spelled it, before resolution. */ + readonly callerInputs: Readonly>; +} + +/** A closeable one-shot service that inspects one contract input. */ +export interface EvidenceInspectionService { + inspect( + input: unknown, + options: { readonly signal: AbortSignal }, + ): Promise>; + close(): Promise; +} + +const primaryCallerPath = ({ + operation, + callerInputs, +}: ContractCommandContext): string => { + const callerPath = callerInputs.path; + if (callerPath === undefined) + throw new Error(`${operation} has no caller path field named path`); + return callerPath; +}; + +/** + * Inspect through a closeable service, finish its cleanup, and record the + * caller's spelling of `path` in the Evidence. + */ +export const inspectWithService = + (service: () => EvidenceInspectionService, providerId: string) => + (input: unknown, context: ContractCommandContext) => { + const callerPath = primaryCallerPath(context); + const owned = service(); + return finishCliEvidenceCommand( + callerPath, + () => owned.inspect(input, { signal: context.signal }), + owned, + providerId, + context.operation, + context.signal, + ).then(projectCliAnalysisResult); + }; + +const descriptions = new WeakMap(); + +/** Commands registered on this CLI through `registerContractCommand`. */ +export const contractCommandDescriptions = ( + cli: CliInstance, +): readonly ContractCliDescription[] => descriptions.get(cli) ?? []; + +const incurOptions = (description: ContractCliDescription): z.ZodType => { + const object = z.object( + Object.fromEntries( + description.parameters + .filter(({ positional }) => !positional) + .map(({ key, schema }) => [key, schema]), + ), + ); + const constrained = + description.optionConstraints === undefined + ? object + : object.meta(description.optionConstraints); + const { checkOptions } = description; + return checkOptions === undefined + ? constrained + : constrained.superRefine((parsed, context) => { + for (const { path, issue } of checkOptions(parsed)) + context.addIssue({ ...issue, path: [...path] } as never); + }); +}; + +/** + * Register one CLI command described from a tool contract. The handler + * receives the contract input, with caller paths made absolute. + */ +export const registerContractCommand = < + Contract extends ProjectedContract, + Value, +>( + cli: CliInstance, + logger: Logger, + contract: Contract, + projection: CliContractProjection>, + run: ( + input: z.input, + context: ContractCommandContext, + ) => Promise, +): void => { + const description = describeContractCommand(contract, projection); + const positionals = description.parameters.filter( + ({ positional }) => positional, + ); + const alias = Object.fromEntries( + description.parameters.flatMap(({ key, alias: short, positional }) => + short === undefined || positional ? [] : [[key, short]], + ), + ); + cli.command(description.command, { + description: description.description, + ...(positionals.length === 0 + ? {} + : { + args: z.object( + Object.fromEntries( + positionals.map(({ key, schema }) => [key, schema]), + ), + ), + }), + options: incurOptions(description), + ...(Object.keys(alias).length === 0 ? {} : { alias }), + run: ({ + args, + options, + }: { + readonly args?: Readonly>; + readonly options: Readonly>; + }) => { + const values = { ...options, ...args }; + return withCommandCancellation((signal) => + logCliCommand(logger, description.command, () => + run( + description.toContractInput(values) as z.input< + Contract["inputSchema"] + >, + { + signal, + operation: contract.name, + callerInputs: description.callerInputs(values), + }, + ), + ), + ); + }, + } as never); + const registered = descriptions.get(cli) ?? []; + registered.push(description); + descriptions.set(cli, registered); +}; diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index 8dedec0878..a2ea4b0a8f 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -1,28 +1,14 @@ -import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; -import { z } from "incur"; -import { createSqliteDatabaseService } from "../composition/sqlite.js"; -import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; -import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; -import { cliDecimalField } from "./options.js"; -import { - inspectSqliteDatabaseInputSchema, - sqliteTableSelectionSchema, - SQLITE_TABLE_REQUIRED_SELECTION_FIELDS, -} from "../domain/sqlite/sqliteDatabase.js"; -const selectionAliases = { - table: "table", - columns: "column", - schema_scope: "schemaScope", - row_limit: "rowLimit", - row_offset: "rowOffset", -} as const; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { createSqliteDatabaseService } from "../composition/sqlite.js"; +import { SQLITE_TOOL_CONTRACTS } from "../contracts/sqlite/sqliteToolContracts.js"; +import { contractNamed } from "./contractCliDescription.js"; +import { + inspectWithService, + registerContractCommand, +} from "./contractCommand.js"; +import type { CliInstance } from "./types.js"; /** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */ export const registerSqliteCommands = ( @@ -31,104 +17,43 @@ export const registerSqliteCommands = ( environment: Readonly, ): void => { const service = createSqliteDatabaseService(environment); - cli.command(CLI_COMMANDS.inspectSqliteDatabase, { - description: - "Inspect SQLite schema and optional ordinary-table rows from a local snapshot", - args: z.object({ - path: localPathStringSchema.describe("Local SQLite database snapshot"), - }), - options: z - .object({ - workerTimeoutMs: cliDecimalField( - inspectSqliteDatabaseInputSchema.shape.worker_timeout_ms, - ), - table: sqliteTableSelectionSchema.shape.table.describe( - "Exact ordinary table name to read", - ), - column: sqliteTableSelectionSchema.shape.columns.describe( - "Repeat to select exact row columns in the requested order; requires table", - ), - schemaScope: sqliteTableSelectionSchema.shape.schema_scope.describe( - "Compact inventory by default, or selected-table details when table is supplied; database requests full schema", - ), - rowLimit: cliDecimalField( - inspectSqliteDatabaseInputSchema.shape.row_limit, - ).describe( - "Positive safe integer row count; default 20; requires table", - ), - rowOffset: cliDecimalField( - inspectSqliteDatabaseInputSchema.shape.row_offset, - ).describe( - "Non-negative safe integer rows to skip before the window; default 0; requires table", - ), - }) - .superRefine((options, context) => { - const selection = sqliteTableSelectionSchema.safeParse({ - table: options.table, - columns: options.column, - schema_scope: options.schemaScope, - row_limit: options.rowLimit, - row_offset: options.rowOffset, - }); - if (selection.success) return; - for (const issue of selection.error.issues) { - const path = issue.path.map((field) => - typeof field === "string" && field in selectionAliases - ? selectionAliases[field as keyof typeof selectionAliases] - : field, - ); - context.addIssue({ ...issue, path }); - } - }) - .meta({ - dependentRequired: Object.fromEntries( - SQLITE_TABLE_REQUIRED_SELECTION_FIELDS.map((field) => [ - selectionAliases[field], - ["table"], - ]), - ), - if: { - properties: { schemaScope: { const: "selected_table" } }, - required: ["schemaScope"], + const contract = contractNamed( + SQLITE_TOOL_CONTRACTS, + "inspect_sqlite_database", + ); + registerContractCommand( + cli, + logger, + contract, + { + command: CLI_COMMANDS.inspectSqliteDatabase, + description: + "Inspect SQLite schema and optional ordinary-table rows from a local snapshot", + positionals: ["path"], + callerPaths: ["path"], + crossFieldRules: "argv", + fields: { + path: { description: "Local SQLite database snapshot" }, + table: { description: "Exact ordinary table name to read" }, + columns: { + name: "column", + description: + "Repeat to select exact row columns in the requested order; requires table", }, - then: { required: ["table"] }, - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, () => - finishCliEvidenceCommand( - args.path, - () => - service.inspect( - { - path: absoluteCallerPath(args.path), - ...(options.workerTimeoutMs === undefined - ? {} - : { worker_timeout_ms: options.workerTimeoutMs }), - ...(options.table === undefined - ? {} - : { table: options.table }), - ...(options.rowLimit === undefined - ? {} - : { row_limit: options.rowLimit }), - ...(options.rowOffset === undefined - ? {} - : { row_offset: options.rowOffset }), - ...(options.column === undefined - ? {} - : { columns: options.column }), - ...(options.schemaScope === undefined - ? {} - : { schema_scope: options.schemaScope }), - }, - { signal }, - ), - service, - "sqlite", - "inspect_sqlite_database", - signal, - ).then(projectCliAnalysisResult), - ), - ), - }); + schema_scope: { + description: + "Compact inventory by default, or selected-table details when table is supplied; database requests full schema", + }, + row_limit: { + description: + "Positive safe integer row count; default 20; requires table", + }, + row_offset: { + description: + "Non-negative safe integer rows to skip before the window; default 0; requires table", + }, + }, + }, + inspectWithService(() => service, "sqlite"), + ); }; diff --git a/tests/boundary/cli/cliContractCommandParity.test.ts b/tests/boundary/cli/cliContractCommandParity.test.ts new file mode 100644 index 0000000000..430b4f2777 --- /dev/null +++ b/tests/boundary/cli/cliContractCommandParity.test.ts @@ -0,0 +1,265 @@ +import { Parser, z } from "incur"; +import { describe, expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; +import { + describeContractCommand, + type ContractCliDescription, + type ContractCliParameter, +} from "../../../src/cli/contractCliDescription.js"; +import { contractCommandDescriptions } from "../../../src/cli/contractCommand.js"; +import { + visitRegisteredCommands, + type RegisteredCommand, +} from "../../../src/cli/visitRegisteredCommands.js"; +import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js"; + +const DECIMAL_TOKEN = /^[0-9]+$/u; + +const cli = createCli({}); +const registered = new Map(); +visitRegisteredCommands(cli, (command, path) => { + registered.set(path, command); +}); +const descriptions = contractCommandDescriptions(cli); + +const inputJsonSchema = (field: z.ZodType): Record => + z.toJSONSchema(field, { io: "input", unrepresentable: "any" }); + +/** Values at and around each advertised bound, plus spellings argv can carry. */ +const probes = (field: z.ZodType): readonly unknown[] => { + const json = inputJsonSchema(field); + const values: unknown[] = [ + undefined, + null, + "", + "\0", + "a\0b", + "\uD800", + "x", + "rel/x", + "/abs/x", + true, + false, + -1, + 0, + 1, + 1.5, + 64, + 4096, + 2 ** 31, + 2 ** 53, + [], + ["x"], + [""], + ["a\0b"], + "0", + "1", + "010", + "0x10", + "1e3", + "+1", + " 1", + "-1", + "1.5", + ]; + for (const bound of ["minimum", "maximum", "minLength", "maxLength"]) { + const value = json[bound]; + if (typeof value !== "number") continue; + const near = [value - 1, value, value + 1]; + values.push(...near, ...near.filter((n) => n >= 0).map(String)); + if (bound.endsWith("Length")) + values.push(...near.filter((n) => n >= 0).map((n) => "x".repeat(n))); + } + if (Array.isArray(json.enum)) values.push(...json.enum, `${json.enum[0]}!`); + return values; +}; + +const isNumeric = (field: z.ZodType): boolean => { + const json = inputJsonSchema(field); + return json.type === "integer" || json.type === "number"; +}; + +/** Argv omission is an absent key, never an explicit undefined. */ +const admits = (field: z.ZodType, value: unknown): boolean => + value === undefined + ? z.object({ value: field }).safeParse({}).success + : field.safeParse(value).success; + +/** What the contract admits for one value the caller typed on the CLI. */ +const expectedAdmission = ( + parameter: ContractCliParameter, + contractField: z.ZodType, + value: unknown, +): boolean => { + if (parameter.callerPath) { + // A caller path is validated as typed: an empty value never becomes the + // working directory. A nonempty value is resolved, then the contract rules. + if (typeof value !== "string") return admits(contractField, value); + return value !== "" && admits(contractField, absoluteCallerPath(value)); + } + if (typeof value === "string" && isNumeric(contractField)) + return DECIMAL_TOKEN.test(value) && admits(contractField, Number(value)); + return admits(contractField, value); +}; + +const registeredField = ( + description: ContractCliDescription, + parameter: ContractCliParameter, +): z.ZodType => { + const command = registered.get(description.command)!; + const group = ( + parameter.positional ? command.args : command.options + ) as z.ZodObject; + return group.shape[parameter.key] as z.ZodType; +}; + +const contractShape = (description: ContractCliDescription) => + description.contract.inputSchema.shape as Record; + +const each = descriptions.map( + (description) => [description.command, description] as const, +); + +describe("CLI commands projected from tool contracts", () => { + it("registers the migrated commands", () => { + expect(descriptions.map(({ command }) => command)).toEqual( + expect.arrayContaining([ + "inspect-sqlite-database", + "inspect-elf-notes", + "inspect-pe-resources", + ]), + ); + }); + + it.each(each)( + "%s exposes every contract field or declares its omission", + (_command, description) => { + expect( + [ + ...description.parameters.map(({ field }) => field), + ...Object.keys(description.omitted), + ] + .toSorted() + .join(), + ).toBe(Object.keys(contractShape(description)).toSorted().join()); + const command = registered.get(description.command)!; + const argKeys = Object.keys( + (command.args as z.ZodObject | undefined)?.shape ?? {}, + ); + const optionKeys = Object.keys( + (command.options as z.ZodObject | undefined)?.shape ?? {}, + ); + expect(argKeys).toEqual( + description.parameters + .filter(({ positional }) => positional) + .map(({ key }) => key), + ); + expect(optionKeys.toSorted()).toEqual( + description.parameters + .filter(({ positional }) => !positional) + .map(({ key }) => key) + .toSorted(), + ); + }, + ); + + it.each(each)( + "%s admits exactly what its contract admits", + (_command, description) => { + const mismatches: string[] = []; + for (const parameter of description.parameters) { + const cliField = registeredField(description, parameter); + const contractField = contractShape(description)[parameter.field]!; + // Incur's help reads flag and choice types only through `optional`. + expect(cliField, parameter.key).not.toBeInstanceOf(z.ZodExactOptional); + if (parameter.callerPath) { + // Only the absolute-path rule is relaxed, and only by resolution. + expect(admits(contractField, "rel/x"), parameter.field).toBe(false); + expect(admits(contractField, absoluteCallerPath("rel/x"))).toBe(true); + } + for (const value of probes(contractField)) { + const expected = expectedAdmission(parameter, contractField, value); + if (admits(cliField, value) !== expected) + mismatches.push( + `${parameter.key}=${JSON.stringify(value)} expected ${String(expected)}`, + ); + } + } + expect(mismatches).toEqual([]); + }, + ); + + it.each(each)( + "%s takes each boolean contract field as a bare flag", + (_command, description) => { + for (const parameter of description.parameters) { + if (parameter.positional || !parameter.booleanFlag) continue; + const options = z.object({ + [parameter.key]: registeredField(description, parameter), + }); + const flag = `--${parameter.key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`)}`; + expect(Parser.parse([flag], { options }).options, flag).toEqual({ + [parameter.key]: true, + }); + } + }, + ); + + it.each(each)( + "%s maps parsed argv to an admitted contract input", + (_command, description) => { + const command = registered.get(description.command)!; + const values = { + ...(command.options as z.ZodObject).parse({}), + ...Object.fromEntries( + description.parameters + .filter(({ positional }) => positional) + .map(({ key }) => [key, "rel/target"]), + ), + }; + const input = description.toContractInput(values); + expect(description.contract.inputSchema.safeParse(input).error).toBe( + undefined, + ); + for (const parameter of description.parameters) + if (parameter.callerPath && parameter.positional) { + expect(input[parameter.field]).toBe(absoluteCallerPath("rel/target")); + expect(description.callerInputs(values)[parameter.field]).toBe( + "rel/target", + ); + } + }, + ); + + it.each([ + ["an object", z.object({ value: z.string() }).optional()], + ["a record", z.record(z.string(), z.string()).optional()], + ["a union", z.union([z.string(), z.boolean()]).optional()], + ["a pipe", z.string().pipe(z.string()).optional()], + ["an array of objects", z.array(z.object({})).optional()], + ["an array of numbers", z.array(z.number()).optional()], + ["a tuple", z.tuple([z.string()]).optional()], + ])("refuses to describe a field that is %s", (_kind, field) => { + expect(() => + describeContractCommand( + { name: "probe", inputSchema: z.object({ field }) }, + { command: "probe", description: "Probe" }, + ), + ).toThrow("probe.field has no argv projection"); + }); + + it("refuses an input constraint the CLI schema cannot carry", () => { + expect(() => + describeContractCommand( + { + name: "probe", + inputSchema: z + .object({ value: z.string().optional() }) + .meta({ not: { required: ["value"] } }), + }, + { command: "probe", description: "Probe" }, + ), + ).toThrow("probe input keyword not has no CLI projection"); + }); +});