fix(ghidra): explain read-only project edit refusals (#8413)

* fix(ghidra): explain read-only project edit refusals

* test: follow provider guidance ownership
This commit is contained in:
morluto authored and GitHub committed 2026-11-04 19:28:15 +08:00
1 parent 703c11c90f
commit 810cebcba0
4 files changed
+80 -1

No files matched your search

+3 -1
View File
@@ -258,8 +258,10 @@ type CapabilityAvailability =
readonly available: false;
readonly reason: string;
readonly availabilityCode?: ProviderRejectionCode;
/** Provider-owned explanation and recovery for an attempted operation. */
readonly userMessage?: string;
/**
* Repair instruction from the readiness owner that made this operation
* Recovery instruction from the owner that made this operation
* unavailable. Static capability facts omit it.
*/
readonly remediation?: string;
@@ -76,6 +76,14 @@ export const prepareSessionExecution = (
active.route.binding?.identity.id ?? active.route.identity.id,
operation,
capability?.reason ?? "operation is not declared by this provider",
{
...(capability?.userMessage === undefined
? {}
: { userMessage: capability.userMessage }),
...(capability?.remediation === undefined
? {}
: { remediationAction: capability.remediation }),
},
),
);
}
+4
View File
@@ -276,6 +276,10 @@ export const existingProjectCapabilities = (
available: false,
reason: "Existing Ghidra project sessions are read-only.",
availabilityCode: "open_options_invalid",
userMessage:
"This Ghidra project session is read-only; the requested edit was not applied.",
remediation:
"Continue inspecting the selected project. To make session-only edits, start a separate REA session with REA_GHIDRA_PROJECT_PATH and REA_GHIDRA_PROGRAM_NAME unset, then open a fresh Ghidra import. It is a separate ephemeral analysis and does not preserve the selected project's state.",
};
return { ...capability, limitations };
});
@@ -28,6 +28,7 @@ import { err, ok } from "../../../src/domain/result.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { existingProjectCapabilities } from "../../../src/ghidra/GhidraProviderCapabilities.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "../../../src/contracts/sessionStatusContract.js";
@@ -782,6 +783,70 @@ const structured = (result: CallToolResult): Record<string, unknown> =>
? parseMcpToolError(result)
: z.record(z.string(), z.unknown()).parse(result.structuredContent);
it("preserves a selected Ghidra project's read-only guidance when an edit is requested", async () => {
const directory = await createTestTempDirectory("rea-project-refusal-mcp-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const base = candidate("ghidra", starts);
const projectCapabilities = existingProjectCapabilities("linux");
const annotation = projectCapabilities.find(
(entry) => entry.operation === "annotate_native_function",
);
if (annotation === undefined || annotation.available)
throw new Error("Expected read-only annotation capability");
expect(annotation.userMessage).toBeDefined();
expect(annotation.remediation).toBeDefined();
const provider = {
...base,
capabilities: () =>
projectCapabilities.map((entry) => ({
...entry,
provider: base.identity(),
})),
};
const session = composeBinarySession(
new AnalysisProviderRegistry([provider]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "project-refusal", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: target } }))
.isError,
).not.toBe(true);
const refused = await mcp.callTool({
name: "annotate_native_function",
arguments: { procedure: "fixture", comment: "must not be applied" },
});
expect(refused.isError).toBe(true);
expect(parseMcpToolError(refused)).toMatchObject({
error: {
code: "capability_unavailable",
message: annotation.userMessage,
details: { reason: "Existing Ghidra project sessions are read-only." },
remediation: { action: annotation.remediation },
},
});
const status = await sessionStatusOf(mcp, { detail: "complete" });
const availability = status.parsed.tool_availability.groups.find((group) =>
group.names.includes("annotate_native_function"),
);
expect(availability).toMatchObject({
available: false,
remediation: annotation.remediation,
});
expect(starts).toEqual([]);
});
it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => {
directory = await createTestTempDirectory("rea-profile-completion-mcp-");
const path = join(directory, "fixture.hop");