Compare commits

...
Author SHA1 Message Date
morluto 45a3d03dbf style(test): format CLI help default annotations (#9280)
(cherry picked from commit ac30fbd658)
2026-11-07 23:16:19 +08:00
morluto 4e518b0f2b chore: sync main test updates 2026-11-07 22:50:57 +08:00
morluto 4983d30edf chore: sync main analysis changes 2026-11-07 22:50:54 +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
morluto eb94309098 fix(cli): reconcile source closure with main 2026-11-07 22:42:55 +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 67ca695caf Merge branch 'babysit-8870' into babysit-8873
# Conflicts:
#	src/application/DirectAnalysis.ts
#	src/cli/analysisEvidenceDelivery.ts
#	tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts
2026-11-07 15:52:27 +08:00
morluto 95dc5c0ad0 Merge remote-tracking branch 'origin/main' into babysit-8870
# Conflicts:
#	src/application/DirectAnalysis.ts
#	src/application/binary/AnalysisSnapshotCache.ts
#	src/application/binary/AnalysisSnapshotReplayBinding.ts
#	src/application/investigation/InvestigationEvidence.ts
#	tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts
#	tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts
2026-11-07 15:51:36 +08:00
morluto 5df3e273ae fix: supervise function bundle verifier processes 2026-11-07 15:24:00 +08:00
morluto 196f0afbbc fix(cli): retain sources across incomplete finalization 2026-11-04 14:37:44 +08:00
morluto 3368a036ae test(ghidra): verify saved project evidence bundles 2026-11-04 14:34:19 +08:00
morluto 2e1e99283e fix(cli): preserve partially captured source closure 2026-11-04 14:26:17 +08:00
morluto 2e8d463400 fix(cli): retain function evidence sources in bundles 2026-11-04 14:24:09 +08:00
morluto e62484931c fix: bound and share source-closure checks 2026-11-04 14:17:15 +08:00
morluto 89d5bb7056 fix: revalidate restored snapshot sources lazily 2026-11-04 14:06:59 +08:00
morluto 75c7187290 fix: preserve source closure during snapshot replay 2026-11-04 13:55:45 +08:00
22 changed files with 826 additions and 102 deletions

No files matched your search

@@ -0,0 +1,187 @@
#!/usr/bin/env node
// Exercise the public one-shot CLI on a caller-owned saved project. The report
// is intentionally synthetic; it is not a claim about Ghidra's analysis history.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
const installation = process.env.GHIDRA_INSTALL_DIR;
if (!installation || !process.env.JAVA_HOME)
throw new Error(
"Set GHIDRA_INSTALL_DIR and JAVA_HOME to caller-installed Ghidra and JDK 21.",
);
const commands = await createVerifierCommands();
let root;
let commandsClosed = false;
const targetHash = await withVerifierCleanup(async () => {
root = await mkdtemp(join(tmpdir(), "rea-function-bundle-"));
const target = join(root, "fixture");
const projectDir = join(root, "project");
const scriptDir = join(root, "scripts");
const source = join(root, "fixture.c");
const project = join(projectDir, "SourceControl.gpr");
const repo = resolve(process.cwd());
const hash = (data) => createHash("sha256").update(data).digest("hex");
const tree = async (directory, prefix = "") => {
const result = [];
for (const entry of (
await readdir(directory, { withFileTypes: true })
).sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(directory, entry.name);
const relative = join(prefix, entry.name);
if (entry.isDirectory()) result.push(...(await tree(path, relative)));
else if (entry.isFile())
result.push([relative, hash(await readFile(path))]);
else throw new Error(`Unexpected project entry: ${relative}`);
}
return result;
};
const run = async (command, args, options = {}) =>
commands.exec(command, args, {
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
...options,
});
await mkdir(projectDir);
await mkdir(scriptDir);
await writeFile(
source,
"__attribute__((noinline)) int leaf(int x) { return x + 1; }\nint main(void) { return leaf(2); }\n",
);
await writeFile(
join(scriptDir, "SetHistory.java"),
`import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Program;
public class SetHistory extends GhidraScript {
public void run() throws Exception {
currentProgram.getOptions(Program.PROGRAM_INFO).setString("REA disabled analyzers", "{\\"format\\":\\"rea-ghidra-disabled-analyzers-v1\\",\\"analyzers\\":[{\\"name\\":\\"Synthetic historical control\\",\\"status\\":\\"disabled\\"}]}");
}
}
`,
);
await run(process.env.REA_CC ?? "cc", ["-O0", "-g", source, "-o", target]);
await run(join(installation, "support", "analyzeHeadless"), [
projectDir,
"SourceControl",
"-import",
target,
"-scriptPath",
scriptDir,
"-postScript",
"SetHistory.java",
]);
const before = await tree(projectDir);
const targetHash = hash(await readFile(target));
const env = {
...process.env,
REA_GHIDRA_PROJECT_PATH: project,
REA_GHIDRA_PROGRAM_NAME: "fixture",
};
const cli = join(repo, "scripts", "rea.mjs");
for (const detail of ["complete", "summary"]) {
const output = join(root, `${detail}.json`);
const { stdout } = await run(
process.execPath,
[
cli,
"function",
target,
"main",
"--provider",
"ghidra",
"--detail",
detail,
"--evidence_output",
output,
"--format",
"json",
"--full-output",
],
{ env },
);
assert.equal(JSON.parse(stdout).ok, true);
const bundle = JSON.parse(await readFile(output, "utf8"));
const parent = bundle.records.find(
(record) => record.operation === "analyze_function",
);
assert.ok(parent, "function observation missing from bundle");
assert.ok(
parent.evidence_links.length,
"fixture did not produce a linked historical source",
);
const records = new Map(
bundle.records.map((record) => [record.evidence_id, record]),
);
for (const record of bundle.records)
for (const link of record.evidence_links)
assert.ok(records.has(link), `bundle omits linked Evidence ${link}`);
assert.ok(
parent.evidence_links.some(
(id) => records.get(id)?.operation === "health",
),
"historical health source missing",
);
}
const existing = join(root, "existing.json");
await writeFile(existing, "caller-owned\n");
let failure;
try {
await run(
process.execPath,
[
cli,
"function",
target,
"main",
"--provider",
"ghidra",
"--detail",
"summary",
"--evidence_output",
existing,
"--format",
"json",
"--full-output",
],
{ env },
);
assert.fail("existing output unexpectedly overwritten");
} catch (error) {
if (error.code !== 1) throw error;
failure = JSON.parse(error.snapshot.stdout.text);
}
assert.equal(await readFile(existing, "utf8"), "caller-owned\n");
assert.equal(failure.ok, false);
assert.equal(failure.error.details.source_closure.complete, true);
assert.ok(
failure.error.details.source_closure.records.some(
(record) => record.operation === "health",
),
);
assert.equal(hash(await readFile(target)), targetHash);
assert.deepEqual(await tree(projectDir), before);
return targetHash;
}, [
async () => {
await commands.close();
commandsClosed = true;
},
async () => {
if (commandsClosed && root !== undefined)
await rm(root, { recursive: true, force: true });
},
]);
process.stdout.write(
`PASS Ghidra function bundles: complete, summary, no-overwrite; target and project unchanged (${targetHash})\n`,
);
+41 -31
View File
@@ -16,7 +16,10 @@ import {
import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js";
import { silentLogger } from "../logger.js";
import type { Logger } from "pino";
import { withEvidenceCallerInputPath } from "../domain/evidence.js";
import {
tryParseEvidence,
withEvidenceCallerInputPath,
} from "../domain/evidence.js";
import {
createImmutableEvidenceBundle,
type EvidenceBundle,
@@ -25,8 +28,10 @@ import {
retainAnalysisResultSources,
type AnalysisResultWithSources,
} from "./investigation/recordEvidenceSources.js";
import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js";
import { createInvestigationEvidenceClosureChecker } from "./investigation/InvestigationEvidence.js";
import {
createInvestigationEvidenceClosureChecker,
collectInvestigationEvidenceClosure,
} from "./investigation/InvestigationEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
@@ -383,12 +388,12 @@ const runAnalysis = async (
readonly evidenceParameters?: Readonly<Record<string, JsonValue>>;
readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void;
readonly createSession?: () => BinarySession | Promise<BinarySession>;
readonly includeEvidenceSources?: boolean;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly espFlash?: EspFlashSelection;
readonly includeEvidenceSources?: boolean;
},
): Promise<AnalysisResultWithSources<Evidence, JsonValue>> => {
const { logger, signal, snapshotPath } = options;
@@ -411,23 +416,19 @@ const runAnalysis = async (
evidence: Evidence,
supplied: readonly Evidence[] = [],
): Result<null, AnalysisError> => {
if (options.includeEvidenceSources !== true) return ok(null);
const known = new Map(
if (!options.includeEvidenceSources) return ok(null);
const records = new Map(
supplied.map((record) => [record.evidence_id, record]),
);
const closure = resolveInvestigationEvidenceClosure(
const collected = collectInvestigationEvidenceClosure(
{
evidenceForAnalysis: (id) => {
const record = known.get(id) ?? session.evidenceForAnalysis(id);
if (record !== undefined) known.set(id, record);
return record;
},
evidenceForAnalysis: (id) =>
records.get(id) ?? session.evidenceForAnalysis(id),
},
evidence.evidence_links,
);
if (!closure.ok) return closure;
sourceEvidence = closure.value.map((id) => known.get(id) as Evidence);
return ok(null);
sourceEvidence = collected.records;
return collected.result;
};
const execute = async (): Promise<Result<Evidence, AnalysisError>> => {
let isDirectory = false;
@@ -512,12 +513,12 @@ const runAnalysis = async (
});
if (checkClosure(cached.evidence_links)) {
const evidence = withEvidenceCallerInputPath(cached, path);
const sources = captureSources(
const captured = captureSources(
evidence,
snapshot.evidence_bundle.records,
);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
options.onEvidenceBundle?.(snapshot.evidence_bundle);
return ok(evidence);
}
@@ -548,20 +549,30 @@ const runAnalysis = async (
evidenceProfile,
saveSnapshot: snapshotPath !== undefined,
});
if (!executed.ok) return executed;
if (!executed.ok) {
if (options.includeEvidenceSources) {
const partial = tryParseEvidence(executed.error.partialObservation);
if (partial !== undefined) {
const captured = captureSources(partial);
if (!captured.ok)
executed.error.retainSecondaryFailure(captured.error);
}
}
return executed;
}
const { cachePolicy } = executed.value;
const evidence = withEvidenceCallerInputPath(executed.value.evidence, path);
// Provider execution may have already recorded the same semantic Evidence
// without this invocation's request path. Import replaces that metadata
// while retaining the authenticated result and snapshot binding.
const captured = captureSources(evidence);
if (!captured.ok)
return err(captured.error.retainPartialObservation(evidence));
const recorded = session.importEvidenceBundle(
createImmutableEvidenceBundle([evidence]),
);
if (!recorded.ok)
return err(recorded.error.retainPartialObservation(evidence));
const sources = captureSources(evidence);
if (!sources.ok)
return err(sources.error.retainPartialObservation(evidence));
if (isWorkflowEvidenceTool(tool)) {
const unknowns = recordWorkflowUnknowns({
name: tool,
@@ -643,16 +654,15 @@ const runAnalysis = async (
outcome.cleanupError.retainDiagnostics({
analysis_snapshot: { ...publishedSnapshot },
});
return err(
directAnalysisCleanupFailure(result, outcome.cleanupError, path),
);
return {
...err(directAnalysisCleanupFailure(result, outcome.cleanupError, path)),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
}
return result.ok
? {
...ok(result.value),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
}
: err(cliError(result.error, path));
return {
...(result.ok ? ok(result.value) : err(cliError(result.error, path))),
...(sourceEvidence === undefined ? {} : { sourceEvidence }),
};
};
const executeAnalysisTool = async (input: {
@@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { InvestigationRecords } from "./InvestigationRecords.js";
import {
createInvestigationEvidenceClosureChecker,
collectInvestigationEvidenceClosure,
resolveInvestigationEvidenceClosure,
} from "./InvestigationEvidence.js";
@@ -158,3 +159,25 @@ it("reuses a complete shared source graph without weakening missing-source check
expect(lookups).toBe(3);
expect(check([source.evidence_id, missing])).toBe(false);
});
it("collects other reachable sources after a missing ancestor", () => {
const missing = `ev_${"0".repeat(64)}`;
const first = observation("/reachable");
const incomplete = observation("/incomplete", [missing]);
const records = new InvestigationRecords();
for (const record of [first, incomplete]) {
const added = records.recordEvidence(record);
if (!added.ok) throw added.error;
}
const result = collectInvestigationEvidenceClosure(records, [
first.evidence_id,
incomplete.evidence_id,
]);
expect(result.result).toMatchObject({
ok: false,
error: { reason: "missing" },
});
expect(result.records.map((record) => record.evidence_id).sort()).toEqual(
[first.evidence_id, incomplete.evidence_id].sort(),
);
});
@@ -3,7 +3,7 @@ import type { EvidenceReader } from "./InvestigationRecordPort.js";
import { uniqueSorted } from "../../domain/canonicalOrdering.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { Evidence } from "../../domain/evidence.js";
import { ok, type Result } from "../../domain/result.js";
import { err, ok, type Result } from "../../domain/result.js";
type Lookup = (id: string) => Evidence | undefined;
@@ -12,8 +12,10 @@ const visitClosure = (
lookup: Lookup,
seedIds: readonly string[],
knownComplete?: ReadonlySet<string>,
collect?: (record: Evidence) => void,
): Result<Set<string>, EvidenceIntegrityError> => {
const visited = new Set<string>();
let failure: EvidenceIntegrityError | undefined;
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
@@ -28,12 +30,36 @@ const visitClosure = (
lookup,
"retained-closure",
);
if (!evidence.ok) return evidence;
if (!evidence.ok) {
if (collect === undefined) return evidence;
failure ??= evidence.error;
visited.add(evidenceId);
continue;
}
visited.add(evidenceId);
collect?.(evidence.value);
for (const linkedId of evidence.value.evidence_links)
pending.push(linkedId);
}
return ok(visited);
return failure === undefined ? ok(visited) : err(failure);
};
/** Gather every reachable authenticated record while retaining the first failure. */
export const collectInvestigationEvidenceClosure = (
reader: Pick<EvidenceReader, "evidenceForAnalysis">,
seedIds: readonly string[],
): {
readonly records: Evidence[];
readonly result: Result<null, EvidenceIntegrityError>;
} => {
const records: Evidence[] = [];
const visited = visitClosure(
(id) => reader.evidenceForAnalysis(id),
seedIds,
undefined,
(record) => records.push(record),
);
return { records, result: visited.ok ? ok(null) : visited };
};
/** Follow exact session references without exporting unrelated records or Unknowns. */
+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 }) =>
+5 -1
View File
@@ -447,7 +447,11 @@ const registerFunctionCommand = (
"analyze_function",
),
},
{ ...directAnalysisOptionsFromCli(logger, options), signal },
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(complete, delivery);
}),
+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()
+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()
@@ -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 = [
@@ -89,6 +89,9 @@ it("summary delivery returns the compact view and writes a bundle inspect-analys
evidence_bundle: { path: string; bytes: number };
};
expect(h.runDirectAnalysis).toHaveBeenCalledOnce();
expect(h.runDirectAnalysis.mock.calls[0]?.[3]).toMatchObject({
includeEvidenceSources: true,
});
expect(out.summary.normalized_result.kind).toBe("summary");
expect(typeof out.summary.normalized_result.parent_evidence_id).toBe(
"string",
@@ -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([]);
});
});
@@ -1,8 +1,10 @@
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
import { publishCliAnalysisEvidence } from "../../../src/cli/analysisEvidenceDelivery.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { readFile, symlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
@@ -159,6 +161,7 @@ interface SnapshotProviderOptions {
readonly supportedOperations?: readonly CapabilityDescriptor["operation"][];
readonly limitations?: readonly string[];
readonly sourceEvidence?: boolean;
readonly sourceMissingLink?: boolean;
}
const makeProvider = (
@@ -263,6 +266,9 @@ const makeProvider = (
operation: "health",
parameters: {},
result: "fixture source",
...(options.sourceMissingLink
? { evidenceLinks: [`ev_${"0".repeat(64)}`] }
: {}),
}),
],
}
@@ -1880,7 +1886,10 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
});
const dependencies = fixture.dependencies(provider);
const parameters = { procedure: "0x1000", document: "fixture" };
const options = { snapshotPath: fixture.snapshotPath };
const options = {
snapshotPath: fixture.snapshotPath,
includeEvidenceSources: true,
};
const run = () =>
runDirectAnalysis(
dependencies,
@@ -1888,8 +1897,12 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
"procedure_pseudo_code",
parameters,
options,
).then(projectCliAnalysisResult);
const first = parseEvidence(await run());
);
const firstResult = await run();
const first = parseEvidence(projectCliAnalysisResult(firstResult));
expect(
firstResult.sourceEvidence?.map((record) => record.evidence_id),
).toEqual(first.evidence_links);
expect(first.evidence_links).toHaveLength(1);
const saved = await readAnalysisSnapshot(fixture.snapshotPath);
if (!saved.ok) throw saved.error;
@@ -1901,9 +1914,30 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
),
).toBe(true);
const initialStarts = fixture.starts.length;
expect(parseEvidence(await run()).evidence_links).toEqual(
const replayed = await run();
expect(
parseEvidence(projectCliAnalysisResult(replayed)).evidence_links,
).toEqual(first.evidence_links);
expect(replayed.sourceEvidence?.map((record) => record.evidence_id)).toEqual(
first.evidence_links,
);
const outputPath = join(
dirname(fixture.snapshotPath),
"replayed-evidence.json",
);
await publishCliAnalysisEvidence(replayed, {
providerId: IDENTITY.id,
operation: "procedure_pseudo_code",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const published = JSON.parse(await readFile(outputPath, "utf8")) as {
records: Evidence[];
};
expect(published.records.map((record) => record.evidence_id).sort()).toEqual(
[first.evidence_id, ...first.evidence_links].sort(),
);
expect(fixture.starts).toHaveLength(initialStarts);
const withoutSource = {
@@ -1918,7 +1952,11 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources),
);
if (!written.ok) throw written.error;
const refreshed = parseEvidence(await run());
const refreshedResult = await run();
const refreshed = parseEvidence(projectCliAnalysisResult(refreshedResult));
expect(
refreshedResult.sourceEvidence?.map((record) => record.evidence_id),
).toEqual(first.evidence_links);
expect(fixture.starts).toHaveLength(initialStarts + 1);
expect(refreshed.evidence_links).toEqual(first.evidence_links);
const repaired = await readAnalysisSnapshot(fixture.snapshotPath);
@@ -1930,6 +1968,146 @@ it("replays source-bearing direct analysis only while snapshot source Evidence i
).toBe(true);
});
it("preserves captured sources when a transitive source is missing", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-partial-source-",
);
const provider = fixture.createProvider({
supportedOperations: ["procedure_pseudo_code"],
sourceEvidence: true,
sourceMissingLink: true,
});
const result = await runDirectAnalysis(
fixture.dependencies(provider),
fixture.path,
"procedure_pseudo_code",
{ procedure: "0x1000", document: "fixture" },
{ includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
expect(result.sourceEvidence).toHaveLength(1);
const outputPath = join(dirname(fixture.snapshotPath), "missing-source.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "procedure_pseudo_code",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
details: {
source_closure: {
records: Evidence[];
omitted: { evidence_id: string }[];
complete: boolean;
};
};
};
expect(failure.details.source_closure.complete).toBe(false);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(result.sourceEvidence?.map((record) => record.evidence_id));
expect(failure.details.source_closure.omitted).toEqual([
{ evidence_id: `ev_${"0".repeat(64)}`, reason: "not_captured" },
]);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains completed function sources when cancellation arrives as execution returns", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-cancel-source-",
);
const provider = fixture.createProvider({
supportedOperations: ["analyze_function"],
sourceEvidence: true,
});
const session = createTestBinarySession(provider);
const execute = session.execute.bind(session);
const controller = new AbortController();
session.execute = async (...args) => {
const result = await execute(...args);
if (args[0] === "analyze_function") controller.abort();
return result;
};
const dependencies = {
...fixture.dependencies(provider),
createBinarySession: () => session,
};
const result = await runDirectAnalysis(
dependencies,
fixture.path,
"analyze_function",
{ procedure: "0x1000" },
{ signal: controller.signal, includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
const outputPath = join(dirname(fixture.snapshotPath), "cancelled.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "analyze_function",
signal: controller.signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
code: string;
details: {
partial_observation: Evidence;
source_closure: { records: Evidence[]; complete: boolean };
};
};
expect(failure.code).toBe("cancelled");
expect(failure.details.source_closure.complete).toBe(true);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(failure.details.partial_observation.evidence_links);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains function sources when completed parent admission fails", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-parent-admission-",
);
const provider = fixture.createProvider({
supportedOperations: ["analyze_function"],
sourceEvidence: true,
});
const session = createTestBinarySession(provider);
session.importEvidenceBundle = () =>
err(new EvidenceIntegrityError("controlled parent admission failure"));
const dependencies = {
...fixture.dependencies(provider),
createBinarySession: () => session,
};
const result = await runDirectAnalysis(
dependencies,
fixture.path,
"analyze_function",
{ procedure: "0x1000" },
{ includeEvidenceSources: true },
);
expect(result.ok).toBe(false);
const outputPath = join(dirname(fixture.snapshotPath), "refused.json");
const output = await publishCliAnalysisEvidence(result, {
providerId: IDENTITY.id,
operation: "analyze_function",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
details: {
partial_observation: Evidence;
source_closure: { records: Evidence[]; complete: boolean };
};
};
expect(failure.details.source_closure.complete).toBe(true);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(failure.details.partial_observation.evidence_links);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("runs decompile export against the provider after loading a pseudocode snapshot", async () => {
const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-");
const original = fixture.createProvider({
@@ -592,6 +592,63 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)(
},
);
it.each(["interrupt", "cleanup-failure"] as const)(
"retains captured source closure when direct finalization fails: %s",
async (scenario) => {
const workflow = await composedWorkflowFixture();
const controller = new AbortController();
const close = workflow.session.close.bind(workflow.session);
workflow.session.close = async () => {
workflow.session.close = close;
if (scenario === "interrupt") controller.abort();
const closed = await close();
if (scenario === "interrupt" || !closed.ok) return closed;
return err(
new ProviderCleanupError(
"fixture",
["owned-root"],
{ reason: "controlled session close failure" },
{ operation: "close_binary" },
),
);
};
const root = await createTestTempDirectory("rea-closure-direct-captured-");
const complete = await runDirectAnalysis(
workflow.dependencies,
workflow.path,
"binary_overview",
{},
{ signal: controller.signal, includeEvidenceSources: true },
);
const output = await publishCliAnalysisEvidence(complete, {
...delivery(root),
signal: controller.signal,
});
const failure = output as {
code: string;
details: { partial_observation: unknown };
};
expect(failure.code).toBe(
scenario === "interrupt" ? "cancelled" : "cleanup_incomplete",
);
const links = parseEvidence(
failure.details.partial_observation,
).evidence_links;
expect(links.length).toBeGreaterThan(0);
expect(closureOf(output)).toMatchObject({
publication: "not_published",
complete: true,
omitted_count: 0,
});
expect(
closureOf(output)
.records.map((record) => record.evidence_id)
.sort(),
).toEqual([...links].sort());
expect(await readdir(root)).toEqual([]);
},
);
it("reports through the shared one-shot delivery when cancellation arrives during close", async () => {
const root = await createTestTempDirectory("rea-closure-deliver-");
const held = source("held");
@@ -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 [
+6
View File
@@ -3,6 +3,12 @@
These source-owned fixtures are compiled at verification time; generated
binaries and Ghidra projects are never committed.
For the one-shot function bundle handoff, run `npm run build:cached` and then
`node scripts/verify-real-ghidra-function-bundle.mjs` with `GHIDRA_INSTALL_DIR`
and `JAVA_HOME` set. The verifier compiles its own target, creates a project
with a synthetic history report, and exercises complete, summary, and
no-overwrite CLI delivery. It checks the target and project files for changes.
- `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images
with double-float ABI declarations and an independently known instruction.
`../c/riscv-float-abi.c`, `riscv-probe.S` and `riscv-isa-probe.S` produce