Compare commits

..
Author SHA1 Message Date
morluto ae2e049035 Merge remote-tracking branch 'origin/main' into test/shared-stdio-mcp-client
# Conflicts:
#	tests/boundary/mcp/javascriptEndpointCandidate.test.ts
2026-11-13 17:52:10 -05:00
morluto c688ae13d8 Merge branch 'main' into test/shared-stdio-mcp-client 2026-11-14 04:24:34 +08:00
morluto 6d6ac533da test(mcp): keep failed file-scoped MCP cleanup owned
The three module-level clients removed every cleanup callback before
running them, so a failed cleanup lost its only handle. Add
fileLocalToolsMcp, which connects before the file's tests and removes
each cleanup only after it succeeds, and use it in those files.
2026-11-13 15:14:44 -05:00
morluto 7590ecca9f test(mcp): open stdio clients through the shared local-tools fixture
Twelve files built their own StdioClientTransport and Client for
`rea mcp`. Those clients never listed tools, so the SDK did not
validate structured results against advertised output schemas, and each
file repeated transport setup, stderr draining and close ordering.

Use connectLocalToolsMcp, which lists tools, validates replies,
silences logging, leaves deep providers unconfigured and owns cleanup.
Per-test clients register test-scoped cleanup; module-level clients
collect cleanup for afterAll.
2026-11-13 14:36:24 -05:00
51 changed files with 848 additions and 2052 deletions

No files matched your search

