mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Replace the scalar document_id discriminator on a coach conversation with a CoachConversationDocument join table and a sticky documentScoped flag, so a conversation can be grounded on several stored documents at once while the prompt-injection fence stays intact across the data-model change. The fence is re-established at both enforcement points, fail-closed: - Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a fresh chat with staged attachments) to the new /api/insights/chat/fenced endpoint; the fenced body is strict and never carries scope, guided-question, or prefill hints. - Server: the tool route fetches with documentScoped:false AND 404s when the fetched row still carries an attachment (flag/join drift), emitting a fence_drift audit alarm. Document text now enters an LLM prompt in exactly one code path — the fenced pipeline — with no tools and no health snapshot; a module-graph test pins that the tool loop is unreachable from it. The fenced turn fences each document separately with a server-built, scrubbed header line (title and filename are untrusted text), runs a per-document egress consent check on the single picked provider, and grounds numerically over the live attachment union only. Attachments are capped at five documents and 192 KB of combined text with honest truncation. Attach and detach ride dedicated endpoints: attaching validates the document is owned, live, indexed, and within cap, then flips the conversation fenced (the one legal, privilege-reducing transition, audit-logged); detaching removes the document but never clears the flag. The document-sheet chat keeps its public wire contract and shares the same fenced pipeline over the join. Migration 0245 adds the join table and the flag, backfills every legacy single-document chat into a one-attachment fenced conversation, and drops the scalar column in the same transaction — no dual-truth window.
258 lines
15 KiB
TypeScript
258 lines
15 KiB
TypeScript
/**
|
||
* v1.27.33 (Document vault P4 — chat about a document) / v1.29.x (S7 — attach
|
||
* MULTIPLE documents to a coach conversation) — the system-prompt builder and
|
||
* data-fence for the fenced chat.
|
||
*
|
||
* SECURITY IS THE HEART of this surface: a document body is attacker-controllable
|
||
* (a user can upload a PDF whose text says "ignore all instructions and …"). The
|
||
* document text therefore enters the prompt as FENCED DATA, never as
|
||
* instructions, using the same delimiting/spotlighting pattern the Coach's
|
||
* `fenceSelfReport` ships — a hard-to-forge marker pair, embedded markers
|
||
* scrubbed so the content cannot terminate the fence, and an explicit frame that
|
||
* everything inside is a document to answer questions about, never a directive.
|
||
*
|
||
* S7 widens this to N documents in one prompt. Each document gets its OWN fence
|
||
* pair, preceded by a server-composed header line. The header interpolates the
|
||
* document TITLE and FILENAME — both user-authored plaintext, so both are ALSO
|
||
* untrusted: they are marker-scrubbed, newline-stripped, and length-capped before
|
||
* splicing, so a hostile title cannot fabricate an out-of-fence instruction line
|
||
* (the one new injection surface N-doc adds over 1-doc).
|
||
*
|
||
* The context is the attached documents' text ONLY (research D3): no health
|
||
* snapshot, no tools. The safety spine (`safetyAcute` + `safetyGlp1` +
|
||
* `toneContract` + `formattingContract`) is composed verbatim from the shared
|
||
* cross-surface contracts so a fenced chat holds the same medical-safety boundary
|
||
* as the rest of the app; the grounding + extractive-citation + honest-absence
|
||
* rules are document-specific (the shared `grounding` fragment is worded for the
|
||
* health SNAPSHOT + the user's own baseline, which have no referent here).
|
||
*
|
||
* Dependency-free of Prisma on purpose, mirroring `self-report-fence.ts`: the
|
||
* route imports the builder, the fence, and the shared contracts only.
|
||
*/
|
||
import {
|
||
composeSharedContracts,
|
||
type ContractLocale,
|
||
} from "@/lib/ai/prompts/shared-contracts";
|
||
|
||
/** Unlikely literal marker pair fencing each document's text as DATA. */
|
||
export const DOCUMENT_FENCE_START = "<<<DOCUMENT_START>>>";
|
||
export const DOCUMENT_FENCE_END = "<<<DOCUMENT_END>>>";
|
||
|
||
/**
|
||
* Combined byte budget for ALL attached documents' fenced text in one prompt.
|
||
* Per-doc text is already ≤64 KB, but 5×64 KB is too much context; over budget
|
||
* the LONGEST documents are tail-truncated first with an explicit in-fence
|
||
* marker. Never drop a document silently.
|
||
*/
|
||
export const FENCED_CHAT_CONTEXT_MAX_BYTES = 192 * 1024;
|
||
|
||
/** Max characters of a header field (title / filename) after scrubbing. */
|
||
const HEADER_FIELD_MAX = 120;
|
||
|
||
/**
|
||
* Wrap one document's text in the data-fence markers. Embedded marker strings
|
||
* are removed from the content FIRST — document text must never be able to
|
||
* terminate the fence and smuggle trailing lines into instruction position.
|
||
* Mirrors `fenceSelfReport`.
|
||
*/
|
||
export function fenceDocument(text: string): string {
|
||
const scrubbed = text
|
||
.replaceAll(DOCUMENT_FENCE_START, "")
|
||
.replaceAll(DOCUMENT_FENCE_END, "");
|
||
return `${DOCUMENT_FENCE_START}\n${scrubbed}\n${DOCUMENT_FENCE_END}`;
|
||
}
|
||
|
||
/**
|
||
* Neutralise an attacker-controlled header field (document title / filename)
|
||
* before it is spliced into an OUT-OF-FENCE instruction line. Removes the fence
|
||
* markers, flattens every newline / control char to a space (so it cannot open a
|
||
* new line that looks like a system directive), collapses runs of whitespace,
|
||
* and length-caps. Returns null for an empty result so the caller omits the part.
|
||
*/
|
||
export function scrubHeaderField(
|
||
raw: string | null | undefined,
|
||
): string | null {
|
||
if (!raw) return null;
|
||
const flattened = raw
|
||
.replaceAll(DOCUMENT_FENCE_START, "")
|
||
.replaceAll(DOCUMENT_FENCE_END, "")
|
||
// Every C0 control char (incl. newline / CR / tab) → space.
|
||
.replace(/[ |