From 3be439cbf3db2c53c766bf93a4e247fdfb8242ae Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:50:49 +0000 Subject: [PATCH] feat(errors): add caller-safe typed error projections --- src/application/ProcessCaptureAuthority.ts | 8 +- src/application/ProcessCaptureError.ts | 24 ++++ src/application/ProcessCaptureLifecycle.ts | 27 +++- src/application/ProcessHarness.ts | 8 +- src/domain/errors.ts | 153 ++++++++++++++++++++- src/domain/processScenario.ts | 11 +- tests/errors.test.ts | 48 +++++-- 7 files changed, 261 insertions(+), 18 deletions(-) diff --git a/src/application/ProcessCaptureAuthority.ts b/src/application/ProcessCaptureAuthority.ts index c33ce2954..9b4041232 100644 --- a/src/application/ProcessCaptureAuthority.ts +++ b/src/application/ProcessCaptureAuthority.ts @@ -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. */ diff --git a/src/application/ProcessCaptureError.ts b/src/application/ProcessCaptureError.ts index 17593e7d1..9d1cb6b84 100644 --- a/src/application/ProcessCaptureError.ts +++ b/src/application/ProcessCaptureError.ts @@ -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.", + }); diff --git a/src/application/ProcessCaptureLifecycle.ts b/src/application/ProcessCaptureLifecycle.ts index 7470079fd..5de864e90 100644 --- a/src/application/ProcessCaptureLifecycle.ts +++ b/src/application/ProcessCaptureLifecycle.ts @@ -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["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; +}; diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 67f540e80..2483f89d9 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -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, [ diff --git a/src/domain/errors.ts b/src/domain/errors.ts index bc34a47d1..e5327f27f 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -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> { 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>; } @@ -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> +> = { + 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> => { @@ -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 = new Set([ + "AnalysisProtocolError", + "AnalysisOutputError", + "HopperProtocolError", +]); +const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet = new Set([ + "AnalysisCapabilityUnavailableError", + "ProviderSelectionError", +]); +const CANCELLED_TAGS: ReadonlySet = new Set([ + "AnalysisCancelledError", + "HopperCancelledError", +]); +const TIMEOUT_TAGS: ReadonlySet = new Set([ + "AnalysisTimeoutError", + "HopperTimeoutError", +]); +const ADAPTER_FAILURE_TAGS: ReadonlySet = new Set([ + "ProviderAdapterError", + "HopperRemoteError", +]); +const START_FAILURE_TAGS: ReadonlySet = 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, diff --git a/src/domain/processScenario.ts b/src/domain/processScenario.ts index a3ec96704..71d6dfa15 100644 --- a/src/domain/processScenario.ts +++ b/src/domain/processScenario.ts @@ -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) || diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 9d319ea18..a9e14ce98 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -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>; 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); }); });