+25 -4
View File
@@ -1091,11 +1091,32 @@ existing four-minute timing collection plus the bounded diagnostic stages.
Fast built shards target under ten minutes of uncontended shard-job execution.
That figure is the `test-shard` job. The job waits for classification and the
build, and the portable-test merge runs after the slowest shard. Runner queue is
separate and grows when more shard jobs compete for runners. The npm download
cache stays the setup cache rather than a shared `node_modules` artifact.
build, and the portable-test merge runs after the slowest shard. On 2026-10-20,
run `39451548945` measured those surrounding jobs with runner queue excluded:
classification 15 seconds, build 49 seconds, and the portable-test merge 29
seconds. Shard 1/5 of that run spent 22 seconds before built Vitest and 3
seconds after it, including `npm ci` at 9 seconds. `node_modules` is about 674
MiB, so publishing it as a shared artifact would transfer more data than that
install saves. The npm download cache stays the setup cache.
File timings from that run put the built suite at 7944 seconds of module time.
The sequencer keeps each file's full duration on one worker. Extra workers
overlap other files in the same sequence group, and `process-boundary` runs
after that group. Scheduling the unsplit inventory onto five shards peaks at
820 seconds of tests. That run's slowest built shard job lasted 925 seconds
(shard 4/5), and shard 1/5 spent 874 seconds in built Vitest, so hosted job
time sits above the file schedule. Six shards of the same inventory peak at
684 seconds of tests. Eight shards, after the 578-second loader file is split
by case count (54 cases stay in that file and 46 move into each of three
files), peak at 513 seconds of tests. With the measured 25-second shard setup,
that shard job is about 538 seconds. Adding the measured classification, build,
and merge makes an uncontended portable critical path of about 631 seconds,
still before the gap between this file schedule and hosted Vitest. Runner queue
is separate and grows when more shard jobs compete for runners.
`process-boundary` stays serial because process-tree sampling shares the host.
Hosted CI jobs use four workers; other hosts stay at two
Raising the worker cap from two to four on this four-core host cut a six-file
acceptance sample from 96–101 seconds to 71 seconds and did not remove the
single-file floor. Hosted CI jobs use four workers; other hosts stay at two
because they are shared with other checks. Full qualification keeps six covered
shards; its longer instrumentation is outside this fast budget.
-6
View File
@@ -96,10 +96,6 @@ Persistent command Evidence retains the caller-selected and resolved startup pat
Session status retains up to 64 recent protocol frames. Each history entry keeps a complete-character UTF-8 prefix of at most 16 KiB, plus a truncation marker when needed; `recent_output_truncated` reports omitted history. This history budget also accounts for replacement characters from invalid UTF-8. It does not shorten command Evidence, whose output budget defaults to 16 MiB. Rizin debug startup waits 30000 ms with no runtime override. Each command frame defaults to 120000 ms; its deadline, command-output budget, session capacity and pending-command queue use the [shared debugger launch settings](#debugger-launch-settings). The 16777216-byte frame cap and 4096-frame command cap remain fixed. Evidence names the cap that stopped the command. The one-shot `command_timeout_ms` setting does not change a debug session.
### Stopped debugger sessions
A Rizin or GDB session stops accepting commands when REA stops it, for example after an output or frame limit, a command deadline, cancellation or a protocol failure, or when the debugger process exits. REA keeps the stopped session and the first reason it observed until `close_rizin_debug_session` or `close_gdb_session` releases it, including after a retried cleanup succeeds. `rizin_debug_session_status` and `gdb_session_status` then report `state: "closed"` with that reason in `closed_reason`. A later `rizin_debug_command` or `gdb_console` on the session fails with `capability_unavailable`; `details.reason` carries the same reason, and the remediation is to close the session and start a new one. A `session_id` that this server never returned, or that close already released, fails with `invalid_request` instead.
### Debugger launch settings
GDB and Rizin share these settings in the environment that launches REA:
@@ -140,8 +136,6 @@ rea debug-with-rizin --inferior-tty /dev/pts/7 ./program dr
rea execute-rizin-command ./program iI
```
Rizin exits with status 0 even when its shell refuses a statement, so the exit code does not show whether the command ran. Rizin 0.6 through 0.9.1 log one of four stderr lines for such a refusal: `ERROR: Command '…' does not exist.`, ``ERROR: Wrong number of arguments passed to `…`…``, `ERROR: core: Error while executing command: …` (an invalid statement), or `ERROR: core: Error while parsing command: …`. Rizin then skips the refused statement and every later one in the command. When the completed process's stderr contains one of these lines, REA reports `completion_status: "rejected"` and adds a limitation naming the refusal. stdout then holds only the output of earlier statements, so empty stdout is not an empty result. Other `ERROR:` lines, such as `ERROR: Cannot find function at …`, come from commands that ran; REA keeps them in stderr and reports `complete`. A command that lowers `log.level` or redirects stderr hides refusals from REA.
Captured stdout and stderr use the same `max_output_bytes` (CLI: `--max-output-bytes`) admission and recovery described above, default 16 MiB. One-shot commands independently use `command_timeout_ms`. Raising output admission cannot recover an earlier capture; command effects and cleanup must be checked before another invocation.
### Decoder and map inputs
+8 -2
View File
@@ -842,8 +842,14 @@ branches are not producers. A tier with no qualifying artifact leaves history
unset, and Vitest uses its own assignment. A later schedule that reuses
qualification without uploading timings leaves the previous artifact in place.
Each tier keeps its own history because stale weights from another tier, or
from before a large test change, unbalance the shards.
On 2026-10-21 the newest full history was run
`39545047294`, recorded before compiled-CLI session reuse. Fast run
`39552964873` packed its eight built shards from that history, and those shard
jobs executed for 319–591 seconds. Replaying that run's file durations through
the sequencer's two-worker schedule balances every shard at about 404 seconds
of file time. The same replay with the stale history weights leaves the slowest
shard near 547 seconds and the fastest near 293. That spread is file-schedule
time, not runner queue, which is why each tier keeps its own history.
CI assigns discovered files by historical duration rather than dividing only
by file count. A file with no recorded duration, such as a newly added or
renamed test, is weighted at the median recorded duration until a published
@@ -11,11 +11,6 @@ export interface ReverseEngineeringProviderCommand {
readonly identity: EvidenceProvider;
/** What the provider cannot attest about the arguments it built. */
readonly limitations?: readonly string[];
/**
* Explain a provider refusal of the command that a completed process
* reported only in its diagnostics, or return undefined.
*/
readonly rejection?: (stderr: string) => string | undefined;
readonly environmentOverrides?: Readonly<NodeJS.ProcessEnv>;
}
@@ -599,7 +599,7 @@ export class ReverseEngineeringService {
let stderrInvalidUtf8 = false;
let cleanupIncomplete = false;
let cleanupOnlyFailure = false;
let completionStatus: "complete" | "rejected" | "unknown" = "complete";
let completionStatus: "complete" | "unknown" = "complete";
let outputLimitReached = false;
let failure: AnalysisError | undefined;
let cleanupOwner: ProviderProcessSupervisor | undefined;
@@ -1076,15 +1076,6 @@ export class ReverseEngineeringService {
: digestAfter !== digest;
const outputBudgetSource =
parameters.max_output_bytes === undefined ? "default" : "request";
// Only a completed process's diagnostics can establish a refusal; partial
// or failed captures keep their unknown completion.
const rejection =
completionStatus === "complete" &&
(failure === undefined ||
(failure instanceof AnalysisOutputError && failure.cleanupOnly))
? profile.rejection?.(stderr)
: undefined;
if (rejection !== undefined) completionStatus = "rejected";
const result = {
stdout,
stderr,
@@ -1125,7 +1116,6 @@ export class ReverseEngineeringService {
]
: []),
...(profile.limitations ?? []),
...(rejection === undefined ? [] : [rejection]),
...(stdoutInvalidUtf8 || stderrInvalidUtf8
? [
`Captured ${[
+7 -7
View File
@@ -22,7 +22,7 @@ import { parseAnalysisInput } from "../application/AnalysisInput.js";
import { androidJniSelectionSchema } from "../domain/android/androidJniBindings.js";
import { androidJniDeliverySchema } from "../domain/android/androidJniQuery.js";
import { androidInputSchemas } from "../domain/android/androidAnalysis.js";
import { cliDecimalField } from "./options.js";
import { cliIntegerArgument } from "./options.js";
/** Expose the same explicit APK requests through one-shot CLI commands. */
export const registerAndroidCommands = (
@@ -223,11 +223,11 @@ export const registerAndroidCommands = (
),
}),
options: z.object({
overloadIndex: cliDecimalField(
androidInputSchemas.inspect_android_method.shape.overload_index.describe(
overloadIndex: cliIntegerArgument(0)
.optional()
.describe(
"Explicit zero-based index among same-name methods; required when ambiguous",
),
),
mappingPath,
...analysisEvidenceDeliveryOptions,
}),
@@ -255,11 +255,11 @@ export const registerAndroidCommands = (
options: z.object({
methodName:
androidInputSchemas.trace_android_references.shape.method_name,
overloadIndex: cliDecimalField(
androidInputSchemas.trace_android_references.shape.overload_index.describe(
overloadIndex: cliIntegerArgument(0)
.optional()
.describe(
"Index among defined methods or observed external prototypes listed by an ambiguous query",
),
),
}),
run: ({ args, options }) =>
execute(
+3 -6
View File
@@ -8,8 +8,7 @@ import { logCliCommand } from "../cliLogging.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
import { cliDecimalField } from "./options.js";
import { cutterCommandInputSchema } from "../contracts/cutterToolContracts.js";
import { cliIntegerArgument } from "./options.js";
/** Register commands that control an already-running Cutter instance. */
export const registerCutterCommands = (
@@ -44,10 +43,8 @@ export const registerCutterCommands = (
.string()
.uuid()
.describe("Session ID copied from list-cutter-sessions."),
expectedGeneration: cliDecimalField(
cutterCommandInputSchema.shape.expected_generation.describe(
"Session generation returned by list-cutter-sessions.",
),
expectedGeneration: cliIntegerArgument(0).describe(
"Session generation returned by list-cutter-sessions.",
),
command: z
.string()
+6 -8
View File
@@ -38,7 +38,7 @@ import { inspectWebNetworkCaptureInputSchema } from "../domain/webNetworkCapture
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import type { CliInstance } from "./types.js";
import { cliDecimalField, publishArgvInput } from "./options.js";
import { cliDecimalField } from "./options.js";
/** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */
const cliError = (error: AnalysisError): JsonValue => ({
@@ -689,13 +689,11 @@ export const registerEnvironmentCommands = (
host: mcpHttpHostSchema
.optional()
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
port: publishArgvInput(
mcpHttpPortSchema
.optional()
.describe(
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
),
),
port: mcpHttpPortSchema
.optional()
.describe(
"Listener port; default MCP_PORT, else 0 for an ephemeral port",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
+11 -6
View File
@@ -24,6 +24,13 @@ type DecimalWrapper =
readonly kind: "optional" | "exactOptional" | "nullable" | "readonly";
};
/** Parse decimal integer tokens before Number coercion can erase their syntax. */
export const cliIntegerArgument = (min: number) =>
z
.string()
.regex(DECIMAL_INTEGER_TOKEN, "Expected a decimal integer")
.pipe(z.coerce.number<string>().safe().min(min));
const decimalToken = (domain: z.ZodNumber) =>
z
.string()
@@ -35,12 +42,10 @@ const unwrapSchema = (field: { unwrap: () => unknown }): z.ZodType =>
field.unwrap() as z.ZodType;
/**
* Incur advertises Zod's output view. Publish the input view so an argv token
* grammar that transforms text survives `--schema` and other SDK conversions.
* Incur advertises Zod's output view. Publish the input view so the decimal
* token guard survives `--schema` and other SDK conversions.
*/
export const publishArgvInput = <Field extends z.ZodType>(
field: Field,
): Field => {
const publishDecimalInput = (field: z.ZodType): z.ZodType => {
const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(field, {
io: "input",
});
@@ -144,7 +149,7 @@ export const cliDecimalField = <Field extends z.ZodType>(
numeric.description === undefined
? admitted
: admitted.describe(numeric.description);
return publishArgvInput(described) as z.ZodType<z.output<Field>>;
return publishDecimalInput(described) as z.ZodType<z.output<Field>>;
};
/** Adapt each numeric property of a shared shape; other properties stay unchanged. */
+1 -1
View File
@@ -80,7 +80,7 @@ const cleanupOnlyCompleteEvidence = (
return typeof normalized === "object" &&
normalized !== null &&
!Array.isArray(normalized) &&
normalized.completion_status !== "unknown"
normalized.completion_status === "complete"
? parsed.data
: undefined;
};
+14 -28
View File
@@ -1,6 +1,6 @@
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
import { z } from "incur";
import { cliDecimalField } from "./options.js";
import { cliDecimalField, cliIntegerArgument } from "./options.js";
import type { DirectAnalysis } from "../composition/directAnalysis.js";
import { createHistoricalSourceSummary } from "../domain/referenceSourceSummary.js";
@@ -21,10 +21,7 @@ import {
} from "../domain/analysisErrorCore.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
nativeUiObservationInputSchema,
nativeUiScenarioInputSchema,
} from "../domain/native/nativeUiObservation.js";
import { nativeUiScenarioInputSchema } from "../domain/native/nativeUiObservation.js";
import {
nativeCommandTimeoutField,
nativeUiDeadlineField,
@@ -52,16 +49,11 @@ export const registerUtilityCommands = (
registerReferenceSourceCommand(cli, logger, environment);
};
// Decimal tokens only; shared fields own their bounds and defaults.
const nativeUiScopeFields = nativeUiObservationInputSchema.shape;
// Decimal tokens only; shared fields own their bounds and help.
const nativeUiScopeOptions = {
pid: cliDecimalField(
nativeUiScopeFields.pid.describe("Existing application PID"),
),
windowId: cliDecimalField(
nativeUiScopeFields.window_id.describe(
"Exact selected application window ID",
),
pid: cliIntegerArgument(1).describe("Existing application PID"),
windowId: cliIntegerArgument(1).describe(
"Exact selected application window ID",
),
screenshot: z
.boolean()
@@ -71,11 +63,9 @@ const nativeUiScopeOptions = {
.boolean()
.default(true)
.describe("Read the selected window accessibility tree"),
maxNodes: cliDecimalField(
nativeUiScopeFields.max_nodes.describe(
"Maximum accessibility nodes per capture",
),
),
maxNodes: cliIntegerArgument(1)
.default(500)
.describe("Maximum accessibility nodes per capture"),
nativeUiDeadlineMs: cliDecimalField(nativeUiDeadlineField),
};
@@ -374,15 +364,11 @@ const registerReferenceSourceCommand = (
.describe(
"Complete committed graph or compact historical-source summary",
),
outlineDepth: cliDecimalField(
z
.int()
.min(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
),
outlineDepth: cliIntegerArgument(0)
.optional()
.describe(
"Summary outline path depth (default: 2; 0 omits entries); requires --detail summary",
),
})
.superRefine((options, context) => {
if (options.outlineDepth !== undefined && options.detail !== "summary")
+3 -5
View File
@@ -6,7 +6,7 @@ import { logCliCommand } from "../cliLogging.js";
import { webModuleTraceInputSchema } from "../domain/webModuleTrace.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliDecimalField } from "./options.js";
import { cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
@@ -25,10 +25,8 @@ export const registerWebModuleCommands = (
"Trace one captured script's native imports to URLs and captured source candidates",
args: z.object({
manifestPath: webModuleTraceInputSchema.shape.manifest_path,
scriptIndex: cliDecimalField(
webModuleTraceInputSchema.shape.script_index.describe(
"Zero-based manifest script index",
),
scriptIndex: cliIntegerArgument(0).describe(
"Zero-based manifest script index",
),
}),
options: z.object({
+2 -5
View File
@@ -8,7 +8,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliDecimalField } from "./options.js";
import { cliDecimalField, cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import {
analysisEvidenceDeliveryOptions,
@@ -18,9 +18,6 @@ import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { explicitSensitiveValueOptions } from "./explicitSensitiveArguments.js";
const recordOrdinal =
inspectWebNetworkCaptureInputSchema.shape.record_ordinals.unwrap().element;
/** Expose offline historical capture inspection with the same selection contract as MCP. */
export const registerWebNetworkCaptureCommands = (
cli: CliInstance,
@@ -54,7 +51,7 @@ export const registerWebNetworkCaptureCommands = (
.optional()
.describe("Fully qualified message type in the descriptor set"),
record: z
.array(cliDecimalField(recordOrdinal))
.array(cliIntegerArgument(0))
.optional()
.describe("Zero-based producer ordinals; repeat to select several"),
...explicitSensitiveValueOptions,
+6 -13
View File
@@ -16,11 +16,10 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliDecimalField } from "./options.js";
import { cliDecimalField, cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
const sourceMapSelection = webSourceLocationInputSchema.shape.source_map;
const generatedPosition = webSourceLocationInputSchema.shape.generated_position;
/** Expose the same explicit selected map/point contract through the CLI. */
export const registerWebSourceLocationCommands = (
@@ -34,20 +33,14 @@ export const registerWebSourceLocationCommands = (
"Trace one retained script position through an explicitly selected local source map",
args: z.object({
manifestPath: webSourceLocationInputSchema.shape.manifest_path,
scriptIndex: cliDecimalField(
webSourceLocationInputSchema.shape.script_index.describe(
"Zero-based manifest script index",
),
scriptIndex: cliIntegerArgument(0).describe(
"Zero-based manifest script index",
),
sourceMapPath: sourceMapSelection.shape.path,
sourceMapUrl: sourceMapSelection.shape.url,
line: cliDecimalField(
generatedPosition.shape.line.describe("One-based generated line"),
),
column: cliDecimalField(
generatedPosition.shape.column.describe(
"Zero-based UTF-16 generated column",
),
line: cliIntegerArgument(1).describe("One-based generated line"),
column: cliIntegerArgument(0).describe(
"Zero-based UTF-16 generated column",
),
}),
options: z.object({
-2
View File
@@ -6,7 +6,6 @@ import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js
import {
rizinCommand,
rizinCommandLimitations,
rizinCommandRejection,
} from "../rizin/RizinCommand.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js";
@@ -33,7 +32,6 @@ export const createReverseEngineeringCommandPort = (
),
arguments: rizinCommand(input),
limitations: rizinCommandLimitations(input),
rejection: rizinCommandRejection,
identity: RIZIN_PROVIDER_IDENTITY,
}),
});
+1 -7
View File
@@ -63,7 +63,7 @@ export const GDB_TOOL_CONTRACTS = [
...toolContractMetadata("gdb_session_status"),
kind: "native-provider",
description:
"Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; closed_reason says why a closed session stopped, and truncation and invalid-UTF-8 fields report discarded or lossy data.",
"Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; truncation and invalid-UTF-8 fields report discarded or lossy data.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: z.strictObject({
session_id: sessionId,
@@ -79,12 +79,6 @@ export const GDB_TOOL_CONTRACTS = [
diagnostics_truncated: z.boolean(),
inferior_tty: z.string().nullable(),
inferior_tty_verified: z.boolean(),
closed_reason: z
.string()
.exactOptional()
.describe(
"Present when state is closed: why the session stopped accepting commands. Close it and start a new session to continue.",
),
}),
examples: [
{
@@ -39,7 +39,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
...toolContractMetadata("execute_rizin_command"),
kind: "native-provider",
description:
"Run one caller-selected Rizin command on a local artifact. Commands/plugins can execute code, mutate files, access network and read outside the artifact; those external inputs are not Evidence-bound. Suppresses user settings/scripts but retains plugins. Raw stdout/stderr is authoritative, JSON is command-specific, and UTF-8 loss/truncation is explicit; output-limit termination leaves completion unknown. Rizin exits 0 even when its shell refuses a statement; completion_status rejected marks that refusal, and later statements did not run. Other command failures can still exit 0, so inspect stderr before treating empty output as absence. For raw images, asm_arch/asm_bits/asm_cpu select decoding and base_address maps offset 0; base_address also passes -n to skip loaders even on ELF, without REA confirming either. An admitted command retires shared binary analysis: close Ghidra explicitly first, then open_binary after effects settle. command_timeout_ms is per-phase and max_output_bytes bounds combined output (16 MiB default); both apply next call without restart and do not raise client receive limits or persistent debugger deadlines.",
"Run one caller-selected Rizin command on a local artifact. Commands/plugins can execute code, mutate files, access network and read outside the artifact; those external inputs are not Evidence-bound. Suppresses user settings/scripts but retains plugins. completion_status reports process completion; Rizin can reject a command and exit with code 0. Inspect stdout/stderr before treating empty output as absence. Raw stdout/stderr is authoritative, JSON is command-specific, and UTF-8 loss/truncation is explicit; output-limit termination leaves completion unknown. For raw images, asm_arch/asm_bits/asm_cpu select decoding and base_address maps offset 0; base_address also passes -n to skip loaders even on ELF, without REA confirming either. An admitted command retires shared binary analysis: close Ghidra explicitly first, then open_binary after effects settle. command_timeout_ms is per-phase and max_output_bytes bounds combined output (16 MiB default); both apply next call without restart and do not raise client receive limits or persistent debugger deadlines.",
inputSchema: rizinInputSchema,
outputSchema: evidenceResultOf(commandObservationSchema),
examples: [
+1 -7
View File
@@ -72,7 +72,7 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [
...toolContractMetadata("rizin_debug_session_status"),
kind: "native-provider",
description:
"Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; closed_reason says why a closed session stopped, truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.",
"Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.",
inputSchema: z.strictObject({ session_id: sessionId }),
outputSchema: z.strictObject({
session_id: sessionId,
@@ -82,12 +82,6 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [
recent_output_truncated: z.boolean(),
diagnostics_truncated: z.boolean(),
output_invalid_utf8: z.boolean(),
closed_reason: z
.string()
.exactOptional()
.describe(
"Present when state is closed: why the session stopped accepting commands. Close it and start a new session to continue.",
),
}),
examples: [
{
+1 -5
View File
@@ -9,11 +9,7 @@ export const commandObservationSchema = z.strictObject({
output_budget_bytes: z.int().positive().exactOptional(),
output_budget_source: z.enum(["default", "request"]).exactOptional(),
output_truncated: z.boolean(),
completion_status: z
.enum(["complete", "rejected", "unknown"])
.describe(
"complete: the provider process completed. rejected: it completed, but the provider reported that it refused the command; limitations say what ran. unknown: the capture or process ended early.",
),
completion_status: z.enum(["complete", "unknown"]),
artifact_sha256_after: z
.string()
.regex(/^[a-f0-9]{64}$/u)
-58
View File
@@ -1,58 +0,0 @@
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "./analysisErrorCore.js";
/** Tool names and wording for one persistent debugger's session errors. */
export interface DebuggerSessionTools {
readonly providerId: string;
readonly name: string;
readonly start: string;
readonly close: string;
}
/** A session_id this server does not hold, unlike a retained stopped session. */
export const unknownDebuggerSessionError = (
tools: DebuggerSessionTools,
operation: string,
): AnalysisInputError =>
new AnalysisInputError(
operation,
{
conformsToInputContract: true,
userMessage: `No ${tools.name} debug session has this session_id in this REA server.`,
remediationAction: `Use a session_id that ${tools.start} returned in this server process; ${tools.close} releases a session's ID. Call ${tools.start} to begin a new session.`,
},
[
{
path: ["session_id"],
reason: "invalid_value",
message: `No ${tools.name} debug session has this session_id in this REA server.`,
},
],
);
/** Refuse a command on a retained session that stopped, naming why it stopped. */
export const stoppedDebuggerSessionError = (
tools: DebuggerSessionTools,
operation: string,
reason: string,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(tools.providerId, operation, reason, {
userMessage: `The ${tools.name} debug session stopped and accepts no more commands. ${reason}`,
remediationAction: `Call ${tools.close} with this session_id to release the stopped session, then call ${tools.start} to begin a new one. Earlier commands may have changed the target, so confirm their effects before repeating them.`,
});
export const RIZIN_DEBUG_SESSION_TOOLS = {
providerId: "rizin",
name: "Rizin",
start: "start_rizin_debug_session",
close: "close_rizin_debug_session",
} as const satisfies DebuggerSessionTools;
export const GDB_SESSION_TOOLS = {
providerId: "gdb",
name: "GDB",
start: "start_gdb_session",
close: "close_gdb_session",
} as const satisfies DebuggerSessionTools;
@@ -961,15 +961,22 @@ const createGlobalPathRead = (
};
byNamespace.set(namespace, effects);
}
const escapePrecedesRead = effects.escapes.some(({ node }) => {
// Selecting this callee precedes exposing its own receiver. Other reads,
// including arguments, need activation-aware ordering across repetitions.
// The call that escapes a receiver contains the callee being identified.
// That escape cannot have replaced the callee before the call selects it.
const escapePrecedesRead = effects.escapes.some((effect) => {
const escape = effect.node;
if (
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
node.callee === site
escape !== undefined &&
escape !== site &&
typeof escape.start === "number" &&
typeof escape.end === "number" &&
typeof site.start === "number" &&
typeof site.end === "number" &&
site.start >= escape.start &&
site.end <= escape.end
)
return false;
return semanticEffectTiming(site, node, state) !== "after";
return semanticEffectTiming(site, escape, state) !== "after";
});
if (
(escapesCovered && effects.escapes.length === 0) ||
@@ -1,67 +0,0 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
const spawns = (source: string) =>
analyzeJavaScriptSemantics(source).childProcessSpawns;
it.each(["spawn", "fork"])("rejects function-valued %s options", (method) => {
expect(
spawns(`
import { ${method} } from "node:child_process";
${method}("worker.js", () => {});
${method}("worker.js", [], function options() {});
${method}("worker.js", [], class Options {});
`),
).toEqual([]);
});
it("rejects a literal exec callback after explicit options", () => {
expect(
spawns(`
import { exec } from "node:child_process";
exec("node --version", {}, false);
exec("node --version", null, {});
`),
).toEqual([]);
});
it("selects execFile callbacks with and without an argv array", () => {
expect(
spawns(`
import { execFile } from "node:child_process";
execFile("node", {}, { cwd: "." });
execFile("node", [], {}, false);
execFile("node", null, {}, "callback");
`),
).toEqual([]);
});
it("keeps callback overloads and nullish callbacks", () => {
expect(
spawns(`
import { exec, execFile } from "node:child_process";
exec("node --version", () => {}, false);
exec("node --version", {}, null);
execFile("node", () => {}, { cwd: false });
execFile("node", [], () => {}, false);
execFile("node", {}, null);
execFile("node", [], {}, undefined);
`),
).toHaveLength(6);
});
it("leaves uncertain callback slots and spread positions undecided", () => {
expect(
spawns(`
import { exec, execFile, spawn } from "node:child_process";
function run(options, callback, args) {
exec("node --version", options, false);
exec("node --version", {}, callback);
execFile("node", options, false);
execFile("node", [], options, false);
execFile("node", ...args, {}, false);
spawn("node", ...args, () => {});
}
`),
).toHaveLength(6);
});
@@ -1,189 +0,0 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
const operations = (source: string) =>
analyzeJavaScriptSemantics(source).timerOperations;
it.each(["setTimeout", "setInterval"])(
"does not create a %s handle when delay conversion must throw",
(method) => {
const result = operations(`
import { ${method}, clearTimeout } from "node:timers";
const positive = ${method}(() => {}, 1n);
const negative = ${method}(() => {}, -1n);
const zero = ${method}(() => {}, 0n);
const symbol = ${method}(() => {}, Symbol("delay"));
const converted = ${method}(() => {}, BigInt("1"));
clearTimeout(positive);
`);
expect(result.filter(({ kind }) => kind === "schedule")).toEqual([]);
expect(result).toMatchObject([
{ kind: "cancel", resolution: "unresolved", linkedTimerId: null },
]);
},
);
it("keeps supported delays and the immediate callback's BigInt argument", () => {
const result = operations(`
import { setTimeout, setInterval, setImmediate } from "node:timers";
setTimeout(() => {}, "2");
setInterval(() => {}, null);
setTimeout(() => {}, !1n);
setTimeout(() => {}, void 1n);
setImmediate(() => {}, 1n);
`);
expect(result.map(({ method }) => method)).toEqual([
"setTimeout",
"setInterval",
"setTimeout",
"setTimeout",
"setImmediate",
]);
});
it("retains uncertain delays, shifted arguments and shadowed constructors", () => {
const result = operations(`
import { setTimeout } from "node:timers";
function schedule(delay, args, Symbol, BigInt) {
setTimeout(() => {}, delay);
setTimeout(...args, 1n);
setTimeout(() => {}, ...args);
setTimeout(() => {}, Symbol("delay"));
setTimeout(() => {}, BigInt("1"));
}
`);
expect(result).toHaveLength(5);
});
it.each([
'eval("Symbol = () => 1; BigInt = () => 1");',
'(0, eval)("Symbol = () => 1; BigInt = () => 1");',
'Function("Symbol = () => 1; BigInt = () => 1")();',
])(
"retains delays after global code may replace constructors: %s",
(effect) => {
expect(
operations(`
import { setTimeout, setInterval } from "node:timers";
${effect}
setTimeout(() => {}, Symbol("delay"));
setInterval(() => {}, BigInt("1"));
`).map(({ method }) => method),
).toEqual(["setTimeout", "setInterval"]);
},
);
it("keeps literal rejection independent of global code effects", () => {
expect(
operations(`
import { setTimeout } from "node:timers";
eval(code);
setTimeout(() => {}, 1n);
`),
).toEqual([]);
});
it("ignores later global code and shadowed eval for constructor identity", () => {
expect(
operations(`
import { setTimeout } from "node:timers";
setTimeout(() => {}, Symbol("delay"));
eval("Symbol = () => 1");
`),
).toEqual([]);
expect(
operations(`
import { setTimeout } from "node:timers";
function run(eval) {
eval("Symbol = () => 1");
setTimeout(() => {}, Symbol("delay"));
}
`),
).toEqual([]);
});
it.each([
["Symbol", "configure(globalThis);"],
["BigInt", "configure(globalThis);"],
["Symbol", "const host = globalThis; configure(host);"],
["BigInt", "configure({ host: globalThis });"],
])("retains %s delays and cancellation after %s", (constructor, effect) => {
const result = operations(`
import configure from "./configure.js";
import { setTimeout, clearTimeout } from "node:timers";
${effect}
const handle = setTimeout(() => {}, ${constructor}("1"));
clearTimeout(handle);
`);
expect(result).toMatchObject([
{ kind: "schedule", resolution: "complete" },
{ kind: "cancel", resolution: "complete" },
]);
expect(result[1]?.linkedTimerId).toBe(result[0]?.timerId);
});
it("still rejects literal BigInt delays after the global object escapes", () => {
expect(
operations(`
import configure from "./configure.js";
import { setTimeout, setInterval } from "node:timers";
configure(globalThis);
setTimeout(() => {}, 1n);
setInterval(() => {}, -1n);
`),
).toEqual([]);
});
it("does not let later or unrelated escapes change constructor identity", () => {
expect(
operations(`
import configure from "./configure.js";
import { setTimeout, setInterval } from "node:timers";
configure({});
setTimeout(() => {}, Symbol("1"));
setInterval(() => {}, BigInt("1"));
configure(globalThis);
`),
).toEqual([]);
});
it("selects a constructor before an argument can escape the global object", () => {
expect(
operations(`
import configure from "./configure.js";
import { setTimeout } from "node:timers";
setTimeout(() => {}, Symbol(configure(globalThis)));
`),
).toEqual([]);
});
it.each(["Symbol", "BigInt"])(
"retains %s identity uncertainty inside an escaped callback",
(constructor) => {
const result = operations(`
import configure from "./configure.js";
import { setTimeout, clearTimeout } from "node:timers";
configure(globalThis, () => {
const handle = setTimeout(() => {}, ${constructor}("1"));
clearTimeout(handle);
});
`);
expect(result).toMatchObject([
{ kind: "schedule", resolution: "complete" },
{ kind: "cancel", resolution: "complete" },
]);
expect(result[1]?.linkedTimerId).toBe(result[0]?.timerId);
},
);
it.each([
'for (let i=0;i<2;i++) configure(globalThis, i ? setTimeout(()=>{}, Symbol("1")) : null);',
'function run(first) { configure(globalThis, first ? null : setTimeout(()=>{}, BigInt("1"))); } run(true); run(false);',
'let first=true; while (next()) { configure(globalThis, first ? null : setTimeout(()=>{}, Symbol("1"))); first=false; }',
])("retains constructor uncertainty across repeated escapes: %s", (source) => {
expect(
operations(
`import configure from "./configure.js"; import {setTimeout} from "node:timers"; ${source}`,
),
).toMatchObject([{ kind: "schedule" }]);
});
@@ -19,14 +19,8 @@ import {
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
import {
createGlobalReferenceReader,
isUnmodifiedGlobalReference,
} from "./javascriptSemanticAmbientWrites.js";
import {
semanticOpaqueGlobalMayHaveChanged,
semanticOpaqueNameMayHaveChanged,
} from "./javascriptSemanticOpaqueEffects.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import {
builtinDataEffectIdentity,
@@ -115,10 +109,6 @@ interface TimerCollectionInput {
readonly ownerCallableId: string | null;
readonly state: JavaScriptSemanticAnalysisState;
readonly output: TimerCandidate[];
readonly delayConstructors: ReadonlyMap<
string,
ReturnType<typeof createGlobalReferenceReader>
>;
readonly ancestors: readonly t.Node[];
}
@@ -130,10 +120,6 @@ export const collectJavaScriptSemanticAsyncEffects = (
): AsyncEffectAnalysis => {
const events: JavaScriptSemanticEventOperation[] = [];
const timers: TimerCandidate[] = [];
const delayConstructors = new Map([
["Symbol", createGlobalReferenceReader(state, ["Symbol"])],
["BigInt", createGlobalReferenceReader(state, ["BigInt"])],
]);
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack = createSemanticCallableIdStack(callableIds);
const ancestors: t.Node[] = [];
@@ -148,7 +134,6 @@ export const collectJavaScriptSemanticAsyncEffects = (
ownerCallableId,
state,
output: timers,
delayConstructors,
ancestors,
});
}
@@ -391,38 +376,6 @@ const schedulesWithNonFunctionCallback = (
return t.isUnaryExpression(value);
};
// Node converts timeout and interval delays to numbers before creating a handle.
// BigInt and Symbol values throw there. A spread can shift the delay slot, and
// shadowed or replaced constructors cannot establish the argument's type.
const schedulesWithInvalidDelay = (
node: t.CallExpression | t.OptionalCallExpression,
state: JavaScriptSemanticAnalysisState,
delayConstructors: TimerCollectionInput["delayConstructors"],
): boolean => {
if (
node.arguments.slice(0, 2).some((argument) => t.isSpreadElement(argument))
)
return false;
const argument = node.arguments[1];
if (argument === undefined) return false;
const value = unwrapJavaScriptExpression(argument).node;
if (t.isBigIntLiteral(value)) return true;
if (
t.isUnaryExpression(value) &&
["+", "-", "~"].includes(value.operator) &&
t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)
)
return true;
return (
t.isCallExpression(value) &&
t.isIdentifier(value.callee) &&
delayConstructors.get(value.callee.name)?.(value.callee).status ===
"unmodified" &&
!semanticOpaqueNameMayHaveChanged(value.callee, state) &&
!semanticOpaqueGlobalMayHaveChanged(value.callee, state)
);
};
const collectTimer = (input: TimerCollectionInput): void => {
const { node, ownerCallableId, state, output, ancestors } = input;
const identity = timerMethod(node.callee, state);
@@ -431,9 +384,7 @@ const collectTimer = (input: TimerCollectionInput): void => {
if (
TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) &&
nodeBuiltinTimerMethod(node.callee, state) !== null &&
(schedulesWithNonFunctionCallback(node, state) ||
((method === "setTimeout" || method === "setInterval") &&
schedulesWithInvalidDelay(node, state, input.delayConstructors)))
schedulesWithNonFunctionCallback(node, input.state)
)
return;
const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method)
@@ -79,8 +79,7 @@ export const collectJavaScriptSemanticChildProcesses = (
!forkRejectsStdioArray(candidate, context) &&
!rejectsStdioLiteral(candidate, context) &&
!rejectsProcessOptionLiteral(candidate, context) &&
!rejectsOptionsShape(candidate, context, state) &&
!rejectsCallback(candidate, context),
!rejectsOptionsShape(candidate, context, state),
);
const childProcessSpawns = candidates.map((candidate) =>
immutableSpawn(candidate, context, state),
@@ -648,13 +647,6 @@ const rejectsOptionsShape = (
if (options === undefined) return false;
const value = unwrapJavaScriptExpression(options).node;
if (context.isArray(value)) return true;
if (
candidate.method !== "execFile" &&
(t.isFunctionExpression(value) ||
t.isArrowFunctionExpression(value) ||
t.isClassExpression(value))
)
return true;
const literal = readExactJavaScriptLiteral(value);
if (literal.found)
return !(candidate.method !== "spawn" && literal.value === null);
@@ -670,52 +662,6 @@ const rejectsOptionsShape = (
return isUnpricedPrimitiveLiteral(value);
};
/** Select only callback slots whose overload is established by source syntax. */
const rejectsCallback = (
candidate: SpawnCandidate,
context: ChildSpawnContext,
): boolean => {
if (candidate.method !== "exec" && candidate.method !== "execFile")
return false;
const args = candidate.node.arguments;
if (args.slice(0, 4).some((argument) => t.isSpreadElement(argument)))
return false;
const second = args[1] && unwrapJavaScriptExpression(args[1]).node;
const third = args[2] && unwrapJavaScriptExpression(args[2]).node;
let callback: t.Node | undefined;
if (candidate.method === "exec") {
// A function in slot 1 replaces slot 2, even when slot 2 is not callable.
if (
second !== undefined &&
!context.isNullish(second) &&
!rejectsSemanticEventListener(second)
)
return false;
callback = third;
} else if (t.isObjectExpression(second) || t.isRegExpLiteral(second)) {
callback = third;
} else if (
second === undefined ||
context.isArray(second) ||
context.isNullish(second)
) {
// With argv present, a third-slot function replaces the fourth callback.
if (
third !== undefined &&
!context.isNullish(third) &&
!rejectsSemanticEventListener(third)
)
return false;
callback = args[3];
} else {
return false;
}
return (
callback !== undefined &&
!context.isNullish(callback) &&
rejectsSemanticEventListener(callback)
);
};
/**
* The exact-value reader deliberately refuses to price a BigInt, a non-finite
* numeric literal such as `1e400`, and a negated numeric literal such as `-0`.
+36 -52
View File
@@ -13,11 +13,6 @@ import {
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
GDB_SESSION_TOOLS,
stoppedDebuggerSessionError,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import {
AnalysisError,
type AnalysisErrorOptions,
@@ -547,8 +542,6 @@ interface GdbSession {
noInferiorVerified: boolean;
/** Sticky admission seal once destructive cleanup starts, including failed stops. */
cleanupStarted: boolean;
/** close_gdb_session started cleanup, so a successful retry releases the ID. */
closeRequested: boolean;
stopAttempt: ReturnType<typeof stopOwnedDebuggerProcess> | undefined;
readonly pending: Map<number, PendingCommand>;
readonly cleanupFailures: Set<AnalysisError>;
@@ -573,8 +566,17 @@ interface GdbSession {
recentRecordsTruncated: boolean;
}
const gdbConsoleUnavailable = (): AnalysisInputError =>
new AnalysisInputError("gdb_console", undefined, [
{
path: ["session_id"],
reason: "invalid_value",
message: "GDB session is unavailable or closed.",
},
]);
const gdbCloseUnavailable = (): AnalysisInputError =>
unknownDebuggerSessionError(GDB_SESSION_TOOLS, "close_gdb_session");
new AnalysisInputError("close_gdb_session");
/** Owns persistent GDB/MI sessions independently from the active binary target. */
export class GdbSessionManager {
@@ -767,7 +769,7 @@ export class GdbSessionManager {
"start_gdb_session",
"memory",
`GDB session capacity reached (${String(this.#maxSessions.value)} sessions).`,
"Close a GDB session before starting another; a stopped session that gdb_session_status reports closed still holds its slot until close_gdb_session releases it.",
"Close a GDB session before starting another.",
this.#maxSessions,
{
max_sessions: this.#maxSessions.value,
@@ -901,7 +903,6 @@ export class GdbSessionManager {
// during startup, so no inferior can exist until execute() is called.
noInferiorVerified: true,
cleanupStarted: false,
closeRequested: false,
stopAttempt: undefined,
pending: new Map(),
cleanupFailures: new Set(),
@@ -1255,13 +1256,15 @@ export class GdbSessionManager {
>
> {
const session = this.#sessions.get(sessionId);
if (session === undefined)
return err(unknownDebuggerSessionError(GDB_SESSION_TOOLS, "gdb_console"));
const stopped = this.#stopReason(session);
if (stopped !== undefined)
return err(
stoppedDebuggerSessionError(GDB_SESSION_TOOLS, "gdb_console", stopped),
);
if (
this.#closing ||
session === undefined ||
!session.ready ||
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined
)
return err(gdbConsoleUnavailable());
if (signal?.aborted)
return err(cancelledWithCallerReason("gdb_console", signal));
try {
@@ -1270,16 +1273,14 @@ export class GdbSessionManager {
async () => {
// Retained cleanup ownership and a delayed exit event do not imply
// command readiness. Recheck admission when this queued turn starts.
// Every path that releases a session first records why it stopped.
const stoppedWhileQueued = this.#stopReason(session);
if (stoppedWhileQueued !== undefined)
return err(
stoppedDebuggerSessionError(
GDB_SESSION_TOOLS,
"gdb_console",
stoppedWhileQueued,
),
);
if (
this.#closing ||
this.#sessions.get(sessionId) !== session ||
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined
)
return err(gdbConsoleUnavailable());
session.noInferiorVerified = false;
session.knownInferiorIdentities.clear();
if (commandMayChangeInferiorOutput(command))
@@ -1454,17 +1455,21 @@ export class GdbSessionManager {
readonly diagnostics_truncated: boolean;
readonly inferior_tty: string | null;
readonly inferior_tty_verified: boolean;
readonly closed_reason?: string;
}
| undefined {
const session = this.#sessions.get(sessionId);
if (session === undefined) return undefined;
const diagnostics = session.supervisor.snapshot();
const stopped = this.#stopReason(session);
return {
session_id: session.id,
mi_version: session.miVersion,
state: stopped === undefined ? "ready" : "closed",
state:
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined ||
!session.ready
? "closed"
: "ready",
recent_mi_records: [...session.lines],
recent_mi_records_truncated: session.recentRecordsTruncated,
mi_invalid_utf8: session.invalidUtf8Observed,
@@ -1476,24 +1481,9 @@ export class GdbSessionManager {
diagnostics_truncated: diagnostics.diagnosticTruncated === true,
inferior_tty: session.inferiorTty ?? null,
inferior_tty_verified: session.inferiorTtyVerified,
...(stopped === undefined ? {} : { closed_reason: stopped }),
};
}
/** Why a retained session accepts no commands, or undefined while it is ready. */
#stopReason(session: GdbSession): string | undefined {
if (session.protocolError !== undefined) return session.protocolError;
if (this.#closing)
return "REA is shutting down and closing every GDB session.";
if (session.closeRequested)
return "close_gdb_session started releasing this session, and its owned cleanup has not completed.";
if (session.exited)
return `The GDB process exited (exit code ${String(session.exitCode)}, signal ${String(session.exitSignal)}); a command such as quit, or a crash, can end it.`;
if (session.cleanupStarted || !session.ready)
return "REA stopped this GDB session.";
return undefined;
}
async close(
sessionId: string,
): Promise<
@@ -1559,7 +1549,6 @@ export class GdbSessionManager {
// Refusing inferior disposition above leaves console remediation
// available; from here onward only owned cleanup may be retried.
session.cleanupStarted = true;
session.closeRequested = true;
if (groupCount === 0) {
session.noInferiorVerified = true;
targetState = "none_observed";
@@ -1599,7 +1588,6 @@ export class GdbSessionManager {
async #finishSessionCleanup(
session: GdbSession,
): Promise<Result<{ readonly target_state: "unknown" }, AnalysisError>> {
session.closeRequested = true;
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
return err(
@@ -1626,10 +1614,7 @@ export class GdbSessionManager {
stopped.reason,
),
);
// The caller still holds a published session's ID; keep it addressable
// so commands and status report why it stopped until it is closed.
if (!session.ready || session.closeRequested)
this.#sessions.delete(session.id);
this.#sessions.delete(session.id);
}
return ok(null);
}
@@ -2224,7 +2209,6 @@ export class GdbSessionManager {
"The unrestricted GDB console can access local shell, filesystem, network, and target controls.",
],
});
session.protocolError ??= `A GDB command exceeded its ${String(acceptedDeadline.value)} ms deadline; owned-session shutdown was requested because output correlation can no longer be guaranteed.`;
void this.#stopDebuggerOnly(session).then((stopped) => {
if (
stopped.status === "incomplete" &&
+1 -41
View File
@@ -25,7 +25,7 @@ export const rizinCommand = (input: RizinInput): readonly string[] => [
export const rizinCommandLimitations = (
input: RizinInput,
): readonly string[] => [
"completion_status covers process completion and Rizin shell refusals only. A statement that ran can still fail while Rizin returns exit_code 0, so inspect stderr before treating an empty response as absence.",
"completion_status reports process completion. Command validity remains unverified: Rizin can reject a command while returning exit_code 0. Inspect stdout and stderr before treating an empty response as absence.",
...(input.asm_arch === undefined &&
input.asm_bits === undefined &&
input.asm_cpu === undefined
@@ -39,43 +39,3 @@ export const rizinCommandLimitations = (
"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.",
]),
];
/**
* Rizin's shell logs exactly these lines when it refuses a statement
* (librz/core/cmd/cmd.c, unchanged from 0.6 through 0.9.1). Rizin then skips
* the remaining statements and still exits 0. Other ERROR lines come from
* commands that ran, so they are diagnostics rather than refusals.
*/
const RIZIN_SHELL_REFUSALS: readonly {
readonly line: RegExp;
readonly refused: (match: RegExpExecArray) => string;
}[] = [
{
line: /^ERROR: Command '(.*)' does not exist\.$/u,
refused: (match) => `the unknown command ${JSON.stringify(match[1])}`,
},
{
line: /^ERROR: Wrong number of arguments passed to `(.*)`, see its help with `.*\?`$/u,
refused: (match) =>
`a wrong argument count for ${JSON.stringify(match[1])}`,
},
{
line: /^ERROR: core: Error while executing command: /u,
refused: () => "an invalid statement",
},
{
line: /^ERROR: core: Error while parsing command: /u,
refused: () => "input that failed to parse",
},
];
/** Explain the first Rizin shell refusal in a completed process's stderr. */
export const rizinCommandRejection = (stderr: string): string | undefined => {
for (const text of stderr.split(/\r?\n/u))
for (const { line, refused } of RIZIN_SHELL_REFUSALS) {
const match = line.exec(text);
if (match !== null)
return `Rizin refused ${refused(match)} (ERROR line on stderr). It skips a refused statement and every later one, so stdout holds at most the output of earlier statements; empty stdout is not an empty result. Correct the statement, using <command>? for Rizin's help, and run it again.`;
}
return undefined;
};
+38 -87
View File
@@ -22,11 +22,6 @@ import {
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
RIZIN_DEBUG_SESSION_TOOLS,
stoppedDebuggerSessionError,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import type {
AnalysisError,
AnalysisErrorOptions,
@@ -229,18 +224,9 @@ interface RizinDebugSession {
frameChunks: Buffer[];
bufferedFrameBytes: number;
ready: boolean;
/** start returned this session's ID, so the caller must close it. */
published: boolean;
closeRequested: boolean;
exited: boolean;
commandTail: Promise<void>;
protocolError: string | undefined;
/**
* Why the session stopped accepting commands. Cleanup synchronization
* clears protocolError once the transport is correlated again, but the
* session stays stopped, so the diagnosis is retained here.
*/
stopReason: string | undefined;
cleanupSynchronization:
| {
readonly marker: string;
@@ -378,7 +364,7 @@ export class RizinDebugSessionManager {
"start_rizin_debug_session",
"memory",
`Rizin session capacity reached (${String(this.#maxSessions.value)} sessions).`,
"Close a Rizin debug session before starting another; a stopped session that rizin_debug_session_status reports closed still holds its slot until close_rizin_debug_session releases it.",
"Close a Rizin debug session before starting another.",
this.#maxSessions,
{
max_sessions: this.#maxSessions.value,
@@ -623,12 +609,9 @@ export class RizinDebugSessionManager {
frameChunks: [],
bufferedFrameBytes: 0,
ready: false,
published: false,
closeRequested: false,
exited: false,
commandTail: Promise.resolve(),
protocolError: undefined,
stopReason: undefined,
cleanupSynchronization: undefined,
commandCaptureActive: false,
commandOutputDropped: false,
@@ -668,7 +651,6 @@ export class RizinDebugSessionManager {
if (session.protocolError !== undefined)
throw new Error(session.protocolError);
session.ready = true;
session.published = true;
return ok({ session_id: id, backend: input.backend ?? null });
} catch (caught) {
const protocolFailure = session.protocolError;
@@ -737,22 +719,15 @@ export class RizinDebugSessionManager {
>
> {
const session = this.#sessions.get(sessionId);
if (session === undefined)
return err(
unknownDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"rizin_debug_command",
),
);
const stopped = this.#stopReason(session);
if (stopped !== undefined)
return err(
stoppedDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"rizin_debug_command",
stopped,
),
);
if (
session === undefined ||
!session.ready ||
session.exited ||
session.protocolError !== undefined ||
session.closing ||
this.#closingAll
)
return err(new AnalysisInputError("rizin_debug_command"));
if (signal?.aborted)
return err(rizinCancellation("rizin_debug_command", signal));
if (session.pendingCommandCount >= this.#maxPendingCommands.value)
@@ -807,15 +782,14 @@ export class RizinDebugSessionManager {
acquired = true;
if (signal?.aborted)
return err(rizinCancellation("rizin_debug_command", signal));
const stoppedWhileQueued = this.#stopReason(session);
if (stoppedWhileQueued !== undefined)
return err(
stoppedDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"rizin_debug_command",
stoppedWhileQueued,
),
);
if (
!session.ready ||
session.exited ||
session.closing ||
this.#closingAll ||
session.protocolError !== undefined
)
return err(new AnalysisInputError("rizin_debug_command"));
if (hasUnclaimedRizinFrames(session)) {
const message =
"Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated.";
@@ -1094,40 +1068,27 @@ export class RizinDebugSessionManager {
readonly recent_output_truncated: boolean;
readonly diagnostics_truncated: boolean;
readonly output_invalid_utf8: boolean;
readonly closed_reason?: string;
}
| undefined {
const session = this.#sessions.get(sessionId);
if (session === undefined) return undefined;
const stopped = this.#stopReason(session);
return {
session_id: session.id,
state: stopped === undefined ? "ready" : "closed",
inferior_tty: session.inferiorTty,
recent_output: [...session.history],
recent_output_truncated: session.historyTruncated,
diagnostics_truncated:
session.supervisor.snapshot().diagnosticTruncated === true,
output_invalid_utf8: session.invalidUtf8Observed,
...(stopped === undefined ? {} : { closed_reason: stopped }),
};
}
/** Why a retained session accepts no commands, or undefined while it is ready. */
#stopReason(session: RizinDebugSession): string | undefined {
const diagnosed = session.stopReason ?? session.protocolError;
if (diagnosed !== undefined) return diagnosed;
if (this.#closingAll)
return "REA is shutting down and closing every Rizin debug session.";
if (session.closeRequested)
return "close_rizin_debug_session started releasing this session, and its owned cleanup has not completed.";
if (session.exited) {
const { exitCode, signalCode } = session.launched.process;
return `The Rizin process exited (exit code ${String(exitCode)}, signal ${String(signalCode)}); a command such as q, or a crash, can end it.`;
}
if (session.closing || !session.ready)
return "REA stopped this Rizin debug session.";
return undefined;
return session === undefined
? undefined
: {
session_id: session.id,
state:
session.exited ||
session.protocolError !== undefined ||
session.closing ||
!session.ready
? "closed"
: "ready",
inferior_tty: session.inferiorTty,
recent_output: [...session.history],
recent_output_truncated: session.historyTruncated,
diagnostics_truncated:
session.supervisor.snapshot().diagnosticTruncated === true,
output_invalid_utf8: session.invalidUtf8Observed,
};
}
async close(
@@ -1137,13 +1098,7 @@ export class RizinDebugSessionManager {
return err(new AnalysisInputError("close_rizin_debug_session"));
const session = this.#sessions.get(sessionId);
if (session === undefined)
return err(
unknownDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
"close_rizin_debug_session",
),
);
session.closeRequested = true;
return err(new AnalysisInputError("close_rizin_debug_session"));
session.closing = true;
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
@@ -1186,10 +1141,7 @@ export class RizinDebugSessionManager {
stopped.reason,
),
);
// The caller still holds a published session's ID; keep it addressable
// so commands and status report why it stopped until it is closed.
if (!session.published || session.closeRequested)
this.#sessions.delete(session.id);
this.#sessions.delete(session.id);
}
return ok(null);
}
@@ -1564,7 +1516,6 @@ export class RizinDebugSessionManager {
clearTimeout(timer);
session.cleanupSynchronization = undefined;
if (cause === undefined) {
session.stopReason ??= session.protocolError;
session.protocolError = undefined;
session.commandOutputDropped = false;
resolve();
+5 -10
View File
@@ -2,10 +2,6 @@ import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { Logger } from "pino";
import type { GdbSessionManager } from "../gdb/GdbSessionManager.js";
import {
GDB_SESSION_TOOLS,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
@@ -14,6 +10,7 @@ import {
} from "../application/analysisView/AnalysisViewService.js";
import { toolContract } from "../contracts/toolContracts.js";
import { err, ok } from "../domain/result.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import {
runOwnedCleanupWithExternalMutationAdmission,
runWithExternalMutationAdmission,
@@ -120,12 +117,7 @@ export const registerGdbTools = (
const result = manager.status(input.session_id);
if (result === undefined)
return delivery.toCallToolResult(
err(
unknownDebuggerSessionError(
GDB_SESSION_TOOLS,
"gdb_session_status",
),
),
err(invalidSession("gdb_session_status")),
status,
);
return delivery.toCallToolResult(
@@ -158,3 +150,6 @@ export const registerGdbTools = (
},
);
};
const invalidSession = (operation: string): AnalysisInputError =>
new AnalysisInputError(operation);
+2 -11
View File
@@ -3,10 +3,7 @@ import type { Logger } from "pino";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { toolContract } from "../contracts/toolContracts.js";
import {
RIZIN_DEBUG_SESSION_TOOLS,
unknownDebuggerSessionError,
} from "../domain/debuggerSessionErrors.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
import {
runOwnedCleanupWithExternalMutationAdmission,
@@ -102,13 +99,7 @@ export const registerRizinDebugTools = (
const result = manager.status(input.session_id);
if (result === undefined)
return delivery.toCallToolResult(
{
ok: false,
error: unknownDebuggerSessionError(
RIZIN_DEBUG_SESSION_TOOLS,
status.name,
),
},
{ ok: false, error: new AnalysisInputError(status.name) },
status,
);
return delivery.toCallToolResult(
@@ -1,8 +1,7 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
@@ -10,6 +9,7 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j
import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js";
import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -53,109 +53,93 @@ cliTest(
"analyze_javascript_application",
).outputSchema.parse(cliResponse.json);
const client = new Client({ name: "endpoint-payload-e2e", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
const { client } = await connectLocalToolsMcp();
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, format: "directory" },
});
transport.stderr?.on("data", () => undefined);
try {
await client.connect(transport);
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, format: "directory" },
});
expect(response.isError).not.toBe(true);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = response.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing MCP text result");
const textEvidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(text.text));
expect(textEvidence.evidence_id).toBe(evidence.evidence_id);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
expect(result.graph.coverage.limits).toContainEqual(
expect.objectContaining({
name: "javascript_semantic_primitive_bytes",
}),
);
expect(
result.semantic_graph.nodes.some(
({ kind, properties }) =>
kind === "literal" && properties.value === endpoint,
expect(response.isError).not.toBe(true);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = response.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing MCP text result");
const textEvidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(text.text));
expect(textEvidence.evidence_id).toBe(evidence.evidence_id);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
expect(result.graph.coverage.limits).toContainEqual(
expect.objectContaining({
name: "javascript_semantic_primitive_bytes",
}),
);
expect(
result.semantic_graph.nodes.some(
({ kind, properties }) =>
kind === "literal" && properties.value === endpoint,
),
).toBe(false);
const endpoints = result.graph.nodes.filter(
(node) => node.kind === "endpoint",
);
const nodeFor = (value: string) => {
const node = endpoints.find((candidate) =>
candidate.observations.some(
({ properties }) => properties.value === value,
),
).toBe(false);
const endpoints = result.graph.nodes.filter(
(node) => node.kind === "endpoint",
);
const nodeFor = (value: string) => {
const node = endpoints.find((candidate) =>
candidate.observations.some(
({ properties }) => properties.value === value,
),
);
if (node === undefined) throw new Error("Complete endpoint was lost");
return node;
};
const endpointNode = nodeFor(endpoint);
expect(
new Set(
[endpoint, digestLookalike, surrogate, replacement].map(
(value) => nodeFor(value).node_id,
),
).size,
).toBe(4);
const repeatedNode = nodeFor("/ok");
expect(
endpoints.filter((node) =>
node.observations.some(
({ properties }) => properties.value === "/ok",
),
if (node === undefined) throw new Error("Complete endpoint was lost");
return node;
};
const endpointNode = nodeFor(endpoint);
expect(
new Set(
[endpoint, digestLookalike, surrogate, replacement].map(
(value) => nodeFor(value).node_id,
),
).toHaveLength(1);
expect(repeatedNode.observations).toHaveLength(2);
expect(repeatedNode.identity).toMatchObject({
key: JSON.stringify(["literal", "/ok"]),
});
expect(
repeatedNode.observations.every(({ label }) => label === "/ok"),
).toBe(true);
expect(endpointNode.identity).toMatchObject({
artifact_sha256: createHash("sha256").update(source).digest("hex"),
});
expect(endpointNode.observations[0]?.evidence.location).toMatchObject({
available: true,
value: { kind: "source-range", source: "main.js" },
});
const requestNodes = result.semantic_graph.nodes.filter(
(node) =>
node.kind === "request" && node.properties.endpoint === endpoint,
);
expect(requestNodes).toHaveLength(1);
).size,
).toBe(4);
const repeatedNode = nodeFor("/ok");
expect(
endpoints.filter((node) =>
node.observations.some(({ properties }) => properties.value === "/ok"),
),
).toHaveLength(1);
expect(repeatedNode.observations).toHaveLength(2);
expect(repeatedNode.identity).toMatchObject({
key: JSON.stringify(["literal", "/ok"]),
});
expect(
repeatedNode.observations.every(({ label }) => label === "/ok"),
).toBe(true);
expect(endpointNode.identity).toMatchObject({
artifact_sha256: createHash("sha256").update(source).digest("hex"),
});
expect(endpointNode.observations[0]?.evidence.location).toMatchObject({
available: true,
value: { kind: "source-range", source: "main.js" },
});
const requestNodes = result.semantic_graph.nodes.filter(
(node) =>
node.kind === "request" && node.properties.endpoint === endpoint,
);
expect(requestNodes).toHaveLength(1);
await verifyEndpointWorkflows({
client,
evidenceId: evidence.evidence_id,
endpoint,
endpointNodeId: endpointNode.node_id,
requestNodeIds: requestNodes.map(({ node_id }) => node_id),
root,
source,
sourcePath,
});
await client.ping();
} finally {
await client.close();
await transport.close();
}
await verifyEndpointWorkflows({
client,
evidenceId: evidence.evidence_id,
endpoint,
endpointNodeId: endpointNode.node_id,
requestNodeIds: requestNodes.map(({ node_id }) => node_id),
root,
source,
sourcePath,
});
await client.ping();
},
);
@@ -1,13 +1,12 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -42,123 +41,106 @@ cliTest(
const cliEvidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(cliResponse.json);
const client = new Client({ name: "event-payload-e2e", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
const { client } = await connectLocalToolsMcp();
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, format: "directory" },
});
transport.stderr?.on("data", () => undefined);
try {
await client.connect(transport);
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, format: "directory" },
});
expect(response.isError).not.toBe(true);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = response.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing MCP text result");
expect(
toolContract("analyze_javascript_application").outputSchema.parse(
JSON.parse(text.text),
).evidence_id,
).toBe(evidence.evidence_id);
const graph = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
const events = graph.nodes.filter((node) => node.kind === "event");
const selected = events.filter(
(node) => node.properties.event_name === name,
);
expect(selected).toHaveLength(1);
const event = selected[0];
if (event === undefined) throw new Error("Complete event was lost");
const listeners = graph.nodes.filter(
expect(response.isError).not.toBe(true);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = response.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing MCP text result");
expect(
toolContract("analyze_javascript_application").outputSchema.parse(
JSON.parse(text.text),
).evidence_id,
).toBe(evidence.evidence_id);
const graph = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
const events = graph.nodes.filter((node) => node.kind === "event");
const selected = events.filter(
(node) => node.properties.event_name === name,
);
expect(selected).toHaveLength(1);
const event = selected[0];
if (event === undefined) throw new Error("Complete event was lost");
const listeners = graph.nodes.filter(
(node) => node.kind === "listener" && node.properties.event_name === name,
);
expect(new Set(listeners.map((node) => node.properties.method))).toEqual(
new Set(["on", "off"]),
);
const listenerIds = new Set(listeners.map((node) => node.node_id));
expect(
graph.relations
.filter(
(relation) =>
relation.source_node_id === event.node_id &&
listenerIds.has(relation.target_node_id),
)
.map((relation) => relation.relation),
).toEqual(
expect.arrayContaining(["registers-listener", "removes-listener"]),
);
const lookalikeEvent = events.find(
(node) => node.properties.event_name === lookalike,
);
expect(lookalikeEvent).toBeDefined();
expect(lookalikeEvent?.node_id).not.toBe(event.node_id);
const shortEvents = events.filter(
(node) => node.properties.event_name === "ok",
);
expect(shortEvents).toHaveLength(2);
expect(
new Set(shortEvents.map((node) => node.properties.emitter_key)).size,
).toBe(2);
expect(
shortEvents.every(
(node) =>
node.kind === "listener" && node.properties.event_name === name,
);
expect(new Set(listeners.map((node) => node.properties.method))).toEqual(
new Set(["on", "off"]),
);
const listenerIds = new Set(listeners.map((node) => node.node_id));
expect(
graph.relations
.filter(
(relation) =>
relation.source_node_id === event.node_id &&
listenerIds.has(relation.target_node_id),
)
.map((relation) => relation.relation),
).toEqual(
expect.arrayContaining(["registers-listener", "removes-listener"]),
);
const lookalikeEvent = events.find(
(node) => node.properties.event_name === lookalike,
);
expect(lookalikeEvent).toBeDefined();
expect(lookalikeEvent?.node_id).not.toBe(event.node_id);
const shortEvents = events.filter(
(node) => node.properties.event_name === "ok",
);
expect(shortEvents).toHaveLength(2);
expect(
new Set(shortEvents.map((node) => node.properties.emitter_key)).size,
).toBe(2);
expect(
shortEvents.every(
(node) =>
node.label === "ok" && node.identity.role_key.endsWith("\0ok"),
),
).toBe(true);
const dynamic = events.find(
(node) => node.properties.event_name === null,
);
expect(dynamic?.label).toBeNull();
expect(
graph.unknowns.some(
(unknown) =>
unknown.node_id === dynamic?.node_id && unknown.family === "event",
),
).toBe(true);
const traceResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: {
application: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
query: {
seed: { kind: "event", name },
direction: "forward-influence",
},
node.label === "ok" && node.identity.role_key.endsWith("\0ok"),
),
).toBe(true);
const dynamic = events.find((node) => node.properties.event_name === null);
expect(dynamic?.label).toBeNull();
expect(
graph.unknowns.some(
(unknown) =>
unknown.node_id === dynamic?.node_id && unknown.family === "event",
),
).toBe(true);
const traceResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: {
application: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
});
expect(traceResponse.isError).not.toBe(true);
const traceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(traceResponse.structuredContent);
const trace = javaScriptSemanticTraceResultSchema.parse(
traceEvidence.normalized_result,
);
expect(new Set(trace.seed_node_ids)).toEqual(
new Set([event.node_id, ...listenerIds]),
);
expect(
trace.nodes
.filter((node) => listenerIds.has(node.node_id))
.map((node) => node.properties.event_name),
).toEqual(listeners.map(() => name));
await client.ping();
} finally {
await client.close();
await transport.close();
}
query: {
seed: { kind: "event", name },
direction: "forward-influence",
},
},
});
expect(traceResponse.isError).not.toBe(true);
const traceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(traceResponse.structuredContent);
const trace = javaScriptSemanticTraceResultSchema.parse(
traceEvidence.normalized_result,
);
expect(new Set(trace.seed_node_ids)).toEqual(
new Set([event.node_id, ...listenerIds]),
);
expect(
trace.nodes
.filter((node) => listenerIds.has(node.node_id))
.map((node) => node.properties.event_name),
).toEqual(listeners.map(() => name));
await client.ping();
},
);
@@ -1,10 +1,9 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -58,34 +57,15 @@ cliTest(
0,
),
).toBe(29);
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
stderr: "pipe",
const { client } = await connectLocalToolsMcp();
const response = await client.callTool({
name: contract.name,
arguments: { input_path: root, detail: "complete", ...options },
});
const client = new Client({
name: "unknown-capacity-regression",
version: "1",
});
try {
await client.connect(transport);
const response = await client.callTool({
name: contract.name,
arguments: { input_path: root, detail: "complete", ...options },
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcp = contract.outputSchema.parse(response.structuredContent);
expect(mcp.parameters).toEqual(evidence.parameters);
expect(mcp.evidence_id).toBe(evidence.evidence_id);
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcp = contract.outputSchema.parse(response.structuredContent);
expect(mcp.parameters).toEqual(evidence.parameters);
expect(mcp.evidence_id).toBe(evidence.evidence_id);
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
},
);
+87 -103
View File
@@ -1,12 +1,12 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { peResourcesSchema } from "../../../src/domain/native/peResources.js";
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
@@ -422,113 +422,97 @@ cliTest(
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-resource-acceptance-");
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
stderr: "pipe",
});
const client = new Client({ name: "pe-resource-acceptance", version: "1" });
try {
await client.connect(transport);
const inventory = await client.listTools();
const definition = inventory.tools.find(
({ name }) => name === contract.name,
);
if (definition === undefined)
throw new Error("Missing PE resource tool contract");
expect(inventory.tools).toContainEqual(
expect.objectContaining({
name: contract.name,
annotations: expect.objectContaining({
readOnlyHint: false,
openWorldHint: false,
}),
const { client } = await connectLocalToolsMcp();
const inventory = await client.listTools();
const definition = inventory.tools.find(
({ name }) => name === contract.name,
);
if (definition === undefined)
throw new Error("Missing PE resource tool contract");
expect(inventory.tools).toContainEqual(
expect.objectContaining({
name: contract.name,
annotations: expect.objectContaining({
readOnlyHint: false,
openWorldHint: false,
}),
}),
);
const call: PeResourceCall = (arguments_) =>
client.callTool(
{ name: definition.name, arguments: arguments_ },
{ toolDefinition: definition },
);
const call: PeResourceCall = (arguments_) =>
client.callTool(
{ name: definition.name, arguments: arguments_ },
{ toolDefinition: definition },
);
const sessionDefinition = inventory.tools.find(
({ name }) => name === "binary_session",
);
if (sessionDefinition === undefined)
throw new Error("Missing binary_session tool contract");
const session = await client.callTool(
{ name: sessionDefinition.name, arguments: {} },
{ toolDefinition: sessionDefinition },
);
expect(session.isError, JSON.stringify(session)).not.toBe(true);
const status = toolContract("binary_session").outputSchema.parse(
session.structuredContent,
).result;
expect(
status.tool_availability.groups.find((group) =>
group.names.includes("inspect_pe_resources"),
),
).toMatchObject({ available: true });
await expectPeInventoriesAcrossAdapters(cli, call, root);
await expectPeEmptyBranchesAcrossAdapters(cli, call, root);
await expectPeWordAlignedRecordsAcrossAdapters({
cli,
call,
root,
});
await expectPeMappingOwnershipAcrossAdapters(cli, call, root);
await expectPePaddingFailureAcrossAdapters({
cli,
call,
path: join(root, "raw-padding.exe"),
});
await expectPeInputFailuresAcrossAdapters({
cli,
call,
root,
});
const absent = peResourceFixture();
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
const path = join(root, "absent.exe");
await writeFile(path, absent.bytes);
const empty = await call({ path });
expect(empty.isError).not.toBe(true);
expect(
contract.outputSchema.parse(empty.structuredContent).normalized_result,
).toMatchObject({
directory: null,
directories: [],
resources: [],
coverage: { status: "complete", resources: 0 },
});
for (const arguments_ of [
{ path, max_file_bytes: 64 },
{ path: "relative.exe" },
{ path, extra: true },
]) {
const rejected = await call(arguments_);
expect(rejected.isError).toBe(true);
expect(
contract.outputSchema.safeParse(rejected.structuredContent).success,
).toBe(false);
}
const damaged = peResourceFixture();
damaged.bytes.writeUInt32LE(17, damaged.directoryAt + 4);
const badPath = join(root, "damaged.exe");
await writeFile(badPath, damaged.bytes);
const rejected = await call({ path: badPath });
const sessionDefinition = inventory.tools.find(
({ name }) => name === "binary_session",
);
if (sessionDefinition === undefined)
throw new Error("Missing binary_session tool contract");
const session = await client.callTool(
{ name: sessionDefinition.name, arguments: {} },
{ toolDefinition: sessionDefinition },
);
expect(session.isError, JSON.stringify(session)).not.toBe(true);
const status = toolContract("binary_session").outputSchema.parse(
session.structuredContent,
).result;
expect(
status.tool_availability.groups.find((group) =>
group.names.includes("inspect_pe_resources"),
),
).toMatchObject({ available: true });
await expectPeInventoriesAcrossAdapters(cli, call, root);
await expectPeEmptyBranchesAcrossAdapters(cli, call, root);
await expectPeWordAlignedRecordsAcrossAdapters({
cli,
call,
root,
});
await expectPeMappingOwnershipAcrossAdapters(cli, call, root);
await expectPePaddingFailureAcrossAdapters({
cli,
call,
path: join(root, "raw-padding.exe"),
});
await expectPeInputFailuresAcrossAdapters({
cli,
call,
root,
});
const absent = peResourceFixture();
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
const path = join(root, "absent.exe");
await writeFile(path, absent.bytes);
const empty = await call({ path });
expect(empty.isError).not.toBe(true);
expect(
contract.outputSchema.parse(empty.structuredContent).normalized_result,
).toMatchObject({
directory: null,
directories: [],
resources: [],
coverage: { status: "complete", resources: 0 },
});
for (const arguments_ of [
{ path, max_file_bytes: 64 },
{ path: "relative.exe" },
{ path, extra: true },
]) {
const rejected = await call(arguments_);
expect(rejected.isError).toBe(true);
expect(
contract.outputSchema.safeParse(rejected.structuredContent).success,
).toBe(false);
await client.ping();
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
const damaged = peResourceFixture();
damaged.bytes.writeUInt32LE(17, damaged.directoryAt + 4);
const badPath = join(root, "damaged.exe");
await writeFile(badPath, damaged.bytes);
const rejected = await call({ path: badPath });
expect(rejected.isError).toBe(true);
expect(
contract.outputSchema.safeParse(rejected.structuredContent).success,
).toBe(false);
await client.ping();
},
);
+64 -82
View File
@@ -1,12 +1,11 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../src/domain/evidence.js";
import { buildManagedMemberPointerFixture } from "../../src/dotnet/ManagedPe.fixture.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -18,87 +17,70 @@ cliTest(
const bytes = buildManagedMemberPointerFixture();
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
stderr: "pipe",
});
transport.stderr?.on("data", () => undefined);
const client = new Client({ name: "managed-pointer-parity", version: "1" });
try {
await client.connect(transport);
for (const name of [
"inspect_managed_artifact",
"inspect_managed_members",
] as const) {
const cliResult = await cli.run({
arguments: [name.replaceAll("_", "-"), path, "--json"],
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const cliEvidence = parseEvidence(cliResult.json);
const response = await client.callTool({ name, arguments: { path } });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcpEvidence = parseEvidence(response.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(
cliEvidence.normalized_result,
);
expect(mcpEvidence.subject?.digest.sha256).toBe(sha256);
expect(cliEvidence.subject?.digest.sha256).toBe(sha256);
const { client } = await connectLocalToolsMcp();
for (const name of [
"inspect_managed_artifact",
"inspect_managed_members",
] as const) {
const cliResult = await cli.run({
arguments: [name.replaceAll("_", "-"), path, "--json"],
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const cliEvidence = parseEvidence(cliResult.json);
const response = await client.callTool({ name, arguments: { path } });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcpEvidence = parseEvidence(response.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(
cliEvidence.normalized_result,
);
expect(mcpEvidence.subject?.digest.sha256).toBe(sha256);
expect(cliEvidence.subject?.digest.sha256).toBe(sha256);
expect(mcpEvidence.normalized_result).toMatchObject({
coverage: { state: "complete", issues: [] },
});
if (name === "inspect_managed_artifact") {
expect(mcpEvidence.normalized_result).toMatchObject({
coverage: { state: "complete", issues: [] },
metadata: { stream_names: expect.arrayContaining(["#-"]) },
attributes: [
expect.objectContaining({
parent_token: "0x20000001",
constructor_token: "0x06000001",
type_name: "Fixture.B",
}),
],
});
} else {
expect(mcpEvidence.normalized_result).toMatchObject({
types: [
expect.objectContaining({ full_name: "<Module>" }),
expect.objectContaining({ full_name: "Fixture.A" }),
expect.objectContaining({ full_name: "Fixture.B" }),
],
fields: [
expect.objectContaining({
token: "0x04000001",
declaring_type: "Fixture.B",
declaring_type_token: "0x02000003",
}),
expect.objectContaining({
token: "0x04000002",
declaring_type: "Fixture.A",
declaring_type_token: "0x02000002",
}),
],
methods: [
expect.objectContaining({
token: "0x06000001",
declaring_type: "Fixture.B",
declaring_type_token: "0x02000003",
}),
expect.objectContaining({
token: "0x06000002",
declaring_type: "Fixture.A",
declaring_type_token: "0x02000002",
}),
],
});
if (name === "inspect_managed_artifact") {
expect(mcpEvidence.normalized_result).toMatchObject({
metadata: { stream_names: expect.arrayContaining(["#-"]) },
attributes: [
expect.objectContaining({
parent_token: "0x20000001",
constructor_token: "0x06000001",
type_name: "Fixture.B",
}),
],
});
} else {
expect(mcpEvidence.normalized_result).toMatchObject({
types: [
expect.objectContaining({ full_name: "<Module>" }),
expect.objectContaining({ full_name: "Fixture.A" }),
expect.objectContaining({ full_name: "Fixture.B" }),
],
fields: [
expect.objectContaining({
token: "0x04000001",
declaring_type: "Fixture.B",
declaring_type_token: "0x02000003",
}),
expect.objectContaining({
token: "0x04000002",
declaring_type: "Fixture.A",
declaring_type_token: "0x02000002",
}),
],
methods: [
expect.objectContaining({
token: "0x06000001",
declaring_type: "Fixture.B",
declaring_type_token: "0x02000003",
}),
expect.objectContaining({
token: "0x06000002",
declaring_type: "Fixture.A",
declaring_type_token: "0x02000002",
}),
],
});
}
}
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
},
@@ -1,12 +1,11 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../src/domain/evidence.js";
import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -18,66 +17,46 @@ cliTest(
const bytes = buildManagedPeFixture({ metadataExtraData: 0x1234_5678 });
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
stderr: "pipe",
});
transport.stderr?.on("data", () => undefined);
const client = new Client({
name: "managed-extra-data-parity",
version: "1",
});
try {
await client.connect(transport);
for (const name of [
"inspect_managed_artifact",
"inspect_managed_members",
] as const) {
const cliResult = await cli.run({
arguments: [name.replaceAll("_", "-"), path, "--json"],
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const cliEvidence = parseEvidence(cliResult.json);
const response = await client.callTool({ name, arguments: { path } });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcpEvidence = parseEvidence(response.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(
cliEvidence.normalized_result,
);
expect(mcpEvidence.subject?.digest.sha256).toBe(sha256);
expect(cliEvidence.subject?.digest.sha256).toBe(sha256);
const { client } = await connectLocalToolsMcp();
for (const name of [
"inspect_managed_artifact",
"inspect_managed_members",
] as const) {
const cliResult = await cli.run({
arguments: [name.replaceAll("_", "-"), path, "--json"],
});
expect(cliResult.exitCode, cliResult.stderr).toBe(0);
const cliEvidence = parseEvidence(cliResult.json);
const response = await client.callTool({ name, arguments: { path } });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const mcpEvidence = parseEvidence(response.structuredContent);
expect(mcpEvidence.normalized_result).toEqual(
cliEvidence.normalized_result,
);
expect(mcpEvidence.subject?.digest.sha256).toBe(sha256);
expect(cliEvidence.subject?.digest.sha256).toBe(sha256);
expect(mcpEvidence.normalized_result).toMatchObject({
module: {
name: "Fixture.dll",
mvid: "00112233-4455-6677-8899-aabbccddeeff",
},
coverage: { state: "complete", issues: [] },
});
if (name === "inspect_managed_artifact") {
expect(mcpEvidence.normalized_result).toMatchObject({
module: {
name: "Fixture.dll",
mvid: "00112233-4455-6677-8899-aabbccddeeff",
},
coverage: { state: "complete", issues: [] },
assembly: { name: "Fixture.Managed" },
});
} else {
expect(mcpEvidence.normalized_result).toMatchObject({
types: [expect.objectContaining({ full_name: "Fixture.Program" })],
methods: [
expect.objectContaining({
token: "0x06000001",
name: "Main",
declaring_type: "Fixture.Program",
}),
],
});
if (name === "inspect_managed_artifact") {
expect(mcpEvidence.normalized_result).toMatchObject({
assembly: { name: "Fixture.Managed" },
});
} else {
expect(mcpEvidence.normalized_result).toMatchObject({
types: [expect.objectContaining({ full_name: "Fixture.Program" })],
methods: [
expect.objectContaining({
token: "0x06000001",
name: "Main",
declaring_type: "Fixture.Program",
}),
],
});
}
}
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
},
@@ -13,16 +13,12 @@ const outcomes = [
command: "axffj",
stdout: "",
stderr: "ERROR: Command 'axffj' does not exist.\n",
// The refusal line on stderr is classified; process facts stay as observed.
completion: "rejected",
},
{ command: "q", stdout: "", stderr: "", completion: "complete" },
{ command: "q", stdout: "", stderr: "" },
{
command: "echo diagnostic",
stdout: "ERROR: Command 'axffj' does not exist.\n",
stderr: "WARNING: fixture warning\n",
// Refusal text on stdout is command output, not a Rizin diagnostic.
completion: "complete",
},
] as const;
@@ -75,7 +71,7 @@ cliTest.skipIf(process.platform === "win32").for(outcomes)(
stdout: outcome.stdout,
stderr: outcome.stderr,
exit_code: 0,
completion_status: outcome.completion,
completion_status: "complete",
output_truncated: false,
artifact_changed: false,
});
@@ -1,179 +0,0 @@
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,
});
}
},
);
+18 -95
View File
@@ -68,16 +68,8 @@ const REQUIRED_NUMERIC_FIELDS = [
"inspect-web-page options.observationMs",
"observe-web-execution options.observationMs",
"extract-firmware options.maxDepth",
"trace-web-source-location args.line",
"cutter-command args.expectedGeneration",
"observe-native-ui options.maxNodes",
"inspect-web-network-capture options.record",
"environment-serve options.port",
] as const;
/** Decimal argv tokens the parser admits as numbers, for fields advertised as strings. */
const PROBE_TOKENS = ["0", "1"] as const;
describe("decimal integer CLI admission", () => {
it("rejects 0x10 and 1e3 for every numeric input and keeps an in-range decimal", () => {
const cli = createCli({});
@@ -91,6 +83,7 @@ describe("decimal integer CLI admission", () => {
const advertised = Schema.toJsonSchema(parser);
if (!isRecord(advertised) || !isRecord(advertised.properties)) continue;
for (const [name, property] of Object.entries(advertised.properties)) {
if (!numericBranches(property)) continue;
const field = parser.shape[name];
if (field === undefined) {
failures.push(
@@ -99,31 +92,11 @@ describe("decimal integer CLI admission", () => {
continue;
}
const label = `${path} ${group}.${name}`;
// A repeated option parses each argv token as one array item.
const repeated = isRecord(property) && property.type === "array";
const item = repeated ? property.items : property;
const isolated = z.object({ [name]: field });
const read = (token: string): unknown => {
const parsed =
group === "options"
? Parser.parse([flagName(name), token], { options: isolated })
.options[name]
: Parser.parse([token], { args: isolated }).args[name];
return repeated && Array.isArray(parsed) ? parsed[0] : parsed;
};
const readsNumber = (token: string): boolean => {
try {
return typeof read(token) === "number";
} catch {
return false;
}
};
// String-advertised token grammars (for example --port) still count.
const probed = PROBE_TOKENS.find(readsNumber);
if (!numericBranches(item) && probed === undefined) continue;
let validate: ((value: unknown) => boolean) | undefined;
try {
validate = ajv.compile(item as Parameters<Ajv2020["compile"]>[0]);
validate = ajv.compile(
property as Parameters<Ajv2020["compile"]>[0],
);
} catch (error) {
failures.push(
`${label}: Ajv could not compile the advertised schema (${error instanceof Error ? error.message : String(error)})`,
@@ -134,6 +107,15 @@ describe("decimal integer CLI admission", () => {
if (validate(token))
failures.push(`${label}: advertised schema accepts ${token}`);
}
const isolated = z.object({ [name]: field });
const read = (token: string): unknown => {
if (group === "options") {
return Parser.parse([flagName(name), token], {
options: isolated,
}).options[name];
}
return Parser.parse([token], { args: isolated }).args[name];
};
for (const token of NON_DECIMAL) {
try {
read(token);
@@ -142,9 +124,7 @@ describe("decimal integer CLI admission", () => {
// Decimal tokens are the only numeric spelling.
}
}
const candidate =
bounds(item)[0]?.min ??
(probed === undefined ? undefined : Number(probed));
const candidate = bounds(property)[0]?.min;
if (candidate === undefined) {
failures.push(`${label}: no in-range decimal candidate`);
continue;
@@ -158,7 +138,8 @@ describe("decimal integer CLI admission", () => {
);
continue;
}
const admitsLeadingZeros = JSON.stringify(item).includes("^[0-9]+$");
const admitsLeadingZeros =
JSON.stringify(property).includes("^[0-9]+$");
try {
const parsed = read(`0${decimal}`);
if (parsed !== candidate)
@@ -171,10 +152,9 @@ describe("decimal integer CLI admission", () => {
`${label}: leading-zero decimal was rejected (${error instanceof Error ? error.message : String(error)})`,
);
}
// Argv supplies text, so the advertisement must admit the token itself.
if (!validate(decimal))
if (!(validate(decimal) || validate(candidate)))
failures.push(
`${label}: advertised schema rejects in-range token "${decimal}"`,
`${label}: advertised schema rejects in-range ${decimal}`,
);
checked.push(label);
}
@@ -188,60 +168,3 @@ describe("decimal integer CLI admission", () => {
expect(failures).toEqual([]);
});
});
describe("decimal integer CLI advertisement", () => {
const schemaOf = async (command: string): Promise<unknown> => {
let stdout = "";
let exitCode = 0;
await createCli({}).serve([command, "--schema", "--json"], {
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
});
expect(exitCode, command).toBe(0);
return JSON.parse(stdout) as unknown;
};
it("publishes the token grammar in --schema for arguments and options", async () => {
const location = await schemaOf("trace-web-source-location");
expect(location).toMatchObject({
args: {
properties: {
line: {
description: "One-based generated line",
anyOf: [
{ type: "integer", minimum: 1 },
{ type: "string", pattern: "^[0-9]+$" },
],
},
},
},
});
expect(await schemaOf("inspect-web-network-capture")).toMatchObject({
options: {
properties: {
record: {
type: "array",
items: {
anyOf: [
{ type: "integer", minimum: 0 },
{ type: "string", pattern: "^[0-9]+$" },
],
},
},
},
},
});
expect(await schemaOf("environment-serve")).toMatchObject({
options: {
properties: {
port: { type: "string", pattern: "^(?:0|[1-9]\\d*)$" },
},
},
});
});
});
@@ -739,12 +739,7 @@ describe("reference-source outline-depth input schema", () => {
const cli = createCli({});
const advertised = await advertisedSchema(cli, "import-reference-source");
const outlineDepth = advertised.options?.properties?.outlineDepth;
expect(outlineDepth).toMatchObject({
anyOf: [
{ type: "integer", minimum: 0 },
{ type: "string", pattern: "^[0-9]+$" },
],
});
expect(outlineDepth).toMatchObject({ type: "integer", minimum: 0 });
const parser = parserSchema(cli, "import-reference-source", "options");
expect(parser?.safeParse({}).success).toBe(true);
@@ -790,10 +785,7 @@ describe("reference-source outline-depth input schema", () => {
if (parsed === undefined || !parsed.success)
throw new Error(`Expected outline-depth token ${token} to parse`);
expect(validate(parsed.data)).toBe(true);
expect(validate({ outlineDepth: token, detail: "summary" })).toBe(true);
}
for (const token of ["0x2", "2e0", "-1"])
expect(validate({ outlineDepth: token, detail: "summary" })).toBe(false);
expect(validate({ outlineDepth: -1 })).toBe(false);
expect(validate({ outlineDepth: 9007199254740992 })).toBe(false);
expect(validate({ outlineDepth: 2 })).toBe(false);
+1 -27
View File
@@ -3,7 +3,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { McpServer } from "@modelcontextprotocol/server";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
@@ -29,7 +28,6 @@ import {
createCacheProvider,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import {
validateDebuggerStatusResponse,
waitForStartedFile,
@@ -224,32 +222,8 @@ process.stdin.on("data", async (chunk) => {
"gdb_session_status",
sessionId,
);
expect(status).toMatchObject({
state: "closed",
closed_reason: expect.stringContaining(
"A GDB MI command was cancelled",
),
});
expect(status.state).toBe("closed");
expect(status).toHaveProperty("diagnostics_truncated");
// A later command names the retained reason instead of an argument error.
expect(
parseMcpToolError(
(await client.callTool({
name: "gdb_console",
arguments: { session_id: sessionId, command: "info registers" },
})) as CallToolResult,
).error,
).toMatchObject({
code: "capability_unavailable",
message: expect.stringContaining(
"The GDB debug session stopped and accepts no more commands. A GDB MI command was cancelled",
),
remediation: {
action: expect.stringMatching(
/close_gdb_session.*start_gdb_session/u,
),
},
});
await manager.closeAll();
expect(stopResults).toContain("stopped");
} finally {
@@ -1,11 +1,10 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("preserves Map omission and uncertain endpoint Evidence through public MCP", async () => {
@@ -21,64 +20,51 @@ it("preserves Map omission and uncertain endpoint Evidence through public MCP",
fetch('/network');
`,
);
const client = new Client({ name: "map-endpoint-regression", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
const { client } = await connectLocalToolsMcp();
const output = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root },
});
try {
await client.connect(transport);
const output = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root },
});
expect(output.isError).not.toBe(true);
const evidence = parseEvidence(output.structuredContent);
const text = output.content.find((block) => block.type === "text");
expect(text?.type === "text" ? JSON.parse(text.text) : undefined).toEqual(
evidence,
);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
const endpoints = result.graph.nodes.filter(
({ kind }) => kind === "endpoint",
);
expect(
endpoints
.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.value),
)
.sort(),
).toEqual(["/network", "/uncertain"]);
const candidate = endpoints.find(
({ observations }) => observations[0]?.properties.value === "/uncertain",
);
expect(candidate?.observations[0]).toMatchObject({
properties: { interpretation: "candidate" },
evidence: {
state: "inferred",
confidence: "low",
limitations: expect.arrayContaining([
expect.stringContaining("only a candidate"),
]),
},
});
expect(
result.graph.edges.find(
({ target_node_id }) => target_node_id === candidate?.node_id,
)?.evidence,
).toMatchObject({ state: "inferred", confidence: "low" });
expect(
endpoints.find(
({ observations }) => observations[0]?.properties.value === "/network",
)?.observations[0]?.evidence,
).toMatchObject({ state: "observed", confidence: "exact" });
} finally {
await client.close();
await transport.close();
}
expect(output.isError).not.toBe(true);
const evidence = parseEvidence(output.structuredContent);
const text = output.content.find((block) => block.type === "text");
expect(text?.type === "text" ? JSON.parse(text.text) : undefined).toEqual(
evidence,
);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
const endpoints = result.graph.nodes.filter(
({ kind }) => kind === "endpoint",
);
expect(
endpoints
.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.value),
)
.sort(),
).toEqual(["/network", "/uncertain"]);
const candidate = endpoints.find(
({ observations }) => observations[0]?.properties.value === "/uncertain",
);
expect(candidate?.observations[0]).toMatchObject({
properties: { interpretation: "candidate" },
evidence: {
state: "inferred",
confidence: "low",
limitations: expect.arrayContaining([
expect.stringContaining("only a candidate"),
]),
},
});
expect(
result.graph.edges.find(
({ target_node_id }) => target_node_id === candidate?.node_id,
)?.evidence,
).toMatchObject({ state: "inferred", confidence: "low" });
expect(
endpoints.find(
({ observations }) => observations[0]?.properties.value === "/network",
)?.observations[0]?.evidence,
).toMatchObject({ state: "observed", confidence: "exact" });
});
@@ -1,30 +1,15 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, expect } from "vitest";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { fileLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const client = new Client({ name: "literal-budget-boundary", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
});
beforeAll(async () => {
await client.connect(transport);
transport.stderr?.on("data", () => undefined);
});
afterAll(async () => {
await Promise.all([client.close(), transport.close()]);
});
const mcp = fileLocalToolsMcp();
const large = "x".repeat(200_000);
for (const scenario of [
@@ -67,7 +52,7 @@ for (const scenario of [
const cliEvidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(cliResponse.json);
const response = await client.callTool({
const response = await mcp.client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root },
});
@@ -131,7 +116,7 @@ for (const scenario of [
).toMatchObject({ sha256: digest });
}
expect(await readFile(path, "utf8")).toBe(source);
await client.ping();
await mcp.client.ping();
},
);
}
@@ -1,14 +1,13 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js";
import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -56,127 +55,113 @@ cliTest(
"analyze_javascript_application",
).outputSchema.parse(cliResponse.json);
const client = new Client({ name: "literal-payload-e2e", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
const { client } = await connectLocalToolsMcp();
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, format: "directory" },
});
transport.stderr?.on("data", () => undefined);
try {
await client.connect(transport);
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root, format: "directory" },
});
// Default wire delivery must retain every admitted value in the aggregate.
expect(response.isError).not.toBe(true);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = response.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing MCP text result");
const textEvidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(text.text));
expect(textEvidence.evidence_id).toBe(evidence.evidence_id);
const graph = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
const retainedPayloads = graph.nodes
.filter(
(node) =>
node.kind === "literal" &&
typeof node.properties.value === "string" &&
values.includes(node.properties.value),
)
.map((node) => node.properties.value);
expect(retainedPayloads.toSorted()).toEqual(values.toSorted());
const literal = graph.nodes.find(
(node) => node.kind === "literal" && node.properties.value === value,
);
if (literal === undefined) throw new Error("Complete literal was lost");
const sourceSha256 = createHash("sha256").update(source).digest("hex");
expect(literal.identity).toMatchObject({
module_path: "main.js",
artifact_sha256: sourceSha256,
});
expect(literal.evidence.location).toMatchObject({
available: true,
value: { kind: "source-range", source: "main.js" },
});
const choices = graph.nodes.filter(
// Default wire delivery must retain every admitted value in the aggregate.
expect(response.isError).not.toBe(true);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(response.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = response.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing MCP text result");
const textEvidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(text.text));
expect(textEvidence.evidence_id).toBe(evidence.evidence_id);
const graph = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
const retainedPayloads = graph.nodes
.filter(
(node) =>
node.kind === "literal" &&
(node.properties.value === 1 || node.properties.value === "1"),
);
expect(choices.length).toBe(4);
expect(new Set(choices.map((node) => node.node_id)).size).toBe(4);
expect(
graph.nodes.some(
(node) => node.kind === "literal" && node.properties.value === "",
),
).toBe(true);
typeof node.properties.value === "string" &&
values.includes(node.properties.value),
)
.map((node) => node.properties.value);
expect(retainedPayloads.toSorted()).toEqual(values.toSorted());
const literal = graph.nodes.find(
(node) => node.kind === "literal" && node.properties.value === value,
);
if (literal === undefined) throw new Error("Complete literal was lost");
const sourceSha256 = createHash("sha256").update(source).digest("hex");
expect(literal.identity).toMatchObject({
module_path: "main.js",
artifact_sha256: sourceSha256,
});
expect(literal.evidence.location).toMatchObject({
available: true,
value: { kind: "source-range", source: "main.js" },
});
const choices = graph.nodes.filter(
(node) =>
node.kind === "literal" &&
(node.properties.value === 1 || node.properties.value === "1"),
);
expect(choices.length).toBe(4);
expect(new Set(choices.map((node) => node.node_id)).size).toBe(4);
expect(
graph.nodes.some(
(node) => node.kind === "literal" && node.properties.value === "",
),
).toBe(true);
const traceResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: {
application: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
query: {
seed: { kind: "semantic-node", node_id: literal.node_id },
direction: "backward-provenance",
},
const traceResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: {
application: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
});
expect(traceResponse.isError).not.toBe(true);
const traceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(traceResponse.structuredContent);
const trace = javaScriptSemanticTraceResultSchema.parse(
traceEvidence.normalized_result,
);
expect(
trace.nodes.some(
(node) =>
node.node_id === literal.node_id && node.properties.value === value,
),
).toBe(true);
const valueTraceResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: {
application: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
query: {
seed: { kind: "literal", value: "1" },
direction: "backward-provenance",
},
query: {
seed: { kind: "semantic-node", node_id: literal.node_id },
direction: "backward-provenance",
},
});
expect(valueTraceResponse.isError).not.toBe(true);
const valueTraceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(valueTraceResponse.structuredContent);
const valueTrace = javaScriptSemanticTraceResultSchema.parse(
valueTraceEvidence.normalized_result,
);
expect(valueTrace.seed_node_ids).toEqual(
choices
.filter((node) => node.properties.value === "1")
.map((node) => node.node_id)
.sort(compareUnicodeCodePoints),
);
await client.ping();
} finally {
await client.close();
await transport.close();
}
},
});
expect(traceResponse.isError).not.toBe(true);
const traceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(traceResponse.structuredContent);
const trace = javaScriptSemanticTraceResultSchema.parse(
traceEvidence.normalized_result,
);
expect(
trace.nodes.some(
(node) =>
node.node_id === literal.node_id && node.properties.value === value,
),
).toBe(true);
const valueTraceResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: {
application: {
kind: "retained-evidence",
evidence_id: evidence.evidence_id,
},
query: {
seed: { kind: "literal", value: "1" },
direction: "backward-provenance",
},
},
});
expect(valueTraceResponse.isError).not.toBe(true);
const valueTraceEvidence = toolContract(
"trace_javascript_semantics",
).outputSchema.parse(valueTraceResponse.structuredContent);
const valueTrace = javaScriptSemanticTraceResultSchema.parse(
valueTraceEvidence.normalized_result,
);
expect(valueTrace.seed_node_ids).toEqual(
choices
.filter((node) => node.properties.value === "1")
.map((node) => node.node_id)
.sort(compareUnicodeCodePoints),
);
await client.ping();
},
);
@@ -1,9 +1,7 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, expect } from "vitest";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
@@ -12,24 +10,11 @@ import {
applicationFeatureTraceResultSchema,
type ApplicationFeatureTraceResult,
} from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js";
import { fileLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const client = new Client({ name: "native-handoff-identity", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
});
beforeAll(async () => {
await client.connect(transport);
transport.stderr?.on("data", () => undefined);
});
afterAll(async () => {
await Promise.all([client.close(), transport.close()]);
});
const mcp = fileLocalToolsMcp();
const sha256 = (value: Uint8Array) =>
createHash("sha256").update(value).digest("hex");
@@ -126,7 +111,7 @@ for (const available of [false, true]) {
const parent = toolContract(
"analyze_javascript_application",
).outputSchema.parse(analysis.json);
const summary = await client.callTool({
const summary = await mcp.client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: archive, detail: "summary" },
});
@@ -179,7 +164,7 @@ for (const available of [false, true]) {
const cliEvidence = toolContract(
"trace_application_feature",
).outputSchema.parse(cliTrace.json);
const response = await client.callTool({
const response = await mcp.client.callTool({
name: "trace_application_feature",
arguments: {
...query,
@@ -217,7 +202,7 @@ for (const available of [false, true]) {
expect(sha256(await readFile(archive))).toBe(archiveDigest);
if (available)
expect(sha256(await readFile(companion))).toBe(nativeDigest);
await client.ping();
await mcp.client.ping();
},
);
}
+26 -40
View File
@@ -1,12 +1,11 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -65,44 +64,31 @@ cliTest(
});
}
const client = new Client({ name: "timer-delay-boundary", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
const { client } = await connectLocalToolsMcp();
const reply = await client.callTool({
name: contract.name,
arguments: { input_path: root },
});
transport.stderr?.on("data", () => undefined);
try {
await client.connect(transport);
const reply = await client.callTool({
name: contract.name,
arguments: { input_path: root },
});
expect(reply.isError).not.toBe(true);
const evidence = contract.outputSchema.parse(reply.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = reply.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing inline Evidence");
expect(
contract.outputSchema.parse(JSON.parse(text.text)).evidence_id,
).toBe(evidence.evidence_id);
const retained = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
expect(retained.nodes.filter(({ kind }) => kind === "timer")).toEqual(
timers,
);
expect(
retained.nodes.some(
({ kind, label }) => kind === "function" && label === "greet",
),
).toBe(true);
await expect(client.ping()).resolves.toEqual({});
} finally {
await Promise.all([client.close(), transport.close()]);
}
expect(reply.isError).not.toBe(true);
const evidence = contract.outputSchema.parse(reply.structuredContent);
expect(evidence.evidence_id).toBe(cliEvidence.evidence_id);
const text = reply.content.find((item) => item.type === "text");
if (text?.type !== "text") throw new Error("Missing inline Evidence");
expect(contract.outputSchema.parse(JSON.parse(text.text)).evidence_id).toBe(
evidence.evidence_id,
);
const retained = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
).semantic_graph;
expect(retained.nodes.filter(({ kind }) => kind === "timer")).toEqual(
timers,
);
expect(
retained.nodes.some(
({ kind, label }) => kind === "function" && label === "greet",
),
).toBe(true);
await expect(client.ping()).resolves.toEqual({});
expect(await readFile(path, "utf8")).toBe(source);
},
);
@@ -1,8 +1,6 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, expect } from "vitest";
import { join } from "node:path";
import { expect } from "vitest";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js";
@@ -14,25 +12,12 @@ import { managedMemberInspectionSchema } from "../../../src/domain/managed/manag
import { managedApplicationGraphReferenceInputSchema } from "../../../src/domain/managed/managedApplicationGraph.js";
import { managedNativeVerificationResultSchema } from "../../../src/domain/managed/managedNativeVerificationSchemas.js";
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
import { fileLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, runFileCli } from "../../support/cli/cliFixture.js";
const client = new Client({ name: "managed-native-adapters", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" },
stderr: "pipe",
});
beforeAll(async () => {
await client.connect(transport);
transport.stderr?.on("data", () => undefined);
});
afterAll(async () => {
await Promise.all([client.close(), transport.close()]);
});
const fileMcp = fileLocalToolsMcp();
cliTest(
"preserves managed graph identity failures and wrong-role controls through CLI and stdio MCP",
@@ -74,7 +59,7 @@ cliTest(
const input = managedApplicationGraphReferenceInputSchema.parse({
managed_members: parseEvidence(JSON.parse(JSON.stringify(source))),
});
const response = await client.callTool({
const response = await fileMcp.client.callTool({
name: "project_managed_application_graph",
arguments: input,
});
@@ -110,7 +95,7 @@ cliTest(
const wrongRole = MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries;
const wrongInput = { managed_members: wrongRole };
const wrongResponse = await client.callTool({
const wrongResponse = await fileMcp.client.callTool({
name: "project_managed_application_graph",
arguments: wrongInput,
});
@@ -142,7 +127,7 @@ cliTest(
expect(wrongCommand.exitCode, wrongCommand.stderr).toBe(1);
expect(wrongCommand.json).toMatchObject(wrongError);
const control = await client.callTool({
const control = await fileMcp.client.callTool({
name: "project_managed_application_graph",
arguments: MANAGED_APPLICATION_GRAPH_EXAMPLE,
});
@@ -299,7 +284,7 @@ cliTest(
},
}),
);
const inspected = await client.callTool({
const inspected = await fileMcp.client.callTool({
name: "inspect_managed_native_boundaries",
arguments: { path: managedPath },
});
@@ -332,7 +317,7 @@ cliTest(
managed_boundaries: managed,
native_observations: [native],
};
const mcp = await client.callTool({
const mcp = await fileMcp.client.callTool({
name: "verify_managed_native_boundaries",
arguments: input,
});
@@ -379,7 +364,7 @@ cliTest(
const root = await createTestTempDirectory("rea-managed-native-adapters-");
const path = join(root, "fixture.dll");
await writeFile(path, buildManagedPeFixture({ pinvoke: {} }));
const inspected = await client.callTool({
const inspected = await fileMcp.client.callTool({
name: "inspect_managed_native_boundaries",
arguments: { path },
});
@@ -391,7 +376,7 @@ cliTest(
native_observations: [native],
};
const mcp = await client.callTool({
const mcp = await fileMcp.client.callTool({
name: "verify_managed_native_boundaries",
arguments: input,
});
@@ -429,7 +414,7 @@ cliTest(
"Some native Evidence operations were unsupported by this workflow.",
);
const tampered = await client.callTool({
const tampered = await fileMcp.client.callTool({
name: "verify_managed_native_boundaries",
arguments: {
...input,
@@ -1,154 +0,0 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { McpServer } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { silentLogger } from "../../../src/logger.js";
import { registerRizinDebugTools } from "../../../src/server/registerRizinDebugTools.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { validateDebuggerStatusResponse } from "../../support/reverseDebuggerMcpTestHelpers.js";
import { createInjectedProcess } from "../providers/RizinDebugSessionCoverage.fixtures.js";
import {
createRizinTestManager,
rizinTestInferiorTty,
} from "../providers/RizinTestInferiorTty.js";
const test = it.skipIf(process.platform === "win32");
const LIMIT_REASON = "Rizin command output exceeded the 8 byte command limit.";
// The injected stream simulates Rizin's NUL-framed protocol; it is not Rizin.
test.each([
{ cleanupRetry: false, command: "noisy", reason: LIMIT_REASON },
{ cleanupRetry: true, command: "noisy", reason: LIMIT_REASON },
{
cleanupRetry: false,
command: "q",
reason: "The Rizin process exited (exit code null, signal SIGTERM)",
},
])(
"reports why a Rizin session stopped through MCP after $command (cleanup retry: $cleanupRetry)",
async ({ cleanupRetry, command, reason }) => {
let stops = 0;
const injected = createInjectedProcess({
onWrite: (text, control) => {
const marker = /!echo (.+)\n/u.exec(text)?.[1];
if (marker === undefined) return;
if (text.startsWith("noisy\n"))
control.output(`too much output\0${marker}\0`);
else if (text.startsWith("q\n")) setImmediate(() => control.exit());
else if (text.startsWith("!echo __REA_CLEANUP_SYNC_"))
control.output(`${marker}\0`);
else if (text.startsWith("olj\n")) control.output(`[]\0${marker}\0`);
},
});
const manager = createRizinTestManager(
{ REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: "8" },
{
spawnProcess: injected.spawnProcess,
// The injected stream has no OS process; detach through the protocol.
stopProcess: async (session) => {
stops += 1;
if (cleanupRetry && stops === 1)
return { status: "incomplete", reason: "injected stop pending" };
if (session.exited) return { status: "already_exited" };
await session.detachInferior?.();
injected.control.exit();
return { status: "stopped" };
},
},
);
const server = new McpServer({ name: "rizin-stopped", version: "1" });
registerRizinDebugTools(
server,
manager,
silentLogger,
createToolResultDelivery(undefined),
);
const client = new Client({ name: "rizin-stopped", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const call = (name: string, arguments_: Record<string, string>) =>
client.callTool({
name,
arguments: arguments_,
}) as Promise<CallToolResult>;
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const { session_id } = toolContract(
"start_rizin_debug_session",
).outputSchema.parse(
(
await call("start_rizin_debug_session", {
path: "fixture.bin",
inferior_tty: await rizinTestInferiorTty(),
})
).structuredContent,
);
const stopping = await call("rizin_debug_command", {
session_id,
command,
});
if (cleanupRetry)
expect(parseMcpToolError(stopping).error.code).toBe(
"cleanup_incomplete",
);
else expect(stopping.isError).not.toBe(true);
// The next call retries retained cleanup first, then refuses the
// command with the retained diagnosis instead of an argument error.
const later = parseMcpToolError(
await call("rizin_debug_command", { session_id, command: "dr" }),
).error;
expect(later).toMatchObject({
code: "capability_unavailable",
message: expect.stringContaining(
"The Rizin debug session stopped and accepts no more commands.",
),
remediation: {
action: expect.stringMatching(
/close_rizin_debug_session.*start_rizin_debug_session/u,
),
},
details: { reason: expect.stringContaining(reason) },
});
expect(later.message).toContain(reason);
expect(stops).toBe(cleanupRetry ? 2 : 1);
const status = await validateDebuggerStatusResponse(
client,
"rizin_debug_session_status",
session_id,
);
expect(status).toMatchObject({
state: "closed",
closed_reason: expect.stringContaining(reason),
});
expect(
(await call("close_rizin_debug_session", { session_id })).isError,
).not.toBe(true);
// A released ID is unknown, which differs from a retained stopped session.
expect(
parseMcpToolError(
await call("rizin_debug_command", { session_id, command: "dr" }),
).error,
).toMatchObject({
code: "invalid_request",
message:
"No Rizin debug session has this session_id in this REA server.",
});
} finally {
await closeVerifierResources([
() => client.close(),
() => server.close(),
() => manager.closeAll(),
]);
}
},
);
@@ -695,9 +695,9 @@ it("rejects GDB commands queued before closeAll when their turn arrives", async
await expect(queued).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
_tag: "AnalysisInputError",
operation: "gdb_console",
reason: expect.stringContaining("REA is shutting down"),
issues: [{ path: ["session_id"], reason: "invalid_value" }],
},
});
await closing;
@@ -732,9 +732,9 @@ it("rejects GDB commands queued behind a session close", async () => {
await expect(queued).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
_tag: "AnalysisInputError",
operation: "gdb_console",
reason: expect.stringContaining("close_gdb_session started releasing"),
issues: [{ path: ["session_id"], reason: "invalid_value" }],
},
});
expect(commands.some((command) => command.includes('"run"'))).toBe(false);
@@ -785,9 +785,9 @@ it.each(["close", "retryCleanupFailures"] as const)(
const unavailable = {
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
_tag: "AnalysisInputError",
operation: "gdb_console",
reason: expect.stringContaining("close_gdb_session started releasing"),
issues: [{ path: ["session_id"], reason: "invalid_value" }],
},
};
// Finish the barrier before assertions so a regression cannot strand teardown.
@@ -1874,10 +1874,7 @@ it.each([
const command = await manager.execute(sessionId, "show version");
expect(command).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
operation: "gdb_console",
},
error: { _tag: "AnalysisInputError" },
});
expect(commands).not.toContain('-interpreter-exec console "show version"');
expect(fake.children[0]?.exitCode).toBeNull();
@@ -2304,11 +2301,7 @@ it("rejects the command queued behind a timed-out GDB request", async () => {
const rejected = await queued;
expect(rejected.ok).toBe(false);
if (!rejected.ok)
expect(rejected.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("timed out"),
});
if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisInputError");
});
it("retains a multi-run console command through its completion query before dispatching a follower", async () => {
@@ -2476,7 +2469,7 @@ it("retains unknown completion and partial Evidence when the completion query fa
await manager.execute(started.value.session_id, "show version"),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisCapabilityUnavailableError" },
error: { _tag: "AnalysisInputError" },
});
});
@@ -2607,7 +2600,7 @@ it.each([false, true])(
if (follower !== undefined)
expect(await follower).toMatchObject({
ok: false,
error: { _tag: "AnalysisCapabilityUnavailableError" },
error: { _tag: "AnalysisInputError" },
});
expect(commands.some((command) => command.includes("show version"))).toBe(
false,
@@ -4002,8 +3995,6 @@ it.each(["incomplete", "rejected"] as const)(
fake.children[0]?.exit(0, null);
return { status: "stopped" };
}
// That simulated verification stands for later stops of the session.
if (stopCalls > 3) return { status: "already_exited" };
return stopOwnedDebuggerProcess(session, name);
},
});
@@ -4053,25 +4044,15 @@ it.each(["incomplete", "rejected"] as const)(
);
expect(subsequent).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("GDB stdin write failed"),
},
error: { _tag: "AnalysisInputError" },
});
expect((await manager.retryCleanupFailures()).ok).toBe(false);
expect(stopCalls).toBe(2);
if (!failed.ok) expect(failed.error.cleanupIncomplete).toBe(true);
expect((await manager.retryCleanupFailures()).ok).toBe(true);
expect(stopCalls).toBe(3);
if (!failed.ok) expect(failed.error.cleanupIncomplete).toBe(false);
// The caller still holds the published ID, so the diagnosis outlives
// cleanup until close_gdb_session releases it.
expect(manager.status(started.value.session_id)).toMatchObject({
state: "closed",
closed_reason: expect.stringContaining("GDB stdin write failed"),
});
expect((await manager.close(started.value.session_id)).ok).toBe(true);
expect(manager.status(started.value.session_id)).toBeUndefined();
if (!failed.ok) expect(failed.error.cleanupIncomplete).toBe(false);
},
);
@@ -1,7 +1,10 @@
import { afterEach, expect, it, vi } from "vitest";
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../../../src/domain/analysisErrorCore.js";
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js";
import {
@@ -352,13 +355,7 @@ it.skipIf(process.platform === "win32")(
expect((await first).ok).toBe(true);
const second = await queued;
expect(second.ok).toBe(false);
if (!second.ok)
expect(second.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining(
"The Rizin process exited (exit code null, signal SIGTERM)",
),
});
if (!second.ok) expect(second.error).toBeInstanceOf(AnalysisInputError);
expect(writes.some((text) => text.includes("second\n"))).toBe(false);
},
);
+31 -1
View File
@@ -9,7 +9,7 @@ import {
} from "@modelcontextprotocol/client/stdio";
import { closeVerifierResources } from "../../scripts/lib/verifier-cleanup.mjs";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished } from "vitest";
import { afterAll, beforeAll, expect, onTestFinished } from "vitest";
import { createMcpSchemaValidation } from "./mcpSchemaValidation.js";
/** Default to native tools while preserving explicit caller configuration. */
@@ -89,3 +89,33 @@ export async function connectLocalToolsMcp(
};
return { client, call };
}
/**
* One production stdio MCP client for the calling test file. It connects
* before the file's tests. Teardown removes each cleanup only after it
* succeeds, so a failed cleanup stays owned.
*/
export const fileLocalToolsMcp = (
environment: Readonly<Record<string, string>> = {},
) => {
let connected: Awaited<ReturnType<typeof connectLocalToolsMcp>> | undefined;
const cleanups: Array<() => Promise<void>> = [];
beforeAll(async () => {
connected = await connectLocalToolsMcp(environment, (cleanup) => {
cleanups.push(cleanup);
});
});
afterAll(async () => {
for (let cleanup = cleanups.at(-1); cleanup; cleanup = cleanups.at(-1)) {
await cleanup();
cleanups.pop();
}
});
return {
get client() {
if (connected === undefined)
throw new Error("The file's MCP client is not connected");
return connected.client;
},
};
};