test(ai): freeze the consent boundary around provider resolution

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 01:45:30 +02:00
1 parent 5211771e8d
commit 91a605de2a
1 file changed
+192
@@ -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<string, string> = {
// --- 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([]);
});
});