fix(native): recover macOS command deadlines (#6059)

* fix(native): recover macOS command deadlines

Promote native_command_timeout_ms (default 60000), native_ui_deadline_ms
(default 180000), and pwntools_timeout_ms (default 30000) as validated
per-call settings. Keep cpuSeconds, byte allowances, LLDB grace, and plist
probes fixed, and say those caps have no runtime override.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(native): report the deadline that actually expired

Pass the selected native command timeout through xcrun resolution, thread
the pwntools deadline into pwndbg failures, and state the selected UI
deadline apart from the fixed wait-step cap. SIGXFSZ and EFBIG recovery
names the file-size allowance.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(native): honor the selected deadline on helper stages

The UI helper compile and capture subprocesses used fixed 60s and 30s
exec timeouts, so a longer native_ui_deadline_ms still failed as a generic
provider error. Preparation timeouts reported the 60000 ms default instead
of the selected native_command_timeout_ms.

Memory resource-constraint recovery names memory again, and the Darwin
plist CLI expectation matches the byte-counted input allowance.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 02:59:27 +08:00
1 parent 67a6477f01
commit ae09e3fe70
55 files changed
+1365 -169

No files matched your search

+136 -32
View File
@@ -53,11 +53,18 @@ import type { EvidenceLocation } from "../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import {
DEFAULT_NATIVE_UI_DEADLINE_MS,
MAX_NATIVE_TIMER_MS,
fixedNativeAllowance,
nativeDeadlineRecovery,
readNativeDeadline,
} from "../domain/native/nativeDeadlines.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
@@ -90,6 +97,7 @@ import { err, ok, type Result } from "../domain/result.js";
import {
NATIVE_COMMAND_TIMEOUT_MS,
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
NativeCommandFailure,
XcrunCommandRunner,
failureCapture,
@@ -136,6 +144,7 @@ interface NativePlistReadContext {
readonly selection: NativePlistSelection;
readonly resources: ArtifactResourceScope;
readonly signal: AbortSignal | undefined;
readonly timeoutMs: number;
}
/** Read-only semantic provider composed from Xcode command-line utilities. */
@@ -420,10 +429,30 @@ class NativeMacOSClient implements AnalysisClient {
return err(
this.#nativeUiCleanupFailure(earlierCleanupFailures, operation),
);
const runtime = createNativeUiHelperRuntime(this.environment);
const deadline = readNativeDeadline(
parameters.native_ui_deadline_ms,
DEFAULT_NATIVE_UI_DEADLINE_MS,
);
if (!deadline.ok)
return err(
new AnalysisInputError(operation, { userMessage: deadline.message }, [
{
path: ["native_ui_deadline_ms"],
reason: deadline.reason,
message: deadline.message,
minimum: 1,
maximum: MAX_NATIVE_TIMER_MS,
},
]),
);
const runtime = createNativeUiHelperRuntime(
this.environment,
deadline.timeoutMs,
);
const interruption = new NativeUiInterruptionControl(
signal,
runtime.signal,
deadline.timeoutMs,
);
try {
this.#pendingNativeUiRuntimes.add(runtime);
@@ -554,20 +583,60 @@ class NativeMacOSClient implements AnalysisClient {
),
);
case "inspect_macho":
return this.#inspectMacho(signal);
case "inspect_signature":
return this.#inspectSignature(signal);
case "inspect_plist":
return this.#inspectPlist(parameters, signal);
case "list_architectures":
return this.#listArchitectures(signal);
case "demangle_swift":
return this.#demangle(parameters, signal);
return this.#dispatchCommand(operation, parameters, signal);
}
}
#dispatchCommand(
operation:
| "inspect_macho"
| "inspect_signature"
| "inspect_plist"
| "list_architectures"
| "demangle_swift",
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
): Promise<Result<NativeObservation, AnalysisError>> {
const timeout = readNativeDeadline(
parameters.native_command_timeout_ms,
NATIVE_COMMAND_TIMEOUT_MS,
);
if (!timeout.ok)
return Promise.resolve(
err(
new AnalysisInputError(operation, { userMessage: timeout.message }, [
{
path: ["native_command_timeout_ms"],
reason: timeout.reason,
message: timeout.message,
minimum: 1,
maximum: MAX_NATIVE_TIMER_MS,
},
]),
),
);
const timeoutMs = timeout.timeoutMs;
switch (operation) {
case "inspect_macho":
return this.#inspectMacho(signal, timeoutMs);
case "inspect_signature":
return this.#inspectSignature(signal, timeoutMs);
case "inspect_plist":
return this.#inspectPlist(parameters, signal, timeoutMs);
case "list_architectures":
return this.#listArchitectures(signal, timeoutMs);
case "demangle_swift":
return this.#demangle(parameters, signal, timeoutMs);
}
}
async #listArchitectures(
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
// lipo reads only Mach-O; its refusal of a PE, ELF, or plist target is not
// a tool failure that a retry or `rea doctor` could repair.
@@ -583,7 +652,7 @@ class NativeMacOSClient implements AnalysisClient {
"list_architectures",
"lipo",
["-detailed_info", this.target.path],
{ signal },
{ signal, timeoutMs },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
@@ -610,7 +679,8 @@ class NativeMacOSClient implements AnalysisClient {
async #demangle(
parameters: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
const symbols = z.array(z.string()).safeParse(parameters.symbols);
if (!symbols.success)
@@ -625,7 +695,7 @@ class NativeMacOSClient implements AnalysisClient {
"swift-demangle",
// `--` keeps symbols that begin with `-` from selecting demangler options.
["--compact", "--", ...symbols.data],
{ signal },
{ signal, timeoutMs },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
@@ -642,7 +712,8 @@ class NativeMacOSClient implements AnalysisClient {
}
async #inspectSignature(
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
// This provider's signature inspection covers Mach-O code signatures
// only. On another container, codesign's "not signed" verdict cannot
@@ -660,7 +731,7 @@ class NativeMacOSClient implements AnalysisClient {
const inspected = await withSignatureTarget(
this.target,
this.#signatureResources,
(binding) => this.#inspectBoundSignature(binding, signal),
(binding) => this.#inspectBoundSignature(binding, signal, timeoutMs),
signal,
this.privateRuntimeRootFactory,
);
@@ -675,7 +746,8 @@ class NativeMacOSClient implements AnalysisClient {
async #inspectBoundSignature(
binding: SignatureTargetBinding,
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<InspectSignature, AnalysisError>> {
const executablePaths = [
binding.snapshotPath,
@@ -685,6 +757,7 @@ class NativeMacOSClient implements AnalysisClient {
binding,
["-d", "--verbose=4"],
signal,
timeoutMs,
);
if (!display.ok) return display;
const unsigned = isNonzeroUnsignedObservation(
@@ -702,6 +775,7 @@ class NativeMacOSClient implements AnalysisClient {
binding,
["-d", "-r-"],
signal,
timeoutMs,
);
if (!requirements.ok) return requirements;
const requirementsFailure = codeSignCaptureFailure(
@@ -714,6 +788,7 @@ class NativeMacOSClient implements AnalysisClient {
binding,
["-d", "--entitlements", ":-"],
signal,
timeoutMs,
);
if (!entitlements.ok) return entitlements;
const entitlementsFailure = codeSignCaptureFailure(
@@ -780,6 +855,7 @@ class NativeMacOSClient implements AnalysisClient {
entitlements: entitlements.value.exitCode,
},
signal,
timeoutMs,
);
if (!slices.ok) {
limitations.push(
@@ -803,7 +879,8 @@ class NativeMacOSClient implements AnalysisClient {
requirements: number | null;
entitlements: number | null;
}>,
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<void, AnalysisError>> {
const { captures, limitations } = observation;
const unsigned: string[] = [];
@@ -824,6 +901,7 @@ class NativeMacOSClient implements AnalysisClient {
binding,
["-d", "-a", architecture, "--verbose=4"],
signal,
timeoutMs,
);
if (!slice.ok) return slice;
captures.push(slice.value);
@@ -844,7 +922,8 @@ class NativeMacOSClient implements AnalysisClient {
async #captureSignature(
binding: SignatureTargetBinding,
args: readonly string[],
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeCommandCapture, AnalysisError>> {
const before = await verifySignatureTarget(this.target, binding, signal);
if (!before.ok) return before;
@@ -857,6 +936,7 @@ class NativeMacOSClient implements AnalysisClient {
{
signal,
acceptNonZero: true,
timeoutMs,
},
);
} catch (cause: unknown) {
@@ -879,7 +959,8 @@ class NativeMacOSClient implements AnalysisClient {
async #inspectPlist(
parameters: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
const requested = parameters.path;
if (requested !== undefined && typeof requested !== "string")
@@ -901,6 +982,7 @@ class NativeMacOSClient implements AnalysisClient {
selection: plist.value,
resources: this.#plistResources,
signal,
timeoutMs,
};
const selected = await requireRegularPlist(fileInput);
if (!selected.ok) return selected;
@@ -908,7 +990,7 @@ class NativeMacOSClient implements AnalysisClient {
"inspect_plist",
"file",
["-b", plist.value.path],
{ signal },
{ signal, timeoutMs },
);
if (!classified.ok) return classified;
const binary = await this.#inspectBinaryPlist(
@@ -964,12 +1046,12 @@ class NativeMacOSClient implements AnalysisClient {
classified: NativeCommandCapture,
): Promise<Result<NativeObservation, AnalysisError>> {
const { path } = fileInput.selection;
const { signal } = fileInput;
const { signal, timeoutMs } = fileInput;
const json = await this.#run(
"inspect_plist",
"plutil",
["-convert", "json", "-o", "-", "--", path],
{ signal, acceptNonZero: true },
{ signal, acceptNonZero: true, timeoutMs },
);
if (!json.ok) return json;
// JSON cannot express data, dates, or non-finite reals; plutil rejects
@@ -983,7 +1065,7 @@ class NativeMacOSClient implements AnalysisClient {
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", path],
{ signal, acceptNonZero: !jsonDecoded },
{ signal, acceptNonZero: !jsonDecoded, timeoutMs },
);
if (xml !== undefined && !xml.ok) return xml;
if (xml !== undefined && xml.value.exitCode !== 0) {
@@ -1044,7 +1126,8 @@ class NativeMacOSClient implements AnalysisClient {
}
async #inspectMacho(
signal?: AbortSignal,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
if (!isMachOFormat(this.target.format))
return err(
@@ -1060,6 +1143,7 @@ class NativeMacOSClient implements AnalysisClient {
run: (tool, arguments_, commandSignal, acceptNonZeroIf) =>
this.#run("inspect_macho", tool, arguments_, {
signal: commandSignal,
timeoutMs,
...(acceptNonZeroIf === undefined ? {} : { acceptNonZeroIf }),
}),
invocation: (capture) => invocation(capture, this.target.path),
@@ -1072,11 +1156,12 @@ class NativeMacOSClient implements AnalysisClient {
arguments_: readonly string[],
options: {
readonly signal?: AbortSignal | undefined;
readonly timeoutMs: number;
readonly acceptNonZero?: boolean;
readonly acceptNonZeroIf?:
| ((capture: NativeCommandCapture) => boolean)
| undefined;
} = {},
},
): Promise<Result<NativeCommandCapture, AnalysisError>> {
if (this.#closed)
return err(
@@ -1088,6 +1173,7 @@ class NativeMacOSClient implements AnalysisClient {
);
const command = this.runner.run(tool, arguments_, {
...(options.signal === undefined ? {} : { signal: options.signal }),
timeoutMs: options.timeoutMs,
acceptNonZero:
options.acceptNonZero ?? options.acceptNonZeroIf !== undefined,
});
@@ -1132,7 +1218,9 @@ class NativeMacOSClient implements AnalysisClient {
);
return captured;
}
return err(translateCommandFailure(operation, captured.error));
return err(
translateCommandFailure(operation, captured.error, options.timeoutMs),
);
}
}
@@ -1207,6 +1295,7 @@ const isNativeOperation = (
const translateCommandFailure = (
operation: NativeToolName,
failure: NativeCommandFailure,
timeoutMs = NATIVE_COMMAND_TIMEOUT_MS,
): AnalysisError => {
const cleanupOwners = [
...failure.cleanupFailures,
@@ -1271,9 +1360,17 @@ const translateCommandFailure = (
preparation.cancellation === undefined
? new ProviderAdapterError(IDENTITY.id, operation, options)
: failure.reason === "timeout"
? new AnalysisTimeoutError(operation, NATIVE_COMMAND_TIMEOUT_MS, {
? new AnalysisTimeoutError(operation, timeoutMs, {
...options,
cause: preparation.cancellation.reason,
...nativeDeadlineRecovery({
timeoutMs,
setting: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
}),
})
: new AnalysisCancelledError(operation, {
...options,
@@ -1326,24 +1423,31 @@ const translateCommandFailure = (
);
if (failure.reason === "timeout")
return bindCleanup(
new AnalysisTimeoutError(
operation,
NATIVE_COMMAND_TIMEOUT_MS,
errorOptions,
),
new AnalysisTimeoutError(operation, timeoutMs, {
...errorOptions,
...nativeDeadlineRecovery({
timeoutMs,
setting: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
}),
}),
);
if (failure.reason === "output-limit")
return bindCleanup(
new AnalysisResourceConstraintError(
operation,
"memory",
`Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes; complete output is required for analysis.`,
`Native command output exceeded ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes. Complete output is required for analysis. This release has no runtime override. native_command_timeout_ms does not raise this allowance.`,
{ max_output_bytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES },
{
...errorOptions,
...fixedAllowanceOptions(
...fixedNativeAllowance(
"The native command output capture allowance",
"capture",
`${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)} bytes`,
),
},
),