Compare commits

..
Author SHA1 Message Date
morlutoandPompilio Donato 579ecbd032 fix(mcp): declare binary session prerequisite process effects
Recover the isolated effect metadata correction identified in #1669. Keep the generic annotation-algebra fixture independent of operation-specific effects.

Co-authored-by: Pompilio Donato <238757198+pompiliodonatoapp@users.noreply.github.com>
2026-10-14 03:07:03 +08:00
10 changed files with 31 additions and 446 deletions
+1 -10
View File
@@ -3632,17 +3632,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
* The canonical empty string is a small literal with a zero count.
*/
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
return swiftStringLiterals(function, new SwiftLiteralBudget());
}
// The budget is owned by one decode run; an explicit budget also permits deterministic producer regressions.
private SwiftStringLiteralDecode swiftStringLiterals(Function function, SwiftLiteralBudget budget) throws Exception {
Register[] registers = new Register[31];
for (int index = 0; index < registers.length; index++) {
registers[index] = currentProgram.getRegister("x" + index);
}
TreeMap<String, JsonObject> observed = new TreeMap<>();
TreeMap<String, JsonObject> omitted = new TreeMap<>();
SwiftLiteralBudget budget = new SwiftLiteralBudget();
Map<String, Boolean> selectedValues = new HashMap<>();
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
@@ -3887,11 +3883,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
}
SwiftLiteralBudget(long remaining) {
if (remaining < 0) throw new IllegalArgumentException("Swift literal budget must be nonnegative");
this.remaining = remaining;
}
boolean takeState() {
if (remaining < BYTES_PER_RECORD) return false;
remaining -= BYTES_PER_RECORD;
+7 -12
View File
@@ -209,11 +209,9 @@ native/runtime questions retain their own authority and coverage.
Names and semantic labels do not establish durable member identity. Comparison
preserves observed differences, structural inferences, and unknowns separately.
Cross-version matching first prefers exact raw CIL/signature identity, with
equal decoded fingerprints. Equal decoded fingerprints alone cannot establish
an exact match: they omit switch targets and collapse distinct NaN encodings.
When that does not match, it pairs an exact declared type, method name, and raw
signature before trying structural body shape. The exact-signature key uses names only
Cross-version matching first prefers exact CIL/signature identity. When that
does not match, it pairs an exact declared type, method name, and raw signature
before trying structural body shape. The exact-signature key uses names only
as part of that full tuple; names alone never select a pair. Duplicate tuples
remain ambiguous rather than being paired by token order. Fields follow the
same exact tiers. The raw signature is exact whether or not REA decoded it, so
@@ -224,11 +222,8 @@ unpaired member, one-sided or ambiguous, with the same declared type and name
whose signature was not decoded.
For a matched method, an unavailable or partial body makes body-shape facets
unknown while preserving observed signature differences. For complete bodies,
a difference in either the raw CIL digest or the decoded fingerprint is reported
in the `cil` dimension, including reordered switch targets with equal decoded
fingerprints. Structural identity compares normalized signatures, the limited
decoded-CIL tuple fingerprint,
unknown while preserving observed signature differences. Structural identity
compares normalized signatures, the limited decoded-CIL tuple fingerprint,
constants, and bounded shape context. It reports all candidates at the winning
score and remains ambiguous when the evidence does not distinguish them. The
digest does not itself remap metadata tokens.
@@ -365,8 +360,8 @@ Opcode names retain short/long forms and prefix opcodes. This makes the value a
reproducible decoded-tuple fingerprint, not a canonical semantic CIL identity:
it does not resolve tokens across MVIDs, does not commit full control flow or
exception semantics, and does not replace the raw CIL digest. Cross-build
comparison requires equal raw CIL digests as well for exact CIL/signature
matches and reports ambiguity when structural evidence is insufficient.
comparison uses the value only as one unique-only structural tier and reports
ambiguity when that evidence is insufficient.
For example, a decoded `nop; ret` body serializes exactly as
`[["nop","none",null],["ret","none",null]]` and has digest
+2 -144
View File
@@ -1,14 +1,7 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
} from "node:fs/promises";
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -16,11 +9,6 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../dist/process/ProviderProcess.js";
import { toolContract } from "../dist/contracts/toolContracts.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
@@ -62,13 +50,8 @@ const literalSource = fileURLToPath(
);
const binary = join(root, "swift-inventory");
const literalBinary = join(root, "swift-literals");
const literalAssembly = fileURLToPath(
new URL("../tests/conformance/swift/decoder-safeguards.S", import.meta.url),
);
const literalObject = join(root, "decoder-safeguards.o");
const reports = [];
let literalReport;
let decoderCleaned = true;
try {
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
timeout: 60_000,
@@ -76,19 +59,6 @@ try {
await mkdir(join(root, "copied"));
const copied = join(root, "copied", "swift-inventory");
await copyFile(binary, copied);
await execute(
"xcrun",
[
"clang",
"-target",
"arm64-apple-macosx13.0",
"-c",
literalAssembly,
"-o",
literalObject,
],
{ timeout: 60_000 },
);
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
await execute(
"swiftc",
@@ -97,13 +67,11 @@ try {
"-target",
"arm64-apple-macosx13.0",
literalSource,
literalObject,
"-o",
literalBinary,
],
{ timeout: 60_000 },
);
await verifyDecoderSafeguards();
await client.connect(transport);
for (const [variant, path] of [
["debug", binary],
@@ -186,7 +154,7 @@ try {
} finally {
await client.close();
await transport.close();
if (decoderCleaned) await rm(root, { recursive: true, force: true });
await rm(root, { recursive: true, force: true });
}
console.log(
JSON.stringify(
@@ -259,113 +227,3 @@ async function verifyStringLiterals() {
await call("close_binary", {});
}
}
// Exercise the actual Java producer inside a disposable Ghidra database. The
// explicit private budget boundary makes exhaustion deterministic, without a
// production CLI/MCP option or dependence on the verification host's free heap.
async function verifyDecoderSafeguards() {
const scripts = join(root, "decoder-scripts");
await mkdir(scripts);
const bridgeSources = fileURLToPath(
new URL("../bridge/ghidra/", import.meta.url),
);
for (const name of await readdir(bridgeSources)) {
if (name.endsWith(".java"))
await copyFile(join(bridgeSources, name), join(scripts, name));
}
await copyFile(
fileURLToPath(
new URL(
"../tests/conformance/swift/ReaSwiftDecoderRegressions.java",
import.meta.url,
),
),
join(scripts, "ReaSwiftDecoderRegressions.java"),
);
const report = join(root, "decoder-safeguards.txt");
await runDecoderProbe(scripts, report);
assert.equal(
await readFile(report, "utf8"),
"REA_SWIFT_DECODER_SAFEGUARDS_OK\n",
);
}
async function runDecoderProbe(scripts, report) {
const controller = new AbortController();
let interrupt;
const interrupted = new Promise((resolve) => {
interrupt = () => {
controller.abort(new Error("Swift decoder probe interrupted"));
resolve(false);
};
});
process.on("SIGINT", interrupt);
process.on("SIGTERM", interrupt);
let supervisor;
try {
const launch = await spawnOwnedProviderProcess({
command: installation.analyzeHeadlessPath,
arguments: [
root,
"decoder-regressions",
"-import",
literalBinary,
"-scriptPath",
scripts,
"-postScript",
"ReaSwiftDecoderRegressions.java",
report,
"-deleteProject",
"-max-cpu",
"2",
],
runId: run.run_id,
expectedCommand: null,
env: { JAVA_HOME: installation.javaHome },
signal: controller.signal,
});
decoderCleaned = false;
supervisor = new ProviderProcessSupervisor(
{
...launch,
ownsProcessLifetime: true,
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
},
{ maxDiagnosticBytes: 8 * 1024 * 1024 },
);
assert.ok(
await Promise.race([supervisor.waitForExit(120_000), interrupted]),
controller.signal.aborted
? "Swift decoder probe interrupted"
: "Swift decoder probe timed out",
);
assert.ok(
await supervisor.waitForOutputClose(1_000),
"Swift decoder probe output did not close",
);
const snapshot = supervisor.snapshot();
assert.equal(
snapshot.exitCode,
0,
`${snapshot.stdout.text}\n${snapshot.stderr.text}`,
);
assert.ok(
!snapshot.diagnosticTruncated,
"Swift decoder probe exceeded its diagnostic capacity",
);
} finally {
try {
if (supervisor !== undefined) {
const stopped = await supervisor.stop();
decoderCleaned = stopped.status === "verified-cleanup";
assert.ok(
decoderCleaned,
`Owned Ghidra cleanup incomplete; retained ${root}: ${JSON.stringify(stopped)}`,
);
}
} finally {
process.off("SIGINT", interrupt);
process.off("SIGTERM", interrupt);
}
}
}
+18 -3
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import { TOOL_EFFECTS, annotationsFromEffects } from "./toolEffects.js";
import {
TOOL_EFFECTS,
annotationsFromEffects,
type ToolEffects,
} from "./toolEffects.js";
/**
* Descriptions that name running, launching, executing, starting, installing,
@@ -148,8 +152,19 @@ describe("tool effects", () => {
});
it("derives destructiveHint from each destructive effect alone", () => {
const base = TOOL_EFFECTS["binary_session"];
if (base === undefined) throw new Error("binary_session is unaudited");
// Keep annotation algebra independent of any operation's audited effects.
const base: ToolEffects = {
mutatesTarget: false,
mutatesSession: false,
writesFilesystem: false,
launchesProcess: false,
accessesNetwork: false,
changesUiState: false,
mayDiscardData: false,
executesForeignCode: false,
mutatesDevice: false,
idempotent: true,
};
expect(annotationsFromEffects(base)).toEqual({
readOnlyHint: true,
destructiveHint: false,
+1 -1
View File
@@ -605,7 +605,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
mayDiscardData: true,
idempotent: false,
}),
binary_session: effects(),
binary_session: effects({ launchesProcess: true }),
export_evidence_bundle: effects({
writesFilesystem: true,
mayDiscardData: true,
@@ -73,11 +73,7 @@ const methodDimensions = (
if (bothAbsent) return { dimensions, bodyUnknown: false };
if (!leftBodyComplete || !rightBodyComplete)
return { dimensions, bodyUnknown: true };
// The decoded fingerprint omits switch targets and is not exact CIL identity.
if (
left.body.il_sha256 !== right.body.il_sha256 ||
left.body.normalized_il_sha256 !== right.body.normalized_il_sha256
)
if (left.body.normalized_il_sha256 !== right.body.normalized_il_sha256)
dimensions.push("cil");
if (
canonicalize(left.body.opcode_counts) !==
@@ -49,13 +49,10 @@ interface Ambiguous<Item> {
const keyMethod = (item: Method): Keyed<Method> => ({
item,
exactKey:
item.body.status === "present" &&
item.body.il_sha256 !== null &&
item.body.normalized_il_sha256 !== null
item.body.status === "present" && item.body.normalized_il_sha256 !== null
? sha256([
"method-exact",
item.signature.raw_sha256,
item.body.il_sha256,
item.body.normalized_il_sha256,
])
: null,
@@ -116,63 +116,6 @@ describe("managed member comparison", () => {
});
});
describe("managed member comparison with equal decoded fingerprints", () => {
it.each([
{ name: "Main", basis: "exact-signature" },
{ name: "Renamed", basis: "structural-method-shape" },
])("detects reordered switch targets in $name", ({ name, basis }) => {
const observe = (targets: readonly number[], methodName: string) => {
// ldarg.0; switch (case 0, case 1); ldc.i4.0; ret; ldc.i4.1; ret.
// Both cases target instruction starts, relative to the end of the table.
const il = Buffer.alloc(18);
il[0] = 0x02;
il[1] = 0x45;
il.writeUInt32LE(2, 2);
targets.forEach((target, index) =>
il.writeInt32LE(target, 6 + index * 4),
);
Buffer.from([0x16, 0x2a, 0x17, 0x2a]).copy(il, 14);
return inspect(
buildManagedPeFixture({
methodName,
methodSignature: Buffer.from([0x00, 0x01, 0x08, 0x08]),
ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]),
}),
`/tmp/${methodName}-${targets.join("-")}.dll`,
);
};
const left = observe([0, 2], "Main");
const right = observe([2, 0], name);
const leftBody = left.result.methods[0]?.body;
const rightBody = right.result.methods[0]?.body;
expect(leftBody?.status).toBe("present");
expect(rightBody?.status).toBe("present");
expect(leftBody?.normalized_il_sha256).toBe(
rightBody?.normalized_il_sha256,
);
expect(leftBody?.il_sha256).not.toBe(rightBody?.il_sha256);
const result = compareManagedMembers(left, right);
expect(result.matching.exact_il_signature).toBe(0);
expect(result.methods[0]).toMatchObject({
status: "changed",
match: { status: "matched", basis },
dimensions: ["cil"],
});
expect(
compareManagedMembers(right, observe([2, 0], name)).methods[0],
).toMatchObject({
status: "unchanged",
match: {
status: "matched",
basis: "exact-il-signature",
confidence: "exact",
},
dimensions: [],
});
});
});
describe("managed member comparison floating-point constants", () => {
it.each([
{
@@ -1,152 +0,0 @@
// Producer regressions run inside a real, ephemeral Ghidra analysis of the
// source-owned AArch64 fixture. No target code is executed and no decoded JSON
// fixture substitutes for the Java decoder under test.
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import ghidra.app.util.headless.HeadlessScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.listing.InstructionIterator;
import ghidra.program.model.lang.Register;
import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
public class ReaSwiftDecoderRegressions extends HeadlessScript {
private ReaGhidraBridge decoder;
private Class<?> budgetClass;
@Override
public void run() throws Exception {
decoder = new ReaGhidraBridge();
setInherited(decoder, "currentProgram", currentProgram);
setInherited(decoder, "monitor", monitor);
setInherited(decoder, "sessionDefaultAddressSpace", currentProgram.getAddressFactory().getDefaultAddressSpace());
budgetClass = Class.forName("ReaGhidraBridge$SwiftLiteralBudget", true, decoder.getClass().getClassLoader());
Function copied = function("rea_swift_decoder_copied");
JsonArray copiedValues = literals(decode(copied, 65536));
require(copiedValues.size() > 0, "copied constant pair was not decoded");
for (var element : copiedValues) {
require(element.getAsJsonObject().get("value").getAsString().equals("ok"), "copied words invented text");
}
for (String name : new String[] {"select", "selected_copy", "padding", "utf8"}) {
Object result = decode(function("rea_swift_decoder_" + name), 65536);
require(literals(result).isEmpty(), name + " produced an invented Swift literal");
require(omissions(result).isEmpty(), name + " was confused with capacity exhaustion");
}
for (String name : new String[] {"select", "selected_copy"}) {
Function selected = function("rea_swift_decoder_" + name);
require((Boolean) invoke("selectedValue", new Class<?>[] {Function.class, Instruction.class, Register.class}, selected, returnInstruction(selected), currentProgram.getRegister("x0")),
name + " did not retain conditional-select provenance");
}
require(invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, 0x6261L, 0xe100000000000000L) == null,
"nonzero small-string padding was accepted");
require(invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, 0xc0L, 0xa100000000000000L) == null,
"malformed small-string UTF-8 was accepted");
rejectLarge("rea_swift_decoder_bad_terminator", 0xd000000000000003L);
rejectLarge("rea_swift_decoder_bad_large_utf8", 0x1000000000000001L);
// Zero covers provenance-state exhaustion; 8192 admits both register
// cache entries and then rejects the retained literal/text charge.
assertCapacityOmission(copied, 0);
assertCapacityOmission(copied, 8192);
require(getScriptArgs().length == 1, "Expected owned result path");
Files.writeString(Path.of(getScriptArgs()[0]), "REA_SWIFT_DECODER_SAFEGUARDS_OK\n");
}
private void assertCapacityOmission(Function function, long remaining) throws Exception {
Object exhausted = decode(function, remaining);
require(literals(exhausted).isEmpty(), "capacity failure published decoded small text at budget " + remaining);
JsonArray omitted = omissions(exhausted);
require(omitted.size() == 1, "capacity failure did not stop subsequent use sites at budget " + remaining);
JsonObject omission = omitted.get(0).getAsJsonObject();
require(omission.get("reason").getAsString().equals("capacity"), "capacity omission lost its reason");
require(!omission.has("value"), "omission claimed a retained literal value");
}
private Object decode(Function function, long remaining) throws Exception {
Constructor<?> constructor = budgetClass.getDeclaredConstructor(long.class);
constructor.setAccessible(true);
return invoke("swiftStringLiterals", new Class<?>[] {Function.class, budgetClass}, function, constructor.newInstance(remaining));
}
private void rejectLarge(String name, long word) throws Exception {
Address address = symbol(name).getAddress();
long object = 0x8000000000000000L | ((address.getOffset() - 0x20) & 0x00ffffffffffffffL);
Object layout = invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, word, object);
require(layout != null, "large negative fixture did not reach the memory-text decoder");
require(invoke("swiftLargeLiteralText", new Class<?>[] {layout.getClass()}, layout) == null,
name + " was accepted as literal text");
}
private Instruction returnInstruction(Function function) {
InstructionIterator instructions = currentProgram.getListing().getInstructions(function.getBody(), true);
while (instructions.hasNext()) {
Instruction instruction = instructions.next();
if (instruction.getMnemonicString().equalsIgnoreCase("ret")) return instruction;
}
throw new AssertionError("Fixture has no return instruction");
}
private Function function(String name) {
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
Function found = null;
while (functions.hasNext()) {
Function candidate = functions.next();
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
require(found == null, "ambiguous fixture function " + name);
found = candidate;
}
require(found != null, "missing fixture function " + name);
return found;
}
private Symbol symbol(String name) {
SymbolIterator symbols = currentProgram.getSymbolTable().getAllSymbols(true);
Symbol found = null;
while (symbols.hasNext()) {
Symbol candidate = symbols.next();
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
require(found == null, "ambiguous fixture symbol " + name);
found = candidate;
}
require(found != null, "missing fixture symbol " + name);
return found;
}
private Object invoke(String name, Class<?>[] types, Object... arguments) throws Exception {
Method method = ReaGhidraBridge.class.getDeclaredMethod(name, types);
method.setAccessible(true);
return method.invoke(decoder, arguments);
}
private JsonArray literals(Object result) throws Exception { return array(result, "literals"); }
private JsonArray omissions(Object result) throws Exception { return array(result, "omissions"); }
private JsonArray array(Object result, String name) throws Exception {
Method method = result.getClass().getDeclaredMethod(name);
method.setAccessible(true);
return (JsonArray) method.invoke(result);
}
private void setInherited(Object target, String name, Object value) throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
try {
Field field = type.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
return;
} catch (NoSuchFieldException nextOwner) { }
}
throw new AssertionError("Missing Ghidra state field " + name);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -1,58 +0,0 @@
/* Static-analysis fixture only. Never executed. Deliberate malformed ABI words
* cannot be produced by a well-formed Swift source literal. */
.text
.p2align 2
.globl _rea_swift_decoder_copied
_rea_swift_decoder_copied:
mov x5, #0x6b6f
movz x6, #0xe200, lsl #48
mov x0, x5
mov x1, x6
stp x0, x1, [sp, #-16]!
ldp x0, x1, [sp], #16
ret
.p2align 2
.globl _rea_swift_decoder_select
_rea_swift_decoder_select:
mov x2, #0x61
mov x3, #0x6262
movz x6, #0xe100, lsl #48
movz x7, #0xe200, lsl #48
cmp w4, #0
csel x0, x2, x3, eq
csel x1, x6, x7, eq
ret
.p2align 2
.globl _rea_swift_decoder_selected_copy
_rea_swift_decoder_selected_copy:
mov x2, #0x61
mov x3, #0x62
cmp w4, #0
csel x5, x2, x3, eq
mov x0, x5
movz x1, #0xe100, lsl #48
ret
.p2align 2
.globl _rea_swift_decoder_padding
_rea_swift_decoder_padding:
mov x0, #0x6261
movz x1, #0xe100, lsl #48
ret
.p2align 2
.globl _rea_swift_decoder_utf8
_rea_swift_decoder_utf8:
mov x0, #0xc0
movz x1, #0xa100, lsl #48
ret
.section __DATA,__const
.globl _rea_swift_decoder_bad_terminator
_rea_swift_decoder_bad_terminator:
.byte 0x61, 0x62, 0x63, 0x78
.globl _rea_swift_decoder_bad_large_utf8
_rea_swift_decoder_bad_large_utf8:
.byte 0xc0, 0