Fixed media transcripts reaching the AI without the prompt injection filter (#1083)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-18 13:48:18 +02:00
1 parent 8e3660a74a
commit 34d88f723c
14 files changed
+423 -63

No files matched your search

@@ -5,6 +5,7 @@ using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Security;
namespace AIStudio.Tools.Services;
@@ -14,6 +15,7 @@ namespace AIStudio.Tools.Services;
public sealed class MediaTranscriptionService(
RustService rustService,
SettingsManager settingsManager,
PromptInjectionGuardService promptInjectionGuardService,
IEnumerable<IMediaTranscriptStorage> transcriptStorages,
ILogger<MediaTranscriptionService> logger) : IDisposable
{
@@ -210,7 +212,7 @@ public sealed class MediaTranscriptionService(
{
var result = await this.TranscribeImportAsync(mediaPath, target, cancellation.Token);
if (result.Status is MediaTranscriptionResultStatus.SUCCEEDED)
this.AddCompletedText(target, result.Text);
await this.AddCompletedTextAsync(target, mediaPath, result.Text);
else if (result.Status is MediaTranscriptionResultStatus.CANCELLED)
status = MediaImportStatus.CANCELLED;
else if (result.Status is MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL)
@@ -248,8 +250,18 @@ public sealed class MediaTranscriptionService(
}
/// <summary>Stores a completed field transcript for reattachment after navigation.</summary>
private void AddCompletedText(MediaImportTarget target, string text)
/// <remarks>
/// The field hands the transcript on to the AI, so it passes the filter for prompt injections
/// first, like every document loaded into such a field, and is reported right away like one.
/// It is filtered here rather than where the field takes it: only here is the recording known,
/// which is what the user hears about, and the field may look for its transcript more than once.
/// </remarks>
/// <param name="target">The field which receives the transcript.</param>
/// <param name="mediaPath">The recording the transcript belongs to.</param>
/// <param name="transcript">The transcript as the provider returned it.</param>
internal async Task AddCompletedTextAsync(MediaImportTarget target, string mediaPath, string transcript)
{
var text = await promptInjectionGuardService.SanitizeAsync(transcript, PromptInjectionSource.FileContent(mediaPath), reportingScope: null);
lock (this.stateLock)
{
if (!this.pendingDeliveries.TryGetValue(target, out var pending))
@@ -37,8 +37,13 @@ public sealed partial class RustService
/// attachment: nothing leaves the app on that path, so there is nothing to warn about, and
/// reporting it there would warn a second time when the file is actually sent.
/// </param>
/// <param name="reportAs">
/// The source the user is told about when passages were filtered out, or null for the file
/// itself. A file which only holds what AI Studio made of another one, e.g. the transcript of a
/// recording, names the file the user knows instead.
/// </param>
/// <param name="reportingScope">The action the file is read for, or null when it is read for none.</param>
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false, bool reportPromptInjections = true, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default)
public async Task<FileExtractionResult> ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false, bool reportPromptInjections = true, PromptInjectionSource? reportAs = null, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default)
{
//
// The runtime filters prompt injections while it streams the file. Doing it there rather
@@ -250,7 +255,7 @@ public sealed partial class RustService
// where the content never leaves the app.
//
if (reportPromptInjections)
await guardService.ReportAsync(new(PromptInjectionSource.FileContent(path), promptInjectionFindings, promptInjectionRedactedCount), reportingScope);
await guardService.ReportAsync(new(reportAs ?? PromptInjectionSource.FileContent(path), promptInjectionFindings, promptInjectionRedactedCount), reportingScope);
//
// Filtering does not change the outcome: the passages were removed and the document
@@ -27,7 +27,8 @@ public sealed partial class RustService
/// </summary>
/// <remarks>
/// File content does not go through here: the runtime filters it while it streams the file.
/// This is the path for content the app fetched itself, i.e. web pages and retrieval contexts.
/// This is the path for content the app holds itself, e.g. web pages, retrieval contexts, and
/// the transcript of a recording loaded into the field of an assistant.
/// </remarks>
/// <param name="text">The content to filter.</param>
/// <returns>The filtered content and what was found or null when the runtime could not be reached.</returns>
@@ -62,6 +62,22 @@ public sealed partial class RustService : BackgroundService
this.extractionHttp = CreateHttpClient(apiPort, certificateFingerprint, Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Talks to a runtime which a test plays itself.
/// </summary>
/// <remarks>
/// Only for tests. The runtime filters prompt injections out of every file and text, so without
/// it no test could check which file the app asks for, or what it does with the answer.
/// </remarks>
/// <param name="runtime">Answers every request in place of the runtime.</param>
internal RustService(HttpMessageHandler runtime)
{
this.apiPort = string.Empty;
this.certificateFingerprint = string.Empty;
this.http = new HttpClient(runtime, disposeHandler: false) { BaseAddress = new Uri("https://127.0.0.1"), Timeout = TimeSpan.FromSeconds(100) };
this.extractionHttp = new HttpClient(runtime, disposeHandler: false) { BaseAddress = new Uri("https://127.0.0.1"), Timeout = Timeout.InfiniteTimeSpan };
}
private static HttpClient CreateHttpClient(string apiPort, string certificateFingerprint, TimeSpan timeout)
{
var certificateValidationHandler = new HttpClientHandler