ci(ghidra): add Windows P0 acceptance and real-engine lanes

Add a hosted Windows x64 lane for named host capabilities, portable provider tests, and packed CLI/MCP verification. Add a default-branch-only repository-dispatch workflow for a controlled self-hosted Ghidra 12.1.2 runner, backed by a deterministic source-owned native PE fixture and a verifier that exercises all 18 admitted operations, target digest linkage, authenticated loopback transport, and complete runtime cleanup.
This commit is contained in:
N0zoM1z0
2026-07-17 03:05:19 +09:00
committed by N0zoM1z0
parent bc0fb45f16
commit 0b3551076d
10 changed files with 637 additions and 20 deletions
+52
View File
@@ -0,0 +1,52 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const output = resolve(root, "build", "fixtures", "rea-ghidra-windows.exe");
const bytes = Buffer.alloc(1024);
const peOffset = 0x80;
const optionalHeader = peOffset + 24;
const sectionTable = optionalHeader + 0xf0;
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(peOffset, 0x3c);
bytes.write("PE\0\0", peOffset, "binary");
bytes.writeUInt16LE(0x8664, peOffset + 4);
bytes.writeUInt16LE(1, peOffset + 6);
bytes.writeUInt16LE(0xf0, peOffset + 20);
bytes.writeUInt16LE(0x22, peOffset + 22);
bytes.writeUInt16LE(0x20b, optionalHeader);
bytes.writeUInt32LE(0x200, optionalHeader + 4);
bytes.writeUInt32LE(0x1000, optionalHeader + 16);
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
bytes.writeBigUInt64LE(0x1_4000_0000n, optionalHeader + 24);
bytes.writeUInt32LE(0x1000, optionalHeader + 32);
bytes.writeUInt32LE(0x200, optionalHeader + 36);
bytes.writeUInt16LE(6, optionalHeader + 40);
bytes.writeUInt16LE(6, optionalHeader + 48);
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
bytes.writeUInt32LE(0x200, optionalHeader + 60);
bytes.writeUInt16LE(3, optionalHeader + 68);
bytes.writeUInt16LE(0x8160, optionalHeader + 70);
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 72);
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 80);
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 88);
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 96);
bytes.writeUInt32LE(16, optionalHeader + 108);
bytes.write(".text", sectionTable, "ascii");
bytes.writeUInt32LE(0x16, sectionTable + 8);
bytes.writeUInt32LE(0x1000, sectionTable + 12);
bytes.writeUInt32LE(0x200, sectionTable + 16);
bytes.writeUInt32LE(0x200, sectionTable + 20);
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
// entry calls the second function, returns zero; second returns 42.
bytes.set([0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3], 0x200);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, bytes);
process.stdout.write(`${output}\n`);
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
import { access } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveGhidraAnalysisProfile } from "../dist/ghidra/GhidraAnalysisProfile.js";
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
import { GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS } from "../dist/ghidra/GhidraDefaults.js";
import {
GHIDRA_FUNCTION_OPERATIONS,
parseGhidraFunctionInput,
parseGhidraFunctionResult,
} from "../dist/ghidra/GhidraFunctionValues.js";
import {
GHIDRA_INVENTORY_OPERATIONS,
parseGhidraInventoryInput,
parseGhidraInventoryResult,
} from "../dist/ghidra/GhidraInventoryValues.js";
import {
inspectGhidraInstallation,
SUPPORTED_GHIDRA_VERSION,
} from "../dist/ghidra/GhidraInstallation.js";
import { GhidraHeadlessLauncher } from "../dist/ghidra/GhidraLauncher.js";
import { GHIDRA_PROVIDER_IDENTITY } from "../dist/ghidra/GhidraProvider.js";
import { GHIDRA_SESSION_CAPABILITIES as SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
import { parseBinaryTarget } from "../dist/domain/binaryTarget.js";
if (process.platform !== "win32" || process.arch !== "x64")
throw new Error(
"The real Windows Ghidra verifier requires a Windows x64 host",
);
const installDir = process.env.GHIDRA_INSTALL_DIR;
if (installDir === undefined || !isAbsolute(installDir))
throw new Error(
"Set GHIDRA_INSTALL_DIR to the absolute root of Ghidra 12.1.2.",
);
const installation = inspectGhidraInstallation({
installDir,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (
!installation.available ||
installation.analyzeHeadlessPath === null ||
installation.providerVersion !== SUPPORTED_GHIDRA_VERSION
)
throw new Error(
`Windows Ghidra installation is unavailable: ${JSON.stringify(installation)}`,
);
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const targetPath = resolve(root, "build", "fixtures", "rea-ghidra-windows.exe");
const target = await parseBinaryTarget(targetPath);
if (
!target.ok ||
target.value.format !== "pe" ||
target.value.architecture !== "x86_64" ||
target.value.executableRole !== "application" ||
target.value.managed !== false
)
throw new Error(
`Controlled Windows fixture failed admission: ${JSON.stringify(target)}`,
);
const profile = await resolveGhidraAnalysisProfile(
target.value,
GHIDRA_PROVIDER_IDENTITY,
installation,
);
if (!profile.ok || profile.value.profile === null)
throw new Error("Windows Ghidra profile could not be committed");
const client = new GhidraClient({
launcher: new GhidraHeadlessLauncher({
analyzeHeadlessPath: installation.analyzeHeadlessPath,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
bridgeScriptPath: fileURLToPath(
new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
),
platform: "win32",
}),
targetPath: target.value.path,
targetSha256: target.value.sha256,
transport: "authenticated-loopback-tcp",
providerVersion: SUPPORTED_GHIDRA_VERSION,
profileDigest: profile.value.profile.digest,
requestTimeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
});
let coordinates;
let report;
const observed = new Set();
try {
const started = await client.start();
if (!started.ok) throw started.error;
assertSession(
started.value,
target.value.sha256,
profile.value.profile.digest,
);
coordinates = client.diagnostics();
assertRuntimeCoordinates(coordinates, target.value.sha256);
const documents = await inventory("list_documents", {});
const names = await inventory("list_names", {});
const procedures = await inventory("list_procedures", {});
const segments = await inventory("list_segments", {});
const strings = await inventory("list_strings", {});
if (
!Array.isArray(documents) ||
documents.length !== 1 ||
!pageHasItems(procedures) ||
!Array.isArray(segments) ||
segments.length === 0 ||
!isPage(names) ||
!isPage(strings)
)
throw new Error("Windows Ghidra inventory proof was incomplete");
const procedure = procedures.items.find(
(item) => item.procedure?.external === false,
);
if (procedure === undefined)
throw new Error("Windows Ghidra fixture exposed no local procedure");
await inventory("address_name", { address: procedure.address });
const resolvedAddress = await inventory("procedure_address", {
procedure: procedure.value,
});
if (resolvedAddress !== procedure.address)
throw new Error("Windows procedure name/address resolution drifted");
await inventory("resolve_containing_procedure", {
address: procedure.address,
});
await inventory("search_procedures", { pattern: procedure.value });
await inventory("search_strings", { pattern: "rea" });
for (const operation of [
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
])
await functionOperation(operation, { procedure: procedure.value });
await functionOperation("xrefs", { address: procedure.address });
await functionOperation("analyze_function", {
procedure: procedure.value,
include_assembly: true,
});
const expected = [
...GHIDRA_INVENTORY_OPERATIONS,
...GHIDRA_FUNCTION_OPERATIONS,
];
if (
observed.size !== expected.length ||
expected.some((operation) => !observed.has(operation))
)
throw new Error(
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
);
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
target: {
format: target.value.format,
architecture: target.value.architecture,
executable_role: target.value.executableRole,
managed: target.value.managed,
sha256: target.value.sha256,
},
bridge_version: started.value.bridge_version,
transport: "authenticated-loopback-tcp",
operations: [...observed].sort((left, right) => left.localeCompare(right)),
cleanup: "complete",
limitations: [
"approved-non-sensitive-fixtures-only",
"no-job-object-ownership",
"no-private-dacl-proof",
"no-reparse-point-authority",
],
};
} finally {
await client.close();
if (coordinates !== undefined) await assertCleanup(coordinates);
}
if (report === undefined)
throw new Error("Windows Ghidra report was not produced");
process.stdout.write(`${JSON.stringify(report)}\n`);
async function inventory(operation, parameters) {
const input = parseGhidraInventoryInput(operation, parameters);
if (!input.ok) throw input.error;
const called = await client.callTool(operation, input.value);
if (!called.ok) throw called.error;
const result = parseGhidraInventoryResult(operation, called.value);
if (!result.ok) throw result.error;
observed.add(operation);
return result.value;
}
async function functionOperation(operation, parameters) {
const input = parseGhidraFunctionInput(operation, parameters);
if (!input.ok) throw input.error;
const called = await client.callTool(operation, input.value, {
timeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
});
if (!called.ok) throw called.error;
const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) throw result.error;
observed.add(operation);
return result.value;
}
function assertSession(session, targetSha256, profileDigest) {
if (
session.provider.id !== "ghidra" ||
session.provider.version !== SUPPORTED_GHIDRA_VERSION ||
session.profile_digest !== profileDigest ||
session.target.sha256 !== targetSha256 ||
session.read_only !== true ||
session.analysis_complete !== true ||
session.analysis_timed_out !== false ||
session.capabilities.join(",") !== SESSION_CAPABILITIES.join(",")
)
throw new Error(
`Windows Ghidra session commitment drifted: ${JSON.stringify(session)}`,
);
}
function assertRuntimeCoordinates(runtime, targetSha256) {
if (
runtime.transport !== "authenticated-loopback-tcp" ||
runtime.target_sha256 !== targetSha256 ||
typeof runtime.runtime_root !== "string" ||
typeof runtime.endpoint_path !== "string" ||
typeof runtime.project_root !== "string" ||
typeof runtime.process_id !== "number"
)
throw new Error(
`Windows Ghidra runtime coordinates are incomplete: ${JSON.stringify(runtime)}`,
);
}
async function assertCleanup(runtime) {
for (const name of ["runtime_root", "endpoint_path", "project_root"]) {
const path = runtime[name];
if (typeof path === "string" && (await exists(path)))
throw new Error(`Windows Ghidra cleanup left ${name}: ${path}`);
}
if (
typeof runtime.process_id === "number" &&
processExists(runtime.process_id)
)
throw new Error(
`Windows Ghidra cleanup left process ${String(runtime.process_id)}`,
);
}
const isPage = (value) =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Array.isArray(value.items);
const pageHasItems = (value) => isPage(value) && value.items.length > 0;
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
function processExists(pid) {
try {
process.kill(pid, 0);
return true;
} catch (cause) {
return !(
cause instanceof Error &&
"code" in cause &&
cause.code === "ESRCH"
);
}
}
+14 -5
View File
@@ -200,10 +200,18 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
try {
const started = await client.start();
if (!started.ok) throw started.error;
assertSession(started.value, profile.value.profile.digest);
assertSession(
started.value,
profile.value.profile.digest,
parsedTarget.value.sha256,
);
const pinged = await client.ping();
if (!pinged.ok) throw pinged.error;
assertSession(pinged.value, profile.value.profile.digest);
assertSession(
pinged.value,
profile.value.profile.digest,
parsedTarget.value.sha256,
);
runtimeCoordinates = client.diagnostics();
assertRuntimeCoordinates(runtimeCoordinates);
@@ -1027,11 +1035,12 @@ function symbolTail(value) {
return value.split("::").at(-1);
}
function assertSession(session, expectedDigest) {
function assertSession(session, expectedDigest, expectedTargetSha256) {
if (
session.provider.id !== "ghidra" ||
session.provider.version !== SUPPORTED_GHIDRA_VERSION ||
session.profile_digest !== expectedDigest ||
session.target.sha256 !== expectedTargetSha256 ||
session.read_only !== true ||
session.analysis_complete !== true ||
session.analysis_timed_out !== false ||
@@ -1045,7 +1054,7 @@ function assertSession(session, expectedDigest) {
}
function assertRuntimeCoordinates(coordinates) {
for (const name of ["runtime_root", "socket_path", "project_root"])
for (const name of ["runtime_root", "endpoint_path", "project_root"])
if (typeof coordinates[name] !== "string")
throw new Error(`Ghidra verifier did not observe ${name}`);
if (typeof coordinates.process_id !== "number")
@@ -1053,7 +1062,7 @@ function assertRuntimeCoordinates(coordinates) {
}
async function assertCleanup(coordinates) {
for (const name of ["runtime_root", "socket_path", "project_root"]) {
for (const name of ["runtime_root", "endpoint_path", "project_root"]) {
const path = coordinates[name];
if (typeof path === "string" && (await exists(path)))
throw new Error(`Ghidra cleanup left ${name}: ${path}`);
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { access, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
const exec = promisify(execFile);
const root = process.cwd();
const workspace = await mkdtemp(join(tmpdir(), "rea-windows-package-"));
const prefix = join(workspace, "prefix");
try {
const packed = JSON.parse(
(
await npm(
["pack", "--json", "--silent", "--pack-destination", workspace],
root,
)
).stdout,
);
const packageResult = packed[0];
if (
packageResult === undefined ||
typeof packageResult.filename !== "string" ||
!Array.isArray(packageResult.files)
)
throw new Error(
`npm pack returned invalid metadata: ${JSON.stringify(packed)}`,
);
const packagedPaths = new Set(packageResult.files.map(({ path }) => path));
for (const required of [
"bridge/ghidra/ReaGhidraBridge.java",
"dist/main.js",
"dist/cli.js",
"scripts/rea.mjs",
])
if (!packagedPaths.has(required))
throw new Error(`Windows package omitted ${required}`);
const tarball = join(workspace, packageResult.filename);
await access(tarball);
await npm(
["install", "--no-package-lock", "--no-save", "--prefix", prefix, tarball],
workspace,
);
const entry = join(
prefix,
"node_modules",
"rea-agents",
"scripts",
"rea.mjs",
);
const environment = {
...process.env,
REA_ANALYSIS_PROVIDER: "auto",
};
const help = await exec(process.execPath, [entry, "--help"], {
env: environment,
windowsHide: true,
});
if (!help.stdout.includes("open") || !help.stdout.includes("providers"))
throw new Error("Packaged Windows CLI help omitted analysis commands");
const server = new StdioClientTransport({
command: process.execPath,
args: [entry, "mcp"],
env: environment,
stderr: "pipe",
});
const client = new Client({ name: "rea-windows-package", version: "1.0.0" });
try {
await client.connect(server);
const catalog = await client.listTools();
const expected = TOOL_CONTRACTS.map(({ name }) => name).sort(
(left, right) => left.localeCompare(right),
);
const actual = catalog.tools
.map(({ name }) => name)
.sort((left, right) => left.localeCompare(right));
if (JSON.stringify(actual) !== JSON.stringify(expected))
throw new Error(
"Packaged Windows MCP catalog drifted from TOOL_CONTRACTS",
);
} finally {
await client.close();
}
process.stdout.write(
`${JSON.stringify({
ok: true,
platform: process.platform,
package: packageResult.filename,
tools: TOOL_CONTRACTS.length,
ghidra_bridge: "present",
})}\n`,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
function npm(arguments_, cwd) {
const npmExecPath = process.env.npm_execpath;
return npmExecPath === undefined
? exec(process.platform === "win32" ? "npm.cmd" : "npm", arguments_, {
cwd,
windowsHide: true,
})
: exec(process.execPath, [npmExecPath, ...arguments_], {
cwd,
windowsHide: true,
});
}