mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
* feat(native): list ELF and Mach-O initializer and finalizer functions inspect_native_initializers and inspect-native-initializers list the functions an ELF or Mach-O image runs before and after main: ELF DT_INIT, DT_FINI, preinit, init and fini arrays and .ctors/.dtors, and Mach-O __mod_init_func, __mod_term_func and __init_offsets, with slot addresses, resolved targets, symbols where available and relocation facts for relocatable objects. Nothing is executed. Slot, symbol, relocation and note budgets are shared across tables, sentinels are matched at the image width, and target-derived names are bounded and marked untrusted. * fix(native): keep ELF initializer slots unresolved when relocation coverage is partial A skipped, unreadable or over-budget section relocation table now keeps every slot it could reach unresolved instead of reading the stored word as raw or dropping a .ctors slot as a sentinel. Dynamic writes that only partly overlap a slot, or write it twice, leave it unresolved. Linked images no longer reapply retained non-SHF_ALLOC link-time records. The dynamic relocation walk now pauses at the shared reader's step boundaries and observes cancellation there, resolved targets need only their first byte mapped, and unreadable symbol names make the report incomplete. * fix(native): stop reporting unchecked Mach-O initializer words as resolved Object files now apply their own section relocations: an UNSIGNED entry against a defined symbol gives symbol_addend, one against an undefined symbol gives unresolved_import, and any other entry leaves the slot unresolved. Linked images without a fixup decoder, such as 32-bit ones, keep pointer slots unresolved when an LC_DYLD_INFO bind stream or LC_DYSYMTAB external relocations could replace the stored word. LC_DYLD_INFO rebases now report dyld_info_rebase instead of chained_fixup, and unreadable symbol names make the report incomplete. Section headers keep their declared reloff and nreloc for this. * docs(native): move initializer guide under docs/guides/native The documentation guide assigns analyst workflows to docs/guides/<domain>/. Move the guide there, place its navigation and directory entries in the Native binaries group, and update links from the docs index, CLI guide, spec and authored skill. The public /native-initializers route is unchanged. The guide and spec also describe per-slot relocation coverage, retained link-time records, Mach-O object relocations, undecoded bind streams, the dyld_info_rebase resolution and unreadable names. * fix(native): keep IFUNC and partly relocated ELF initializer slots unresolved An absolute relocation against an STT_GNU_IFUNC symbol no longer reports the resolver address as the target. Section relocation records now use their real write width, so a record that writes only part of a slot, starts inside it or repeats it leaves the slot unresolved instead of reading a raw zero or dropping a .ctors slot as a sentinel. On 32-bit ARM the Thumb bit is cleared when locating a target's section and file offset, while value_va keeps the stored callable value. * fix(native): locate Thumb Mach-O initializer targets and reject arm64_32 with a 64-bit header On CPU_TYPE_ARM the Thumb bit is cleared when locating and naming a target, since nlist values do not carry it. An arm64_32 image that declares a 64-bit header is rejected as malformed rather than read with 8-byte slots. The guide and spec describe IFUNC relocations, partly relocated section slots, Thumb targets and this rejection. * fix(native): index initializer address lookups and keep ambiguous mappings unknown ELF and Mach-O initializer lookups now build the shared file mapping index once per image, so each slot and target lookup is logarithmic in the number of load segments rather than a scan of every mapping. An address that load segments map ambiguously no longer falls back to a section header offset. Its file offset is reported as unknown and the report is incomplete; only genuinely unmapped spans use the section fallback. * fix(native): report Mach-O bind names over the name budget A bound import name longer than the 1024-byte name budget was dropped without a trace, although it is the only identity of an unresolved initializer target. It now counts toward the over-long name limitation and makes the report incomplete. * test(native): count mapping lookups instead of timing the load index regression The load-mapping regression asserted a wall-clock bound, which is sensitive to machine load. It now spies on the shared mapping module and requires the same number of mapping scans and index builds for 8 and 64 slots, so a per-slot scan of every load segment fails deterministically on a small fixture. * fix(native): yield while decoding legacy rebase runs * fix(native): preserve initializer mapping and coverage * fix(native): index legacy fixup spans * fix(native): preserve relocation uncertainty and bound bind lookup work * fix(native): validate ELF slot mappings and yield section relocation scans --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
763 lines
25 KiB
TypeScript
763 lines
25 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
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";
|
|
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
|
|
import type { ProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
|
|
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
|
|
import { itWithCaptureCapability as captureTest } from "../../boundary/process/processCaptureCapability.js";
|
|
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliCompileCacheEnvironment } from "../../support/cli/cliFixture.js";
|
|
|
|
const execute = promisify(execFile);
|
|
const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url));
|
|
const nul = "/tmp/selected\0filename";
|
|
const electron = {
|
|
executable_path: "/tmp/Electron",
|
|
application_path: "/tmp/main.js",
|
|
};
|
|
|
|
const captureResult = (value: unknown): ProcessCapture => {
|
|
const normalized = toolContract(
|
|
"capture_process_scenario",
|
|
).outputSchema.parse(value).normalized_result;
|
|
if ("status" in normalized && normalized.status === "planned")
|
|
throw new Error("expected process capture output");
|
|
return normalized as ProcessCapture;
|
|
};
|
|
|
|
it("keeps overall replay verification unknown when capture divergence is unknown", async () => {
|
|
const { call } = await connectLocalToolsMcp();
|
|
const captureEvidence = (
|
|
side: "left" | "right",
|
|
{
|
|
environmentUnknown = false,
|
|
exitCode = 0,
|
|
}: {
|
|
readonly environmentUnknown?: boolean;
|
|
readonly exitCode?: number;
|
|
} = {},
|
|
) =>
|
|
createEvidence(undefined, PROCESS_PROVIDER, {
|
|
predicateType: "rea.process-capture",
|
|
operation: "capture_process_scenario",
|
|
parameters: { side },
|
|
result: {
|
|
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
|
exit: { ...EMPTY_PROCESS_CAPTURE_EXAMPLE.exit, code: exitCode },
|
|
residual_unknowns: environmentUnknown
|
|
? [
|
|
{
|
|
scope: "environment",
|
|
reason:
|
|
"Inherited host environment variables are not recorded.",
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
confidence: "observed",
|
|
authority: "controlled-replay",
|
|
environment: {
|
|
id: "fixture-process",
|
|
platform: "fixture",
|
|
architecture: "fixture",
|
|
isolation: "process",
|
|
},
|
|
});
|
|
const compare = async (left: unknown, right: unknown) => {
|
|
const response = await call("compare_process_captures", { left, right });
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
return toolContract("compare_process_captures").outputSchema.parse(
|
|
response.structuredContent,
|
|
);
|
|
};
|
|
const verify = async (
|
|
comparison: { readonly evidence_id: string },
|
|
dimension: "overall" | "terminal",
|
|
) => {
|
|
const response = await call("verify_reconstruction", {
|
|
specification: {
|
|
name: "Overall replay compatibility",
|
|
claims: [
|
|
{
|
|
kind: "behavioral",
|
|
claim_id: dimension,
|
|
title: "Replay behavior agrees",
|
|
comparison_evidence_id: comparison.evidence_id,
|
|
dimension,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
return toolContract("verify_reconstruction").outputSchema.parse(
|
|
response.structuredContent,
|
|
).normalized_result;
|
|
};
|
|
|
|
const complete = await compare(
|
|
captureEvidence("left"),
|
|
captureEvidence("right"),
|
|
);
|
|
expect((await verify(complete, "overall")).status).toBe("pass");
|
|
|
|
const incomplete = await compare(
|
|
captureEvidence("left", { environmentUnknown: true }),
|
|
captureEvidence("right", { environmentUnknown: true }),
|
|
);
|
|
expect(incomplete.normalized_result).toMatchObject({
|
|
status: "unchanged",
|
|
first_divergence: {
|
|
status: "unknown",
|
|
reason: "Residual unknowns prevent proving that no divergence occurred.",
|
|
},
|
|
});
|
|
const overall = await verify(incomplete, "overall");
|
|
expect(overall.status).toBe("unknown");
|
|
expect(overall.claims.items[0]?.limitations).toContain(
|
|
"Residual unknowns prevent proving that no divergence occurred.",
|
|
);
|
|
expect((await verify(incomplete, "terminal")).status).toBe("pass");
|
|
|
|
const changed = await compare(
|
|
captureEvidence("left", { environmentUnknown: true }),
|
|
captureEvidence("right", { environmentUnknown: true, exitCode: 1 }),
|
|
);
|
|
expect((await verify(changed, "overall")).status).toBe("fail");
|
|
});
|
|
|
|
const runCliExpectingInputError = async (
|
|
scenarioPath: string,
|
|
environment?: NodeJS.ProcessEnv,
|
|
): Promise<unknown> => {
|
|
const failure = await execute(
|
|
process.execPath,
|
|
[cli, "capture-process", scenarioPath, "--json"],
|
|
{ env: environment },
|
|
).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toMatchObject({ code: 1 });
|
|
if (!(failure instanceof Error) || !("stdout" in failure))
|
|
throw new Error("Expected the CLI to report an input error on stdout");
|
|
return JSON.parse(String(failure.stdout)) as unknown;
|
|
};
|
|
|
|
const pathRequests: readonly [string, Record<string, unknown>][] = [
|
|
["open_binary", { path: nul }],
|
|
["open_binary", { path: process.execPath, snapshot_path: nul }],
|
|
["close_binary", { snapshot_path: nul }],
|
|
["binary_session", { expected_server_path: nul }],
|
|
["export_evidence_bundle", { path: nul }],
|
|
["import_evidence_bundle", { path: nul }],
|
|
["inspect_managed_artifact", { path: nul }],
|
|
["inspect_android_package", { path: nul }],
|
|
["search_android_classes", { path: nul, query: "" }],
|
|
["inspect_android_class", { path: nul, class_name: "probe.Class" }],
|
|
[
|
|
"inspect_android_method",
|
|
{ path: nul, class_name: "probe.Class", method_name: "run" },
|
|
],
|
|
[
|
|
"inspect_android_class",
|
|
{ path: "/tmp/probe.apk", class_name: "probe.Class", mapping_path: nul },
|
|
],
|
|
[
|
|
"inspect_android_method",
|
|
{
|
|
path: "/tmp/probe.apk",
|
|
class_name: "probe.Class",
|
|
method_name: "run",
|
|
mapping_path: nul,
|
|
},
|
|
],
|
|
["trace_android_references", { path: nul, class_name: "probe.Class" }],
|
|
["inspect_firmware_regions", { path: nul }],
|
|
["extract_firmware", { path: nul, output_directory: "/tmp/out" }],
|
|
["extract_firmware", { path: "/tmp/firmware.bin", output_directory: nul }],
|
|
["inspect_plist", { path: nul }],
|
|
["inspect_with_objdump", { path: nul, operation: "file_headers" }],
|
|
["execute_rizin_command", { path: nul, command: "iI" }],
|
|
...[
|
|
{ kind: "function", name: "main\0suffix" },
|
|
{ kind: "function", name: "main", module: "lib.dylib\0suffix" },
|
|
{ kind: "objc-method", selector: "run\0suffix" },
|
|
{ kind: "objc-method", class_name: "Probe\0suffix", selector: "run" },
|
|
].map((breakpoint): [string, Record<string, unknown>] => [
|
|
"observe_native_calls",
|
|
{ breakpoints: [breakpoint] },
|
|
]),
|
|
[
|
|
"observe_native_calls",
|
|
{
|
|
breakpoints: [{ kind: "function", name: "main" }],
|
|
working_directory: nul,
|
|
},
|
|
],
|
|
[
|
|
"observe_native_calls",
|
|
{ breakpoints: [{ kind: "function", name: "main" }], arguments: ["a\0b"] },
|
|
],
|
|
["analyze_javascript_application", { input_path: nul }],
|
|
["recover_javascript_sources", { path: nul, output_directory: "/tmp/out" }],
|
|
[
|
|
"recover_javascript_sources",
|
|
{ path: "/tmp/main.js", output_directory: nul },
|
|
],
|
|
[
|
|
"compare_bundles",
|
|
{ left_bundle_path: nul, right_bundle_path: "/tmp/right.json" },
|
|
],
|
|
[
|
|
"compare_bundles",
|
|
{ left_bundle_path: "/tmp/left.json", right_bundle_path: nul },
|
|
],
|
|
[
|
|
"capture_browser_scenario",
|
|
{
|
|
browser: { mode: "launch", executable_path: nul },
|
|
start_url: { url: "http://127.0.0.1:1" },
|
|
actions: [
|
|
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 0 },
|
|
],
|
|
},
|
|
],
|
|
...["executable_path", "application_path", "application_root"].map(
|
|
(key): [string, Record<string, unknown>] => [
|
|
"capture_electron_scenario",
|
|
{ ...electron, [key]: nul },
|
|
],
|
|
),
|
|
["capture_electron_scenario", { ...electron, args: ["a\0b"] }],
|
|
["demangle_swift", { symbols: ["abc\0def"] }],
|
|
["inspect_web_network_capture", { capture_path: nul, format: "har" }],
|
|
[
|
|
"export_web_scripts",
|
|
{ capture_path: nul, output_directory: "/tmp/exported-scripts" },
|
|
],
|
|
["inspect_pe_resources", { path: nul }],
|
|
["inspect_pe_certificates", { path: nul }],
|
|
["inspect_pe_imports", { path: nul }],
|
|
["inspect_windows_shortcut", { path: nul }],
|
|
["inspect_native_exports", { path: nul }],
|
|
["inspect_elf_notes", { path: nul }],
|
|
["inspect_native_initializers", { path: nul }],
|
|
["inspect_elf_unwind_ranges", { path: nul }],
|
|
["inspect_apple_ips", { path: nul }],
|
|
["inspect_devicetree", { path: nul }],
|
|
["inspect_kernel_module", { path: nul }],
|
|
["inspect_kernel_build_config", { path: nul }],
|
|
["inspect_pe_load_config", { path: nul }],
|
|
["inspect_binary_hardening", { path: nul }],
|
|
["inspect_python_pyc", { path: nul }],
|
|
["inspect_erlang_beam", { path: nul }],
|
|
["inspect_binary_layout", { path: nul }],
|
|
["inspect_recorded_crash", { path: nul }],
|
|
["inspect_wasm_artifact", { path: nul }],
|
|
["inspect_wasm_artifact", { path: "/tmp/sample.wasm", glue_paths: [nul] }],
|
|
[
|
|
"inspect_wasm_artifact",
|
|
{ path: "/tmp/sample.wasm", candidate_paths: [nul] },
|
|
],
|
|
["inspect_evm_interface", { path: nul, encoding: "raw" }],
|
|
[
|
|
"export_web_scripts",
|
|
{ capture_path: "/tmp/capture.json", output_directory: nul },
|
|
],
|
|
[
|
|
"trace_web_source_location",
|
|
{
|
|
manifest_path: nul,
|
|
script_index: 0,
|
|
source_map: { path: "/tmp/source.map", url: "http://127.0.0.1/a" },
|
|
generated_position: { line: 1, column: 0 },
|
|
},
|
|
],
|
|
[
|
|
"trace_web_source_location",
|
|
{
|
|
manifest_path: "/tmp/manifest.json",
|
|
script_index: 0,
|
|
source_map: { path: nul, url: "http://127.0.0.1/a" },
|
|
generated_position: { line: 1, column: 0 },
|
|
},
|
|
],
|
|
["trace_web_module_imports", { manifest_path: nul, script_index: 0 }],
|
|
[
|
|
"trace_web_module_imports",
|
|
{
|
|
manifest_path: "/tmp/manifest.json",
|
|
script_index: 0,
|
|
import_map: { path: nul, base_url: "http://127.0.0.1/a" },
|
|
},
|
|
],
|
|
["start_frida_session", { mode: "spawn", device_id: "local", program: nul }],
|
|
[
|
|
"load_frida_script",
|
|
{
|
|
session_id: "00000000-0000-4000-8000-000000000001",
|
|
source_kind: "file",
|
|
path: nul,
|
|
},
|
|
],
|
|
[
|
|
"instrument_with_frida",
|
|
{
|
|
mode: "spawn",
|
|
device_id: "local",
|
|
program: nul,
|
|
source_kind: "inline",
|
|
source: "send(1);",
|
|
},
|
|
],
|
|
[
|
|
"instrument_with_frida",
|
|
{
|
|
mode: "spawn",
|
|
device_id: "local",
|
|
program: "/bin/true",
|
|
source_kind: "file",
|
|
path: nul,
|
|
},
|
|
],
|
|
["start_rizin_debug_session", { path: nul, inferior_tty: "/dev/pts/7" }],
|
|
["start_rizin_debug_session", { path: "/tmp/program", inferior_tty: nul }],
|
|
["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 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(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 = [
|
|
{ terminal: { columns: 80, row: 12 } },
|
|
{ events: [{ type: "input", at_ms: 0, data: "x", secret: true }] },
|
|
{ events: [{ type: "resize", at_ms: 0, columns: 80, rows: 24, row: 12 }] },
|
|
{ events: [{ type: "signal", at_ms: 0, signal: "SIGTERM", pid: 1 }] },
|
|
{ normalization: { path: true } },
|
|
{ limits: { file_count: 1 } },
|
|
{
|
|
normalization: {
|
|
patterns: [{ pattern: "x", replacement: "y", regex: true }],
|
|
},
|
|
},
|
|
];
|
|
|
|
captureTest(
|
|
"rejects malformed process choices through CLI and MCP before launching the command",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-local-contracts-");
|
|
const environment = cliCompileCacheEnvironment(
|
|
process.env,
|
|
join(root, "node-compile-cache"),
|
|
);
|
|
const marker = join(root, "launched");
|
|
const scenario = {
|
|
executable: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
"require('node:fs').writeFileSync(process.argv[1],'launched')",
|
|
marker,
|
|
],
|
|
};
|
|
const { call } = await connectLocalToolsMcp();
|
|
const invalid = [
|
|
...ignoredOptions,
|
|
...["", "KEY=VALUE", "KEY\0VALUE", "REA_PROCESS_RUN_ID"].map((key) => ({
|
|
environment: { [key]: "value" },
|
|
})),
|
|
{ executable: `node\0` },
|
|
{ arguments: ["\0"] },
|
|
{ working_directory: "/tmp/dir\0" },
|
|
{ environment: { KEY: "\0" } },
|
|
{ filesystem_observation_paths: ["\0"] },
|
|
{ timeout_ms: 10, events: [{ type: "input", at_ms: 11, data: "late" }] },
|
|
{
|
|
events: [
|
|
{ type: "resize", at_ms: 2, columns: 80, rows: 24 },
|
|
{ type: "input", at_ms: 1, data: "out of order" },
|
|
],
|
|
},
|
|
...["command_shims", "replay", "reactive", "checkpoints"].map(
|
|
(field) => ({
|
|
[field]: [],
|
|
}),
|
|
),
|
|
];
|
|
// Each CLI case initializes its own runtime. Bound subprocess fanout too,
|
|
// so one test cannot oversubscribe the host beyond the Vitest workers.
|
|
for (let start = 0; start < invalid.length; start += 2)
|
|
await Promise.all(
|
|
invalid.slice(start, start + 2).map(async (options, offset) => {
|
|
const request = { ...scenario, ...options };
|
|
expect(
|
|
(await call("capture_process_scenario", request)).isError,
|
|
JSON.stringify(options),
|
|
).toBe(true);
|
|
const path = join(root, `scenario-${String(start + offset)}.json`);
|
|
await writeFile(path, JSON.stringify(request));
|
|
await expect(
|
|
execute(
|
|
process.execPath,
|
|
[cli, "capture-process", path, "--json"],
|
|
{
|
|
env: environment,
|
|
},
|
|
),
|
|
JSON.stringify(options),
|
|
).rejects.toMatchObject({
|
|
code: 1,
|
|
stdout: expect.stringContaining("invalid_request"),
|
|
});
|
|
}),
|
|
);
|
|
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const missing = join(root, "missing-executable");
|
|
const failed = await call("capture_process_scenario", {
|
|
executable: missing,
|
|
});
|
|
expect(parseMcpToolError(failed)).toMatchObject({
|
|
error: {
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "capture_process_scenario",
|
|
issues: [
|
|
{
|
|
path: ["executable"],
|
|
message: expect.stringContaining(`${missing} (ENOENT)`),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await writeFile(
|
|
join(root, "scenario.json"),
|
|
JSON.stringify({ executable: missing }),
|
|
);
|
|
const executableCliFailure = await runCliExpectingInputError(
|
|
join(root, "scenario.json"),
|
|
environment,
|
|
);
|
|
expect(executableCliFailure).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "capture_process_scenario",
|
|
issues: [
|
|
{
|
|
path: ["executable"],
|
|
message: expect.stringContaining(`${missing} (ENOENT)`),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const missingWorkingDirectory = join(root, "missing-working-directory");
|
|
const cwdFailure = await call("capture_process_scenario", {
|
|
executable: process.execPath,
|
|
working_directory: missingWorkingDirectory,
|
|
});
|
|
expect(parseMcpToolError(cwdFailure)).toMatchObject({
|
|
error: {
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "capture_process_scenario",
|
|
issues: [
|
|
{
|
|
path: ["working_directory"],
|
|
message: expect.stringContaining(
|
|
`${missingWorkingDirectory} (ENOENT)`,
|
|
),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const cwdScenario = join(root, "missing-working-directory.json");
|
|
await writeFile(
|
|
cwdScenario,
|
|
JSON.stringify({
|
|
executable: process.execPath,
|
|
working_directory: missingWorkingDirectory,
|
|
}),
|
|
);
|
|
expect(
|
|
await runCliExpectingInputError(cwdScenario, environment),
|
|
).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "capture_process_scenario",
|
|
issues: [
|
|
{
|
|
path: ["working_directory"],
|
|
message: expect.stringContaining(
|
|
`${missingWorkingDirectory} (ENOENT)`,
|
|
),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
},
|
|
// 22 malformed requests plus two filesystem errors each start a fresh CLI.
|
|
// Cache compilation, but allow for startup at the bounded fanout of two.
|
|
120_000,
|
|
);
|
|
|
|
captureTest.each(["untruncated", "truncated"] as const)(
|
|
"captures real process choices and composes a %s self-comparison",
|
|
async (captureState) => {
|
|
const root = await createTestTempDirectory("rea-local-capture-");
|
|
const { call } = await connectLocalToolsMcp();
|
|
const captured = await call("capture_process_scenario", {
|
|
executable: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
`require('node:fs').writeFileSync('state','selected');console.log(process.env['app.setting'],process.env.MY_PASSWORD,process.env['REA_PROCESS_RUN_ID'+String.fromCharCode(10)]);${captureState === "truncated" ? "console.log('x'.repeat(5000))" : ""}`,
|
|
],
|
|
working_directory: root,
|
|
environment: {
|
|
"app.setting": "value",
|
|
"1": "numbered",
|
|
MY_PASSWORD: "ordinary-evidence",
|
|
"REA_PROCESS_RUN_ID\n": "caller-value",
|
|
},
|
|
filesystem_observation_paths: [root],
|
|
limits: captureState === "truncated" ? { output_bytes: 128 } : {},
|
|
});
|
|
expect(captured.isError, JSON.stringify(captured)).not.toBe(true);
|
|
const source = toolContract("capture_process_scenario").outputSchema.parse(
|
|
captured.structuredContent,
|
|
);
|
|
const capture = captureResult(source);
|
|
if (captureState === "untruncated")
|
|
expect(capture.frames.map((frame) => frame.data).join("")).toContain(
|
|
"value ordinary-evidence caller-value",
|
|
);
|
|
else expect(capture.truncated).toBe(true);
|
|
const environment = await call("capture_process_scenario", {
|
|
executable: "/usr/bin/printenv",
|
|
arguments: ["1"],
|
|
environment: { "1": "numbered" },
|
|
});
|
|
const environmentEnvelope = toolContract(
|
|
"capture_process_scenario",
|
|
).outputSchema.parse(environment.structuredContent);
|
|
const received = captureResult(environmentEnvelope)
|
|
.frames.map((frame) => frame.data)
|
|
.join("");
|
|
expect(received).toBe("numbered\r\n");
|
|
expect(await readFile(join(root, "state"), "utf8")).toBe("selected");
|
|
expect(capture.filesystem_effects).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ status: "created" })]),
|
|
);
|
|
expect(capture.cleanup).toMatchObject({
|
|
owned_process_group: "verified",
|
|
temporary_root: "removed",
|
|
});
|
|
const compared = await call("compare_process_captures", {
|
|
left: source,
|
|
right: source,
|
|
});
|
|
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
|
|
const comparison = toolContract(
|
|
"compare_process_captures",
|
|
).outputSchema.parse(compared.structuredContent);
|
|
expect(comparison.normalized_result).toMatchObject({
|
|
status: "unknown",
|
|
terminal: captureState === "truncated" ? "unknown" : "unchanged",
|
|
interaction: "unchanged",
|
|
exit: "unchanged",
|
|
process: "unknown",
|
|
});
|
|
const verification = await call("verify_reconstruction", {
|
|
specification: {
|
|
name: "Self comparison retains uncertainty",
|
|
claims: [
|
|
{
|
|
claim_id: "control",
|
|
title: "Control capture",
|
|
comparison_evidence_id: comparison.evidence_id,
|
|
kind: "behavioral",
|
|
dimension: "overall",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(verification.isError, JSON.stringify(verification)).not.toBe(true);
|
|
expect(verification.structuredContent).toMatchObject({
|
|
normalized_result: {
|
|
status: "unknown",
|
|
claims: {
|
|
items: [
|
|
{
|
|
left_evidence_ids: [source.evidence_id],
|
|
right_evidence_ids: [source.evidence_id],
|
|
evidence_links: expect.arrayContaining([
|
|
source.evidence_id,
|
|
comparison.evidence_id,
|
|
]),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
const behavior = await call("find_changed_behavior", {
|
|
comparisons: [comparison],
|
|
});
|
|
expect(behavior.isError, JSON.stringify(behavior)).not.toBe(true);
|
|
expect(behavior.structuredContent).toMatchObject({
|
|
normalized_result: { behavior_status: "unknown" },
|
|
});
|
|
const unknowns = await call("list_unknowns", {
|
|
domain: "process-comparison",
|
|
});
|
|
expect(unknowns.structuredContent).toMatchObject({
|
|
result: {
|
|
items: [
|
|
{
|
|
status: "open",
|
|
supporting_evidence_ids: [source.evidence_id],
|
|
contradicting_evidence_ids: [],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const bundle = await call("get_evidence_bundle", {});
|
|
expect(
|
|
(await call("compare_process_captures", { left: source, right: source }))
|
|
.isError,
|
|
).not.toBe(true);
|
|
expect((await call("get_evidence_bundle", {})).structuredContent).toEqual(
|
|
bundle.structuredContent,
|
|
);
|
|
const path = join(root, "capture.json");
|
|
await writeFile(path, JSON.stringify(source));
|
|
const cliComparison = await execute(process.execPath, [
|
|
cli,
|
|
"compare-process-captures",
|
|
path,
|
|
path,
|
|
"--json",
|
|
]);
|
|
expect(parseEvidence(JSON.parse(cliComparison.stdout))).toEqual(comparison);
|
|
},
|
|
);
|
|
|
|
captureTest(
|
|
"accepts NUL as actual terminal input while preserving capture defaults",
|
|
async () => {
|
|
const { call } = await connectLocalToolsMcp();
|
|
const response = await call("capture_process_scenario", {
|
|
executable: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
"process.stdin.setRawMode(true);process.stdin.resume();process.stdin.once('data',bytes=>{console.log('stdin-byte:'+bytes[0]);process.exit(0)});console.log('ready')",
|
|
],
|
|
events: [{ type: "input", at_ms: 1000, data: "\0" }],
|
|
});
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
const capture = captureResult(response.structuredContent);
|
|
expect(capture.frames.map((frame) => frame.data).join("")).toContain(
|
|
"stdin-byte:0",
|
|
);
|
|
expect(capture.manifest.scenario).toMatchObject({
|
|
environment: {},
|
|
filesystem_observation_paths: [],
|
|
terminal: { columns: 80, rows: 24, scrollback: 1000 },
|
|
});
|
|
expect(capture.interaction_events).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "input",
|
|
data: "\0",
|
|
outcome: "dispatched",
|
|
}),
|
|
]),
|
|
);
|
|
},
|
|
);
|