- Notifications
You must be signed in to change notification settings - Fork 0
feat(rag): refuse adversarial-manipulation queries at the routing layer#179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -49,6 +49,52 @@ const queryStopWords = new Set([ | ||
| "guideline", | ||
| ]); | ||
| // Query-side adversarial-manipulation guard. Neutralizing injected instructions | ||
| // embedded in retrieved *source* text is handled separately (neutralizeInstructions | ||
| // in rag.ts); this catches manipulation intent in the *user query* itself — asking | ||
| // the model to ignore its instructions, fabricate citations/evidence, pretend the | ||
| // evidence supports a claim, or exfiltrate a system prompt / secrets. Such a query | ||
| // often mentions a real clinical term (e.g. "clozapine protocol"), so it retrieves | ||
| // genuine sources and would otherwise be answered. | ||
| // | ||
| // The patterns are deliberately tight to avoid refusing legitimate clinical | ||
| // wording: each requires an explicit manipulation verb next to a manipulation | ||
| // object. In particular they distinguish fabricated-*evidence* framing ("as if the | ||
| // protocol supports this request") from patient-state hypotheticals ("as if the | ||
| // symptoms support toxicity"), a jailbreak persona ("you are now an unrestricted | ||
| // assistant") from a training scenario ("you are now an inpatient..."), and | ||
| // fabrication verbs from clinical nouns like "manufacturer"/"inventory" and | ||
| // composition wording like "documents that make up the evidence base". Validated | ||
| // against the golden eval set and a corpus of trigger-adjacent legitimate probes. | ||
| const adversarialManipulationPatterns: RegExp[] = [ | ||
| // Instruction override / jailbreak | ||
| /\b(?:ignore|disregard|override|forget|bypass)\s+(?:all\s+|any\s+)?(?:(?:previous|prior|above|earlier|these|those|the|your)\s+)?(?:instructions?|messages?|prompts?|rules?|guardrails?)\b/i, | ||
| // Persona jailbreak — requires a jailbreak object, not a bare "you are now a ..." | ||
| /\b(?:you\s+are\s+now|act\s+as|pretend\s+to\s+be|roleplay\s+as)\s+(?:a\s+|an\s+|the\s+)?(?:unrestricted|unfiltered|uncensored|jailbroken|jailbreak|developer[-\s]?mode|do[-\s]?anything|dan\b|god[-\s]?mode|sudo|root)\b/i, | ||
| // Fabricate evidence/citations — real fabrication verbs incl. gerunds ("forging"), | ||
| // but not "forgot" (forg(?:e|ed|es|ing|ery)) or clinical "invent"/"manufacture". | ||
| /\b(?:fabricat\w*|forg(?:e|ed|es|ing|ery)|falsif\w*|counterfeit\w*)\b[^.?!]{0,40}\b(?:citations?|chunks?|references?|sources?|evidence|quotes?|values?|data)\b/i, | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Explicit fake/forged citations (plural-aware). Deliberately not bare "id"/"ids": | ||
| // "a patient gives a false ID" is an identity document, not citation fraud. | ||
| /\b(?:fake|bogus|false|forged|fabricated|made[-\s]?up|placeholder|dummy)\s+(?:citations?|chunks?|references?|sources?|evidence|quotes?)\b/i, | ||
| /\bcitation_chunk_id\b/i, | ||
| // Pretend the evidence is complete/sufficient/supports (tight objects) | ||
| /\bpretend\b[^.?!]{0,30}\b(?:evidence|sources?|citations?|data)\b[^.?!]{0,25}\b(?:complete|sufficient|conclusive|enough|available|supports?|proves?|confirms?)\b/i, | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Answer "as if" the evidence/source/protocol supports *this request/claim* | ||
| /\bas\s+if\b[^.?!]{0,40}\b(?:evidence|sources?|protocol|guideline|documents?|citations?)\b[^.?!]{0,30}\b(?:support|prove|confirm|allow|approve|establish)\w*\b[^.?!]{0,25}\b(?:this|the)\s+(?:request|claim|answer|query|response|prompt)\b/i, | ||
| // Secret / system-prompt exfiltration by verb (incl. provide/list/output/dump). | ||
| // "credentials" is intentionally excluded — clinical "prescriber credentials" | ||
| // means professional qualifications, not secrets. | ||
| /\b(?:reveal|expose|print|show|leak|return|disclose|tell|give|send|share|provide|list|output|dump|divulge|repeat)\b[^.?!]{0,50}\b(?:system\s+prompt|hidden\s+(?:system\s+)?prompt|developer\s+(?:prompt|message|instructions?)|api\s+keys?|secret\s+(?:keys?|tokens?))\b/i, | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Direct interrogative / possessive requests for the system prompt or API keys | ||
| // ("what is your hidden system prompt", "the api keys") — no exfiltration verb. | ||
| /\b(?:what(?:'s|\s+is|\s+are)?|your|any|the)\b[^.?!]{0,20}\b(?:hidden\s+)?(?:system\s+prompt|developer\s+(?:prompt|message|instructions?)|api\s+keys?)\b/i, | ||
| ]; | ||
| export function hasAdversarialManipulationIntent(query: string): boolean { | ||
| return adversarialManipulationPatterns.some((pattern) => pattern.test(query)); | ||
| } | ||
| export function strongestRetrievalScore(results: SearchResult[]) { | ||
| return results.reduce((max, result) => Math.max(max, result.hybrid_score ?? result.similarity), 0); | ||
| } | ||
| @@ -241,6 +287,20 @@ export function chooseAnswerRoute(args: { | ||
| const queryClass = args.queryClass ?? classifyRagQuery(args.query).queryClass; | ||
| const topTextRank = Math.max(0, ...args.results.map((result) => result.text_rank ?? 0)); | ||
| // Refuse queries whose intent is to manipulate the model (fabricate citations, | ||
| // pretend the evidence supports a claim, override instructions, exfiltrate | ||
| // secrets). This fires before any retrieval-score routing so a query that | ||
| // happens to surface real sources still fails closed. | ||
| if (hasAdversarialManipulationIntent(args.query)) { | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| return { | ||
| mode: "unsupported", | ||
| model: null, | ||
| reason: "adversarial_manipulation_refused", | ||
| strongestScore, | ||
| documentCount: documents, | ||
| }; | ||
| } | ||
| if (args.results.length === 0) { | ||
| return { | ||
| mode: "unsupported", | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -36,7 +36,12 @@ import { queryCacheKeyForStorage, queryPrivacyMetadata, queryTextForStorage } fr | ||
| import { normalizeSourceMetadata } from "@/lib/source-metadata"; | ||
| import { isReviewedTablePromotable } from "@/lib/table-review"; | ||
| import { isClinicalImageEvidence, normalizeImageBbox } from "@/lib/image-filtering"; | ||
| import { chooseAnswerRoute, hasDirectTitleSupport, shouldRetryWithStrongAfterFast } from "@/lib/rag-routing"; | ||
| import { | ||
| chooseAnswerRoute, | ||
| hasAdversarialManipulationIntent, | ||
| hasDirectTitleSupport, | ||
| shouldRetryWithStrongAfterFast, | ||
| } from "@/lib/rag-routing"; | ||
| import { fetchRelatedDocumentMetadata, fetchRelatedDocuments } from "@/lib/document-enrichment"; | ||
| import { boldHighYieldClinicalText, boldRagAnswerHighYieldText, rankAnswerEvidence } from "@/lib/answer-ranking"; | ||
| import { applyMemoryCardBoosts, fetchMemoryCardsForQuery, ragDeepMemoryVersion } from "@/lib/deep-memory"; | ||
| @@ -1163,6 +1168,11 @@ function uniqueTextValues(values: Array<string | null | undefined>, limit = 32) | ||
| async function analyzeQueryWithClassifierFallback(query: string, analysis: ClinicalQueryAnalysis) { | ||
| if ( | ||
| // Fail closed before any generative model call: an adversarial-manipulation | ||
| // query is routed to "unsupported" downstream, so never send its text to the | ||
| // LLM query classifier. (Embedding-based retrieval is non-generative and not | ||
| // an injection surface.) | ||
| hasAdversarialManipulationIntent(query) || | ||
| unavailableDocumentNoisePattern.test(query) || | ||
| (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) | ||
| ) { | ||
| @@ -6330,7 +6340,12 @@ async function answerQuestionWithScopeUncoalesced( | ||
| allowGlobalSearch: args.allowGlobalSearch, | ||
| }); | ||
| const answerFocusQuery = queryForClinicalMode(args.query, args.queryMode ?? "auto"); | ||
| const cachedAnswer = getCachedAnswer(args, startedAt); | ||
| // Never serve a cached answer for an adversarial-manipulation query: a poisoned | ||
| // entry written before this guard existed (or a shared-cache hit under an | ||
| // unchanged cache version) would bypass chooseAnswerRoute's refusal. Skipping the | ||
| // cache lets the query flow to routing, which fails it closed to "unsupported". | ||
| const adversarialQuery = hasAdversarialManipulationIntent(answerFocusQuery); | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const cachedAnswer = adversarialQuery ? null : getCachedAnswer(args, startedAt); | ||
| if (cachedAnswer) { | ||
| const cachedSources = annotateSearchResults(answerFocusQuery, cachedAnswer.sources ?? []); | ||
| const cachedRelevance = cachedAnswer.relevance ?? buildEvidenceRelevance(answerFocusQuery, cachedSources); | ||
| @@ -6355,7 +6370,7 @@ async function answerQuestionWithScopeUncoalesced( | ||
| : cachedAnswer.smartPanel, | ||
| }; | ||
| } | ||
| const sharedCachedAnswer = await getSharedCachedAnswer(args, startedAt); | ||
| const sharedCachedAnswer = adversarialQuery ? null : await getSharedCachedAnswer(args, startedAt); | ||
| if (sharedCachedAnswer) { | ||
| setCachedAnswer(args, sharedCachedAnswer); | ||
| const cachedSources = annotateSearchResults(answerFocusQuery, sharedCachedAnswer.sources ?? []); | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.