Files
rea/src/cli/fridaCommands.ts
T
morluto 4cbcead46c fix(frida): share canonical discovery results across adapters
Own device and process discovery projection in the application workflow,
bound to each named output contract. CLI and MCP now consume that result
directly, including retained observations when final CLI cleanup fails.

Remove the duplicate adapter projections and preserve provider failures,
caller-selected identifiers, process records and cleanup diagnostics.

Validated with 103 focused tests, npm run check and docs:check. Real local
Frida 17.23.2 discovery on macOS arm64 returned the same three devices
through the compiled CLI and production stdio MCP; both now pass the
advertised schema. Remote endpoints and native Linux/Windows unverified.

Fixes #4637

Co-authored-by: shreyansh1729 <shreyanshiitian1729@gmail.com>
Co-authored-by: Kaushalendra-Marcus <yadavkausha4a5@gmail.com>
2026-10-19 17:35:10 +08:00

301 lines
10 KiB
TypeScript

import { z } from "incur";
import {
FridaInstrumentationService,
withRecoveredFridaCleanup,
} from "../application/frida/FridaInstrumentationService.js";
import type {
FridaInstrumentationPort,
FridaRemoteConnection,
} from "../application/frida/FridaInstrumentationPort.js";
import { FridaInstrumentationManager } from "../frida/FridaInstrumentationManager.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, type Result } from "../domain/result.js";
import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "../contracts/fridaToolContracts.js";
import { isValidFridaRemoteAddress } from "../contracts/fridaRemoteAddress.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Register Frida discovery and one-shot instrumentation CLI operations. */
export const registerFridaCommands = (
cli: CliInstance,
logger: Logger,
provider: FridaInstrumentationPort = new FridaInstrumentationManager(),
): void => {
const service = new FridaInstrumentationService(provider);
// CLI calls own a disposable provider; MCP retains its shared provider until
// server shutdown. Await cleanup here, including failed startup resources.
const withProviderCleanup = async <Value>(
operation: () => Promise<Result<Value, AnalysisError>>,
): Promise<Result<Value, AnalysisError>> => {
const result = await operation();
try {
await provider.closeAll();
} catch (cause: unknown) {
const cleanup = new ProviderCleanupError(
"frida",
["Frida sessions or connections"],
result.ok
? { partial_observation: jsonValueSchema.parse(result.value) }
: {},
{ cause, operation: "frida_cleanup" },
);
return err(
result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
"frida_cleanup",
),
);
}
return result.ok ? result : err(withRecoveredFridaCleanup(result.error));
};
const remoteOptions = z.object({
remoteAddress: z
.string()
.min(1)
.max(2048)
.refine(isValidFridaRemoteAddress)
.optional()
.describe("Remote Frida host address, optionally with a port"),
token: z.string().min(1).optional().describe("Remote authentication token"),
certificate: z
.string()
.min(1)
.optional()
.describe("Remote TLS certificate"),
origin: z.string().min(1).optional().describe("Remote connection origin"),
keepaliveInterval: z
.number()
.int()
.positive()
.optional()
.describe(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION),
});
const remoteFrom = (
options: z.infer<typeof remoteOptions>,
): FridaRemoteConnection | undefined =>
options.remoteAddress === undefined
? undefined
: {
address: options.remoteAddress,
...(options.token === undefined ? {} : { token: options.token }),
...(options.certificate === undefined
? {}
: { certificate: options.certificate }),
...(options.origin === undefined ? {} : { origin: options.origin }),
...(options.keepaliveInterval === undefined
? {}
: { keepaliveInterval: options.keepaliveInterval }),
};
const remoteAddressRequired = (
options: z.infer<typeof remoteOptions>,
): boolean =>
options.remoteAddress !== undefined ||
(options.token === undefined &&
options.certificate === undefined &&
options.origin === undefined &&
options.keepaliveInterval === undefined);
cli.command(CLI_COMMANDS.listFridaDevices, {
description: "List local Frida devices and an optional remote endpoint",
args: z.object({}),
options: remoteOptions.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ options }) =>
logCliCommand(logger, CLI_COMMANDS.listFridaDevices, async () => {
const result = await withProviderCleanup(() =>
service.listDevices(remoteFrom(options)),
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
cli.command(CLI_COMMANDS.listFridaProcesses, {
description: "List processes visible through a Frida device",
args: z.object({}),
options: remoteOptions
.extend({
deviceId: z
.string()
.min(1)
.optional()
.describe("Frida device identifier"),
})
.refine(
(options) =>
options.remoteAddress === undefined || options.deviceId === undefined,
"Choose either deviceId or remoteAddress, not both",
)
.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ options }) =>
logCliCommand(logger, CLI_COMMANDS.listFridaProcesses, async () => {
const remote = remoteFrom(options);
const result = await withProviderCleanup(() =>
service.listProcesses(
remote === undefined
? { deviceId: options.deviceId ?? "local" }
: { remote },
),
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
});
cli.command(CLI_COMMANDS.instrumentWithFrida, {
description: "Run one Frida script against a selected process and detach",
args: z.object({
mode: z
.enum(["attach", "spawn"])
.describe("Attach to a PID or spawn a program"),
target: z
.string()
.min(1)
.describe("Process ID for attach or program path for spawn"),
}),
options: remoteOptions
.extend({
deviceId: z
.string()
.min(1)
.optional()
.describe("Frida device identifier"),
script: z.string().optional().describe("Inline JavaScript to load"),
scriptFile: z
.string()
.min(1)
.optional()
.describe("Path to JavaScript source file"),
argument: z
.array(z.string())
.optional()
.describe("Arguments passed to a spawned target"),
durationMs: z
.number()
.int()
.min(0)
.max(60_000)
.default(1_000)
.describe("Instrumentation duration in milliseconds"),
})
.refine(
(options) =>
options.remoteAddress === undefined || options.deviceId === undefined,
"Choose either deviceId or remoteAddress, not both",
)
.refine(
remoteAddressRequired,
"Remote credentials and connection options require remoteAddress",
),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
if (args.mode === "attach" && options.argument !== undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["argument"],
reason: "invalid_value",
message:
"--argument is only valid in spawn mode; remove it when attaching to a process.",
},
],
),
);
if (
(options.script === undefined) ===
(options.scriptFile === undefined)
)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["script"],
reason: "invalid_value",
message:
"Provide exactly one of --script or --script-file.",
},
],
),
);
const remote = remoteFrom(options);
const selected =
remote === undefined
? { deviceId: options.deviceId ?? "local" }
: { remote };
const targetInput =
args.mode === "attach"
? /^[1-9][0-9]*$/u.test(args.target) &&
Number.isSafeInteger(Number(args.target)) &&
Number(args.target) > 0
? {
...selected,
mode: "attach" as const,
pid: Number(args.target),
}
: undefined
: {
...selected,
mode: "spawn" as const,
program: args.target,
...(options.argument === undefined
? {}
: { argv: options.argument }),
};
if (targetInput === undefined)
return projectAnalysisError(
new AnalysisInputError(
CLI_COMMANDS.instrumentWithFrida,
undefined,
[
{
path: ["target"],
reason: "invalid_value",
message: "attach mode needs a positive integer process ID.",
},
],
),
);
const result = await withProviderCleanup(() =>
service.instrument(
{
...targetInput,
source:
options.scriptFile === undefined
? { sourceKind: "inline", source: options.script ?? "" }
: { sourceKind: "file", path: options.scriptFile },
durationMs: options.durationMs,
},
signal,
),
);
if (!result.ok) return projectAnalysisError(result.error);
if (result.value.cleanupError !== null) process.exitCode = 1;
return {
evidence: result.value.evidence,
cleanup_error: result.value.cleanupError,
};
}),
),
});
};