feat(objdump): select native disassembly by symbol (#5171)

Push optional native symbol selection into GNU objdump before collecting Evidence. Preserve native matching, raw output, artifact identity, scoped limitations and the existing full-disassembly default through the shared CLI/MCP workflow.

Fixes #5159

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
gensokyo-devandKaoru0822 authored and GitHub committed 2026-10-19 20:15:20 +08:00
1 parent dc936f433c
commit 3ea0d9489e
9 files changed
+253 -25

No files matched your search

+3
View File
@@ -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
```
@@ -190,11 +190,16 @@ export class ReverseEngineeringService {
);
const input = parsed.data;
const command = this.#commands.objdump(input);
let parameters: Readonly<Record<string, JsonValue>>;
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.",
+11 -9
View File
@@ -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 },
),
@@ -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: [
+39
View File
@@ -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"],
+29 -9
View File
@@ -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") }),
]);
+4 -1
View File
@@ -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<typeof input.operation, readonly string[]>;
return [
@@ -50,13 +50,14 @@ const observation = (completionStatus: "complete" | "unknown") =>
const runObjdump = async (
service: Partial<ReverseEngineeringService>,
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<unknown>;
}
>();
@@ -66,7 +67,7 @@ const runObjdump = async (
definition: {
run(input: {
args: { path: string; operation: string };
options: { followDebugLinks: boolean };
options: { followDebugLinks: boolean; symbol?: string };
}): Promise<unknown>;
},
) => {
@@ -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));
@@ -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();
});