mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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.
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { resolve, dirname } from "node:path";
|
|
|
|
/**
|
|
* v1.29.x (S7) — THE structural guarantee behind the fence (design §2.3 / §0).
|
|
*
|
|
* Document text enters an LLM prompt in EXACTLY ONE code path: the fenced
|
|
* pipeline. That pipeline registers NO tools and builds NO health snapshot. This
|
|
* test converts "benign race" from an argument into a PROPERTY: it walks the
|
|
* static import graph of the fenced entry points and asserts the coach tool
|
|
* registry + snapshot builder are UNREACHABLE from them — and symmetrically that
|
|
* the tool route cannot reach any document-text loader. A future refactor that
|
|
* wires document text into the tool path (or tools into the fenced path) fails
|
|
* loudly here, in the same spirit as the i18n call-site walker.
|
|
*/
|
|
|
|
const SRC = resolve(__dirname, "../../..");
|
|
|
|
function resolveAlias(spec: string, fromFile: string): string | null {
|
|
let candidate: string;
|
|
if (spec.startsWith("@/")) {
|
|
candidate = resolve(SRC, spec.slice(2));
|
|
} else if (spec.startsWith(".")) {
|
|
candidate = resolve(dirname(fromFile), spec);
|
|
} else {
|
|
return null; // bare package import — outside our graph
|
|
}
|
|
for (const ext of [".ts", ".tsx", "/index.ts", "/index.tsx"]) {
|
|
const p = `${candidate}${ext}`;
|
|
if (existsSync(p)) return p;
|
|
}
|
|
if (existsSync(candidate)) return candidate;
|
|
return null;
|
|
}
|
|
|
|
const IMPORT_RE = /(?:from|import)\s+["']([^"']+)["']/g;
|
|
|
|
/** BFS the static import graph from `entries`, returning every reachable file. */
|
|
function reachableFrom(entries: string[]): Set<string> {
|
|
const seen = new Set<string>();
|
|
const queue = [...entries];
|
|
while (queue.length > 0) {
|
|
const file = queue.pop()!;
|
|
if (seen.has(file)) continue;
|
|
seen.add(file);
|
|
let source: string;
|
|
try {
|
|
source = readFileSync(file, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const match of source.matchAll(IMPORT_RE)) {
|
|
const resolved = resolveAlias(match[1], file);
|
|
if (resolved && !seen.has(resolved)) queue.push(resolved);
|
|
}
|
|
}
|
|
return seen;
|
|
}
|
|
|
|
const FENCED_ENTRIES = [
|
|
resolve(SRC, "lib/documents/fenced-chat.ts"),
|
|
resolve(SRC, "app/api/insights/chat/fenced/route.ts"),
|
|
resolve(SRC, "app/api/documents/inbound/[id]/chat/route.ts"),
|
|
];
|
|
|
|
const TOOL_ROUTE = resolve(SRC, "app/api/insights/chat/route.ts");
|
|
|
|
// Modules that constitute the tool loop / health snapshot — forbidden in the
|
|
// fenced graph.
|
|
const TOOL_MODULES = [
|
|
resolve(SRC, "lib/ai/coach/tools.ts"),
|
|
resolve(SRC, "lib/ai/coach/snapshot.ts"),
|
|
];
|
|
|
|
// Document-text loaders — forbidden in the tool-route graph.
|
|
const DOCUMENT_TEXT_MODULES = [
|
|
resolve(SRC, "lib/documents/fenced-chat.ts"),
|
|
resolve(SRC, "lib/documents/content-index.ts"),
|
|
resolve(SRC, "lib/documents/document-chat-prompt.ts"),
|
|
];
|
|
|
|
describe("fenced-chat module graph — the fence is structural", () => {
|
|
it("the fenced pipeline can reach neither the coach tool registry nor the snapshot builder", () => {
|
|
const reachable = reachableFrom(FENCED_ENTRIES);
|
|
for (const forbidden of TOOL_MODULES) {
|
|
expect(
|
|
reachable.has(forbidden),
|
|
`Fenced graph must NOT import ${forbidden}`,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("the tool route can reach no document-text loader (no untrusted document text on the tool path)", () => {
|
|
const reachable = reachableFrom([TOOL_ROUTE]);
|
|
for (const forbidden of DOCUMENT_TEXT_MODULES) {
|
|
expect(
|
|
reachable.has(forbidden),
|
|
`Tool route must NOT import ${forbidden}`,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("sanity: the fenced pipeline DOES reach its document loader + prompt builder", () => {
|
|
const reachable = reachableFrom(FENCED_ENTRIES);
|
|
expect(reachable.has(resolve(SRC, "lib/documents/content-index.ts"))).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
reachable.has(resolve(SRC, "lib/documents/document-chat-prompt.ts")),
|
|
).toBe(true);
|
|
});
|
|
});
|