From a732c859ddd005a0179854897a645b531bf163d3 Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:17:54 +0700 Subject: [PATCH] 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. --- src/contracts/fridaToolContracts.ts | 9 +++--- src/contracts/gdbToolContracts.ts | 5 ++- src/contracts/rizinDebugToolContracts.ts | 5 +-- .../tools/localToolContracts.test.ts | 32 +++++++++++++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/contracts/fridaToolContracts.ts b/src/contracts/fridaToolContracts.ts index d63759c925..cc07c2c53c 100644 --- a/src/contracts/fridaToolContracts.ts +++ b/src/contracts/fridaToolContracts.ts @@ -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( diff --git a/src/contracts/gdbToolContracts.ts b/src/contracts/gdbToolContracts.ts index f1858a14a3..03e4868f49 100644 --- a/src/contracts/gdbToolContracts.ts +++ b/src/contracts/gdbToolContracts.ts @@ -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.", diff --git a/src/contracts/rizinDebugToolContracts.ts b/src/contracts/rizinDebugToolContracts.ts index d638a143e9..12ffbf896b 100644 --- a/src/contracts/rizinDebugToolContracts.ts +++ b/src/contracts/rizinDebugToolContracts.ts @@ -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({ diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 7b911bd27e..9f3a092ba9 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -156,6 +156,38 @@ const pathRequests: readonly [string, Record][] = [ 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 () => {