mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(coach): re-detect replayed injections from prior turns
v1.4.43 W13 M-3 — `detectRefusal` runs only on the inbound `message` each turn. `priorTurns` are loaded straight from `coach_messages.encryptedContent` and concatenated into the `userPrompt` transcript every reply. An injection that slipped past the regex bank on a previous turn was re-entering the prompt verbatim on every subsequent turn, so the model saw it on every reply — false-negative amplification. Re-run `detectRefusal` against each user-turn re-loaded from DB before building the transcript. On a hit, short-circuit the SSE with a refusal AND emit an `audit.coach.replay-injection` row so operators have a durable signal. The audit row carries the conversation id, turn index, and matched reason — never the message content, so the bypass payload doesn't survive into the audit ledger.
This commit is contained in:
1 parent
8767f81924
commit
81b1eeea8d
2 files changed
+103
No files matched your search
@@ -31,6 +31,7 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
|
||||
@@ -194,6 +195,44 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs
|
||||
// only on the inbound `message` per turn, so an injection that
|
||||
// slipped past the regex bank on a previous turn would re-enter
|
||||
// the prompt every reply. Re-run the detector against every
|
||||
// user-turn re-loaded from DB; on a hit, short-circuit the SSE
|
||||
// with a refusal AND drop an `audit.coach.replay-injection`
|
||||
// row so the failure case is observable. The audit row carries
|
||||
// the conversation id (server-owned), the turn index (no PII)
|
||||
// and the matched reason — never the message content.
|
||||
for (let i = 0; i < priorTurns.length; i++) {
|
||||
const turn = priorTurns[i];
|
||||
if (turn.role !== "user") continue;
|
||||
const replayed = detectRefusal({ message: turn.content, locale });
|
||||
if (!replayed.refuse) continue;
|
||||
annotate({
|
||||
action: { name: "insights.coach.replay_injection" },
|
||||
meta: { reason: replayed.reason, turnIndex: i },
|
||||
});
|
||||
await auditLog("audit.coach.replay-injection", {
|
||||
userId,
|
||||
details: {
|
||||
conversationId: existing.id,
|
||||
turnIndex: i,
|
||||
reason: replayed.reason,
|
||||
},
|
||||
});
|
||||
return streamRefusal({
|
||||
userId,
|
||||
conversationId: existing.id,
|
||||
message,
|
||||
refusalText:
|
||||
replayed.message ??
|
||||
(locale === "de"
|
||||
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const created = await createConversation({ userId, title: message });
|
||||
workingConversationId = created.id;
|
||||
|
||||
Reference in new issue
Block a user