diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index e224100a79..5782775aba 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -18,8 +18,11 @@ can run. For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema. +For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed. + ```sh rea inspect-with-objdump ./program disassemble +rea inspect-with-objdump ./program disassemble --symbol my_function rea inspect-with-objdump ./program dwarf rea inspect-with-objdump ./program dwarf --follow-debug-links ``` diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 8818bb8eff..567985e596 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -190,11 +190,16 @@ export class ReverseEngineeringService { ); const input = parsed.data; const command = this.#commands.objdump(input); + let parameters: Readonly>; + if (input.operation === "disassemble") { + const { symbol, ...base } = input; + parameters = symbol === undefined ? base : { ...base, symbol }; + } else parameters = input; return this.#observe( "inspect_with_objdump", command, input.operation === "architectures" ? undefined : input.path, - input, + parameters, options.signal, ); } @@ -581,6 +586,12 @@ export class ReverseEngineeringService { ]), "A non-zero process exit is preserved as an observed command result.", ...(operation === "inspect_with_objdump" && + typeof parameters.symbol === "string" + ? [ + "Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.", + ] + : []), + ...(operation === "inspect_with_objdump" && parameters.follow_debug_links === true ? [ "DWARF output may include local debug-link files whose identities are not bound to Evidence.", diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index 1feb9184dc..8df885b537 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -11,6 +11,7 @@ import { evidenceSchema, type Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; import { err } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { OBJDUMP_ARTIFACT_OPERATIONS } from "../domain/reverseEngineering.js"; import { logCliCommand } from "../cliLogging.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -112,18 +113,16 @@ export const registerReverseEngineeringCommands = ( .min(1) .describe("Path to the local artifact to inspect."), operation: z - .enum([ - "file_headers", - "section_headers", - "symbols", - "relocations", - "dwarf", - "disassemble", - "disassemble_all", - ]) + .enum(OBJDUMP_ARTIFACT_OPERATIONS) .describe("GNU objdump inspection operation to run."), }), options: z.object({ + symbol: z + .string() + .optional() + .describe( + "Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.", + ), followDebugLinks: z .boolean() .default(false) @@ -139,6 +138,9 @@ export const registerReverseEngineeringCommands = ( { ...args, follow_debug_links: options.followDebugLinks, + ...(options.symbol === undefined + ? {} + : { symbol: options.symbol }), }, { signal }, ), diff --git a/src/contracts/reverseEngineeringToolContracts.ts b/src/contracts/reverseEngineeringToolContracts.ts index 7b6542be1a..41b7d5b7d0 100644 --- a/src/contracts/reverseEngineeringToolContracts.ts +++ b/src/contracts/reverseEngineeringToolContracts.ts @@ -29,7 +29,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_with_objdump"), kind: "native-provider", description: - "Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.", + "Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.", inputSchema: objdumpInputSchema, outputSchema: evidenceResultOf(commandObservationSchema), examples: [ diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts index 41cd19fdf4..9f61277279 100644 --- a/src/contracts/toolInputReferences.test.ts +++ b/src/contracts/toolInputReferences.test.ts @@ -67,6 +67,45 @@ it.each(["draft-2020-12", "draft-07"] as const)( }, ); +it.each(["draft-2020-12", "draft-07"] as const)( + "keeps objdump Unicode validation and portable SDK patterns in %s", + (target) => { + const canonical = toolInputSchemaWithMetadata( + toolContract("inspect_with_objdump"), + ); + const schema = canonical["~standard"].jsonSchema.input({ target }); + const input = { path: "sample.elf", operation: "disassemble" }; + for (const unicodeRegExp of [false, true]) { + const options = { strict: false, validateFormats: false, unicodeRegExp }; + const ajv = + target === "draft-2020-12" ? new Ajv2020(options) : new Ajv(options); + expect(ajv.validateSchema(schema)).toBe(true); + const validate = ajv.compile(schema); + for (const symbol of [ + "known_function", + "selector_\u{1F680}", + "selector_\uFFFD", + ]) { + expect( + validate({ ...input, symbol }), + JSON.stringify(validate.errors), + ).toBe(true); + expect(canonical.safeParse({ ...input, symbol }).success).toBe(true); + } + expect(validate({ ...input, symbol: "a\u0000b" })).toBe(false); + } + // Scalar Unicode engines cannot express unpaired UTF-16 surrogates. The + // registered canonical validator owns that runtime constraint. + for (const symbol of [ + "\ud800", + "\udc00", + "prefix\ud800suffix", + "\udc00\ud800", + ]) + expect(canonical.safeParse({ ...input, symbol }).success).toBe(false); + }, +); + describe("advertised object dependencies", () => { it.each([ ["draft-07", "dependencies"], diff --git a/src/domain/reverseEngineering.ts b/src/domain/reverseEngineering.ts index fb0cc9a08c..58ad7ffb38 100644 --- a/src/domain/reverseEngineering.ts +++ b/src/domain/reverseEngineering.ts @@ -1,24 +1,44 @@ import { z } from "zod"; +export const OBJDUMP_ARTIFACT_OPERATIONS = [ + "file_headers", + "section_headers", + "symbols", + "relocations", + "dwarf", + "disassemble", + "disassemble_all", +] as const; + const objdumpArtifactInputSchema = z .object({ path: z.string().min(1), - operation: z.enum([ - "file_headers", - "section_headers", - "symbols", - "relocations", - "dwarf", - "disassemble", - "disassemble_all", - ]), + operation: z.enum(OBJDUMP_ARTIFACT_OPERATIONS).exclude(["disassemble"]), follow_debug_links: z.boolean().default(false), }) .strict(); +const objdumpDisassemblyInputSchema = objdumpArtifactInputSchema.extend({ + operation: z.literal("disassemble"), + symbol: z + .string() + .min(1) + .regex(/^[^\x00]*$/u) + // Keep UTF-16 checks out of patterns consumed by scalar Unicode engines. + .refine( + (symbol) => !/[\uD800-\uDFFF]/u.test(symbol), + "Symbol must be well-formed Unicode.", + ) + .describe( + "Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow GNU objdump; omitted means all code sections.", + ) + .optional(), +}); + /** Caller-selected artifact facet or pathless provider capability query. */ export const objdumpInputSchema = z.discriminatedUnion("operation", [ objdumpArtifactInputSchema, + objdumpDisassemblyInputSchema, z.strictObject({ operation: z.literal("architectures") }), ]); diff --git a/src/objdump/ObjdumpCommand.ts b/src/objdump/ObjdumpCommand.ts index 33fe303032..823f91760f 100644 --- a/src/objdump/ObjdumpCommand.ts +++ b/src/objdump/ObjdumpCommand.ts @@ -16,7 +16,10 @@ export const objdumpCommand = (input: ObjdumpInput): readonly string[] => { symbols: ["-t"], relocations: ["-r"], dwarf: ["-W", input.follow_debug_links ? "-WK" : "-WN"], - disassemble: ["-d"], + disassemble: + input.operation === "disassemble" && input.symbol !== undefined + ? [`--disassemble=${input.symbol}`] + : ["-d"], disassemble_all: ["-D"], } satisfies Record; return [ diff --git a/tests/boundary/cli/reverseEngineeringCommands.test.ts b/tests/boundary/cli/reverseEngineeringCommands.test.ts index 9af7217706..bc1d6a3b4b 100644 --- a/tests/boundary/cli/reverseEngineeringCommands.test.ts +++ b/tests/boundary/cli/reverseEngineeringCommands.test.ts @@ -50,13 +50,14 @@ const observation = (completionStatus: "complete" | "unknown") => const runObjdump = async ( service: Partial, path = "/tmp/sample", + selection: { operation?: string; symbol?: string } = {}, ) => { const commands = new Map< string, { run(input: { args: { path: string; operation: string }; - options: { followDebugLinks: boolean }; + options: { followDebugLinks: boolean; symbol?: string }; }): Promise; } >(); @@ -66,7 +67,7 @@ const runObjdump = async ( definition: { run(input: { args: { path: string; operation: string }; - options: { followDebugLinks: boolean }; + options: { followDebugLinks: boolean; symbol?: string }; }): Promise; }, ) => { @@ -85,11 +86,31 @@ const runObjdump = async ( if (command === undefined) throw new Error("objdump CLI command was not registered"); return command.run({ - args: { path, operation: "file_headers" }, - options: { followDebugLinks: false }, + args: { path, operation: selection.operation ?? "file_headers" }, + options: { + followDebugLinks: false, + ...(selection.symbol === undefined ? {} : { symbol: selection.symbol }), + }, }); }; +it("forwards the selected native symbol to the shared workflow", async () => { + const inspectWithObjdump = vi.fn(async (_input: unknown) => + ok(observation("complete")), + ); + await runObjdump( + { inspectWithObjdump, close: async () => undefined }, + "/tmp/sample", + { operation: "disassemble", symbol: "_Z3foov@@VERSION_1" }, + ); + expect(inspectWithObjdump.mock.calls[0]?.[0]).toEqual({ + path: "/tmp/sample", + operation: "disassemble", + follow_debug_links: false, + symbol: "_Z3foov@@VERSION_1", + }); +}); + it("lists objdump architectures without inventing an artifact path", async () => { const evidence = observation("complete"); const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence)); diff --git a/tests/composition/objdumpSelectedDisassembly.test.ts b/tests/composition/objdumpSelectedDisassembly.test.ts new file mode 100644 index 0000000000..2973a32955 --- /dev/null +++ b/tests/composition/objdumpSelectedDisassembly.test.ts @@ -0,0 +1,129 @@ +import { rm } from "node:fs/promises"; +import { expect, it, vi } from "vitest"; + +import { createArtifact } from "../support/reverseEngineeringCompositionFixtures.js"; +import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js"; + +it("keeps omitted and undefined selection out of default command Evidence", async () => { + const { directory, path } = await createArtifact(); + const run = vi.fn(async (_command: string, _args: readonly string[]) => ({ + stdout: "complete disassembly\n", + stderr: "", + })); + const service = new TestReverseEngineeringService({ run }); + try { + for (const selector of [{}, { symbol: undefined }]) { + const result = await service.inspectWithObjdump({ + path, + operation: "disassemble", + ...selector, + }); + if (!result.ok) throw result.error; + expect(result.value.parameters).not.toHaveProperty("symbol"); + expect(result.value.parameters.command_arguments).toEqual(["-d", path]); + } + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + "curl_version_info", + "_Z3foov@@VERSION_1", + "--leading-option with spaces\nand-newline", + "native_\u{1F680}_symbol", + "native_\uFFFD_symbol", +])( + "preserves native symbol %j as one argument and scoped Evidence", + async (symbol) => { + const { directory, path } = await createArtifact(); + const run = vi.fn(async (_command: string, _args: readonly string[]) => ({ + stdout: "selected disassembly\n", + stderr: "", + })); + const service = new TestReverseEngineeringService({ run }); + try { + const result = await service.inspectWithObjdump({ + path, + operation: "disassemble", + symbol, + }); + if (!result.ok) throw result.error; + expect(run.mock.calls[0]?.[1]).toEqual([`--disassemble=${symbol}`, path]); + expect(result.value.parameters).toMatchObject({ + operation: "disassemble", + symbol, + command_arguments: [`--disassemble=${symbol}`, path], + }); + expect(result.value.normalized_result).toMatchObject({ + stdout: "selected disassembly\n", + output_truncated: false, + artifact_changed: false, + completion_status: "complete", + }); + expect(result.value.limitations).toContain( + "Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.", + ); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.each([ + "architectures", + "file_headers", + "section_headers", + "symbols", + "relocations", + "dwarf", + "disassemble_all", +])( + "rejects symbol selection for %s before reading or launching", + async (operation) => { + const digest = vi.fn(); + const run = vi.fn(); + const service = new TestReverseEngineeringService({ digest, run }); + const result = await service.inspectWithObjdump({ + ...(operation === "architectures" ? {} : { path: "/unread-artifact" }), + operation, + symbol: "my_function", + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(digest).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + await service.close(); + }, +); + +it.each([ + "", + "a\u0000b", + "\ud800", + "\udc00", + "prefix\ud800suffix", + "prefix\udc00\ud800suffix", + null, + 1, +])("rejects invalid symbol %j before artifact admission", async (symbol) => { + const digest = vi.fn(); + const run = vi.fn(); + const service = new TestReverseEngineeringService({ digest, run }); + const result = await service.inspectWithObjdump({ + path: "/unread-artifact", + operation: "disassemble", + symbol, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(digest).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + await service.close(); +});