From 5211771e8d36836991ff7cd8524b58279b7e4a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 01:43:27 +0200 Subject: [PATCH 1/2] fix(ai): require consent before server-managed egress on two coach surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-context clarifying questions and the AI-composed proactive nudge both reached a provider without a consent check of any kind. Either could resolve to the operator's server-managed credential (admin-openai or the shared central Codex), so a user with no AI credentials and no receipt on file had health data leave the server on the operator's key. The questions path is the sharper of the two: its prompt carries the complete Coach snapshot. Gate both with the chain predicate pair the status path already uses, on the coach surface. The gate is skip-shaped on both — the questions fall back to the deterministic hints, the nudge keeps its deterministic template — so a missing receipt costs the user nothing but the model's phrasing. BYOK, local and ChatGPT-OAuth chains stay ungated; that egress is the user's own act. Move the questions path off the read-then-write budget check onto the atomic reserve/reconcile pair, so two concurrent saves can no longer both observe a sub-cap spend and both call the provider. The cap now follows the chain's cost owner instead of always charging the operator ceiling. --- .../self-context-questions-consent.test.ts | 251 ++++++++++++++++++ src/lib/ai/coach/self-context-questions.ts | 133 ++++++++-- .../__tests__/coach-nudge-ai-consent.test.ts | 170 ++++++++++++ src/lib/jobs/coach-nudge-ai.ts | 24 ++ 4 files changed, 554 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts create mode 100644 src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts diff --git a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts new file mode 100644 index 000000000..9acd08c5e --- /dev/null +++ b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts @@ -0,0 +1,251 @@ +/** + * Consent + budget contract for the self-context clarifying questions. + * + * The questions prompt ships the COMPLETE Coach snapshot, so it is a real PHI + * egress and carries the same receipt requirement as the Coach chat. These + * tests pin the two halves of that contract: + * + * - a chain that could reach the operator's server-managed credential + * (`admin-openai` / `admin-codex`) refuses without an active receipt and + * lands on the deterministic hints — the provider is never called, and the + * snapshot is never even built; + * - the same chain proceeds once a receipt is on file; + * - a BYOK / local chain stays ungated (the user's own egress); + * - the budget runs through the ATOMIC reserve/reconcile pair, not a + * read-then-write check. + * + * `@/lib/consent/receipts` is mocked rather than the consent guard itself, so + * the real `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` + * logic is what these tests exercise. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +const latestActiveReceipt = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt })); +vi.mock("@/lib/documents/document-settings", () => ({ + documentAutoReadEnabled: vi.fn(async () => false), +})); + +const providerMocks = vi.hoisted(() => ({ + hasAnyConfiguredProvider: vi.fn(async () => true), + resolveProvider: vi.fn(async () => ({ type: "none" })), + resolveProviderChain: vi.fn(async () => [] as unknown[]), +})); +vi.mock("@/lib/ai/provider", () => providerMocks); + +const budgetMocks = vi.hoisted(() => ({ + buildDateKey: vi.fn(() => "2026-07-18"), + reserveBudget: vi.fn(async () => ({ + allowed: true, + reserved: 300, + totalAfter: 300, + })), + reconcileSpend: vi.fn(async () => {}), + resolveDailyCap: vi.fn(() => 200_000), +})); +vi.mock("@/lib/ai/coach/budget", () => budgetMocks); + +const buildCoachSnapshot = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot })); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { deriveClarifyingQuestions } from "../self-context-questions"; +import type { SelfContext } from "../about-me"; + +const ctx: SelfContext = { + aboutMe: "Shift work, half-marathon training.", + conditions: "Hypertension", + allergies: null, + coachFocus: null, +}; + +/** A provider whose completion returns three usable questions. */ +function makeProvider() { + return { + type: "openai", + generateCompletion: vi.fn(async () => ({ + content: '["Question one?","Question two?"]', + model: "gpt-4o", + tokensUsed: 120, + cachedInputTokens: 0, + })), + }; +} + +let provider: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + provider = makeProvider(); + latestActiveReceipt.mockResolvedValue(null); + providerMocks.hasAnyConfiguredProvider.mockResolvedValue(true); + providerMocks.resolveProvider.mockResolvedValue({ type: "none" }); + budgetMocks.reserveBudget.mockResolvedValue({ + allowed: true, + reserved: 300, + totalAfter: 300, + }); + buildCoachSnapshot.mockResolvedValue({ snapshotJson: '{"weight":[]}' }); +}); + +describe("deriveClarifyingQuestions — server-managed consent gate", () => { + it("refuses the AI path on an operator-managed chain with no receipt", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("fallback"); + // The snapshot is the PHI: it must never even be built, let alone sent. + expect(provider.generateCompletion).not.toHaveBeenCalled(); + expect(buildCoachSnapshot).not.toHaveBeenCalled(); + // And no budget is consumed by a refused request. + expect(budgetMocks.reserveBudget).not.toHaveBeenCalled(); + }); + + it("refuses on the operator-shared central Codex with no receipt", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "admin-codex", instance: provider }, + ]); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("fallback"); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); + + it("refuses when the legacy single-provider fallback serves the request", async () => { + // Empty chain → `resolveProvider` fallback, which is tagged `admin-openai` + // because an admin-key `OpenAIClient` is indistinguishable from a BYOK one. + providerMocks.resolveProviderChain.mockResolvedValue([]); + providerMocks.resolveProvider.mockResolvedValue(provider); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("fallback"); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); + + it("proceeds on an operator-managed chain once an ai_coach receipt is active", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + latestActiveReceipt.mockImplementation(async (_userId, kind) => + kind === "ai_coach" ? { id: "receipt-1" } : null, + ); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("ai"); + expect(out.questions).toEqual(["Question one?", "Question two?"]); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("accepts the master ai_full grant for the coach surface", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + latestActiveReceipt.mockImplementation(async (_userId, kind) => + kind === "ai_full" ? { id: "receipt-2" } : null, + ); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("ai"); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + ]); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("ai"); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + expect(latestActiveReceipt).not.toHaveBeenCalled(); + }); + + it("leaves a local chain ungated", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "local", instance: provider }, + ]); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("ai"); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => { + // The runner may cascade to the admin key on a primary failure, so the + // whole chain is what the gate judges — not just its head. + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + { providerType: "admin-openai", instance: provider }, + ]); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("fallback"); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); +}); + +describe("deriveClarifyingQuestions — atomic budget", () => { + beforeEach(() => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + ]); + }); + + it("reserves before the call and reconciles the actual spend after", async () => { + await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(budgetMocks.reserveBudget).toHaveBeenCalledWith( + "user-1", + 300, + "2026-07-18", + 200_000, + ); + expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith( + "user-1", + 300, + 120, + "2026-07-18", + 0, + ); + }); + + it("falls back without calling the provider when the reservation is refused", async () => { + budgetMocks.reserveBudget.mockResolvedValue({ + allowed: false, + reserved: 300, + totalAfter: 200_000, + }); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("fallback"); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); + + it("reconciles the reservation to zero when the provider throws", async () => { + provider.generateCompletion.mockRejectedValue(new Error("upstream down")); + + const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + + expect(out.source).toBe("fallback"); + expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith( + "user-1", + 300, + 0, + "2026-07-18", + ); + }); +}); diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index d6a4074cc..351915ae2 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -10,24 +10,42 @@ * * Two paths, gated in order: * 1. AI path — only when the user has a working provider - * (`hasAnyConfiguredProvider`) AND the daily Coach token budget - * is not exhausted. One single-shot completion (no fallback - * chain — this is a nicety, not a core flow); spend is recorded - * against the same per-day ledger the Coach uses. + * (`hasAnyConfiguredProvider`), an active consent receipt covers + * the egress, AND the daily Coach token budget is not exhausted. + * One single-shot completion (no fallback chain — this is a + * nicety, not a core flow); spend rides the same per-day ledger + * the Coach uses. * 2. Deterministic path — two static completion hints for the first * two unanswered fields, localised through the server translator. * Also the landing spot for every AI-path failure (no provider, - * budget gone, model error, unparseable output). + * consent missing, budget gone, model error, unparseable output). + * + * The prompt carries the FULL Coach snapshot, so this is a genuine PHI + * egress and it is gated exactly like every other Coach surface: a chain + * that could reach the operator's server-managed credential requires an + * active `ai_coach` / `ai_full` receipt. The gate is SKIP-shaped, not + * throw-shaped — a missing receipt lands on the deterministic hints, which + * is what the caller (`PUT /api/coach/about-me`) already expects for every + * other AI-path miss. * * Server-only — reads `@/lib/db` through the provider resolver. */ -import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider"; +import { + hasAnyConfiguredProvider, + resolveProvider, + resolveProviderChain, +} from "@/lib/ai/provider"; +import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; +import { + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, +} from "@/lib/ai/consent-guard"; import { singleUserTurn } from "@/lib/ai/types"; import { buildDateKey, - getDailyTokenSpend, - OPERATOR_COST_CAP, - recordSpend, + reconcileSpend, + reserveBudget, + resolveDailyCap, } from "@/lib/ai/coach/budget"; import { clampPendingQuestions, @@ -116,6 +134,27 @@ export function buildPrompts( return { systemPrompt, userPrompt }; } +/** + * Resolve the chain that will serve this generation, so the consent gate and + * the daily cap both see the SAME provider set the call will actually use. + * Mirrors `resolveStatusChain` in `@/lib/insights/status-provider`: the chain + * first, then the legacy single provider tagged `admin-openai` — the + * conservative tag, because `resolveProvider`'s admin fallback returns a bare + * `OpenAIClient` that is indistinguishable from a BYOK key at the instance + * level. Tagging it as server-managed makes the gate fail closed. Returns null + * when nothing can serve. + */ +async function resolveQuestionsChain( + userId: string, +): Promise { + const chain = await resolveProviderChain(userId); + if (chain.length > 0) return chain; + + const legacy = await resolveProvider(userId); + if (legacy.type === "none") return null; + return [{ providerType: "admin-openai", instance: legacy }]; +} + /** * Derive the pending questions for a freshly saved self-context. * Never throws — every failure path lands on the deterministic @@ -134,12 +173,42 @@ export async function deriveClarifyingQuestions( try { if (!(await hasAnyConfiguredProvider(userId))) return fallback(); - // Budget gate — same per-day ledger the Coach chat enforces. - const dateKey = buildDateKey(); - const spent = await getDailyTokenSpend(userId, dateKey); - if (spent >= OPERATOR_COST_CAP) return fallback(); + const chain = await resolveQuestionsChain(userId); + if (chain === null) return fallback(); - const provider = await resolveProvider(userId); + // Consent gate — BEFORE the snapshot is built, let alone sent. This + // prompt ships the complete Coach snapshot, so a chain that could egress + // via the operator's server-managed credential needs an active + // `ai_coach` / `ai_full` receipt. BYOK / local / ChatGPT-OAuth chains are + // the user's own egress and stay ungated, matching every other surface. + if ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(userId, "coach")) + ) { + annotate({ + action: { name: "coach.self_context.consent_required" }, + meta: { self_context_questions_source: "fallback" }, + }); + return fallback(); + } + + // Budget gate — the same per-day ledger the Coach chat enforces, via the + // ATOMIC reserve/reconcile pair. The prior read-then-write + // (`getDailyTokenSpend` then `recordSpend`) let two concurrent saves both + // observe a sub-cap spend and both call the provider; the upsert-increment + // serialises them on the row's unique key instead. The cap follows the + // chain's cost owner, so a user on their own plan is not held to the + // operator-cost ceiling. + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + QUESTIONS_MAX_TOKENS, + dateKey, + resolveDailyCap(chain), + ); + if (!reservation.allowed) return fallback(); + + const provider = chain[0].instance; // v1.16.6 — same snapshot the Coach chat rides; lets the model // ask about what the user actually tracks. Best-effort: a snapshot // failure must not cost the AI path, the prompt just stays @@ -152,18 +221,34 @@ export async function deriveClarifyingQuestions( resolveLocale(locale), snapshotJson, ); - const result = await provider.generateCompletion( - singleUserTurn({ - system: systemPrompt, - user: userPrompt, - temperature: 0.4, - maxTokens: QUESTIONS_MAX_TOKENS, - }), - ); - if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) { - await recordSpend({ userId, tokens: result.tokensUsed, dateKey }); + + let result; + try { + result = await provider.generateCompletion( + singleUserTurn({ + system: systemPrompt, + user: userPrompt, + temperature: 0.4, + maxTokens: QUESTIONS_MAX_TOKENS, + }), + ); + } catch (err) { + // Reconcile the reservation down to zero actual spend before the + // failure lands on the deterministic fallback. + await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch( + () => {}, + ); + throw err; } + await reconcileSpend( + userId, + reservation.reserved, + result.tokensUsed ?? 0, + dateKey, + result.cachedInputTokens ?? 0, + ).catch(() => {}); + const questions = parseQuestionsReply(result.content); if (questions.length === 0) return fallback(); diff --git a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts new file mode 100644 index 000000000..b267ff776 --- /dev/null +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -0,0 +1,170 @@ +/** + * Consent contract for the AI-composed proactive Coach nudge. + * + * The nudge sends less PHI than the Coach chat (an abstract trigger topic plus + * the deterministic template body — never the user's own words or figures), but + * it is still the user's health situation leaving the server, it runs + * unattended on the 05:15 tick, and the chain can end at the operator's + * server-managed credential. So it carries the same receipt requirement. + * + * The gate is skip-shaped: no receipt → `null` → the caller ships the + * deterministic template, so the nudge itself is never lost. + * + * `@/lib/consent/receipts` is mocked rather than the consent guard, so the real + * `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` logic runs. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +const latestActiveReceipt = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt })); +vi.mock("@/lib/documents/document-settings", () => ({ + documentAutoReadEnabled: vi.fn(async () => false), +})); + +const resolveProviderChain = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain })); + +const budgetMocks = vi.hoisted(() => ({ + buildDateKey: vi.fn(() => "2026-07-18"), + reserveBudget: vi.fn(async () => ({ + allowed: true, + reserved: 160, + totalAfter: 160, + })), + reconcileSpend: vi.fn(async () => {}), + resolveDailyCap: vi.fn(() => 200_000), +})); +vi.mock("@/lib/ai/coach/budget", () => budgetMocks); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { + composeNudgeWithAI, + createNudgeAiTickBudget, + type ComposeNudgeParams, +} from "../coach-nudge-ai"; + +function makeProvider() { + return { + type: "openai", + generateCompletion: vi.fn(async () => ({ + content: "Your rhythm has shifted a little this week — worth a look?", + model: "gpt-4o", + tokensUsed: 90, + cachedInputTokens: 0, + })), + }; +} + +let provider: ReturnType; + +function params(): ComposeNudgeParams { + return { + userId: "user-1", + trigger: "compliance", + locale: "en", + name: "A", + hasCoachFocus: false, + template: { title: "Morning", body: "A gentle deterministic body." }, + tickBudget: createNudgeAiTickBudget(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + provider = makeProvider(); + latestActiveReceipt.mockResolvedValue(null); + budgetMocks.reserveBudget.mockResolvedValue({ + allowed: true, + reserved: 160, + totalAfter: 160, + }); +}); + +describe("composeNudgeWithAI — server-managed consent gate", () => { + it("skips AI composition on an operator-managed chain with no receipt", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + + const out = await composeNudgeWithAI(params()); + + // null = the caller keeps the deterministic template. + expect(out).toBeNull(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); + + it("burns neither a budget reservation nor a per-tick slot when refused", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + const p = params(); + + await composeNudgeWithAI(p); + + expect(budgetMocks.reserveBudget).not.toHaveBeenCalled(); + expect(p.tickBudget.remainingCount).toBe( + createNudgeAiTickBudget().remainingCount, + ); + }); + + it("skips on the operator-shared central Codex with no receipt", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-codex", instance: provider }, + ]); + + expect(await composeNudgeWithAI(params())).toBeNull(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); + + it("composes once an ai_coach receipt is active", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + latestActiveReceipt.mockImplementation(async (_userId, kind) => + kind === "ai_coach" ? { id: "receipt-1" } : null, + ); + + const out = await composeNudgeWithAI(params()); + + expect(out).not.toBeNull(); + expect(out?.body).toBe( + "Your rhythm has shifted a little this week — worth a look?", + ); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("accepts the master ai_full grant", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + latestActiveReceipt.mockImplementation(async (_userId, kind) => + kind === "ai_full" ? { id: "receipt-2" } : null, + ); + + expect(await composeNudgeWithAI(params())).not.toBeNull(); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + }); + + it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + ]); + + expect(await composeNudgeWithAI(params())).not.toBeNull(); + expect(provider.generateCompletion).toHaveBeenCalledTimes(1); + expect(latestActiveReceipt).not.toHaveBeenCalled(); + }); + + it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + { providerType: "admin-openai", instance: provider }, + ]); + + expect(await composeNudgeWithAI(params())).toBeNull(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 49944c59b..6e45aa434 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -9,6 +9,13 @@ * model's output. * * Hard guards (non-negotiable): + * - a CONSENT gate: a chain that could egress via the operator's + * server-managed credential requires an active `ai_coach` / `ai_full` + * receipt. The PHI volume here is small (an abstract trigger topic plus + * the deterministic template body) but it is still the user's health + * situation leaving the server on a credential they did not contract, and + * this tick runs unattended — so it carries the same receipt requirement + * as the interactive Coach; * - per-user daily BUDGET gate (the same reserve/reconcile ledger the Coach * chat uses, with the chain-aware daily cap); * - a tight per-call TIMEOUT (≤ ~9 s) on both the provider `timeoutMs` and a @@ -28,6 +35,10 @@ import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge"; import type { Locale } from "@/lib/i18n/config"; import { resolveProviderChain } from "@/lib/ai/provider"; +import { + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, +} from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -156,6 +167,19 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const chain = await resolveProviderChain(params.userId); if (chain.length === 0) return null; + // Consent gate — before the budget reservation, so a user without a + // receipt never spends a slot or a token. Skip-shaped like every other + // guard here: no receipt → return null and the caller ships the + // deterministic template, so the nudge itself is never lost. BYOK / local + // / ChatGPT-OAuth chains are the user's own egress and stay ungated. + if ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(params.userId, "coach")) + ) { + annotate({ action: { name: "coach.nudge.ai.consent_required" } }); + return null; + } + const budget = AI_BUDGETS.coachNudge; const maxTokens = budget.maxTokens ?? 160; const dailyCap = resolveDailyCap(chain); From 91a605de2a70bfa8265e6ea9cdaf656889e46033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 01:45:30 +0200 Subject: [PATCH 2/2] test(ai): freeze the consent boundary around provider resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both consent gaps had the same shape: a module resolved a live provider and nothing connected that to "check a receipt first". Behavioural tests cover the two surfaces; this covers the class. A module importing resolveProvider, resolveProviderChain or resolveProviderForTest holds something it can call generateCompletion on, so it must also import a consent helper — or sit on an allowlist with a written reason. Four entries today, all of them probes or the credential connection test, none of which send health data. The guard is an import-level tripwire and its header says so: it cannot show the gate is reached on every path, called before the egress, or passed the right surface. What it does is stop a new ungated call site landing without a reviewer seeing this file change. --- .../ai-consent-enforcement-guard.test.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/__tests__/ai-consent-enforcement-guard.test.ts diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts new file mode 100644 index 000000000..ccdb21426 --- /dev/null +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -0,0 +1,192 @@ +/** + * Structural guard on the AI consent boundary. + * + * Two surfaces shipped without a consent check — the self-context clarifying + * questions and the AI-composed proactive nudge — for the same reason: nothing + * connected "I am holding a live provider instance" to "I must have checked a + * receipt first". Behavioural tests close the two we found. This closes the + * class, so the third one cannot appear quietly. + * + * The invariant: a module that resolves a provider INSTANCE for a user + * (`resolveProvider` / `resolveProviderChain` / `resolveProviderForTest`) holds + * something it can call `generateCompletion` on. Every such module must also + * pull in a consent helper — or sit on the allowlist below with a written + * reason a reviewer signed off. + * + * WHAT THIS CANNOT PROVE — read this before trusting it: + * + * - It is an IMPORT-level check. It proves a consent helper is in the + * module's scope, NOT that the gate is reached on every path through the + * file. A module could import `hasActiveConsentForSurface`, call it on one + * branch, and egress ungated on another; this guard stays green. + * - It cannot prove ORDER. A gate called after the provider call, or after + * the snapshot is built, passes here and is still wrong. + * - It cannot prove the SURFACE is right. Passing `"insights"` where the data + * is a Coach snapshot passes here. + * - It cannot prove an allowlist entry is HONEST. A reviewer who waves + * through a new entry with a plausible-sounding reason defeats it entirely. + * The allowlist is a review checkpoint, not a proof. + * + * What it does buy: a NEW ungated provider call site cannot land silently. The + * author must either wire a consent helper or edit this file, and editing this + * file puts the decision in front of a reviewer. That is the whole claim. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */ +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** + * The helpers that hand back a live, callable provider for a user. Deliberately + * NOT including the presence-only probes (`hasAnyConfiguredProvider`, + * `resolveProviderAvailability`, `userRowHasProviderCredential`): those never + * decrypt a key or construct a client, so they cannot egress anything. + */ +const RESOLUTION_HELPERS = [ + "resolveProvider", + "resolveProviderChain", + "resolveProviderForTest", +] as const; + +/** The two sanctioned consent APIs. Either satisfies the guard. */ +const CONSENT_HELPERS = [ + // Chain-shaped: throw-form and predicate-form. + "assertConsentForChain", + "chainRequiresServerManagedConsent", + "hasActiveConsentForSurface", + // Document-class, pick-shaped. + "assertDocumentEgressConsent", + "isExternalDocumentEgress", +] as const; + +/** + * Extract the named bindings a file imports from a given module specifier. + * Handles the multi-line `import { a, b } from "…"` form the codebase uses. + * Comments mentioning a helper are NOT import statements and do not count — + * that distinction is the whole point of parsing the import rather than + * grepping the file. + */ +function importedNamesFrom(source: string, moduleSpecifier: string): string[] { + const re = new RegExp( + `import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["']${moduleSpecifier.replace( + /[/\\^$*+?.()|[\]{}]/g, + "\\$&", + )}["']`, + "g", + ); + const names: string[] = []; + for (const match of source.matchAll(re)) { + for (const raw of match[1].split(",")) { + const name = raw + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/)[0]; + if (name) names.push(name.trim()); + } + } + return names; +} + +function resolvesAProvider(source: string): boolean { + const imported = importedNamesFrom(source, "@/lib/ai/provider"); + return RESOLUTION_HELPERS.some((h) => imported.includes(h)); +} + +function importsAConsentHelper(source: string): boolean { + const imported = importedNamesFrom(source, "@/lib/ai/consent-guard"); + return CONSENT_HELPERS.some((h) => imported.includes(h)); +} + +/** + * Modules that resolve a provider and deliberately carry no consent helper. + * Every entry needs a reason that survives a reviewer asking "what PHI does + * this send?" — the answer here is "none" in every case. + * + * `lib/ai/provider.ts` needs no entry: it DEFINES the resolution helpers rather + * than importing them, so the detector never flags it. That is correct — the + * resolver constructs clients and never sends a prompt, and gating it would + * invert the layering. + */ +const ALLOWLIST: Record = { + // --- Capability / config probes: resolve a chain, never call it. --- + "lib/labs/ocr-capability.ts": + "Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.", + "app/api/insights/provider-chain/route.ts": + "Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.", + "app/api/insights/comprehensive/route.ts": + "Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.", + + // --- Connection test: a real provider call, but with no user data. --- + "app/api/ai/test/route.ts": + "Sends a fixed synthetic prompt to verify credentials resolve and the endpoint answers. No health data is read, so there is no PHI egress for a receipt to authorise. Bounded by its own 5/min rate limit.", +}; + +describe("every provider-resolving module carries a consent helper", () => { + const resolvers = sourceFiles().filter((rel) => resolvesAProvider(read(rel))); + + it("finds the provider-resolving modules at all (the guard is wired up)", () => { + // A refactor that renames the resolver or moves the module would otherwise + // silently empty the set and make every assertion below vacuous. + expect(resolvers.length).toBeGreaterThanOrEqual(8); + expect(resolvers).toContain("lib/ai/coach/self-context-questions.ts"); + expect(resolvers).toContain("lib/jobs/coach-nudge-ai.ts"); + }); + + it("gates every provider-resolving module that is not explicitly allowlisted", () => { + const ungated = resolvers.filter( + (rel) => !importsAConsentHelper(read(rel)) && !(rel in ALLOWLIST), + ); + expect(ungated).toEqual([]); + }); + + it("keeps the allowlist free of stale entries", () => { + // An entry that no longer resolves a provider is dead weight that hides + // the next real one. Delete it rather than letting it rot. + const stale = Object.keys(ALLOWLIST).filter( + (rel) => !resolvers.includes(rel), + ); + expect(stale).toEqual([]); + }); + + it("requires a substantive reason on every allowlist entry", () => { + const thin = Object.entries(ALLOWLIST) + .filter(([, reason]) => reason.trim().length < 40) + .map(([rel]) => rel); + expect(thin).toEqual([]); + }); +}); + +describe("the consent guard itself stays the single source of the policy", () => { + it("keeps the server-managed provider set defined in exactly one place", () => { + // Both operator-credential tags must be classified in consent-guard.ts and + // nowhere else — a second copy of this list is how the policy drifts. + const guard = read("lib/ai/consent-guard.ts"); + expect(guard).toContain('"admin-openai"'); + expect(guard).toContain('"admin-codex"'); + + const copies = sourceFiles().filter( + (rel) => + rel !== "lib/ai/consent-guard.ts" && + /SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)), + ); + expect(copies).toEqual([]); + }); +});