Compare commits

...
Author SHA1 Message Date
morluto f5b62fe820 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-07 23:17:48 +08:00
morluto e5af91e689 fix(ghidra): supervise project coverage verification 2026-11-07 23:17:48 +08:00
morluto ac30fbd658 style(test): format CLI help default annotations (#9280) 2026-11-07 23:02:44 +08:00
Nianze Wuandmorluto 5d21bddb18 fix(desktop): read JSON with deeply nested overwritten values (#9277)
* fix(desktop): read JSON with deeply nested overwritten values

* fix(desktop): bound exported JSON construction by view depth

* fix(desktop): preserve deep JSON numeric facts within bounded memory

---------

Co-authored-by: morluto <[email protected]>
2026-11-07 22:57:15 +08:00
morluto 5d0ba95b62 chore(ghidra): merge current main 2026-11-07 22:54:10 +08:00
morluto e61a0b093a fix(ghidra): reconcile selected project coverage 2026-11-07 22:53:26 +08:00
morlutoandCursor Agent 2e2f981ccf test: assert absolute-path facts without frozen sentences (#9231)
* test: assert absolute-path facts without frozen sentences

Sibling path tests now require the field path, invalid_format, and regex
expectation, and they record that each negative case ran. NUL rejection
follows the argument that contains the byte instead of searching for the
word NUL.

* test: retain actionable NUL path diagnostics

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-11-07 22:46:17 +08:00
Heo Hao f8bb792f15 fix(cli): stop restating schema defaults in help descriptions (#9278)
The help renderer appends "(default: X)" whenever the option field declares a
schema default, so descriptions that also spell the default out print it twice:

  --block-size <value>  Power-of-two block size from 256 to 65536 bytes
                        (default 4096) (default: 4096)

Remove the hand-written default from the eleven options that own a schema
default across inspect-byte-entropy, inspect-devicetree,
inspect-packet-capture, inspect-packet-fields, inspect-signal-decoders and
decode-signal-recording, matching the rule in cliDecimalField's contract that
the domain field owns bounds, defaults and help.

Options without a schema default keep their prose: --match_limit, --line_limit,
--limit and --packet_limit are nullable or optional, so the renderer emits no
annotation and the description is the only place the effective value appears.

Add a boundary test asserting each such option states its default exactly once,
so a restated default cannot come back unnoticed.
2026-11-07 22:38:03 +08:00
morluto ec37c2adfb test(ghidra): remove ineffective scan directives 2026-11-01 07:56:09 +08:00
morluto 9ee5c9c603 test(ghidra): document owned verifier exit marker 2026-11-01 07:43:22 +08:00
morluto 979b751dbb test(ghidra): document trusted verifier compiler selection 2026-11-01 07:39:45 +08:00
morluto a9d3247573 test(ghidra): retain verifier MCP cleanup ownership 2026-11-01 07:32:18 +08:00
morluto 9679109ecd fix(ghidra): qualify selected project call coverage 2026-11-01 07:21:30 +08:00
31 changed files with 1430 additions and 216 deletions

No files matched your search

+33 -2
View File
@@ -179,6 +179,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private List<FunctionEntry> procedureInventory;
private List<InventoryItem> stringInventory;
private DecompInterface decompiler;
private boolean existingProject;
// A refused Program may only report its admission failure to the client.
private String identityAdmissionFailure;
/** Per-function native decompiler limit from the session; 0 leaves it unbounded. */
@@ -199,6 +200,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
Path descriptorPath = Path.of(arguments[0]);
checkpointDirectory = descriptorPath.toAbsolutePath().normalize().getParent();
SessionDescriptor descriptor = readDescriptor(descriptorPath);
existingProject = descriptor.existingProject;
decompileTimeoutSeconds = descriptor.decompileTimeoutSeconds();
// Windows retains a native immutable bearer descriptor lease until
// runtime_close; POSIX removes it after this read. Normal cleanup removes
@@ -3716,7 +3718,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private static JsonObject callRelationshipCoverage(
private JsonObject callRelationshipCoverage(
CallBoundaryObservation calls, Integer missingCallers) {
JsonObject coverage = completeResolvedCallCoverage();
Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls();
@@ -3733,7 +3735,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (calls == null && missingCallers == null) directions.add("callers");
directions.add("callees");
coverage.add("limited_directions", directions);
return coverage;
return qualifyProjectCallCoverage(coverage, calls, missingCallers);
}
JsonArray limitations = new JsonArray();
if (callerLimitation != null) limitations.add(callerLimitation);
@@ -3747,6 +3749,35 @@ public final class ReaGhidraBridge extends HeadlessScript {
coverage.add("limitations", limitations);
coverage.add("limited_directions", directions);
}
return qualifyProjectCallCoverage(coverage, calls, missingCallers);
}
private JsonObject qualifyProjectCallCoverage(
JsonObject coverage, CallBoundaryObservation calls, Integer missingCallers) {
if (!existingProject) return coverage;
String reason = "The selected project's prior analysis completeness is unknown; " +
"the recorded call relationships may omit undiscovered callers or callees.";
String status = coverage.get("status").getAsString();
// A single-direction read with known omissions is still partial; an
// unknown project history adds a limitation rather than erasing them.
if (status.equals("partial") && (calls == null || missingCallers == null)) {
coverage.getAsJsonArray("limitations").add(reason);
return coverage;
}
if (status.equals("unknown")) reason += " " + coverage.get("reason").getAsString();
if (status.equals("partial")) {
for (JsonElement limitation : coverage.getAsJsonArray("limitations"))
reason += " " + limitation.getAsString();
}
JsonArray directions = new JsonArray();
if (missingCallers != null || (calls == null && missingCallers == null))
directions.add("callers");
if (calls != null || (calls == null && missingCallers == null))
directions.add("callees");
coverage.addProperty("status", "unknown");
coverage.addProperty("reason", reason);
coverage.remove("limitations");
coverage.add("limited_directions", directions);
return coverage;
}
+2 -2
View File
@@ -310,8 +310,8 @@ entry it does not read. The module reads the central directory itself, as REA
does, and shows `zipfile` only the records it lists, reading each entry in
bounded chunks no further than a byte past its declared size. A JSON file
holding a number JavaScript cannot read exactly, such as an integer above 2^53,
or nested more than 500 levels deep, gets an `error` instead of a view, as in
REA, while text that is not one JSON
or whose retained value nests more than 500 levels deep, gets an `error` instead of a view, as in
REA. Duplicate keys keep their last value before the depth check. Text that is not one JSON
document, such as JSON Lines, stays text. Format detection follows REA's
rules. Files larger than 64 MiB get an `error` instead of a view
and are not read. XML is read with Python's `expat` twice: with namespace
+5
View File
@@ -1558,6 +1558,11 @@ For supported header-selected MIPS ELF targets, the profile also binds the
selected target's ELF metadata; it does not claim a fresh-import MIPS lane was
used for the saved project.
A matching recorded import digest does not claim that the database is unedited.
Caller and callee coverage therefore remains unknown. A single-direction query
with known omissions remains partial and also reports the unknown project history.
Observed call edges are still available: a path to an observed caller or callee
can be reached, but a missing path is unknown when the project's prior analysis
cannot establish completeness.
Saved REA seed/analyzer reports remain historical facts in retained source Evidence;
they are not interpreted as overrides applied by this session.
Project content participates in snapshot identity, so different project states
+1
View File
@@ -45,6 +45,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
| Ghidra lane | Supported runner/target | Additional local tools |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:project-coverage` | POSIX host; selected-project call coverage, real MCP, retained Evidence and an unexecuted host fixture | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback, not-applicable and unknown analyzer names | Ghidra 12.1.4 and full JDK 21 |
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 double-float and RV32 ilp32 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf and objdump, Ghidra 12.1.4, and full JDK 21 |
+1
View File
@@ -207,6 +207,7 @@
"verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:project-coverage": "npm run build:cached && node scripts/verify-real-ghidra-project-coverage.mjs",
"verify:ghidra:checkpoints": "npm run build:cached && node scripts/verify-real-ghidra-checkpoints.mjs",
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
+21
View File
@@ -0,0 +1,21 @@
import type { Client, Transport } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
/** Share an in-flight SDK close, including one started during failed initialization. */
export class VerifierStdioTransport extends StdioClientTransport {}
type VerifierConnection = {
client: Client;
transport: Transport;
definitions: Map<
string,
Awaited<ReturnType<Client["listTools"]>>["tools"][number]
>;
};
/** Own MCP transports from connection through discovery, use, and cleanup retry. */
export function createVerifierMcpOwner(): {
open(client: Client, transport: Transport): Promise<VerifierConnection>;
closeConnection(connection: VerifierConnection): Promise<void>;
close(): Promise<void>;
};
+54
View File
@@ -0,0 +1,54 @@
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import {
closeVerifierResources,
withVerifierCleanup,
} from "./verifier-cleanup.mjs";
/** Share an in-flight SDK close, including one started during failed initialization. */
export class VerifierStdioTransport extends StdioClientTransport {
#closing;
close() {
if (this.#closing !== undefined) return this.#closing;
this.#closing = super.close().catch((error) => {
this.#closing = undefined;
throw error;
});
return this.#closing;
}
}
/** Own MCP transports from connection through discovery, use, and cleanup retry. */
export function createVerifierMcpOwner() {
const owned = new Set();
const closeConnection = async (connection) => {
await closeVerifierResources([
() => connection.client.close(),
() => connection.transport.close(),
]);
owned.delete(connection);
};
return {
async open(client, transport) {
const connection = { client, transport, definitions: new Map() };
owned.add(connection);
try {
await client.connect(transport);
connection.definitions = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
return connection;
} catch (cause) {
return withVerifierCleanup(() => {
throw cause;
}, [() => closeConnection(connection)]);
}
},
closeConnection,
close() {
return closeVerifierResources(
[...owned].map((connection) => () => closeConnection(connection)),
);
},
};
}
@@ -0,0 +1,411 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../dist/process/OwnedCommand.js";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { Client } from "@modelcontextprotocol/client";
import { createAnalysisProfile } from "../dist/domain/analysisProfile.js";
import { createEvidenceBundle } from "../dist/domain/evidenceBundle.js";
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import {
createVerifierMcpOwner,
VerifierStdioTransport,
} from "./lib/verifier-mcp-owner.mjs";
import {
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
const exec = promisify(execFile);
export async function verifyProjectCoverage(run) {
const installDir = process.env.GHIDRA_INSTALL_DIR;
if (installDir === undefined || !isAbsolute(installDir))
throw new Error(
"Set GHIDRA_INSTALL_DIR to an absolute Ghidra installation path.",
);
const installation = await inspectVerifierGhidraInstallation({
environment: process.env,
installDir,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (installation.status !== "available")
throw new Error(`Ghidra is unavailable: ${JSON.stringify(installation)}`);
if (process.platform === "win32")
throw new Error(
"REA selected Ghidra projects are supported on POSIX hosts.",
);
const scriptRoot = fileURLToPath(
new URL("../tests/conformance/ghidra/", import.meta.url),
);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const workspace = await mkdtemp(
join(tmpdir(), "rea-ghidra-project-coverage-"),
);
const connections = createVerifierMcpOwner();
const target = join(workspace, "fixture");
const projects = join(workspace, "projects");
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
JAVA_HOME: installation.javaHome,
GHIDRA_INSTALL_DIR: installation.installDir,
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
REA_LOG_LEVEL: "silent",
REA_PROCESS_RUN_ID: run.run_id,
};
const hash = (data) => createHash("sha256").update(data).digest("hex");
async function manifest(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
return entry.isDirectory()
? manifest(path)
: entry.isFile()
? [[path, hash(await readFile(path))]]
: [];
}),
);
return files.flat().sort((a, b) => a[0].localeCompare(b[0]));
}
async function connect(environment) {
const client = new Client({
name: "selected-project-coverage-verifier",
version: "1",
});
const transport = new VerifierStdioTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
return connections.open(client, transport);
}
async function call(connection, name, args = {}) {
const reply = await connection.client.callTool(
{ name, arguments: args },
{ timeout: 180000, toolDefinition: connection.definitions.get(name) },
);
return requireMcpOutput(reply, name);
}
let commandCleanupIncomplete = false;
const result = await withVerifierCleanup(async () => {
await mkdir(projects);
await exec(
// The invoking developer selects the compiler; execFile does not use a shell.
process.env.REA_CC ?? "cc",
[
"-O0",
"-g",
"-fno-inline",
"-fno-optimize-sibling-calls",
join(scriptRoot, "selected-project-coverage.c"),
"-o",
target,
],
{ env, timeout: 120000 },
);
for (const name of ["partial", "analyzed"]) {
const args = [projects, name, "-import", target];
if (name === "partial")
args.push(
"-noanalysis",
"-scriptPath",
scriptRoot,
"-postScript",
"ReaSelectedProjectSeeds.java",
);
let result;
try {
result = await runOwnedCommand(
{
command: installation.analyzeHeadlessPath,
arguments: args,
env,
runId: run.run_id,
expectedCommand: null,
},
{ timeoutMs: 180000, diagnosticBytes: 16 * 1024 * 1024 },
);
} catch (cause) {
commandCleanupIncomplete =
cause instanceof OwnedCommandFailure &&
cause.cleanupOwner !== undefined;
throw cause;
}
if (name === "partial")
for (const seed of [
"rea_project_leaf",
"rea_project_caller",
"rea_project_other",
])
assert.match(
result.stdout.text,
new RegExp(`REA_PROJECT_SEED ${seed} `),
);
}
const before = {
target: hash(await readFile(target)),
projects: await manifest(projects),
};
const report = [];
for (const mode of ["fresh", "partial", "analyzed"]) {
const selected = mode !== "fresh";
const connection = await connect({
...env,
...(selected
? {
REA_GHIDRA_PROJECT_PATH: join(projects, `${mode}.gpr`),
REA_GHIDRA_PROGRAM_NAME: "fixture",
}
: {}),
});
const bundlePath = join(workspace, `${mode}.json`);
const paths = [];
await withVerifierCleanup(async () => {
await call(connection, "open_binary", {
path: target,
provider_id: "ghidra",
});
const inventory = (
await call(connection, "list_procedures", {
detail: "complete",
})
).normalized_result;
const entry = (name) => {
const matches = inventory.filter(
(item) => item.value === name || item.value === `_${name}`,
);
assert.equal(matches.length, 1, `Expected one entry for ${name}`);
return matches[0].address;
};
const leaf = entry("rea_project_leaf"),
caller = entry("rea_project_caller"),
other = entry("rea_project_other");
const direct = await call(connection, "procedure_callees", {
procedure: caller,
});
assert.equal(
direct.relationship_coverage.status,
selected ? "unknown" : "complete",
);
assert.equal(
direct.normalized_result.includes(leaf),
mode !== "partial",
);
assert.equal(
direct.analysis_profile.parameters
.selected_project_relationship_coverage,
selected ? "prior-analysis-unknown-v1" : undefined,
);
for (const [start, goal, direction] of [
[caller, leaf, "forward"],
[leaf, caller, "backward"],
[other, caller, "backward"],
]) {
const evidence = await call(connection, "trace_call_path", {
start,
goal,
direction,
});
paths.push(evidence);
}
assert.equal(
paths[0].normalized_result.goal_status,
mode === "partial" ? "unknown" : "reached",
);
assert.equal(
paths[1].normalized_result.goal_status,
mode === "partial" ? "unknown" : "reached",
);
assert.equal(
paths[2].normalized_result.goal_status,
selected ? "unknown" : "not_reached",
);
if (selected)
for (const evidence of paths)
assert.ok(
Object.values(
evidence.normalized_result.relationship_coverage,
).some(
(coverage) =>
coverage.status === "unknown" &&
/prior analysis completeness is unknown/u.test(
coverage.reason,
),
),
);
if (mode === "analyzed") {
const dossier = await call(connection, "analyze_function", {
procedure: entry("rea_project_callback"),
detail: "complete",
});
assert.equal(dossier.relationship_coverage.status, "unknown");
assert.match(
dossier.relationship_coverage.reason,
/decoded call\(s\) without destinations/u,
);
assert.match(
dossier.relationship_coverage.reason,
/prior analysis completeness is unknown/u,
);
const callbackPath = await call(connection, "trace_call_path", {
start: entry("rea_project_callback"),
goal: leaf,
direction: "forward",
});
const coverages = Object.values(
callbackPath.normalized_result.relationship_coverage,
);
assert.ok(
coverages.some(
(coverage) =>
coverage.status === "partial" &&
coverage.limitations.some((text) =>
/decoded call\(s\) without destinations/u.test(text),
) &&
coverage.limitations.some((text) =>
/prior analysis completeness is unknown/u.test(text),
),
),
);
}
await call(connection, "export_evidence_bundle", { path: bundlePath });
if (mode === "partial") {
const snapshotPath = join(workspace, "partial-snapshot.json");
await call(connection, "close_binary", {
snapshot_path: snapshotPath,
});
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.equal(
snapshot.binding.analysis_profile.parameters
.selected_project_relationship_coverage,
"prior-analysis-unknown-v1",
);
const profile = snapshot.binding.analysis_profile;
const oldParameters = { ...profile.parameters };
delete oldParameters.selected_project_relationship_coverage;
const stalePath = join(workspace, "partial-old-profile.json");
await writeFile(
stalePath,
JSON.stringify({
...snapshot,
binding: {
...snapshot.binding,
analysis_profile: createAnalysisProfile(
profile.provider,
oldParameters,
),
},
entries: [],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([]),
}),
);
const staleBefore = await readFile(stalePath);
const refused = await connection.client.callTool({
name: "open_binary",
arguments: {
path: target,
provider_id: "ghidra",
snapshot_path: stalePath,
},
});
const error = requireMcpToolError(refused);
assert.equal(error.code, "evidence_integrity_mismatch");
assert.deepEqual(error.details.binding_mismatch.profile_digest, {
snapshot: createAnalysisProfile(profile.provider, oldParameters)
.digest,
selected: profile.digest,
});
assert.deepEqual(await readFile(stalePath), staleBefore);
await call(connection, "open_binary", {
path: target,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
const replay = await call(connection, "trace_call_path", {
start: caller,
goal: leaf,
direction: "forward",
});
assert.deepEqual(
replay.normalized_result,
paths[0].normalized_result,
);
await call(connection, "close_binary");
} else await call(connection, "close_binary");
}, [() => connections.closeConnection(connection)]);
const offline = await connect(env);
await withVerifierCleanup(async () => {
await call(offline, "import_evidence_bundle", { path: bundlePath });
const records = (
await call(offline, "get_evidence_bundle", { detail: "complete" })
).result.records;
for (const evidence of paths) {
const saved = records.find(
(record) => record.evidence_id === evidence.evidence_id,
);
assert.ok(saved);
assert.deepEqual(saved.normalized_result, evidence.normalized_result);
assert.deepEqual(saved.limitations, evidence.limitations);
}
}, [() => connections.closeConnection(offline)]);
report.push({
mode,
statuses: paths.map(
(evidence) => evidence.normalized_result.goal_status,
),
});
}
assert.deepEqual(
{
target: hash(await readFile(target)),
projects: await manifest(projects),
},
before,
);
return {
ok: true,
provider: installation.providerVersion,
results: report,
};
}, [
async () => {
await connections.close();
if (!commandCleanupIncomplete)
await rm(workspace, { recursive: true, force: true });
},
]);
return result;
}
if (
process.argv[1] !== undefined &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const run = createVerifierRun();
const report = await verifyProjectCoverage(run);
await completeVerifierRun(run);
process.stdout.write(`${JSON.stringify(report)}\n`);
}
+7
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { verifyProjectCoverage } from "./verify-real-ghidra-project-coverage.mjs";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -320,6 +322,10 @@ try {
const customPath = process.env.GHIDRA_TARGET_PATH;
const custom =
customPath === undefined ? null : await verifyTarget(customPath, "custom");
const projectCoverage =
!crossFormat && !aarch64JumpTableOnly && process.platform !== "win32"
? await verifyProjectCoverage(verifierRun)
: null;
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
@@ -340,6 +346,7 @@ try {
? (crossResults[0]?.native_api_cli ?? null)
: jumpTable.native_api_cli,
native_value_e2e: debug.native_values,
selected_project_coverage: projectCoverage,
relocation_symbols: relocationSymbols,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
+6 -8
View File
@@ -266,12 +266,10 @@ export const registerBinaryDiagnosticsCommands = (
// "0x10" or "1e2".
blockSize: cliDecimalField(
inspectByteEntropyInputSchema.shape.block_size,
).describe(
"Power-of-two block size from 256 to 65536 bytes (default 4096)",
),
).describe("Power-of-two block size from 256 to 65536 bytes"),
offset: cliDecimalField(
inspectByteEntropyInputSchema.shape.offset,
).describe("Window start as a decimal file byte offset (default 0)"),
).describe("Window start as a decimal file byte offset"),
length: cliDecimalField(
inspectByteEntropyInputSchema.shape.length,
).describe("Window length in bytes; omit to extend through EOF"),
@@ -429,16 +427,16 @@ export const registerBinaryDiagnosticsCommands = (
options: z.object({
maxFileBytes: cliDecimalField(
inspectDevicetreeInputSchema.shape.max_file_bytes,
).describe("Maximum FDT file size in bytes (default 4194304)"),
).describe("Maximum FDT file size in bytes"),
maxNodes: cliDecimalField(
inspectDevicetreeInputSchema.shape.max_nodes,
).describe("Maximum number of device-tree nodes (default 8192)"),
).describe("Maximum number of device-tree nodes"),
maxProperties: cliDecimalField(
inspectDevicetreeInputSchema.shape.max_properties,
).describe("Maximum number of device-tree properties (default 32768)"),
).describe("Maximum number of device-tree properties"),
maxDepth: cliDecimalField(
inspectDevicetreeInputSchema.shape.max_depth,
).describe("Maximum device-tree nesting depth (default 64)"),
).describe("Maximum device-tree nesting depth"),
...analysisEvidenceDeliveryOptions,
}),
run: ({ args, options }) =>
+1 -1
View File
@@ -123,7 +123,7 @@ describe("CLI JSON path normalization", () => {
expect.objectContaining({
path: ["executable_path"],
code: "invalid_format",
message: expect.stringContaining("absolute local filesystem path"),
format: "regex",
}),
]);
});
+1 -1
View File
@@ -51,7 +51,7 @@ const captureInputShape = z.strictObject({
.int()
.nonnegative()
.default(0)
.describe("Skip this many matching packets after filtering; default 0"),
.describe("Skip this many matching packets after filtering"),
match_limit: z
.number()
.int()
+2 -4
View File
@@ -5,9 +5,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
kind: z
.enum(["protocols", "fields"])
.default("protocols")
.describe(
"Return protocol registrations or field definitions; defaults to protocols",
),
.describe("Return protocol registrations or field definitions"),
protocol: z
.string()
.min(1)
@@ -30,7 +28,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
.nonnegative()
.max(Number.MAX_SAFE_INTEGER)
.default(0)
.describe("Number of matching records to skip; defaults to 0"),
.describe("Number of matching records to skip"),
limit: z
.number()
.int()
+3 -142
View File
@@ -1,3 +1,4 @@
import { PYTHON_JSON_READER } from "./appExportPythonJson.js";
import { PYTHON_XML_READER } from "./appExportPythonXml.js";
import { PYTHON_ZIP_HELPERS } from "./appExportPythonZip.js";
@@ -12,6 +13,7 @@ export const PYTHON_READY_CHECK = `
export const PYTHON_IMPORTS = `from __future__ import annotations
from array import array
import contextlib
import copy
import hashlib
@@ -57,11 +59,6 @@ MAX_PARSED_BYTES = 4 * 1024 * 1024
PARSED_TOO_LARGE = "larger than the 4 MiB limit for JSON and key file views"
MAX_JSON_VIEW_DEPTH = 500
JSON_TOO_DEEP = f"nested deeper than the {MAX_JSON_VIEW_DEPTH} levels a JSON view keeps"
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
r"|true|false|null))")
JSON_SPACE = re.compile(r"[ \t\n\r]*")
def _respelled(text: str, home: Path) -> str:
@@ -254,143 +251,7 @@ def _keyfile(text: str) -> dict:
${PYTHON_XML_READER}
def _not_json(constant: str):
raise ValueError(f"{constant} is not JSON")
class _Inexact(Exception):
"""A JSON number that REA's state view refuses, since reading it would change it."""
def _digits(text: str) -> str:
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
def _js_string(value: float) -> str:
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
def _safe_number(text: str) -> bool:
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
if re.fullmatch(r"-?[0-9]+", text):
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
value = float(text)
if not math.isfinite(value):
return False
written = _js_string(value)
return written == text or _digits(written) == _digits(text)
def _exact_number(text: str):
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
written-back form both safe, and not -0."""
value = float(text)
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
or (value == 0 and math.copysign(1, value) < 0)):
raise _Inexact(text)
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
_INEXACT = object()
_LEVEL_END = object()
def _number_or_mark(spelling: str):
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
try:
return _exact_number(spelling)
except _Inexact:
return _INEXACT
def _holds_inexact(value) -> bool:
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
iterator per nesting level, so no child list is copied."""
levels = [iter((value,))]
while levels:
item = next(levels[-1], _LEVEL_END)
if item is _LEVEL_END:
levels.pop()
elif item is _INEXACT:
return True
elif isinstance(item, dict):
levels.append(iter(item.values()))
elif isinstance(item, list):
levels.append(iter(item))
return False
def _deeper_than(root, limit: int) -> bool:
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
iterators open above it, one per nesting level, so no child list is copied."""
levels = [iter((root,))]
while levels:
item = next(levels[-1], _LEVEL_END)
if item is _LEVEL_END:
levels.pop()
elif len(levels) - 1 > limit:
return True
elif isinstance(item, dict):
levels.append(iter(item.values()))
elif isinstance(item, list):
levels.append(iter(item))
return False
def _is_json(text: str) -> bool:
"""Whether text is one JSON value as REA's state view reads it, checked a token at a time without building it or
recursing: it holds a byte per open container and one string at a time, so text too deep for Python's parser or
too large to parse into a view is still told apart from other text."""
containers, expect, empty, at = bytearray(), "value", False, 0
while True:
token = JSON_TOKEN.match(text, at)
if token is None:
break
kind, at = token.lastindex, token.end()
if kind == 5:
try:
at = json.decoder.scanstring(text, at)[1]
except ValueError:
return False
if kind == 1 and expect == "value":
containers.append(ord(text[at - 1]))
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
# A closing bracket's code is its opening's plus two.
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
containers.pop()
expect, empty = "after", False
elif kind == 3 and expect == "after" and containers:
expect = "value" if containers[-1] == ord("[") else "key"
elif kind == 4 and expect == "colon":
expect = "value"
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
expect, empty = ("colon" if expect == "key" else "after"), False
else:
return False
return expect == "after" and not containers and JSON_SPACE.fullmatch(text, at) is not None
def _json_view(text: str, size: int):
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
parsed-view limit, or nested past Python's recursion limit, is told apart without building its value."""
if size > MAX_PARSED_BYTES:
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if _is_json(text) else None
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
try:
view = json.loads(text, parse_constant=_not_json, parse_float=_number_or_mark, parse_int=_number_or_mark)
except RecursionError:
# Nesting too deep for Python to parse is far deeper than REA's view keeps, once the rest of it is JSON too.
return {"format": "json", "view": None, "error": JSON_TOO_DEEP} if _is_json(text) else None
except ValueError:
return None
if _holds_inexact(view):
return {"format": "json", "view": None,
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
return {"format": "json", "view": view, "truncated": False}
${PYTHON_JSON_READER}
def _text_view(name: str, text: str, size: int) -> dict:
"""The format REA's state view detects for decoded text of size bytes, and its view; a JSON or key file over the
+231
View File
@@ -0,0 +1,231 @@
/** JSON parsing and bounded retained-view checks for exported desktop environments. */
export const PYTHON_JSON_READER = String.raw`
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
r"|true|false|null))")
JSON_SPACE = re.compile(r"[ \t\n\r]*")
class _Inexact(Exception):
"""A JSON number that REA's state view refuses, since reading it would change it."""
def _digits(text: str) -> str:
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
def _js_string(value: float) -> str:
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
def _safe_number(text: str) -> bool:
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
if re.fullmatch(r"-?[0-9]+", text):
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
value = float(text)
if not math.isfinite(value):
return False
written = _js_string(value)
return written == text or _digits(written) == _digits(text)
def _exact_number(text: str):
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
written-back form both safe, and not -0."""
value = float(text)
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
or (value == 0 and math.copysign(1, value) < 0)):
raise _Inexact(text)
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
_INEXACT = object()
_DEEP = object()
_DEEP_INEXACT = object()
_LEVEL_END = object()
_INVALID_JSON = object()
JSON_ATOMS = {"true": True, "false": False, "null": None}
def _number_or_mark(spelling: str):
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
try:
return _exact_number(spelling)
except _Inexact:
return _INEXACT
def _holds_inexact(value) -> bool:
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
iterator per nesting level, so no child list is copied."""
levels = [iter((value,))]
while levels:
item = next(levels[-1], _LEVEL_END)
if item is _LEVEL_END:
levels.pop()
elif item is _INEXACT or item is _DEEP_INEXACT:
return True
elif isinstance(item, dict):
levels.append(iter(item.values()))
elif isinstance(item, list):
levels.append(iter(item))
return False
def _deeper_than(root, limit: int) -> bool:
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
iterators open above it, one per nesting level, so no child list is copied."""
levels = [iter((root,))]
while levels:
item = next(levels[-1], _LEVEL_END)
if item is _LEVEL_END:
levels.pop()
elif item is _DEEP or item is _DEEP_INEXACT:
return True
elif len(levels) - 1 > limit:
return True
elif isinstance(item, dict):
levels.append(iter(item.values()))
elif isinstance(item, list):
levels.append(iter(item))
return False
def _read_json(text: str, build: bool = True):
"""One JSON value, or _INVALID_JSON, read without recursion so duplicate keys settle before depth is checked.
Deep containers are summarized rather than built; exact-number marks still follow last-key-wins semantics.
With build=False, only syntax is checked: one byte per open container and one decoded string at a time."""
containers, expect, empty, at = bytearray(), "value", False, 0
values, keys, root = [], [], _INVALID_JSON
deep_bad, deep_key_at, bad_keys = bytearray(), array("I"), {}
def keep(value) -> None:
nonlocal root
if not values:
root = value
elif containers[-1] == ord("["):
values[-1].append(value)
else:
values[-1][keys[-1]] = value
def record_deep(bad: bool) -> None:
"""Summarize a finished child; keep only object keys with live inexact values."""
depth = len(deep_bad) - 1
if containers[-1] == ord("["):
deep_bad[-1] |= 2 | bad
return
marked = bad_keys.get(depth)
if bad or marked is not None:
key, _ = json.decoder.scanstring(text, deep_key_at[-1])
if bad:
if marked is None:
marked = key
elif isinstance(marked, str):
if marked != key:
marked = {marked, key}
else:
marked.add(key)
else:
if isinstance(marked, str):
if marked == key:
marked = None
else:
marked.discard(key)
if len(marked) == 1:
marked = next(iter(marked))
if marked is None:
bad_keys.pop(depth, None)
else:
bad_keys[depth] = marked
deep_bad[-1] = 2 | (marked is not None)
else:
deep_bad[-1] |= 2
while True:
token = JSON_TOKEN.match(text, at)
if token is None:
break
kind, at = token.lastindex, token.end()
if kind == 5:
string_at = at
try:
value, at = json.decoder.scanstring(text, at)
except ValueError:
return _INVALID_JSON
if kind == 1 and expect == "value":
if build:
if deep_bad or len(containers) >= MAX_JSON_VIEW_DEPTH:
deep_bad.append(0)
deep_key_at.append(0)
else:
value = [] if text[at - 1] == "[" else {}
keep(value)
values.append(value)
keys.append(None)
containers.append(ord(text[at - 1]))
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
# A closing bracket's code is its opening's plus two.
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
opening = containers.pop()
if build:
if deep_bad:
state = deep_bad.pop()
bad = bool(state & 1)
deep_key_at.pop()
bad_keys.pop(len(deep_bad), None)
if deep_bad:
record_deep(bad)
else:
keep((_DEEP_INEXACT if bad else _DEEP) if state & 2 else ([] if opening == ord("[") else {}))
else:
values.pop()
keys.pop()
expect, empty = "after", False
elif kind == 3 and expect == "after" and containers:
expect = "value" if containers[-1] == ord("[") else "key"
elif kind == 4 and expect == "colon":
expect = "value"
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
if build:
if expect == "key":
if deep_bad:
deep_key_at[-1] = string_at
else:
keys[-1] = value
else:
if kind == 6:
value = JSON_ATOMS.get(token[6], _INVALID_JSON)
if value is _INVALID_JSON:
value = _number_or_mark(token[6])
if deep_bad:
record_deep(value is _INEXACT)
else:
keep(value)
expect, empty = ("colon" if expect == "key" else "after"), False
else:
return _INVALID_JSON
if expect != "after" or containers or JSON_SPACE.fullmatch(text, at) is None:
return _INVALID_JSON
return root if build else True
def _json_view(text: str, size: int):
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
parsed-view limit is told apart without building its value."""
if size > MAX_PARSED_BYTES:
valid = _read_json(text, build=False) is not _INVALID_JSON
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if valid else None
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
view = _read_json(text)
if view is _INVALID_JSON:
return None
if _holds_inexact(view):
return {"format": "json", "view": None,
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
return {"format": "json", "view": view, "truncated": False}
`;
+20 -1
View File
@@ -21,11 +21,20 @@ const PATHS = [
];
it("matches the host path module for absolute local paths", () => {
const checked: string[] = [];
for (const path of PATHS) {
checked.push(path);
expect(absoluteLocalPathStringSchema.safeParse(path).success).toBe(
isAbsolute(path),
);
}
expect(checked).toEqual(
expect.arrayContaining([
"/absolute/path",
"relative/path",
"file:///absolute/path",
]),
);
expect(absoluteLocalPathStringSchema.safeParse("").success).toBe(false);
expect(
absoluteLocalPathStringSchema.safeParse("/absolute/with\0nul").success,
@@ -40,8 +49,18 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => {
() => new RegExp(windowsAbsolutePathPattern.source, flags),
flags,
).not.toThrow();
for (const path of PATHS)
const checked: string[] = [];
for (const path of PATHS) {
checked.push(path);
expect(windowsAbsolutePathPattern.test(path), path).toBe(
win32.isAbsolute(path),
);
}
expect(checked).toEqual(
expect.arrayContaining([
"C:\\absolute\\path",
"C:relative/path",
"/absolute/path",
]),
);
});
+1 -3
View File
@@ -24,9 +24,7 @@ export const inspectSignalDecodersInputSchema = z.strictObject({
.nonnegative()
.max(Number.MAX_SAFE_INTEGER)
.default(0)
.describe(
"Number of matching decoders or help lines to skip; defaults to 0",
),
.describe("Number of matching decoders or help lines to skip"),
limit: z
.number()
.int()
+1 -1
View File
@@ -31,7 +31,7 @@ const signalInputShape = z.strictObject({
.int()
.nonnegative()
.default(0)
.describe("Skip this many literal LF-separated output lines; default 0"),
.describe("Skip this many literal LF-separated output lines"),
line_limit: z
.number()
.int()
+1
View File
@@ -485,6 +485,7 @@ export const existingGhidraAnalysisProfile = (
...(mipsElf === undefined ? {} : { mips_elf: mipsElf }),
import_mode: "existing-project-private-copy-v1",
...sharedAnalysisSemantics,
selected_project_relationship_coverage: "prior-analysis-unknown-v1",
annotation_policy: "read-only-existing-project-v1",
existing_project: project,
original_analysis: { completeness: "unknown", settings: "unknown" },
@@ -255,11 +255,11 @@ cliTest(
code: "invalid_request",
details: {
issues: [
{
expect.objectContaining({
path: ["executable_path"],
reason: "invalid_format",
message: expect.stringContaining("absolute local filesystem path"),
},
expected: "regex",
}),
],
},
});
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { z } from "zod";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
@@ -329,21 +330,84 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
["start_gdb_session", { inferior_tty: nul }],
];
const nulIssuePaths = (
value: unknown,
path: readonly (string | number)[] = [],
): (string | number)[][] => {
if (typeof value === "string") return value.includes("\0") ? [[...path]] : [];
if (Array.isArray(value))
return value.flatMap((item, index) =>
nulIssuePaths(item, [...path, index]),
);
if (typeof value === "object" && value !== null)
return Object.entries(value).flatMap(([key, item]) =>
nulIssuePaths(item, [...path, key]),
);
return [];
};
it("rejects OS-invalid local inputs through the real advertised MCP contracts", async () => {
const { call } = await connectLocalToolsMcp();
const checked: string[] = [];
for (const [name, arguments_] of pathRequests) {
const response = await call(name, arguments_);
expect(response.isError, name).toBe(true);
expect(JSON.stringify(response.content), name).toContain("NUL");
const paths = nulIssuePaths(arguments_);
expect(paths.length, name).toBeGreaterThan(0);
const error = parseMcpToolError(await call(name, arguments_)).error;
expect(error, name).toMatchObject({
code: "invalid_request",
details: { operation: name },
});
const issues = z
.object({
issues: z.array(
z.object({
path: z.array(z.union([z.string(), z.number()])),
reason: z.string(),
message: z.string(),
expected: z.unknown().optional(),
}),
),
})
.parse(error.details).issues;
expect(issues, name).toEqual(
expect.arrayContaining(
paths.map((path) =>
expect.objectContaining({
path,
reason: "invalid_format",
expected: "regex",
message: expect.stringMatching(/\bNUL\b|null byte|zero byte/i),
}),
),
),
);
checked.push(name);
}
expect(checked).toEqual(
expect.arrayContaining([
"extract_firmware",
"inspect_wasm_artifact",
"demangle_swift",
"capture_electron_scenario",
]),
);
const relative = await call("extract_firmware", {
path: "/tmp/firmware.bin",
output_directory: "relative-output",
});
expect(relative.isError).toBe(true);
expect(JSON.stringify(relative.content)).toContain(
"absolute local filesystem path",
);
expect(parseMcpToolError(relative).error).toMatchObject({
code: "invalid_request",
details: {
operation: "extract_firmware",
issues: [
expect.objectContaining({
path: ["output_directory"],
reason: "invalid_format",
expected: "regex",
}),
],
},
});
}, 60_000);
const ignoredOptions = [
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { createCli } from "../../../src/cli.js";
type CliInstance = ReturnType<typeof createCli>;
const serve = async (
cli: CliInstance,
argv: readonly string[],
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
let stdout = "";
let exitCode = 0;
await cli.serve([...argv], {
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
});
return { stdout, exitCode };
};
/**
* Options whose owning field already declares a schema default. The help
* renderer appends that default, so the description must not restate it.
*/
const singleDefaultOptions: readonly {
readonly command: string;
readonly options: readonly string[];
}[] = [
{ command: "inspect-byte-entropy", options: ["--block-size", "--offset"] },
{
command: "inspect-devicetree",
options: [
"--max-file-bytes",
"--max-nodes",
"--max-properties",
"--max-depth",
],
},
{
command: "decode-signal-recording",
options: ["--line_offset", "--line_limit"],
},
{
command: "inspect-packet-capture",
options: ["--match_offset", "--match_limit"],
},
{
command: "inspect-packet-fields",
options: ["--kind", "--offset"],
},
{ command: "inspect-signal-decoders", options: ["--offset"] },
];
const optionLine = (help: string, option: string): string | undefined => {
const line = help
.split(/\r?\n/u)
.find((candidate) => candidate.trimStart().startsWith(`${option} `));
return line?.trim();
};
describe("CLI help default annotations", () => {
it("states each schema-owned option default exactly once", async () => {
const cli = createCli({});
const repeated: string[] = [];
for (const { command, options } of singleDefaultOptions) {
const { stdout, exitCode } = await serve(cli, [command, "--help"]);
expect(exitCode, command).toBe(0);
for (const option of options) {
const line = optionLine(stdout, option);
if (line === undefined) {
repeated.push(`${command} ${option}: option line missing`);
continue;
}
const statements = line.match(/default/giu)?.length ?? 0;
if (statements !== 1)
repeated.push(
`${command} ${option}: ${statements} default statements`,
);
}
}
expect(repeated).toEqual([]);
});
});
@@ -13,7 +13,7 @@ interface PathInputCase {
readonly field: string;
readonly valid: Readonly<Record<string, unknown>>;
readonly invalid: Readonly<Record<string, unknown>>;
readonly issuePath?: readonly string[];
readonly issuePath: readonly string[];
}
const createMcp = async (
@@ -104,7 +104,13 @@ const inputCases = (
field: string,
valid: Readonly<Record<string, unknown>>,
invalid: Readonly<Record<string, unknown>>,
): PathInputCase => ({ name, field, valid, invalid });
): PathInputCase => ({
name,
field,
valid,
invalid,
issuePath: field.split("."),
});
return [
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
@@ -246,8 +252,7 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const absolute = resolve("rea-absolute-input");
const relative = "relative-input";
for (const { name, field, valid, invalid } of [
const schemaCases = [
...inputCases(absolute, relative),
{
name: "inspect_go_binary",
@@ -276,7 +281,9 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
right_bundle_path: absolute,
},
},
]) {
];
const checked: string[] = [];
for (const { name, field, valid, invalid } of schemaCases) {
const schema = listed.tools.find(
(tool) => tool.name === name,
)?.inputSchema;
@@ -288,7 +295,16 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
validate(valid),
`${name}.${field}: ${JSON.stringify(validate.errors)}`,
).toBe(true);
checked.push(`${name}.${field}`);
}
expect(checked).toEqual(
expect.arrayContaining([
"open_binary.path",
"extract_firmware.output_directory",
"inspect_go_binary.path NUL",
"compare_bundles.left_bundle_path empty",
]),
);
expect(firmwareCalls).toEqual([]);
} finally {
await close();
@@ -299,19 +315,39 @@ it("rejects relative paths through in-memory MCP handlers", async () => {
const firmwareCalls: string[] = [];
const { client, close } = await createMcp(firmwareCalls);
try {
for (const { name, field, invalid, issuePath } of inputCases(
resolve("rea-absolute-input"),
"relative-input",
)) {
const cases = inputCases(resolve("rea-absolute-input"), "relative-input");
const checked: string[] = [];
for (const { name, field, invalid, issuePath } of cases) {
const result = await client.callTool({ name, arguments: invalid });
expect(result.isError, `${name}.${field}`).toBe(true);
expect(JSON.stringify(result), `${name}.${field}`).toContain("absolute");
if (issuePath !== undefined)
expect(parseMcpToolError(result).error.details).toMatchObject({
operation: name,
issues: [{ path: issuePath, reason: "invalid_format" }],
});
expect(parseMcpToolError(result).error, `${name}.${field}`).toMatchObject(
{
code: "invalid_request",
details: {
operation: name,
issues: [
expect.objectContaining({
path: issuePath,
reason: "invalid_format",
expected: "regex",
}),
],
},
},
);
checked.push(`${name}.${field}`);
}
expect(checked).toEqual(
expect.arrayContaining([
"open_binary.path",
"open_binary.snapshot_path",
"extract_firmware.path",
"extract_firmware.output_directory",
"inspect_analysis_view.source.path",
"capture_browser_scenario.browser.executable_path",
"capture_electron_scenario.application_root",
]),
);
expect(firmwareCalls).toEqual([]);
} finally {
await close();
+26 -4
View File
@@ -709,8 +709,19 @@ describe("session filesystem path boundaries over MCP", () => {
name: "export_evidence_bundle",
arguments: { path: "relative-bundle.json" },
});
expect(exported.isError, JSON.stringify(exported.content)).toBe(true);
expect(JSON.stringify(exported.content)).toContain("absolute");
expect(parseMcpToolError(exported).error).toMatchObject({
code: "invalid_request",
details: {
operation: "export_evidence_bundle",
issues: [
expect.objectContaining({
path: ["path"],
reason: "invalid_format",
expected: "regex",
}),
],
},
});
const opened = await mcp.callTool({
name: "open_binary",
@@ -721,8 +732,19 @@ describe("session filesystem path boundaries over MCP", () => {
name: "close_binary",
arguments: { snapshot_path: "relative-analysis.json" },
});
expect(closed.isError, JSON.stringify(closed.content)).toBe(true);
expect(JSON.stringify(closed.content)).toContain("absolute");
expect(parseMcpToolError(closed).error).toMatchObject({
code: "invalid_request",
details: {
operation: "close_binary",
issues: [
expect.objectContaining({
path: ["snapshot_path"],
reason: "invalid_format",
expected: "regex",
}),
],
},
});
await mcp.callTool({ name: "close_binary", arguments: {} });
}, 10_000);
});
+22 -14
View File
@@ -383,7 +383,7 @@ it("attributes rejected WASM paths to their selected fields before provider work
},
});
onTestFinished(harness.close);
for (const [input, paths] of [
const relativeCases = [
[{ path: "relative.wasm" }, [["path"]]],
[
{ path: value.artifact.path, glue_paths: ["relative.js"] },
@@ -412,7 +412,9 @@ it("attributes rejected WASM paths to their selected fields before provider work
},
[["path"], ["glue_paths", 0], ["candidate_paths", 0]],
],
] as const) {
] as const;
const checked: string[] = [];
for (const [input, paths] of relativeCases) {
const response = await harness.client.callTool({
name: "inspect_wasm_artifact",
arguments: input,
@@ -423,14 +425,19 @@ it("attributes rejected WASM paths to their selected fields before provider work
.object({ issues: z.array(z.unknown()) })
.parse(failure.details);
expect(issues).toEqual(
paths.map((path) => ({
path,
reason: "invalid_format",
expected: "regex",
message: "must be an absolute local filesystem path",
})),
paths.map((path) =>
expect.objectContaining({
path,
reason: "invalid_format",
expected: "regex",
}),
),
);
checked.push(paths.map((path) => path.join(".")).join("+"));
}
expect(checked).toEqual(
expect.arrayContaining(["path", "glue_paths.0", "candidate_paths.0"]),
);
expect(inspect).not.toHaveBeenCalled();
const valid = await harness.client.callTool({
name: "inspect_wasm_artifact",
@@ -760,12 +767,13 @@ it("rejects malformed paths, refuses WASM summary delivery before provider work
expect(
z.object({ issues: z.array(z.unknown()) }).parse(rejected.details).issues,
).toEqual(
[["path"], ["glue_paths", 0]].map((path) => ({
path,
reason: "invalid_format",
expected: "regex",
message: "must be an absolute local filesystem path",
})),
[["path"], ["glue_paths", 0]].map((path) =>
expect.objectContaining({
path,
reason: "invalid_format",
expected: "regex",
}),
),
);
expect(inspect).not.toHaveBeenCalled();
for (const input of [
@@ -0,0 +1,74 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type Transport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import {
createVerifierMcpOwner,
VerifierStdioTransport,
} from "../../../scripts/lib/verifier-mcp-owner.mjs";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each(["initialize", "discovery"])(
"closes the real MCP child when %s fails before ownership transfers",
async (mode) => {
const workspace = await createTestTempDirectory("rea-verifier-mcp-owner-");
const marker = join(workspace, "exit.txt");
const client = new Client({ name: "verifier-owner-test", version: "1" });
const transport = new VerifierStdioTransport({
command: process.execPath,
args: [
fileURLToPath(
new URL("../../fixtures/verifier-mcp-failure.mjs", import.meta.url),
),
],
env: {
PATH: process.env.PATH ?? "",
REA_TEST_EXIT_MARKER: marker,
REA_TEST_FAILURE_MODE: mode,
},
stderr: "pipe",
});
const owner = createVerifierMcpOwner();
try {
await expect(owner.open(client, transport)).rejects.toThrow();
expect(await readFile(marker, "utf8")).toBe("exited");
} finally {
await owner.close();
}
},
);
it("retains an owner after failed cleanup and retries every close", async () => {
const failure = new Error("discovery failed");
const cleanupFailure = new Error("client close failed");
let clientCloses = 0;
let transportCloses = 0;
const client = {
connect: async () => {},
listTools: async () => {
throw failure;
},
close: async () => {
if (++clientCloses === 1) throw cleanupFailure;
},
} as unknown as Client;
const transport = {
close: async () => {
transportCloses++;
},
} as unknown as Transport;
const owner = createVerifierMcpOwner();
let thrown: unknown;
try {
await owner.open(client, transport);
} catch (cause) {
thrown = cause;
}
expect(thrown).toBeInstanceOf(AggregateError);
expect((thrown as AggregateError).cause).toBe(failure);
expect(transportCloses).toBe(1);
await owner.close();
expect(clientCloses).toBe(2);
expect(transportCloses).toBe(2);
});
@@ -0,0 +1,205 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
PYTHON_HELPERS,
PYTHON_IMPORTS,
} from "../../../src/domain/desktop/appExportPython.js";
import {
detectStateFormat,
textualView,
} from "../../../src/domain/desktop/stateFormats.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const arrays = "[".repeat(1500) + "1" + "]".repeat(1500);
const objects = '{"a":'.repeat(1500) + "1" + "}".repeat(1500);
const cases = [
["discarded arrays", `{"value":${arrays},"value":"done"}`, true],
["discarded objects", `{"value":${objects},"value":"done"}`, true],
["escaped duplicate key", `{"val\\u0075e":${arrays},"value":"done"}`, true],
["retained deep value", `{"value":${arrays}}`, false],
[
"retained deep inexact number",
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)}}`,
false,
],
[
"deep overwritten inexact number",
`{"value":${"[".repeat(600)}{"x":9007199254740993,"x":1}${"]".repeat(600)}}`,
false,
],
[
"deep empty-key overwrite",
`{"value":${"[".repeat(600)}{"":9007199254740993,"":1}${"]".repeat(600)}}`,
false,
],
[
"deep multiple inexact keys with one overwritten",
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1}${"]".repeat(600)}}`,
false,
],
[
"deep multiple inexact keys all overwritten",
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1,"b":2}${"]".repeat(600)}}`,
false,
],
[
"deep inexact key replaced by inexact",
`{"value":${"[".repeat(600)}{"a":9007199254740993,"a":9007199254740995}${"]".repeat(600)}}`,
false,
],
[
"deep escaped duplicate overwritten",
`{"value":${"[".repeat(600)}{"\\u0061":9007199254740993,"a":1}${"]".repeat(600)}}`,
false,
],
[
"discarded deep inexact subtree",
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)},"value":"done"}`,
true,
],
["last admitted depth", "[".repeat(500) + "1" + "]".repeat(500), true],
["empty container at limit", "[".repeat(501) + "]".repeat(501), true],
["empty container over limit", "[".repeat(502) + "]".repeat(502), false],
["trailing comma", `{"value":${arrays},"value":"done",}`, false],
["missing value", `{"value":${arrays},"value":}`, false],
["wrong closing bracket", `{"value":${arrays},"value":"done"]`, false],
] as const;
it.each(cases)("reads JSON with %s", async (_name, text, readable) => {
const root = await createTestTempDirectory("desktop-json-depth-");
const script = join(root, "read.py");
const imports = PYTHON_IMPORTS.split("\n")
.filter((line) => !line.includes("agentenv_protocol"))
.join("\n");
await writeFile(
script,
`${imports}${PYTHON_HELPERS}
text = json.load(sys.stdin)
print(json.dumps(_text_view("state.json", text, len(text.encode("utf-8")))))
`,
);
const output = JSON.parse(
execFileSync(
process.platform === "win32" ? "python" : "python3",
["-I", "-B", "-X", "utf8", script],
{ input: JSON.stringify(text), encoding: "utf8" },
),
) as { format: string; view: unknown; error?: string };
const bytes = Buffer.from(text);
const format = detectStateFormat("state.json", bytes);
if (format !== "json" && format !== "text")
throw new Error(`Unexpected native format ${format}`);
const native = textualView(format, bytes);
expect(format === "json" && native.ok).toBe(readable);
expect(output.format).toBe(format);
if (native.ok) {
expect(output.error).toBeUndefined();
// Compare JSON values on their wire form, independent of object prototypes.
expect(JSON.stringify(output.view)).toBe(JSON.stringify(native.value));
if (
readable &&
(_name.startsWith("discarded") || _name === "escaped duplicate key")
)
expect(output.view).toEqual({ value: "done" });
} else {
expect(output.view).toBeNull();
expect(output.error).toBe(native.error);
}
});
it("bounds construction before refusing retained excessive JSON depth", async () => {
const root = await createTestTempDirectory("desktop-json-construction-");
const script = join(root, "bounded.py");
const imports = PYTHON_IMPORTS.split("\n")
.filter((line) => !line.includes("agentenv_protocol"))
.join("\n");
await writeFile(
script,
`${imports}${PYTHON_HELPERS}
import tracemalloc
text = "[" * 20_000 + "0" + "]" * 20_000
recursion_limit = sys.getrecursionlimit()
tracemalloc.start()
entry = _json_view(text, len(text))
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(json.dumps({"entry": entry, "peak": peak, "recursion_unchanged": sys.getrecursionlimit() == recursion_limit}))
`,
);
const output = JSON.parse(
execFileSync(
process.platform === "win32" ? "python" : "python3",
["-I", "-B", "-X", "utf8", script],
{ encoding: "utf8" },
),
) as { entry: unknown; peak: number; recursion_unchanged: boolean };
expect(output.entry).toEqual({
format: "json",
view: null,
error: "nested deeper than the 500 levels a JSON view keeps",
});
expect(output.recursion_unchanged).toBe(true);
// Python heap only, excluding source construction and interpreter/OS memory.
// A value retained only to refuse it must not allocate every input container.
expect(output.peak).toBeLessThan(1024 * 1024);
});
it("bounds allocations when a very deep value is overwritten", async () => {
const root = await createTestTempDirectory("desktop-json-memory-");
const script = join(root, "read.py");
const imports = PYTHON_IMPORTS.split("\n")
.filter((line) => !line.includes("agentenv_protocol"))
.join("\n");
await writeFile(
script,
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
text = '{"value":' + '[' * 200000 + '1' + ']' * 200000 + ',"value":"done"}'
tracemalloc.start()
result = _json_view(text, len(text))
_, peak = tracemalloc.get_traced_memory()
print(json.dumps({"view": result["view"], "peak": peak}))
`,
);
const measured = JSON.parse(
execFileSync(
process.platform === "win32" ? "python" : "python3",
["-I", "-B", script],
{ encoding: "utf8", timeout: 30000 },
),
) as { view: unknown; peak: number };
expect(measured.view).toEqual({ value: "done" });
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
}, 40000);
it("bounds a wide container already beyond the retained view depth", async () => {
const root = await createTestTempDirectory("desktop-json-wide-depth-");
const script = join(root, "read.py");
const imports = PYTHON_IMPORTS.split("\n")
.filter((line) => !line.includes("agentenv_protocol"))
.join("\n");
await writeFile(
script,
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
text = '[' * 501 + ','.join(['"abc"'] * 600000) + ']' * 501
tracemalloc.start()
result = _json_view(text, len(text))
_, peak = tracemalloc.get_traced_memory()
print(json.dumps({"error": result.get("error"), "peak": peak}))
`,
);
const measured = JSON.parse(
execFileSync(
process.platform === "win32" ? "python" : "python3",
["-I", "-B", script],
{ encoding: "utf8", timeout: 30000 },
),
) as { error: string; peak: number };
expect(measured.error).toBe(
"nested deeper than the 500 levels a JSON view keeps",
);
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
}, 40000);
@@ -34,7 +34,7 @@ const execPython = (
try {
writeFileSync(script, args[codeIndex + 1]!, "utf8");
return execFileSync(
"python3",
process.platform === "win32" ? "python" : "python3",
[
"-X",
"utf8",
@@ -375,15 +375,16 @@ it("checks text for JSON the way REA does, token by token, without parsing it",
"-I",
"-B",
"-c",
`${imports}${PYTHON_HELPERS}\nprint(json.dumps([_is_json(text) for text in json.load(sys.stdin)]))`,
`${imports}${PYTHON_HELPERS}\ntexts = json.load(sys.stdin)\nprint(json.dumps({"syntax": [_read_json(text, build=False) is not _INVALID_JSON for text in texts], "built": [_read_json(text) is not _INVALID_JSON for text in texts]}))`,
],
{ input: JSON.stringify(texts), encoding: "utf8" },
),
) as boolean[];
) as { syntax: boolean[]; built: boolean[] };
const parsed = texts.map((text) => safeParseJson(text).ok);
expect(texts.map((text) => isJsonText(text))).toEqual(parsed);
expect(python).toEqual(parsed);
expect(python.syntax).toEqual(parsed);
expect(python.built).toEqual(parsed);
expect(parsed.filter(Boolean)).toHaveLength(10);
});
@@ -445,9 +446,13 @@ print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["
const root = await createTestTempDirectory("rea-keyfile-python-");
const script = join(root, "fixture.py");
await writeFile(script, program, "utf8");
const output = execFileSync("python3", ["-I", "-B", "-X", "utf8", script], {
input: JSON.stringify(inputs),
encoding: "utf8",
});
const output = execFileSync(
process.platform === "win32" ? "python" : "python3",
["-I", "-B", "-X", "utf8", script],
{
input: JSON.stringify(inputs),
encoding: "utf8",
},
);
expect(JSON.parse(output)).toEqual(inputs.map(keyfileView));
});
@@ -0,0 +1,26 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressSet;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Symbol;
import java.util.List;
// Create addressable entries without decoding their instructions or analyzing calls.
public class ReaSelectedProjectSeeds extends GhidraScript {
@Override public void run() throws Exception {
for (String name : List.of("rea_project_leaf", "rea_project_caller", "rea_project_other")) {
List<Symbol> symbols = getSymbols(name, null);
if (symbols.isEmpty()) symbols = getSymbols("_" + name, null);
if (symbols.size() != 1) throw new IllegalStateException("Expected one symbol for " + name);
Address entry = symbols.get(0).getAddress();
Function function = getFunctionAt(entry);
if (function == null)
function = currentProgram.getFunctionManager().createFunction(
symbols.get(0).getName(), entry, new AddressSet(entry, entry), SourceType.USER_DEFINED);
if (currentProgram.getListing().getInstructions(function.getBody(), true).hasNext())
throw new IllegalStateException("Expected an unanalyzed function: " + name);
println("REA_PROJECT_SEED " + name + " " + entry);
}
}
}
@@ -0,0 +1,14 @@
volatile int rea_project_global = 7;
__attribute__((noinline, used)) int rea_project_leaf(int x) {
return x + rea_project_global;
}
__attribute__((noinline, used)) int rea_project_caller(int x) {
return rea_project_leaf(x);
}
__attribute__((noinline, used)) int rea_project_other(int x) {
return x - rea_project_global;
}
__attribute__((noinline, used)) int rea_project_callback(int (*fn)(int), int x) {
return fn(x) + rea_project_global;
}
int main(void) { return rea_project_caller(rea_project_global); }
+36
View File
@@ -0,0 +1,36 @@
import { writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
const marker = process.env.REA_TEST_EXIT_MARKER;
const mode = process.env.REA_TEST_FAILURE_MODE;
if (!marker || !mode) throw new Error("Missing verifier fixture configuration");
const keepAlive = setInterval(() => {}, 1000);
// The test harness selects its own temporary exit marker.
process.on("exit", () => writeFileSync(marker, "exited"));
process.stdin.on("end", () => clearInterval(keepAlive));
const input = createInterface({ input: process.stdin });
input.on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
if (request.method === "initialize" && mode !== "initialize") {
process.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: {
protocolVersion: request.params.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "verifier-failure-fixture", version: "1" },
},
})}\n`,
);
return;
}
process.stdout.write(
`${JSON.stringify({
jsonrpc: "2.0",
id: request.id,
error: { code: -32603, message: `intentional ${mode} failure` },
})}\n`,
);
});