feat(rizin): select the decoder and map address for one-shot commands (#8991)

* feat(rizin): select the decoder and map address for one-shot commands

execute_rizin_command and rea execute-rizin-command accept optional
asm_arch, asm_bits, asm_cpu and base_address (CLI --asm-arch,
--asm-bits, --asm-cpu and --base-address). REA passes them to Rizin
before the artifact path as -a, -b, -e asm.cpu= and -m. Rizin 0.9.1
refuses -e asm.arch= and -e asm.bits= at startup, while -a and -b work
in 0.7.4 and 0.9.1. Rizin applies -e settings after -a and -b.
base_address is the address of file offset 0, as in REA's raw-binary
inputs.

REA checks only the spelling, because Rizin owns the arch and CPU
vocabulary and its builds differ. The Homebrew build of Rizin 0.9.1
has no xtensa or riscv plugin; the official static Linux build of
0.7.4 has both. Evidence records each input and the complete argument
list. When any input is set, Evidence also states that REA does not
confirm the decoder or map that Rizin used: Rizin uses another value
for one it cannot apply and exits 0. Rizin 0.9.1 keeps its default and
reports the value on stderr, and Rizin 0.7.4 turned -b 8 for arm into
16 without a message. Calls without these inputs keep their argument
list and Evidence.

The real-provider gate tests/boundary/providers/RizinDecoderInputs.test.ts
checks the effect of each flag through MCP with Rizin's own e asm.*,
omlj and byte reads. It skips when no Rizin is on PATH. It passes with
Rizin 0.9.1 (Homebrew, macOS arm64) and 0.7.4 (official static build,
Linux x86_64).

* docs(rizin): map multi-segment ESP32 images with om

An ESP32-family app or bootloader image holds several segments, each
loaded at its own address, so one base_address cannot map it. The
recipe removes the default map with om-* and adds one om map per
segment, using the segment offsets from the image format or from
esptool image-info. It does not depend on an ESP image reader in REA.

The offsets were checked against esptool 4.12.0 image_info on a
synthetic two-segment ESP32 image. Rizin 0.9.1 and 0.7.4 mapped its
segments at their load addresses. Rizin 0.7.4, whose static Linux
build has the xtensa plugin, decoded the IRAM segment as nop.n, nop.n,
ret.n and nop.n.

* fix(rizin): read the file at base_address when a loader would claim it

On Rizin 0.9.1, base_address could read 0xff at the mapped address with
exit 0. -m maps the file, but Rizin still runs its format loaders, and
a loose one can claim a raw image. Rizin 0.9.1's AVR loader matches
when bytes 1 and 3 look like rjmp and turns io.va off, so reads use
file offsets. The BIOS loader, in 0.7.4 and 0.9.1, matches a 64 KiB
file with a jmp 16 bytes from its end and maps it at another address.
An acceptance run on a raw ESP32-S3 segment hit the first case.

With base_address, REA now passes -n together with -m. Rizin then skips
the loaders and maps the whole file at the address: rizin.c calls
rz_io_map_new at the -m address and leaves io.va at its default. The
input therefore means a raw image, and Rizin loads no sections, symbols
or entry point, even from an ELF file. Evidence states this in its own
limitation, and the decoder limitation no longer covers the map.

The real-provider gate adds an AVR-like and a BIOS-like image and reads
below the base, at it, at the end of the image and past it. Without -n
it fails on Rizin 0.9.1. It passes with Rizin 0.9.1 (Homebrew, macOS
arm64) and 0.7.4 (official static build, Linux x86_64). The ESP om
recipe now starts with e io.va=true, because reads use the maps only
while io.va is on.

* fix(rizin): advertise name patterns that compile in v mode

The asm_arch and asm_cpu pattern ended its character class with an
unescaped '-', which JavaScript's v (unicodeSets) mode rejects, so
clients compiling advertised patterns in that mode could not use
execute_rizin_command's schema. Escaping it keeps the same names valid
in every mode, as main's own patterns write it.

* fix(rizin): state what the decoder and map inputs leave unconfirmed

The base_address limitation reported a map REA never observes and left
out that -n keeps Rizin's default arch, bits and byte order. The asm_*
limitation implied Rizin replaces a bad CPU, but e asm.cpu echoes any
value. Compact schemas drop property descriptions, so the tool
description now carries the raw-loading warning. The real-provider
gate checks that asm_cpu changes a decode, and a base_address-only call.

* docs(rizin): record the gate's full run on Rizin 0.7.4

* test(rizin): follow the test guide in the decoder input tests

Focused tests resolve execute_rizin_command from its contract family,
the harness owns the temporary directory before setup can fail, and
guidance is compared with its owner, not with sentences from it.
This commit is contained in:
Entrpi authored and GitHub committed 2026-11-06 20:48:59 +08:00
1 parent 7e9cf83fca
commit 81f9f71f6d
17 files changed
+795 -3

No files matched your search

+114
View File
@@ -124,6 +124,120 @@ rea execute-rizin-command ./program iI
Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. One-shot commands use the `command_timeout_ms` budget described above. That 16 MiB cap has no runtime override.
### Decoder and map inputs
Four optional inputs select how Rizin decodes the artifact and where it maps
it. REA passes each one to Rizin before the artifact path:
| Input | CLI option | Rizin argument | Example |
| -------------- | ---------------- | -------------- | -------------------- |
| `asm_arch` | `--asm-arch` | `-a` | `arm`, `x86`, `6502` |
| `asm_bits` | `--asm-bits` | `-b` | `16` for Thumb |
| `asm_cpu` | `--asm-cpu` | `-e asm.cpu=` | `cortexm` |
| `base_address` | `--base-address` | `-n -m` | `0x08000000` |
Rizin 0.9.1 refuses `-e asm.arch=` and `-e asm.bits=` at startup, and Rizin
0.7.4 accepts them, so REA uses `-a` and `-b`, which both accept. Rizin applies
`-e` settings after `-a` and `-b`, so `asm_cpu` applies to the selected arch.
`base_address` is the address of file offset 0, as in REA's raw-binary inputs.
REA passes it as `-m` together with `-n`, so Rizin maps the whole file there as
a raw image and loads no format information, such as sections, symbols or an
entry point, even from an ELF file. Without `-n`, a Rizin format loader can
claim a raw image and give wrong reads with no error. In Rizin 0.9.1 the AVR
loader turns `io.va` off, so every read at the mapped address returns `0xff`.
In Rizin 0.7.4 and 0.9.1 the BIOS loader maps the file at another address.
A loader can also claim a raw image that you open with only `asm_*` inputs, so
pass `base_address`, `0x0` if the image has no known address. Evidence records
each input and the complete argument list.
With `-n`, Rizin has no headers to read, so arch, bits and byte order stay at
its defaults unless the call selects them. A Thumb image opened with only
`base_address` decodes as plausible AArch64 on an arm64 host. Pass `asm_arch`
and `asm_bits` with `base_address`, and for a big-endian image start the command
with `e cfg.bigendian=true`.
REA checks only the spelling. Arch and CPU names contain letters, digits, `_`,
`.`, `+` and `-`, and start with a letter or a digit. `asm_bits` is an integer
from 1 to 64. `base_address` is `0x` followed by 1 to 16 hexadecimal digits.
Rizin owns the names, and its builds differ: for example, the Homebrew build of
Rizin 0.9.1 has no `xtensa` or `riscv` plugin, while the official static Linux
build of Rizin 0.7.4 has both. The commands `e asm.arch=?` and `e asm.cpu=?`
list what the installed Rizin supports. To use another build, set
`REA_RIZIN_COMMAND`. When Rizin cannot apply an arch or bits value, it uses
another value and exits with status 0. Rizin 0.9.1 keeps its default and
reports the value on stderr. Rizin 0.7.4 reports an unknown arch on stderr, but
for `arm` it used 16 bits instead of 8 without a message. Both accept any
`asm.cpu` value, and `e asm.cpu` then echoes it, so compare it with
`e asm.cpu=?`, which lists the CPUs of the selected arch. Evidence therefore
states that REA does not confirm the decoder that Rizin used. To check the
decoder and the map, start the command with
`e asm.arch; e asm.bits; e asm.cpu; e cfg.bigendian; oml`.
This call reads a Cortex-M flash dump at its address, in Thumb state:
```sh
rea execute-rizin-command ./flash.bin "e asm.arch; e asm.bits; oml; pxw 8 @ 0x08000000" --asm-arch arm --asm-bits 16 --asm-cpu cortexm --base-address 0x08000000
```
Through MCP, the same call is:
```json
{
"path": "/firmware/flash.bin",
"command": "e asm.arch; e asm.bits; oml; pxw 8 @ 0x08000000",
"asm_arch": "arm",
"asm_bits": 16,
"asm_cpu": "cortexm",
"base_address": "0x08000000"
}
```
`pxw 8` prints the initial stack pointer and the Reset vector. The Reset vector
is a Thumb address with its low bit set. Disassemble the handler at the even
address, for example `pd 16 @ 0x08000108` for a Reset vector of `0x08000109`.
### Multi-segment ESP images
An app or bootloader image of the ESP32 family (the ESP32, ESP32-S, ESP32-C,
ESP32-H and ESP32-P series) starts with the magic byte `0xE9` and holds several
segments. ESP8266 images use a different header, and this recipe does not
cover them. Each segment loads at its own address, so one `base_address` cannot
map the image. Instead, remove the default map and add one `om` map for each
segment, in the same command:
1. Find the load address and the length of each segment, and the file offset
of its data. The image header is 24 bytes. Each segment starts with an
8-byte header that holds the load address and the data length, both 32-bit
little-endian, and its data follows. So the data of the first segment
starts at file offset `0x20`, and each later segment header follows the
data of the segment before it. `esptool image-info` lists the same
segments; esptool v4 spells it `image_info --version 2`. Its `File offs`
column is the offset of the 8-byte segment header, so the data starts 8
bytes later. A padding segment carries no code or data, and you can skip
it.
2. Start the command with `e io.va=true`. Reads use the maps only while
`io.va` is on, and a Rizin format loader can turn it off.
3. Rizin opens the image as file descriptor 3, which `ol` shows. For each
segment, add this map, with `r-x` for code and `r--` for data:
`om 3 <load address> <length> <data offset> <permissions> <name>`. A map
cannot have more permissions than the opened file, which is read-only.
4. Select the decoder with `asm_arch` and set `asm_bits` to 32. ESP32, ESP32-S2
and ESP32-S3 use Xtensa. The ESP32-C and ESP32-H series and ESP32-P4 use
RISC-V. Use the plugin name that `e asm.arch=?` lists for the installed
Rizin.
For example, for an image with an IRAM segment of 8 bytes at `0x40080000` and
a DRAM segment of 16 bytes at `0x3ffb0000`:
```sh
rea execute-rizin-command ./app.bin "e io.va=true; om-*; om 3 0x40080000 0x8 0x20 r-x iram0; om 3 0x3ffb0000 0x10 0x30 r-- dram0; oml; pd 4 @ 0x40080000" --asm-arch xtensa --asm-bits 32
```
`om-*` removes the maps that Rizin made when it opened the file, such as the
default map of the whole file at address 0. The entry address is the 32-bit
little-endian value at file offset 4.
## Cutter bridge
Cutter commands use the local REA Python bridge and authenticated loopback messages.
+15
View File
@@ -15,6 +15,21 @@ typecheck selections; its real-provider assertions skip when Rizin 0.7.4 is
unavailable. Run the focused command above on a host with that provider for the
explicit real-provider verification gate.
The decoder and map inputs of `execute_rizin_command` have their own
real-provider gate. Run
`npm run test:focused -- tests/boundary/providers/RizinDecoderInputs.test.ts`.
It calls the tool through MCP with the Rizin on `PATH` and checks the effect of
`-a`, `-b`, `-e asm.cpu=` and `-n -m` on raw images with Rizin's own `e asm.*`
and `omlj` output, and that `asm_cpu` changes how an M-profile instruction
decodes. It also reads bytes below the base address, at it, at the end of the
image and past it, and checks a call with only `base_address`. Two of the images are ones that Rizin's AVR and
BIOS loaders claim without `-n`. It also selects an arch that no host uses by
default, an arch plugin that Rizin does not have, and a bits value that the
arch lacks. The test names the Rizin version it ran. It skips when no Rizin is
on `PATH`; to check another build, put its directory first on `PATH`. It has
passed with Rizin 0.9.1 (Homebrew, macOS arm64) and Rizin 0.7.4 (official static
build, Linux x86_64).
Each real-toolchain command must require only the host tools needed to prove
its stated claim. Use a host-native fixture for host/provider acceptance, and
place optional cross-target formats or platform-specific runners in separate
+1
View File
@@ -102,6 +102,7 @@ const PATH_SCOPES = {
"tests/boundary/providers/RizinTimeoutEvidence.test.ts",
"tests/boundary/providers/RizinDebugSession*.test.ts",
"tests/boundary/providers/RizinInferiorTty.test.ts",
"tests/boundary/providers/RizinDecoderInputs.test.ts",
"tests/boundary/providers/RizinTestInferiorTty.ts",
"tests/boundary/process/ownedDebuggerSession.test.ts",
"tests/boundary/cli/debuggerOneShotExit.test.ts",
@@ -9,6 +9,8 @@ export interface ReverseEngineeringProviderCommand {
readonly executable: string;
readonly arguments: readonly string[];
readonly identity: EvidenceProvider;
/** What the provider cannot attest about the arguments it built. */
readonly limitations?: readonly string[];
readonly environmentOverrides?: Readonly<NodeJS.ProcessEnv>;
}
@@ -736,6 +736,7 @@ export class ReverseEngineeringService {
"Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.",
]
: []),
...(profile.limitations ?? []),
...(stdoutInvalidUtf8 || stderrInvalidUtf8
? [
`Captured ${[
+17
View File
@@ -17,6 +17,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
OBJDUMP_ARTIFACT_OPERATIONS,
reverseCommandTimeoutSchema,
rizinInputSchema,
} from "../domain/reverseEngineering.js";
import { logCliCommand } from "../cliLogging.js";
import type { CliInstance } from "./types.js";
@@ -309,6 +310,10 @@ export const registerReverseEngineeringCommands = (
command: z.string().min(1).describe("One caller-selected Rizin command."),
}),
options: z.object({
asmArch: rizinInputSchema.shape.asm_arch,
asmBits: rizinInputSchema.shape.asm_bits,
asmCpu: rizinInputSchema.shape.asm_cpu,
baseAddress: rizinInputSchema.shape.base_address,
commandTimeoutMs: reverseCommandTimeoutSchema,
}),
run: ({ args, options }) =>
@@ -318,6 +323,18 @@ export const registerReverseEngineeringCommands = (
service.executeRizinCommand(
{
...args,
...(options.asmArch === undefined
? {}
: { asm_arch: options.asmArch }),
...(options.asmBits === undefined
? {}
: { asm_bits: options.asmBits }),
...(options.asmCpu === undefined
? {}
: { asm_cpu: options.asmCpu }),
...(options.baseAddress === undefined
? {}
: { base_address: options.baseAddress }),
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
+5 -1
View File
@@ -3,7 +3,10 @@ import type { ReverseEngineeringCommandPort } from "../application/reverse/Rever
import { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
import { objdumpCommand } from "../objdump/ObjdumpCommand.js";
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
import { rizinCommand } from "../rizin/RizinCommand.js";
import {
rizinCommand,
rizinCommandLimitations,
} from "../rizin/RizinCommand.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js";
@@ -28,6 +31,7 @@ export const createReverseEngineeringCommandPort = (
platform,
),
arguments: rizinCommand(input),
limitations: rizinCommandLimitations(input),
identity: RIZIN_PROVIDER_IDENTITY,
}),
});
@@ -39,7 +39,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
...toolContractMetadata("execute_rizin_command"),
kind: "native-provider",
description:
"Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap or a Rizin debugger session deadline.",
"Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. For a raw image, asm_arch, asm_bits and asm_cpu select the decoder and base_address maps file offset 0 there; base_address also passes -n, which skips format loaders even for an ELF, and REA confirms neither. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap or a Rizin debugger session deadline.",
inputSchema: rizinInputSchema,
outputSchema: evidenceResultOf(commandObservationSchema),
examples: [
+2 -1
View File
@@ -21,7 +21,8 @@ export const BINARY_ARCHITECTURES = [
/** Provider-neutral CPU family for a caller-supplied raw-binary load. */
export const binaryArchitectureSchema = z.enum(BINARY_ARCHITECTURES);
const RAW_BINARY_ENTRY_POINT = /^0x[0-9A-Fa-f]{1,16}$/u;
/** Caller-supplied hexadecimal address of at most 64 bits, such as 0x08000000. */
export const RAW_BINARY_ENTRY_POINT = /^0x[0-9A-Fa-f]{1,16}$/u;
/** Hexadecimal load and entry address; file offset 0 is mapped here. */
export const rawBinaryEntryPointSchema = z
+39
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { commandEvidenceDetailSchema } from "./commandObservation.js";
import { localPathStringSchema } from "./localPath.js";
import { RAW_BINARY_ENTRY_POINT } from "./rawBinary.js";
/** Omitted one-shot objdump/rizin wall clock. Preflight shares it; the command gets it again. */
export const DEFAULT_REVERSE_COMMAND_TIMEOUT_MS = 120_000;
@@ -72,6 +73,15 @@ export const objdumpInputSchema = z.discriminatedUnion("operation", [
}),
]);
// Rizin owns its arch and CPU vocabulary, and the plugins differ between
// builds, so REA checks only the spelling. Rizin sets no length limit.
const rizinNameSchema = z
.string()
.regex(
/^[A-Za-z0-9][A-Za-z0-9_.+\-]*$/u,
"must be a Rizin name: letters, digits, '_', '.', '+' or '-', starting with a letter or digit",
);
/** Caller-selected Rizin command against one local artifact. */
export const rizinInputSchema = z
.object({
@@ -83,6 +93,35 @@ export const rizinInputSchema = z
(value) => !/[\r\n\u0000]/u.test(value),
"A single command line is required",
),
asm_arch: rizinNameSchema
.exactOptional()
.describe(
"Rizin arch plugin, passed as -a, such as arm. Plugins differ between builds; e asm.arch=? lists them.",
),
// Rizin's widest decoders are 64-bit.
asm_bits: z
.number()
.int()
.min(1)
.max(64)
.exactOptional()
.describe(
"Rizin asm.bits, passed as -b, such as 16 for Thumb. Omitted, Rizin's default applies.",
),
asm_cpu: rizinNameSchema
.exactOptional()
.describe(
"Rizin asm.cpu, passed as -e asm.cpu=, such as cortexm; e asm.cpu=? lists those of the selected arch.",
),
base_address: z
.string()
.regex(RAW_BINARY_ENTRY_POINT, {
message: "base_address must be a hexadecimal address of the form 0x…",
})
.exactOptional()
.describe(
"Address of file offset 0. REA passes -n and -m, so Rizin maps the file there as a raw image, reads no format headers, and keeps its default decoder unless asm_arch and asm_bits select one.",
),
...commandTimeoutMsField,
})
.strict();
+27
View File
@@ -7,7 +7,34 @@ export const rizinCommand = (input: RizinInput): readonly string[] => [
"-q",
"-e",
"scr.color=0",
// Rizin 0.9.1 refuses -e asm.arch= and -e asm.bits= at startup. Once the
// file is open it applies -a and -b, then the -e settings.
...(input.asm_arch === undefined ? [] : ["-a", input.asm_arch]),
...(input.asm_bits === undefined ? [] : ["-b", String(input.asm_bits)]),
...(input.asm_cpu === undefined ? [] : ["-e", `asm.cpu=${input.asm_cpu}`]),
// A format loader can claim a raw image. Rizin 0.9.1's AVR loader turns
// io.va off, so reads at the -m map return 0xff, and the BIOS loader moves
// the map. -n skips the loaders, and Rizin maps file offset 0 at the base.
...(input.base_address === undefined ? [] : ["-n", "-m", input.base_address]),
"-c",
input.command,
absoluteCallerPath(input.path),
];
/** What REA cannot attest about the decoder and map inputs it passed to Rizin. */
export const rizinCommandLimitations = (
input: RizinInput,
): readonly string[] => [
...(input.asm_arch === undefined &&
input.asm_bits === undefined &&
input.asm_cpu === undefined
? []
: [
"REA does not confirm that Rizin applied the selected asm_* values. Rizin uses another arch or bits value when it cannot apply one, at most reporting it on stderr, and e asm.cpu echoes any value, so compare it with e asm.cpu=?.",
]),
...(input.base_address === undefined
? []
: [
"With base_address, REA passed -n and -m so that Rizin maps file offset 0 at that address as a raw image; REA does not confirm the map, which oml shows. -n skips Rizin's format loaders, so expect no sections, symbols or entry point, and arch, bits and byte order stay at Rizin's defaults unless the call selects them.",
]),
];
@@ -1,8 +1,10 @@
import { chmod, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../../src/contracts/reverseEngineeringToolContracts.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -51,6 +53,73 @@ cliTest(
},
);
cliTest(
"passes Rizin decoder and map flags before the artifact",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-rizin-load-flags-");
const provider = join(root, "argv-rizin");
const artifact = join(root, "flash.bin");
await writeFile(artifact, "flash");
await writeFile(
provider,
`#!${process.execPath}\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)));\n`,
);
await chmod(provider, 0o755);
const result = await cli.run({
arguments: [
"execute-rizin-command",
artifact,
"oml",
"--asm-arch",
"riscv",
"--asm-bits",
"32",
"--asm-cpu",
"rv32imc",
"--base-address",
"0x42000000",
"--format",
"json",
],
cwd: root,
environment: { REA_RIZIN_COMMAND: provider },
});
expect(result.exitCode, result.stderr).toBe(0);
const rizinContract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find(
(contract) => contract.name === "execute_rizin_command",
);
if (rizinContract === undefined)
throw new Error("execute_rizin_command contract is missing");
const evidence = rizinContract.outputSchema.parse(result.json);
const argv = [
"-N",
"-q",
"-e",
"scr.color=0",
"-a",
"riscv",
"-b",
"32",
"-e",
"asm.cpu=rv32imc",
"-n",
"-m",
"0x42000000",
"-c",
"oml",
artifact,
];
expect(JSON.parse(evidence.normalized_result.stdout)).toEqual(argv);
expect(evidence.parameters).toMatchObject({
asm_arch: "riscv",
asm_bits: 32,
asm_cpu: "rv32imc",
base_address: "0x42000000",
command_arguments: argv,
});
},
);
cliTest(
"reports the rejected Rizin command before inspecting the target",
async ({ cli }) => {
@@ -3,8 +3,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Client } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../../src/contracts/reverseEngineeringToolContracts.js";
import { rizinCommandLimitations } from "../../../src/rizin/RizinCommand.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createProviderEnvironmentMcpHarness } from "../../support/reverseDebuggerMcpTestHelpers.js";
const writeProvider = async (path: string, output: string): Promise<void> => {
@@ -119,3 +123,92 @@ it("snapshots an explicit provider environment when the server is created", asyn
'"captured"',
);
});
it("passes Rizin decoder and map inputs as flags before the artifact", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-rizin-load-mcp-"));
const provider = join(directory, "argv-rizin");
const artifact = join(directory, "flash.bin");
await writeFile(artifact, "flash");
await writeFile(
provider,
`#!${process.execPath}\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)));\n`,
);
await chmod(provider, 0o755);
const { client } = await connectProviderClient({
directory,
clientName: "reverse-rizin-load-inputs",
configuredProvider: provider,
});
const input = {
path: artifact,
command: "e asm.arch; e asm.bits; e asm.cpu; oml",
asm_arch: "arm",
asm_bits: 16,
asm_cpu: "cortexm",
base_address: "0x08000000",
};
const response = await client.callTool({
name: "execute_rizin_command",
arguments: input,
});
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const rizinContract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find(
(contract) => contract.name === "execute_rizin_command",
);
if (rizinContract === undefined)
throw new Error("execute_rizin_command contract is missing");
const evidence = rizinContract.outputSchema.parse(response.structuredContent);
const argv = [
"-N",
"-q",
"-e",
"scr.color=0",
"-a",
"arm",
"-b",
"16",
"-e",
"asm.cpu=cortexm",
"-n",
"-m",
"0x08000000",
"-c",
input.command,
artifact,
];
expect(JSON.parse(evidence.normalized_result.stdout)).toEqual(argv);
expect(evidence.parameters).toMatchObject({
...input,
command_arguments: argv,
});
expect(evidence.limitations).toEqual(
expect.arrayContaining([...rizinCommandLimitations(input)]),
);
// Compact schemas drop property descriptions, so the tool description names
// the input that changes how Rizin loads the file.
const { tools } = await client.listTools();
const listed = tools.find((tool) => tool.name === rizinContract.name);
expect(listed?.description).toBe(rizinContract.description);
expect(listed?.description).toContain("base_address");
const refused = await client.callTool({
name: "execute_rizin_command",
arguments: { ...input, asm_cpu: "cortexm; q" },
});
expect(refused.isError).toBe(true);
expect(parseMcpToolError(refused as CallToolResult).error).toMatchObject({
code: "invalid_request",
details: {
operation: "execute_rizin_command",
issues: [
{
path: ["asm_cpu"],
reason: "invalid_format",
message: expect.stringMatching(/^must be a Rizin name: /u),
},
],
},
});
});
@@ -0,0 +1,259 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../../src/contracts/reverseEngineeringToolContracts.js";
import { rizinCommandLimitations } from "../../../src/rizin/RizinCommand.js";
import { createProviderEnvironmentMcpHarness } from "../../support/reverseDebuggerMcpTestHelpers.js";
// Real-provider gate for execute_rizin_command's decoder and map inputs. It
// runs the Rizin found on PATH and skips without one; put another Rizin's
// bin directory first on PATH to check that version.
const rizin = findRizin();
const banner = rizin === undefined ? "no Rizin on PATH" : versionBanner(rizin);
// Cortex-M vector table head (SP 0x20002000, Reset 0x08000109), then the
// Thumb instructions `bx lr` and `nop`.
const CORTEX_M_IMAGE = Buffer.from([
0x00, 0x20, 0x00, 0x20, 0x09, 0x01, 0x00, 0x08, 0x70, 0x47, 0x00, 0xbf,
]);
// Thumb `msr primask, r0`, which only an M-profile decoder knows.
const MSR_PRIMASK = Buffer.from([0x80, 0xf3, 0x10, 0x88]);
// 6502 `nop; rts`; no host decodes 6502 by default.
const MOS_6502_IMAGE = Buffer.from([0xea, 0x60]);
// Raw images that Rizin's loaders claim unless REA passes -n. Rizin 0.9.1's
// AVR loader (bytes 1 and 3 look like rjmp) turns io.va off, so reads at the
// -m map return 0xff. The BIOS loader (a jmp 16 bytes from the end of a 64 KiB
// file) maps the file at another address in 0.7.4 and 0.9.1.
const AVR_LIKE_IMAGE = Buffer.from(
Array.from({ length: 64 }, (_, index) =>
index === 1 || index === 3 ? 0xc0 : index,
),
);
const BIOS_LIKE_IMAGE = Buffer.alloc(0x10000);
BIOS_LIKE_IMAGE.set([0x11, 0x22, 0x33, 0x44], 0);
BIOS_LIKE_IMAGE[0xfff0] = 0xea;
BIOS_LIKE_IMAGE[0xffff] = 0x5a;
const rizinContract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find(
(contract) => contract.name === "execute_rizin_command",
);
if (rizinContract === undefined)
throw new Error("execute_rizin_command contract is missing");
const hex = (value: number): string => `0x${value.toString(16)}`;
const runRizin = async (
input: Readonly<Record<string, string | number>>,
image: Buffer,
) => {
const directory = await mkdtemp(join(tmpdir(), "rea-rizin-decoder-"));
// The harness owns the directory, so it must exist before setup can fail.
const { client } = await createProviderEnvironmentMcpHarness({
directory,
clientName: "real-rizin-decoder-inputs",
serverOptions: { environment: { REA_RIZIN_COMMAND: rizin! } },
});
const path = join(directory, "image.bin");
await writeFile(path, image);
const toolArguments = { path, ...input };
const response = await client.callTool({
name: "execute_rizin_command",
arguments: toolArguments,
});
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const evidence = rizinContract.outputSchema.parse(response.structuredContent);
expect(evidence.normalized_result.exit_code).toBe(0);
expect(evidence.limitations).toEqual(
expect.arrayContaining([
...rizinCommandLimitations({
...toolArguments,
command: String(input.command),
}),
]),
);
return {
lines: evidence.normalized_result.stdout.trimEnd().split("\n"),
stderr: evidence.normalized_result.stderr,
};
};
const mapsOf = (line: string | undefined) =>
JSON.parse(line ?? "null") as readonly {
readonly fd: number;
readonly delta: number;
readonly from: number;
}[];
it.skipIf(rizin === undefined)(
`applies -a, -b, -e asm.cpu= and -m to a raw Cortex-M image (${banner})`,
async () => {
const { lines } = await runRizin(
{
command:
"e asm.arch; e asm.bits; e asm.cpu; omlj; p8 1 @ 0x07ffffff; p8 12 @ 0x08000000; p8 1 @ 0x0800000c; pi 1 @ 0x08000008",
asm_arch: "arm",
asm_bits: 16,
asm_cpu: "cortexm",
base_address: "0x08000000",
},
CORTEX_M_IMAGE,
);
expect(lines.slice(0, 3)).toEqual(["arm", "16", "cortexm"]);
// One map: file offset 0 at the base address, and nothing mapped below
// it or past its end.
expect(mapsOf(lines[3])).toEqual([
expect.objectContaining({ delta: 0, from: 0x08000000 }),
]);
expect(lines.slice(4)).toEqual([
"ff",
CORTEX_M_IMAGE.toString("hex"),
"ff",
"bx lr",
]);
},
);
it.skipIf(rizin === undefined)(
`applies asm_cpu to the decoder, and echoes one Rizin lacks (${banner})`,
async () => {
const thumb = { asm_arch: "arm", asm_bits: 16, base_address: "0x0" };
const plain = await runRizin({ command: "pi 1", ...thumb }, MSR_PRIMASK);
expect(plain.lines).toEqual(["invalid"]);
const selected = await runRizin(
{ command: "pi 1", ...thumb, asm_cpu: "cortexm" },
MSR_PRIMASK,
);
expect(selected.lines).toEqual(["msr primask, r0"]);
const unknown = await runRizin(
{ command: "e asm.cpu; pi 1", ...thumb, asm_cpu: "rea-no-such-cpu" },
MSR_PRIMASK,
);
expect(unknown.lines).toEqual(["rea-no-such-cpu", "invalid"]);
},
);
it.skipIf(rizin === undefined)(
`states what a call with only base_address leaves unconfirmed (${banner})`,
async () => {
const { lines } = await runRizin(
{ command: "omlj; om-*; omlj", base_address: "0x8000" },
CORTEX_M_IMAGE,
);
expect(mapsOf(lines[0])).toEqual([
expect.objectContaining({ delta: 0, from: 0x8000 }),
]);
// The command removed the map, so the map is not a fact of the result.
expect(mapsOf(lines[1])).toEqual([]);
},
);
it.skipIf(rizin === undefined)(
`reads the file at base_address when a Rizin loader would claim it (${banner})`,
async () => {
const base = 0x08000000;
for (const image of [AVR_LIKE_IMAGE, BIOS_LIKE_IMAGE]) {
const last = base + image.length - 1;
const { lines } = await runRizin(
{
command: `e io.va; omlj; p8 1 @ ${hex(base - 1)}; p8 4 @ ${hex(base)}; p8 1 @ ${hex(last)}; p8 1 @ ${hex(last + 1)}`,
asm_arch: "arm",
asm_bits: 16,
base_address: hex(base),
},
image,
);
expect(lines[0]).toBe("true");
expect(mapsOf(lines[1])).toEqual([
expect.objectContaining({ delta: 0, from: base }),
]);
expect(lines.slice(2)).toEqual([
"ff",
image.subarray(0, 4).toString("hex"),
image.subarray(-1).toString("hex"),
"ff",
]);
}
},
);
it.skipIf(rizin === undefined)(
`selects an arch that differs from the host default (${banner})`,
async () => {
const plain = await runRizin(
{ command: "e asm.arch; e asm.bits; omlj" },
MOS_6502_IMAGE,
);
expect(plain.lines[0]).not.toBe("6502");
expect(mapsOf(plain.lines[2])).toEqual([
expect.objectContaining({ delta: 0, from: 0 }),
]);
const selected = await runRizin(
{
command: "e asm.arch; e asm.bits; omlj; pi 2 @ 0x8000",
asm_arch: "6502",
asm_bits: 8,
base_address: "0x8000",
},
MOS_6502_IMAGE,
);
expect(selected.lines.slice(0, 2)).toEqual(["6502", "8"]);
expect(mapsOf(selected.lines[2])).toEqual([
expect.objectContaining({ delta: 0, from: 0x8000 }),
]);
expect(selected.lines.slice(3)).toEqual(["nop", "rts"]);
},
);
it.skipIf(rizin === undefined)(
`does not apply an arch or bits value that Rizin lacks (${banner})`,
async () => {
const plain = await runRizin({ command: "e asm.arch" }, MOS_6502_IMAGE);
const unknown = await runRizin(
{
command: "e asm.arch; e asm.bits",
asm_arch: "rea-no-such-arch",
asm_bits: 16,
},
MOS_6502_IMAGE,
);
// Rizin exits 0, decodes with its default arch and names the value only
// on stderr. Evidence carries the limitation for this case.
expect(unknown.lines[0]).toBe(plain.lines[0]);
expect(unknown.stderr).toContain("rea-no-such-arch");
// ARM has no 8-bit mode. Rizin 0.9.1 keeps its default and reports the
// value on stderr; Rizin 0.7.4 uses 16 without a message.
const unsupported = await runRizin(
{ command: "e asm.bits", asm_arch: "arm", asm_bits: 8 },
CORTEX_M_IMAGE,
);
expect(unsupported.lines[0]).not.toBe("8");
},
);
function findRizin(): string | undefined {
try {
const path = execFileSync("which", ["rizin"], { encoding: "utf8" }).trim();
return path === "" ? undefined : path;
} catch {
return undefined;
}
}
function versionBanner(binary: string): string {
try {
return (
execFileSync(binary, ["-v"], { encoding: "utf8" }).split("\n")[0] ??
"unknown Rizin"
).trim();
} catch {
return "unknown Rizin";
}
}
@@ -17,6 +17,7 @@ import { createReverseEngineeringService } from "../../src/composition/reverseEn
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js";
import { rizinCommandLimitations } from "../../src/rizin/RizinCommand.js";
it("documents the shared combined capture limit for both command tools", () => {
expect(REVERSE_ENGINEERING_TOOL_CONTRACTS.map(({ name }) => name)).toEqual(
@@ -494,6 +495,153 @@ describe("Rizin command service", () => {
});
});
it("passes decoder and map inputs to Rizin before the path and records them", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-rizin-load-"));
const path = join(directory, "flash.bin");
await writeFile(path, Buffer.from([0x70, 0x47]));
const calls: (readonly string[])[] = [];
const service = new ReverseEngineeringService({
environment: { REA_RIZIN_COMMAND: "/tools/rizin" },
run: async (_command, args) => {
calls.push(args);
return { stdout: "bx lr\n", stderr: "" };
},
});
const input = {
path,
command: "pi 1",
asm_arch: "arm",
asm_bits: 16,
asm_cpu: "cortexm",
base_address: "0x08000000",
};
try {
const result = await service.executeRizinCommand(input);
if (!result.ok) throw result.error;
const argv = [
"-N",
"-q",
"-e",
"scr.color=0",
"-a",
"arm",
"-b",
"16",
"-e",
"asm.cpu=cortexm",
"-n",
"-m",
"0x08000000",
"-c",
"pi 1",
path,
];
// The command is the last launch; a provider probe may precede it.
expect(calls.at(-1)).toEqual(argv);
expect(result.value.parameters).toMatchObject({
...input,
command_arguments: argv,
});
expect(result.value.limitations).toEqual(
expect.arrayContaining([...rizinCommandLimitations(input)]),
);
// One statement for the decoder inputs and one for the raw map.
expect(rizinCommandLimitations(input)).toHaveLength(2);
expect(
rizinCommandLimitations({ path, command: "pi 1", asm_bits: 16 }),
).toHaveLength(1);
expect(
rizinCommandLimitations({ path, command: "pi 1", base_address: "0x0" }),
).toHaveLength(1);
expect(rizinCommandLimitations({ path, command: "pi 1" })).toEqual([]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("checks only the spelling and range of decoder and map inputs", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-rizin-load-input-"));
const path = join(directory, "image.bin");
await writeFile(path, "image");
const calls: (readonly string[])[] = [];
const service = new ReverseEngineeringService({
run: async (_command, args) => {
calls.push(args);
return { stdout: "", stderr: "" };
},
});
try {
// Rizin's own names: digits first, dotted plugins, '+', '-' and case.
for (const [selection, flags] of [
[{ asm_arch: "6502", asm_bits: 8 }, ["-a", "6502", "-b", "8"]],
[{ asm_arch: "x86.nasm", asm_bits: 64 }, ["-a", "x86.nasm", "-b", "64"]],
[{ asm_cpu: "c55x+" }, ["-e", "asm.cpu=c55x+"]],
[{ asm_cpu: "c166-generic" }, ["-e", "asm.cpu=c166-generic"]],
[{ asm_cpu: "ATxmega128a4u" }, ["-e", "asm.cpu=ATxmega128a4u"]],
[{ asm_bits: 1 }, ["-b", "1"]],
[{ base_address: "0x0" }, ["-n", "-m", "0x0"]],
[
{ base_address: "0xFFFFFFFFFFFFFFFF" },
["-n", "-m", "0xFFFFFFFFFFFFFFFF"],
],
] as const) {
const result = await service.executeRizinCommand({
path,
command: "e asm.arch",
...selection,
});
if (!result.ok) throw result.error;
expect(result.value.parameters.command_arguments).toEqual([
"-N",
"-q",
"-e",
"scr.color=0",
...flags,
"-c",
"e asm.arch",
path,
]);
}
calls.length = 0;
for (const [field, value] of [
["asm_arch", ""],
["asm_arch", "arm;q"],
["asm_arch", "arm x"],
["asm_arch", "-q"],
["asm_cpu", "?"],
["asm_cpu", "cortex=m"],
["asm_bits", 0],
["asm_bits", 65],
["asm_bits", 16.5],
["asm_bits", "16"],
["base_address", "0x"],
["base_address", "08000000"],
["base_address", "0X08000000"],
["base_address", "0x10000000000000000"],
] as const) {
const result = await service.executeRizinCommand({
path,
command: "e asm.arch",
[field]: value,
});
if (result.ok) throw new Error(`${field}=${String(value)} was accepted`);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "execute_rizin_command",
issues: [expect.objectContaining({ path: [field] })],
},
});
}
expect(calls).toEqual([]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("retains partial Evidence when a provider command times out", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-rizin-timeout-partial-"));
const path = join(directory, "fixture.bin");
+1
View File
@@ -18,6 +18,7 @@
"tests/boundary/providers/RizinTimeoutEvidence.test.ts",
"tests/boundary/mcp/rizinPendingOutput.test.ts",
"tests/boundary/providers/RizinInferiorTty.test.ts",
"tests/boundary/providers/RizinDecoderInputs.test.ts",
"tests/boundary/providers/RizinTestInferiorTty.ts",
"tests/boundary/providers/RizinCommandTimeout.test.ts",
"tests/boundary/providers/CutterBridgeClient.test.ts",
+1
View File
@@ -24,6 +24,7 @@ export default defineConfig({
"tests/boundary/providers/GdbSessionManagers.test.ts",
"tests/boundary/providers/RizinDebugSessionManagers.test.ts",
"tests/boundary/providers/RizinInferiorTty.test.ts",
"tests/boundary/providers/RizinDecoderInputs.test.ts",
"tests/boundary/providers/GdbSessionManagerCoverage.test.ts",
"tests/boundary/providers/GdbCommandQueue.test.ts",
"tests/boundary/providers/GdbSessionReviewRegressions.test.ts",