From 3eae278da58ff7f80a3fc699cdb2e4f8d1c6dbee Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:56:32 +0800 Subject: [PATCH] fix(frida): preserve unknown capture policy facts (#9934) --- .../frida/FridaInstrumentationService.test.ts | 8 +++- src/contracts/fridaToolContracts.test.ts | 47 ++++++++++++++++++- src/domain/fridaCapturePolicy.ts | 44 ++++++++--------- src/frida/FridaInstrumentationManager.test.ts | 28 +++++++++-- src/frida/FridaInstrumentationManager.ts | 6 +-- src/frida/fridaCaptureLimits.ts | 29 +++++++----- 6 files changed, 118 insertions(+), 44 deletions(-) diff --git a/src/application/frida/FridaInstrumentationService.test.ts b/src/application/frida/FridaInstrumentationService.test.ts index bea7b7d50e..35b69d9076 100644 --- a/src/application/frida/FridaInstrumentationService.test.ts +++ b/src/application/frida/FridaInstrumentationService.test.ts @@ -100,6 +100,7 @@ it("keeps default Frida status identity and messages and refreshes every summary messages: [{ observed: 1 }], messages_truncated: false, }); + expect(complete.value.normalized_result).not.toHaveProperty("capture_limits"); expect(complete.value.limitations.join("\n")).not.toContain( "no runtime override", ); @@ -120,6 +121,7 @@ it("keeps default Frida status identity and messages and refreshes every summary }, }); expect(JSON.stringify(summary.value)).not.toContain('"messages":'); + expect(JSON.stringify(summary.value)).not.toContain('"capture_limits":'); expect(reads).toBe(2); const parent = [...records.values()][0]; expect(parent?.normalized_result).toMatchObject({ @@ -127,7 +129,7 @@ it("keeps default Frida status identity and messages and refreshes every summary }); expect(parent?.limitations).toEqual( expect.arrayContaining([ - expect.stringContaining("16777216"), + expect.stringContaining("selected value is unknown"), expect.stringContaining( "Already discarded observations cannot be recovered", ), @@ -162,7 +164,9 @@ it("names the Frida field cap and ignores flags inside a caller payload", () => }); if (!result.ok) throw result.error; const text = result.value.limitations.join("\n"); - expect(text).toContain("32768"); + expect(text).toContain("capture_limits.message_text_bytes"); + expect(text).toContain("selected value is unknown"); + expect(text).not.toContain("32768"); expect(text).not.toContain("16777216"); expect(text).not.toContain("4096"); }); diff --git a/src/contracts/fridaToolContracts.test.ts b/src/contracts/fridaToolContracts.test.ts index 4f22639c3c..b4845e757c 100644 --- a/src/contracts/fridaToolContracts.test.ts +++ b/src/contracts/fridaToolContracts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { TOOL_CONTRACTS } from "./toolContracts.js"; +import { toolContract, TOOL_CONTRACTS } from "./toolContracts.js"; import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "./fridaToolContracts.js"; import { isValidFridaRemoteAddress } from "../domain/fridaRemoteAddress.js"; @@ -168,3 +168,48 @@ describe("Frida tool contracts", () => { expect(isValidFridaRemoteAddress("user@frida-target")).toBe(false); }); }); + +it("keeps request defaults out of observed Frida policies", () => { + const start = toolContract("start_frida_session"); + const identity = { + session_id: "00000000-0000-4000-8000-000000000001", + device_id: "fixture", + target: "fixture", + pid: 1, + mode: "attach", + state: "detached", + }; + expect( + start.inputSchema.parse({ + mode: "attach", + pid: 1, + capture_limits: { session_bytes: 12345 }, + }), + ).toEqual({ + mode: "attach", + pid: 1, + capture_limits: { session_bytes: 12345 }, + }); + expect(start.outputSchema.parse(identity)).not.toHaveProperty( + "capture_limits", + ); + for (const capture_limits of [ + {}, + { session_bytes: 12345 }, + { session_bytes: -1 }, + ]) + expect( + start.outputSchema.safeParse({ ...identity, capture_limits }).success, + ).toBe(false); + const capture_limits = { + session_bytes: 12345, + message_type_bytes: 456, + message_text_bytes: 789, + json_nodes: 100, + json_depth: 10, + }; + expect(start.outputSchema.parse({ ...identity, capture_limits })).toEqual({ + ...identity, + capture_limits, + }); +}); diff --git a/src/domain/fridaCapturePolicy.ts b/src/domain/fridaCapturePolicy.ts index 00800913a4..e5302c8b8d 100644 --- a/src/domain/fridaCapturePolicy.ts +++ b/src/domain/fridaCapturePolicy.ts @@ -7,8 +7,8 @@ export const FRIDA_CAPTURE_MAX_JSON_DEPTH = MAX_JSON_DEPTH - 8; const CAPTURE_POLICY_DESCRIPTION = "Retention budgets selected before attaching or spawning, fixed for this session. Defaults: 16 MiB session, 4 KiB type, 32 KiB error text, 250000 JSON nodes and depth64. Session bytes include JSON/base64 expansion and capture metadata; a budget too small for one event retains no event. Raising these cannot recover already discarded observations or raise MCP/client delivery limits. Select larger budgets before a new explicitly chosen experiment; no REA restart is required."; -/** Capture policy is accepted before attachment and remains fixed for that session. */ -export const fridaCapturePolicySchema = z +/** Defaults belong to the session owner, before attachment. */ +const sessionCapturePolicySchema = z .strictObject({ session_bytes: z .int() @@ -27,31 +27,31 @@ export const fridaCapturePolicySchema = z }) .describe(CAPTURE_POLICY_DESCRIPTION); -/** Request fields preserve omission; defaults are resolved only by the session owner. */ -export const fridaCapturePolicyInputSchema = z +/** Observations report a full policy or omit it; omitted facts have no defaults. */ +export const fridaCapturePolicySchema = z .strictObject({ - session_bytes: fridaCapturePolicySchema.shape.session_bytes - .removeDefault() - .optional(), - message_type_bytes: fridaCapturePolicySchema.shape.message_type_bytes - .removeDefault() - .optional(), - message_text_bytes: fridaCapturePolicySchema.shape.message_text_bytes - .removeDefault() - .optional(), - json_nodes: fridaCapturePolicySchema.shape.json_nodes - .removeDefault() - .optional(), - json_depth: fridaCapturePolicySchema.shape.json_depth - .removeDefault() - .optional(), + session_bytes: + sessionCapturePolicySchema.shape.session_bytes.removeDefault(), + message_type_bytes: + sessionCapturePolicySchema.shape.message_type_bytes.removeDefault(), + message_text_bytes: + sessionCapturePolicySchema.shape.message_text_bytes.removeDefault(), + json_nodes: sessionCapturePolicySchema.shape.json_nodes.removeDefault(), + json_depth: sessionCapturePolicySchema.shape.json_depth.removeDefault(), }) + .describe( + "Retention budgets reported by the session owner. An omitted policy is unknown.", + ); + +/** Request fields preserve omission; defaults are resolved only by the session owner. */ +export const fridaCapturePolicyInputSchema = fridaCapturePolicySchema + .partial() .describe(CAPTURE_POLICY_DESCRIPTION); +export const resolveFridaCapturePolicy = (input: unknown) => + sessionCapturePolicySchema.safeParse(input); + export type FridaCapturePolicy = z.output; export type FridaCapturePolicyInput = z.input< typeof fridaCapturePolicyInputSchema >; -export const DEFAULT_FRIDA_CAPTURE_POLICY = Object.freeze( - fridaCapturePolicySchema.parse({}), -); diff --git a/src/frida/FridaInstrumentationManager.test.ts b/src/frida/FridaInstrumentationManager.test.ts index 7c8443a0c6..28549e9745 100644 --- a/src/frida/FridaInstrumentationManager.test.ts +++ b/src/frida/FridaInstrumentationManager.test.ts @@ -268,11 +268,13 @@ describe("FridaInstrumentationManager", () => { expect(fridaCaptureLimitations({ messages_truncated: false })).toEqual([]); expect( fridaCaptureLimitations({ messages_truncated: true }).join("\n"), - ).toContain("16777216"); + ).toContain("selected value is unknown"); const field = fridaCaptureLimitations({ messages: [{ type_truncated: true }], }).join("\n"); - expect(field).toContain("4096"); + expect(field).toContain("capture_limits.message_type_bytes"); + expect(field).toContain("selected value is unknown"); + expect(field).not.toContain("4096"); expect(field).not.toContain("16777216"); const text = fridaCaptureLimitations({ messages: [ @@ -282,7 +284,9 @@ describe("FridaInstrumentationManager", () => { }, ], }).join("\n"); - expect(text).toContain("32768"); + expect(text).toContain("capture_limits.message_text_bytes"); + expect(text).toContain("selected value is unknown"); + expect(text).not.toContain("32768"); expect(text).not.toContain("16777216"); expect(text).not.toContain("4096"); expect( @@ -1927,3 +1931,21 @@ it("retries only unresolved sessions and resolves the typed close aggregate", as expect(calls).toEqual([2, 1]); expect(failure.cleanupIncomplete).toBe(false); }); + +// Malformed or partial observations do not establish the session's policy. +it.each([{}, { session_bytes: 12345 }, { session_bytes: -1 }])( + "keeps observed capture policy unknown for %j", + (capture_limits) => { + const text = fridaCaptureLimitations({ + capture_limits, + messages_truncated: true, + messages: [{ type_truncated: true, description_truncated: true }], + }).join("\n"); + expect(text).toContain("selected value is unknown"); + for (const invented of ["16777216", "4096", "32768"]) + expect(text).not.toContain(invented); + expect(text).toContain( + "Already discarded observations cannot be recovered", + ); + }, +); diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index e263962f33..0d0bbae941 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -49,7 +49,7 @@ import { type FridaOwnedCaptureLimit, } from "./fridaCaptureLimits.js"; import { - fridaCapturePolicySchema, + resolveFridaCapturePolicy, type FridaCapturePolicy, } from "../domain/fridaCapturePolicy.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; @@ -305,9 +305,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { async #startSession( input: StartFridaSessionInput, ): Promise> { - const policy = fridaCapturePolicySchema.safeParse( - input.captureLimits ?? {}, - ); + const policy = resolveFridaCapturePolicy(input.captureLimits ?? {}); if (!policy.success) return err( analysisInputErrorFromIssues( diff --git a/src/frida/fridaCaptureLimits.ts b/src/frida/fridaCaptureLimits.ts index 5a6c4de78a..56944befba 100644 --- a/src/frida/fridaCaptureLimits.ts +++ b/src/frida/fridaCaptureLimits.ts @@ -1,5 +1,4 @@ import { - DEFAULT_FRIDA_CAPTURE_POLICY, fridaCapturePolicySchema, FRIDA_CAPTURE_MAX_JSON_DEPTH, type FridaCapturePolicy, @@ -32,28 +31,35 @@ const namedLimit = (value: unknown): FridaOwnedCaptureLimit | undefined => const limitationFor = ( limit: FridaOwnedCaptureLimit, - policy: FridaCapturePolicy, + policy: FridaCapturePolicy | undefined, ): string => { - const recover = (field: string, value: number, unit: string): string => - `Frida capture reached capture_limits.${field} (${String(value)} ${unit}). Select a larger capture_limits.${field} before starting a new explicitly chosen session or instrument_with_frida experiment; no REA restart is required. Already discarded observations cannot be recovered. Session bytes and MCP/client delivery budgets remain independent.`; + const recover = ( + field: string, + value: number | undefined, + unit: string, + ): string => + value === undefined + ? `Frida capture reached capture_limits.${field}; its selected value is unknown because the observation did not report a valid capture policy. Select capture_limits.${field} before starting a new explicitly chosen session or instrument_with_frida experiment. Already discarded observations cannot be recovered. Session bytes and MCP/client delivery budgets remain independent.` + : `Frida capture reached capture_limits.${field} (${String(value)} ${unit}). Select a larger capture_limits.${field} before starting a new explicitly chosen session or instrument_with_frida experiment; no REA restart is required. Already discarded observations cannot be recovered. Session bytes and MCP/client delivery budgets remain independent.`; switch (limit) { case "session_bytes": case "payload_bytes": case "binary_bytes": return recover( "session_bytes", - policy.session_bytes, + policy?.session_bytes, "bytes including JSON/base64 expansion and metadata", ); case "message_type": - return recover("message_type_bytes", policy.message_type_bytes, "bytes"); + return recover("message_type_bytes", policy?.message_type_bytes, "bytes"); case "message_text": - return recover("message_text_bytes", policy.message_text_bytes, "bytes"); + return recover("message_text_bytes", policy?.message_text_bytes, "bytes"); case "json_nodes": - return recover("json_nodes", policy.json_nodes, "nodes"); + return recover("json_nodes", policy?.json_nodes, "nodes"); case "json_depth": - return policy.json_depth < FRIDA_CAPTURE_MAX_JSON_DEPTH - ? recover("json_depth", policy.json_depth, "levels") + + return policy === undefined || + policy.json_depth < FRIDA_CAPTURE_MAX_JSON_DEPTH + ? recover("json_depth", policy?.json_depth, "levels") + ` The maximum is ${String(FRIDA_CAPTURE_MAX_JSON_DEPTH)} levels, reserving the surrounding Evidence envelope.` : `Frida capture reached capture_limits.json_depth (${String(policy.json_depth)} levels), the shared JSON representation allowance after reserving the Evidence envelope. No higher depth is supported. Select a script that sends shallower payloads for a new explicitly chosen experiment. Already discarded observations cannot be recovered.`; case "unrepresentable": @@ -100,8 +106,7 @@ export const fridaCaptureLimitations = (result: unknown): readonly string[] => { typeof result === "object" && result !== null && "capture_limits" in result ? fridaCapturePolicySchema.safeParse(result.capture_limits) : undefined; - const policy = - selected?.success === true ? selected.data : DEFAULT_FRIDA_CAPTURE_POLICY; + const policy = selected?.success === true ? selected.data : undefined; collectOwnedCaps(result, caps); return FRIDA_OWNED_CAPTURE_LIMITS.flatMap((limit) => caps.has(limit) ? [limitationFor(limit, policy)] : [],