Compare commits

...
Author SHA1 Message Date
morluto 5ce9776a19 fix(ghidra): reconcile current main formatting 2026-11-07 23:16:20 +08:00
morluto 5b2bb57247 fix(ghidra): reconcile overlay coverage with main 2026-11-07 22:37:54 +08:00
Cursor Agent 3fc27b45cd docs(ghidra): match the raw lane's overlay coverage
The raw Ghidra lane checks a manually created overlay block through MCP.
Say that, and do not claim an overlapping-load ELF, CLI search, or snapshot
cursor handoff.
2026-11-04 19:58:40 +00:00
Cursor Agent b9db66256a merge: report loaded overlays separately from unloaded blocks
Keep contiguous initialized-block search and list loaded overlays in
skipped_overlays. Unloaded blocks, including unloaded overlays, stay in
skipped_not_loaded.
2026-11-04 19:42:48 +00:00
morluto 331f2dd827 test(ghidra): classify search start as an address bound 2026-11-02 08:19:58 +08:00
morluto 2a2fdfc0db test(ghidra): avoid freezing byte search help wording 2026-11-02 07:53:26 +08:00
morluto 5fad84347a test(ghidra): exercise overlay search cursor handoff 2026-11-02 07:43:14 +08:00
morluto 027aea38a2 fix(cli): explain overlay byte search scope 2026-11-02 07:36:39 +08:00
morluto f584290933 fix(ghidra)!: distinguish skipped overlays in byte search
BREAKING CHANGE: Byte search results require skipped_overlays; loaded overlays no longer appear in skipped_not_loaded. The Ghidra profile changes so prior snapshots are not replayed.
2026-11-02 07:31:31 +08:00
17 changed files with 111 additions and 16 deletions

No files matched your search

