From 9efe9960eece776b9cc3a463c8f04f31b230a0d5 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 8 Oct 2026 16:58:06 +0200 Subject: [PATCH 1/3] fix(cli): name unclassified defects in the error fingerprint A crash whose defect matched no classification was reported as a bare `error:Defect`, so every unhandled failure shared one fingerprint. The fingerprint now appends the defect's safe identity: its tag, else its error name with any errno-style code, else its value type. The message is never read, so no user data reaches telemetry. Co-Authored-By: Claude Opus 5.5 --- .../shared/telemetry/error-actionability.ts | 31 +++++++++++++++++-- .../error-actionability.unit.test.ts | 24 ++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 552e6bcc25..f72e81b2c8 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -962,17 +962,42 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { return toActionability(actionability.unknown, "error", undefined); } +/** A dotted tag such as `Namespace.NamespaceError` is still a source-owned identifier. */ +function safeDefectIdentifier(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return /^[A-Za-z][A-Za-z0-9_.]{0,63}$/.test(value) ? value : undefined; +} + +/** + * Names an unclassified defect by its tag, error name, and errno-style code, or by its type for + * a non-error value. Never reads the message, so it carries no user data. + */ +function defectIdentity(defect: unknown): string { + const error = unwrapNativeFailure(defect); + if (!isErrorRecord(error)) return typeof error; + const tag = safeDefectIdentifier(readString(error, "_tag")); + const name = error instanceof Error ? safeDefectIdentifier(error.name) : undefined; + const base = tag ?? name ?? (error instanceof Error ? "Error" : "object"); + const code = readString(error, "code"); + return code !== undefined && /^E[A-Z0-9]{1,30}$/.test(code) ? `${base}:${code}` : base; +} + export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { let firstKnownDefect: CliErrorActionability | undefined; - let hasUnknownDefect = false; + let unknownDefect: { readonly defect: unknown } | undefined; for (const reason of cause.reasons) { if (!Cause.isDieReason(reason)) continue; const classified = classifyCliErrorActionability(reason.defect); if (classified.error_kind === CliErrorKind.InternalBug) return classified; - if (classified.error_kind === CliErrorKind.Unknown) hasUnknownDefect = true; + if (classified.error_kind === CliErrorKind.Unknown) unknownDefect ??= { defect: reason.defect }; else firstKnownDefect ??= classified; } - if (hasUnknownDefect) return toActionability(actionability.internalPanic, "error", "Defect"); + if (unknownDefect !== undefined) { + return { + ...toActionability(actionability.internalPanic, "error", "Defect"), + error_fingerprint: `error:Defect:${defectIdentity(unknownDefect.defect)}`, + }; + } if (firstKnownDefect !== undefined) return firstKnownDefect; if (Cause.hasInterruptsOnly(cause)) { return toActionability(actionability.cancelled, "error", "Interrupt"); diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index e0508fd99f..cd493bfeb1 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -297,6 +297,30 @@ describe("classifyCliCauseActionability", () => { }); }); + it("names an unclassified defect by its tag, name, and errno code, never its message", () => { + const secret = "private-token"; + class NamespaceDefect extends Data.TaggedError("Namespace.NamespaceError")<{ + readonly message: string; + }> {} + const cases: ReadonlyArray = [ + [new UndeclaredError({ message: secret }), "error:Defect:UndeclaredError"], + [new NamespaceDefect({ message: secret }), "error:Defect:Namespace.NamespaceError"], + [Object.assign(new Error(secret), { code: "ENOENT" }), "error:Defect:Error:ENOENT"], + [new Error(secret), "error:Defect:Error"], + [secret, "error:Defect:string"], + ]; + + for (const [defect, fingerprint] of cases) { + const result = classifyCliCauseActionability(Cause.die(defect)); + expect(result).toMatchObject({ + error_kind: "internal_bug", + error_category: "panic", + error_fingerprint: fingerprint, + }); + expect(JSON.stringify(result)).not.toContain(secret); + } + }); + it("does not leak details from cause chains", () => { const secret = "private-token"; const cause = Cause.combine(Cause.die(new TypeError("boom")), Cause.die(new Error(secret))); From 095dc377c4cd368f05e954fafb438097923b8c85 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 9 Oct 2026 18:16:52 +0200 Subject: [PATCH 2/3] fix(cli): take defect identity only from source-owned fields Read the defect's tag from its class prototype and accept only known errno codes, so instance fields never reach the fingerprint, and guard the read so telemetry cannot replace the command's failure. Co-Authored-By: Claude Opus 5.5 --- .../shared/telemetry/error-actionability.ts | 41 ++++++++++--------- .../error-actionability.unit.test.ts | 18 +++++++- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index f72e81b2c8..217608a3cc 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,3 +1,4 @@ +import { constants as osConstants } from "node:os"; import { Cause, Option, Predicate } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; @@ -656,11 +657,13 @@ function readDeclaration(error: unknown): CliErrorActionabilityDeclaration | und } } -function safeIdentifier(value: string | undefined): string | undefined { +/** `allowDots` admits namespaced tags such as `Namespace.NamespaceError`. */ +function safeIdentifier(value: string | undefined, allowDots = false): string | undefined { if (value === undefined) return undefined; // The length cap is defense-in-depth: every legitimate identifier is a // class/tag name, so an oversized value is never a real CLI error source. - return /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) ? value : undefined; + const pattern = allowDots ? /^[A-Za-z][A-Za-z0-9_.]{0,63}$/ : /^[A-Za-z][A-Za-z0-9_]{0,63}$/; + return pattern.test(value) ? value : undefined; } function readErrorTag(error: unknown): string | undefined { @@ -698,7 +701,7 @@ function readDeclaredErrorFingerprintId(error: unknown): string | undefined { * identifier minification. Never invoke prototype getters here: only the * static data property created by Effect is a safe fingerprint authority. */ -function readStableTaggedPrototypeName(error: unknown): string | undefined { +function readStableTaggedPrototypeName(error: unknown, allowDots = false): string | undefined { if (!(error instanceof Error)) return undefined; let prototype: unknown = Object.getPrototypeOf(error); while (prototype !== Error.prototype) { @@ -706,7 +709,9 @@ function readStableTaggedPrototypeName(error: unknown): string | undefined { const descriptor = Object.getOwnPropertyDescriptor(prototype, "name"); if (descriptor !== undefined && "value" in descriptor) { const name = - typeof descriptor.value === "string" ? safeIdentifier(descriptor.value) : undefined; + typeof descriptor.value === "string" + ? safeIdentifier(descriptor.value, allowDots) + : undefined; if (name !== undefined && name !== "Error") return name; } prototype = Object.getPrototypeOf(prototype); @@ -962,24 +967,22 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { return toActionability(actionability.unknown, "error", undefined); } -/** A dotted tag such as `Namespace.NamespaceError` is still a source-owned identifier. */ -function safeDefectIdentifier(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - return /^[A-Za-z][A-Za-z0-9_.]{0,63}$/.test(value) ? value : undefined; -} - /** - * Names an unclassified defect by its tag, error name, and errno-style code, or by its type for - * a non-error value. Never reads the message, so it carries no user data. + * Names an unclassified defect by its class's prototype tag and a known errno code, or by its type + * for a non-error value. Instance fields other than an errno code are never read, so only + * source-owned identifiers reach telemetry. */ function defectIdentity(defect: unknown): string { - const error = unwrapNativeFailure(defect); - if (!isErrorRecord(error)) return typeof error; - const tag = safeDefectIdentifier(readString(error, "_tag")); - const name = error instanceof Error ? safeDefectIdentifier(error.name) : undefined; - const base = tag ?? name ?? (error instanceof Error ? "Error" : "object"); - const code = readString(error, "code"); - return code !== undefined && /^E[A-Z0-9]{1,30}$/.test(code) ? `${base}:${code}` : base; + try { + const error = unwrapNativeFailure(defect); + if (!isErrorRecord(error)) return typeof error; + if (!(error instanceof Error)) return "object"; + const base = readStableTaggedPrototypeName(error, true) ?? "Error"; + const code = readString(error, "code"); + return code !== undefined && Object.hasOwn(osConstants.errno, code) ? `${base}:${code}` : base; + } catch { + return "Unreadable"; + } } export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index cd493bfeb1..3e52b500fd 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -297,8 +297,8 @@ describe("classifyCliCauseActionability", () => { }); }); - it("names an unclassified defect by its tag, name, and errno code, never its message", () => { - const secret = "private-token"; + it("names an unclassified defect by its source-owned tag and errno code, never instance data", () => { + const secret = "privateToken"; class NamespaceDefect extends Data.TaggedError("Namespace.NamespaceError")<{ readonly message: string; }> {} @@ -307,6 +307,20 @@ describe("classifyCliCauseActionability", () => { [new NamespaceDefect({ message: secret }), "error:Defect:Namespace.NamespaceError"], [Object.assign(new Error(secret), { code: "ENOENT" }), "error:Defect:Error:ENOENT"], [new Error(secret), "error:Defect:Error"], + [Object.assign(new Error(secret), { name: secret, _tag: secret }), "error:Defect:Error"], + [ + Object.assign(new Error(secret), { code: `E${secret.toUpperCase()}` }), + "error:Defect:Error", + ], + [{ _tag: secret }, "error:Defect:object"], + [ + Object.defineProperty(new Error(secret), "code", { + get: () => { + throw new Error(secret); + }, + }), + "error:Defect:Unreadable", + ], [secret, "error:Defect:string"], ]; From 89fa361ac3660b745fc1f4e06261b969df8f66d1 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 9 Oct 2026 18:25:21 +0200 Subject: [PATCH 3/3] fix(cli): read the defect code without getters from a fixed list Co-Authored-By: Claude Opus 5.5 --- .../shared/telemetry/error-actionability.ts | 46 +++++++++++++++++-- .../error-actionability.unit.test.ts | 3 +- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 217608a3cc..927ee36f33 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,4 +1,3 @@ -import { constants as osConstants } from "node:os"; import { Cause, Option, Predicate } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; @@ -967,8 +966,46 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { return toActionability(actionability.unknown, "error", undefined); } +/** A fixed list, so a code groups the same way on every platform. */ +const defectSystemErrorCodes = new Set([ + "EACCES", + "EADDRINUSE", + "EADDRNOTAVAIL", + "EAGAIN", + "EAI_AGAIN", + "EAI_FAIL", + "EAI_NONAME", + "EBADF", + "EBUSY", + "ECONNABORTED", + "ECONNREFUSED", + "ECONNRESET", + "EEXIST", + "EHOSTUNREACH", + "EINVAL", + "EISDIR", + "ELOOP", + "EMFILE", + "ENAMETOOLONG", + "ENETUNREACH", + "ENFILE", + "ENOENT", + "ENOMEM", + "ENOSPC", + "ENOTDIR", + "ENOTEMPTY", + "ENOTFOUND", + "ENOTSUP", + "EPERM", + "EPIPE", + "EROFS", + "ESRCH", + "ETIMEDOUT", + "EXDEV", +]); + /** - * Names an unclassified defect by its class's prototype tag and a known errno code, or by its type + * Names an unclassified defect by its class's prototype tag and a known system error code, or by its type * for a non-error value. Instance fields other than an errno code are never read, so only * source-owned identifiers reach telemetry. */ @@ -978,8 +1015,9 @@ function defectIdentity(defect: unknown): string { if (!isErrorRecord(error)) return typeof error; if (!(error instanceof Error)) return "object"; const base = readStableTaggedPrototypeName(error, true) ?? "Error"; - const code = readString(error, "code"); - return code !== undefined && Object.hasOwn(osConstants.errno, code) ? `${base}:${code}` : base; + // A data descriptor, so reading the code never runs a getter. + const code = Object.getOwnPropertyDescriptor(error, "code")?.value; + return typeof code === "string" && defectSystemErrorCodes.has(code) ? `${base}:${code}` : base; } catch { return "Unreadable"; } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 3e52b500fd..6a5db0036b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -306,6 +306,7 @@ describe("classifyCliCauseActionability", () => { [new UndeclaredError({ message: secret }), "error:Defect:UndeclaredError"], [new NamespaceDefect({ message: secret }), "error:Defect:Namespace.NamespaceError"], [Object.assign(new Error(secret), { code: "ENOENT" }), "error:Defect:Error:ENOENT"], + [Object.assign(new Error(secret), { code: "EAI_AGAIN" }), "error:Defect:Error:EAI_AGAIN"], [new Error(secret), "error:Defect:Error"], [Object.assign(new Error(secret), { name: secret, _tag: secret }), "error:Defect:Error"], [ @@ -319,7 +320,7 @@ describe("classifyCliCauseActionability", () => { throw new Error(secret); }, }), - "error:Defect:Unreadable", + "error:Defect:Error", ], [secret, "error:Defect:string"], ];