Compare commits

...
Author SHA1 Message Date
Kaoru0822 626c7b422a Merge remote-tracking branch 'origin/main' into fix/8198-ui-action-handler-offset 2026-10-31 16:07:12 +00:00
Kaoru0822andClaude Opus 5.5 678cd82042 fix(native): locate UI handler code instead of its method record
trace_native_ui_action paired an Objective-C handler's implementation
address with the file offset of its encoded method-list entry, so the
function node pointed agents at metadata bytes instead of the handler.

The Apple metadata reader already mapped the implementation address
through the selected image's segment table but dropped the offset. Keep
it as implementation_file_offset on each dispatch implementation (same
container coordinates as the record location, null when unmapped or
symbol-only, absent in older Evidence), and build the handler node's
location from that pair only. The method record keeps its own location
in the node's Evidence.

Fixes #8198

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:37:05 +00:00
11 changed files with 398 additions and 5 deletions

No files matched your search

+1
View File
@@ -799,6 +799,7 @@ jobs:
tests/boundary/filesystem/installScript.test.ts
tests/boundary/filesystem/nibNativeHierarchy.test.ts
tests/boundary/filesystem/interfaceBuilderBundle.test.ts
tests/acceptance/nativeUiHandlerLocation.test.ts
tests/boundary/cli/nativePlistCli.test.ts
tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts
tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
+8 -1
View File
@@ -378,7 +378,9 @@ bounded typed call references. Direct references, resolved indirect references,
ambiguous candidates, inferred untyped provider callees and targetless call
sites remain distinct. A static route does not establish runtime reachability.
Swift/closure dispatch requires resolved static references; unavailable ABI
forms remain unknown.
forms remain unknown. A handler node's `location` pairs the implementation
address with its `implementation_file_offset`, or an unknown offset; the method
record's own location remains in the node's Evidence.
Incomplete reference kinds do not discard known typed calls or partial
provider call references. A `provider_call` with `observed` resolution retains
@@ -835,6 +837,11 @@ complete observation.
non-generic, non-resilient class vtable descriptors. Records include exact virtual addresses/file offsets and
artifact evidence. Vtable indexes are metadata word offsets; witness indexes
start after the conformance header. Names unavailable in metadata remain null.
An Objective-C implementation's `location` is its method-list entry; the
reader maps `implementation_address` separately into
`implementation_file_offset`, which uses the same file coordinates as
`location.file_offset`, including a universal slice's container offset.
Unmapped implementations and symbol-only records keep it null.
The selected Mach-O header must match the requested CPU family for both thin
files and universal slices before metadata decoding. CPU subtypes and
capability bits remain source facts; an `arm64` request may select an
+5
View File
@@ -152,6 +152,11 @@ const PATH_SCOPES = {
"tests/boundary/filesystem/installScript.test.ts",
"tests/boundary/filesystem/nibNativeHierarchy.test.ts",
"tests/boundary/filesystem/interfaceBuilderBundle.test.ts",
// Compiled UI handler joins: metadata reader, graph and trace workflow.
"src/domain/native/objcSwiftMetadata.ts",
"src/domain/native/nativeInvestigationGraph.ts",
"src/application/native/NativeUiActionTrace.ts",
"tests/acceptance/nativeUiHandlerLocation.test.ts",
"tests/boundary/cli/nativePlistCli.test.ts",
"tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts",
"tests/boundary/cli/nativePlistSelectionFailureCli.test.ts",
@@ -257,6 +257,90 @@ describe("Interface Builder dispatch joins", () => {
);
});
describe("Interface Builder handler locations", () => {
const record = { address: "0x100000798", file_offset: 34712 };
const handlerFor = (implementationFileOffset?: number | null) => {
const ui = buildInterfaceBuilderAnalysis({
targetSha256: "b".repeat(64),
toolVersion: "test",
documents: [
{
relativePath: "Contents/Resources/Main.nib",
archiveSha256: "c".repeat(64),
documentKind: "nib",
raw: {
"com.apple.ibtool.document.objects": {
controller: { customClass: "AuditController" },
button: { class: "NSButton", title: "Run" },
},
"com.apple.ibtool.document.connections": {
button: [
{
type: "action",
label: "runAction:",
destinationId: "controller",
},
],
},
},
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const metadata = objcSwiftMetadataSchema.parse({
objc_dispatch_implementations: [
{
class_name: "AuditController",
selector: "runAction:",
method_type: "instance",
implementation_address: "0x10000077c",
...(implementationFileOffset === undefined
? {}
: { implementation_file_offset: implementationFileOffset }),
location: record,
decode: { status: "decoded", reason: null },
evidence: [
{
kind: "binary_metadata",
description: "Encoded relative Objective-C method entry",
location: record,
artifact_path: "/fixture/Audit.app/Contents/MacOS/Audit",
artifact_sha256: "b".repeat(64),
},
],
},
],
db_save_result: null,
});
return joinInterfaceBuilderDispatch(ui.graph, metadata).nodes.find(
({ id }) => id === "native:function:0x10000077c",
);
};
it.each([
{ label: "mapped selected-slice", offset: 34684, expected: 34684 },
{ label: "unmapped", offset: null, expected: null },
{
label: "older Evidence without a mapped",
offset: undefined,
expected: null,
},
])(
"pairs the handler address only with its $label implementation offset",
({ offset, expected }) => {
const handler = handlerFor(offset);
expect(handler?.location).toEqual({
address: "0x10000077c",
file_offset: expected,
});
// The method record keeps its own coordinates as Evidence.
expect(handler?.evidence.map(({ location }) => location)).toEqual([
record,
]);
},
);
});
describe("Objective-C category ownership joins", () => {
it.each([
{
@@ -389,9 +389,11 @@ export const joinInterfaceBuilderDispatch = (
id: functionId,
kind: "function",
name: `${implementation.class_name} ${selector}`,
// `implementation.location` is the method record, which stays in its
// Evidence. Only the reader's mapping of the code address locates it.
location: {
address: implementation.implementation_address,
file_offset: implementation.location.file_offset,
file_offset: implementation.implementation_file_offset ?? null,
},
attributes: {
class_name: implementation.class_name,
+4 -2
View File
@@ -139,10 +139,12 @@ describe("symbol-only Objective-C implementation addresses", () => {
1,
);
expect(result.objc_classes[0]?.methods[0]?.address).toBe(numeric);
// A provider symbol names an address; it does not map it into the file.
expect(result.objc_dispatch_implementations[0]).toMatchObject({
implementation_address: canonical,
location: { address: canonical },
evidence: [{ location: { address: source } }],
implementation_file_offset: null,
location: { address: canonical, file_offset: null },
evidence: [{ location: { address: source, file_offset: null } }],
});
},
);
+7
View File
@@ -184,6 +184,12 @@ export const objcDispatchImplementationSchema = z.strictObject({
selector: z.string().min(1),
method_type: z.enum(["instance", "class"]),
implementation_address: z.string().nullable(),
/**
* File offset of `implementation_address`, in the same artifact coordinates
* as `location.file_offset`. Null when no reader mapped the implementation;
* absent in older Evidence. `location` is the method record, not the code.
*/
implementation_file_offset: z.int().nonnegative().nullable().optional(),
/** Category that adds this method, when it is not defined by the class itself. */
category: z.string().nullable().optional(),
location: nativeMetadataLocationSchema,
@@ -598,6 +604,7 @@ export const inspectNativeDispatchMetadata = (
selector,
method_type: methodType,
implementation_address: address,
implementation_file_offset: null,
location: { address, file_offset: null },
decode: { status: "partial", reason: "symbol_name_only" },
evidence,
+9 -1
View File
@@ -1212,11 +1212,13 @@ describe("Apple dispatch binary metadata", () => {
},
{ name: "Fixture", is_meta_class: true },
]);
// The method entry and its code are distinct locations in one image.
expect(result.objc_dispatch_implementations).toMatchObject([
{
selector: "performAction:",
implementation_address: "0x100000700",
location: { file_offset: 0x488 },
implementation_file_offset: 0x700,
location: { address: "0x100000488", file_offset: 0x488 },
decode: { status: "decoded" },
},
]);
@@ -1315,6 +1317,8 @@ describe("Apple dispatch coverage ownership", () => {
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
expect(result.objc_dispatch_implementations[0]).toMatchObject({
implementation_address: null,
implementation_file_offset: null,
location: { address: "0x100000488", file_offset: 0x488 },
decode: { status: "partial" },
});
expect(
@@ -1387,6 +1391,8 @@ describe("Apple dispatch coverage ownership", () => {
category: "FixtureCategory",
class_name: "(FixtureCategory)",
implementation_address: "0x100000700",
implementation_file_offset: 0x700,
location: { address: "0x100000b88", file_offset: 0xb88 },
}),
);
expect(
@@ -1951,6 +1957,7 @@ describe("universal Apple dispatch metadata", () => {
});
expect(result.objc_dispatch_implementations[0]).toMatchObject({
implementation_address: "0x100000700",
implementation_file_offset: offset + 0x700,
location: { file_offset: offset + 0x488 },
});
});
@@ -2035,6 +2042,7 @@ describe("byte-swapped universal Apple dispatch metadata", () => {
});
expect(result.objc_dispatch_implementations[0]).toMatchObject({
implementation_address: "0x100000700",
implementation_file_offset: 256 + 0x700,
location: { file_offset: 256 + 0x488 },
});
},
+1
View File
@@ -188,6 +188,7 @@ export const decodeObjcDispatchFacets = (input: {
method_type: methodType,
...(category === undefined ? {} : { category }),
implementation_address: implementationLocation?.address ?? null,
implementation_file_offset: implementationLocation?.file_offset ?? null,
location: read.location(entry),
decode:
implementationLocation === null
@@ -0,0 +1,271 @@
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { buildBinary } from "plist";
import { describe, expect } from "vitest";
import { z } from "zod";
import { nativeInvestigationTraceSchema } from "../../src/domain/native/nativeInvestigationGraph.js";
import { nativeDispatchMetadataResultSchema } from "../../src/domain/native/objcSwiftMetadata.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
import type { TestProcesses } from "../support/process/processFixture.js";
const HANDLER = "-[AuditController runAction:]";
const SOURCE = `#import <Cocoa/Cocoa.h>
__attribute__((noinline)) void audit_tail_sink(id value) { __asm__ volatile("" : : "r"(value) : "memory"); }
@interface AuditController : NSViewController
- (IBAction)runAction:(id)sender;
@end
@implementation AuditController
- (IBAction)runAction:(id)sender { audit_tail_sink(sender); }
@end
int main(void) { return 0; }
`;
/** Keep provider selection on REA's own Interface Builder and Mach-O readers. */
const LOCAL_READERS = {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
GHIDRA_INSTALL_DIR: "/rea-unconfigured-deep-provider/ghidra",
JAVA_HOME: "/rea-unconfigured-deep-provider/java",
REA_IDA_MCP_CONFIG: "/rea-unconfigured-deep-provider/ida-mcp.json",
REA_KUNA_COMMAND: "/rea-unconfigured-deep-provider/kuna",
REA_KUNA_CACHE_DIR: "/rea-unconfigured-deep-provider/kuna-cache",
};
const evidenceOf = <Schema extends z.ZodType>(schema: Schema) =>
z.object({
subject: z.object({ architecture: z.string() }),
normalized_result: schema,
});
const traceEvidence = evidenceOf(nativeInvestigationTraceSchema);
const metadataEvidence = evidenceOf(nativeDispatchMetadataResultSchema);
/** Bundle a compiled handler with a keyed-archive NIB action wired to it. */
const writeApp = async (root: string, executable: string) => {
const app = join(root, "Audit.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await mkdir(join(contents, "Resources"));
await copyFile(executable, join(contents, "MacOS", "Audit"));
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Audit</string></dict></plist>",
);
await writeFile(
join(contents, "Resources", "Main.nib"),
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 4 } },
{ $class: { UID: 5 }, NSClassName: "AuditController" },
{
$class: { UID: 6 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "runAction:",
},
{
$classname: "NSButton",
$classes: ["NSButton", "NSControl", "NSView", "NSObject"],
},
{
$classname: "NSCustomObject",
$classes: ["NSCustomObject", "NSObject"],
},
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"],
},
],
$top: { root: { UID: 1 } },
}),
);
return { app, executable: join(contents, "MacOS", "Audit") };
};
/** Map the handler symbol through Apple's own symbol, load-command and FAT readers. */
const independentHandlerLocation = async (
processes: TestProcesses,
executable: string,
architecture: string,
) => {
const tool = async (...arguments_: string[]) => {
const result = await processes.run("/usr/bin/xcrun", arguments_, {
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(0);
return result.stdout;
};
const symbol = (await tool("nm", "-arch", architecture, executable))
.split("\n")
.find((line) => line.endsWith(` ${HANDLER}`));
expect(symbol, `nm lacks ${HANDLER}`).toBeDefined();
const address = BigInt(`0x${symbol!.trim().split(/\s+/u)[0]!}`);
const segment = [
...(await tool("otool", "-arch", architecture, "-l", executable)).matchAll(
/cmd LC_SEGMENT_64\n\s+cmdsize \d+\n\s+segname \S+\n\s+vmaddr (0x[0-9a-f]+)\n\s+vmsize 0x[0-9a-f]+\n\s+fileoff (\d+)\n\s+filesize (\d+)/gu,
),
]
.map(([, vmaddr, fileoff, filesize]) => ({
address: BigInt(vmaddr!),
offset: BigInt(fileoff!),
size: BigInt(filesize!),
}))
.find(
(candidate) =>
candidate.address <= address &&
address < candidate.address + candidate.size,
);
expect(segment, `no file-backed segment maps ${HANDLER}`).toBeDefined();
const fat = await tool("otool", "-f", "-v", executable);
const slice = !fat.startsWith("Fat headers")
? "0"
: /^\s+offset (\d+)$/mu.exec(
fat
.split(/^architecture /mu)
.find((block) => block.startsWith(`${architecture}\n`)) ?? "",
)?.[1];
expect(slice, `otool lacks the ${architecture} slice`).toBeDefined();
return {
address: `0x${address.toString(16)}`,
file_offset: Number(
BigInt(slice!) + segment!.offset + address - segment!.address,
),
};
};
describe.skipIf(process.platform !== "darwin")(
"compiled Objective-C UI handler locations",
() => {
cliTest(
"locates the handler's code, not its method record, in thin and universal images",
async ({ cli, processes }) => {
const root = await createTestTempDirectory("rea-ui-handler-location-");
const source = join(root, "handler.m");
await writeFile(source, SOURCE);
const { call } = await connectLocalToolsMcp(LOCAL_READERS);
const good = async (name: string, arguments_: object) => {
const result = await call(name, { ...arguments_ });
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
return result.structuredContent;
};
for (const variant of [
// arm64 always uses relative method lists.
{ name: "arm64", flags: ["-arch", "arm64"], encoding: "relative" },
// A pre-macOS 11 x86_64 image keeps absolute method lists.
{
name: "x86_64",
flags: ["-arch", "x86_64", "-mmacosx-version-min=10.15"],
encoding: "absolute",
},
// The selected slice's offsets are relative to the container.
{
name: "universal",
flags: [
"-arch",
"x86_64",
"-arch",
"arm64",
"-mmacosx-version-min=10.15",
],
encoding: undefined,
},
]) {
const directory = join(root, variant.name);
await mkdir(directory);
const compiled = await processes.run(
"/usr/bin/xcrun",
[
"clang",
...variant.flags,
"-O2",
"-framework",
"Cocoa",
source,
"-o",
join(directory, "Audit"),
],
{ timeoutMs: 60_000 },
);
expect(compiled.exitCode, compiled.stderr).toBe(0);
const { app, executable } = await writeApp(
directory,
join(directory, "Audit"),
);
const before = await readFile(executable);
await good("open_binary", { path: app });
const traced = traceEvidence.parse(
await good("trace_native_ui_action", { action: "runAction:" }),
);
const metadata = metadataEvidence.parse(
await good("inspect_native_dispatch_metadata", {}),
);
await good("close_binary", {});
const expected = await independentHandlerLocation(
processes,
executable,
traced.subject.architecture,
);
const functionId = `native:function:${expected.address}`;
const trace = traced.normalized_result;
expect(trace.edges, variant.name).toContainEqual(
expect.objectContaining({
relation: "objc_dispatch",
resolution: "resolved",
to: functionId,
}),
);
const handler = trace.nodes.find(({ id }) => id === functionId);
expect(handler?.location, variant.name).toEqual(expected);
const record =
metadata.normalized_result.result.objc_dispatch_implementations.find(
({ class_name, selector }) =>
class_name === "AuditController" && selector === "runAction:",
);
expect(record, variant.name).toMatchObject({
implementation_address: expected.address,
implementation_file_offset: expected.file_offset,
});
// The fixture is discriminating only while the record lies elsewhere.
expect(record?.location.file_offset).not.toBe(expected.file_offset);
expect(handler?.evidence, variant.name).toEqual([
expect.objectContaining({
kind: "binary_metadata",
description:
variant.encoding === undefined
? expect.any(String)
: `Encoded ${variant.encoding} Objective-C method entry`,
location: record?.location,
}),
]);
if (variant.name === "universal") {
const output = await cli.run({
arguments: [
"trace-native-ui-action",
app,
"runAction:",
"--json",
],
environment: LOCAL_READERS,
});
expect(output.exitCode, output.stderr).toBe(0);
const cliTrace = traceEvidence.parse(output.json).normalized_result;
expect(cliTrace.nodes.find(({ id }) => id === functionId)).toEqual(
handler,
);
}
expect(Buffer.compare(await readFile(executable), before)).toBe(0);
}
},
120_000,
);
},
);
@@ -533,6 +533,11 @@ const assertSuccessfulRoute = (
to: `native:function:${handlerAddress}`,
}),
);
// A provider symbol names the handler address but maps no file bytes.
expect(
trace.nodes.find(({ id }) => id === `native:function:${handlerAddress}`)
?.location,
).toEqual({ address: handlerAddress, file_offset: null });
expect(calls).toContain("decode_interface_builder");
expect(calls).toContain(
scenario.name === "authored-ui-direct"