From c0ebecb51a09f6099e6bda77a549e7a39cbf32bb Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 22 Feb 2026 23:00:19 -0800 Subject: [PATCH 1/2] fix: Preserve error chains across error wrapping boundaries Six locations wrapped errors without passing { cause: error }, breaking error chain traversal for debugging and Sentry reporting. Also adds code property to ExecError for structured errno access. Warden findings: bf3caddd, 811a555f, 410d3c52, 91113dcf, ebd7c6df, cd7521bb Severity: medium Co-Authored-By: Warden --- src/cli/git.ts | 2 +- src/evals/index.ts | 2 +- src/sdk/analyze.ts | 5 +++-- src/sdk/auth.ts | 6 ++++-- src/sdk/errors.ts | 4 ++-- src/skills/remote.ts | 2 +- src/utils/exec.ts | 7 ++++--- 7 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/cli/git.ts b/src/cli/git.ts index 3ac59ae6..c50ccad1 100644 --- a/src/cli/git.ts +++ b/src/cli/git.ts @@ -19,7 +19,7 @@ function git(args: string[], cwd: string = process.cwd()): string { return execGitNonInteractive(args, { cwd }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Git command failed: git ${args.join(' ')}\n${message}`); + throw new Error(`Git command failed: git ${args.join(' ')}\n${message}`, { cause: error }); } } diff --git a/src/evals/index.ts b/src/evals/index.ts index 5f3b2df1..c774f31b 100644 --- a/src/evals/index.ts +++ b/src/evals/index.ts @@ -49,7 +49,7 @@ export function loadEvalFile(filePath: string): EvalFile { try { content = readFileSync(filePath, 'utf-8'); } catch (error) { - throw new Error(`Failed to read ${filePath}: ${error}`); + throw new Error(`Failed to read ${filePath}: ${error}`, { cause: error }); } let parsed: unknown; diff --git a/src/sdk/analyze.ts b/src/sdk/analyze.ts index 0174a126..e85961ee 100644 --- a/src/sdk/analyze.ts +++ b/src/sdk/analyze.ts @@ -524,13 +524,14 @@ async function analyzeHunk( const errorMessage = error instanceof Error ? error.message : String(error); throw new WardenAuthenticationError( `Claude Code subprocess failed (${errorMessage}).\n` + - `This usually means the claude CLI cannot run in this environment.` + `This usually means the claude CLI cannot run in this environment.`, + { cause: error } ); } // Authentication errors should surface immediately with helpful guidance if (isAuthenticationError(error)) { - throw new WardenAuthenticationError(); + throw new WardenAuthenticationError(undefined, { cause: error }); } // Don't retry if not a retryable error or we've exhausted retries diff --git a/src/sdk/auth.ts b/src/sdk/auth.ts index 7c7842f1..b50e65e3 100644 --- a/src/sdk/auth.ts +++ b/src/sdk/auth.ts @@ -29,14 +29,16 @@ export function verifyAuth({ apiKey }: { apiKey?: string }): void { if (isNotFound) { throw new WardenAuthenticationError( 'Claude Code CLI not found on PATH.\n' + - 'Either install Claude Code (https://claude.ai/install.sh) or set an API key.' + 'Either install Claude Code (https://claude.ai/install.sh) or set an API key.', + { cause: error } ); } const detail = error instanceof ExecError ? error.stderr : (error as Error).message; throw new WardenAuthenticationError( `Claude Code CLI found but failed to execute: ${detail}\n` + - 'Check that the claude binary has correct permissions and can run in this environment.' + 'Check that the claude binary has correct permissions and can run in this environment.', + { cause: error } ); } } diff --git a/src/sdk/errors.ts b/src/sdk/errors.ts index df347413..c286e44b 100644 --- a/src/sdk/errors.ts +++ b/src/sdk/errors.ts @@ -59,11 +59,11 @@ export function isSubprocessError(error: unknown): boolean { } export class WardenAuthenticationError extends Error { - constructor(sdkError?: string) { + constructor(sdkError?: string, options?: { cause?: unknown }) { const message = sdkError ? `Authentication failed: ${sdkError}\n${AUTH_ERROR_GUIDANCE}` : `Authentication required.${AUTH_ERROR_GUIDANCE}`; - super(message); + super(message, options); this.name = 'WardenAuthenticationError'; } } diff --git a/src/skills/remote.ts b/src/skills/remote.ts index f560f6cd..5ac73568 100644 --- a/src/skills/remote.ts +++ b/src/skills/remote.ts @@ -308,7 +308,7 @@ function execGit(args: string[], options?: { cwd?: string }): string { return execGitNonInteractive(args, { cwd: options?.cwd }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new SkillLoaderError(`Git command failed: git ${args.join(' ')}: ${message}`); + throw new SkillLoaderError(`Git command failed: git ${args.join(' ')}: ${message}`, { cause: error }); } } diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 86a9386d..b69a2fec 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -8,7 +8,8 @@ export class ExecError extends Error { public readonly command: string, public readonly exitCode: number | null, public readonly stderr: string, - public readonly signal: string | null + public readonly signal: string | null, + public readonly code?: string ) { const details = stderr || (signal ? `Killed by signal ${signal}` : 'Unknown error'); super(`Command failed: ${command}\n${details}`); @@ -69,7 +70,7 @@ export function execNonInteractive(command: string, options?: ExecOptions): stri }); if (result.error) { - throw new ExecError(command, null, result.error.message, null); + throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code); } if (result.status !== 0) { @@ -103,7 +104,7 @@ export function execFileNonInteractive( const result = spawnSync(file, args, spawnOptions); if (result.error) { - throw new ExecError(command, null, result.error.message, null); + throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code); } if (result.status !== 0) { From 9bf777889e554ac30216ebfbd1f15a68abf92529 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 23 Feb 2026 09:33:02 -0800 Subject: [PATCH 2/2] fix(utils): Pass cause through ExecError to preserve error chains ExecError constructor now accepts { cause } options and passes them to super(). Both spawn error sites now include the original error as cause, completing the error chain preservation. Co-Authored-By: Claude Opus 4.6 --- src/utils/exec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/utils/exec.ts b/src/utils/exec.ts index b69a2fec..28726b45 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -9,10 +9,11 @@ export class ExecError extends Error { public readonly exitCode: number | null, public readonly stderr: string, public readonly signal: string | null, - public readonly code?: string + public readonly code?: string, + options?: { cause?: unknown } ) { const details = stderr || (signal ? `Killed by signal ${signal}` : 'Unknown error'); - super(`Command failed: ${command}\n${details}`); + super(`Command failed: ${command}\n${details}`, options); this.name = 'ExecError'; } } @@ -70,7 +71,7 @@ export function execNonInteractive(command: string, options?: ExecOptions): stri }); if (result.error) { - throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code); + throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code, { cause: result.error }); } if (result.status !== 0) { @@ -104,7 +105,7 @@ export function execFileNonInteractive( const result = spawnSync(file, args, spawnOptions); if (result.error) { - throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code); + throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code, { cause: result.error }); } if (result.status !== 0) {