mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
Rizin exits 0 when its shell refuses a statement, so execute_rizin_command reported completion_status complete with exit code 0 and an empty stdout that read as a genuine empty result. Rizin 0.6 through 0.9.1 log exactly four refusal lines from librz/core/cmd/cmd.c: an unknown command, a wrong argument count, an invalid statement (RZ_CMD_STATUS_INVALID, which also stops the remaining statements) and a parse error. The Rizin command profile now classifies a completed process's stderr against those lines; the shared one-shot service reports completion_status "rejected" and a limitation that names the refusal and says stdout holds only earlier statements' output. Other ERROR lines come from commands that ran and stay diagnostics. Fixes #10364 Co-Authored-By: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { chmod, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect } from "vitest";
|
|
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js";
|
|
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../support/cli/cliFixture.js";
|
|
|
|
const contract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find(
|
|
({ name }) => name === "execute_rizin_command",
|
|
);
|
|
if (contract === undefined)
|
|
throw new Error("execute_rizin_command contract is missing");
|
|
|
|
interface Outcome {
|
|
readonly command: string;
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
/** Expected completion and the refusal the limitation must name. */
|
|
readonly refused?: RegExp;
|
|
}
|
|
|
|
// Captured from the official Rizin 0.9.1 static Linux build on /usr/bin/true.
|
|
const outcomes: readonly Outcome[] = [
|
|
{
|
|
command: "axffj",
|
|
stdout: "",
|
|
stderr:
|
|
"ERROR: Command 'axffj' does not exist.\nERROR: Displaying the help of command 'axf'.\n\nERROR: Usage: axf[jq] # List xrefs from current seek\n",
|
|
refused: /unknown command "axffj"/u,
|
|
},
|
|
{
|
|
// Rizin runs the statements before a refusal and skips the rest.
|
|
command: "%v 1; zz; %v 2",
|
|
stdout: "0x1\n",
|
|
stderr: "ERROR: Command 'zz' does not exist.\n",
|
|
refused: /unknown command "zz"/u,
|
|
},
|
|
{
|
|
command: "%v 1; afl 1 2; %v 2",
|
|
stdout: "0x1\n",
|
|
stderr:
|
|
"ERROR: Wrong number of arguments passed to `afl`, see its help with `afl?`\n\nERROR: Usage: afl[?] # List functions\n",
|
|
refused: /wrong argument count for "afl"/u,
|
|
},
|
|
{
|
|
command: "pd 1 @ nosuch",
|
|
stdout: "",
|
|
stderr:
|
|
"ERROR: core: Invalid address (nosuch)\nERROR: core: Error while executing command: pd 1 @ nosuch\n",
|
|
refused: /an invalid statement/u,
|
|
},
|
|
{
|
|
command: "?e",
|
|
stdout: "",
|
|
stderr: "ERROR: core: Error while parsing command: `?e`\n",
|
|
refused: /input that failed to parse/u,
|
|
},
|
|
// A command that ran and reported its own failure is not a shell refusal.
|
|
{
|
|
command: "afxj",
|
|
stdout: "",
|
|
stderr: "ERROR: No function found in 0x000023d0.\n",
|
|
},
|
|
// Refusal text on stdout is command output, not a Rizin diagnostic.
|
|
{
|
|
command: "echo refusal",
|
|
stdout: "ERROR: Command 'axffj' does not exist.\n",
|
|
stderr: "",
|
|
},
|
|
];
|
|
|
|
const expectClassified = (raw: unknown, outcome: Outcome): void => {
|
|
const evidence = contract.outputSchema.parse(raw);
|
|
expect(evidence.normalized_result).toMatchObject({
|
|
stdout: outcome.stdout,
|
|
stderr: outcome.stderr,
|
|
exit_code: 0,
|
|
completion_status: outcome.refused === undefined ? "complete" : "rejected",
|
|
});
|
|
expect(evidence.raw_result).toEqual(evidence.normalized_result);
|
|
const refusals = evidence.limitations.filter((limitation) =>
|
|
limitation.startsWith("Rizin refused "),
|
|
);
|
|
if (outcome.refused === undefined) expect(refusals).toEqual([]);
|
|
else {
|
|
expect(refusals).toHaveLength(1);
|
|
expect(refusals[0]).toMatch(outcome.refused);
|
|
expect(refusals[0]).toMatch(/empty stdout is not an empty result/u);
|
|
}
|
|
};
|
|
|
|
cliTest.skipIf(process.platform === "win32")(
|
|
"reports Rizin shell refusals as rejected through CLI and MCP (simulated Rizin)",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-rizin-rejection-");
|
|
const provider = join(root, "rizin-fixture");
|
|
const artifact = join(root, "artifact.bin");
|
|
await writeFile(artifact, "caller-selected inert artifact");
|
|
// A Node script replays recorded Rizin output; it is not Rizin.
|
|
await writeFile(
|
|
provider,
|
|
`#!${process.execPath}\n` +
|
|
`const cases=${JSON.stringify(outcomes)};\n` +
|
|
"const selected=cases.find(item=>item.command===process.argv[process.argv.indexOf('-c')+1]);\n" +
|
|
"if(!selected)process.exit(99);\n" +
|
|
"process.stdout.write(selected.stdout);process.stderr.write(selected.stderr);\n",
|
|
);
|
|
await chmod(provider, 0o755);
|
|
const environment = { REA_RIZIN_COMMAND: provider };
|
|
const { call } = await connectLocalToolsMcp(environment);
|
|
// Both adapters share the service's classification; the CLI checks one
|
|
// refusal and one command diagnostic.
|
|
for (const outcome of [outcomes[0]!, outcomes[5]!]) {
|
|
const response = await cli.sharedCliRun!({
|
|
arguments: [
|
|
"execute-rizin-command",
|
|
artifact,
|
|
outcome.command,
|
|
"--json",
|
|
],
|
|
environment,
|
|
});
|
|
expect(response.exitCode, `${response.stdout}\n${response.stderr}`).toBe(
|
|
0,
|
|
);
|
|
expectClassified(response.json, outcome);
|
|
}
|
|
for (const outcome of outcomes) {
|
|
const mcp = await call("execute_rizin_command", {
|
|
path: artifact,
|
|
command: outcome.command,
|
|
});
|
|
expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true);
|
|
expectClassified(mcp.structuredContent, outcome);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Real-provider gate: runs the Rizin found on PATH and skips without one.
|
|
const rizin = (() => {
|
|
try {
|
|
const path = execFileSync("which", ["rizin"], { encoding: "utf8" }).trim();
|
|
return path === "" ? undefined : path;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
})();
|
|
|
|
cliTest.skipIf(rizin === undefined)(
|
|
"classifies real Rizin refusals and command diagnostics",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-real-rizin-rejection-");
|
|
const artifact = join(root, "artifact.bin");
|
|
await writeFile(artifact, Buffer.from([0x90, 0xc3]));
|
|
const { call } = await connectLocalToolsMcp({ REA_RIZIN_COMMAND: rizin! });
|
|
const expected = [
|
|
["axffj", "rejected"],
|
|
["%v 1; zz; %v 2", "rejected"],
|
|
["%v 1; afl 1 2; %v 2", "rejected"],
|
|
["pd 1 @ nosuch", "rejected"],
|
|
["s nosuch", "complete"],
|
|
["%v 1; %v 2", "complete"],
|
|
] as const;
|
|
for (const [command, status] of expected) {
|
|
const response = await call("execute_rizin_command", {
|
|
path: artifact,
|
|
command,
|
|
});
|
|
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
|
const evidence = contract.outputSchema.parse(response.structuredContent);
|
|
expect(evidence.normalized_result, command).toMatchObject({
|
|
exit_code: 0,
|
|
completion_status: status,
|
|
});
|
|
}
|
|
},
|
|
);
|