From 5df59ee1fd630b9062f39e9f375ea850657d0aad Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 07:51:46 -0700 Subject: [PATCH] fix(errors): name fixed plist decode allowances instead of host memory in recovery (#5860) fix(errors): derive fixed-allowance recovery from a typed refusal field inspect_plist refused a binary plist above its fixed 64 MiB decode estimate, or nested deeper than 128 levels, with resource "memory" and no recovery, so the shared presentation told the caller to add worker or host memory. Neither raises that allowance. The 64 MiB source-size refusal likewise got the worker file-size advice for evidence replies. AnalysisResourceConstraintError now carries a typed fixedAllowance { name, kind }. fixedAllowanceOptions sets it instead of a prebuilt string, and the presentation derives the recovery from it, so every existing fixed-allowance producer moves to the same field. Operation re-attribution, IDA startup re-attribution and explicit redaction copy the field through one resourceConstraintRecovery helper; redaction still applies to the allowance name. The plist decode, nesting and input refusals use the field. A nesting refusal now reports maximum_nesting_depth instead of the decode byte limit it did not exceed. Fixes #5847 Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 9 ++- .../apple/PropertyListDecodeBudget.ts | 5 +- src/domain/analysisErrorCore.ts | 51 +++++++++++- src/domain/analysisErrorPresentation.ts | 24 +++--- src/domain/explicitSensitiveFailure.ts | 11 +-- src/domain/fixedAllowanceRemediation.ts | 11 ++- src/ida/IdaSessionClient.ts | 25 +++--- src/native/NativePlistSource.ts | 34 +++++--- src/native/parsers/plist.ts | 22 +++++- src/providers/AnalysisClientFailure.ts | 11 +-- .../boundary/captures/captureProvider.test.ts | 5 +- .../nativePlistSelectionFailureCli.test.ts | 77 ++++++++++++++++++- .../boundary/il2cpp/il2cppGhidraSeeds.test.ts | 21 +++-- .../providers/fixedAllowanceRecovery.test.ts | 55 +++++++++++++ .../native/nativePlistBinarySource.test.ts | 70 ++++++++++++++++- .../signals/recordingProvider.test.ts | 5 +- 16 files changed, 364 insertions(+), 72 deletions(-) create mode 100644 tests/boundary/providers/fixedAllowanceRecovery.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ebc5e646c4..c4aaa93b97 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -302,8 +302,13 @@ Binary plist inspection reads the original bytes through an owned file descripto so conversion cannot erase control characters, fractional dates, negative zero, or the high word of a 128-bit integer. It accepts at most 64 MiB of input and reserves an estimated 64 MiB decode budget before parsing; this estimate is not -a hard process-memory bound. Binary-source provenance records the `file` -classification without claiming a `plutil` conversion. +a hard process-memory bound. A refused input reports non-retryable +`resource_constraint` with `reported_limits.maximum_input_bytes`, +`maximum_decoded_bytes` or, for nesting deeper than 128 levels, +`maximum_nesting_depth`. These are fixed allowances: worker limits and host +memory do not raise them, and the unchanged request fails the same way. +Binary-source provenance records the `file` classification without claiming a +`plutil` conversion. A plist explicitly selected through the path input or as the active plist target reports an unreadable or malformed selection as `invalid_request`. A missing or diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts index 45ca4cbd0a..e4b43c1b3c 100644 --- a/src/artifacts/apple/PropertyListDecodeBudget.ts +++ b/src/artifacts/apple/PropertyListDecodeBudget.ts @@ -122,6 +122,9 @@ function* xmlPreflightTokens(xml: string) { } } +/** Deepest binary plist value nesting the reference walk admits. */ +export const MAX_BINARY_PLIST_NESTING_DEPTH = 128; + /** Resource or structural failure found while preflighting a plist. */ export type BinaryPlistExpansionFailure = "cycle" | "depth" | "expansion"; @@ -299,7 +302,7 @@ export const estimateBinaryPlistExpansion = ( continue; } pendingObjectCount -= 1; - if (entry.depth > 128) + if (entry.depth > MAX_BINARY_PLIST_NESTING_DEPTH) throw fail("depth", `binary plist nesting exceeds ${budget}`); if (active.has(entry.object)) throw fail("cycle", `binary plist reference cycle exceeds ${budget}`); diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index a00435b2f8..1a08034263 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "./jsonValue.js"; +import type { FixedAllowance } from "./fixedAllowanceRemediation.js"; import { AnalysisError, type AnalysisErrorOptions, @@ -33,6 +34,12 @@ export const ANALYSIS_RESOURCE_KINDS = [ export class AnalysisResourceConstraintError extends AnalysisError { readonly _tag = "AnalysisResourceConstraintError"; readonly remediationAction: string | undefined; + /** + * The fixed format, decoder, or capture allowance that refused the request. + * Worker limits and host resources do not raise it, so recovery derives from + * this allowance rather than from `resource`. + */ + readonly fixedAllowance: FixedAllowance | undefined; /** * Whether retrying the same request can succeed. A caller-selected bound is * deterministic for the same input, so it is not retryable even when its @@ -45,17 +52,53 @@ export class AnalysisResourceConstraintError extends AnalysisError { readonly resource: (typeof ANALYSIS_RESOURCE_KINDS)[number], readonly reason: string, readonly reportedLimits: Readonly> | null, - options?: AnalysisErrorOptions & { - readonly remediationAction?: string; - readonly retryable?: boolean; - }, + options?: AnalysisErrorOptions & ResourceConstraintRecovery, ) { super(`Resource constraint during ${operation}: ${reason}`, options); this.remediationAction = options?.remediationAction; + this.fixedAllowance = options?.fixedAllowance; this.retryable = options?.retryable; } } +/** Carry one refusal's recovery to an error re-attributed or redacted elsewhere. */ +export const resourceConstraintRecovery = ( + error: AnalysisResourceConstraintError, + text: (value: string) => string = (value) => value, +): ResourceConstraintRecovery => + error.fixedAllowance !== undefined + ? { + fixedAllowance: { + name: text(error.fixedAllowance.name), + kind: error.fixedAllowance.kind, + }, + ...retryable(error), + } + : { + ...(error.remediationAction === undefined + ? {} + : { remediationAction: text(error.remediationAction) }), + ...retryable(error), + }; + +const retryable = ( + error: AnalysisResourceConstraintError, +): { readonly retryable?: boolean } => + error.retryable === undefined ? {} : { retryable: error.retryable }; + +/** Producer-specific recovery, or a fixed allowance whose recovery is derived. */ +export type ResourceConstraintRecovery = + | { + readonly remediationAction?: string; + readonly fixedAllowance?: never; + readonly retryable?: boolean; + } + | { + readonly fixedAllowance: FixedAllowance; + readonly remediationAction?: never; + readonly retryable?: boolean; + }; + /** Provider-neutral invalid analysis input or output at an application boundary. */ export class AnalysisProtocolError extends AnalysisError { readonly _tag = "AnalysisProtocolError"; diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index ac9599e35d..a0e289c570 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -31,6 +31,7 @@ import { ProviderSelectionError } from "./providerSelectionError.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; import { UnknownRegistryError } from "./unknownRegistryError.js"; import { providerRetryAction } from "./providerOperationHealth.js"; +import { fixedAllowanceRemediation } from "./fixedAllowanceRemediation.js"; import { resolvedAnalysisError, type AnalysisError, @@ -67,15 +68,20 @@ export const analysisErrorRemediationAction = ( if (error instanceof AnalysisResourceConstraintError) return ( error.remediationAction ?? - (error.resource === "capacity" - ? "Wait for an active analysis to complete, then retry within the reported admission limits." - : error.resource === "transport" - ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." - : error.resource === "cpu" - ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." - : error.resource === "file-size" - ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." - : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") + (error.fixedAllowance !== undefined + ? fixedAllowanceRemediation( + error.fixedAllowance.name, + error.fixedAllowance.kind, + ) + : error.resource === "capacity" + ? "Wait for an active analysis to complete, then retry within the reported admission limits." + : error.resource === "transport" + ? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable." + : error.resource === "cpu" + ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." + : error.resource === "file-size" + ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." + : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") ); if ( error instanceof AnalysisTimeoutError && diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 14d680fb2f..5cdd9ae3bd 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -6,6 +6,7 @@ import { AnalysisOutputError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, + resourceConstraintRecovery, AnalysisCancelledError, AnalysisTimeoutError, } from "./analysisErrorCore.js"; @@ -183,15 +184,7 @@ const redactFailure = ( error.resource, text(error.reason), error.reportedLimits === null ? null : diagnostics(error.reportedLimits), - { - ...context, - ...(error.remediationAction === undefined - ? {} - : { remediationAction: text(error.remediationAction) }), - ...(error.retryable === undefined - ? {} - : { retryable: error.retryable }), - }, + { ...context, ...resourceConstraintRecovery(error, text) }, ); if (error instanceof ProviderCleanupError) return new ProviderCleanupError( diff --git a/src/domain/fixedAllowanceRemediation.ts b/src/domain/fixedAllowanceRemediation.ts index a121a7f15d..962c3987d9 100644 --- a/src/domain/fixedAllowanceRemediation.ts +++ b/src/domain/fixedAllowanceRemediation.ts @@ -39,11 +39,18 @@ export const fixedAllowanceRemediation = ( } }; +/** A named deterministic allowance and the recovery family it belongs to. */ +export interface FixedAllowance { + /** Display name, such as "The tshark JSON decoding allowance". */ + readonly name: string; + readonly kind: FixedAllowanceKind; +} + /** Non-retryable options for a fixed allowance. Occupied capacity stays retryable. */ export const fixedAllowanceOptions = ( allowance: string, kind: FixedAllowanceKind, -): { readonly retryable: false; readonly remediationAction: string } => ({ +): { readonly retryable: false; readonly fixedAllowance: FixedAllowance } => ({ retryable: false, - remediationAction: fixedAllowanceRemediation(allowance, kind), + fixedAllowance: { name: allowance, kind }, }); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index ffbd433ce2..52e6882661 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -14,6 +14,7 @@ import { AnalysisInputError, AnalysisTimeoutError, AnalysisResourceConstraintError, + resourceConstraintRecovery, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -530,33 +531,35 @@ export class IdaSessionClient implements AnalysisClient { ...(cause.userMessage === undefined ? {} : { userMessage: cause.userMessage }), - ...(cause.remediationAction === undefined - ? {} - : { remediationAction: cause.remediationAction }), diagnostics: { ...cause.diagnostics, startup_operation: cause.operation, }, }; + const action = + cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }; // Startup is shared, but each accepted operation owns its attribution. // Keep the original cached failure unchanged for later callers. const failure = cause instanceof AnalysisTimeoutError - ? new AnalysisTimeoutError(operation, cause.timeoutMs, options) + ? new AnalysisTimeoutError(operation, cause.timeoutMs, { + ...options, + ...action, + }) : cause instanceof AnalysisResourceConstraintError ? new AnalysisResourceConstraintError( operation, cause.resource, cause.reason, cause.reportedLimits, - { - ...options, - ...(cause.retryable === undefined - ? {} - : { retryable: cause.retryable }), - }, + { ...options, ...resourceConstraintRecovery(cause) }, ) - : new ProviderAdapterError(cause.providerId, operation, options); + : new ProviderAdapterError(cause.providerId, operation, { + ...options, + ...action, + }); throw failure .bindCleanupDependencies([cause]) .bindCleanupResourceOwner(cause); diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index 6b66a9f345..9624b07643 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -33,7 +33,11 @@ import { } from "../artifacts/ArtifactFileOperation.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js"; -import { MAX_NATIVE_PLIST_BYTES } from "./parsers/plist.js"; +import { + MAX_NATIVE_PLIST_BYTES, + NATIVE_PLIST_INPUT_ALLOWANCE, +} from "./parsers/plist.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; /** Resolved lookup identity and selection intent, independent of file readiness. */ export interface NativePlistSelection { @@ -214,15 +218,25 @@ export const nativePlistFailure = ( ); if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") { const resource = cause.boundary ?? "memory"; - return new AnalysisResourceConstraintError( - "inspect_plist", - resource, - cause.message, - resource === "file-size" - ? { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES } - : null, - { cause, capturedOutput, retryable: false }, - ); + return resource === "file-size" + ? new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, + { + cause, + capturedOutput, + ...fixedAllowanceOptions(NATIVE_PLIST_INPUT_ALLOWANCE, "input"), + }, + ) + : new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + null, + { cause, capturedOutput, retryable: false }, + ); } if (selection === undefined) return new ProviderAdapterError(IDENTITY.id, "inspect_plist", { diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index b6863b2e48..76976f5206 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -34,14 +34,22 @@ import { } from "../../artifacts/apple/PlistScalars.js"; import { estimateBinaryPlistExpansion, + MAX_BINARY_PLIST_NESTING_DEPTH, PropertyListDecodeBudgetFailure, } from "../../artifacts/apple/PropertyListDecodeBudget.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; /** Input ceiling matches native command output; projection is bounded separately. */ export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024; const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024; +const mebibytes = (bytes: number): string => + `${String(bytes / (1024 * 1024))} MiB`; + +/** Recovery name for the fixed source-size ceiling. */ +export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} native plist input allowance`; + const plistObject = z.record(z.string(), z.unknown()); /** Stable bundle metadata projected from one plist value. */ @@ -287,8 +295,18 @@ export const parsePlistBinary = ( "inspect_plist", "memory", cause.message, - { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES }, - { cause, retryable: false }, + cause.kind === "depth" + ? { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH } + : { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES }, + { + cause, + ...fixedAllowanceOptions( + cause.kind === "depth" + ? `The ${String(MAX_BINARY_PLIST_NESTING_DEPTH)}-level native binary plist nesting allowance` + : `The ${mebibytes(MAX_NATIVE_PLIST_DECODE_BYTES)} native binary plist decode allowance`, + "parser", + ), + }, ), ); return err( diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts index 56b24b6102..85c98e8db2 100644 --- a/src/providers/AnalysisClientFailure.ts +++ b/src/providers/AnalysisClientFailure.ts @@ -13,6 +13,7 @@ import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisResourceConstraintError, + resourceConstraintRecovery, AnalysisOutputError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; @@ -237,15 +238,7 @@ export const analysisClientFailureForOperation = ( cause.resource, cause.reason, cause.reportedLimits, - { - ...context, - ...(cause.retryable === undefined - ? {} - : { retryable: cause.retryable }), - ...(cause.remediationAction === undefined - ? {} - : { remediationAction: cause.remediationAction }), - }, + { ...context, ...resourceConstraintRecovery(cause) }, ); else result = new ProviderAdapterError(providerId, operation, { diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index 9265e8120c..f70875b586 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -15,6 +15,7 @@ import { createDeferred } from "../../fixtures/binarySession.js"; import { Cli } from "incur"; import { registerCaptureCommands } from "../../../src/cli/captureCommands.js"; import { silentLogger } from "../../../src/logger.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; // A command-boundary simulator: real process ownership/filesystem, not real TShark coverage. const fixture = async (mode = "ok", failCleanup = false) => { @@ -623,10 +624,10 @@ it.each([ reportedLimits: { output_bytes: 32 * 1024 * 1024 }, retryable: false, }); - expect(result.error.remediationAction).toContain( + expect(analysisErrorRemediationAction(result.error)).toContain( "The retained command-output allowance", ); - expect(result.error.remediationAction).not.toMatch( + expect(analysisErrorRemediationAction(result.error)).not.toMatch( /Review the reported worker memory limits/u, ); } diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts index 81046e68ad..87a9ea15fc 100644 --- a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -1,6 +1,7 @@ -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { buildBinary } from "plist"; import { describe, expect } from "vitest"; import { z } from "zod"; @@ -248,3 +249,77 @@ describe.skipIf(process.platform !== "darwin")( ); }, ); + +describe.skipIf(process.platform !== "darwin")( + "native plist CLI fixed allowances", + () => { + const hostMemoryAdvice = /available host memory|worker file-size limits/u; + + cliTest( + "names the binary plist decode allowance instead of host memory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-plist-decode-limit-"); + const path = join(root, "large.plist"); + // The decode estimate exceeds 64 MiB below the 64 MiB input ceiling. + const source = buildBinary({ + data: Buffer.alloc(22 * 1024 * 1024, 0x78), + }); + await writeFile(path, source); + const runs = []; + for (let attempt = 0; attempt < 2; attempt += 1) + runs.push( + await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }), + ); + const [first, second] = runs; + expect(first?.exitCode).toBe(1); + expect(second?.json).toEqual(first?.json); + expect(first?.json).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: + "The 64 MiB native binary plist decode allowance is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.", + }, + details: { + operation: "inspect_plist", + resource: "memory", + reported_limits: { maximum_decoded_bytes: 64 * 1024 * 1024 }, + }, + }); + expect(Buffer.compare(await readFile(path), source)).toBe(0); + }, + ); + + cliTest( + "names the binary plist input allowance instead of worker file-size limits", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-plist-input-limit-"); + const path = join(root, "huge.plist"); + await writeFile(path, "bplist00"); + await truncate(path, 64 * 1024 * 1024 + 1); + const result = await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: expect.stringContaining( + "Select a suitable complete artifact within the fixed 64 MiB native plist input allowance.", + ), + }, + details: { + resource: "file-size", + reported_limits: { maximum_input_bytes: 64 * 1024 * 1024 }, + }, + }); + expect(JSON.stringify(result.json)).not.toMatch(hostMemoryAdvice); + }, + ); + }, +); diff --git a/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts b/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts index 45b33ed455..28eb34cc0d 100644 --- a/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts +++ b/tests/boundary/il2cpp/il2cppGhidraSeeds.test.ts @@ -13,6 +13,7 @@ import { IL2CPP_METADATA_SECTION_NAMES, type Il2cppMetadata, } from "../../../src/domain/il2cpp/il2cppMetadata.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; const type = ( name: string, @@ -188,12 +189,16 @@ it("bounds folded-name retention against the actual Ghidra file admission", () = if (!(caught instanceof AnalysisResourceConstraintError)) throw new Error("Expected seed-file allowance refusal"); expect(caught.message).toMatch(/byte admission/); - expect(caught.remediationAction).toContain( + expect(analysisErrorRemediationAction(caught)).toContain( "The IL2CPP Ghidra seed-file allowance", ); - expect(caught.remediationAction).toContain("fixed byte allowance"); - expect(caught.remediationAction).not.toContain("fixed count"); - expect(caught.remediationAction).not.toContain("seed-name allowance"); + expect(analysisErrorRemediationAction(caught)).toContain( + "fixed byte allowance", + ); + expect(analysisErrorRemediationAction(caught)).not.toContain("fixed count"); + expect(analysisErrorRemediationAction(caught)).not.toContain( + "seed-name allowance", + ); expect(caught.retryable).toBe(false); }); it("refuses cyclic type ancestry before emitting a seed", () => { @@ -229,10 +234,12 @@ it("refuses names the real Ghidra seed parser cannot admit", () => { if (!(caught instanceof AnalysisResourceConstraintError)) throw new Error("Expected seed-name allowance refusal"); expect(caught.message).toMatch(/character admission/); - expect(caught.remediationAction).toContain( + expect(analysisErrorRemediationAction(caught)).toContain( "The IL2CPP Ghidra seed-name allowance", ); - expect(caught.remediationAction).toContain("fixed count"); - expect(caught.remediationAction).not.toContain("fixed byte allowance"); + expect(analysisErrorRemediationAction(caught)).toContain("fixed count"); + expect(analysisErrorRemediationAction(caught)).not.toContain( + "fixed byte allowance", + ); expect(caught.retryable).toBe(false); }); diff --git a/tests/boundary/providers/fixedAllowanceRecovery.test.ts b/tests/boundary/providers/fixedAllowanceRecovery.test.ts new file mode 100644 index 0000000000..b586e6352b --- /dev/null +++ b/tests/boundary/providers/fixedAllowanceRecovery.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; + +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js"; +import { + fixedAllowanceOptions, + fixedAllowanceRemediation, +} from "../../../src/domain/fixedAllowanceRemediation.js"; +import { analysisClientFailureForOperation } from "../../../src/providers/AnalysisClientFailure.js"; + +const refusal = () => + new AnalysisResourceConstraintError( + "provider_stage", + "memory", + "decoded representation exceeds the fixture allowance", + { maximum_decoded_bytes: 64 }, + fixedAllowanceOptions( + "The explicit-secret fixture decode allowance", + "parser", + ), + ); + +it.each([ + { + boundary: "operation attribution", + copy: (error: AnalysisError) => + analysisClientFailureForOperation(error, "fixture", "inspect_fixture"), + allowance: "The explicit-secret fixture decode allowance", + }, + { + boundary: "explicit redaction", + copy: (error: AnalysisError) => + redactExplicitFailure(error, ["explicit-secret"]), + allowance: "The [REDACTED] fixture decode allowance", + }, +])( + "keeps a fixed allowance's derived recovery through $boundary", + ({ copy, allowance }) => { + const copied = copy(refusal()); + expect(copied).toBeInstanceOf(AnalysisResourceConstraintError); + expect(copied).toMatchObject({ + fixedAllowance: { name: allowance, kind: "parser" }, + remediationAction: undefined, + retryable: false, + }); + expect(projectAnalysisError(copied)).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { action: fixedAllowanceRemediation(allowance, "parser") }, + details: { reported_limits: { maximum_decoded_bytes: 64 } }, + }); + }, +); diff --git a/tests/boundary/providers/native/nativePlistBinarySource.test.ts b/tests/boundary/providers/native/nativePlistBinarySource.test.ts index 2e40cba12f..8cb99886a2 100644 --- a/tests/boundary/providers/native/nativePlistBinarySource.test.ts +++ b/tests/boundary/providers/native/nativePlistBinarySource.test.ts @@ -11,9 +11,11 @@ import { import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js"; import { scanBinaryPlistScalars } from "../../../../src/artifacts/apple/PlistScalars.js"; import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js"; -import { buildBinary } from "plist"; +import { buildBinary, type PlistValue } from "plist"; import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { MAX_NATIVE_PLIST_BYTES } from "../../../../src/native/parsers/plist.js"; +import { MAX_BINARY_PLIST_NESTING_DEPTH } from "../../../../src/artifacts/apple/PropertyListDecodeBudget.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { NativeFixtureRunner, nativeMachoTarget, @@ -62,10 +64,76 @@ it("reports a binary source-size refusal as a typed resource limit", async () => resource: "file-size", reportedLimits: { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, retryable: false, + fixedAllowance: { kind: "input" }, }); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: expect.stringContaining( + "within the fixed 64 MiB native plist input allowance", + ), + }, + }); + expect(projected.remediation.action).not.toMatch(/worker file-size limits/u); expect(runner.tools).toEqual(["file"]); }); +const nested = (depth: number): PlistValue => + depth === 0 ? 1 : [nested(depth - 1)]; + +it.each([ + { + budget: "decode", + // Three times the source length exceeds the 64 MiB decode estimate while + // the source stays below the separate 64 MiB input ceiling. + value: { data: Buffer.alloc(22 * 1024 * 1024, 0x78) }, + reason: "binary plist object table exceeds the native plist decode budget", + limits: { maximum_decoded_bytes: 64 * 1024 * 1024 }, + allowance: "The 64 MiB native binary plist decode allowance", + }, + { + budget: "nesting", + value: nested(MAX_BINARY_PLIST_NESTING_DEPTH + 1), + reason: "binary plist nesting exceeds the native plist decode budget", + limits: { maximum_nesting_depth: MAX_BINARY_PLIST_NESTING_DEPTH }, + allowance: "The 128-level native binary plist nesting allowance", + }, +])( + "names the fixed binary plist $budget allowance instead of host memory", + async ({ value, reason, limits, allowance }) => { + const { root, path } = await binaryPath(); + await writeFile(path, buildBinary(value)); + const runner = new BinaryNativeRunner({ + file: "Apple binary property list\n", + }); + const client = binaryClient(root, runner); + + const result = await client.execute("inspect_plist", { path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected decode allowance refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error).toMatchObject({ + resource: "memory", + reason, + reportedLimits: limits, + retryable: false, + fixedAllowance: { name: allowance, kind: "parser" }, + }); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { resource: "memory", reported_limits: limits }, + }); + expect(projected.remediation.action).toBe( + `${allowance} is a fixed allowance. Worker memory limits, inherited limits, and additional host memory do not raise it. Select another complete input within the allowance when that preserves the needed observation. Repeating the unchanged request fails the same way.`, + ); + expect(runner.tools).toEqual(["file"]); + }, +); + it("keeps a malformed binary plist as a selected-input failure", async () => { const { root, path } = await binaryPath(); await writeFile(path, Buffer.from("bplist00bad")); diff --git a/tests/boundary/signals/recordingProvider.test.ts b/tests/boundary/signals/recordingProvider.test.ts index 078185bec3..6aaa72f98b 100644 --- a/tests/boundary/signals/recordingProvider.test.ts +++ b/tests/boundary/signals/recordingProvider.test.ts @@ -16,6 +16,7 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { Cli } from "incur"; @@ -736,10 +737,10 @@ it.each([ resource: "memory", retryable: false, }); - expect(result.error.remediationAction).toContain( + expect(analysisErrorRemediationAction(result.error)).toContain( "The retained command-output allowance", ); - expect(result.error.remediationAction).not.toMatch( + expect(analysisErrorRemediationAction(result.error)).not.toMatch( /Review the reported worker memory limits/u, ); }