diff --git a/docs/tool-design.md b/docs/tool-design.md index d36be85c60..f1d4ecaee6 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -242,6 +242,20 @@ 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. +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 +required alternatives across positional arguments and options. When the CLI +schema format separates those groups and cannot express their relationship, +state the required choice in command discovery/help and retain aggregate admission +before target access. Test emitted schemas and parser behavior together, including +omission, empty strings, alternatives, conflicts and boundary list counts. + +Expected input failures must retain caller field/index paths and safe constraints. +Internal operation/input envelopes are not caller paths. Reuse the input issue +projector at the admission owner; a schema cause alone does not supply correction +metadata to either adapter. + When discoverability changes, evaluate representative broad, direct, and negative tasks on the intended host. Record host/model/catalog, selected tools, arguments, errors, retries, and task outcomes. Schema validity and keyword heuristics do not diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 74245a5729..323f2de4d7 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -155,7 +155,8 @@ const registerDecompileCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.decompile, { - description: "Read one part of an app as code", + description: + "Read one part of an app as code; requires a positional selector, --procedure, or --file-offset", args: z.object({ path: z.string().describe("App or program path"), address: z @@ -170,7 +171,7 @@ const registerDecompileCommand = ( .min(1) .optional() .describe( - "Procedure name or address; use --procedure= for leading dashes", + "Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure= for leading dashes", ), "file-offset": z .string() @@ -249,7 +250,8 @@ const registerXrefsCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.xrefs, { - description: "List bounded references to an analyzed address", + description: + "List bounded references to an analyzed address; requires a positional selector or --address", args: z.object({ path: z.string().describe("App or program path"), address: z.string().min(1).optional().describe("Address or symbol name"), @@ -260,7 +262,7 @@ const registerXrefsCommand = ( .min(1) .optional() .describe( - "Address or symbol name; use --address= for leading dashes", + "Required selector: supply this option or the positional selector; use --address= for leading dashes", ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -290,7 +292,8 @@ const registerTraceCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.trace, { - description: "Trace a literal feature through analyzed references", + description: + "Trace a literal feature through analyzed references; requires a positional selector or --query", args: z.object({ path: z.string().describe("App or program path"), query: z.string().min(1).optional().describe("Literal feature query"), @@ -301,7 +304,7 @@ const registerTraceCommand = ( .min(1) .optional() .describe( - "Complete literal feature query; use --query= for leading dashes", + "Required selector: supply this option or the positional selector; use --query= for leading dashes", ), caseSensitive: z .boolean() @@ -336,7 +339,8 @@ const registerFunctionCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.function, { - description: "Analyze one complete function with evidence", + description: + "Analyze one complete function with evidence; requires a positional selector or --procedure", args: z.object({ path: z.string().describe("App or program path"), address: z @@ -351,7 +355,7 @@ const registerFunctionCommand = ( .min(1) .optional() .describe( - "Procedure name or address; use --procedure= for leading dashes", + "Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure= for leading dashes", ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -381,7 +385,8 @@ const registerInstructionsCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.instructions, { - description: "Read every raw instruction without decompiling", + description: + "Read every raw instruction without decompiling; requires a positional selector or --procedure", args: z.object({ path: z.string().describe("App or program path"), address: z @@ -396,7 +401,7 @@ const registerInstructionsCommand = ( .min(1) .optional() .describe( - "Procedure name or address; use --procedure= for leading dashes", + "Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure= for leading dashes", ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -426,7 +431,8 @@ const registerSearchCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.search, { - description: "Search every analyzed string or procedure name", + description: + "Search every analyzed string or procedure name; requires a positional selector or --pattern", args: z.object({ path: z.string().describe("App or program path"), pattern: z @@ -441,7 +447,7 @@ const registerSearchCommand = ( .min(1) .optional() .describe( - "Complete literal text or regex pattern; use --pattern= for leading dashes", + "Required selector: supply this option or the positional selector; use --pattern= for leading dashes", ), kind: z .enum(["strings", "procedures"]) diff --git a/src/cli/desktopApplicationCommands.ts b/src/cli/desktopApplicationCommands.ts index ab23f9c9da..f6083bbdbf 100644 --- a/src/cli/desktopApplicationCommands.ts +++ b/src/cli/desktopApplicationCommands.ts @@ -1,3 +1,4 @@ +import { localPathStringSchema } from "../domain/localPath.js"; import { analysisEvidenceDeliveryOptions, deliverCliAnalysisEvidence, @@ -20,7 +21,7 @@ export const registerDesktopApplicationCommands = ( description: "Identify desktop runtime candidates from bounded directory and header observations", args: z.object({ - path: z.string().describe("Local installation directory"), + path: localPathStringSchema.describe("Local installation directory"), }), options: z.object({ max_entries: z diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index f606891aa4..eddf565319 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -136,7 +136,8 @@ export const registerFridaCommands = ( ), }); cli.command(CLI_COMMANDS.instrumentWithFrida, { - description: "Run one Frida script against a selected process and detach", + description: + "Run one Frida script against a selected process and detach; requires exactly one of --script or --script-file", args: z.object({ mode: z .enum(["attach", "spawn"]) @@ -153,7 +154,12 @@ export const registerFridaCommands = ( .min(1) .optional() .describe("Frida device identifier"), - script: z.string().optional().describe("Inline JavaScript to load"), + script: z + .string() + .optional() + .describe( + "Inline JavaScript to load; supply exactly one of --script or --script-file", + ), scriptFile: z .string() .min(1) diff --git a/src/cli/javascriptRecoveryCommands.ts b/src/cli/javascriptRecoveryCommands.ts index dfa10ed604..98f7548612 100644 --- a/src/cli/javascriptRecoveryCommands.ts +++ b/src/cli/javascriptRecoveryCommands.ts @@ -1,3 +1,4 @@ +import { javascriptRecoveryInputSchema } from "../domain/javascript/javascriptRecovery.js"; import { z } from "incur"; import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptRecoveryService.js"; import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js"; @@ -21,30 +22,17 @@ export const registerJavaScriptRecoveryCommands = ( description: "Recover readable JavaScript modules with source provenance and verified files", args: z.object({ - path: z - .string() - .describe("Absolute UTF-8 JavaScript input; never executed"), - outputDirectory: z.string().describe("Absolute absent output directory"), + path: javascriptRecoveryInputSchema.shape.path, + outputDirectory: javascriptRecoveryInputSchema.shape.output_directory, }), options: z.object({ - additionalPath: z - .array(z.string()) - .optional() - .describe( - "Related absolute UTF-8 JavaScript file; repeat for multiple chunks", - ), - extractionMode: z - .enum(["structural", "heuristic", "inspection"]) - .optional() - .describe( - "Structural boundaries, heuristic fallback, or inspection-only regions", - ), - rewriteLevel: z - .enum(["minimal", "standard", "aggressive"]) - .optional() - .describe( - "Upstream syntax recovery level; runtime equivalence remains unknown", - ), + additionalPath: javascriptRecoveryInputSchema.shape.additional_paths, + extractionMode: javascriptRecoveryInputSchema.shape.extraction_mode + .removeDefault() + .optional(), + rewriteLevel: javascriptRecoveryInputSchema.shape.rewrite_level + .removeDefault() + .optional(), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts index a1dacaddce..e69cdb60f9 100644 --- a/src/cli/setupCommands.ts +++ b/src/cli/setupCommands.ts @@ -24,12 +24,7 @@ import type { CliInstance } from "./types.js"; import { SUPPORTED_CLIENT_DEFINITIONS } from "../application/SupportedClients.js"; const supportedClientIds = SUPPORTED_CLIENT_DEFINITIONS.map(({ name }) => name); -const supportedClientSchema = z - .string() - .refine( - (candidate) => supportedClientIds.some((name) => name === candidate), - "Unsupported agent integration", - ); +const supportedClientSchema = z.enum(supportedClientIds); const skillClientSchema = z.union([z.literal("shared"), supportedClientSchema]); /** Register setup, doctor, uninstall, and update CLI commands. */ diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 26d3840938..a472f7b2d5 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -121,16 +121,14 @@ const registerNativeUiCommands = ( ...nativeUiScopeOptions, steps: z .string() - .optional() - .describe("JSON array of declarative AX actions or waits"), + .describe( + "Required JSON array of one or more declarative AX actions or waits", + ), }), run: ({ args, options }) => logCliCommand(logger, CLI_COMMANDS.captureNativeUiScenario, async () => { const parameters = nativeUiScope(options); - const decoded = - options.steps === undefined - ? { ok: true as const, value: undefined } - : safeParseJson(options.steps); + const decoded = safeParseJson(options.steps); if (!decoded.ok) return invalidNativeUiScenarioInput(operation, [ { path: ["steps"], reason: "invalid_format", expected: "JSON" }, @@ -144,7 +142,7 @@ const registerNativeUiCommands = ( operation, projectInputIssues(parsed.error.issues, { ...parameters, - ...(options.steps === undefined ? {} : { steps: decoded.value }), + steps: decoded.value, }), ); const { native_ui_deadline_ms: deadline, ...scenario } = parsed.data; diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index cee9d5d571..862677ffa3 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -1,3 +1,4 @@ +import { localPathStringSchema } from "../domain/localPath.js"; import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -10,7 +11,10 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; -import { inspectWasmArtifactInputSchema } from "../domain/wasm/wasmArtifact.js"; +import { + inspectWasmArtifactInputSchema, + wasmArtifactPathsSchema, +} from "../domain/wasm/wasmArtifact.js"; /** Resolve all caller-selected artifact paths through the shared workflow. */ export const registerWasmCommands = ( @@ -45,16 +49,14 @@ export const registerWasmCommands = ( cli.command(wasmArtifactModule.cliCommand, { description: "Inspect an explicit local WASM artifact with WABT", args: z.object({ - path: z.string().describe("Local WASM artifact"), + path: localPathStringSchema.describe("Local WASM artifact"), }), options: z.object({ commandTimeoutMs: inspectWasmArtifactInputSchema.shape.command_timeout_ms, - glue_paths: z - .array(z.string()) + glue_paths: wasmArtifactPathsSchema(localPathStringSchema) .optional() .describe("Selected local JavaScript glue paths"), - candidate_paths: z - .array(z.string()) + candidate_paths: wasmArtifactPathsSchema(localPathStringSchema) .optional() .describe("Explicit local WASM candidates"), }), diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index 887a163143..08fb0f4318 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -14,13 +14,17 @@ const artifact = z.strictObject({ /** Default execution budget; upper bound is the runtime's signed 32-bit timer range. */ export const DEFAULT_WASM_COMMAND_TIMEOUT_MS = 30_000; export const MAX_WASM_COMMAND_TIMEOUT_MS = 2_147_483_647; +/** Selected artifact lists share the same resource bound across path transports. */ +export const wasmArtifactPathsSchema = ( + pathSchema: z.ZodString = absoluteLocalPathStringSchema, +) => z.array(pathSchema).max(16); /** All artifacts are explicitly selected local files; references never trigger acquisition. */ export const inspectWasmArtifactInputSchema = z.strictObject({ path: absoluteLocalPathStringSchema.describe( "Absolute selected local WASM artifact path", ), - glue_paths: z.array(absoluteLocalPathStringSchema).max(16).default([]), - candidate_paths: z.array(absoluteLocalPathStringSchema).max(16).default([]), + glue_paths: wasmArtifactPathsSchema().default([]), + candidate_paths: wasmArtifactPathsSchema().default([]), command_timeout_ms: z .number() .int() diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 95e4480578..c1b31c8441 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -112,6 +112,63 @@ describe("CLI advertised input schemas", () => { expect(mismatches).toEqual([]); }); + it("preserves client choices, selected path bounds and required decoded steps", async () => { + const cli = createCli({}); + const ajv = new Ajv2020({ strict: false }); + for (const command of ["setup", "doctor"]) { + const schema = await advertisedSchema(cli, command); + const validate = ajv.compile(schema.options ?? {}); + for (const client of ["codex", "unsupported-client"]) { + const input = { client: [client] }; + expect(validate(input)).toBe(client === "codex"); + expect( + parserSchema(cli, command, "options")?.safeParse(input).success, + ).toBe(client === "codex"); + } + } + const setup = await advertisedSchema(cli, "setup"); + const validateSkill = ajv.compile(setup.options ?? {}); + for (const skillClient of ["shared", "codex", "unsupported-client"]) { + const input = { skillClient: [skillClient] }; + expect(validateSkill(input)).toBe(skillClient !== "unsupported-client"); + expect( + parserSchema(cli, "setup", "options")?.safeParse(input).success, + ).toBe(skillClient !== "unsupported-client"); + } + for (const [command, field] of [ + ["inspect-wasm-artifact", "glue_paths"], + ["inspect-wasm-artifact", "candidate_paths"], + ["recover-javascript-sources", "additionalPath"], + ]) { + const schema = await advertisedSchema(cli, command); + const validate = ajv.compile(schema.options ?? {}); + for (const count of [16, 17]) { + const input = { + [field]: Array.from({ length: count }, () => "./selected"), + }; + expect(validate(input)).toBe(count === 16); + expect( + parserSchema(cli, command, "options")?.safeParse(input).success, + ).toBe(count === 16); + } + } + const scenario = await advertisedSchema(cli, "capture-native-ui-scenario"); + expect(scenario.options?.required).toContain("steps"); + expect(scenario.options?.properties.steps).toMatchObject({ + description: expect.stringContaining("one or more"), + }); + const desktop = await advertisedSchema(cli, "inspect-desktop-application"); + const validate = ajv.compile(desktop.args ?? {}); + for (const path of ["", ".", "./selected"]) { + expect(validate({ path })).toBe(path !== ""); + expect( + parserSchema(cli, "inspect-desktop-application", "args")?.safeParse({ + path, + }).success, + ).toBe(path !== ""); + } + }); + it("allows an omitted WASM timeout while validating explicitly supplied values", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "inspect-wasm-artifact");