fix(ghidra): scope rejected-input diagnostics to their request (#7140)

Scope expected Ghidra input failures to the active request after the authenticated startup handshake. Preserve operation output, identity and log coordinates while retaining cumulative output with its session owner; lifecycle and unexpected failures keep full diagnostics.

Share remote input-code classification with domain error projection. Verify startup rejection, healthy continuation, cleanup, unknown errors, OOM diagnostics and real CLI/MCP workflows. The measured unknown-symbol reply falls from 13,170 to 2,209 bytes with unchanged successful facts and Evidence IDs.
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-25 22:37:32 +08:00
1 parent f69c52a1cf
commit 6381aa0b1d
10 files changed
+260 -52

No files matched your search

+11
View File
@@ -30,6 +30,17 @@ signal. Confirm resource causes from process output or host/container records;
operation-scoped JVM `OutOfMemoryError` observations retain their separate resource operation-scoped JVM `OutOfMemoryError` observations retain their separate resource
classification. Automatic kernel or cgroup OOM attribution is not implemented. classification. Automatic kernel or cgroup OOM attribution is not implemented.
After the authenticated startup handshake, rejected inputs (`invalid_request`,
`not_found`, `ambiguous`, or an invalid function name) use
`diagnostics.diagnostic_scope: "request"` when their active output checkpoint is
available. They preserve the active request's
`failure_output`, target/profile identity and runtime/log coordinates, while
omitting cumulative launcher stdout/stderr and launch-support output. Startup,
process, protocol and other remote failures retain full session diagnostics.
The Ghidra client still owns its bounded cumulative output; this projection does
not clear it or alter session readiness. Log paths refer to the owned ephemeral
runtime and are removed by normal cleanup.
Provider selection and profile identity follow Provider selection and profile identity follow
[MCP contracts](mcp-contracts.md#identity-and-discovery). One deep [MCP contracts](mcp-contracts.md#identity-and-discovery). One deep
provider stays bound to the target; runtime failure never selects another provider stays bound to the target; runtime failure never selects another
+17 -3
View File
@@ -54,6 +54,7 @@ import {
ghidraFailureOutput, ghidraFailureOutput,
GHIDRA_JVM_OOME_MARKER, GHIDRA_JVM_OOME_MARKER,
GHIDRA_PROCESS_DIAGNOSTIC_BYTES, GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
type GhidraDiagnosticsScope,
} from "./GhidraDiagnostics.js"; } from "./GhidraDiagnostics.js";
import { GhidraLaunchError, type GhidraLaunch } from "./GhidraLauncher.js"; import { GhidraLaunchError, type GhidraLaunch } from "./GhidraLauncher.js";
import { GhidraResponseRouter } from "./GhidraResponseRouter.js"; import { GhidraResponseRouter } from "./GhidraResponseRouter.js";
@@ -158,7 +159,7 @@ export class GhidraClient {
#startupAttempt: GhidraStartupAttempt | undefined; #startupAttempt: GhidraStartupAttempt | undefined;
#closePromise: Promise<void> | undefined; #closePromise: Promise<void> | undefined;
readonly #failure = bindGhidraSessionFailure( readonly #failure = bindGhidraSessionFailure(
() => this.#diagnostics(), (scope) => this.#diagnostics(scope),
(value, incomplete) => this.#redactAuthentication(value, incomplete), (value, incomplete) => this.#redactAuthentication(value, incomplete),
); );
readonly #wire: GhidraWire; readonly #wire: GhidraWire;
@@ -1339,7 +1340,17 @@ export class GhidraClient {
}); });
} }
#diagnostics(): Readonly<Record<string, JsonValue>> { #diagnostics(
requestedScope: GhidraDiagnosticsScope = "session",
): Readonly<Record<string, JsonValue>> {
// Startup errors still need launcher evidence. A request projection must
// have both a completed handshake and its own active output checkpoint.
const scope =
requestedScope === "request" &&
this.#startupSucceeded() &&
this.#activeOutputCheckpoint !== undefined
? "request"
: "session";
const currentSnapshot = this.#process?.snapshot() ?? this.#processSnapshot; const currentSnapshot = this.#process?.snapshot() ?? this.#processSnapshot;
const snapshot: ProviderProcessSnapshot | undefined = const snapshot: ProviderProcessSnapshot | undefined =
this.#outputDrainIncomplete && currentSnapshot !== undefined this.#outputDrainIncomplete && currentSnapshot !== undefined
@@ -1350,7 +1361,9 @@ export class GhidraClient {
} }
: currentSnapshot; : currentSnapshot;
const checkpoint = const checkpoint =
this.#activeOutputCheckpoint ?? this.#startupOutputCheckpoint; scope === "request"
? this.#activeOutputCheckpoint
: (this.#activeOutputCheckpoint ?? this.#startupOutputCheckpoint);
const scoped = const scoped =
checkpoint === undefined checkpoint === undefined
? undefined ? undefined
@@ -1360,6 +1373,7 @@ export class GhidraClient {
? undefined ? undefined
: ghidraFailureOutput(scoped, this.#token, this.#outputDrainIncomplete); : ghidraFailureOutput(scoped, this.#token, this.#outputDrainIncomplete);
return createGhidraDiagnostics({ return createGhidraDiagnostics({
scope,
targetPath: this.#options.targetPath, targetPath: this.#options.targetPath,
targetSha256: this.#options.targetSha256, targetSha256: this.#options.targetSha256,
...(this.#targetAdmission === undefined ...(this.#targetAdmission === undefined
+58
View File
@@ -5,6 +5,64 @@ import {
} from "./GhidraDiagnostics.js"; } from "./GhidraDiagnostics.js";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
it("keeps request output and provenance without projecting launcher history", () => {
const output = { text: "launcher output", bytes: 15, observedBytes: 15 };
const options = {
targetPath: "./caller-selected/../target",
targetSha256: "a".repeat(64),
transport: "unix-socket" as const,
providerVersion: "12.1.4",
profileDigest: "b".repeat(64),
runtimeRoot: "/owned/runtime",
snapshot: { stdout: output, stderr: output, exitCode: null, signal: null },
previous: {},
failure_output: { stdout: { text: "query output" }, complete: false },
};
const request = createGhidraDiagnostics({ ...options, scope: "request" });
expect(request).toMatchObject({
diagnostic_scope: "request",
target_path: options.targetPath,
target_sha256: options.targetSha256,
profile_digest: options.profileDigest,
runtime_root: options.runtimeRoot,
failure_output: options.failure_output,
});
expect(request).not.toHaveProperty("stdout");
expect(request).not.toHaveProperty("stderr");
expect(createGhidraDiagnostics(options)).toMatchObject({
stdout: { text: output.text },
stderr: { text: output.text },
failure_output: options.failure_output,
});
});
it("keeps cached session output owned while projecting historical request coordinates", () => {
const previous = {
target_path: "/selected/target",
ghidra_log_path: "/owned/project/ghidra.log",
stdout: { text: "old launcher output" },
stderr: { text: "old launcher stderr" },
launch_support: [{ stdout: "JDK version" }],
failure_output: { stdout: { text: "old request" }, complete: true },
};
const request = createGhidraDiagnostics({
scope: "request",
targetPath: "/selected/target",
targetSha256: "a".repeat(64),
transport: "unix-socket",
providerVersion: "12.1.4",
profileDigest: "b".repeat(64),
previous,
});
expect(request).toEqual({
diagnostic_scope: "request",
target_path: previous.target_path,
ghidra_log_path: previous.ghidra_log_path,
});
expect(previous.stdout.text).toBe("old launcher output");
expect(previous.failure_output.stdout.text).toBe("old request");
});
it("does not reuse historical operation-scoped failure output without a current owner", () => { it("does not reuse historical operation-scoped failure output without a current owner", () => {
const diagnostics = createGhidraDiagnostics({ const diagnostics = createGhidraDiagnostics({
targetPath: "/tmp/target", targetPath: "/tmp/target",
+25 -9
View File
@@ -13,8 +13,12 @@ export const GHIDRA_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
/** JVM error class located in all launcher output, beyond the retention budget. */ /** JVM error class located in all launcher output, beyond the retention budget. */
export const GHIDRA_JVM_OOME_MARKER = "java.lang.OutOfMemoryError"; export const GHIDRA_JVM_OOME_MARKER = "java.lang.OutOfMemoryError";
/** A rejected caller input owns request output, not the launcher's history. */
export type GhidraDiagnosticsScope = "session" | "request";
/** Inputs needed to project bounded diagnostics for one Ghidra runtime. */ /** Inputs needed to project bounded diagnostics for one Ghidra runtime. */
export interface GhidraDiagnosticsOptions { export interface GhidraDiagnosticsOptions {
readonly scope?: GhidraDiagnosticsScope;
readonly targetPath: string; readonly targetPath: string;
readonly targetSha256: string; readonly targetSha256: string;
readonly targetAdmission?: JsonValue; readonly targetAdmission?: JsonValue;
@@ -43,9 +47,16 @@ export const createGhidraDiagnostics = (
) { ) {
const history: Record<string, JsonValue> = { ...options.previous }; const history: Record<string, JsonValue> = { ...options.previous };
delete history.failure_output; delete history.failure_output;
if (options.scope === "request") {
delete history.stdout;
delete history.stderr;
delete history.launch_support;
history.diagnostic_scope = "request";
}
return history; return history;
} }
return { return {
...(options.scope === "request" ? { diagnostic_scope: "request" } : {}),
target_path: options.targetPath, target_path: options.targetPath,
target_sha256: options.targetSha256, target_sha256: options.targetSha256,
...(options.targetAdmission === undefined ...(options.targetAdmission === undefined
@@ -71,7 +82,8 @@ export const createGhidraDiagnostics = (
ghidra_log_path: options.launch.ghidraLogPath, ghidra_log_path: options.launch.ghidraLogPath,
script_log_path: options.launch.scriptLogPath, script_log_path: options.launch.scriptLogPath,
process_id: options.launch.process.pid ?? null, process_id: options.launch.process.pid ?? null,
...(options.launch.launchSupport === undefined ...(options.scope === "request" ||
options.launch.launchSupport === undefined
? {} ? {}
: { : {
launch_support: options.launch.launchSupport.map((output) => ({ launch_support: options.launch.launchSupport.map((output) => ({
@@ -90,14 +102,18 @@ export const createGhidraDiagnostics = (
stop_requested: options.snapshot.stopRequested === true, stop_requested: options.snapshot.stopRequested === true,
diagnostic_truncated: options.snapshot.diagnosticTruncated === true, diagnostic_truncated: options.snapshot.diagnosticTruncated === true,
output_incomplete: options.snapshot.outputIncomplete === true, output_incomplete: options.snapshot.outputIncomplete === true,
stdout: ghidraDiagnosticStream( ...(options.scope === "request"
options.snapshot.stdout, ? {}
options.token, : {
), stdout: ghidraDiagnosticStream(
stderr: ghidraDiagnosticStream( options.snapshot.stdout,
options.snapshot.stderr, options.token,
options.token, ),
), stderr: ghidraDiagnosticStream(
options.snapshot.stderr,
options.token,
),
}),
}), }),
...(options.failure_output === undefined ...(options.failure_output === undefined
? {} ? {}
+2 -6
View File
@@ -99,6 +99,7 @@ import {
} from "./GhidraProviderCapabilities.js"; } from "./GhidraProviderCapabilities.js";
import { import {
GHIDRA_STARTUP_DEADLINE_ELAPSED, GHIDRA_STARTUP_DEADLINE_ELAPSED,
isGhidraInputFailureCode,
type GhidraSessionError, type GhidraSessionError,
} from "./GhidraSessionError.js"; } from "./GhidraSessionError.js";
import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
@@ -1046,12 +1047,7 @@ const projectPrimarySessionError = (
"Retry this search in literal mode or simplify the regex.", "Retry this search in literal mode or simplify the regex.",
}, },
); );
if ( if (failure.kind === "remote" && isGhidraInputFailureCode(failure.remoteCode))
failure.kind === "remote" &&
["invalid_request", "not_found", "ambiguous"].includes(
failure.remoteCode ?? "",
)
)
return new AnalysisInputError(operation, context, [ return new AnalysisInputError(operation, context, [
{ path: [], reason: "invalid_value", message: failure.message }, { path: [], reason: "invalid_value", message: failure.message },
]); ]);
+33
View File
@@ -7,6 +7,39 @@ import {
bindGhidraSessionFailure, bindGhidraSessionFailure,
} from "./GhidraSessionError.js"; } from "./GhidraSessionError.js";
import { GhidraLaunchError } from "./GhidraLauncher.js"; import { GhidraLaunchError } from "./GhidraLauncher.js";
import { createGhidraDiagnostics } from "./GhidraDiagnostics.js";
it.each(["decompile_failed", "future_remote_failure"])(
"retains complete session diagnostics for non-input remote code %s",
(remoteCode) => {
const factory = bindGhidraSessionFailure((scope) =>
createGhidraDiagnostics({
scope,
targetPath: "/selected/target",
targetSha256: "a".repeat(64),
transport: "unix-socket",
providerVersion: "12.1.4",
profileDigest: "b".repeat(64),
runtimeRoot: "/owned/runtime",
snapshot: {
stdout: { text: "engine diagnostic", bytes: 17, observedBytes: 17 },
stderr: { text: "", bytes: 0, observedBytes: 0 },
exitCode: null,
signal: null,
},
previous: {},
}),
);
const failure = factory("remote", "Engine failed", undefined, {
remoteCode,
});
expect(failure.diagnostics).toMatchObject({
stdout: { text: "engine diagnostic" },
remote_code: remoteCode,
});
expect(failure.diagnostics).not.toHaveProperty("diagnostic_scope");
},
);
it("reports truncated cyclic causes in serializable diagnostics", () => { it("reports truncated cyclic causes in serializable diagnostics", () => {
const cause = new Error("cyclic launcher failure"); const cause = new Error("cyclic launcher failure");
+16 -3
View File
@@ -6,6 +6,7 @@ import {
import type { JsonValue } from "../domain/jsonValue.js"; import type { JsonValue } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import type { GhidraDiagnosticsScope } from "./GhidraDiagnostics.js";
import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js"; import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js";
import { import {
GhidraLaunchError, GhidraLaunchError,
@@ -184,10 +185,16 @@ const createGhidraSessionError = (failure: {
}, },
); );
/** Bind session-failure construction to a live, token-redacted diagnostic view. */ /** Bridge input failures share classification with their domain error projection. */
export const isGhidraInputFailureCode = (code: string | undefined): boolean =>
code === "invalid_request" || code === "not_found" || code === "ambiguous";
/** Bind session-failure construction to its causal diagnostic scope. */
export const bindGhidraSessionFailure = export const bindGhidraSessionFailure =
( (
diagnostics: () => Readonly<Record<string, JsonValue>>, diagnostics: (
scope: GhidraDiagnosticsScope,
) => Readonly<Record<string, JsonValue>>,
redact: (value: string, incomplete?: boolean) => string = (value) => value, redact: (value: string, incomplete?: boolean) => string = (value) => value,
) => ) =>
( (
@@ -203,7 +210,13 @@ export const bindGhidraSessionFailure =
kind, kind,
message: redact(message), message: redact(message),
diagnostics: { diagnostics: {
...diagnostics(), ...diagnostics(
kind === "remote" &&
(isGhidraInputFailureCode(options.remoteCode) ||
options.remoteCode === "invalid_function_name")
? "request"
: "session",
),
...(cause === undefined ...(cause === undefined
? {} ? {}
: { failure_cause: describeCause(cause, redact) }), : { failure_cause: describeCause(cause, redact) }),
@@ -26,6 +26,7 @@ type FixtureMode =
| "future_id" | "future_id"
| "analysis_timeout" | "analysis_timeout"
| "remote_error" | "remote_error"
| "remote_startup_error"
| "shutdown_error" | "shutdown_error"
| "invalid_shutdown_ack" | "invalid_shutdown_ack"
| "hang_after_start" | "hang_after_start"
@@ -950,6 +950,71 @@ describe("GhidraClient captured source snapshot", () => {
}); });
}); });
it("scopes authenticated input-failure diagnostics to the request while retaining session output", async () => {
const client = clientFor(new FixtureLauncher("remote_error"));
const result = await client.callTool("list_procedures", {
document: null,
offset: 0,
limit: 500,
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "remote",
remoteCode: "not_found",
diagnostics: {
diagnostic_scope: "request",
remote_code: "not_found",
remote_message: "Unknown Ghidra procedure name",
failure_output: {
stdout: { text: "Fixture request rejected: missing procedure\n" },
complete: true,
},
},
},
});
if (result.ok) throw new Error("Expected remote lookup failure");
expect(result.error.diagnostics).not.toHaveProperty("stdout");
expect(result.error.diagnostics).not.toHaveProperty("stderr");
expect(result.error.diagnostics).not.toHaveProperty("launch_support");
expect(client.diagnostics()).toMatchObject({
stdout: {
text: "Fixture launcher startup output\nFixture request rejected: missing procedure\n",
},
});
expect(client.isReady()).toBe(true);
await expect(
client.callTool("list_strings", { document: null }),
).resolves.toMatchObject({ ok: true });
await client.close();
expect(client.diagnostics()).toMatchObject({
stdout: {
text: expect.stringContaining("Fixture launcher startup output"),
},
});
});
it("retains launcher diagnostics when an input error rejects the startup handshake", async () => {
const client = clientFor(new FixtureLauncher("remote_startup_error"));
const result = await client.start();
expect(result).toMatchObject({
ok: false,
error: {
kind: "remote",
remoteCode: "invalid_request",
diagnostics: {
stdout: {
text: expect.stringContaining("Fixture launcher startup output"),
},
},
},
});
if (result.ok) throw new Error("Expected startup failure");
expect(result.error.diagnostics).not.toHaveProperty("diagnostic_scope");
});
describe("GhidraClient", () => { describe("GhidraClient", () => {
it("completes an exact, fragmented post-analysis handshake", async () => { it("completes an exact, fragmented post-analysis handshake", async () => {
const launcher = new FixtureLauncher("fragmented"); const launcher = new FixtureLauncher("fragmented");
@@ -1051,28 +1116,6 @@ describe("GhidraClient", () => {
}); });
}); });
it("preserves an authenticated operation failure code and diagnostics", async () => {
const client = clientFor(new FixtureLauncher("remote_error"));
const result = await client.callTool("list_procedures", {
document: null,
offset: 0,
limit: 500,
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "remote",
remoteCode: "not_found",
diagnostics: {
remote_code: "not_found",
remote_message: "Unknown Ghidra procedure name",
},
},
});
});
it.each([ it.each([
["wrong_identity", "protocol"], ["wrong_identity", "protocol"],
["malformed", "protocol"], ["malformed", "protocol"],
+32 -9
View File
@@ -62,6 +62,11 @@ const cutIdentifierAtBudget = async () => {
const writeThen = (stream, text, then) => const writeThen = (stream, text, then) =>
stream.write(text, () => setTimeout(then, 10)); stream.write(text, () => setTimeout(then, 10));
if (mode === "remote_error" || mode === "remote_startup_error")
await new Promise((resolve) =>
process.stdout.write("Fixture launcher startup output\n", resolve),
);
let endpointPublication; let endpointPublication;
if (mode === "exit") process.exit(73); if (mode === "exit") process.exit(73);
if (mode === "flood_silent") { if (mode === "flood_silent") {
@@ -199,15 +204,20 @@ const handleRequest = (socket, server, request, state) => {
return; return;
} }
if (state.mode === "remote_error" && request.method === "list_procedures") { if (state.mode === "remote_error" && request.method === "list_procedures") {
socket.write( writeThen(
`${JSON.stringify({ process.stdout,
id: request.id, "Fixture request rejected: missing procedure\n",
ok: false, () =>
error: { socket.write(
code: "not_found", `${JSON.stringify({
message: "Unknown Ghidra procedure name", id: request.id,
}, ok: false,
})}\n`, error: {
code: "not_found",
message: "Unknown Ghidra procedure name",
},
})}\n`,
),
); );
return; return;
} }
@@ -266,6 +276,19 @@ const handleRequest = (socket, server, request, state) => {
}; };
const handlePing = (socket, request, state) => { const handlePing = (socket, request, state) => {
if (state.mode === "remote_startup_error") {
socket.write(
`${JSON.stringify({
id: request.id,
ok: false,
error: {
code: "invalid_request",
message: "Fixture rejected startup handshake",
},
})}\n`,
);
return;
}
state.pings.value += 1; state.pings.value += 1;
if (state.mode === "diagnostic_output" && state.pings.value === 1) { if (state.mode === "diagnostic_output" && state.pings.value === 1) {
const retainedTokenPrefixBytes = Math.floor(state.token.length / 2); const retainedTokenPrefixBytes = Math.floor(state.token.length / 2);