fix(pwntools): let callers widen the artifact input bound (#2486)

* fix(pwntools): let callers widen the artifact input bound

The pwntools adapters rejected every artifact over 32 MiB before any
analysis ran, and nothing in configuration, environment, CLI or MCP
could raise that bound, so real recorded core dumps stayed unanalyzable.

Resolve the bound from REA_PWNTOOLS_MAX_INPUT_BYTES in front of the
shipped default. An absent value keeps 32 MiB, and a value outside
1..1073741824 bytes is refused as unusable provider configuration before
the artifact is read instead of falling back silently.

* fix(pwntools): name the bound variable in its own refusal

An unusable REA_PWNTOOLS_MAX_INPUT_BYTES travelled through the shared
engine-absence helper, which always stamped configuration_key
REA_PWNTOOLS_PYTHON, so a caller following that field was pointed at the
Python executable instead of the bound it had just set. The guide also
described the refusal as an invalid request while the decoder reports
provider_unavailable.

Let the failure context carry the key under review and pass the bound's
own name at that call site, then state the code callers actually receive
in the guide. Assert the key on the projected rejection so the engine
default and the bound override each stay attributed to the right setting.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
The one -Niu 2026-10-13 21:18:12 +08:00 committed by GitHub
parent 59d33a2733
commit c44d17fd3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 252 additions and 7 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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,

View File

@ -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 }),
},

View File

@ -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",
);
});
});

View File

@ -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<NodeJS.ProcessEnv>,
): 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 };
};

View File

@ -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<string> => {
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");
},
);