+9 -1
View File
@@ -876,6 +876,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray blocks = new JsonArray();
JsonArray skipped = new JsonArray();
JsonArray notLoaded = new JsonArray();
JsonArray overlays = new JsonArray();
List<ByteSearchBlock> eligible = new ArrayList<>();
for (MemoryBlock block : ordered) {
monitor.checkCancelled();
@@ -883,10 +884,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address high = block.getEnd();
Address bound = start == null ? end : start;
if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue;
if (bound == null && (!block.isLoaded() || block.isOverlay())) {
// Without bounds, search loaded non-overlay memory. Unloaded file
// parts and loaded overlays stay reachable through explicit bounds.
if (bound == null && !block.isLoaded()) {
notLoaded.add(byteSearchCoverage(block, low, high));
continue;
}
if (bound == null && block.isOverlay()) {
overlays.add(byteSearchCoverage(block, low, high));
continue;
}
if (start != null && low.compareTo(start) < 0) low = start;
if (end != null && high.compareTo(end) > 0) high = end;
if (low.compareTo(high) > 0) continue;
@@ -960,6 +967,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
searched.addProperty("bytes", covered);
searched.add("skipped_uninitialized", skipped);
searched.add("skipped_not_loaded", notLoaded);
searched.add("skipped_overlays", overlays);
JsonObject result = new JsonObject();
result.add("matches", matches);
result.add("searched", searched);
+11 -4
View File
@@ -335,10 +335,17 @@ must be constrained.
Optional `start` and `end` addresses are inclusive and select one address space.
A match must end at or before `end`, so `end` set inside a match excludes it.
Without bounds, loaded memory blocks are eligible, in Ghidra address order.
Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are
listed in `searched.skipped_not_loaded` and are not searched; pass `start` or
`end` in their address space to search them.
Without bounds, loaded non-overlay memory blocks are eligible, in Ghidra address
order, including across adjacent initialized blocks. Blocks that are not loaded
(file parts such as `.symtab`) are listed in `searched.skipped_not_loaded`;
loaded overlay blocks are listed separately in `searched.skipped_overlays`.
Neither is searched by default; pass `start` or `end` in its address space to
search it. A block that is both unloaded and an overlay is listed once under
`skipped_not_loaded`.
The separate overlay field changes the byte-search result contract and the
Ghidra analysis profile. Refresh cached MCP schemas; snapshots from the older
profile are not replayed. Previously exported Evidence remains importable as
historical data with its original result shape.
Overlapping matches are included, including across adjacent eligible initialized
blocks in the same address space. Gaps and excluded or uninitialized blocks break
a search run. Each match reports its address, starting segment, unique source file
+1 -1
View File
@@ -531,7 +531,7 @@ the entry changes without `provider_id`, preservation of a session annotation
and the target/provider receipt after a rejected open, unchanged source bytes,
and owned cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. AArch64 remains covered by deterministic tests rather than
known return value. On a disposable Ghidra project, MCP `search_bytes` also checks a manually created loaded `overlay_test` block: an unbounded search lists it in `skipped_overlays` and not in `skipped_not_loaded`, and bounds in that overlay space find the pattern. This lane does not build an overlapping-load ELF, does not call CLI `search-bytes`, and does not page an overlay search across a snapshot reopen. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](raw-binary.md).
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
+6 -1
View File
@@ -760,7 +760,12 @@ async function verifyCrossBlockProject(target, targetSha256) {
!all.matches.some((match) => match.address.startsWith("overlay_test:")),
);
assert.ok(
all.searched.skipped_not_loaded.some(
!all.searched.skipped_not_loaded.some(
(block) => block.segment === "overlay_test",
),
);
assert.ok(
all.searched.skipped_overlays.some(
(block) => block.segment === "overlay_test",
),
);
+14 -3
View File
@@ -51,15 +51,26 @@ export const registerCoreNativeCommands = (
): void => {
registerAnnotationCommand(cli, logger, runDirectAnalysis);
cli.command(CLI_COMMANDS.searchBytes, {
description: "Find masked byte patterns in initialized provider memory",
description:
"Find masked bytes; unbounded searches skip unloaded blocks and overlays",
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
pattern: bytePatternFields.pattern,
}),
options: z.object({
mask: bytePatternFields.mask,
start: z.string().optional().describe("Inclusive start address"),
end: z.string().optional().describe("Inclusive end address"),
start: z
.string()
.optional()
.describe(
"Inclusive start address; use an overlay address space to search it",
),
end: z
.string()
.optional()
.describe(
"Inclusive end address; use an overlay address space to search it",
),
cursor: z
.string()
.optional()
+1 -1
View File
@@ -11,7 +11,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
{
name: "search_bytes",
description:
"Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
"Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Without bounds, unloaded blocks and loaded overlays are skipped and listed separately; select an overlay address space with start or end to search it. Matches are ordered by provider address and may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
inputSchema: z
.strictObject({
...bytePatternFields,
+2
View File
@@ -22,6 +22,7 @@ it.each(["0x20", "overlay:0x10", "invalid"])(
bytes: 16,
skipped_uninitialized: [],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: true,
next_cursor: null,
@@ -52,6 +53,7 @@ it.each(["", "overlay:"])(
bytes: 16,
skipped_uninitialized: [],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: true,
next_cursor: null,
+15 -2
View File
@@ -165,9 +165,22 @@ export const byteSearchResultSchema = z
blocks: z.array(coverageBlock),
bytes: z.int().nonnegative().max(Number.MAX_SAFE_INTEGER),
skipped_uninitialized: z.array(coverageBlock),
skipped_not_loaded: z.array(coverageBlock),
skipped_not_loaded: z
.array(coverageBlock)
.describe(
"Unloaded blocks skipped by an unbounded search, including unloaded overlays.",
),
skipped_overlays: z
.array(coverageBlock)
.describe(
"Loaded overlay blocks skipped by an unbounded search; select their address space with a bound.",
),
}),
complete: z.boolean(),
complete: z
.boolean()
.describe(
"No further matches remain in the eligible selection; skipped blocks were not searched.",
),
next_cursor: z.string().min(1).nullable(),
})
.superRefine((value, context) => {
+1
View File
@@ -70,6 +70,7 @@ const sharedAnalysisSemantics = {
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_status: "unlisted-address-without-decode-v1",
instruction_references: "exact-address-incoming-without-synthetic-entry-v3",
byte_search_coverage: "separate-loaded-overlays-v1",
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
} as const;
+10
View File
@@ -367,6 +367,14 @@ const byteSearch = (space = "") => ({
bytes: 16,
},
],
skipped_overlays: [
{
segment: "overlay",
start: `${space}0x0040`,
end: `${space}0x004f`,
bytes: 16,
},
],
},
complete: true,
next_cursor: null as string | null,
@@ -392,6 +400,8 @@ it.each([
["uninitialized end", ["searched", "skipped_uninitialized", 0, "end"]],
["unloaded start", ["searched", "skipped_not_loaded", 0, "start"]],
["unloaded end", ["searched", "skipped_not_loaded", 0, "end"]],
["overlay start", ["searched", "skipped_overlays", 0, "start"]],
["overlay end", ["searched", "skipped_overlays", 0, "end"]],
] as const)(
"rejects noncanonical %s at its owning address field",
(_label, path) => {
+1
View File
@@ -445,6 +445,7 @@ const resultSchemas = {
blocks: z.array(canonicalByteCoverage),
skipped_uninitialized: z.array(canonicalByteCoverage),
skipped_not_loaded: z.array(canonicalByteCoverage),
skipped_overlays: z.array(canonicalByteCoverage),
}),
}),
inspect_native_load_image: nativeLoadImageObservationSchema,
+1 -1
View File
@@ -138,7 +138,7 @@ export const limitationsFor = (
case "search_bytes":
return [
...common,
"Searches contiguous eligible initialized loader-adjusted memory in provider address order, including across adjacent blocks. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately and break search runs. Overlapping matches are included. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.",
"Searches contiguous eligible initialized loader-adjusted memory in provider address order, including across adjacent blocks. Bounds and page coverage ends are inclusive. Unbounded searches separately list skipped unloaded blocks and loaded overlays; use an overlay address-space bound to search it. Uninitialized blocks are listed separately and break search runs. Overlapping matches are included. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.",
];
case "address_to_file_offset":
return [
+3 -2
View File
@@ -11,9 +11,9 @@ import { GhidraProvider } from "./GhidraProvider.js";
// bit-field base-type revisions that prevent reuse of snapshots with earlier
// results.
const RAW_DIGESTS = {
"6502": "6f904539c00521b04f76a3bfd1ca88275e080b5751f9e2305de66c20d0bd2fa0",
"6502": "9f1632d3209c7cb3b17be1de28311aedf3a1b23d66ac45ac0006a4cd9a956ac1",
"x86-disabled-analyzer":
"5a534d959baf34abdafaea64ae9fcb9126553802d42ffa5e4bc0426de5f0af8c",
"ab9dd0676e0e647654b70c4dca25e1efeae3867c52623af0cd4a842f3f7aab68",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
@@ -50,6 +50,7 @@ describe("raw-binary analysis profile digest", () => {
"source-mappings-entry-context-relocation-symbol-v3",
instruction_status: "unlisted-address-without-decode-v1",
byte_search_boundary: "contiguous-eligible-initialized-blocks-v1",
byte_search_coverage: "separate-loaded-overlays-v1",
data_type_observations: "bit-field-base-and-lookup-v1",
analyzer_preset: "ghidra-default",
});
+5
View File
@@ -307,6 +307,11 @@ describe("Ghidra relationship coverage replay", () => {
describe("Ghidra snapshot profile admission", () => {
it.each([
{
parameter: "byte_search_coverage",
current: "separate-loaded-overlays-v1",
legacy: undefined,
},
{
parameter: "data_type_observations",
current: "bit-field-base-and-lookup-v1",
@@ -75,6 +75,14 @@ it("retains byte-search coverage and rejects malformed pages before creating Evi
skipped_not_loaded: [
{ segment: ".symtab", start: "0x0", end: "0x17f", bytes: 384 },
],
skipped_overlays: [
{
segment: "overlay",
start: "overlay:0x401000",
end: "overlay:0x40100f",
bytes: 16,
},
],
},
complete: false,
next_cursor: "continuation",
@@ -116,6 +124,27 @@ it("retains byte-search coverage and rejects malformed pages before creating Evi
expect(parseMcpToolError(malformed)).toMatchObject({
error: { code: "unreadable_output" },
});
output = jsonValueSchema.parse({
...page,
searched: {
...page.searched,
skipped_overlays: [
{
segment: "overlay",
start: "overlay:0x401000",
end: "overlay:0x40100f",
bytes: 15,
},
],
},
});
const invalidOverlay = await harness.mcp.callTool({
name: "search_bytes",
arguments: { pattern: "48 ??" },
});
expect(parseMcpToolError(invalidOverlay)).toMatchObject({
error: { code: "unreadable_output" },
});
for (const block of [
{ segment: ".text", start: "0x1000", end: "0x1009", bytes: 1 },
{ segment: ".text", start: "0x1009", end: "0x1000", bytes: 1 },
@@ -101,6 +101,7 @@ describe("official operation arguments", () => {
bytes: 0,
skipped_uninitialized: [],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: true,
next_cursor: null,
+1
View File
@@ -636,6 +636,7 @@ const inventoryResultFor = (method, params) => {
},
],
skipped_not_loaded: [],
skipped_overlays: [],
},
complete: !paged,
next_cursor: paged ? "fixture-second-page" : null,