From 3faa4ee76e68e732eeda4686b907a7b5aa0add85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:59:37 +0200 Subject: [PATCH] fix(coach): log swallowed self-context decrypt failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach self-context loader failed soft per field (undecryptable allergies/conditions → null) with no signal, so a key-rotation gap made the Coach generate guidance without ever seeing the user's recorded allergies, indistinguishable from an empty profile. Keep the per-field fail-closed posture but emit a wide-event on each swallowed decrypt and on the DB-read guard, so a systemic failure surfaces instead of masquerading as 'the user never wrote anything'. --- .../self-context-decrypt-logging.test.ts | 85 +++++++++++++++++++ src/lib/ai/coach/about-me.ts | 36 ++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts diff --git a/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts b/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts new file mode 100644 index 000000000..20e002dea --- /dev/null +++ b/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts @@ -0,0 +1,85 @@ +/** + * F-CRYPTO-4 — an undecryptable Coach self-context field (allergies / + * conditions) must NOT vanish silently. It still fails closed per field (never + * surfaces ciphertext, never throws into prompt assembly), but a swallowed + * decrypt now emits a wide-event so a key-rotation gap surfaces instead of + * masquerading as "the user never wrote anything". + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { decryptMock, addWarningMock, findUniqueMock } = vi.hoisted(() => ({ + decryptMock: vi.fn(), + addWarningMock: vi.fn(), + findUniqueMock: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { userHealthProfile: { findUnique: findUniqueMock } }, +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: decryptMock, + encryptToBytes: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock }), +})); + +import { getSelfContextForUser } from "../about-me"; + +beforeEach(() => { + decryptMock.mockReset(); + addWarningMock.mockReset(); + findUniqueMock.mockReset(); +}); + +describe("getSelfContextForUser — undecryptable field", () => { + it("fails closed to null AND logs a wide-event on a decrypt failure", async () => { + findUniqueMock.mockResolvedValue({ + aboutMeEncrypted: new Uint8Array([1]), + conditionsEncrypted: null, + allergiesEncrypted: new Uint8Array([2]), // safety-relevant, undecryptable + coachFocusEncrypted: null, + }); + decryptMock.mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + + const ctx = await getSelfContextForUser("u1"); + + // Never surfaces ciphertext; the field is null (fail-closed). + expect(ctx.aboutMe).toBeNull(); + expect(ctx.allergies).toBeNull(); + // But the swallowed decrypt is now visible — one warning per undecryptable + // field (aboutMe + allergies here). + expect(addWarningMock).toHaveBeenCalledTimes(2); + expect(addWarningMock.mock.calls[0][0]).toContain("self-context"); + }); + + it("does not log when the fields are genuinely unset", async () => { + findUniqueMock.mockResolvedValue({ + aboutMeEncrypted: null, + conditionsEncrypted: null, + allergiesEncrypted: null, + coachFocusEncrypted: null, + }); + + const ctx = await getSelfContextForUser("u1"); + expect(ctx).toEqual({ + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, + }); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("logs a wide-event when the DB read itself throws (no silent empty profile)", async () => { + findUniqueMock.mockRejectedValue(new Error("db down")); + + const ctx = await getSelfContextForUser("u1"); + expect(ctx.allergies).toBeNull(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("self-context load failed"), + ); + }); +}); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index e94dc3bdd..089224d62 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -27,6 +27,7 @@ * Server-only — reads `@/lib/db`. */ import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { SELF_REPORT_FENCE_START, @@ -48,9 +49,18 @@ function decryptOrNull(payload: Uint8Array | null): string | null { try { const text = decryptFromBytes(payload).trim(); return text.length > 0 ? text : null; - } catch { - // Fail closed per field — never surface ciphertext, never throw - // into a prompt-assembly path. + } catch (err) { + // Fail closed per field — never surface ciphertext, never throw into a + // prompt-assembly path. But do NOT swallow silently: a stored-but- + // undecryptable safety-relevant field (allergies / conditions) that + // vanishes from the Coach's self-context with no signal is exactly the + // F-CRYPTO-4 gap. Emit a wide-event so a key-rotation gap surfaces instead + // of masquerading as "the user never wrote anything". + getEvent()?.addWarning( + `coach self-context: field decrypt failed (${ + err instanceof Error ? err.message : String(err) + })`, + ); return null; } } @@ -83,7 +93,15 @@ export async function getSelfContextForUser( allergies: decryptOrNull(row?.allergiesEncrypted ?? null), coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null), }; - } catch { + } catch (err) { + // Per-field decrypt already fails closed above; this catch is the DB-read + // guard. Surface it (rather than masquerading as an empty profile) so a + // systemic read/key-config failure is visible in wide-events. + getEvent()?.addWarning( + `coach self-context load failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return { aboutMe: null, conditions: null, @@ -390,7 +408,15 @@ export async function getSelfContextTextForUser( ); if (!selfText && !recordsText) return null; return [selfText, recordsText].filter(Boolean).join("\n"); - } catch { + } catch (err) { + // Per-field decrypt fails closed inside the composer; this catch is the + // DB-read guard. Log it so a systemic failure surfaces rather than the + // Coach silently generating guidance without the user's recorded profile. + getEvent()?.addWarning( + `coach self-context text load failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } }