From d734e6424df5fed42399dc22b759a1c9962c01f2 Mon Sep 17 00:00:00 2001 From: hiba malik Date: Sat, 14 Nov 2026 10:21:35 +0000 Subject: [PATCH] fix(cli): advertise parsed JSON documents in --schema `--schema` for JSON-input CLI commands showed only an opaque `inputJson` string, so callers could not see the document the command actually parses. The argument now carries the decoded document as its contentSchema, built from the same contract each command or its service parses with (the Evidence input frame, or the ledger and coverage request schemas). `$schema` is stripped, and a schema JSON Schema cannot represent is refused rather than advertised as {}. Fixes #9195 Supersedes #9283 Squashed from #9283 (with morluto's validation test) and rebased onto main; the coverage test now expects boundary_id's stable-identifier pattern from #9919. #9214 by 1052326311 took the same approach. Co-Authored-By: morluto <76467478+morluto@users.noreply.github.com> Co-Authored-By: 1052326311 <65798732+1052326311@users.noreply.github.com> Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .../ReconstructionCoverageService.ts | 13 +- .../ReconstructionObligationLedgerRequest.ts | 14 +- src/cli/analysisViewCommands.ts | 28 +- src/cli/applicationCommands.ts | 49 ++- src/cli/artifactComparisonCommand.ts | 7 +- src/cli/electronCommands.ts | 22 +- src/cli/jsonInputArgument.ts | 73 ++++ src/cli/managedCommands.ts | 20 +- src/cli/managedProjectGraphCommand.ts | 11 +- src/cli/nativeCallCommands.ts | 10 +- src/cli/sourceToBundleCommand.ts | 8 +- src/cli/staticObservedEndpointsCommand.ts | 16 +- src/cliBrowserScenarioCommands.ts | 21 +- src/domain/reconstructionCoverageInput.ts | 9 + tests/boundary/cli/jsonInputSchema.test.ts | 390 ++++++++++++++++++ 15 files changed, 625 insertions(+), 66 deletions(-) create mode 100644 src/cli/jsonInputArgument.ts create mode 100644 tests/boundary/cli/jsonInputSchema.test.ts diff --git a/src/application/ReconstructionCoverageService.ts b/src/application/ReconstructionCoverageService.ts index c19cef03a1..8bea27fb04 100644 --- a/src/application/ReconstructionCoverageService.ts +++ b/src/application/ReconstructionCoverageService.ts @@ -1,7 +1,6 @@ import { parseAnalysisInput } from "./AnalysisInput.js"; -import { z } from "zod"; -import { reconstructionCoverageEvaluationInputSchema } from "../domain/reconstructionCoverageInput.js"; +import { reconstructionCoverageRequestSchema } from "../domain/reconstructionCoverageInput.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { evaluateParsedReconstructionClosure, @@ -11,17 +10,17 @@ import { import type { Result } from "../domain/result.js"; const OPERATION = "evaluate_reconstruction_coverage"; -const requestEnvelopeSchema = - reconstructionCoverageEvaluationInputSchema.extend({ - coverage: z.unknown(), - }); /** Evaluate inline reconstruction coverage against a named boundary. */ export const evaluateReconstructionCoverage = ( rawInput: unknown, nowEpochMs = Date.now(), ): Result => { - const parsed = parseAnalysisInput(OPERATION, rawInput, requestEnvelopeSchema); + const parsed = parseAnalysisInput( + OPERATION, + rawInput, + reconstructionCoverageRequestSchema, + ); if (!parsed.ok) return parsed; const coverage = readReconstructionCoverageData(parsed.value.coverage, [ "coverage", diff --git a/src/application/ReconstructionObligationLedgerRequest.ts b/src/application/ReconstructionObligationLedgerRequest.ts index 73f25656b8..ecdc5833a7 100644 --- a/src/application/ReconstructionObligationLedgerRequest.ts +++ b/src/application/ReconstructionObligationLedgerRequest.ts @@ -16,9 +16,11 @@ import { import { err, ok, type Result } from "../domain/result.js"; const OPERATION = "build_reconstruction_obligation_ledger"; -const requestSchema = reconstructionObligationLedgerInputSchema.extend({ - evidence_bundle: z.unknown(), -}); +/** The ledger parser admits the bundle itself after this envelope. */ +export const reconstructionObligationLedgerRequestSchema = + reconstructionObligationLedgerInputSchema.extend({ + evidence_bundle: z.unknown(), + }); /** Authenticated bundle and its single request-owned record index. */ export interface ReconstructionObligationSources { @@ -36,7 +38,11 @@ export interface ResolvedReconstructionObligationRequest { export const resolveReconstructionObligationLedgerRequest = ( input: unknown, ): Result => { - const frame = parseAnalysisInput(OPERATION, input, requestSchema); + const frame = parseAnalysisInput( + OPERATION, + input, + reconstructionObligationLedgerRequestSchema, + ); if (!frame.ok) return frame; const bundle = readEvidenceBundle(frame.value.evidence_bundle, { operation: OPERATION, diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index 90f8b86947..d28fd097a0 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -18,6 +18,13 @@ import type { CliInstance } from "./types.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; +import { inspectAnalysisViewInputSchema } from "../domain/analysisView/analysisViewRequest.js"; + +const inspectEvidenceBundleCliInputSchema = + getEvidenceBundleInputSchema.safeExtend({ + path: importEvidenceBundleInputSchema.shape.path, + }); /** Project selected views of completed analysis Evidence through the CLI. */ export const registerAnalysisViewCommands = ( @@ -28,11 +35,10 @@ export const registerAnalysisViewCommands = ( description: "Inspect a local Evidence bundle completely, as discovery metadata, as a page of selected complete records, or as direct-link and retained-closure membership.", args: z.object({ - inputJson: z - .string() - .describe( - "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", - ), + inputJson: jsonInputArgument( + "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", + inspectEvidenceBundleCliInputSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => @@ -43,10 +49,9 @@ export const registerAnalysisViewCommands = ( signal, ); if (!input.ok) return input.error; - const schema = getEvidenceBundleInputSchema.safeExtend({ - path: importEvidenceBundleInputSchema.shape.path, - }); - const parsed = schema.safeParse(input.value); + const parsed = inspectEvidenceBundleCliInputSchema.safeParse( + input.value, + ); if (!parsed.success) return { error: "Evidence bundle inspection failed", @@ -75,7 +80,10 @@ export const registerAnalysisViewCommands = ( description: "Project a selected view of completed Evidence JSON for a supported analysis operation. For inventory rows, use --format csv or --format tsv with --filter-output normalized_result.items and --columns logical_path,artifact_id (if those fields are included in the view).", args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline workflow JSON or JSON file path", + inspectAnalysisViewInputSchema, + ), }), options: z.object({ evidenceBundle: localPathStringSchema diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index 2ee2d05c5d..4bd145a2d5 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -13,6 +13,17 @@ import type { Logger } from "pino"; import { registerSourceToBundleCommand } from "./sourceToBundleCommand.js"; import { registerStaticObservedEndpointsCommand } from "./staticObservedEndpointsCommand.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; +import { + compareApplicationVersionsRequestSchema, + compareJavaScriptExportShapesRequestSchema, + traceApplicationFeatureRequestSchema, + traceJavaScriptSemanticsRequestSchema, +} from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { reconstructionCoverageRequestSchema } from "../domain/reconstructionCoverageInput.js"; +import { reconstructionObligationLedgerRequestSchema } from "../application/ReconstructionObligationLedgerRequest.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; @@ -24,6 +35,23 @@ import { withCommandCancellation } from "./commandCancellation.js"; type CliInstance = ReturnType; +const traceApplicationFeatureDocumentSchema = getEvidenceInputContract( + traceApplicationFeatureRequestSchema, +).frameSchema; +const traceJavaScriptSemanticsDocumentSchema = getEvidenceInputContract( + traceJavaScriptSemanticsRequestSchema, +).frameSchema; +const compareApplicationVersionsDocumentSchema = getEvidenceInputContract( + compareApplicationVersionsRequestSchema, +).frameSchema; +const compareJavaScriptExportShapesDocumentSchema = getEvidenceInputContract( + compareJavaScriptExportShapesRequestSchema, +).frameSchema; +const applicationInventoryDocumentSchema = getEvidenceInputContract( + applicationInventoryProjectionInputSchema, +).frameSchema; +const workflowJsonDescription = "Inline workflow JSON or JSON file path"; + /** Register CLI equivalents of provider-neutral application graph workflows. */ export const registerApplicationCommands = ( cli: ReturnType, @@ -34,6 +62,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.traceApplicationFeature, operation: "trace_application_feature", + documentSchema: traceApplicationFeatureDocumentSchema, description: "Trace a typed seed through authenticated application Evidence JSON", workflow: async (input, lookup, signal) => { @@ -48,6 +77,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.traceJavaScriptSemantics, operation: "trace_javascript_semantics", + documentSchema: traceJavaScriptSemanticsDocumentSchema, description: "Trace bounded static semantic relations through authenticated application Evidence", workflow: async (input, lookup, signal) => { @@ -64,6 +94,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.compareApplicationVersions, operation: "compare_application_versions", + documentSchema: compareApplicationVersionsDocumentSchema, description: "Compare two authenticated JavaScript Application Graph versions", workflow: async (input, lookup, signal) => { @@ -85,6 +116,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.compareJavaScriptExportShapes, operation: "compare_javascript_export_shapes", + documentSchema: compareJavaScriptExportShapesDocumentSchema, description: "Compare exact static JavaScript export return shapes without execution", workflow: async (input, lookup, signal) => { @@ -103,6 +135,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.projectAndroidApplicationGraph, operation: "project_android_application_graph", + documentSchema: applicationInventoryDocumentSchema, description: "Project authenticated APK inventory Evidence into an Android application graph", workflow: async (input, lookup, signal) => { @@ -119,6 +152,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.projectAppleApplicationGraph, operation: "project_apple_application_graph", + documentSchema: applicationInventoryDocumentSchema, description: "Project authenticated IPA or macOS app inventory Evidence into an Apple application graph", workflow: async (input, lookup, signal) => { @@ -133,6 +167,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.projectHarmonyApplicationGraph, operation: "project_harmony_application_graph", + documentSchema: applicationInventoryDocumentSchema, description: "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an application graph", workflow: async (input, lookup, signal) => { @@ -157,6 +192,7 @@ const registerObligationLedgerCommand = ( description: "Generate a deterministic Evidence-backed reconstruction obligation ledger page", inputSchema: z.unknown(), + documentSchema: reconstructionObligationLedgerRequestSchema, workflow: async (input) => { const { buildReconstructionObligationLedgerEvidence } = await import("../application/ReconstructionObligationLedgerService.js"); @@ -171,6 +207,7 @@ const registerCoverageCommand = (cli: CliInstance, logger: Logger): void => name: CLI_COMMANDS.evaluateReconstructionCoverage, description: "Evaluate inline fail-closed reconstruction coverage", inputSchema: z.unknown(), + documentSchema: reconstructionCoverageRequestSchema, workflow: async (input) => { const { evaluateReconstructionCoverage } = await import("../application/ReconstructionCoverageService.js"); @@ -184,6 +221,8 @@ interface JsonCommandOptions { readonly name: string; readonly description: string; readonly inputSchema: Schema; + /** Contract advertised by `--schema`. Runtime parsing stays `inputSchema`. */ + readonly documentSchema?: z.ZodType; readonly workflow: ( input: z.output, ) => Promise< @@ -198,12 +237,16 @@ const registerJsonCommand = ({ name, description, inputSchema, + documentSchema, workflow, }: JsonCommandOptions): void => { cli.command(name, { description, args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + workflowJsonDescription, + documentSchema ?? inputSchema, + ), }), run: ({ args }) => logCliCommand(logger, name, async () => { @@ -239,6 +282,7 @@ const registerEvidenceJsonCommand = ({ name, operation, description, + documentSchema, workflow, }: { readonly cli: CliInstance; @@ -246,6 +290,7 @@ const registerEvidenceJsonCommand = ({ readonly name: string; readonly operation: string; readonly description: string; + readonly documentSchema: z.ZodType; readonly workflow: ( input: unknown, lookup: EvidenceLookup | undefined, @@ -255,7 +300,7 @@ const registerEvidenceJsonCommand = ({ cli.command(name, { description, args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument(workflowJsonDescription, documentSchema), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/artifactComparisonCommand.ts b/src/cli/artifactComparisonCommand.ts index bdbb47b994..0149d2aa5a 100644 --- a/src/cli/artifactComparisonCommand.ts +++ b/src/cli/artifactComparisonCommand.ts @@ -6,12 +6,14 @@ import { resolveArtifactComparisonSources, } from "../application/artifacts/ArtifactComparisonEvidence.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { artifactComparisonInputSchema } from "../contracts/artifactComparisonContracts.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { ARTIFACT_COMPARISON_PROVIDER } from "../domain/comparisonEvidenceIdentity.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import type { CliInstance } from "./types.js"; const OPERATION = "compare_artifacts"; @@ -26,7 +28,10 @@ export const registerArtifactComparisonCommand = ( description: 'Compare two artifact inventories, including FIT and legacy uImage firmware components, named as {left, right}: complete Evidence or {kind:"evidence-file",path,evidence_id?}. A one-shot command retains no session Evidence for retained-evidence references.', args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline workflow JSON or JSON file path", + artifactComparisonInputSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index aaa177fef1..acd3505317 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -16,6 +16,8 @@ import { listElectronTargetsInputSchema, } from "../domain/javascript/electronObservation.js"; import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js"; +import { reconcileJavaScriptRuntimeRequestSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; @@ -42,6 +44,7 @@ import { evidenceBundleOption, runEvidenceInputCommand, } from "./evidenceInputCommand.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; /** Register CLI equivalents of the Electron MCP tools. */ export const registerElectronCommands = ( @@ -116,11 +119,10 @@ const registerElectronActiveCommand = ( cli.command(CLI_COMMANDS.captureElectronScenario, { description: "Run one owned Electron scenario with cancellable actions", args: z.object({ - inputJson: z - .string() - .describe( - "Inline JSON or a JSON file matching capture_electron_scenario", - ), + inputJson: jsonInputArgument( + "Inline JSON or a JSON file matching capture_electron_scenario", + electronActiveObservationInputSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => @@ -184,11 +186,11 @@ const registerJavaScriptRuntimeReconciliationCommand = ( description: "Reconcile static application and passive runtime Evidence JSON", args: z.object({ - inputJson: z - .string() - .describe( - "Inline JSON or a JSON file path matching reconcile_javascript_runtime", - ), + inputJson: jsonInputArgument( + "Inline JSON or a JSON file path matching reconcile_javascript_runtime", + getEvidenceInputContract(reconcileJavaScriptRuntimeRequestSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/jsonInputArgument.ts b/src/cli/jsonInputArgument.ts new file mode 100644 index 0000000000..21c265e9a9 --- /dev/null +++ b/src/cli/jsonInputArgument.ts @@ -0,0 +1,73 @@ +import { z } from "incur"; + +const JSON_SCHEMA_DIALECT = "$schema"; +const wrapperTypes = new Set([ + "optional", + "nonoptional", + "nullable", + "default", + "prefault", + "readonly", + "catch", +]); + +const innerSchema = (schema: z.ZodType): z.ZodType | undefined => { + if (!("unwrap" in schema) || typeof schema.unwrap !== "function") + return undefined; + const inner: unknown = schema.unwrap(); + return inner instanceof z.ZodType ? inner : undefined; +}; + +/** Skip wrappers so `unknown` and `any` are recognized as the whole document. */ +const documentType = (schema: z.ZodType): string => { + let current = schema; + for (;;) { + const type = current._zod.def.type; + if (!wrapperTypes.has(type)) return type; + const inner = innerSchema(current); + if (inner === undefined) return type; + current = inner; + } +}; + +/** + * JSON Schema for one positional JSON document. + * + * `{}` is only returned for a document the handler parses as `unknown` or + * `any`. Any other empty conversion is omitted: a custom or unrepresentable + * document must not be advertised as accepting every value. Input mode keeps + * the type a transform accepts, so a string transform stays a string. + */ +export const jsonInputContentSchema = ( + schema: z.ZodType, +): Record | undefined => { + let converted: Record; + try { + converted = z.toJSONSchema(schema, { + io: "input", + unrepresentable: "any", + }) as Record; + } catch { + return undefined; + } + const content = { ...converted }; + delete content[JSON_SCHEMA_DIALECT]; + if (Object.keys(content).length > 0) return content; + const type = documentType(schema); + return type === "unknown" || type === "any" ? {} : undefined; +}; + +/** Keep the positional string and attach the document contract Incur can see. */ +export const jsonInputArgument = ( + description: string, + schema: z.ZodType, +): z.ZodString => { + const argument = z.string().describe(description); + const contentSchema = jsonInputContentSchema(schema); + return contentSchema === undefined + ? argument.meta({ contentMediaType: "application/json" }) + : argument.meta({ + contentMediaType: "application/json", + contentSchema, + }); +}; diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index a0835cfc4c..f6be42f30a 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -19,6 +19,10 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { registerProjectManagedApplicationGraph } from "./managedProjectGraphCommand.js"; import { managedMembersInputSchema } from "../contracts/managed/managedToolContracts.js"; +import { managedReconstructionImportRequestSchema } from "../domain/managed/managedReconstruction.js"; +import { managedNativeVerificationRequestSchema } from "../domain/managed/managedNativeVerificationSchemas.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -240,9 +244,11 @@ const registerImportManagedReconstruction = ( description: "Import decompiler-produced managed reconstruction against exact static member evidence", args: z.object({ - inputJson: z - .string() - .describe("Inline managed reconstruction JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline managed reconstruction JSON or JSON file path", + getEvidenceInputContract(managedReconstructionImportRequestSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => @@ -278,9 +284,11 @@ const registerVerifyManagedNativeBoundaries = ( description: "Verify managed P/Invoke declarations against authenticated native Evidence", args: z.object({ - inputJson: z - .string() - .describe("Inline managed/native verification JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline managed/native verification JSON or JSON file path", + getEvidenceInputContract(managedNativeVerificationRequestSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/managedProjectGraphCommand.ts b/src/cli/managedProjectGraphCommand.ts index 6865b9c3dd..8e1838f2e4 100644 --- a/src/cli/managedProjectGraphCommand.ts +++ b/src/cli/managedProjectGraphCommand.ts @@ -11,6 +11,9 @@ import { runEvidenceInputCommand, } from "./evidenceInputCommand.js"; import type { Logger } from "pino"; +import { managedApplicationGraphReferenceInputSchema } from "../domain/managed/managedApplicationGraph.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import type { CliInstance } from "./types.js"; /** Register managed static-Evidence application graph projection. */ @@ -22,9 +25,11 @@ export const registerProjectManagedApplicationGraph = ( description: "Project authenticated managed static Evidence into an application graph", args: z.object({ - inputJson: z - .string() - .describe("Inline managed application graph JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline managed application graph JSON or JSON file path", + getEvidenceInputContract(managedApplicationGraphReferenceInputSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/nativeCallCommands.ts b/src/cli/nativeCallCommands.ts index 35916d8a78..b7055cc7eb 100644 --- a/src/cli/nativeCallCommands.ts +++ b/src/cli/nativeCallCommands.ts @@ -12,6 +12,7 @@ import { localPathStringSchema } from "../domain/localPath.js"; import { nativeCallObservationInputSchema } from "../domain/native/nativeCallObservation.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; const OPERATION = "observe_native_calls"; @@ -26,11 +27,10 @@ export const registerNativeCallCommands = ( "Launch a Mach-O under LLDB and record entries into selected functions or Objective-C methods", args: z.object({ path: localPathStringSchema.describe("Mach-O or app path"), - inputJson: z - .string() - .describe( - "Inline JSON or a JSON file path: breakpoints, arguments, environment, duration_ms, max_events, argument_registers, backtrace_frames, capture_limits", - ), + inputJson: jsonInputArgument( + "Inline JSON or a JSON file path: breakpoints, arguments, environment, duration_ms, max_events, argument_registers, backtrace_frames, capture_limits", + nativeCallObservationInputSchema, + ), }), run: ({ args }) => logCliCommand(logger, CLI_COMMANDS.observeNativeCalls, async () => { diff --git a/src/cli/sourceToBundleCommand.ts b/src/cli/sourceToBundleCommand.ts index 2d4d01a787..4a71c947be 100644 --- a/src/cli/sourceToBundleCommand.ts +++ b/src/cli/sourceToBundleCommand.ts @@ -5,7 +5,9 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { compareSourceToBundleCliInputSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import { evidenceBundleOption, runEvidenceInputCommand, @@ -21,7 +23,11 @@ export const registerSourceToBundleCommand = ( description: "Compare historical source (inline graph or reference {kind:file,path}) with authenticated application Evidence. Save the complete JSON to query with inspect-analysis-view.", args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline workflow JSON or JSON file path", + getEvidenceInputContract(compareSourceToBundleCliInputSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/staticObservedEndpointsCommand.ts b/src/cli/staticObservedEndpointsCommand.ts index 849d0bb501..02445ec7ff 100644 --- a/src/cli/staticObservedEndpointsCommand.ts +++ b/src/cli/staticObservedEndpointsCommand.ts @@ -6,6 +6,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { compareStaticObservedEndpointsFactsSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; import { endpointComparisonViewRequestSchema } from "../domain/analysisView/endpointComparisonView.js"; import { parseAnalysisInput } from "../application/AnalysisInput.js"; @@ -23,6 +24,9 @@ import { } from "./analysisEvidenceDelivery.js"; const operation = "compare_static_and_observed_endpoints"; +const comparisonDocumentSchema = getEvidenceInputContract( + compareStaticObservedEndpointsFactsSchema, +).frameSchema; const deliverySchema = z .object({ detail: analysisEvidenceDeliveryOptions.detail, @@ -73,8 +77,7 @@ const runComparison = async ( const input = parseAnalysisInput( operation, raw.value, - getEvidenceInputContract(compareStaticObservedEndpointsFactsSchema) - .frameSchema, + comparisonDocumentSchema, signal, ); if (!input.ok) return projectAnalysisError(input.error); @@ -112,11 +115,10 @@ export const registerStaticObservedEndpointsCommand = ( description: "Compare static JavaScript endpoints and HAR Evidence JSON offline; --detail summary with --evidence_output saves full facts for selected inspect-analysis-view pages", args: z.object({ - inputJson: z - .string() - .describe( - "Inline JSON or file containing application, capture and optional origin", - ), + inputJson: jsonInputArgument( + "Inline JSON or file containing application, capture and optional origin", + comparisonDocumentSchema, + ), }), options: commandOptions, run: ({ args, options }) => diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 2f3ec96975..3f77b170dc 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -21,9 +21,12 @@ import { import { err, ok, type Result } from "./domain/result.js"; import type { BrowserScenarioCapturePort } from "./application/BrowserScenarioCapturePort.js"; import type { JsonValue } from "./domain/jsonValue.js"; +import { jsonInputArgument } from "./cli/jsonInputArgument.js"; import type { Logger } from "pino"; const OPERATION = "capture_browser_scenario"; +const browserScenarioJsonDescription = + "Inline browser scenario JSON, JSON file path, or '-' for stdin"; /** Register controlled browser execution and offline validation commands. */ export const registerBrowserScenarioCommands = ( @@ -40,11 +43,10 @@ export const registerBrowserScenarioCommands = ( description: "Run one bounded Playwright browser scenario and return Evidence", args: z.object({ - inputJson: z - .string() - .describe( - "Inline browser scenario JSON, JSON file path, or '-' for stdin", - ), + inputJson: jsonInputArgument( + browserScenarioJsonDescription, + browserScenarioSchema, + ), }), options: z.object(analysisEvidenceDeliveryOptions), run: ({ args, options: delivery }) => @@ -110,11 +112,10 @@ const registerBrowserScenarioValidationCommand = ( description: "Validate one browser scenario without launching or attaching to a browser", args: z.object({ - inputJson: z - .string() - .describe( - "Inline browser scenario JSON, JSON file path, or '-' for stdin", - ), + inputJson: jsonInputArgument( + browserScenarioJsonDescription, + browserScenarioSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/domain/reconstructionCoverageInput.ts b/src/domain/reconstructionCoverageInput.ts index f752f8530e..d7d04f20fb 100644 --- a/src/domain/reconstructionCoverageInput.ts +++ b/src/domain/reconstructionCoverageInput.ts @@ -9,3 +9,12 @@ export const reconstructionCoverageEvaluationInputSchema = z.strictObject({ coverage: reconstructionCoverageDataSchema, boundary_id: reconstructionBoundaryIdSchema, }); + +/** + * Envelope the coverage command parses before its reader authenticates + * `coverage`. The reader owns that document, so this field stays unknown. + */ +export const reconstructionCoverageRequestSchema = + reconstructionCoverageEvaluationInputSchema.extend({ + coverage: z.unknown(), + }); diff --git a/tests/boundary/cli/jsonInputSchema.test.ts b/tests/boundary/cli/jsonInputSchema.test.ts new file mode 100644 index 0000000000..d9697381fd --- /dev/null +++ b/tests/boundary/cli/jsonInputSchema.test.ts @@ -0,0 +1,390 @@ +import { beforeAll, expect, it } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { z } from "zod"; + +import { createCli } from "../../../src/cli.js"; +import { CLI_COMMANDS } from "../../../src/cliCommandNames.js"; +import { artifactComparisonInputSchema } from "../../../src/contracts/artifactComparisonContracts.js"; +import { + compareApplicationVersionsRequestSchema, + compareJavaScriptExportShapesRequestSchema, + compareSourceToBundleCliInputSchema, + compareStaticObservedEndpointsFactsSchema, + reconcileJavaScriptRuntimeRequestSchema, + traceApplicationFeatureRequestSchema, + traceJavaScriptSemanticsRequestSchema, +} from "../../../src/contracts/javascript/applicationWorkflowInputContracts.js"; +import { + getEvidenceBundleInputSchema, + importEvidenceBundleInputSchema, +} from "../../../src/contracts/sessionToolSchemas.js"; +import { reconstructionCoverageRequestSchema } from "../../../src/domain/reconstructionCoverageInput.js"; +import { reconstructionObligationLedgerRequestSchema } from "../../../src/application/ReconstructionObligationLedgerRequest.js"; +import { inspectAnalysisViewInputSchema } from "../../../src/domain/analysisView/analysisViewRequest.js"; +import { browserScenarioSchema } from "../../../src/domain/browserScenario.js"; +import { getEvidenceInputContract } from "../../../src/domain/evidenceInputContract.js"; +import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; +import { applicationInventoryProjectionInputSchema } from "../../../src/domain/mobileApplicationGraphProjection.js"; +import { managedApplicationGraphReferenceInputSchema } from "../../../src/domain/managed/managedApplicationGraph.js"; +import { managedNativeVerificationRequestSchema } from "../../../src/domain/managed/managedNativeVerificationSchemas.js"; +import { managedReconstructionImportRequestSchema } from "../../../src/domain/managed/managedReconstruction.js"; +import { nativeCallObservationInputSchema } from "../../../src/domain/native/nativeCallObservation.js"; +import { + jsonInputArgument, + jsonInputContentSchema, +} from "../../../src/cli/jsonInputArgument.js"; +import { visitRegisteredCommands } from "../../../src/cli/visitRegisteredCommands.js"; +import type { CliInstance } from "../../../src/cli/types.js"; + +const frame = (schema: z.ZodType): z.ZodType => + getEvidenceInputContract(schema).frameSchema; + +const inspectEvidenceBundleCliInputSchema = + getEvidenceBundleInputSchema.safeExtend({ + path: importEvidenceBundleInputSchema.shape.path, + }); +const inventoryDocumentSchema = frame( + applicationInventoryProjectionInputSchema, +); + +/** Every positional JSON document and the schema its handler parses. */ +const documents = [ + { + command: CLI_COMMANDS.traceApplicationFeature, + schema: frame(traceApplicationFeatureRequestSchema), + }, + { + command: CLI_COMMANDS.traceJavaScriptSemantics, + schema: frame(traceJavaScriptSemanticsRequestSchema), + }, + { + command: CLI_COMMANDS.compareApplicationVersions, + schema: frame(compareApplicationVersionsRequestSchema), + }, + { + command: CLI_COMMANDS.compareStaticAndObservedEndpoints, + schema: frame(compareStaticObservedEndpointsFactsSchema), + }, + { + command: CLI_COMMANDS.compareSourceToBundle, + schema: frame(compareSourceToBundleCliInputSchema), + }, + { + command: CLI_COMMANDS.compareJavaScriptExportShapes, + schema: frame(compareJavaScriptExportShapesRequestSchema), + }, + { + command: CLI_COMMANDS.buildReconstructionObligationLedger, + schema: reconstructionObligationLedgerRequestSchema, + }, + { + command: CLI_COMMANDS.evaluateReconstructionCoverage, + schema: reconstructionCoverageRequestSchema, + }, + { + command: CLI_COMMANDS.projectAndroidApplicationGraph, + schema: inventoryDocumentSchema, + }, + { + command: CLI_COMMANDS.projectAppleApplicationGraph, + schema: inventoryDocumentSchema, + }, + { + command: CLI_COMMANDS.projectHarmonyApplicationGraph, + schema: inventoryDocumentSchema, + }, + { + command: CLI_COMMANDS.inspectEvidenceBundle, + schema: inspectEvidenceBundleCliInputSchema, + }, + { + command: CLI_COMMANDS.inspectAnalysisView, + schema: inspectAnalysisViewInputSchema, + }, + { + command: CLI_COMMANDS.captureElectronScenario, + schema: electronActiveObservationInputSchema, + }, + { + command: CLI_COMMANDS.reconcileJavaScriptRuntime, + schema: frame(reconcileJavaScriptRuntimeRequestSchema), + }, + { + command: CLI_COMMANDS.observeNativeCalls, + schema: nativeCallObservationInputSchema, + }, + { + command: CLI_COMMANDS.importManagedReconstruction, + schema: frame(managedReconstructionImportRequestSchema), + }, + { + command: CLI_COMMANDS.verifyManagedNativeBoundaries, + schema: frame(managedNativeVerificationRequestSchema), + }, + { + command: CLI_COMMANDS.projectManagedApplicationGraph, + schema: frame(managedApplicationGraphReferenceInputSchema), + }, + { + command: CLI_COMMANDS.compareArtifacts, + schema: artifactComparisonInputSchema, + }, + { + command: CLI_COMMANDS.captureBrowserScenario, + schema: browserScenarioSchema, + }, + { + command: CLI_COMMANDS.validateBrowserScenario, + schema: browserScenarioSchema, + }, +] as const; + +interface InputJsonSchema { + readonly type?: unknown; + readonly description?: unknown; + readonly contentMediaType?: unknown; + readonly contentSchema?: { + readonly properties?: Readonly>; + readonly required?: readonly string[]; + }; +} + +const jsonInputCommands = (cli: CliInstance): string[] => { + const names: string[] = []; + visitRegisteredCommands(cli, (command, path) => { + if ( + command.args instanceof z.ZodObject && + Object.hasOwn(command.args.shape, "inputJson") + ) + names.push(path); + }); + return names; +}; + +let cli: ReturnType; + +beforeAll(() => { + cli = createCli({}); +}); + +const serve = async (argv: readonly string[]) => { + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { stdout, exitCode }; +}; + +const advertisedInput = (stdout: string): InputJsonSchema => { + const document = JSON.parse(stdout) as { + args?: { + properties?: { inputJson?: InputJsonSchema }; + required?: readonly string[]; + }; + }; + expect(document.args?.required).toContain("inputJson"); + const inputJson = document.args?.properties?.inputJson; + expect(inputJson).toMatchObject({ + type: "string", + contentMediaType: "application/json", + }); + expect(typeof inputJson?.description).toBe("string"); + return inputJson as InputJsonSchema; +}; + +it("advertises the parsed document for every JSON-input command", async () => { + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(jsonInputCommands(cli).sort()).toEqual( + documents.map(({ command }) => command).sort(), + ); + for (const { command, schema } of documents) { + const served = await serve([command, "--schema", "--json"]); + expect(served.exitCode, served.stdout).toBe(0); + const inputJson = advertisedInput(served.stdout); + expect(inputJson.contentSchema, command).toBeDefined(); + expect(inputJson.contentSchema, command).toEqual( + jsonInputContentSchema(schema), + ); + expect(inputJson.contentSchema, command).not.toEqual({}); + expect( + () => ajv.compile(inputJson.contentSchema as AnySchema), + command, + ).not.toThrow(); + } +}); + +it("validates representative parsed documents with the advertised schema", async () => { + const input = advertisedInput( + ( + await serve([ + CLI_COMMANDS.evaluateReconstructionCoverage, + "--schema", + "--json", + ]) + ).stdout, + ); + const validate = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(input.contentSchema as AnySchema); + const accepted = { coverage: { arbitrary: true }, boundary_id: "main" }; + const rejected = [ + { coverage: {}, boundary_id: "" }, + { coverage: {} }, + { coverage: {}, boundary_id: "main", extra: true }, + ]; + expect(reconstructionCoverageRequestSchema.safeParse(accepted).success).toBe( + true, + ); + expect(validate(accepted)).toBe(true); + for (const document of rejected) { + expect( + reconstructionCoverageRequestSchema.safeParse(document).success, + ).toBe(false); + expect(validate(document)).toBe(false); + } +}); + +it("names the feature-trace, semantic-query, and browser-scenario fields", async () => { + const trace = advertisedInput( + (await serve([CLI_COMMANDS.traceApplicationFeature, "--schema", "--json"])) + .stdout, + ); + expect(trace.description).toBe("Inline workflow JSON or JSON file path"); + expect(Object.keys(trace.contentSchema?.properties ?? {})).toEqual([ + "native_observations", + "seed", + "semantic_page", + "direction", + "application", + ]); + expect(trace.contentSchema?.required).toEqual(["seed", "application"]); + + const semantic = advertisedInput( + (await serve([CLI_COMMANDS.traceJavaScriptSemantics, "--schema", "--json"])) + .stdout, + ); + expect(Object.keys(semantic.contentSchema?.properties ?? {})).toEqual([ + "application", + "query", + ]); + expect(semantic.contentSchema?.required).toEqual(["application", "query"]); + + const browser = advertisedInput( + (await serve([CLI_COMMANDS.captureBrowserScenario, "--schema", "--json"])) + .stdout, + ); + expect(browser.description).toBe( + "Inline browser scenario JSON, JSON file path, or '-' for stdin", + ); + expect(browser.contentSchema?.properties).toEqual( + expect.objectContaining({ + browser: expect.any(Object), + start_url: expect.any(Object), + actions: expect.any(Object), + }), + ); + expect(browser.contentSchema?.required).toEqual( + expect.arrayContaining(["browser", "start_url", "actions"]), + ); +}); + +it("keeps unknown envelope fields permissive and named fields constrained", async () => { + const coverage = advertisedInput( + ( + await serve([ + CLI_COMMANDS.evaluateReconstructionCoverage, + "--schema", + "--json", + ]) + ).stdout, + ); + expect(coverage.contentSchema?.properties?.coverage).toEqual({}); + // boundary_id is a stable identifier (#9919), so its pattern is advertised. + expect(coverage.contentSchema?.properties?.boundary_id).toMatchObject({ + type: "string", + pattern: expect.any(String), + }); + expect(coverage.contentSchema?.required).toEqual(["coverage", "boundary_id"]); + + const ledger = advertisedInput( + ( + await serve([ + CLI_COMMANDS.buildReconstructionObligationLedger, + "--schema", + "--json", + ]) + ).stdout, + ); + expect(ledger.contentSchema?.properties?.evidence_bundle).toEqual({}); + expect(ledger.contentSchema?.required).toEqual( + expect.arrayContaining([ + "evidence_bundle", + "reviewed_obligations", + "manifest", + ]), + ); +}); + +it("prints the nested document in the default schema format", async () => { + const served = await serve([ + CLI_COMMANDS.traceApplicationFeature, + "--schema", + ]); + expect(served.exitCode, served.stdout).toBe(0); + expect(served.stdout).toContain("contentMediaType: application/json"); + expect(served.stdout).toContain("contentSchema:"); + expect(served.stdout).toContain("seed:"); + expect(served.stdout).toContain("application:"); +}); + +it("uses an empty schema only for unknown or any documents", () => { + expect(jsonInputContentSchema(z.unknown())).toEqual({}); + expect(jsonInputContentSchema(z.any())).toEqual({}); + expect(jsonInputContentSchema(z.unknown().nonoptional())).toEqual({}); + + const custom = z.custom(); + expect(jsonInputContentSchema(custom)).toBeUndefined(); + const omitted = z.toJSONSchema(jsonInputArgument("payload", custom)); + expect(omitted).toMatchObject({ + type: "string", + description: "payload", + contentMediaType: "application/json", + }); + expect(omitted).not.toHaveProperty("contentSchema"); + + const exploding = z.object({ value: z.string() }); + exploding._zod.toJSONSchema = () => { + throw new Error("unconvertible"); + }; + expect(jsonInputContentSchema(exploding)).toBeUndefined(); + expect(() => + z.toJSONSchema(jsonInputArgument("payload", exploding)), + ).not.toThrow(); + + const query = z.toJSONSchema( + jsonInputArgument( + "Inline workflow JSON or JSON file path", + z.strictObject({ query: z.string() }), + ), + ); + expect(query).toMatchObject({ + type: "string", + description: "Inline workflow JSON or JSON file path", + contentMediaType: "application/json", + contentSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, + }); +});