mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
703c11c90f
commit
810cebcba0
4 files changed
+80
-1
No files matched your search
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user