mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
feat(analysis): make resource budgets recoverable across CLI and MCP (#9706)
* refactor(analysis): centralize resource budgets and recovery facts * feat(analysis): expose ASAR and Ghidra runtime admission controls * feat(artifacts): expose reader admission and preserve limited recognition * feat(javascript): expose recoverable semantic precision budgets * feat(web): add recoverable source-map budgets and compact retained views * feat(web): make historical capture decoding budgets recoverable * fix(desktop): enforce selected view policies across capture and export * feat(runtime): make Frida and LLDB retention budgets selectable * feat(native): expose diagnostic budgets and preserve exhaustion causes * feat(managed): expose recoverable snapshot and representation budgets * feat(metadata): make Go and Rust inspection budgets recoverable * feat(wasm): expose representation and producer admissions * feat(evm): expose per-call worker resource budgets * feat(android): make resource and string-pool omissions recoverable * feat(producers): expose command and recorded-capture budgets * docs(analysis): record recovery contracts and integrated validation * fix(docs): link calibration oracle from published verification pages * fix(docs): restore desktop task anchors and receipt links * test(analysis): align fixtures with selectable budget contracts * fix(analysis): remove source-location projector dependency cycle * test(analysis): refresh budget recovery and verifier regressions * test(analysis): preserve format and publication recovery expectations * test(windows): align conformance with owned runtime admission * fix(desktop): preserve selected alias kinds in exported state * test(analysis): align provider fixtures with recoverable budgets * test(desktop): validate emitted alias metadata structurally * fix(ghidra): classify bounded Windows endpoint refusals * test(cli): assert selectable artifact admission recovery * fix(cli): preserve capture provenance in environment builds
This commit is contained in:
1 parent
55b6d2ca20
commit
cb4e5cfbff
515 files changed
+21066
-3637
No files matched your search
@@ -7,10 +7,15 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
MAX_NATIVE_CALL_CAPTURE_BYTES,
|
||||
nativeCallCaptureLimits,
|
||||
} from "../domain/native/nativeCallCaptureLimits.js";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
@@ -67,8 +72,6 @@ const OPERATION = "observe_native_calls";
|
||||
const PROVIDER = "native-macos";
|
||||
const LLDB_REQUIREMENT =
|
||||
"LLDB from Xcode or the Command Line Tools is not available";
|
||||
/** Captured target output kept per stream; the rest is counted, not stored. */
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
/**
|
||||
* LLDB startup, symbol loading and teardown beyond the observation window.
|
||||
* This grace has no runtime override; duration_ms is the caller-selected window.
|
||||
@@ -82,7 +85,7 @@ const lldbGraceRecovery = (
|
||||
const total = durationMs + DEADLINE_GRACE_MS;
|
||||
return {
|
||||
userMessage: `observe_native_calls reached its ${String(total)} ms server/provider deadline: duration_ms ${String(durationMs)} plus a fixed ${String(DEADLINE_GRACE_MS)} ms LLDB startup, symbol loading, and teardown grace. This is not the MCP client deadline.`,
|
||||
remediationAction: `This release has no runtime override for the ${String(DEADLINE_GRACE_MS)} ms grace. Increase duration_ms on the next call when the observation window was too short; the grace stays added to that window. Captured output beyond ${String(MAX_OUTPUT_BYTES)} bytes per stream is counted and not kept, and that byte allowance has no runtime override. Failure diagnostics keep at most ${String(DIAGNOSTIC_BYTES)} bytes and that excerpt has no runtime override. Allow execution, cleanup, and delivery headroom in the outer MCP client deadline. Confirm the owned process was killed and cleanup finished before retrying.`,
|
||||
remediationAction: `This release has no runtime override for the ${String(DEADLINE_GRACE_MS)} ms grace. Increase duration_ms on the next call when the observation window was too short; the grace stays added to that window. Captured output retention is selected with capture_limits.output_bytes before launch; changing it cannot recover an earlier capture. Failure diagnostics keep at most ${String(DIAGNOSTIC_BYTES)} bytes and that excerpt has no runtime override. Allow execution, cleanup, and delivery headroom in the outer MCP client deadline. Confirm the owned process was killed and cleanup finished before retrying.`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -113,6 +116,7 @@ const tracedSchema = z.strictObject({
|
||||
stable: z.boolean(),
|
||||
}),
|
||||
resource_limit_reached: z.boolean(),
|
||||
resource_limit: z.enum(["trace_bytes", "trace_frames"]).nullable().optional(),
|
||||
breakpoint_locations_truncated: z.boolean().optional(),
|
||||
target_output: z.strictObject({
|
||||
stdout_bytes: z.int().nonnegative(),
|
||||
@@ -607,6 +611,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
const directory = retained.root.path;
|
||||
const paths = lldbRuntimePaths(directory);
|
||||
const { input } = request;
|
||||
const captureLimits = nativeCallCaptureLimits(input.capture_limits);
|
||||
await writeFile(
|
||||
paths.config,
|
||||
JSON.stringify({
|
||||
@@ -625,7 +630,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
max_events: input.max_events,
|
||||
argument_registers: input.argument_registers,
|
||||
backtrace_frames: input.backtrace_frames,
|
||||
max_output_bytes: MAX_OUTPUT_BYTES,
|
||||
capture_limits: captureLimits,
|
||||
stdout_path: paths.stdout,
|
||||
stderr_path: paths.stderr,
|
||||
stdout_capture_path: paths.stdoutCapture,
|
||||
@@ -725,6 +730,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
run.target_output.stdout_truncated,
|
||||
run.target_output.stdout_complete,
|
||||
retained.files,
|
||||
captureLimits.output_bytes,
|
||||
),
|
||||
stderr: await capturedOutput(
|
||||
paths.stderrCapture,
|
||||
@@ -732,6 +738,7 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
run.target_output.stderr_truncated,
|
||||
run.target_output.stderr_complete,
|
||||
retained.files,
|
||||
captureLimits.output_bytes,
|
||||
),
|
||||
});
|
||||
const getPartialObservation = async (
|
||||
@@ -855,6 +862,16 @@ export class LldbCallTracer implements NativeCallTracer {
|
||||
},
|
||||
),
|
||||
);
|
||||
const readbackFailure =
|
||||
readback !== undefined && !readback.ok ? readback.error : undefined;
|
||||
if (
|
||||
output === undefined &&
|
||||
readbackFailure instanceof AnalysisResourceConstraintError
|
||||
)
|
||||
return failureWithPartial((partialObservation) => {
|
||||
readbackFailure.partialObservation = partialObservation;
|
||||
return readbackFailure;
|
||||
});
|
||||
if (output === undefined)
|
||||
return failureWithPartial(
|
||||
(partialObservation) =>
|
||||
@@ -1056,13 +1073,29 @@ const partialNativeObservation = async (options: {
|
||||
readonly version: string | null;
|
||||
readonly reason: NativeCallPartialObservation["coverage"]["reason"];
|
||||
}): Promise<PartialNativeObservationRead> => {
|
||||
const captureLimits = nativeCallCaptureLimits(
|
||||
options.request.input.capture_limits,
|
||||
);
|
||||
const [journal, pid, stdout, stderr] = await Promise.all([
|
||||
readLldbObservationJournal(options.observationPath, options.files),
|
||||
readLldbObservationJournal(
|
||||
options.observationPath,
|
||||
options.files,
|
||||
captureLimits.trace_bytes,
|
||||
),
|
||||
options.files.readTargetPid(options.pidPath),
|
||||
readPartialCapture(options.stdoutPath, options.files),
|
||||
readPartialCapture(options.stderrPath, options.files),
|
||||
readPartialCapture(
|
||||
options.stdoutPath,
|
||||
options.files,
|
||||
captureLimits.output_bytes,
|
||||
),
|
||||
readPartialCapture(
|
||||
options.stderrPath,
|
||||
options.files,
|
||||
captureLimits.output_bytes,
|
||||
),
|
||||
]);
|
||||
const observation = projectLldbPartialObservation({
|
||||
captureLimits,
|
||||
target: {
|
||||
path: options.request.executable,
|
||||
sha256: options.request.expectedSha256,
|
||||
@@ -1140,12 +1173,39 @@ const readTracerOutput = async (
|
||||
path: string,
|
||||
files: LldbReadbackFiles,
|
||||
): Promise<Result<z.infer<typeof tracerOutputSchema>, unknown>> => {
|
||||
const bytes = await files.read(path, (file) => file.readFile());
|
||||
const bytes = await files.read(path, async (file) => {
|
||||
const { size } = await file.stat();
|
||||
if (size > MAX_NATIVE_CALL_CAPTURE_BYTES)
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"file-size",
|
||||
"LLDB tracer result exceeds the runtime Buffer/single-string representation ceiling",
|
||||
{
|
||||
maximum_result_bytes: MAX_NATIVE_CALL_CAPTURE_BYTES,
|
||||
observed_result_bytes: size,
|
||||
},
|
||||
{
|
||||
retryable: false,
|
||||
remediationAction:
|
||||
"The combined LLDB result cannot fit the running Node Buffer/single-string representation. Select smaller capture_limits.trace_bytes or capture_limits.breakpoint_location_bytes, or fewer breakpoints before a new authorized observation. Increasing the client receive buffer cannot raise this ceiling. Retained journal/output observations remain partial; earlier omitted facts cannot be recovered.",
|
||||
},
|
||||
),
|
||||
);
|
||||
const read = await readBoundedPrefix(
|
||||
size,
|
||||
async (buffer, offset, length, position) =>
|
||||
(await file.read(buffer, offset, length, position)).bytesRead,
|
||||
);
|
||||
if (read.status === "read-failure") return err(read.cause);
|
||||
return ok(read.buffer.subarray(0, read.bytesRead));
|
||||
});
|
||||
if (!bytes.ok) return bytes;
|
||||
if (!bytes.value.ok) return bytes.value;
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.value,
|
||||
bytes.value.value,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(cause);
|
||||
@@ -1176,11 +1236,12 @@ const capturedOutput = async (
|
||||
truncated: boolean,
|
||||
complete: boolean,
|
||||
files: LldbReadbackFiles,
|
||||
maximumBytes: number,
|
||||
): Promise<CapturedOutputRead> => {
|
||||
const readback = await files.read(path, async (handle) => {
|
||||
const { size } = await handle.stat();
|
||||
const read = await readBoundedPrefix(
|
||||
Math.min(size, MAX_OUTPUT_BYTES),
|
||||
Math.min(size, maximumBytes),
|
||||
async (target, offset, length, position) =>
|
||||
(await handle.read(target, offset, length, position)).bytesRead,
|
||||
);
|
||||
@@ -1192,13 +1253,11 @@ const capturedOutput = async (
|
||||
text: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
bytes: observedBytes,
|
||||
truncated:
|
||||
truncated ||
|
||||
size > MAX_OUTPUT_BYTES ||
|
||||
read.status === "read-failure",
|
||||
truncated || size > maximumBytes || read.status === "read-failure",
|
||||
complete:
|
||||
read.status !== "read-failure" &&
|
||||
complete &&
|
||||
bytesRead >= Math.min(observedBytes, MAX_OUTPUT_BYTES),
|
||||
bytesRead >= Math.min(observedBytes, maximumBytes),
|
||||
},
|
||||
...(read.status === "read-failure" ? { readFailure: read.cause } : {}),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
NATIVE_CALL_CAPTURE_DEFAULTS,
|
||||
type NativeCallCaptureLimits,
|
||||
} from "../domain/native/nativeCallCaptureLimits.js";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import type { LldbReadbackFiles } from "./LldbReadbackFiles.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
@@ -5,10 +9,6 @@ import type { Result } from "../domain/result.js";
|
||||
import { nativeCallEventSchema } from "../domain/native/nativeCallObservation.js";
|
||||
import type { NativeCallPartialObservation } from "../domain/native/nativeCallPartialObservation.js";
|
||||
|
||||
/** Maximum journal size enforced by the LLDB bridge. */
|
||||
export const LLDB_OBSERVATION_JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_CAPTURE_BYTES = 1024 * 1024;
|
||||
|
||||
export interface ParsedLldbJournal {
|
||||
readonly events: ReturnType<typeof nativeCallEventSchema.parse>[];
|
||||
readonly otherStops: string[];
|
||||
@@ -58,6 +58,7 @@ export type BoundedPrefixRead =
|
||||
|
||||
/** Join safely retained journal and process evidence into its failure contract. */
|
||||
export const projectLldbPartialObservation = (input: {
|
||||
readonly captureLimits?: NativeCallCaptureLimits;
|
||||
readonly target: NativeCallPartialObservation["target"];
|
||||
readonly pid: number | undefined;
|
||||
readonly stdout: NativeCallPartialObservation["process"]["stdout"];
|
||||
@@ -69,6 +70,9 @@ export const projectLldbPartialObservation = (input: {
|
||||
readonly reason: NativeCallPartialObservation["coverage"]["reason"];
|
||||
}): NativeCallPartialObservation => ({
|
||||
kind: "native-call-observation",
|
||||
...(input.captureLimits === undefined
|
||||
? {}
|
||||
: { capture_limits: input.captureLimits }),
|
||||
target: input.target,
|
||||
process: {
|
||||
pid: input.pid ?? null,
|
||||
@@ -209,19 +213,20 @@ const readPrefix = (handle: FileHandle, requestedBytes: number) =>
|
||||
export const readLldbObservationJournal = (
|
||||
path: string,
|
||||
files: LldbReadbackFiles,
|
||||
maximumBytes: number = NATIVE_CALL_CAPTURE_DEFAULTS.trace_bytes,
|
||||
): Promise<Result<LldbObservationJournalRead, unknown>> =>
|
||||
files.read(path, async (handle) => {
|
||||
const { size } = await handle.stat();
|
||||
const length = Math.min(size, LLDB_OBSERVATION_JOURNAL_MAX_BYTES);
|
||||
const length = Math.min(size, maximumBytes);
|
||||
const read = await readPrefix(handle, length);
|
||||
const { buffer, bytesRead } = read;
|
||||
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
|
||||
const value: LldbObservationJournalValue = {
|
||||
parsed: parseLldbObservationJournal(buffer.subarray(0, bytesRead)),
|
||||
limitations: [
|
||||
...(size > LLDB_OBSERVATION_JOURNAL_MAX_BYTES
|
||||
...(size > maximumBytes
|
||||
? [
|
||||
"The observation journal exceeded 8388608 bytes (8 MiB); only its bounded prefix was read. This release has no runtime override.",
|
||||
`The observation journal exceeded capture_limits.trace_bytes (${String(maximumBytes)} bytes); only its bounded prefix was read. Select a larger trace_bytes budget before a new authorized observation; earlier omitted facts cannot be recovered.`,
|
||||
]
|
||||
: []),
|
||||
...(read.status === "eof"
|
||||
@@ -245,10 +250,11 @@ export const readLldbObservationJournal = (
|
||||
export const readPartialCapture = (
|
||||
path: string,
|
||||
files: LldbReadbackFiles,
|
||||
maximumBytes: number = NATIVE_CALL_CAPTURE_DEFAULTS.output_bytes,
|
||||
): Promise<Result<PartialCaptureRead, unknown>> =>
|
||||
files.read(path, async (handle) => {
|
||||
const { size } = await handle.stat();
|
||||
const length = Math.min(size, MAX_CAPTURE_BYTES);
|
||||
const length = Math.min(size, maximumBytes);
|
||||
const read = await readPrefix(handle, length);
|
||||
const { buffer, bytesRead } = read;
|
||||
if (read.status === "read-failure" && bytesRead === 0) throw read.cause;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nativeCallCaptureLimits } from "../domain/native/nativeCallCaptureLimits.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
@@ -43,8 +44,8 @@ export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [
|
||||
|
||||
export const NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION =
|
||||
"The breakpoint location list is incomplete; location_count gives the full matching count.";
|
||||
export const NATIVE_CALL_BREAKPOINT_LOCATION_BUDGET_LIMITATION =
|
||||
"Resolved breakpoint locations exceeded the aggregate 8388608-byte (8 MiB) metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome. This release has no runtime override.";
|
||||
const breakpointLocationBudgetLimitation = (bytes: number): string =>
|
||||
`Resolved breakpoint locations exceeded capture_limits.breakpoint_location_bytes (${String(bytes)} bytes); omitted locations are counted in location_count. This metadata limit does not change the process outcome. Select a larger budget before a new authorized observation; earlier omitted facts cannot be recovered.`;
|
||||
|
||||
/** Launch the active Mach-O under LLDB, record requested call entries, then stop it. */
|
||||
export const observeNativeCalls = async (
|
||||
@@ -122,6 +123,7 @@ const nativeCallFacts = (
|
||||
trace: NativeCallReadback,
|
||||
) => {
|
||||
const { run } = trace;
|
||||
const limits = nativeCallCaptureLimits(input.capture_limits);
|
||||
const terminated = run.terminated && trace.terminated;
|
||||
const terminationSummary = terminated
|
||||
? "the process was killed"
|
||||
@@ -131,6 +133,14 @@ const nativeCallFacts = (
|
||||
.map(({ index }) => index);
|
||||
const eventLimit = run.outcome === "event-limit";
|
||||
const resourceLimit = run.outcome === "resource-limit";
|
||||
const traceByteBudget = `capture_limits.trace_bytes (${String(limits.trace_bytes)} bytes)`;
|
||||
const traceFrameBudget = `capture_limits.trace_frames (${String(limits.trace_frames)} frames)`;
|
||||
const exhaustedTraceBudget =
|
||||
run.resource_limit === "trace_bytes"
|
||||
? traceByteBudget
|
||||
: run.resource_limit === "trace_frames"
|
||||
? traceFrameBudget
|
||||
: `${traceByteBudget} or ${traceFrameBudget}`;
|
||||
const breakpointLocationLimit = run.breakpoint_locations_truncated === true;
|
||||
const partial =
|
||||
eventLimit ||
|
||||
@@ -140,6 +150,7 @@ const nativeCallFacts = (
|
||||
run.outcome === "stop-limit" ||
|
||||
unresolved.length > 0;
|
||||
return {
|
||||
capture_limits: limits,
|
||||
target: {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
@@ -183,6 +194,9 @@ const nativeCallFacts = (
|
||||
status: partial ? "partial" : "complete",
|
||||
event_limit_reached: eventLimit,
|
||||
resource_limit_reached: resourceLimit,
|
||||
...(run.resource_limit == null
|
||||
? {}
|
||||
: { capture_limit: run.resource_limit }),
|
||||
...(breakpointLocationLimit
|
||||
? { breakpoint_locations_truncated: true }
|
||||
: {}),
|
||||
@@ -198,7 +212,7 @@ const nativeCallFacts = (
|
||||
: []),
|
||||
...(resourceLimit
|
||||
? [
|
||||
`Observation stopped before retaining the next complete event because the estimated 8388608-byte (8 MiB) trace-payload or 65536-frame aggregate budget was reached; ${terminationSummary}. This release has no runtime override for those budgets.`,
|
||||
`Observation stopped before retaining the next complete event because ${exhaustedTraceBudget} was reached; ${terminationSummary}. Select a larger corresponding budget before a new authorized observation; earlier omitted calls cannot be recovered.`,
|
||||
]
|
||||
: []),
|
||||
...(run.outcome === "duration-elapsed"
|
||||
@@ -213,7 +227,7 @@ const nativeCallFacts = (
|
||||
]),
|
||||
...(run.outcome === "stop-limit"
|
||||
? [
|
||||
`Observation stopped after repeated signal or exception stops; ${terminationSummary}.`,
|
||||
`Observation stopped at capture_limits.other_stops (${String(limits.other_stops)} signal or exception stops); ${terminationSummary}. Select a larger budget before a new authorized observation; earlier omitted stops cannot be recovered.`,
|
||||
]
|
||||
: []),
|
||||
...(unresolved.length === 0
|
||||
@@ -228,14 +242,14 @@ const nativeCallFacts = (
|
||||
? [NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION]
|
||||
: []),
|
||||
...(breakpointLocationLimit
|
||||
? [NATIVE_CALL_BREAKPOINT_LOCATION_BUDGET_LIMITATION]
|
||||
? [breakpointLocationBudgetLimitation(limits.breakpoint_location_bytes)]
|
||||
: []),
|
||||
...(trace.stdout === null || trace.stderr === null
|
||||
? ["One or more target output captures were unavailable."]
|
||||
: []),
|
||||
...(trace.stdout?.truncated === true || trace.stderr?.truncated === true
|
||||
? [
|
||||
"Target output beyond 1048576 bytes (1 MiB) per stream is counted but not kept. This release has no runtime override.",
|
||||
`Target output beyond capture_limits.output_bytes (${String(limits.output_bytes)} bytes per stream) is counted but not kept. Select a larger budget before a new authorized observation; earlier omitted output cannot be recovered.`,
|
||||
]
|
||||
: []),
|
||||
...(trace.stdout?.complete === false || trace.stderr?.complete === false
|
||||
|
||||
@@ -171,32 +171,24 @@ it("keeps earlier .modinfo entries when the last span is unterminated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing separator, an empty key, and an over-long field per entry", () => {
|
||||
it("reports a missing separator and an empty key as malformed entries", () => {
|
||||
const report = parse(
|
||||
kernelModuleFixture({
|
||||
modinfo: Buffer.concat([
|
||||
Buffer.from("license\0=GPL\0", "latin1"),
|
||||
Buffer.from(`${"k".repeat(257)}=x\0`, "latin1"),
|
||||
Buffer.from(`name=${"v".repeat(4097)}\0`, "latin1"),
|
||||
]),
|
||||
modinfo: Buffer.from("license\0=GPL\0", "latin1"),
|
||||
}),
|
||||
);
|
||||
expect(report.modinfo?.entries.map((entry) => entry.reason)).toEqual([
|
||||
"modinfo_missing_separator",
|
||||
"modinfo_empty_key",
|
||||
"modinfo_field_exceeds_limit",
|
||||
"modinfo_field_exceeds_limit",
|
||||
]);
|
||||
expect(report.coverage.status).toBe("partial");
|
||||
expect(report.modinfo?.entries[1]).toMatchObject({
|
||||
key: null,
|
||||
value: "GPL",
|
||||
});
|
||||
expect(report.modinfo?.entries[2]).toMatchObject({ key: null, value: "x" });
|
||||
expect(report.modinfo?.entries[3]).toMatchObject({
|
||||
key: "name",
|
||||
value: null,
|
||||
});
|
||||
expect(
|
||||
report.modinfo?.entries.every((entry) => entry.status === "malformed"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("closes __versions when the section size is not a multiple of 64", () => {
|
||||
|
||||
@@ -4,10 +4,11 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { elfObjectTypeName } from "../../domain/native/elfNotes.js";
|
||||
import {
|
||||
KERNEL_MODULE_VERSION_ENTRY_BYTES,
|
||||
MAX_KERNEL_MODULE_KEY_BYTES,
|
||||
DEFAULT_KERNEL_MODULE_KEY_BYTES,
|
||||
MAX_KERNEL_MODULE_RAW_BYTES,
|
||||
MAX_KERNEL_MODULE_SECTION_NAME_BYTES,
|
||||
MAX_KERNEL_MODULE_VALUE_BYTES,
|
||||
DEFAULT_KERNEL_MODULE_VALUE_BYTES,
|
||||
MAX_INSPECT_KERNEL_MODULE_FILE_BYTES,
|
||||
MODULE_SIGNATURE_INFO_BYTES,
|
||||
MODULE_SIGNATURE_MAGIC,
|
||||
MODULE_SIGNATURE_PKCS7_ID_TYPE,
|
||||
@@ -164,7 +165,7 @@ const parseModinfo = (
|
||||
section: ElfSection | undefined,
|
||||
use: SectionUse,
|
||||
examined: { count: number },
|
||||
maxEntries: number,
|
||||
input: InspectKernelModuleInput,
|
||||
): NonNullable<Report["modinfo"]> => {
|
||||
if (section === undefined)
|
||||
return {
|
||||
@@ -196,6 +197,8 @@ const parseModinfo = (
|
||||
reason: "modinfo_section_compressed",
|
||||
};
|
||||
const entries: NonNullable<Report["modinfo"]>["entries"] = [];
|
||||
let largestKeyBytes = 0;
|
||||
let largestValueBytes = 0;
|
||||
let at = 0;
|
||||
while (at < stored.length) {
|
||||
if (stored[at] === 0) {
|
||||
@@ -208,25 +211,27 @@ const parseModinfo = (
|
||||
const record = stored.subarray(at, stop);
|
||||
const raw = stored.subarray(at, terminated ? end + 1 : stored.length);
|
||||
examined.count += 1;
|
||||
exceeds(examined.count, maxEntries);
|
||||
exceeds(examined.count, input.max_entries);
|
||||
const separator = record.indexOf(0x3d);
|
||||
let key: string | null = null;
|
||||
let value: string | null = null;
|
||||
const omittedFields: Array<"key" | "value"> = [];
|
||||
let reason: NonNullable<Report["modinfo"]>["entries"][number]["reason"] =
|
||||
terminated ? null : "unterminated_modinfo_entry";
|
||||
if (separator < 0) reason ??= "modinfo_missing_separator";
|
||||
else {
|
||||
const keyBytes = record.subarray(0, separator);
|
||||
const valueBytes = record.subarray(separator + 1);
|
||||
largestKeyBytes = Math.max(largestKeyBytes, keyBytes.length);
|
||||
largestValueBytes = Math.max(largestValueBytes, valueBytes.length);
|
||||
if (keyBytes.length === 0) reason ??= "modinfo_empty_key";
|
||||
if (
|
||||
keyBytes.length > MAX_KERNEL_MODULE_KEY_BYTES ||
|
||||
valueBytes.length > MAX_KERNEL_MODULE_VALUE_BYTES
|
||||
)
|
||||
reason ??= "modinfo_field_exceeds_limit";
|
||||
if (keyBytes.length > 0 && keyBytes.length <= MAX_KERNEL_MODULE_KEY_BYTES)
|
||||
if (keyBytes.length > input.max_modinfo_key_bytes)
|
||||
omittedFields.push("key");
|
||||
if (valueBytes.length > input.max_modinfo_value_bytes)
|
||||
omittedFields.push("value");
|
||||
if (keyBytes.length > 0 && keyBytes.length <= input.max_modinfo_key_bytes)
|
||||
key = keyBytes.toString("latin1");
|
||||
if (valueBytes.length <= MAX_KERNEL_MODULE_VALUE_BYTES)
|
||||
if (valueBytes.length <= input.max_modinfo_value_bytes)
|
||||
value = valueBytes.toString("latin1");
|
||||
}
|
||||
entries.push({
|
||||
@@ -241,10 +246,30 @@ const parseModinfo = (
|
||||
raw_hex: rawHex(raw),
|
||||
status: reason === null ? "observed" : "malformed",
|
||||
reason,
|
||||
omitted_fields: omittedFields,
|
||||
});
|
||||
if (!terminated) break;
|
||||
at = end + 1;
|
||||
}
|
||||
for (const [field, selected, required, defaultBytes] of [
|
||||
[
|
||||
"key",
|
||||
input.max_modinfo_key_bytes,
|
||||
largestKeyBytes,
|
||||
DEFAULT_KERNEL_MODULE_KEY_BYTES,
|
||||
],
|
||||
[
|
||||
"value",
|
||||
input.max_modinfo_value_bytes,
|
||||
largestValueBytes,
|
||||
DEFAULT_KERNEL_MODULE_VALUE_BYTES,
|
||||
],
|
||||
] as const) {
|
||||
if (required > selected)
|
||||
use.limitations.push(
|
||||
`.modinfo ${field} text exceeds max_modinfo_${field}_bytes=${String(selected)} bytes (request budget; default ${String(defaultBytes)}, minimum 1, maximum ${String(MAX_INSPECT_KERNEL_MODULE_FILE_BYTES)}). Omitted fields keep their source range and digest; omission does not establish malformed syntax. Retry inspect_kernel_module with max_modinfo_${field}_bytes at least ${String(required)}, or --max-modinfo-${field}-bytes on the CLI. Applies on the next call without a REA restart; input, entry and raw-hex budgets do not change.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "present",
|
||||
location,
|
||||
@@ -532,7 +557,7 @@ export const parseKernelModule = (
|
||||
firstNamedSection(".modinfo"),
|
||||
use,
|
||||
examined,
|
||||
input.max_entries,
|
||||
input,
|
||||
);
|
||||
const versions = parseVersions(
|
||||
bytes,
|
||||
@@ -611,6 +636,7 @@ export const parseKernelModule = (
|
||||
unbacked > 0 ||
|
||||
ksymtab.some((section) => section.reason !== null) ||
|
||||
sectionIncomplete(modinfo) ||
|
||||
modinfo.entries.some((entry) => entry.omitted_fields.length > 0) ||
|
||||
sectionIncomplete(versions) ||
|
||||
modulePresence.status === "unsupported" ||
|
||||
(modulePresence.status === "present" && modulePresence.location === null) ||
|
||||
|
||||
@@ -33,8 +33,12 @@ import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import {
|
||||
capturedPwntoolsOutput,
|
||||
pwntoolsDecoderFailure,
|
||||
pwntoolsBudgetRecovery,
|
||||
} from "../pwntools/PwntoolsFailures.js";
|
||||
import { PWNTOOLS_LIMITS } from "../pwntools/PwntoolsRelease.js";
|
||||
import {
|
||||
resolvePwntoolsLimits,
|
||||
type PwntoolsProducerLimits,
|
||||
} from "../../domain/native/pwntoolsResourceLimits.js";
|
||||
import { readPwntoolsFailureEvidence } from "../pwntools/PwntoolsResourceLimits.js";
|
||||
import type { PwntoolsCommand } from "../pwntools/PwntoolsDecoder.js";
|
||||
|
||||
@@ -71,6 +75,7 @@ export const inspectPwndbgCore = async ({
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
probePath,
|
||||
producerLimits = resolvePwntoolsLimits(),
|
||||
}: {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly architecture: RecordedCrash["architecture"];
|
||||
@@ -81,6 +86,7 @@ export const inspectPwndbgCore = async ({
|
||||
readonly runCommand: PwntoolsCommand;
|
||||
/** Effective `pwntools_timeout_ms` for this debugger command. */
|
||||
readonly timeoutMs: number;
|
||||
readonly producerLimits?: PwntoolsProducerLimits;
|
||||
readonly probePath?: (
|
||||
path: string,
|
||||
kind: "file" | "directory",
|
||||
@@ -149,6 +155,7 @@ export const inspectPwndbgCore = async ({
|
||||
architecture,
|
||||
gdbinit: entry,
|
||||
venv,
|
||||
producer_limits: producerLimits,
|
||||
}),
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
@@ -158,6 +165,7 @@ export const inspectPwndbgCore = async ({
|
||||
providerId: "pwndbg-core",
|
||||
precedingOutput: diagnostics,
|
||||
timeoutMs,
|
||||
producerLimits,
|
||||
};
|
||||
const failure = (cause: unknown) =>
|
||||
pwntoolsDecoderFailure(
|
||||
@@ -207,7 +215,7 @@ export const inspectPwndbgCore = async ({
|
||||
try {
|
||||
raw = await readStableArtifact(
|
||||
replyPath,
|
||||
PWNTOOLS_LIMITS.outputBytes,
|
||||
producerLimits.output_bytes,
|
||||
options?.signal,
|
||||
undefined,
|
||||
resources,
|
||||
@@ -249,6 +257,9 @@ export const inspectPwndbgCore = async ({
|
||||
`Core-only debugger reply failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{
|
||||
cause,
|
||||
...(cause instanceof ArtifactReaderFailure && cause.reason === "limit"
|
||||
? { remediationAction: pwntoolsBudgetRecovery(producerLimits) }
|
||||
: {}),
|
||||
...(cause instanceof ArtifactReaderFailure
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
@@ -276,6 +287,9 @@ export const inspectPwndbgCore = async ({
|
||||
throw pwndbgContextFinalizationFailure(
|
||||
new AnalysisOutputError(OPERATION, reply.message, {
|
||||
capturedOutput: recordedCrashStageOutput(diagnostics, output),
|
||||
...(reply.reason === "output-limit"
|
||||
? { remediationAction: pwntoolsBudgetRecovery(producerLimits) }
|
||||
: {}),
|
||||
}),
|
||||
finalization,
|
||||
);
|
||||
@@ -309,7 +323,12 @@ export const inspectPwndbgCore = async ({
|
||||
"debugger",
|
||||
gdb,
|
||||
environment.REA_PWNTOOLS_PYTHON ?? "",
|
||||
await readPwntoolsFailureEvidence(cause, stage, resources),
|
||||
await readPwntoolsFailureEvidence(
|
||||
cause,
|
||||
stage,
|
||||
resources,
|
||||
producerLimits,
|
||||
),
|
||||
failureContext,
|
||||
);
|
||||
throw primary;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
pwntoolsDecoderFailure,
|
||||
pwntoolsUnavailable,
|
||||
capturedPwntoolsOutput,
|
||||
pwntoolsBudgetRecovery,
|
||||
} from "./PwntoolsFailures.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, stat, writeFile } from "node:fs/promises";
|
||||
@@ -61,11 +62,18 @@ import type {
|
||||
} from "../../process/ProviderProcess.js";
|
||||
import { PendingOperations } from "../../process/PendingOperations.js";
|
||||
import {
|
||||
pwntoolsResourceLimitsSchema,
|
||||
pwntoolsResourceLimitsSchemaFor,
|
||||
readPwntoolsFailureEvidence,
|
||||
} from "./PwntoolsResourceLimits.js";
|
||||
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
||||
import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js";
|
||||
import {
|
||||
PWNTOOLS_LIMITS,
|
||||
pwntoolsProducerLimitsSchema,
|
||||
resolvePwntoolsLimits,
|
||||
type PwntoolsProducerLimitsInput,
|
||||
type PwntoolsProducerLimits,
|
||||
} from "../../domain/native/pwntoolsResourceLimits.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js";
|
||||
import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js";
|
||||
|
||||
@@ -100,6 +108,7 @@ export interface PwntoolsDecoderProfile<
|
||||
Input extends {
|
||||
readonly path: string;
|
||||
readonly pwntools_timeout_ms?: number | undefined;
|
||||
readonly producer_limits?: PwntoolsProducerLimitsInput | undefined;
|
||||
},
|
||||
Payload,
|
||||
Report extends DecoderReport,
|
||||
@@ -130,6 +139,7 @@ export interface PwntoolsDecoderProfile<
|
||||
readonly options: ExecutionOptions | undefined;
|
||||
readonly runCommand: PwntoolsCommand;
|
||||
readonly timeoutMs: number;
|
||||
readonly producerLimits: PwntoolsProducerLimits;
|
||||
}) => Promise<Report>;
|
||||
}
|
||||
|
||||
@@ -138,6 +148,7 @@ export class PwntoolsDecoder<
|
||||
Input extends {
|
||||
readonly path: string;
|
||||
readonly pwntools_timeout_ms?: number | undefined;
|
||||
readonly producer_limits?: PwntoolsProducerLimitsInput | undefined;
|
||||
},
|
||||
Payload,
|
||||
Report extends DecoderReport,
|
||||
@@ -244,14 +255,36 @@ export class PwntoolsDecoder<
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Report, AnalysisError>> {
|
||||
const environment = snapshotEnvironment(this.environment);
|
||||
const selectedLimits = pwntoolsProducerLimitsSchema.safeParse(
|
||||
input.producer_limits === undefined ? {} : input.producer_limits,
|
||||
);
|
||||
if (!selectedLimits.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
this.profile.operation,
|
||||
selectedLimits.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["producer_limits", ...issue.path],
|
||||
})),
|
||||
input,
|
||||
),
|
||||
);
|
||||
const pending = await this.#retire();
|
||||
if (pending !== undefined) return err(pending);
|
||||
const OPERATION = this.profile.operation;
|
||||
const timeoutMs = input.pwntools_timeout_ms ?? PWNTOOLS_LIMITS.timeoutMs;
|
||||
const timeoutMs =
|
||||
input.pwntools_timeout_ms ?? PWNTOOLS_LIMITS.command_timeout_ms;
|
||||
let producerLimits = {
|
||||
...resolvePwntoolsLimits(selectedLimits.data),
|
||||
command_timeout_ms: timeoutMs,
|
||||
};
|
||||
const context = {
|
||||
operation: OPERATION,
|
||||
providerId: this.identity.id,
|
||||
timeoutMs,
|
||||
get producerLimits() {
|
||||
return producerLimits;
|
||||
},
|
||||
};
|
||||
const replySchema = z.discriminatedUnion("ok", [
|
||||
z.strictObject({
|
||||
@@ -271,7 +304,9 @@ export class PwntoolsDecoder<
|
||||
"decoder",
|
||||
]),
|
||||
message: z.string(),
|
||||
reported_limits: pwntoolsResourceLimitsSchema.nullable().optional(),
|
||||
reported_limits: pwntoolsResourceLimitsSchemaFor(producerLimits)
|
||||
.nullable()
|
||||
.optional(),
|
||||
}),
|
||||
]);
|
||||
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
||||
@@ -324,7 +359,13 @@ export class PwntoolsDecoder<
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
const inputLimit = pwntoolsInputBytes(environment);
|
||||
const inputLimit =
|
||||
selectedLimits.data.input_bytes === undefined
|
||||
? pwntoolsInputBytes(environment)
|
||||
: {
|
||||
configured: true as const,
|
||||
bytes: selectedLimits.data.input_bytes,
|
||||
};
|
||||
if (!inputLimit.configured)
|
||||
throw pwntoolsUnavailable(
|
||||
inputLimit.error,
|
||||
@@ -337,6 +378,10 @@ export class PwntoolsDecoder<
|
||||
},
|
||||
);
|
||||
phase = "artifact-read";
|
||||
producerLimits = {
|
||||
...resolvePwntoolsLimits(selectedLimits.data, inputLimit.bytes),
|
||||
command_timeout_ms: timeoutMs,
|
||||
};
|
||||
selectedPath = input.path;
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
@@ -380,11 +425,15 @@ export class PwntoolsDecoder<
|
||||
}
|
||||
await writeFile(
|
||||
requestPath,
|
||||
JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }),
|
||||
JSON.stringify({
|
||||
snapshot_path: snapshotPath,
|
||||
reply_path: replyPath,
|
||||
producer_limits: producerLimits,
|
||||
}),
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
const execution = await this.#runCommand(
|
||||
timeoutMs,
|
||||
producerLimits,
|
||||
{
|
||||
command: environment.REA_PWNTOOLS_PYTHON ?? "",
|
||||
arguments: [
|
||||
@@ -412,7 +461,7 @@ export class PwntoolsDecoder<
|
||||
try {
|
||||
file = await readStableArtifact(
|
||||
replyPath,
|
||||
PWNTOOLS_LIMITS.outputBytes,
|
||||
producerLimits.output_bytes,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
@@ -483,6 +532,12 @@ export class PwntoolsDecoder<
|
||||
{
|
||||
cause,
|
||||
capturedOutput,
|
||||
...(cause instanceof ArtifactReaderFailure &&
|
||||
cause.reason === "limit"
|
||||
? {
|
||||
remediationAction: `Increase producer_limits.output_bytes above ${String(producerLimits.output_bytes)} bytes on the next call (CLI --producer-limits JSON); no restart is required. File, worker memory and transport limits remain independent.`,
|
||||
}
|
||||
: {}),
|
||||
...(cause instanceof ArtifactReaderFailure
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
@@ -503,11 +558,11 @@ export class PwntoolsDecoder<
|
||||
throw new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
`${reply.message} cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. Output is ${String(PWNTOOLS_LIMITS.outputBytes)} bytes, diagnostics are ${String(PWNTOOLS_LIMITS.diagnosticBytes)} bytes, and address space is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override.`,
|
||||
`${reply.message} ${pwntoolsBudgetRecovery(producerLimits)}`,
|
||||
reply.reported_limits ?? null,
|
||||
{
|
||||
capturedOutput,
|
||||
remediationAction: `Review the reported worker memory limits and available host memory. cpuSeconds ${String(PWNTOOLS_LIMITS.cpuSeconds)}, the ${String(PWNTOOLS_LIMITS.outputBytes)}-byte output allowance, the ${String(PWNTOOLS_LIMITS.diagnosticBytes)}-byte diagnostic allowance, and the ${String(PWNTOOLS_LIMITS.addressSpaceBytes)}-byte address-space allowance have no runtime override. pwntools_timeout_ms does not raise them. Repeating the unchanged request fails the same way.`,
|
||||
remediationAction: `Review reported limits and available host memory; allocation failure alone does not prove exhaustion. ${pwntoolsBudgetRecovery(producerLimits)}`,
|
||||
},
|
||||
);
|
||||
if (reply.reason === "format")
|
||||
@@ -536,6 +591,7 @@ export class PwntoolsDecoder<
|
||||
if (reply.reason === "output-limit")
|
||||
throw new AnalysisOutputError(OPERATION, reply.message, {
|
||||
capturedOutput,
|
||||
remediationAction: `Increase producer_limits.output_bytes above ${String(producerLimits.output_bytes)} bytes on the next call (CLI --producer-limits JSON); no restart is required. File output follows the selected output allowance unless file_bytes is explicit. Tighter inherited limits and transport remain independent.`,
|
||||
});
|
||||
throw new ProviderAdapterError(this.identity.id, OPERATION, {
|
||||
diagnostics: {
|
||||
@@ -573,8 +629,14 @@ export class PwntoolsDecoder<
|
||||
resources: this.#resources,
|
||||
options,
|
||||
runCommand: (spawn, classifyFailure) =>
|
||||
this.#runCommand(timeoutMs, spawn, options, classifyFailure),
|
||||
this.#runCommand(
|
||||
producerLimits,
|
||||
spawn,
|
||||
options,
|
||||
classifyFailure,
|
||||
),
|
||||
timeoutMs,
|
||||
producerLimits,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -605,6 +667,7 @@ export class PwntoolsDecoder<
|
||||
cause,
|
||||
root?.path,
|
||||
this.#resources,
|
||||
producerLimits,
|
||||
);
|
||||
const primary = pwntoolsDecoderFailure(
|
||||
cause,
|
||||
@@ -650,7 +713,7 @@ export class PwntoolsDecoder<
|
||||
}
|
||||
|
||||
async #runCommand(
|
||||
timeoutMs: number,
|
||||
limits: PwntoolsProducerLimits,
|
||||
spawn: OwnedProviderProcessSpawnOptions,
|
||||
options: ExecutionOptions | undefined,
|
||||
failure: (cause: unknown) => AnalysisError,
|
||||
@@ -671,8 +734,8 @@ export class PwntoolsDecoder<
|
||||
expectedCommand: spawn.expectedCommand ?? null,
|
||||
},
|
||||
{
|
||||
timeoutMs,
|
||||
diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes,
|
||||
timeoutMs: limits.command_timeout_ms,
|
||||
diagnosticBytes: limits.diagnostic_bytes,
|
||||
},
|
||||
{
|
||||
...(options?.signal === undefined
|
||||
|
||||
@@ -29,7 +29,7 @@ it.each([undefined, null, false, 0, "stop", { stop: true }, new Error("stop")])(
|
||||
},
|
||||
);
|
||||
|
||||
it("names pwntools_timeout_ms and leaves cpuSeconds and byte allowances fixed", () => {
|
||||
it("distinguishes the wall deadline from caller-selected producer resource budgets", () => {
|
||||
const projected = projectAnalysisError(
|
||||
pwntoolsDecoderFailure(
|
||||
new OwnedCommandFailure("timeout", "deadline expired"),
|
||||
@@ -48,8 +48,9 @@ it("names pwntools_timeout_ms and leaves cpuSeconds and byte allowances fixed",
|
||||
expect(projected.message).toContain("4500 ms");
|
||||
expect(projected.message).toContain("pwntools_timeout_ms");
|
||||
expect(projected.remediation.action).toContain("--pwntools-timeout-ms");
|
||||
expect(projected.remediation.action).toContain("cpuSeconds is 30");
|
||||
expect(projected.remediation.action).toContain("no runtime override");
|
||||
expect(projected.remediation.action).toContain("cpu_seconds=30");
|
||||
expect(projected.remediation.action).toContain("producer_limits");
|
||||
expect(projected.remediation.action).toContain("no REA restart");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
});
|
||||
|
||||
@@ -80,12 +81,12 @@ it("names the reported file-size allowance instead of address space", () => {
|
||||
expect(projected.code).toBe("resource_constraint");
|
||||
expect(projected.message).toContain("file_size_bytes");
|
||||
expect(projected.message).toContain("4096");
|
||||
expect(projected.message).toContain("outputBytes");
|
||||
expect(projected.message).toContain("producer_limits.file_bytes");
|
||||
expect(projected.message).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("4096");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("tighter inherited limit");
|
||||
expect(projected.remediation.action).not.toContain("address-space");
|
||||
expect(projected.remediation.action).not.toContain("address_space_bytes");
|
||||
expect(projected.remediation.action).not.toContain("1111");
|
||||
expect(projected.remediation.action).not.toContain("3221225472");
|
||||
});
|
||||
@@ -105,12 +106,12 @@ it("names the file-size cap when SIGXFSZ has no reported limit", () => {
|
||||
{ marker: { resource: "file-size", failure: null } },
|
||||
),
|
||||
);
|
||||
expect(projected.remediation.action).toContain("outputBytes");
|
||||
expect(projected.remediation.action).toContain("producer_limits.file_bytes");
|
||||
expect(projected.remediation.action).toContain("67108864");
|
||||
expect(projected.remediation.action).toContain("file_size_bytes");
|
||||
expect(projected.remediation.action).toContain("not reported");
|
||||
expect(projected.remediation.action).toContain("tighter inherited limit");
|
||||
expect(projected.remediation.action).not.toContain("address-space");
|
||||
expect(projected.remediation.action).not.toContain("address_space_bytes");
|
||||
expect(projected.remediation.action).not.toContain("3221225472");
|
||||
});
|
||||
|
||||
@@ -132,7 +133,7 @@ it("names memory in the marker recovery for an allocation failure", () => {
|
||||
expect(projected.code).toBe("resource_constraint");
|
||||
expect(projected.details).toMatchObject({ resource: "memory" });
|
||||
expect(projected.remediation.action).toContain("memory");
|
||||
expect(projected.remediation.action).toContain("address-space");
|
||||
expect(projected.remediation.action).toContain("address_space_bytes");
|
||||
expect(projected.remediation.action).toContain("3221225472");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
PWNTOOLS_LIMITS,
|
||||
resolvePwntoolsLimits,
|
||||
type PwntoolsProducerLimits,
|
||||
} from "../../domain/native/pwntoolsResourceLimits.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
bindArtifactFileCleanup,
|
||||
@@ -26,7 +31,6 @@ import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
|
||||
import type { PwntoolsFailureEvidence } from "./PwntoolsResourceLimits.js";
|
||||
import { nativeDeadlineRecovery } from "../../domain/native/nativeDeadlines.js";
|
||||
import {
|
||||
PWNTOOLS_LIMITS,
|
||||
PWNTOOLS_MEMORY_FAILURE_EXIT,
|
||||
PWNTOOLS_FILE_SIZE_FAILURE_EXIT,
|
||||
} from "./PwntoolsRelease.js";
|
||||
@@ -39,18 +43,26 @@ export interface PwntoolsFailureContext {
|
||||
readonly precedingOutput?: AnalysisCapturedOutput;
|
||||
readonly configurationKey?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly producerLimits?: PwntoolsProducerLimits;
|
||||
}
|
||||
|
||||
const fixedPwntoolsBudgets = `cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. The output allowance is ${String(PWNTOOLS_LIMITS.outputBytes)} bytes, the diagnostic allowance is ${String(PWNTOOLS_LIMITS.diagnosticBytes)} bytes, and the address-space allowance is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override for those budgets. pwntools_timeout_ms does not raise them. Artifact admission stays REA_PWNTOOLS_MAX_INPUT_BYTES (default ${String(PWNTOOLS_LIMITS.inputBytes)} bytes).`;
|
||||
/** Recovery identifies the selected controls rather than unrelated wall deadlines. */
|
||||
export const pwntoolsBudgetRecovery = (
|
||||
limits: PwntoolsProducerLimits = resolvePwntoolsLimits(),
|
||||
): string =>
|
||||
`Selected producer_limits: cpu_seconds=${String(limits.cpu_seconds)} seconds, output_bytes=${String(limits.output_bytes)}, diagnostic_bytes=${String(limits.diagnostic_bytes)}, address_space_bytes=${String(limits.address_space_bytes)}, file_bytes=${String(limits.file_bytes)}, input_bytes=${String(limits.input_bytes)} bytes. Increase the exhausted producer_limits field on the next call (CLI --producer-limits JSON); no REA restart is required. Tighter inherited OS limits remain enforced and are not raised by this request. pwntools_timeout_ms changes only the wall deadline.`;
|
||||
|
||||
/** Name the file-size cap and any reported effective limit, not address space. */
|
||||
const fileSizeAllowance = (reportedBytes: number | undefined): string => {
|
||||
const cap = String(PWNTOOLS_LIMITS.outputBytes);
|
||||
const fileSizeAllowance = (
|
||||
reportedBytes: number | undefined,
|
||||
selectedBytes: number,
|
||||
): string => {
|
||||
const cap = String(selectedBytes);
|
||||
const inherited =
|
||||
"A tighter inherited limit is retained. This release has no runtime override. pwntools_timeout_ms does not raise the file-size allowance.";
|
||||
"A tighter inherited limit is retained. Increase producer_limits.file_bytes on the next call (CLI --producer-limits JSON); no restart is required. pwntools_timeout_ms does not raise the file-size allowance.";
|
||||
return reportedBytes === undefined
|
||||
? `The file-size cap is ${cap} bytes (outputBytes). The effective file_size_bytes limit was not reported. ${inherited}`
|
||||
: `The effective file-size allowance is ${String(reportedBytes)} bytes (file_size_bytes). The file-size cap is ${cap} bytes (outputBytes). ${inherited}`;
|
||||
? `The file-size cap is ${cap} bytes (producer_limits.file_bytes). The effective file_size_bytes limit was not reported. ${inherited}`
|
||||
: `The effective file-size allowance is ${String(reportedBytes)} bytes (file_size_bytes). The file-size cap is ${cap} bytes (producer_limits.file_bytes). ${inherited}`;
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT = {
|
||||
@@ -91,6 +103,8 @@ const classifyPwntoolsDecoderFailure = (
|
||||
context: PwntoolsFailureContext,
|
||||
): AnalysisError => {
|
||||
const limitReport = failureEvidence.limits;
|
||||
const limits = context.producerLimits ?? resolvePwntoolsLimits();
|
||||
const budgetRecovery = pwntoolsBudgetRecovery(limits);
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof OwnedCommandFailure) {
|
||||
const currentOutput =
|
||||
@@ -152,24 +166,24 @@ const classifyPwntoolsDecoderFailure = (
|
||||
: cause.cancellation.reason,
|
||||
});
|
||||
if (cause.reason === "timeout") {
|
||||
const timeoutMs = context.timeoutMs ?? PWNTOOLS_LIMITS.timeoutMs;
|
||||
const timeoutMs = context.timeoutMs ?? PWNTOOLS_LIMITS.command_timeout_ms;
|
||||
return new AnalysisTimeoutError(context.operation, timeoutMs, {
|
||||
...outputOptions,
|
||||
...nativeDeadlineRecovery({
|
||||
timeoutMs,
|
||||
setting: "pwntools_timeout_ms",
|
||||
cliFlag: "--pwntools-timeout-ms",
|
||||
defaultMs: PWNTOOLS_LIMITS.timeoutMs,
|
||||
defaultMs: PWNTOOLS_LIMITS.command_timeout_ms,
|
||||
phase: "pwntools command deadline",
|
||||
unchanged: fixedPwntoolsBudgets,
|
||||
unchanged: budgetRecovery,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (cause.reason === "output-limit")
|
||||
return new AnalysisOutputError(context.operation, cause.message, {
|
||||
...outputOptions,
|
||||
userMessage: `${cause.message} ${fixedPwntoolsBudgets}`,
|
||||
remediationAction: fixedPwntoolsBudgets,
|
||||
userMessage: `${cause.message} ${budgetRecovery}`,
|
||||
remediationAction: budgetRecovery,
|
||||
});
|
||||
if (
|
||||
cause.reason === "process" &&
|
||||
@@ -178,7 +192,10 @@ const classifyPwntoolsDecoderFailure = (
|
||||
cause.snapshot?.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT &&
|
||||
cause.snapshot.signal === null))
|
||||
) {
|
||||
const allowance = fileSizeAllowance(limitReport?.limits?.file_size_bytes);
|
||||
const allowance = fileSizeAllowance(
|
||||
limitReport?.limits?.file_size_bytes,
|
||||
limits.file_bytes,
|
||||
);
|
||||
return new AnalysisResourceConstraintError(
|
||||
context.operation,
|
||||
"file-size",
|
||||
@@ -204,11 +221,11 @@ const classifyPwntoolsDecoderFailure = (
|
||||
(limitReport?.failure === null || limitReport === undefined
|
||||
? ""
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`) +
|
||||
` cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. This release has no runtime override.`,
|
||||
` Selected producer_limits.cpu_seconds is ${String(limits.cpu_seconds)} seconds; inherited tighter limits remain enforced.`,
|
||||
limitReport?.limits ?? null,
|
||||
{
|
||||
...outputOptions,
|
||||
remediationAction: `Review the reported worker CPU limits and observed signal. cpuSeconds is ${String(PWNTOOLS_LIMITS.cpuSeconds)}. This release has no runtime override. pwntools_timeout_ms does not raise it. Repeating the unchanged request fails the same way.`,
|
||||
remediationAction: `Review the reported worker CPU limit and observed signal; the signal alone does not establish exhaustion. Increase producer_limits.cpu_seconds above ${String(limits.cpu_seconds)} on the next call (CLI --producer-limits JSON) when that policy was limiting. No restart is required; a tighter inherited limit remains enforced. pwntools_timeout_ms does not raise CPU admission.`,
|
||||
},
|
||||
);
|
||||
if (
|
||||
@@ -224,11 +241,11 @@ const classifyPwntoolsDecoderFailure = (
|
||||
(limitReport?.failure === null || limitReport === undefined
|
||||
? ""
|
||||
: ` Effective limit report unavailable: ${limitReport.failure}`) +
|
||||
` The address-space allowance is ${String(PWNTOOLS_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override.`,
|
||||
` The selected producer_limits.address_space_bytes allowance is ${String(limits.address_space_bytes)} bytes; tighter inherited limits remain enforced.`,
|
||||
limitReport?.limits ?? null,
|
||||
{
|
||||
...outputOptions,
|
||||
remediationAction: `Review the reported worker memory limits and available host memory. ${fixedPwntoolsBudgets}`,
|
||||
remediationAction: `Review the reported worker memory limits and available host memory. ${budgetRecovery}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -278,7 +295,8 @@ const classifyPwntoolsDecoderFailure = (
|
||||
...stableArtifactInputRecovery(cause),
|
||||
...(cause.reason === "limit"
|
||||
? {
|
||||
userMessage: `${cause.message} Admitted artifact bytes use REA_PWNTOOLS_MAX_INPUT_BYTES (default ${String(PWNTOOLS_LIMITS.inputBytes)} bytes, maximum 1073741824). The 1073741824-byte maximum has no runtime override. Restart REA after changing the environment value. pwntools_timeout_ms does not raise this allowance. ${fixedPwntoolsBudgets}`,
|
||||
userMessage: `${cause.message} Selected producer_limits.input_bytes is ${String(limits.input_bytes)} bytes. ${budgetRecovery}`,
|
||||
remediationAction: `Increase producer_limits.input_bytes above ${String(limits.input_bytes)} on the next call (CLI --producer-limits JSON). No restart is required. Changing REA_PWNTOOLS_MAX_INPUT_BYTES affects the launch default instead. CPU, memory, output and transport allowances remain independent.`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -289,7 +307,7 @@ const classifyPwntoolsDecoderFailure = (
|
||||
cause.reason === "limit" ? "out_of_range" : "invalid_format",
|
||||
message:
|
||||
cause.reason === "limit"
|
||||
? `${cause.message} REA_PWNTOOLS_MAX_INPUT_BYTES defaults to ${String(PWNTOOLS_LIMITS.inputBytes)} bytes.`
|
||||
? `${cause.message} REA_PWNTOOLS_MAX_INPUT_BYTES defaults to ${String(PWNTOOLS_LIMITS.input_bytes)} bytes.`
|
||||
: cause.message,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js";
|
||||
import { pwntoolsUnavailable } from "./PwntoolsFailures.js";
|
||||
import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js";
|
||||
import { PWNTOOLS_LIMITS } from "../../domain/native/pwntoolsResourceLimits.js";
|
||||
import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js";
|
||||
|
||||
const CONSTRAINT =
|
||||
@@ -13,9 +13,9 @@ describe("pwntools input bound", () => {
|
||||
it("keeps the shipped default when the override is absent", () => {
|
||||
expect(pwntoolsInputBytes({})).toEqual({
|
||||
configured: true,
|
||||
bytes: PWNTOOLS_LIMITS.inputBytes,
|
||||
bytes: PWNTOOLS_LIMITS.input_bytes,
|
||||
});
|
||||
expect(PWNTOOLS_LIMITS.inputBytes).toBe(32 * 1024 * 1024);
|
||||
expect(PWNTOOLS_LIMITS.input_bytes).toBe(32 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -62,7 +62,7 @@ describe("pwntools input bound", () => {
|
||||
});
|
||||
|
||||
it("never narrows the bound below the shipped default implicitly", () => {
|
||||
const accepted = [1, 1024, PWNTOOLS_LIMITS.inputBytes].map((bytes) =>
|
||||
const accepted = [1, 1024, PWNTOOLS_LIMITS.input_bytes].map((bytes) =>
|
||||
pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: String(bytes) }),
|
||||
);
|
||||
expect(accepted.every((limit) => limit.configured)).toBe(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js";
|
||||
import { PWNTOOLS_LIMITS } from "../../domain/native/pwntoolsResourceLimits.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
|
||||
@@ -32,7 +32,7 @@ export const pwntoolsInputBytes = (
|
||||
): PwntoolsInputBytes => {
|
||||
const value = environment.REA_PWNTOOLS_MAX_INPUT_BYTES;
|
||||
if (value === undefined)
|
||||
return { configured: true, bytes: PWNTOOLS_LIMITS.inputBytes };
|
||||
return { configured: true, bytes: PWNTOOLS_LIMITS.input_bytes };
|
||||
const parsed = pwntoolsInputBytesSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? { configured: true, bytes: parsed.data }
|
||||
|
||||
@@ -127,6 +127,7 @@ export class PwntoolsRecordedCrashProvider
|
||||
options,
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
producerLimits,
|
||||
}) => {
|
||||
if (!input.include_debugger_context) return value;
|
||||
const diagnostics = value.decoder_diagnostics;
|
||||
@@ -162,6 +163,7 @@ export class PwntoolsRecordedCrashProvider
|
||||
options,
|
||||
runCommand,
|
||||
timeoutMs,
|
||||
producerLimits,
|
||||
});
|
||||
const context = completed.context;
|
||||
const enriched = validate(context);
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
import { DEFAULT_PWNTOOLS_TIMEOUT_MS } from "../../domain/native/nativeDeadlines.js";
|
||||
|
||||
/** Bridge-reserved status for MemoryError when no structured reply can be written. */
|
||||
export const PWNTOOLS_MEMORY_FAILURE_EXIT = 75;
|
||||
/** Bridge-reserved status for an observed EFBIG failure without a complete reply. */
|
||||
export const PWNTOOLS_FILE_SIZE_FAILURE_EXIT = 76;
|
||||
|
||||
/**
|
||||
* Complete evidence budgets; address-space is separate from resident memory.
|
||||
* `timeoutMs` is the omitted `pwntools_timeout_ms`. cpuSeconds and the byte
|
||||
* allowances stay fixed.
|
||||
*/
|
||||
export const PWNTOOLS_LIMITS = {
|
||||
inputBytes: 32 * 1024 * 1024,
|
||||
outputBytes: 64 * 1024 * 1024,
|
||||
diagnosticBytes: 1024 * 1024,
|
||||
timeoutMs: DEFAULT_PWNTOOLS_TIMEOUT_MS,
|
||||
addressSpaceBytes: 3 * 1024 * 1024 * 1024,
|
||||
cpuSeconds: 30,
|
||||
} as const;
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
resolvePwntoolsLimits,
|
||||
type PwntoolsProducerLimits,
|
||||
} from "../../domain/native/pwntoolsResourceLimits.js";
|
||||
import { safeParseUtf8Json } from "../../domain/safeJson.js";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -8,21 +12,27 @@ import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
|
||||
import {
|
||||
PWNTOOLS_MEMORY_FAILURE_EXIT,
|
||||
PWNTOOLS_FILE_SIZE_FAILURE_EXIT,
|
||||
PWNTOOLS_LIMITS,
|
||||
} from "./PwntoolsRelease.js";
|
||||
import { z } from "zod";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
/** Actual limits reported by the owned bridge after lowering inherited soft limits. */
|
||||
export const pwntoolsResourceLimitsSchema = z.strictObject({
|
||||
address_space_bytes: z
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(PWNTOOLS_LIMITS.addressSpaceBytes),
|
||||
cpu_seconds: z.int().nonnegative().max(PWNTOOLS_LIMITS.cpuSeconds),
|
||||
file_size_bytes: z.int().nonnegative().max(PWNTOOLS_LIMITS.outputBytes),
|
||||
address_space_bytes: z.int().nonnegative(),
|
||||
cpu_seconds: z.int().nonnegative(),
|
||||
file_size_bytes: z.int().nonnegative(),
|
||||
});
|
||||
|
||||
/** A report must fit this operation's selected ceilings, including raised budgets. */
|
||||
export const pwntoolsResourceLimitsSchemaFor = (
|
||||
limits: PwntoolsProducerLimits,
|
||||
) =>
|
||||
pwntoolsResourceLimitsSchema.extend({
|
||||
address_space_bytes: z.int().nonnegative().max(limits.address_space_bytes),
|
||||
cpu_seconds: z.int().nonnegative().max(limits.cpu_seconds),
|
||||
file_size_bytes: z.int().nonnegative().max(limits.file_bytes),
|
||||
});
|
||||
|
||||
/** A missing or invalid report leaves limits unknown and retains its read failure. */
|
||||
export interface PwntoolsLimitReport {
|
||||
readonly limits: z.output<typeof pwntoolsResourceLimitsSchema> | null;
|
||||
@@ -44,6 +54,7 @@ export const readPwntoolsFailureEvidence = async (
|
||||
cause: unknown,
|
||||
rootPath?: string,
|
||||
resources?: ArtifactResourceScope,
|
||||
selectedLimits: PwntoolsProducerLimits = resolvePwntoolsLimits(),
|
||||
): Promise<PwntoolsFailureEvidence> => {
|
||||
const finalizationFailures: Record<string, string> = {};
|
||||
let limitReport: PwntoolsLimitReport | undefined;
|
||||
@@ -115,7 +126,9 @@ export const readPwntoolsFailureEvidence = async (
|
||||
const decoded = safeParseUtf8Json(reported.bytes);
|
||||
if (!decoded.ok) throw decoded.cause;
|
||||
limitReport = {
|
||||
limits: pwntoolsResourceLimitsSchema.parse(decoded.value),
|
||||
limits: pwntoolsResourceLimitsSchemaFor(selectedLimits).parse(
|
||||
decoded.value,
|
||||
),
|
||||
failure: null,
|
||||
};
|
||||
} catch (reportFailure: unknown) {
|
||||
|
||||
Reference in new issue
Block a user