using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
namespace AIStudio.Tools.Security;
///
/// Filters prompt injections out of external content before it reaches a model.
///
///
/// The detection itself lives in the Rust runtime. File content is filtered while the runtime
/// streams it, so it never passes through here; what this service adds is the path for content
/// the runtime does not read itself — web pages and retrieval contexts — and the reporting the
/// user sees.
///
public sealed class PromptInjectionGuardService(
RustService rustService,
SettingsManager settingsManager,
ILogger logger,
ILoggerFactory loggerFactory)
{
public const string WIKI_URL = "https://en.wikipedia.org/wiki/Prompt_engineering#Prompt_injection";
private const string DETECTION_LOG_CATEGORY = "PromptInjectionProtection";
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PromptInjectionGuardService).Namespace, nameof(PromptInjectionGuardService));
private readonly ILogger detectionLogger = loggerFactory.CreateLogger(DETECTION_LOG_CATEGORY);
private readonly Lock reportLock = new();
private readonly List pendingResults = [];
private int openActions;
///
/// Filters prompt injections out of a text the runtime did not read itself, such as a web
/// page or a retrieval context.
///
///
/// Returns usable text in every case. When the runtime cannot be reached, the text is passed
/// through unchanged: refusing the user's content because a check could not run would cost
/// them their work over a check that is best-effort anyway. The failure is logged and shown,
/// so it does not pass silently.
///
/// The content to filter.
/// Where the content came from, for the report shown to the user.
/// The content with any suspicious passages removed.
public async Task SanitizeAsync(string text, PromptInjectionSource source)
{
if (string.IsNullOrWhiteSpace(text))
return text;
if (await rustService.SanitizePromptInjections(text) is not { } response)
{
logger.LogError("Could not check {SourceKind} '{SourceLabel}' for prompt injections. The content is used unchanged.", source.Kind, source.Label);
await MessageBus.INSTANCE.SendWarning(new(
Icons.Material.Filled.GppMaybe,
string.Format(TB("AI Studio could not check '{0}' for prompt injections. The content is used as it is."), source.NotificationLabel)));
return text;
}
if (response.RedactedCount > 0)
await this.ReportAsync(new(source, response.Findings, response.RedactedCount));
return response.SanitizedText;
}
///
/// Filters prompt injections out of several texts in one runtime request.
///
///
/// For content that belongs to one user action, such as every page a web search returned.
/// The user gets a single report for the whole action, and texts sharing a source are
/// reported as that one source.
/// Returns usable text in every case, for the reason given on the single-text overload. When
/// the check cannot run, every text is passed through unchanged.
///
/// The contents to filter, each with its source.
/// The contents with any suspicious passages removed, in the order they came in.
public async Task> SanitizeAsync(IReadOnlyList texts)
{
if (texts.Count is 0)
return [];
//
// Empty fields are common — many pages have no description or authors — and the runtime
// has nothing to do with them. Only the texts with content are sent, and their positions
// are remembered so the answer can be put back in the caller's order.
//
var sanitizedTexts = texts.Select(x => x.Text).ToArray();
List indicesToScan = [];
for (var index = 0; index < texts.Count; index++)
{
if (!string.IsNullOrWhiteSpace(texts[index].Text))
indicesToScan.Add(index);
}
if (indicesToScan.Count is 0)
return sanitizedTexts;
var responses = await rustService.SanitizePromptInjectionsBatch(indicesToScan.Select(index => texts[index].Text).ToList());
if (responses is null)
{
var sources = texts.Select(x => x.Source).Distinct().ToList();
logger.LogError("Could not check {SourceCount} content source(s) for prompt injections. The content is used unchanged. Sources: {SourceLabels}", sources.Count, string.Join(", ", sources.Select(x => $"{x.Kind} '{x.Label}'")));
await MessageBus.INSTANCE.SendWarning(new(
Icons.Material.Filled.GppMaybe,
sources.Count is 1
? string.Format(TB("AI Studio could not check '{0}' for prompt injections. The content is used as it is."), sources[0].NotificationLabel)
: string.Format(TB("AI Studio could not check {0} sources for prompt injections. The content is used as it is."), sources.Count)));
return sanitizedTexts;
}
//
// Findings are collected per source, not per text: a page whose content and title were
// both filtered is one thing that happened to the user, not two.
//
var findingsBySource = new Dictionary Findings, int RedactedCount)>();
for (var responseIndex = 0; responseIndex < indicesToScan.Count; responseIndex++)
{
var response = responses[responseIndex];
var textIndex = indicesToScan[responseIndex];
sanitizedTexts[textIndex] = response.SanitizedText;
if (response.RedactedCount is 0)
continue;
var source = texts[textIndex].Source;
if (!findingsBySource.TryGetValue(source, out var aggregate))
aggregate = ([], 0);
aggregate.Findings.AddRange(response.Findings);
findingsBySource[source] = (aggregate.Findings, aggregate.RedactedCount + response.RedactedCount);
}
if (findingsBySource.Count is 0)
return sanitizedTexts;
//
// One scope around all sources, so a search across five pages reports once instead of
// five times:
//
await using var reportingScope = this.BeginAction();
foreach (var (source, aggregate) in findingsBySource)
await this.ReportAsync(new(source, aggregate.Findings, aggregate.RedactedCount));
return sanitizedTexts;
}
///
/// Records what was filtered out of one piece of content and tells the user about it.
///
///
/// Within a BeginAction scope the result is collected and reported together
/// with the rest of that action. Outside of one it is reported immediately: a result that
/// simply waited for the next scope would either never reach the user, or reach them as
/// part of an unrelated action later on.
///
public async Task ReportAsync(PromptInjectionScanResult result)
{
if (!result.WasFiltered)
return;
bool reportNow;
lock (this.reportLock)
{
this.pendingResults.Add(result);
reportNow = this.openActions is 0;
}
if (reportNow)
await this.ReportPendingAsync();
}
///
/// Marks the start of one user action, such as attaching a batch of files or sending a
/// message.
///
///
/// Results are collected until the action finishes, so the user gets one report about
/// twenty documents instead of twenty reports. Actions may nest: only the outermost one
/// reports.
///
/// A scope that reports what was filtered once it is disposed.
public ReportingScope BeginAction()
{
lock (this.reportLock)
this.openActions++;
return new(this);
}
private async Task EndActionAsync()
{
lock (this.reportLock)
{
this.openActions--;
// An inner scope reports nothing: the action the user started is still running.
if (this.openActions > 0)
return;
}
await this.ReportPendingAsync();
}
private async Task ReportPendingAsync()
{
List results;
lock (this.reportLock)
{
if (this.pendingResults.Count is 0)
return;
results = [..this.pendingResults];
this.pendingResults.Clear();
}
var totalCount = results.Sum(result => result.RedactedCount);
this.detectionLogger.LogWarning(
"Detected and removed {PassageCount} potentially dangerous passage(s) in {SourceCount} content source(s).",
totalCount,
results.Count);
await MessageBus.INSTANCE.SendWarning(new(
Icons.Material.Filled.GppMaybe,
results.Count is 1
? string.Format(TB("AI Studio removed suspicious instructions from '{0}' before using it."), results[0].Source.NotificationLabel)
: string.Format(TB("AI Studio removed suspicious instructions from {0} sources before using them."), results.Count)));
if (settingsManager.ConfigurationData.App.ShowPromptInjectionAlert)
await MessageBus.INSTANCE.SendMessage(null, Event.SHOW_PROMPT_INJECTION_ALERT, new(results));
}
///
/// Reports everything filtered during one user action when it goes out of scope.
///
public sealed class ReportingScope(PromptInjectionGuardService guardService) : IAsyncDisposable
{
public async ValueTask DisposeAsync() => await guardService.EndActionAsync();
}
}