fix(contracts): reject NUL bytes in Frida, Rizin, and GDB local path inputs (#3575)

Route the caller-selected local path fields in load_frida_script,
instrument_with_frida, start_frida_session, start_rizin_debug_session,
and start_gdb_session through the shared localPathStringSchema so a NUL
byte is refused at the advertised contract boundary instead of reaching
the filesystem or a provider subprocess and surfacing as a raw error.

Extend the OS-invalid-local-input acceptance harness to the tools that
were missing from it.
This commit is contained in:
Dery Ferdika authored and GitHub committed 2026-10-15 01:17:54 +08:00
1 parent 10aa74dfed
commit a732c859dd
4 files changed
+42 -9

No files matched your search

+5 -4
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { localPathStringSchema } from "../domain/localPath.js";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
@@ -122,7 +123,7 @@ export const FRIDA_TOOL_CONTRACTS = [
mode: z.enum(["attach", "spawn"]),
...deviceSelector.shape,
pid: z.number().int().positive().optional(),
program: z.string().min(1).optional(),
program: localPathStringSchema.optional(),
argv: z.array(z.string()).optional(),
})
.refine(
@@ -156,7 +157,7 @@ export const FRIDA_TOOL_CONTRACTS = [
session_id: sessionId,
source_kind: z.enum(["inline", "file"]),
source: z.string().optional(),
path: z.string().min(1).optional(),
path: localPathStringSchema.optional(),
})
.refine(
(value) =>
@@ -271,11 +272,11 @@ export const FRIDA_TOOL_CONTRACTS = [
mode: z.enum(["attach", "spawn"]),
...deviceSelector.shape,
pid: z.number().int().positive().optional(),
program: z.string().min(1).optional(),
program: localPathStringSchema.optional(),
argv: z.array(z.string()).optional(),
source_kind: z.enum(["inline", "file"]),
source: z.string().optional(),
path: z.string().min(1).optional(),
path: localPathStringSchema.optional(),
duration_ms: z.number().int().min(0).max(60_000).default(1_000),
})
.refine(
+2 -3
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { localPathStringSchema } from "../domain/localPath.js";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
@@ -29,9 +30,7 @@ export const GDB_TOOL_CONTRACTS = [
description:
"Start a BYO GDB process in MI3 mode when available, suppress startup files, and disable auto-loading. Persistent GDB sessions are available only on Linux, where REA can verify live inferior output descriptors through /proc before detaching; macOS descriptor verification is not implemented, and Windows Job Object cleanup can terminate debugger inferiors. Optionally set inferior_tty to a caller-selected terminal for future inferior runs; REA verifies the setting through GDB/MI before the session becomes ready. The selected terminal must remain available after GDB exits. A live inferior can be detached during close only while this durable output sink remains verified. The session persists in this REA MCP server process and receives an explicit session ID.",
inputSchema: z.strictObject({
inferior_tty: z
.string()
.min(1)
inferior_tty: localPathStringSchema
.optional()
.describe(
"Caller-selected terminal path for future inferior runs. GDB must report the exact configured path before the session is ready.",
+3 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { localPathStringSchema } from "../domain/localPath.js";
import type { ToolContract } from "./toolContractTypes.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "./toolEffects.js";
@@ -24,8 +25,8 @@ export const RIZIN_DEBUG_TOOL_CONTRACTS = [
description:
"Start a persistent Rizin debugger session using `-N` to suppress user settings and scripts while retaining plugins, `-0` for NUL framing, and `-d` with an optional caller-selected IO debugger backend. Requires a caller-selected Linux TTY for inferior stdout and stderr; the target may write directly to that terminal while running and after detach. REA verifies both destinations and `dbg.exitkills=false` before detaching. Backend and target capabilities depend on the installed build. The session lives in this REA MCP process. Persistent sessions require Linux `/proc` descriptor readback. In a shared MCP server, starting this session retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
inputSchema: z.strictObject({
path: z.string().min(1),
inferior_tty: z.string().min(1),
path: localPathStringSchema,
inferior_tty: localPathStringSchema,
backend: z.string().min(1).optional(),
}),
outputSchema: z.strictObject({
@@ -156,6 +156,38 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
import_map: { path: nul, base_url: "http://127.0.0.1/a" },
},
],
["start_frida_session", { mode: "spawn", device_id: "local", program: nul }],
[
"load_frida_script",
{
session_id: "00000000-0000-4000-8000-000000000001",
source_kind: "file",
path: nul,
},
],
[
"instrument_with_frida",
{
mode: "spawn",
device_id: "local",
program: nul,
source_kind: "inline",
source: "send(1);",
},
],
[
"instrument_with_frida",
{
mode: "spawn",
device_id: "local",
program: "/bin/true",
source_kind: "file",
path: nul,
},
],
["start_rizin_debug_session", { path: nul, inferior_tty: "/dev/pts/7" }],
["start_rizin_debug_session", { path: "/tmp/program", inferior_tty: nul }],
["start_gdb_session", { inferior_tty: nul }],
];
it("rejects OS-invalid local inputs through the real advertised MCP contracts", async () => {