From 5e4c5dca160fa50229054974d44d4374f1698a55 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 15 Aug 2026 00:10:19 +0100 Subject: [PATCH 1/3] VahterBanBot: harden LLM triage against prompt injection Spotlight untrusted username/display-name/message-text (and reaction-triage bio/history) between a random per-request fence, add a conservative pre-LLM injection-phrase heuristic that downgrades a NOT_SPAM verdict to SKIP (human review, never auto-Kill), cap the untrusted message text at 6000 chars, and log the full enriched prompt once + the previously silent warning-band NOT_SPAM pass-through. Also fixes the hermetic fake Azure OpenAI handler's keyword router, which was matching the literal word "SPAM" in the new hardening instruction text itself (outside the untrusted fence) and misrouting every message. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K1FvKkXhLCoZ9F2xJyrcnu --- src/VahterBanBot/Bot.fs | 4 + src/VahterBanBot/InjectionHeuristics.fs | 26 +++++++ src/VahterBanBot/LlmTriage.fs | 89 ++++++++++++++++++--- src/VahterBanBot/VahterBanBot.fsproj | 1 + tests/FakeAzureOcrApi/Handlers.fs | 16 +++- tests/VahterBanBot.Tests/LlmTriageTests.fs | 91 ++++++++++++++++++++++ 6 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 src/VahterBanBot/InjectionHeuristics.fs diff --git a/src/VahterBanBot/Bot.fs b/src/VahterBanBot/Bot.fs index 0e181a9..ce1e2c0 100644 --- a/src/VahterBanBot/Bot.fs +++ b/src/VahterBanBot/Bot.fs @@ -1140,6 +1140,10 @@ type BotService( let actor = Actor.LLM {| modelName = llmTriage.ModelName; promptHash = llmTriage.PromptHash |} return Some (AutoVerdict.Spam (float prediction.Score, actor)) | LlmVerdict.NotSpam -> + let msgLength = if isNull msg.Text then 0 else msg.Text.Length + logger.LogInformation( + "LLM triage NOT_SPAM in ML warning band — message passes (chat {ChatId}, user {UserId}, ML score {MlScore}, msg length {MsgLength})", + msg.ChatId, msg.SenderId, prediction.Score, msgLength) return Some (AutoVerdict.NotSpam (float prediction.Score, Actor.LLM {| modelName = llmTriage.ModelName; promptHash = llmTriage.PromptHash |})) | LlmVerdict.ContentFiltered triggers when botConfig.Value.LlmContentFilterIsSpam -> // Azure's RAI policy rejected the prompt as severely harmful, on a message the diff --git a/src/VahterBanBot/InjectionHeuristics.fs b/src/VahterBanBot/InjectionHeuristics.fs new file mode 100644 index 0000000..4f283cf --- /dev/null +++ b/src/VahterBanBot/InjectionHeuristics.fs @@ -0,0 +1,26 @@ +module VahterBanBot.InjectionHeuristics + +open System.Text.RegularExpressions + +/// Conservative, phrasing-anchored detectors for prompt-injection attempts embedded in +/// untrusted user content (message text, OCR text, display name, bio, …) that ends up inside +/// LlmTriage's `` fenced blocks. These chats legitimately discuss AI/LLM topics, +/// so every pattern requires INSTRUCTION-SHAPED phrasing, not a bare topic mention — "system +/// prompt" as a noun phrase fires, mentioning "GPT" in casual conversation never would. Used +/// by LlmTriage to downgrade an LLM NOT_SPAM verdict to SKIP (human review) — never to force +/// SPAM/Kill on its own; see LlmTriage.fs's classifyUncached for the call site. +let private patterns : (string * Regex) list = + [ "ignore_instructions", Regex(@"ignore (all |the )?(previous|above|prior) instructions", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "disregard_prompt", Regex(@"disregard (your|the) (instructions|prompt)", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "role_override", Regex(@"you are now", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "new_instructions", Regex(@"new instructions:", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "system_prompt_mention", Regex(@"system prompt", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "respond_with", Regex(@"respond (only )?with", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "verdict_literal", Regex(@"\{""verdict""|NOT_SPAM", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) + "addressing_classifier", Regex(@"as an ai|as a language model|to the (ai|llm|classifier|moderator bot)", RegexOptions.IgnoreCase ||| RegexOptions.Compiled) ] + +/// Returns the name of the first matching pattern, or `None`. Never throws — a null/empty +/// input is simply "no match", the same as any other miss. +let detect (text: string) : string option = + if System.String.IsNullOrEmpty text then None + else patterns |> List.tryFind (fun (_, re) -> re.IsMatch text) |> Option.map fst diff --git a/src/VahterBanBot/LlmTriage.fs b/src/VahterBanBot/LlmTriage.fs index 355930b..425574d 100644 --- a/src/VahterBanBot/LlmTriage.fs +++ b/src/VahterBanBot/LlmTriage.fs @@ -251,6 +251,10 @@ type AzureLlmTriage(botConf: IOptions, logger: ILogger