diff --git a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs
index 3d77c7acb..8a384da7a 100644
--- a/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs
+++ b/app/MindWork AI Studio/Assistants/BatchProcessing/AssistantBatchProcessing.razor.Content.cs
@@ -28,6 +28,14 @@ public partial class AssistantBatchProcessing
return null;
}
+ return await this.TakeExtractedContentAsync(fileResult, extraction);
+ }
+
+ ///
+ /// Hands on what the runtime read for a file, or records why there is nothing to hand on.
+ ///
+ private async Task TakeExtractedContentAsync(BatchProcessingFileResult fileResult, FileExtractionResult extraction)
+ {
//
// The user stopped the batch run while we were reading this file. That says nothing about
// the file, so it gets the same status as a cancelled AI request instead of a failure:
@@ -80,20 +88,23 @@ public partial class AssistantBatchProcessing
return null;
}
+ //
+ // Through the runtime like every document, so its filter for prompt injections checks the
+ // transcript as well. Like the documents of a run, a filtered transcript is reported right
+ // away instead of when the run ends, which may be hours later:
+ //
+ FileExtractionResult extraction;
try
{
- return await File.ReadAllTextAsync(transcript.TranscriptPath, token);
- }
- catch (OperationCanceledException)
- {
- this.FinishFileResult(fileResult, BatchProcessingFileStatus.CANCELED, T("The batch run was canceled."));
- return null;
+ extraction = await PersistentMediaTranscript.ReadTranscriptAsync(this.RustService, transcript, fileResult.FilePath, reportingScope: null, token);
}
catch (Exception e)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("Was not able to read the existing transcript: {0}"), e.Message), e);
return null;
}
+
+ return await this.TakeExtractedContentAsync(fileResult, extraction);
}
private static bool IsTranscribableMedia(string filePath) => PersistentMediaTranscript.IsMediaPath(filePath);
diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs
index aa6662177..b1afc1d2e 100644
--- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs
+++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs
@@ -164,6 +164,7 @@ public partial class ReadFileContent : MSGComponentBase
}
/// Applies a completed target transcript after progress or navigation.
+ /// The transcript arrives filtered for prompt injections, cf. MediaTranscriptionService.
private async Task SyncCompletedMediaTextAsync()
{
var delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget);
diff --git a/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscript.cs b/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscript.cs
index 240ae4a05..7e55572e6 100644
--- a/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscript.cs
+++ b/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscript.cs
@@ -3,6 +3,8 @@ using System.Text;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
+using AIStudio.Tools.Security;
+using AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
@@ -155,6 +157,29 @@ internal static class PersistentMediaTranscript
}
}
+ ///
+ /// Reads a transcript for an AI, through the runtime like every document.
+ ///
+ ///
+ /// The runtime filters prompt injections while it reads, so a transcript must never reach an AI
+ /// by any other way, such as reading the file directly. What the filter removed is reported under
+ /// the recording: the transcript is a file AI Studio made, while the recording is the file the
+ /// user chose.
+ ///
+ /// The runtime service.
+ /// A transcript with the status READY.
+ /// The audio or video file the transcript belongs to.
+ /// The action the transcript is read for, or null when it is read for none.
+ /// The cancellation token.
+ /// The filtered text of the transcript, or why it could not be read.
+ public static Task ReadTranscriptAsync(RustService rustService, PersistentMediaTranscriptResult transcript, string mediaPath, PromptInjectionReportingScope? reportingScope, CancellationToken token)
+ {
+ if (transcript.Status is not PersistentMediaTranscriptStatus.READY)
+ throw new ArgumentException($"Only a ready transcript can be read, but its status is {transcript.Status}.", nameof(transcript));
+
+ return rustService.ReadArbitraryFileData(transcript.TranscriptPath, int.MaxValue, reportAs: PromptInjectionSource.FileContent(mediaPath), reportingScope: reportingScope, token: token);
+ }
+
private static PersistentMediaTranscriptResult SourceChanged(MediaTranscriptSlot slot) => PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_CHANGED, slot, TB("The media file changed while it was being transcribed, so its transcript was not kept."));
///
diff --git a/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscriptResult.cs b/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscriptResult.cs
index 553286f97..93ab0f547 100644
--- a/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscriptResult.cs
+++ b/app/MindWork AI Studio/Tools/Media/PersistentMediaTranscriptResult.cs
@@ -8,7 +8,9 @@ namespace AIStudio.Tools.Media;
///
/// The transcript itself is not part of it: whoever needs the text reads the file at
/// TranscriptPath, so a transcript reaches the AI by the same path no matter whether it was just
-/// made or stored long ago.
+/// made or stored long ago. That read always goes through the runtime, which filters prompt
+/// injections: PersistentMediaTranscript.ReadTranscriptAsync reads a transcript as a whole, and a
+/// data source reads it in pieces for its index.
///
/// How the request ended.
/// The path of the transcript; empty when the place could not be determined.
diff --git a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs b/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs
index 48f43c720..b29d8f6ce 100644
--- a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs
+++ b/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs
@@ -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 transcriptStorages,
ILogger 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(
}
/// Stores a completed field transcript for reattachment after navigation.
- private void AddCompletedText(MediaImportTarget target, string text)
+ ///
+ /// 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.
+ ///
+ /// The field which receives the transcript.
+ /// The recording the transcript belongs to.
+ /// The transcript as the provider returned it.
+ 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))
diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs
index a26704c15..08f53bc70 100644
--- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs
+++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs
@@ -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.
///
+ ///
+ /// 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.
+ ///
/// The action the file is read for, or null when it is read for none.
- public async Task ReadArbitraryFileData(string path, int maxChunks, bool extractImages = false, bool reportPromptInjections = true, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default)
+ public async Task 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
diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Security.cs b/app/MindWork AI Studio/Tools/Services/RustService.Security.cs
index 6ab4a43ca..07b829aed 100644
--- a/app/MindWork AI Studio/Tools/Services/RustService.Security.cs
+++ b/app/MindWork AI Studio/Tools/Services/RustService.Security.cs
@@ -27,7 +27,8 @@ public sealed partial class RustService
///
///
/// 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.
///
/// The content to filter.
/// The filtered content and what was found or null when the runtime could not be reached.
diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs
index 88fddfe65..4d9f4b97c 100644
--- a/app/MindWork AI Studio/Tools/Services/RustService.cs
+++ b/app/MindWork AI Studio/Tools/Services/RustService.cs
@@ -62,6 +62,22 @@ public sealed partial class RustService : BackgroundService
this.extractionHttp = CreateHttpClient(apiPort, certificateFingerprint, Timeout.InfiniteTimeSpan);
}
+ ///
+ /// Talks to a runtime which a test plays itself.
+ ///
+ ///
+ /// 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.
+ ///
+ /// Answers every request in place of the runtime.
+ 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
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
index 2931bcf95..9f374fc60 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md
@@ -87,6 +87,7 @@
- Fixed AI Studio occasionally accepting an API key, token, or password which was encrypted with the secret of another organization, instead of refusing it. AI Studio then used a wrong key, so the provider, data source, or tool failed later with a misleading error. Such a value is now always refused in the new protected format, and almost always in the previous one.
- Fixed AI Studio overlooking suspicious instructions (prompt injection) in files such as text or Markdown files when the instructions continued on the next line.
- Fixed AI Studio removing harmless lines from files such as text or Markdown files, mistaking them for suspicious instructions (prompt injection).
+- Fixed AI Studio not checking the transcripts of audio and video files for suspicious instructions (prompt injection) in the Batch Processing assistant and when you load such a file into a field of an assistant. AI Studio now checks these transcripts like all other documents before the AI sees them, even very long ones.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
diff --git a/app/Tests/Tools/FakeRuntime.cs b/app/Tests/Tools/FakeRuntime.cs
new file mode 100644
index 000000000..04568a714
--- /dev/null
+++ b/app/Tests/Tools/FakeRuntime.cs
@@ -0,0 +1,96 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Web;
+
+namespace AIStudio.Tests.Tools;
+
+///
+/// Plays the runtime for a test, so whatever goes through RustService can be checked without one.
+///
+///
+/// It reads files from the disk and filters prompt injections, like the runtime, but only as far as
+/// a test needs it: the one passage the test names is replaced by the marker of the runtime wherever
+/// it occurs, in a file as well as in a text. The answers are spelled out in the wire format of the
+/// runtime, so a test fails as soon as the app stops understanding what the runtime actually sends.
+///
+/// The passage the filter removes.
+internal sealed class FakeRuntime(string suspiciousPassage) : HttpMessageHandler
+{
+ ///
+ /// What the runtime puts where it removed a passage.
+ ///
+ public const string REDACTION_MARKER = "[AI Studio removed suspicious content here]";
+
+ private readonly ConcurrentQueue readFiles = new();
+ private readonly ConcurrentQueue filteredTexts = new();
+
+ ///
+ /// The paths of the files the app asked to read, in order.
+ ///
+ public IReadOnlyList ReadFiles => [..this.readFiles];
+
+ ///
+ /// The texts the app asked to filter, in order.
+ ///
+ public IReadOnlyList FilteredTexts => [..this.filteredTexts];
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var uri = request.RequestUri!;
+ switch (uri.AbsolutePath)
+ {
+ case "/retrieval/fs/extract":
+ var query = HttpUtility.ParseQueryString(uri.Query);
+ var path = query["path"] ?? string.Empty;
+ this.readFiles.Enqueue(path);
+
+ var content = await File.ReadAllTextAsync(path, cancellationToken);
+ return Answer(this.StreamFile(query["stream_id"] ?? string.Empty, content), "text/event-stream");
+
+ case "/security/prompt-injection/sanitize":
+ using (var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)))
+ {
+ var text = body.RootElement.GetProperty("text").GetString() ?? string.Empty;
+ this.filteredTexts.Enqueue(text);
+
+ var filtered = this.Filter(text);
+ return Answer(JsonSerializer.Serialize(new { sanitized_text = filtered.Text, findings = filtered.Findings, redacted_count = filtered.Findings.Length }), "application/json");
+ }
+
+ default:
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ }
+
+ ///
+ /// The events the runtime sends for a text file: its filtered text, then what the filter removed.
+ ///
+ private string StreamFile(string streamId, string content)
+ {
+ var filtered = this.Filter(content);
+ var events = new StringBuilder();
+ AppendEvent(new { content = filtered.Text, stream_id = streamId, metadata = new { Text = new { line_number = 1 } } });
+ if (filtered.Findings.Length > 0)
+ AppendEvent(new { content = string.Empty, stream_id = streamId, metadata = new { PromptInjection = new { findings = filtered.Findings, redacted_count = filtered.Findings.Length } } });
+
+ return events.ToString();
+
+ void AppendEvent(object sseEvent) => events.Append("data: ").Append(JsonSerializer.Serialize(sseEvent)).Append("\n\n");
+ }
+
+ private FilteredText Filter(string text)
+ {
+ var occurrences = text.Split(suspiciousPassage).Length - 1;
+ var findings = Enumerable.Repeat