fix(frida): reject an unreadable script file before starting a session (#2211)

* fix(frida): reject an unreadable script file before starting a session

The shared instrument_with_frida workflow started the Frida session, which
attaches or spawns the target, before the provider read a file script
source. A missing script file then failed loadScript with a bare
invalid_request, and session cleanup resumed the paused spawn, so the
target ran without instrumentation. Check that the selected file is a
readable regular file first and report the path, or access_denied for
EACCES/EPERM, before any device effect.

Fixes #2209

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(frida): let cancellation win over the script file check

An already-aborted request with an unreadable file source returned the
input or access-denied error instead of AnalysisCancelledError. Check the
signal before the script file preflight and again after it, so file
sources keep the existing cancellation semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(frida): keep the pre-start checks out of instrument

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-12 23:10:39 +08:00
1 parent ae412654d5
commit c9aecde580
2 files changed
+127

No files matched your search

@@ -1,3 +1,6 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
@@ -102,6 +105,66 @@ it.each(["startSession", "instrument"] as const)(
},
);
it.each([
[join(tmpdir(), "rea-missing-frida-script.js"), "no file exists there"],
[tmpdir(), "it is not a regular file"],
])(
"rejects the unreadable script file %s before starting Frida",
async (path, reason) => {
let starts = 0;
const provider = createProvider({
async startSession() {
starts += 1;
return { ok: true, value: createSession() };
},
});
const service = new FridaInstrumentationService(provider);
const result = await service.instrument({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: { sourceKind: "file", path },
durationMs: 1,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisInputError);
expect((result.error as AnalysisInputError).issues).toEqual([
{
path: ["path"],
reason: "invalid_value",
message: `Cannot read Frida script file ${path}: ${reason}.`,
},
]);
expect(starts).toBe(0);
},
);
it("reports cancellation before checking a file script source", async () => {
const service = new FridaInstrumentationService(createProvider());
const controller = new AbortController();
controller.abort(new Error("cancelled before call"));
const result = await service.instrument(
{
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
source: {
sourceKind: "file",
path: join(tmpdir(), "rea-missing-frida-script.js"),
},
durationMs: 1,
},
controller.signal,
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
});
it("does not resume a spawned target when cancellation arrives during script loading", async () => {
const controller = new AbortController();
let resumes = 0;
@@ -1,11 +1,15 @@
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
import type {
@@ -19,6 +23,64 @@ import type {
StartFridaSessionInput,
} from "./FridaInstrumentationPort.js";
const unreadableScriptFile = async (
source: FridaScriptSource,
): Promise<AnalysisError | undefined> => {
if (source.sourceKind !== "file") return undefined;
let reason: string;
let cause: unknown;
try {
if (!(await stat(source.path)).isFile())
reason = "it is not a regular file";
else {
await access(source.path, constants.R_OK);
return undefined;
}
} catch (failure: unknown) {
cause = failure;
const code =
failure instanceof Error && "code" in failure ? failure.code : undefined;
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(
"instrument_with_frida",
source.path,
code,
{ cause },
);
reason =
code === "ENOENT" ? "no file exists there" : String(code ?? failure);
}
return new AnalysisInputError(
"instrument_with_frida",
cause === undefined ? undefined : { cause },
[
{
path: ["path"],
reason: "invalid_value",
message: `Cannot read Frida script file ${source.path}: ${reason}.`,
},
],
);
};
/**
* Reject an unreadable script file before any attach or spawn effect;
* cancellation before or during the check still wins.
*/
const rejectBeforeStart = async (
source: FridaScriptSource,
signal: AbortSignal | undefined,
): Promise<AnalysisError | undefined> => {
const cancelled = () =>
new AnalysisCancelledError(
"instrument_with_frida",
signal?.reason === undefined ? {} : { cause: signal.reason },
);
if (signal?.aborted) return cancelled();
const unreadable = await unreadableScriptFile(source);
return signal?.aborted ? cancelled() : unreadable;
};
/** Shared CLI/MCP workflows for Frida's device and instrumentation operations. */
export class FridaInstrumentationService {
readonly #provider: FridaInstrumentationPort;
@@ -163,6 +225,8 @@ export class FridaInstrumentationService {
AnalysisError
>
> {
const rejected = await rejectBeforeStart(input.source, signal);
if (rejected !== undefined) return err(rejected);
const started = await this.#startSession(
input,
signal,