diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 6ee083e6b9..c956bc487a 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -244,6 +244,7 @@ export const TOOL_EFFECTS: Readonly> = { mutatesSession: true, launchesProcess: true, accessesNetwork: true, + executesForeignCode: true, idempotent: false, }), instrument_with_frida: effects({ diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index 20a709040d..7800ba309f 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -588,10 +588,19 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { return err(unknownFridaSession("resume_frida_session", sessionId)); if (managed.mode !== "spawn" || managed.targetResumeObserved) return err( - new AnalysisCapabilityUnavailableError( - "frida", + new AnalysisInputError( "resume_frida_session", - "Only a paused process spawned by this session can be resumed.", + { + userMessage: + "Only a paused process spawned by this session can be resumed.", + }, + [ + { + path: ["session_id"], + reason: "invalid_value", + message: "Select a paused process spawned by this session.", + }, + ], ), ); const pendingScripts = [...managed.scripts.values()].filter( diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index ac6229e5fa..eb0732776d 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -298,6 +298,26 @@ it.each(["attached", "during-resume", "after-resume"] as const)( it("retains detached state and resume evidence when remote cleanup needs retry", async () => { const fixture = await connectedFixture(); + expect( + (await fixture.client.listTools()).tools.find( + (tool) => tool.name === "close_frida_session", + )?.annotations?.destructiveHint, + ).toBe(true); + const inventory = await fixture.client.callTool({ + name: "binary_session", + arguments: {}, + }); + const availability = expandToolAvailability( + toolContract("binary_session").outputSchema.parse( + inventory.structuredContent, + ).result.tool_availability, + ); + expect( + availability.find((tool) => tool.name === "close_frida_session"), + ).toMatchObject({ + effects: { executesForeignCode: true }, + annotations: { destructive: true }, + }); const started = await fixture.client.callTool({ name: "start_frida_session", arguments: { @@ -1434,3 +1454,35 @@ it.each([0, 1, 2, 3, 4, 8])( } }, ); + +it.each(["attached", "resumed"] as const)( + "reports %s Frida resume refusal as selected session input", + async (state) => { + const fixture = await connectedFixture(); + const started = + state === "attached" + ? await fixture.manager.startSession({ mode: "attach", pid: 512 }) + : await fixture.manager.startSession({ + mode: "spawn", + program: "/fixture", + }); + if (!started.ok) throw started.error; + const id = started.value.session_id; + if (state === "resumed") + expect((await fixture.manager.resumeSession(id)).ok).toBe(true); + const result = await fixture.client.callTool({ + name: "resume_frida_session", + arguments: { session_id: id }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [{ path: ["session_id"], reason: "invalid_value" }], + }, + }, + }); + expect(fixture.controls.resumes).toBe(state === "resumed" ? 1 : 0); + }, +);