feat(errors): add caller-safe typed error projections

This commit is contained in:
morluto 2026-07-13 11:50:49 +00:00
parent 9f3639f29f
commit 3be439cbf3
7 changed files with 261 additions and 18 deletions

View File

@ -4,7 +4,10 @@ import type {
ProcessExecutionPolicy,
ProcessScenario,
} from "../domain/processCapture.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
const isWithin = (candidate: string, root: string): boolean =>
candidate === root ||
@ -41,8 +44,7 @@ export const assertRealPathAuthority = async (
/** Expected refusal or runtime failure from the process capture adapter. */
export const assertNotCancelled = (signal: AbortSignal | undefined): void => {
if (signal?.aborted === true)
throw new ProcessCaptureError("process capture was cancelled");
if (signal?.aborted === true) throw processCaptureCancelled();
};
/** Runtime availability of the native PTY adapter on this host. */

View File

@ -1,6 +1,30 @@
import { AnalysisError } from "../domain/errors.js";
interface ProcessCaptureErrorOptions extends ErrorOptions {
readonly userMessage?: string;
readonly userCategory?: "permission_required" | "cancelled";
}
/** Typed application failure produced by controlled process capture. */
export class ProcessCaptureError extends AnalysisError {
readonly _tag = "ProcessCaptureError";
override readonly userMessage: string | undefined;
override readonly userCategory:
| "permission_required"
| "cancelled"
| undefined;
constructor(message: string, options?: ProcessCaptureErrorOptions) {
super(message, options);
this.userMessage = options?.userMessage;
this.userCategory = options?.userCategory;
}
}
/** Caller-visible cancellation without exposing capture implementation state. */
export const processCaptureCancelled = (): ProcessCaptureError =>
new ProcessCaptureError("process capture was cancelled", {
userCategory: "cancelled",
userMessage: "Process capture was cancelled. Start it again when ready.",
});

View File

@ -10,6 +10,7 @@ import type {
InteractionEvent,
ProcessCapture,
ProcessExecutionPolicy,
ProcessPolicyDecision,
ProcessSample,
ProcessScenario,
TerminalFrame,
@ -405,7 +406,11 @@ export const prepareProcessCapture = async (
readonly before: SnapshotResult;
}> => {
const decision = authorizeProcessScenario(scenario, policy);
if (!decision.allowed) throw new ProcessCaptureError(decision.reason);
if (!decision.allowed)
throw new ProcessCaptureError(decision.reason, {
userCategory: "permission_required",
userMessage: processPolicyMessage(decision.reason),
});
await assertRealPathAuthority(scenario, policy);
assertNotCancelled(signal);
const before = await snapshotRoots(scenario, signal);
@ -415,3 +420,23 @@ export const prepareProcessCapture = async (
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
return { temporaryRoot, runId, home, before };
};
const processPolicyMessage = (
reason: Exclude<ProcessPolicyDecision, { readonly allowed: true }>["reason"],
): string => {
if (reason === "process capture is disabled")
return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
if (reason === "host network access is not approved by operator policy")
return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
if (reason === "executable is outside approved roots")
return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
if (reason === "working directory is outside approved roots")
return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
if (reason === "filesystem root is outside approved roots")
return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
if (
reason === "scenario requests an environment variable not allowed by policy"
)
return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
return reason satisfies never;
};

View File

@ -9,7 +9,10 @@ import type {
} from "../domain/processCapture.js";
import { validateProcessCapture } from "../domain/processCapture.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
export { ProcessCaptureError } from "./ProcessCaptureError.js";
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
@ -372,8 +375,7 @@ const completeCapture = async (options: {
const { runtime, scenario } = options;
runtime.checkpoints.trigger("root_exit");
const { reason } = options.exit;
if (reason === "cancelled")
throw new ProcessCaptureError("process capture was cancelled");
if (reason === "cancelled") throw processCaptureCancelled();
const settlement = await observeSettlement(
options.runId,
[

View File

@ -33,6 +33,9 @@ export type AnalysisErrorTag = (typeof ANALYSIS_ERROR_TAGS)[number];
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
abstract readonly _tag: AnalysisErrorTag;
readonly userMessage: string | undefined = undefined;
readonly userCategory: "permission_required" | "cancelled" | undefined =
undefined;
}
/** Base class for failures produced specifically by the Hopper provider. */
@ -300,6 +303,16 @@ export class BinaryTargetError extends AnalysisError {
export interface AnalysisErrorProjection
extends Readonly<Record<string, JsonValue>> {
readonly tag: AnalysisErrorTag;
readonly category:
| "invalid_input"
| "permission_required"
| "unsupported_provider"
| "integrity_mismatch"
| "truncated"
| "cancelled"
| "timeout"
| "unavailable"
| "execution_failure";
readonly message: string;
readonly details: Readonly<Record<string, string | number | boolean | null>>;
}
@ -311,11 +324,55 @@ export const projectAnalysisError = (
assertKnownTag(error._tag);
return {
tag: error._tag,
message: error.message,
category: errorCategory(error),
message: userMessage(error),
details: safeDetails(error),
};
};
const errorCategory = (
error: AnalysisError,
): AnalysisErrorProjection["category"] => {
if (error._tag === "ProcessCaptureError")
return error.userCategory ?? "execution_failure";
if (
error instanceof HopperRemoteError &&
error.diagnosticType === "authorization"
)
return "permission_required";
if (error instanceof ArtifactOperationError)
return artifactErrorCategory(error.reason);
if (error instanceof EvidenceFileError && error.reason === "disabled")
return "unavailable";
return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure";
};
const artifactErrorCategory = (
reason: ArtifactOperationError["reason"],
): AnalysisErrorProjection["category"] => {
if (reason === "integrity") return "integrity_mismatch";
if (reason === "limit") return "truncated";
if (reason === "cancelled") return "cancelled";
if (reason === "unavailable") return "unavailable";
return "execution_failure";
};
const STATIC_ERROR_CATEGORIES: Readonly<
Partial<Record<AnalysisErrorTag, AnalysisErrorProjection["category"]>>
> = {
AnalysisInputError: "invalid_input",
AnalysisCapabilityUnavailableError: "unsupported_provider",
ProviderSelectionError: "unsupported_provider",
EvidenceIntegrityError: "integrity_mismatch",
EvidenceLimitError: "truncated",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "timeout",
HopperTimeoutError: "timeout",
NoBinaryOpenError: "unavailable",
BinaryTargetError: "unavailable",
};
const safeDetails = (
error: AnalysisError,
): Readonly<Record<string, string | number | boolean | null>> => {
@ -360,6 +417,100 @@ const safeDetails = (
return {};
};
const userMessage = (error: AnalysisError): string => {
if (error instanceof AnalysisInputError)
return "Analysis input is invalid. Check the arguments and try again.";
if (UNREADABLE_OUTPUT_TAGS.has(error._tag))
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";
if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag))
return "This analysis is unavailable for the current target. Choose another analysis or target.";
if (CANCELLED_TAGS.has(error._tag))
return "Analysis was cancelled. Start it again when ready.";
if (TIMEOUT_TAGS.has(error._tag))
return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.";
if (ADAPTER_FAILURE_TAGS.has(error._tag))
return "Analysis could not complete. Retry once; if it continues, run `rea doctor`.";
if (START_FAILURE_TAGS.has(error._tag))
return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.";
if (error instanceof ArtifactOperationError)
return artifactMessage(error.reason);
if (error instanceof EvidenceIntegrityError)
return "Evidence is invalid or has changed. Recreate or re-import it, then try again.";
if (error instanceof EvidenceLimitError)
return "Evidence is too large for this session. Reduce the evidence set and try again.";
if (error instanceof EvidenceFileError)
return evidenceFileMessage(error.reason);
if (error instanceof UnknownRegistryError)
return "Evidence state changed before the update completed. Refresh the current state and try again.";
if (error instanceof ConfigurationError)
return "REA configuration is invalid. Run `rea doctor` and fix the reported setting.";
if (error instanceof NoBinaryOpenError) return error.message;
if (error instanceof BinaryTargetError)
return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.";
if (error._tag === "ProcessCaptureError")
return (
error.userMessage ??
"Process capture could not complete. Run `rea doctor`, then review capture policy and try again."
);
return "Analysis could not complete. Run `rea doctor`, then try again.";
};
const UNREADABLE_OUTPUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisProtocolError",
"AnalysisOutputError",
"HopperProtocolError",
]);
const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisCapabilityUnavailableError",
"ProviderSelectionError",
]);
const CANCELLED_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisCancelledError",
"HopperCancelledError",
]);
const TIMEOUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"AnalysisTimeoutError",
"HopperTimeoutError",
]);
const ADAPTER_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"ProviderAdapterError",
"HopperRemoteError",
]);
const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"HopperProcessError",
"HopperStartError",
]);
const artifactMessage = (reason: ArtifactOperationError["reason"]): string => {
if (reason === "cancelled")
return "Artifact operation was cancelled. Start it again when ready.";
if (reason === "limit")
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
if (reason === "path")
return "Artifact path is not allowed. Choose a path inside the artifact and try again.";
if (reason === "unavailable")
return "Artifact format is not available on this system. Choose another artifact or supported environment.";
if (reason === "format" || reason === "integrity")
return "Artifact is invalid or has changed. Get a fresh copy and try again.";
return "Artifact could not be read or written. Check file access and try again.";
};
const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => {
if (reason === "disabled")
return "Evidence file access is disabled. Enable an evidence directory or use inline evidence.";
if (reason === "outside-root")
return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory.";
if (reason === "not-file")
return "Evidence path does not point to a file. Choose an evidence file and try again.";
if (reason === "too-large")
return "Evidence file is too large. Reduce its size and try again.";
if (reason === "exists")
return "Evidence file already exists. Choose another path or allow overwrite.";
if (reason === "invalid-json")
return "Evidence file is not valid JSON. Repair or recreate the file and try again.";
return "Evidence file could not be accessed. Check file permissions and try again.";
};
const KNOWN_ERROR_TAGS = {
AnalysisProtocolError: true,
AnalysisInputError: true,

View File

@ -382,7 +382,16 @@ export interface ProcessExecutionPolicy {
/** Explicit authorization result; denial reasons are safe to show callers. */
export type ProcessPolicyDecision =
| { readonly allowed: true }
| { readonly allowed: false; readonly reason: string };
| {
readonly allowed: false;
readonly reason:
| "process capture is disabled"
| "host network access is not approved by operator policy"
| "executable is outside approved roots"
| "working directory is outside approved roots"
| "scenario requests an environment variable not allowed by policy"
| "filesystem root is outside approved roots";
};
const isWithin = (candidate: string, root: string): boolean =>
resolve(candidate) === resolve(root) ||

View File

@ -36,7 +36,7 @@ describe("analysis error projection", () => {
);
});
it("exhaustively projects stable tags without causes or local paths", () => {
it("projects every typed failure without internal details", () => {
const secretCause = new Error("secret-token");
const byTag = {
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
@ -97,17 +97,47 @@ describe("analysis error projection", () => {
} satisfies Readonly<Record<AnalysisErrorTag, AnalysisError>>;
const errors = Object.values(byTag);
const projected = errors.map(projectAnalysisError);
expect(new Set(projected.map(({ tag }) => tag)).size).toBe(errors.length);
expect(projected).toHaveLength(23);
expect(JSON.stringify(projected)).not.toContain("secret-token");
expect(JSON.stringify(projected)).not.toContain("/secret/local/path");
const serialized = JSON.stringify(projected);
for (const hidden of ["secret-token", "/secret/local/path"])
expect(serialized).not.toContain(hidden);
expect(
projected.find(({ tag }) => tag === "HopperTimeoutError"),
).toMatchObject({ details: { timeoutMs: 100 } });
expect(
projected.find(({ tag }) => tag === "ProviderAdapterError"),
projectAnalysisError(byTag.AnalysisCapabilityUnavailableError),
).toMatchObject({
details: { providerId: "fixture", operation: "overview" },
tag: "AnalysisCapabilityUnavailableError",
category: "unsupported_provider",
details: {
providerId: "fixture",
operation: "overview",
reason: "absent",
},
});
expect(
projectAnalysisError(
new ArtifactOperationError("inventory_artifact", "integrity", {
logicalPath: "main.js",
declaredSha256: "a".repeat(64),
calculatedSha256: "b".repeat(64),
unpacked: true,
}),
),
).toMatchObject({
tag: "ArtifactOperationError",
category: "integrity_mismatch",
details: {
logicalPath: "main.js",
declaredSha256: "a".repeat(64),
calculatedSha256: "b".repeat(64),
unpacked: true,
},
});
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
expect(
projected.every(({ message }) =>
/try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy/u.test(
message,
),
),
).toBe(true);
});
});