diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 29e10a33c..0e6a3f196 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and CLI output are unchanged. Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics -and 30-second owned command deadline. The reply budget applies to the decoder -record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The +and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the +input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The +reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds +copies and encoding overhead. The pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results). diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index d68db74fd..8b1a51662 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -72,9 +72,13 @@ debugger context is `observed`. Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling -and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained; -hard limits are not raised. Complete oversized results fail explicitly. These -process limits do not constitute a sandbox for caller-supplied engine code. +and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input +bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a +decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a +large core that outruns them still fails explicitly instead of partially. Tighter +inherited soft/hard limits are retained; hard limits are not raised. Complete +oversized results fail explicitly. These process limits do not constitute a sandbox +for caller-supplied engine code. Resource exit statuses need matching private failure-branch markers. Unmarked launcher failures retain their actual status/output and unknown resource cause. diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 6aa456631..6aa9a3a96 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -51,6 +51,7 @@ import { PWNTOOLS_PROVIDER_IDENTITY, PWNTOOLS_LIMITS, } from "./PwntoolsRelease.js"; +import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; const HOST_REQUIREMENT = "The initial real-verified pwntools artifact profile supports Linux x64 only."; @@ -245,11 +246,23 @@ export class PwntoolsDecoder< undefined, context, ); + const inputLimit = pwntoolsInputBytes(this.environment); + if (!inputLimit.configured) + throw pwntoolsUnavailable( + inputLimit.error, + selectedPath, + undefined, + undefined, + { + ...context, + configurationKey: "REA_PWNTOOLS_MAX_INPUT_BYTES", + }, + ); phase = "artifact-read"; selectedPath = input.path; const snapshot = await readStableArtifact( input.path, - PWNTOOLS_LIMITS.inputBytes, + inputLimit.bytes, options?.signal, undefined, this.#resources, diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts index f5e1b2654..e36ae9503 100644 --- a/src/native/pwntools/PwntoolsFailures.ts +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -30,6 +30,7 @@ export interface PwntoolsFailureContext { readonly operation: string; readonly providerId: string; readonly precedingOutput?: AnalysisCapturedOutput; + readonly configurationKey?: string; } const DEFAULT_CONTEXT = { operation: "inspect_binary_layout", @@ -274,7 +275,7 @@ export const pwntoolsUnavailable = ( code: "provider_unavailable", reason, diagnostics: { - configuration_key: "REA_PWNTOOLS_PYTHON", + configuration_key: context.configurationKey ?? "REA_PWNTOOLS_PYTHON", executable_path: path, ...(systemCode === undefined ? {} : { system_code: systemCode }), }, diff --git a/src/native/pwntools/PwntoolsInputLimit.test.ts b/src/native/pwntools/PwntoolsInputLimit.test.ts new file mode 100644 index 000000000..d60c27b80 --- /dev/null +++ b/src/native/pwntools/PwntoolsInputLimit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; +import { pwntoolsUnavailable } from "./PwntoolsFailures.js"; +import { + PWNTOOLS_LIMITS, + PWNTOOLS_PROVIDER_IDENTITY, +} from "./PwntoolsRelease.js"; + +const CONSTRAINT = + "REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal integer from 1 to " + + "1073741824 bytes, and REA never installs Python or packages."; + +describe("pwntools input bound", () => { + it("keeps the shipped default when the override is absent", () => { + expect(pwntoolsInputBytes({})).toEqual({ + configured: true, + bytes: PWNTOOLS_LIMITS.inputBytes, + }); + expect(PWNTOOLS_LIMITS.inputBytes).toBe(32 * 1024 * 1024); + }); + + it.each([ + ["1", 1], + ["67108864", 64 * 1024 * 1024], + ["172879872", 172_879_872], + ["1073741824", 1024 * 1024 * 1024], + ] as const)("accepts %s as %s bytes", (value, expected) => { + expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual( + { configured: true, bytes: expected }, + ); + }); + + it.each([ + "", + "0", + "-1", + "33554432.0", + "1e8", + " 67108864", + "67_108_864", + "0x2000000", + "1073741825", + "99999999999999999999", + "not-a-number", + ])("rejects %s instead of widening the bound silently", (value) => { + expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual( + { + configured: false, + error: CONSTRAINT, + }, + ); + }); + + it("rejects a value above the ceiling that a float would round", () => { + const ceiling = pwntoolsInputBytes({ + REA_PWNTOOLS_MAX_INPUT_BYTES: "1073741824", + }); + const above = pwntoolsInputBytes({ + REA_PWNTOOLS_MAX_INPUT_BYTES: "10737418241", + }); + expect(ceiling.configured).toBe(true); + expect(above).toEqual({ configured: false, error: CONSTRAINT }); + }); + + it("never narrows the bound below the shipped default implicitly", () => { + const accepted = [1, 1024, PWNTOOLS_LIMITS.inputBytes].map((bytes) => + pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: String(bytes) }), + ); + expect(accepted.every((limit) => limit.configured)).toBe(true); + }); +}); + +describe("pwntools unavailability configuration key", () => { + const context = { + operation: "inspect_binary_layout", + providerId: PWNTOOLS_PROVIDER_IDENTITY.id, + }; + const reportedKey = (configurationKey?: string) => + pwntoolsUnavailable( + CONSTRAINT, + "/caller-selected/python", + undefined, + undefined, + configurationKey === undefined + ? context + : { ...context, configurationKey }, + ).rejections[0]?.diagnostics["configuration_key"]; + + it("keeps the engine key unless the failure is about the bound", () => { + expect(reportedKey()).toBe("REA_PWNTOOLS_PYTHON"); + expect(reportedKey("REA_PWNTOOLS_MAX_INPUT_BYTES")).toBe( + "REA_PWNTOOLS_MAX_INPUT_BYTES", + ); + }); +}); diff --git a/src/native/pwntools/PwntoolsInputLimit.ts b/src/native/pwntools/PwntoolsInputLimit.ts new file mode 100644 index 000000000..e6867e80f --- /dev/null +++ b/src/native/pwntools/PwntoolsInputLimit.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; + +// The artifact is read into one in-memory buffer and staged again as a private +// snapshot before the owned bridge parses it under a 3 GiB address-space +// reservation, so an override stays within a third of that reservation. +const MAXIMUM_INPUT_BYTES = 1024 * 1024 * 1024; + +/** Optional caller-supplied input bound, in bytes, without a decimal point. */ +export const pwntoolsInputBytesSchema = z + .string() + .regex(/^[1-9]\d*$/u, "must be a positive decimal integer") + .transform(Number) + .pipe(z.number().int().safe().max(MAXIMUM_INPUT_BYTES)); + +const CONSTRAINT = + `REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal integer from 1 to ` + + `${String(MAXIMUM_INPUT_BYTES)} bytes, and REA never installs Python or packages.`; + +/** Resolved input bound, or the reason the caller's override cannot be used. */ +export type PwntoolsInputBytes = + | { readonly configured: true; readonly bytes: number } + | { readonly configured: false; readonly error: string }; + +/** + * Resolve the admitted artifact size for the pwntools adapters. An unset value + * keeps the shipped default, so the bound is only ever widened deliberately. + */ +export const pwntoolsInputBytes = ( + environment: Readonly, +): PwntoolsInputBytes => { + const value = environment.REA_PWNTOOLS_MAX_INPUT_BYTES; + if (value === undefined) + return { configured: true, bytes: PWNTOOLS_LIMITS.inputBytes }; + const parsed = pwntoolsInputBytesSchema.safeParse(value); + return parsed.success + ? { configured: true, bytes: parsed.data } + : { configured: false, error: CONSTRAINT }; +}; diff --git a/tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts new file mode 100644 index 000000000..ca9f0bc2a --- /dev/null +++ b/tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts @@ -0,0 +1,89 @@ +import { open } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, onTestFinished } from "vitest"; + +import { PwntoolsLayoutProvider } from "../../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const unsupportedHost = process.platform !== "linux" || process.arch !== "x64"; +const DEFAULT_INPUT_BYTES = 33_554_432; +// One byte above the shipped bound, so the admission decision is the only +// difference between the first two cases. +const ARTIFACT_BYTES = DEFAULT_INPUT_BYTES + 1; + +/** Sparse regular file just larger than the shipped input bound. */ +const oversizedArtifact = async (): Promise => { + const root = await createTestTempDirectory("rea-pwntools-input-bound-"); + const path = join(root, "recorded-core"); + const handle = await open(path, "w"); + await handle.truncate(ARTIFACT_BYTES); + await handle.close(); + return path; +}; + +/** One admission attempt, reported as the projected caller-visible failure. */ +const inspect = async (override?: string) => { + const path = await oversizedArtifact(); + const provider = new PwntoolsLayoutProvider( + { + REA_PWNTOOLS_PYTHON: process.execPath, + ...(override === undefined + ? {} + : { REA_PWNTOOLS_MAX_INPUT_BYTES: override }), + }, + async (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", "process.stdout.write('no reply')"], + }), + ); + onTestFinished(async () => { + await provider.close(); + }); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected the bounded decoder to fail"); + const projected = projectAnalysisError(result.error); + return { projected, text: JSON.stringify(projected) }; +}; + +it.runIf(!unsupportedHost)( + "refuses an artifact above the shipped bound when no override is set", + async () => { + const { projected, text } = await inspect(); + expect(projected.code).toBe("invalid_request"); + expect(text).toContain( + `Selected artifact exceeds ${String(DEFAULT_INPUT_BYTES)} bytes`, + ); + }, +); + +it.runIf(!unsupportedHost)( + "admits the same artifact once the override widens the bound", + async () => { + const { projected, text } = await inspect(String(ARTIFACT_BYTES)); + // Admission passed, so the only remaining failure is that the owned decoder + // never wrote a reply, which also proves the process really ran. + expect(text).not.toContain("Selected artifact exceeds"); + expect(projected.code).toBe("unreadable_output"); + expect(text).toContain("no reply"); + }, +); + +it.runIf(!unsupportedHost)( + "refuses an unusable override before reading the artifact", + async () => { + const { projected, text } = await inspect("0"); + expect(projected.code).toBe("provider_unavailable"); + expect(projected.details).toMatchObject({ + rejections: [ + { diagnostics: { configuration_key: "REA_PWNTOOLS_MAX_INPUT_BYTES" } }, + ], + }); + expect(text).toContain("REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal"); + expect(text).not.toContain("Selected artifact exceeds"); + }, +);