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"); + }); +});