mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat(ghidra): admit set_address_name and set_addresses_names (#1635)
* feat(ghidra): admit set_address_name and set_addresses_names Ghidra sessions rejected the existing naming tools with "operation is not declared by this provider", so agents had to use annotate_native_function, which returns a complete function dossier per rename and cannot name data addresses. The Java bridge now names a local function entry (namespace-aware, like annotate_native_function) or creates/renames the primary label at any other mapped address, verifying readback. set_addresses_names applies each address in its own transaction and reports per-address booleans. Both are mutating capabilities: excluded from read-only (Windows P0) handshakes and from snapshot replay. * fix(ghidra): normalize qualified symbol names and reject empty leaves Share namespace-aware leaf normalization across functions and primary labels. Invalid qualified leaves now return false for naming operations without marking the provider unavailable; function annotations retain typed invalid-input errors. Extend real CLI/MCP namespace verification with namespaced globals, readback replay, failed-name rollback, mixed batches, and capability checks. Validation: 75 focused tests and npm run check pass. Ghidra 12.1.4/JDK 21 namespace verification passes on macOS arm64 with unchanged source bytes and complete cleanup. The full host lane passes this workflow before the existing entry-alias fixture fails because the compiler places its interior label at the function entry. --------- Co-authored-by: morluto <[email protected]>
This commit is contained in:
@@ -152,7 +152,7 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations.
|
||||
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier.
|
||||
|
||||
## Offline PE resource inventory
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
);
|
||||
private static final String[] CAPABILITIES = {
|
||||
"annotate_native_function",
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"address_to_file_offset",
|
||||
@@ -374,6 +376,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
case "resolve_native_call_targets" -> resolveNativeCallTargets(request.params);
|
||||
case "xrefs" -> xrefs(request.params);
|
||||
case "analyze_function" -> analyzeFunction(request.params);
|
||||
case "set_address_name", "set_addresses_names" -> {
|
||||
if (!descriptor.transport.equals("unix-socket"))
|
||||
throw new RequestFailure("method_unavailable", "Windows P0 does not admit database mutation");
|
||||
yield request.method.equals("set_address_name")
|
||||
? setAddressName(request.params) : setAddressesNames(request.params);
|
||||
}
|
||||
case "annotate_native_function" -> {
|
||||
if (!descriptor.transport.equals("unix-socket"))
|
||||
throw new RequestFailure("method_unavailable", "Windows P0 does not admit database mutation");
|
||||
@@ -417,7 +425,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("analysis_timed_out", timedOut);
|
||||
result.add("capabilities", GSON.toJsonTree(
|
||||
java.util.Arrays.stream(CAPABILITIES)
|
||||
.filter(value -> !readOnly || !value.equals("annotate_native_function"))
|
||||
.filter(value -> !readOnly || !(value.equals("annotate_native_function")
|
||||
|| value.equals("set_address_name") || value.equals("set_addresses_names")))
|
||||
.toArray(String[]::new)
|
||||
));
|
||||
result.add("target", target);
|
||||
@@ -1027,11 +1036,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
for (String field : List.of("name", "comment", "inline_comment")) {
|
||||
if (params.has(field)) validateAnnotationText(requireText(params, field), field);
|
||||
}
|
||||
String leafName = params.has("name")
|
||||
? annotationLeafName(function, requireString(params, "name")) : null;
|
||||
int transaction = currentProgram.startTransaction("REA function annotations");
|
||||
boolean commit = false;
|
||||
try {
|
||||
String leafName = params.has("name")
|
||||
? symbolLeafName(function.getSymbol(), requireString(params, "name")) : null;
|
||||
// Validate edits through Ghidra's own setters, including name rules.
|
||||
// A later invalid name must roll back earlier comment writes.
|
||||
for (String field : List.of("comment", "inline_comment")) {
|
||||
@@ -1080,18 +1089,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
}
|
||||
|
||||
private String annotationLeafName(Function function, String requested) {
|
||||
private String symbolLeafName(Symbol symbol, String requested) throws ghidra.util.exception.InvalidInputException {
|
||||
// Fully qualified readback must be usable as an idempotent rename input.
|
||||
if (requested.equals(procedureName(function))) return function.getName();
|
||||
String namespace = function.getParentNamespace().isGlobal()
|
||||
? "" : function.getParentNamespace().getName(true);
|
||||
if (requested.equals(symbol.getName(true))) return symbol.getName();
|
||||
String namespace = symbol.getParentNamespace().isGlobal()
|
||||
? "" : symbol.getParentNamespace().getName(true);
|
||||
String prefix = namespace + "::";
|
||||
if (!namespace.isEmpty() && requested.startsWith(prefix)) {
|
||||
String leaf = requested.substring(prefix.length());
|
||||
if (!leaf.isEmpty()) return leaf;
|
||||
throw new RequestFailure("invalid_function_name",
|
||||
"Function name has an empty leaf within namespace " + namespace + " at " +
|
||||
canonicalAddress(function.getEntryPoint()) + ": " + requested);
|
||||
throw new ghidra.util.exception.InvalidInputException(
|
||||
"Symbol name has an empty leaf within namespace " + namespace + " at " +
|
||||
canonicalAddress(symbol.getAddress()) + ": " + requested);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
@@ -1416,6 +1425,68 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return symbol == null ? JsonNull.INSTANCE : GSON.toJsonTree(symbol.getName(true));
|
||||
}
|
||||
|
||||
// Analyst naming: a function entry renames the function, other addresses get a primary label.
|
||||
private boolean applyAddressName(Address address, String name) throws Exception {
|
||||
if (!currentProgram.getMemory().contains(address))
|
||||
throw new RequestFailure("invalid_request", "Address is not mapped: " + canonicalAddress(address));
|
||||
validateAnnotationText(name, "name");
|
||||
int transaction = currentProgram.startTransaction("REA address name");
|
||||
boolean commit = false;
|
||||
try {
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
String expected = name;
|
||||
if (function != null) {
|
||||
expected = symbolLeafName(function.getSymbol(), name);
|
||||
function.setName(expected, SourceType.USER_DEFINED);
|
||||
} else {
|
||||
var table = currentProgram.getSymbolTable();
|
||||
Symbol primary = table.getPrimarySymbol(address);
|
||||
if (primary != null && !primary.isDynamic()) {
|
||||
expected = symbolLeafName(primary, name);
|
||||
primary.setName(expected, SourceType.USER_DEFINED);
|
||||
} else {
|
||||
Symbol created = table.createLabel(address, name, SourceType.USER_DEFINED);
|
||||
created.setPrimary();
|
||||
}
|
||||
}
|
||||
invalidateAnalysisCaches();
|
||||
Symbol readback = currentProgram.getSymbolTable().getPrimarySymbol(address);
|
||||
commit = readback != null && expected.equals(readback.getName());
|
||||
return commit;
|
||||
}
|
||||
catch (ghidra.util.exception.InvalidInputException | ghidra.util.exception.DuplicateNameException exception) {
|
||||
return false;
|
||||
}
|
||||
finally {
|
||||
currentProgram.endTransaction(transaction, commit);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonElement setAddressName(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "address", "name"));
|
||||
requireDocument(params);
|
||||
return GSON.toJsonTree(applyAddressName(requireAddress(params, "address"), requireString(params, "name")));
|
||||
}
|
||||
|
||||
private JsonElement setAddressesNames(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "names"));
|
||||
requireDocument(params);
|
||||
if (!params.has("names") || !params.get("names").isJsonObject())
|
||||
throw new RequestFailure("invalid_request", "names must be an object of address -> name");
|
||||
JsonObject result = new JsonObject();
|
||||
for (Map.Entry<String, JsonElement> entry : params.getAsJsonObject("names").entrySet()) {
|
||||
boolean ok;
|
||||
try {
|
||||
ok = applyAddressName(parseReaAddress(entry.getKey()), entry.getValue().getAsString());
|
||||
}
|
||||
catch (RequestFailure failure) {
|
||||
ok = false;
|
||||
}
|
||||
result.addProperty(entry.getKey(), ok);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonArray listDocuments(JsonObject params) {
|
||||
requireKeys(params, Set.of());
|
||||
JsonArray result = new JsonArray();
|
||||
|
||||
@@ -610,8 +610,10 @@ dossiers, instructions, recovered data types, measured load mappings, loaded
|
||||
memory bytes, and observed file offsets. Independent load-image attestation
|
||||
supports DOS MZ and explicitly selected COM; PE returns its measurements with that limitation.
|
||||
On Linux and macOS, `annotate_native_function` also edits a function name and/or
|
||||
entry comments atomically and returns refreshed analysis. These session metadata
|
||||
edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
entry comments atomically and returns refreshed analysis, and `set_address_name`
|
||||
and `set_addresses_names` name a function entry or any other mapped address
|
||||
(creating or renaming its primary label) with per-address success results. These
|
||||
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||
require Hopper; Windows P0 remains read-only.
|
||||
|
||||
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
||||
|
||||
@@ -397,6 +397,12 @@ unpaired Unicode surrogate; a rejection identifies the field and UTF-16 index
|
||||
and leaves every annotation unchanged. CRLF, supplementary Unicode characters,
|
||||
and combining characters are preserved. Windows P0 remains read-only. Ghidra has no
|
||||
GUI authority, and REA never falls back automatically to Hopper.
|
||||
`set_address_name` and `set_addresses_names` give Ghidra sessions the same
|
||||
compact naming results as Hopper: a local function entry renames that function,
|
||||
and any other mapped address, such as a global, receives a new or renamed primary
|
||||
label. A batch applies each address separately and reports one success boolean
|
||||
per address. Use them to name many globals and helpers cheaply; use
|
||||
`annotate_native_function` when you also want comments and a refreshed dossier.
|
||||
Ghidra name edits preserve the existing namespace. Supply either a leaf name
|
||||
such as `renamed` or a fully qualified name in that namespace, such as
|
||||
`alpha::renamed`. The returned qualified name can be reused as an idempotent
|
||||
|
||||
@@ -54,6 +54,14 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
);
|
||||
return { namespace, address: procedure.address };
|
||||
});
|
||||
const names = await call("list_names");
|
||||
const globals = ["alpha", "outer::inner"].map((namespace) => {
|
||||
const symbol = names.find(
|
||||
(item) => item.value === `${namespace}::value` && item.symbol.primary,
|
||||
);
|
||||
assert.ok(symbol, `Missing demangled fixture global ${namespace}::value`);
|
||||
return { namespace, address: symbol.address };
|
||||
});
|
||||
const ambiguous = await reject("procedure_address", { procedure: "same" });
|
||||
assert.equal(ambiguous.code, "invalid_request");
|
||||
for (const { address } of entries)
|
||||
@@ -125,6 +133,16 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
});
|
||||
assert.deepEqual(unchanged.annotations, qualified.annotations);
|
||||
}
|
||||
await verifyAddressNames(call, [entries[0], entries[2], ...globals]);
|
||||
for (const { namespace, address } of entries) {
|
||||
assert.equal(
|
||||
await call("set_address_name", {
|
||||
address,
|
||||
name: `${namespace}::qualified`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -159,6 +177,96 @@ export async function verifyGhidraNamespaceAnnotations({
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAddressNames(call, symbols) {
|
||||
for (const { namespace, address } of symbols) {
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: "address_renamed" }),
|
||||
true,
|
||||
);
|
||||
const qualified = `${namespace}::address_qualified`;
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: qualified }),
|
||||
true,
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), qualified);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: "other::literal" }),
|
||||
true,
|
||||
);
|
||||
const literal = await call("address_name", { address });
|
||||
assert.equal(literal, `${namespace}::other::literal`);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: literal }),
|
||||
true,
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), literal);
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: qualified }),
|
||||
true,
|
||||
);
|
||||
for (let repeat = 0; repeat < 2; repeat++) {
|
||||
const readback = await call("address_name", { address });
|
||||
assert.deepEqual(
|
||||
await call("set_addresses_names", { names: { [address]: readback } }),
|
||||
{ [address]: true },
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), qualified);
|
||||
}
|
||||
assert.equal(
|
||||
await call("set_address_name", { address, name: `${namespace}::` }),
|
||||
false,
|
||||
);
|
||||
const session = await call("binary_session");
|
||||
for (const operation of [
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
"address_name",
|
||||
"list_names",
|
||||
])
|
||||
assert.equal(
|
||||
session.capabilities.find((item) => item.operation === operation)
|
||||
?.available,
|
||||
true,
|
||||
`An invalid name must not mark ${operation} unavailable`,
|
||||
);
|
||||
assert.equal(await call("address_name", { address }), qualified);
|
||||
}
|
||||
const [valid, invalid] = symbols;
|
||||
assert.deepEqual(
|
||||
await call("set_addresses_names", {
|
||||
names: {
|
||||
[valid.address]: `${valid.namespace}::batch_renamed`,
|
||||
[invalid.address]: `${invalid.namespace}::`,
|
||||
},
|
||||
}),
|
||||
{ [valid.address]: true, [invalid.address]: false },
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: valid.address }),
|
||||
`${valid.namespace}::batch_renamed`,
|
||||
);
|
||||
assert.equal(
|
||||
await call("address_name", { address: invalid.address }),
|
||||
`${invalid.namespace}::address_qualified`,
|
||||
);
|
||||
const names = await call("list_names");
|
||||
for (const { namespace, address } of symbols) {
|
||||
const expected =
|
||||
address === valid.address
|
||||
? `${namespace}::batch_renamed`
|
||||
: `${namespace}::address_qualified`;
|
||||
assert.ok(
|
||||
names.some(
|
||||
(item) =>
|
||||
item.address === address &&
|
||||
item.value === expected &&
|
||||
item.symbol.primary,
|
||||
),
|
||||
`Missing refreshed primary symbol ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireNamespaceDemangler(env) {
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: process.env,
|
||||
|
||||
@@ -13,7 +13,10 @@ import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
|
||||
/** Read-only direct inventory operations admitted by the Ghidra adapter. */
|
||||
/**
|
||||
* Direct inventory operations admitted by the Ghidra adapter. All are reads
|
||||
* except the naming operations, which edit the ephemeral session database.
|
||||
*/
|
||||
export const GHIDRA_INVENTORY_OPERATIONS = [
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
@@ -28,8 +31,16 @@ export const GHIDRA_INVENTORY_OPERATIONS = [
|
||||
"resolve_containing_procedure",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
] as const;
|
||||
|
||||
/** Inventory operations that write analyst names into the session database. */
|
||||
export const GHIDRA_NAMING_OPERATIONS = [
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
] as const satisfies readonly GhidraInventoryOperation[];
|
||||
|
||||
/** One direct inventory operation implemented by the packaged Java bridge. */
|
||||
export type GhidraInventoryOperation =
|
||||
(typeof GHIDRA_INVENTORY_OPERATIONS)[number];
|
||||
@@ -83,6 +94,19 @@ const inputSchemas = {
|
||||
.strict(),
|
||||
search_procedures: z.object(searchInput).strict(),
|
||||
search_strings: z.object(searchInput).strict(),
|
||||
set_address_name: z
|
||||
.object({ document, address: explicitAddress, name: z.string().min(1) })
|
||||
.strict(),
|
||||
set_addresses_names: z
|
||||
.object({
|
||||
document,
|
||||
names: z.record(explicitAddress, z.string().min(1)),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => Object.keys(value.names).length > 0,
|
||||
"Supply at least one address name",
|
||||
),
|
||||
} satisfies Readonly<Record<GhidraInventoryOperation, z.ZodType>>;
|
||||
|
||||
/** Validate and default one provider request before it crosses the socket. */
|
||||
@@ -281,6 +305,8 @@ const resultSchemas = {
|
||||
resolve_containing_procedure: containingProcedure,
|
||||
search_procedures: z.array(searchItem),
|
||||
search_strings: z.array(searchItem),
|
||||
set_address_name: z.boolean(),
|
||||
set_addresses_names: z.record(z.string(), z.boolean()),
|
||||
} satisfies Readonly<Record<GhidraInventoryOperation, z.ZodType>>;
|
||||
|
||||
/** Validate exact Java-bridge output before creating Evidence. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../process/WindowsAuthority.js";
|
||||
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js";
|
||||
import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
|
||||
import { GHIDRA_MUTATING_OPERATIONS } from "./GhidraSessionValues.js";
|
||||
|
||||
/** Public identity committed by every Ghidra-backed observation. */
|
||||
export const GHIDRA_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
|
||||
@@ -50,6 +51,14 @@ export const limitationsFor = (operation: string): readonly string[] => {
|
||||
"Annotation text rejects NUL and unpaired Unicode surrogates before mutation, with the field and UTF-16 index in the error. Supported Unicode and line endings are preserved.",
|
||||
"Metadata edits invalidate immutable analysis snapshots and are discarded on close. CLI returns the updated dossier before session cleanup; this is not a saved Ghidra project.",
|
||||
];
|
||||
case "set_address_name":
|
||||
case "set_addresses_names":
|
||||
return [
|
||||
...common,
|
||||
"Names use Ghidra USER_DEFINED source. A local function entry renames that function; any other mapped address receives a new or renamed primary label. Existing symbols retain their namespace and accept leaf names or qualified names within that namespace; qualified readback can be reused without adding namespace prefixes. Readback compares the primary symbol name.",
|
||||
"set_addresses_names applies each address in its own transaction and reports per-address success; invalid names, unmapped addresses, and readback mismatches roll back only that address.",
|
||||
"Metadata edits invalidate immutable analysis snapshots and are discarded on close; this is not a saved Ghidra project.",
|
||||
];
|
||||
case "inspect_native_load_image":
|
||||
return [
|
||||
...common,
|
||||
@@ -162,12 +171,12 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
reason: null,
|
||||
// Project writes are private import machinery. Analysis reads are bound
|
||||
// to the artifact/profile; session annotations disable snapshot replay.
|
||||
...(operation === "annotate_native_function" ||
|
||||
...(GHIDRA_MUTATING_OPERATIONS.has(operation) ||
|
||||
operation === "list_documents"
|
||||
? {}
|
||||
: { cachePolicy: "snapshot" as const }),
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: operation === "annotate_native_function",
|
||||
mutatesArtifact: GHIDRA_MUTATING_OPERATIONS.has(operation),
|
||||
launchesProcess: true,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GHIDRA_SESSION_CAPABILITIES,
|
||||
GHIDRA_MUTATING_OPERATIONS,
|
||||
parseGhidraSessionInfo,
|
||||
} from "./GhidraSessionValues.js";
|
||||
|
||||
@@ -81,7 +82,7 @@ describe("Ghidra mutation handshake", () => {
|
||||
...value,
|
||||
read_only: true,
|
||||
capabilities: value.capabilities.filter(
|
||||
(c) => c !== "annotate_native_function",
|
||||
(c) => !GHIDRA_MUTATING_OPERATIONS.has(c),
|
||||
),
|
||||
};
|
||||
expect(
|
||||
@@ -92,4 +93,24 @@ describe("Ghidra mutation handshake", () => {
|
||||
parseGhidraSessionInfo(value, { ...expected, expectedReadOnly: true }).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
it("treats address naming as database mutation", () => {
|
||||
expect([...GHIDRA_MUTATING_OPERATIONS].sort()).toEqual([
|
||||
"annotate_native_function",
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
]);
|
||||
const value = session();
|
||||
for (const naming of ["set_address_name", "set_addresses_names"]) {
|
||||
expect(value.capabilities).toContain(naming);
|
||||
expect(
|
||||
parseGhidraSessionInfo(
|
||||
{
|
||||
...value,
|
||||
capabilities: value.capabilities.filter((c) => c !== naming),
|
||||
},
|
||||
expected,
|
||||
).ok,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@ import { z } from "zod";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
|
||||
import {
|
||||
GHIDRA_INVENTORY_OPERATIONS,
|
||||
GHIDRA_NAMING_OPERATIONS,
|
||||
} from "./GhidraInventoryValues.js";
|
||||
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js";
|
||||
import { ghidraExtensionResultSchema } from "./extensions/GhidraExtensions.js";
|
||||
|
||||
@@ -14,6 +17,12 @@ export const GHIDRA_SESSION_CAPABILITIES = [
|
||||
...GHIDRA_FUNCTION_OPERATIONS,
|
||||
] as const;
|
||||
|
||||
/** Methods that edit the session database; read-only bridges omit them. */
|
||||
export const GHIDRA_MUTATING_OPERATIONS: ReadonlySet<string> = new Set([
|
||||
"annotate_native_function",
|
||||
...GHIDRA_NAMING_OPERATIONS,
|
||||
]);
|
||||
|
||||
const capabilitySchema = z.enum(GHIDRA_SESSION_CAPABILITIES);
|
||||
|
||||
const sessionInfoSchema = z
|
||||
@@ -64,7 +73,7 @@ export const parseGhidraSessionInfo = (
|
||||
const parsed = sessionInfoSchema.safeParse(value);
|
||||
const readOnly = expected.expectedReadOnly ?? false;
|
||||
const expectedCapabilities = GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
(capability) => !readOnly || capability !== "annotate_native_function",
|
||||
(capability) => !readOnly || !GHIDRA_MUTATING_OPERATIONS.has(capability),
|
||||
);
|
||||
if (
|
||||
!parsed.success ||
|
||||
|
||||
@@ -62,6 +62,66 @@ it("rejects contradictory annotation readback across the provider and MCP bounda
|
||||
}
|
||||
});
|
||||
|
||||
it("routes address naming to Ghidra with compact per-address results", async () => {
|
||||
const requests: { operation: string; input: unknown }[] = [];
|
||||
let output: unknown = null;
|
||||
const harness = await connectGhidraMcp(
|
||||
"ghidra-address-naming",
|
||||
(operation, input) => {
|
||||
requests.push({ operation, input });
|
||||
return Promise.resolve(ok(jsonValueSchema.parse(output)));
|
||||
},
|
||||
);
|
||||
try {
|
||||
output = true;
|
||||
const single = await harness.mcp.callTool({
|
||||
name: "set_address_name",
|
||||
arguments: { address: "0x1000", name: "fixture_entry" },
|
||||
});
|
||||
expect(single.isError).not.toBe(true);
|
||||
expect(single.structuredContent).toMatchObject({ normalized_result: true });
|
||||
output = { "0x1000": true, "0x2000": false };
|
||||
const batch = await harness.mcp.callTool({
|
||||
name: "set_addresses_names",
|
||||
arguments: { names: { "0x1000": "fixture_entry", "0x2000": "g_value" } },
|
||||
});
|
||||
expect(batch.isError).not.toBe(true);
|
||||
expect(batch.structuredContent).toMatchObject({
|
||||
normalized_result: { "0x1000": true, "0x2000": false },
|
||||
});
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
operation: "set_address_name",
|
||||
input: { document: null, address: "0x1000", name: "fixture_entry" },
|
||||
},
|
||||
{
|
||||
operation: "set_addresses_names",
|
||||
input: {
|
||||
document: null,
|
||||
names: { "0x1000": "fixture_entry", "0x2000": "g_value" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const empty = await harness.mcp.callTool({
|
||||
name: "set_addresses_names",
|
||||
arguments: { names: {} },
|
||||
});
|
||||
expect(empty.isError).toBe(true);
|
||||
expect(requests).toHaveLength(2);
|
||||
output = "fixture_entry";
|
||||
const malformed = await harness.mcp.callTool({
|
||||
name: "set_address_name",
|
||||
arguments: { address: "0x1000", name: "fixture_entry" },
|
||||
});
|
||||
expect(malformed.isError).toBe(true);
|
||||
expect(parseMcpToolError(malformed)).toMatchObject({
|
||||
error: { code: "unreadable_output" },
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Ghidra MCP evidence parity", () => {
|
||||
it("preserves provider evidence, composed parity, and capability routing", async () => {
|
||||
const harness = await connectGhidraMcp("ghidra-parity");
|
||||
|
||||
@@ -19,7 +19,10 @@ import type {
|
||||
GhidraLauncher,
|
||||
} from "../../../../src/ghidra/GhidraLauncher.js";
|
||||
import type { GhidraTransportKind } from "../../../../src/ghidra/GhidraTransport.js";
|
||||
import { GHIDRA_SESSION_CAPABILITIES } from "../../../../src/ghidra/GhidraSessionValues.js";
|
||||
import {
|
||||
GHIDRA_MUTATING_OPERATIONS,
|
||||
GHIDRA_SESSION_CAPABILITIES,
|
||||
} from "../../../../src/ghidra/GhidraSessionValues.js";
|
||||
import { waitForExit as waitForChildExit } from "../../../support/process/processFixture.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -177,7 +180,7 @@ describe("GhidraClient", () => {
|
||||
capabilities: GHIDRA_SESSION_CAPABILITIES.filter(
|
||||
(value) =>
|
||||
HOST_TRANSPORT === "unix-socket" ||
|
||||
value !== "annotate_native_function",
|
||||
!GHIDRA_MUTATING_OPERATIONS.has(value),
|
||||
),
|
||||
target: {
|
||||
image_base: "0x1000",
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
#define REA_CPP_SYMBOL(name) name
|
||||
#endif
|
||||
|
||||
volatile int rea_namespace_alpha_value
|
||||
__asm__(REA_CPP_SYMBOL("_ZN5alpha5valueE")) = 3;
|
||||
volatile int rea_namespace_nested_value
|
||||
__asm__(REA_CPP_SYMBOL("_ZN5outer5inner5valueE")) = 1;
|
||||
|
||||
__attribute__((noinline, used)) int rea_namespace_alpha(int value)
|
||||
__asm__(REA_CPP_SYMBOL("_ZN5alpha4sameEi"));
|
||||
__attribute__((noinline, used)) int rea_namespace_beta(int value)
|
||||
@@ -12,9 +17,9 @@ __attribute__((noinline, used)) int rea_namespace_beta(int value)
|
||||
__attribute__((noinline, used)) int rea_namespace_nested(int value)
|
||||
__asm__(REA_CPP_SYMBOL("_ZN5outer5inner4sameEi"));
|
||||
|
||||
int rea_namespace_alpha(int value) { return value + 3; }
|
||||
int rea_namespace_alpha(int value) { return value + rea_namespace_alpha_value; }
|
||||
int rea_namespace_beta(int value) { return value * 2; }
|
||||
int rea_namespace_nested(int value) { return value - 1; }
|
||||
int rea_namespace_nested(int value) { return value - rea_namespace_nested_value; }
|
||||
|
||||
int main(void) {
|
||||
return rea_namespace_alpha(1) + rea_namespace_beta(2) +
|
||||
|
||||
Vendored
+8
-1
@@ -231,6 +231,8 @@ const sessionInfo = ({
|
||||
"resolve_containing_procedure",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
"annotate_native_function",
|
||||
"inspect_native_data_type",
|
||||
"inspect_native_instruction",
|
||||
@@ -246,7 +248,12 @@ const sessionInfo = ({
|
||||
"xrefs",
|
||||
].filter(
|
||||
(value) =>
|
||||
transport === "unix-socket" || value !== "annotate_native_function",
|
||||
transport === "unix-socket" ||
|
||||
![
|
||||
"annotate_native_function",
|
||||
"set_address_name",
|
||||
"set_addresses_names",
|
||||
].includes(value),
|
||||
),
|
||||
target: {
|
||||
name: "fixture",
|
||||
|
||||
Reference in New Issue
Block a user