fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)
* feat(process): add owned debugger shutdown lifecycle * feat(reverse): add local inspection commands * feat(gdb): add persistent debugger integration * feat(cutter): add Cutter bridge integration * test(reverse): enforce complete integration coverage * fix(process): preserve debugger shutdown ownership * fix(reverse): retain evidence across interrupted commands * fix(gdb): honor captured provider environment * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * fix(server): snapshot provider environment at creation * fix(reverse): close review gaps with complete coverage * fix(reverse): address inspection review findings * fix(gdb): bound sessions and retain cleanup ownership * fix(ci): require reverse-tools quality lane * fix(process): unref inherited debugger output pipes * fix(reverse): address remaining PR 1960 findings * fix(gdb): address remaining PR 1961 findings * fix(reverse): account for unrestricted provider execution * fix(process): retain debugger ownership until safe shutdown * fix(process): preserve replacement debugger sessions * fix(reverse): preserve admission and output provenance * fix(process): retain exited debugger until output settles * fix(mcp): bound provider admission and debugger cleanup * fix(reverse): preserve failure provenance and classify E2BIG * fix(gdb): preserve startup cleanup and detach safety * fix(process): retain debugger ownership after detach failure * fix(reverse): preserve mutation and failure provenance * fix(gdb): address debugger review findings * fix(reverse): rebind sessions after external mutation * fix(process): cap debugger output after exit * fix(gdb): protect one-shot detach verification * feat(debugger): add reverse tool coverage gate * test(debugger): cover integrated failure paths * fix(gdb): bound and preserve session observations * fix(gdb): address review regressions * style: format merged integration files * fix(reverse): preserve session and output evidence * refactor(gdb): share repeated test and partial result setup * fix(gdb): invalidate TTY proof for Python aliases * fix(reverse): retain failure output byte counts * test(process): synchronize late debugger output * fix(gdb): invalidate TTY proof for wrapped commands * fix(gdb): recognize wit command abbreviation * test(reverse): wait for cancelled descendant readiness * test(reverse): bound descendant cancellation cleanup * style(reverse): format integration tests and adapters * style: format reverse engineering integration files * test(reverse): remove duplicate environment harness * test(gdb): share session startup assertions * fix(gdb): invalidate output proof for command abbreviations * test(reverse): share composition edge case scenarios * fix(ci): recognize verification and mutation tool entries * test(rizin): cover successful fake inferior detach * test(cli): align full output failure envelope * test(cli): isolate reverse command lifecycle service * test(process): share debugger output fixture setup * fix(binary): preserve high-bit Windows signature bytes * style(binary): format Windows signature parser * test(mcp): preserve call coverage in unchanged comparison * fix(ci): include reverse tools Vitest configs in lint cache * fix(mcp): preserve target content on delivery errors * ci: align duplication ceiling with expanded suite * test(reverse): allow signal admission mutants * fix(reverse): bound retained cleanup failures to process owners * fix(test): replace drifting mutation ranges with owned selectors * fix(reverse): admit persistent commands and retain cleanup ownership * fix(cutter): recover timed-out commands from exact receipts * fix(cutter): reject unsupported bridge request kinds * fix(ci): repair client format and shared verification fixtures * fix(native): preserve debugger cleanup ownership and bridge authentication * test(native): retain diagnostics without perfect coverage requirements * chore(licenses): refresh locked dependency inventory and notices * fix(test): exclude transient command locks from mutation sandboxes * fix(gdb): admit only platforms with safe detach verification * fix(cutter): retain bounded completion receipts until acknowledgement * fix(cutter): acknowledge commands rejected before UI dispatch * test(reverse): fail cancellation checks before fixture teardown * test(gdb): clean up the retained broken-stdin fixture owner * docs(test): clarify focused provider verification scope * fix(rizin): retain close cleanup failures * fix(gdb): wait for inferior stop and bound MI buffering * fix(cutter): retain failed receipt acknowledgements * fix(rizin): return retained session identity on rebind failure * fix(gdb): preserve startup cancellation and lossy status * fix(rizin): retain launch provenance and assemble frames once * fix(cutter): recover receipts across callers and bound discovery * docs: describe debugger evidence and bridge recovery * fix(process): keep cleanup incomplete while writers remain Stopping a debugger no longer reports success when inherited output streams stay open, so BinarySession cannot reopen a binary a child is still changing. Concurrent Cutter cleanup retries share one receipt acknowledgement instead of treating a consumed receipt as failure. * fix(debuggers): recover cleanup with captured process identities * fix(analysis): retire sessions after unrestricted commands * fix(cli): preserve completed evidence after cleanup recovery * fix(cutter): bound frame collection and coalesce cleanup * fix(windows): resolve selected provider paths absolutely * fix(gdb): report decoding loss in failed startup evidence * fix(rizin): scope decoding loss to collected command frames * test(mcp): cover snapshot retirement in reverse tools CI --------- Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com> Co-authored-by: REA Maintainers <noreply@github.com> Co-authored-by: OpenAI Codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
168 files changed
+32703
-238
No files matched your search
@@ -62,6 +62,10 @@ function classifyPackage(base, head) {
|
||||
]),
|
||||
].filter((key) => beforeScripts?.[key] !== afterScripts?.[key]);
|
||||
for (const script of changed) {
|
||||
if (/^test:(coverage|mutation):reverse-tools$/.test(script)) {
|
||||
selected.reverse_tools = true;
|
||||
continue;
|
||||
}
|
||||
if (script === "test:sqlite" || script === "test:sqlite:run") {
|
||||
selected.sqlite = true;
|
||||
continue;
|
||||
@@ -162,6 +166,7 @@ if (tier === "reused") {
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
for (const file of files) {
|
||||
if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true;
|
||||
if (
|
||||
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
|
||||
file,
|
||||
|
||||
@@ -31,6 +31,7 @@ if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
|
||||
const required = reused ? ["changes"] : ["changes", "static", "test"];
|
||||
const lanes = {
|
||||
code: ["build", "test-shard"],
|
||||
reverse_tools: ["reverse-tools-quality"],
|
||||
runtime: ["windows-native-build", "package-e2e"],
|
||||
package: ["windows-curated"],
|
||||
sqlite: ["sqlite-runtime"],
|
||||
|
||||
@@ -2,6 +2,62 @@
|
||||
// Shared build inputs are classified once in plan.mjs rather than repeated in
|
||||
// every workflow.
|
||||
export const SCOPES = {
|
||||
reverse_tools: [
|
||||
"src/application/reverse/**",
|
||||
"src/application/binary/BinarySession.ts",
|
||||
"src/cutter/**",
|
||||
"src/gdb/**",
|
||||
"src/frida/frida-bindings.d.ts",
|
||||
"src/objdump/**",
|
||||
"src/cli/reverseEngineeringCommands.ts",
|
||||
"src/process/ownedDebuggerSession.ts",
|
||||
"src/rizin/**",
|
||||
"src/contracts/*ToolContracts.ts",
|
||||
"src/server/registerCutterTools.ts",
|
||||
"src/server/externalMutationAdmission.ts",
|
||||
"src/server/registerGdbTools.ts",
|
||||
"src/server/registerReverseEngineeringTools.ts",
|
||||
"src/server/providerEnvironment.ts",
|
||||
"src/process/ExecFileOutput.ts",
|
||||
"src/process/ExecFileOutput.test.ts",
|
||||
"tests/composition/analysis-sessions/binarySession.test.ts",
|
||||
"tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts",
|
||||
"tests/boundary/filesystem/reverseToolsMutationConfig.test.ts",
|
||||
"src/server/registerRizinDebugTools.ts",
|
||||
"tests/boundary/providers/CutterBridge*.test.ts",
|
||||
"tests/boundary/providers/cutterBridgeProducer.test.ts",
|
||||
"tests/boundary/providers/GdbSession*.test.ts",
|
||||
"tests/boundary/providers/GdbCommandQueue.test.ts",
|
||||
"tests/boundary/providers/gdbTestHelpers.ts",
|
||||
"tests/boundary/providers/RizinDebugSessionCoverage.part*.test.ts",
|
||||
"tests/boundary/providers/RizinCommandTimeout.test.ts",
|
||||
"tests/boundary/providers/RizinTimeoutEvidence.test.ts",
|
||||
"tests/boundary/providers/RizinDebugSession*.test.ts",
|
||||
"tests/boundary/providers/RizinInferiorTty.test.ts",
|
||||
"tests/boundary/providers/RizinTestInferiorTty.ts",
|
||||
"tests/boundary/process/ownedDebuggerSession.test.ts",
|
||||
"tests/boundary/cli/gdbStartupCleanupCli.test.ts",
|
||||
"tests/boundary/cli/reverseEngineeringCommands.test.ts",
|
||||
"tests/boundary/mcp/cutter*.test.ts",
|
||||
"tests/boundary/mcp/gdbCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/debuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts",
|
||||
"tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts",
|
||||
"tests/support/reverseEngineering*.ts",
|
||||
"tests/support/ownedRizinCommandEvidenceTests.ts",
|
||||
"tests/support/reverseDebuggerMcpTestHelpers.ts",
|
||||
"tests/processOwnershipRunner.ts",
|
||||
"tests/composition/reverseEngineering*.test.ts",
|
||||
"vitest.reverse-tools.config.ts",
|
||||
"vitest.reverse-tools-semantics.config.ts",
|
||||
"stryker.config.mjs",
|
||||
"stryker.semantics.config.mjs",
|
||||
"tsconfig.reverse-tools-mutation.json",
|
||||
"scripts/prepare-reverse-tools-mutation.mjs",
|
||||
"scripts/lib/reverse-tools-mutation-targets.mjs",
|
||||
],
|
||||
// Apple artifact CLI/MCP lane on macOS, including its focused regressions.
|
||||
apple: [
|
||||
"src/native/Apple*",
|
||||
|
||||
@@ -19,6 +19,11 @@ export const toolFamilyCatalog = (sources) => {
|
||||
contracts: [
|
||||
...sources.nativeContracts.NATIVE_TOOL_CONTRACTS,
|
||||
...sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
|
||||
...sources.cutterContracts.CUTTER_TOOL_CONTRACTS,
|
||||
...sources.reverseEngineeringContracts
|
||||
.REVERSE_ENGINEERING_TOOL_CONTRACTS,
|
||||
...sources.gdbContracts.GDB_TOOL_CONTRACTS,
|
||||
...sources.rizinDebugContracts.RIZIN_DEBUG_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -164,6 +169,32 @@ export const providerCatalog = (sources) => {
|
||||
activeContracts,
|
||||
} = electronContractSlices(sources);
|
||||
return [
|
||||
{
|
||||
identity: sources.cutterProvider.CUTTER_PROVIDER_IDENTITY,
|
||||
contracts: sources.cutterContracts.CUTTER_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.objdumpProvider.OBJDUMP_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.reverseEngineeringContracts.REVERSE_ENGINEERING_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "inspect_with_objdump",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.rizinProvider.RIZIN_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.reverseEngineeringContracts.REVERSE_ENGINEERING_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "execute_rizin_command",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.gdbProvider.GDB_PROVIDER_IDENTITY,
|
||||
contracts: sources.gdbContracts.GDB_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.rizinDebugProvider.RIZIN_DEBUGGER_PROVIDER_IDENTITY,
|
||||
contracts: sources.rizinDebugContracts.RIZIN_DEBUG_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.fridaProvider.FRIDA_PROVIDER_IDENTITY,
|
||||
contracts: sources.fridaContracts.FRIDA_TOOL_CONTRACTS,
|
||||
|
||||
@@ -43,6 +43,16 @@ const SOURCE_PATHS = {
|
||||
sessionContracts: "dist/contracts/sessionToolContracts.js",
|
||||
binaryDiagnosticsContracts:
|
||||
"dist/contracts/native/binaryDiagnosticsToolContracts.js",
|
||||
cutterContracts: "dist/contracts/cutterToolContracts.js",
|
||||
cutterProvider: "dist/cutter/CutterBridgeClient.js",
|
||||
reverseEngineeringContracts:
|
||||
"dist/contracts/reverseEngineeringToolContracts.js",
|
||||
gdbContracts: "dist/contracts/gdbToolContracts.js",
|
||||
gdbProvider: "dist/gdb/GdbSessionManager.js",
|
||||
rizinDebugContracts: "dist/contracts/rizinDebugToolContracts.js",
|
||||
objdumpProvider: "dist/objdump/ObjdumpCommand.js",
|
||||
rizinProvider: "dist/rizin/RizinCommand.js",
|
||||
rizinDebugProvider: "dist/rizin/RizinDebugSessionManager.js",
|
||||
fridaContracts: "dist/contracts/fridaToolContracts.js",
|
||||
fridaProvider: "dist/frida/FridaInstrumentationManager.js",
|
||||
pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript6";
|
||||
|
||||
const compact = (text) => text.replace(/\s+/gu, "");
|
||||
const nameOf = (node, source) => node.name?.getText(source);
|
||||
|
||||
const methodOwner = (node, source) => {
|
||||
for (let parent = node; parent !== undefined; parent = parent.parent) {
|
||||
if (ts.isMethodDeclaration(parent) || ts.isConstructorDeclaration(parent))
|
||||
return {
|
||||
owner: nameOf(parent.parent, source),
|
||||
body: parent.body,
|
||||
method: ts.isConstructorDeclaration(parent)
|
||||
? "constructor"
|
||||
: nameOf(parent, source),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const withinOwner = (node, selector, source) => {
|
||||
if (selector.owner === undefined) return true;
|
||||
const owner = methodOwner(node, source);
|
||||
return (
|
||||
owner?.owner === selector.owner &&
|
||||
(selector.method === undefined || owner.method === selector.method)
|
||||
);
|
||||
};
|
||||
|
||||
const contains = (node, predicate) =>
|
||||
predicate(node) ||
|
||||
ts.forEachChild(node, (child) => contains(child, predicate)) === true;
|
||||
|
||||
const inBlockWithBinding = (node, binding) => {
|
||||
if (binding === undefined) return true;
|
||||
let block = node.parent;
|
||||
while (block !== undefined && !ts.isBlock(block)) block = block.parent;
|
||||
return block?.statements.some(
|
||||
(statement) =>
|
||||
ts.isVariableStatement(statement) &&
|
||||
statement.declarationList.declarations.some(
|
||||
(declaration) =>
|
||||
ts.isIdentifier(declaration.name) &&
|
||||
declaration.name.text === binding,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const isCommandHandler = (node, command) => {
|
||||
if (!ts.isArrowFunction(node)) return false;
|
||||
const property = node.parent;
|
||||
if (
|
||||
!ts.isPropertyAssignment(property) ||
|
||||
!ts.isIdentifier(property.name) ||
|
||||
property.name.text !== "run"
|
||||
)
|
||||
return false;
|
||||
const call = property.parent.parent;
|
||||
if (
|
||||
!ts.isCallExpression(call) ||
|
||||
!ts.isPropertyAccessExpression(call.expression) ||
|
||||
!ts.isIdentifier(call.expression.expression) ||
|
||||
call.expression.expression.text !== "cli" ||
|
||||
call.expression.name.text !== "command"
|
||||
)
|
||||
return false;
|
||||
const selected = call.arguments[0];
|
||||
return (
|
||||
selected !== undefined &&
|
||||
ts.isPropertyAccessExpression(selected) &&
|
||||
ts.isIdentifier(selected.expression) &&
|
||||
selected.expression.text === "CLI_COMMANDS" &&
|
||||
selected.name.text === command
|
||||
);
|
||||
};
|
||||
|
||||
const matches = (node, selector, source) => {
|
||||
if (!withinOwner(node, selector, source)) return false;
|
||||
if (
|
||||
selector.direct === true &&
|
||||
node.parent !== methodOwner(node, source)?.body
|
||||
)
|
||||
return false;
|
||||
switch (selector.kind) {
|
||||
case "expression":
|
||||
return (
|
||||
ts.isBinaryExpression(node) &&
|
||||
compact(node.getText(source)) === compact(selector.text)
|
||||
);
|
||||
case "declaration":
|
||||
return (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
nameOf(node, source) === selector.name &&
|
||||
inBlockWithBinding(node, selector.withinBlockWithBinding)
|
||||
);
|
||||
case "method":
|
||||
return (
|
||||
ts.isMethodDeclaration(node) && nameOf(node, source) === selector.name
|
||||
);
|
||||
case "constructor":
|
||||
return ts.isConstructorDeclaration(node);
|
||||
case "guard":
|
||||
return (
|
||||
ts.isIfStatement(node) &&
|
||||
compact(node.expression.getText(source)) === compact(selector.condition)
|
||||
);
|
||||
case "call":
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
compact(node.expression.getText(source)) === compact(selector.callee) &&
|
||||
(selector.event === undefined ||
|
||||
(node.arguments[0] !== undefined &&
|
||||
ts.isStringLiteral(node.arguments[0]) &&
|
||||
node.arguments[0].text === selector.event))
|
||||
);
|
||||
case "try":
|
||||
return (
|
||||
ts.isTryStatement(node) &&
|
||||
contains(node.tryBlock, (child) =>
|
||||
selector.assignment !== undefined
|
||||
? ts.isBinaryExpression(child) &&
|
||||
child.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
||||
ts.isIdentifier(child.left) &&
|
||||
child.left.text === selector.assignment
|
||||
: ts.isCallExpression(child) &&
|
||||
compact(child.expression.getText(source)) ===
|
||||
compact(selector.callee),
|
||||
)
|
||||
);
|
||||
case "command-handler":
|
||||
return isCommandHandler(node, selector.command);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown mutation selector kind: ${String(selector.kind)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse a source once and resolve one owned region for every requested selector. */
|
||||
export const mutationRanges = (path, selectors) => {
|
||||
if (!Array.isArray(selectors) || selectors.length === 0)
|
||||
throw new Error(`Mutation selectors must be nonempty: ${path}`);
|
||||
for (const selector of selectors)
|
||||
if (
|
||||
["method", "constructor", "guard", "call", "try"].includes(
|
||||
selector.kind,
|
||||
) &&
|
||||
typeof selector.owner !== "string"
|
||||
)
|
||||
throw new Error(
|
||||
`Mutation selector requires a named class owner: ${path}`,
|
||||
);
|
||||
const source = ts.createSourceFile(
|
||||
path,
|
||||
readFileSync(path, "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
if (source.parseDiagnostics.length !== 0)
|
||||
throw new Error(
|
||||
`Cannot select mutations from malformed TypeScript: ${path}`,
|
||||
);
|
||||
const found = selectors.map(() => []);
|
||||
const visit = (node) => {
|
||||
for (const [index, selector] of selectors.entries())
|
||||
if (matches(node, selector, source)) found[index].push(node);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
return selectors.map((selector, index) => {
|
||||
const candidates = found[index];
|
||||
if (candidates.length !== 1)
|
||||
throw new Error(
|
||||
`Expected one mutation target in ${path}: ${JSON.stringify(selector)}; found ${candidates.length}`,
|
||||
);
|
||||
const node = candidates[0];
|
||||
const first =
|
||||
source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
||||
const last = source.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
||||
return `${path}:${first}-${last}`;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { mkdir, rm, symlink } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const compiler = resolve(root, "node_modules/typescript6");
|
||||
|
||||
for (const packageName of ["core", "typescript-checker"]) {
|
||||
const packagePath = resolve(
|
||||
root,
|
||||
"node_modules/@stryker-mutator",
|
||||
packageName,
|
||||
);
|
||||
const nodeModulesPath = resolve(packagePath, "node_modules");
|
||||
const typescriptPath = resolve(nodeModulesPath, "typescript");
|
||||
await mkdir(nodeModulesPath, { recursive: true });
|
||||
await rm(typescriptPath, { recursive: true, force: true });
|
||||
await symlink(compiler, typescriptPath, "dir");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { promisify } from "node:util";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const packedHopperBridge = "package/bridge/hopper_bridge.py";
|
||||
const packedCutterBridge = "package/bridge/cutter/rea_bridge.py";
|
||||
const packedGhidraBridge = "package/bridge/ghidra/ReaGhidraBridge.java";
|
||||
const packedGhidraActivator =
|
||||
"package/bridge/ghidra/ReaGhidraBundleActivator.java";
|
||||
@@ -12,6 +13,7 @@ const packedGhidraActivator =
|
||||
export async function verifyPackedBridge({ workspace, tarball, packedFiles }) {
|
||||
for (const path of [
|
||||
packedHopperBridge,
|
||||
packedCutterBridge,
|
||||
packedGhidraBridge,
|
||||
packedGhidraActivator,
|
||||
]) {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
constants,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { createServer } from "../dist/server/createServer.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const cli = resolve("scripts/rea.mjs");
|
||||
const runCli = async (...arguments_) => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[cli, ...arguments_, "--format", "json"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
};
|
||||
|
||||
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
|
||||
const projectPath = join(projectRoot, "smoke.rzdb");
|
||||
const rizinQuote = (value) =>
|
||||
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
const unavailableAnalysis = {
|
||||
execute: async () => {
|
||||
throw new Error(
|
||||
"The Cutter verification does not call unrelated analysis tools.",
|
||||
);
|
||||
},
|
||||
};
|
||||
const expectedPlatform = {
|
||||
Linux: "linux",
|
||||
macOS: "darwin",
|
||||
Windows: "win32",
|
||||
}[process.env.EXPECTED_PLATFORM];
|
||||
if (expectedPlatform !== undefined)
|
||||
assert.equal(
|
||||
process.platform,
|
||||
expectedPlatform,
|
||||
"Cutter runner did not match its declared platform.",
|
||||
);
|
||||
try {
|
||||
const discovery = await runCli("list-cutter-sessions");
|
||||
assert.equal(
|
||||
discovery.discovery_status,
|
||||
"sessions_found",
|
||||
"Open upstream Cutter with the REA plugin before running this smoke test.",
|
||||
);
|
||||
assert.ok(discovery.sessions.length > 0);
|
||||
const session = discovery.sessions[0];
|
||||
assert.ok(session);
|
||||
assert.ok(
|
||||
session.cutter_version,
|
||||
"The upstream Cutter API did not report its version.",
|
||||
);
|
||||
if (process.env.REA_CUTTER_EXPECTED_VERSION !== undefined)
|
||||
assert.ok(
|
||||
session.cutter_version.includes(process.env.REA_CUTTER_EXPECTED_VERSION),
|
||||
"The live Cutter version did not match the configured version.",
|
||||
);
|
||||
assert.ok(
|
||||
session.current_file,
|
||||
"Open a disposable local binary in Cutter before running this smoke test.",
|
||||
);
|
||||
const artifactPath = session.current_file;
|
||||
const artifactDigest = createHash("sha256")
|
||||
.update(await readFile(artifactPath))
|
||||
.digest("hex");
|
||||
|
||||
const inspection = await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"ij",
|
||||
"--json",
|
||||
);
|
||||
assert.equal(inspection.predicate_type, "rea.cutter.command-observation");
|
||||
assert.ok(
|
||||
Object.hasOwn(inspection, "raw_result"),
|
||||
"Cutter ij result was not retained as Evidence.",
|
||||
);
|
||||
assert.equal(typeof inspection.raw_result.bin?.arch, "string");
|
||||
|
||||
const mcpServer = createServer(
|
||||
{ kind: "fixed", analysis: unavailableAnalysis },
|
||||
{
|
||||
providerEnvironment: process.env,
|
||||
},
|
||||
);
|
||||
const mcpClient = new Client({
|
||||
name: "rea-cutter-upstream-smoke",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await mcpServer.connect(serverTransport);
|
||||
await mcpClient.connect(clientTransport);
|
||||
const mcpDiscovery = await mcpClient.callTool({
|
||||
name: "list_cutter_sessions",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(mcpDiscovery.isError, true);
|
||||
assert.equal(
|
||||
mcpDiscovery.structuredContent.discovery_status,
|
||||
"sessions_found",
|
||||
);
|
||||
const mcpInspection = await mcpClient.callTool({
|
||||
name: "cutter_command",
|
||||
arguments: {
|
||||
session_id: session.session_id,
|
||||
expected_generation: session.document_generation,
|
||||
command: "ij",
|
||||
json: true,
|
||||
},
|
||||
});
|
||||
assert.notEqual(mcpInspection.isError, true);
|
||||
assert.deepEqual(
|
||||
mcpInspection.structuredContent.normalized_result,
|
||||
inspection.normalized_result,
|
||||
"Cutter CLI and MCP returned different analysis results.",
|
||||
);
|
||||
} finally {
|
||||
await mcpClient.close();
|
||||
await mcpServer.close();
|
||||
}
|
||||
|
||||
const annotation = `rea-smoke-${randomUUID()}`;
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
`CC ${annotation} @ entry0`,
|
||||
);
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
`Ps ${rizinQuote(projectPath)}`,
|
||||
);
|
||||
await access(projectPath, constants.R_OK | constants.W_OK);
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"CC- @ entry0",
|
||||
);
|
||||
const removed = await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"CC. @ entry0",
|
||||
);
|
||||
assert.ok(
|
||||
!String(removed.raw_result).includes(annotation),
|
||||
"The in-memory annotation was not removed before project reopening.",
|
||||
);
|
||||
await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
`Po ${rizinQuote(projectPath)}`,
|
||||
);
|
||||
const recovered = await runCli(
|
||||
"cutter-command",
|
||||
session.session_id,
|
||||
String(session.document_generation),
|
||||
"CC. @ entry0",
|
||||
);
|
||||
assert.equal(recovered.predicate_type, "rea.cutter.command-observation");
|
||||
assert.ok(
|
||||
String(recovered.raw_result).includes(annotation),
|
||||
"The saved project did not preserve the analysis comment after reopening.",
|
||||
);
|
||||
const finalArtifactDigest = createHash("sha256")
|
||||
.update(await readFile(artifactPath))
|
||||
.digest("hex");
|
||||
assert.equal(
|
||||
finalArtifactDigest,
|
||||
artifactDigest,
|
||||
"Cutter project edits changed the executable bytes.",
|
||||
);
|
||||
|
||||
const report = {
|
||||
ok: true,
|
||||
platform: process.platform,
|
||||
cutter_version: session.cutter_version,
|
||||
document_generation: session.document_generation,
|
||||
cli_mcp_evidence_parity: true,
|
||||
annotation_saved_and_reopened: true,
|
||||
executable_digest_unchanged: true,
|
||||
};
|
||||
await writeFile("cutter-smoke.json", `${JSON.stringify(report, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
console.log(JSON.stringify(report));
|
||||
} finally {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -148,6 +148,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
"//localhost/share/target.bin",
|
||||
])
|
||||
assert.throws(() => native.call("open", [rejected]));
|
||||
assert.throws(
|
||||
() => native.call("cutter_bridge_verify_directory", [sourceDirectory]),
|
||||
/owner|DACL/u,
|
||||
);
|
||||
report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true;
|
||||
// An owned runtime's DELETE lease conflicts with a fresh parent admission.
|
||||
// This produces a genuine sharing failure after successful authority caching,
|
||||
@@ -282,6 +286,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
() => native.call("open", [join(junction, "target.bin")]),
|
||||
/Reparse/u,
|
||||
);
|
||||
assert.throws(
|
||||
() => native.call("cutter_bridge_verify_directory", [junction]),
|
||||
/Reparse/u,
|
||||
);
|
||||
assert.throws(() => native.call("open", [source + ":stream"]));
|
||||
const runtime = native.call("runtime_create", [
|
||||
workspace.replaceAll("\\", "/"),
|
||||
@@ -326,6 +334,47 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
"descriptor.json",
|
||||
Buffer.from("fixture"),
|
||||
]);
|
||||
assert.equal(
|
||||
native.call("cutter_bridge_verify_directory", [runtime.path]).privateDacl,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
native
|
||||
.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"descriptor.json",
|
||||
64 * 1024,
|
||||
])
|
||||
.toString("utf8"),
|
||||
"fixture",
|
||||
);
|
||||
const descriptorJunction = join(runtime.path, "descriptor-link.json");
|
||||
await symlink(sourceDirectory, descriptorJunction, "junction");
|
||||
assert.throws(() =>
|
||||
native.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"descriptor-link.json",
|
||||
64 * 1024,
|
||||
]),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
native.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"descriptor.json",
|
||||
3,
|
||||
]),
|
||||
/byte limit/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
native.call("cutter_bridge_read_descriptor", [
|
||||
runtime.path,
|
||||
"..\\escape.json",
|
||||
64 * 1024,
|
||||
]),
|
||||
/filename component/u,
|
||||
);
|
||||
assert.throws(() =>
|
||||
native.call("runtime_write", [
|
||||
runtime.handle,
|
||||
|
||||
Reference in new issue
Block a user