diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 687ee48c..d759bba3 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5575,8 +5575,20 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINGGUIDELINEDIALOG::T4250996615"] = "P -- Chat attachment UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1071345316"] = "Chat attachment" --- Danger detected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1081126836"] = "Danger detected" +-- The file '{0}' could not be checked for prompt injection and was not attached. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2404630365"] = "The file '{0}' could not be checked for prompt injection and was not attached." + +-- Security warning +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1862129203"] = "Security warning" + +-- AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T3661810823"] = "AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded." + +-- Security active +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4212328947"] = "Security active" + +-- Security notice +UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T4004397997"] = "Security notice" -- Content source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1129278507"] = "Content source" @@ -7339,9 +7351,6 @@ UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2936083926"] = "AI Studio could -- Writer UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T2979224202"] = "Writer" --- Prompt Injection Detected -UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3580322580"] = "Prompt Injection Detected" - -- Show details UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3692372066"] = "Show details" diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index 4b4274fd..6e4a151f 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -5,6 +5,7 @@ using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using AIStudio.Tools.Validation; +using AIStudio.Tools.Security; using Microsoft.AspNetCore.Components; @@ -86,7 +87,10 @@ public partial class AttachDocuments : MSGComponentBase /// Creates and persists a draft owner after media import confirmation. [Parameter] public Func> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult(null); - + + [Inject] + private PromptInjectionGuardService PromptInjectionGuardService { get; init; } = null!; + [Inject] private ILogger Logger { get; set; } = null!; @@ -457,10 +461,7 @@ public partial class AttachDocuments : MSGComponentBase if (!canAddRegularFiles) break; - if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider)) - continue; - - this.DocumentPaths.Add(FileAttachment.FromPath(path)); + await this.TryAddFileAsync(path); } if (mediaPaths.Count is 0) @@ -529,4 +530,34 @@ public partial class AttachDocuments : MSGComponentBase await this.DialogService.ShowAsync(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN); } -} \ No newline at end of file + + private async Task TryAddFileAsync(string filePath) + { + if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, filePath, this.ValidateMediaFileTypes, this.Provider)) + return false; + + var attachment = FileAttachment.FromPath(filePath); + if (attachment.Type is FileAttachmentType.DOCUMENT && this.PromptInjectionGuardService.IsProtectionEnabled) + { + try + { + var fileContent = await this.RustService.ReadArbitraryFileData(filePath, int.MaxValue); + await this.PromptInjectionGuardService.EnsureSafeForLlmAsync(fileContent, PromptInjectionSource.ChatAttachment(filePath)); + } + catch (PromptInjectionBlockedException) + { + return false; + } + catch (Exception exception) + { + this.Logger.LogError(exception, "File attachment '{FilePath}' could not be checked for prompt injection and will not be attached.", filePath); + await this.MessageBus.SendError(new( + Icons.Material.Filled.Cancel, + string.Format(T("The file '{0}' could not be checked for prompt injection and was not attached."), attachment.FileName))); + return false; + } + } + + return this.DocumentPaths.Add(attachment); + } +} diff --git a/app/MindWork AI Studio/Dialogs/PromptInjectionAlertDialog.razor b/app/MindWork AI Studio/Dialogs/PromptInjectionAlertDialog.razor index f2fbc798..cc777cac 100644 --- a/app/MindWork AI Studio/Dialogs/PromptInjectionAlertDialog.razor +++ b/app/MindWork AI Studio/Dialogs/PromptInjectionAlertDialog.razor @@ -17,7 +17,7 @@ - @T("Danger detected") + @T("Security warning") + + + + + @T("Typical attacks on AI systems (e.g. prompt injection) hide instructions within untrusted content to trick an AI model into ignoring its intended rules or performing unintended actions.") + + + + + + + + + + + + + + @T("Security active") + + + + @T("AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded.") + + + @@ -41,12 +70,6 @@ - - - @T("Typical attacks on AI systems (e.g. prompt injection) hide instructions within untrusted content to trick an AI model into ignoring its intended rules or performing unintended actions.") - - - diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index aa58004b..7faa623a 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -367,7 +367,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan }; var dialogReference = await this.DialogService.ShowAsync( - T("Prompt Injection Detected"), + T("Security notice"), dialogParameters, DialogOptions.BLOCKING_FULLSCREEN); diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 0361d1ee..8b38751e 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -4278,15 +4278,28 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie -- Chat attachment UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1071345316"] = "Dateianhang" --- Danger detected -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1081126836"] = "Gefahr erkannt" +-- The file '{0}' could not be checked for prompt injection and was not attached. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2404630365"] = "Die Datei '{0}' konnte nicht auf Prompt-Injection überprüft werden und wurde nicht angehängt." + +-- Security warning +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1862129203"] = "Sicherheitswarnung" + + +-- AI Studio has reliably detected and blocked the suspicious content. Your applications and data remain protected. Please review the content you uploaded. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T3661810823"] = "AI Studio hat den verdächtigen Inhalt zuverlässig erkannt und blockiert. Ihre Anwendungen und Daten bleiben geschützt. Bitte überprüfen Sie den von Ihnen hochgeladenen Inhalt." + +-- Security active +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4212328947"] = "Sicherheit aktiv" + +-- Typical attacks on AI systems +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4221674400"] = "Typische Angriffe auf KI-Systeme (z.B. Prompt Injection) verbergen Anweisungen in nicht vertrauenswürdigen Inhalten, um ein KI-Modell dazu zu bringen, seine vorgesehenen Regeln zu ignorieren oder ungewollte Aktionen auszuführen." + +-- Security notice +UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T4004397997"] = "Sicherheitshinweis" -- Content source UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T1129278507"] = "Datei" --- Prompt injection hides instructions in untrusted content to make an AI model ignore its intended rules or perform unintended actions. -UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T4221674400"] = "Typische Angriffe auf KI-Systeme (z.B. Prompt Injection) verbergen Anweisungen in nicht vertrauenswürdigen Inhalten, um ein KI-Modell dazu zu bringen, seine vorgesehenen Regeln zu ignorieren oder ungewollte Aktionen auszuführen." - -- Attempt to override instructions UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T161976090"] = "Versuch, Anweisungen zu überschreiben" @@ -4371,8 +4384,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T4245463281"] -- Checks web content, file attachments, retrieval context, and similar external input for prompt-injection patterns before it is sent to a model or agent. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T69192586"] = "Prüft Webinhalte, Dateianhänge, Abrufkontext und ähnliche externe Eingaben auf Prompt-Injection-Muster, bevor sie an ein Modell oder einen Agenten gesendet werden." --- Prompt Injection Detected -UI_TEXT_CONTENT["AISTUDIO::LAYOUT::MAINLAYOUT::T3580322580"] = "Prompt-Injection erkannt" -- AI Studio blocked content from '{0}' because it looks like a prompt-injection attempt. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SECURITY::PROMPTINJECTIONGUARDSERVICE::T3560909296"] = "AI Studio hat Inhalte von '{0}' blockiert, da es sich um einen möglichen Prompt-Injection-Versuch handelt." diff --git a/app/MindWork AI Studio/Tools/Security/PromptInjectionGuardService.cs b/app/MindWork AI Studio/Tools/Security/PromptInjectionGuardService.cs index 28d1d6b8..017578f9 100644 --- a/app/MindWork AI Studio/Tools/Security/PromptInjectionGuardService.cs +++ b/app/MindWork AI Studio/Tools/Security/PromptInjectionGuardService.cs @@ -12,9 +12,11 @@ public sealed class PromptInjectionGuardService( private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PromptInjectionGuardService).Namespace, nameof(PromptInjectionGuardService)); + public bool IsProtectionEnabled => settingsManager.ConfigurationData.Chat.EnablePromptInjectionProtection; + public async Task EnsureSafeForLlmAsync(string text, PromptInjectionSource source) { - if (!settingsManager.ConfigurationData.Chat.EnablePromptInjectionProtection || string.IsNullOrWhiteSpace(text)) + if (!this.IsProtectionEnabled || string.IsNullOrWhiteSpace(text)) return text; var result = scanner.Scan(text, source); diff --git a/app/MindWork AI Studio/Tools/Security/PromptInjectionScanner.cs b/app/MindWork AI Studio/Tools/Security/PromptInjectionScanner.cs index 99e0c419..665572d7 100644 --- a/app/MindWork AI Studio/Tools/Security/PromptInjectionScanner.cs +++ b/app/MindWork AI Studio/Tools/Security/PromptInjectionScanner.cs @@ -9,7 +9,7 @@ public sealed class PromptInjectionScanner(ILogger logge private const int MAX_DECODED_CANDIDATES_PER_ENCODING = 12; private const int MAX_DECODED_TEXT_LENGTH = 12_000; private const int MAX_FINDINGS = 8; - private const int SNIPPET_RADIUS = 80; + private const int MAX_SNIPPET_LENGTH = 240; private static readonly IReadOnlyDictionary<(int Length, char First, char Last), string[]> TYPOGLYCEMIA_KEYWORDS = CreateTypoglycemiaKeywordIndex(); @@ -70,8 +70,8 @@ public sealed class PromptInjectionScanner(ILogger logge if (!match.Success) continue; - var snippet = ExtractSnippet(text, match.Index, match.Length, SNIPPET_RADIUS); - AddFinding(findings, findingKeys, new(rule.Id, rule.Category, stage, snippet)); + var snippet = ExtractSnippet(text, match.Index, match.Length); + AddFinding(findings, findingKeys, new(rule.Id, rule.Category, snippet)); } } @@ -131,8 +131,8 @@ public sealed class PromptInjectionScanner(ILogger logge if (!IsTypoglycemiaVariant(token, keyword)) continue; - var snippet = ExtractSnippet(text, match.Index, match.Length, SNIPPET_RADIUS); - AddFinding(findings, findingKeys, new($"typoglycemia:{keyword}", "evasion", "typoglycemia", snippet)); + var snippet = ExtractSnippet(text, match.Index, match.Length); + AddFinding(findings, findingKeys, new($"typoglycemia:{keyword}", "evasion", snippet)); break; } } @@ -265,17 +265,94 @@ public sealed class PromptInjectionScanner(ILogger logge private static void AddFinding(List findings, HashSet findingKeys, PromptInjectionFinding finding) { - var key = $"{finding.RuleId}|{finding.DetectionStage}|{finding.Snippet}"; + var key = $"{finding.Category}|{finding.Snippet}"; if (findingKeys.Add(key)) findings.Add(finding); } - private static string ExtractSnippet(string text, int index, int length, int radius) + private static string ExtractSnippet(string text, int index, int length) { - var start = Math.Max(0, index - radius); - var end = Math.Min(text.Length, index + length + radius); - var snippet = text[start..end].Replace('\r', ' ').Replace('\n', ' ').Trim(); - return snippet.Length > radius * 2 ? $"{snippet[..(radius * 2)]}..." : snippet; + var matchStart = Math.Clamp(index, 0, text.Length); + var matchEnd = Math.Clamp(index + length, matchStart, text.Length); + var sentenceStart = FindSentenceStart(text, matchStart); + var sentenceEnd = FindSentenceEnd(text, matchEnd); + + while (sentenceStart < matchStart && char.IsWhiteSpace(text[sentenceStart])) + sentenceStart++; + + while (sentenceEnd > matchEnd && char.IsWhiteSpace(text[sentenceEnd - 1])) + sentenceEnd--; + + if (sentenceEnd - sentenceStart <= MAX_SNIPPET_LENGTH) + return NormalizeSnippet(text[sentenceStart..sentenceEnd]); + + var matchLength = matchEnd - matchStart; + if (matchLength >= MAX_SNIPPET_LENGTH - 6) + return NormalizeSnippet(text[matchStart..matchEnd]); + + var contextBudget = MAX_SNIPPET_LENGTH - 6 - matchLength; + var leftAvailable = matchStart - sentenceStart; + var rightAvailable = sentenceEnd - matchEnd; + var leftLength = Math.Min(leftAvailable, contextBudget / 2); + var rightLength = Math.Min(rightAvailable, contextBudget - leftLength); + var remainingBudget = contextBudget - leftLength - rightLength; + + leftLength += Math.Min(leftAvailable - leftLength, remainingBudget); + remainingBudget = contextBudget - leftLength - rightLength; + rightLength += Math.Min(rightAvailable - rightLength, remainingBudget); + + var snippetStart = matchStart - leftLength; + var snippetEnd = matchEnd + rightLength; + var snippet = NormalizeSnippet(text[snippetStart..snippetEnd]); + var prefix = snippetStart > sentenceStart ? "..." : string.Empty; + var suffix = snippetEnd < sentenceEnd ? "..." : string.Empty; + return $"{prefix}{snippet}{suffix}"; + } + + private static int FindSentenceStart(string text, int matchStart) + { + for (var index = matchStart - 1; index >= 0; index--) + { + if (IsSentenceBoundary(text[index])) + return index + 1; + } + + return matchStart; + } + + private static int FindSentenceEnd(string text, int matchEnd) + { + for (var index = matchEnd; index < text.Length; index++) + { + if (IsSentenceBoundary(text[index])) + return index + 1; + } + + return matchEnd; + } + + private static bool IsSentenceBoundary(char character) => character is '.' or '!' or '?' or '\r' or '\n'; + + private static string NormalizeSnippet(ReadOnlySpan snippet) + { + var normalized = new StringBuilder(snippet.Length); + var previousCharacterWasWhitespace = false; + foreach (var character in snippet) + { + if (char.IsWhiteSpace(character)) + { + if (normalized.Length > 0 && !previousCharacterWasWhitespace) + normalized.Append(' '); + + previousCharacterWasWhitespace = true; + continue; + } + + normalized.Append(character); + previousCharacterWasWhitespace = false; + } + + return normalized.ToString().Trim(); } private static IReadOnlyDictionary<(int Length, char First, char Last), string[]> CreateTypoglycemiaKeywordIndex()