Add PII redaction POC for secure LLM prompting.

Implements detect-redact-sanitize pipeline with regex, domain rules, and ONNX NER before the LLM boundary, plus NUnit tests and Xenovex push documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bilal Nazer Ali
2026-07-07 13:05:07 +05:30
commit dfc81dea28
60 changed files with 3283 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
namespace PiiRedaction.Core.Tests.TestSupport;
public sealed class CapturingChatClient : IChatClient
{
public string? LastUserMessage { get; private set; }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
LastUserMessage = messages.LastOrDefault(message => message.Role == ChatRole.User)?.Text;
var response = new ChatResponse(new ChatMessage(
ChatRole.Assistant,
$"Captured {LastUserMessage?.Length ?? 0} chars."));
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
yield return new ChatResponseUpdate(ChatRole.Assistant, response.Messages.Last().Text);
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}

View File

@@ -0,0 +1,19 @@
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
public sealed class FakeOnnxNerModelRunner : IOnnxNerModelRunner
{
public bool IsModelAvailable { get; set; }
public IReadOnlyList<PiiEntity> EntitiesToReturn { get; set; } = [];
public string? LastPredictedText { get; private set; }
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
LastPredictedText = text;
return EntitiesToReturn;
}
}

View File

@@ -0,0 +1,33 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
public static class NerEntityBuilder
{
public static IReadOnlyList<PiiEntity> BuildFromScenario(PromptScenario scenario)
{
var entities = new List<PiiEntity>();
foreach (var (type, value) in scenario.ExpectedTypes.Zip(scenario.MustNotContainInSanitized))
{
if (type != PiiEntityType.Person)
{
continue;
}
var searchStart = 0;
while ((searchStart = scenario.Prompt.IndexOf(value, searchStart, StringComparison.Ordinal)) >= 0)
{
entities.Add(new PiiEntity(
PiiEntityType.Person,
value,
searchStart,
value.Length,
PiiDetectionSource.Ner));
searchStart += value.Length;
}
}
return entities;
}
}

View File

@@ -0,0 +1,49 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Redaction;
using PiiRedaction.Core.Sanitization;
namespace PiiRedaction.Core.Tests.TestSupport;
public static class ProductionPipelineFactory
{
public static IPromptSanitizer CreateWithRealModel(IOnnxNerModelRunner runner) =>
new PromptSanitizer(CreateCompositeDetector(runner), new PlaceholderPiiRedactor());
public static IPiiDetector CreateCompositeDetector(IOnnxNerModelRunner runner) =>
new CompositePiiDetector(
[
new DomainRulePiiDetector(),
new RegexPiiDetector(),
new OnnxNerPiiDetector(runner)
]);
public static (IPromptSanitizer Sanitizer, FakeOnnxNerModelRunner NerRunner) Create(
bool modelAvailable = false,
IReadOnlyList<Models.PiiEntity>? nerEntities = null)
{
var nerRunner = CreateNerRunner(modelAvailable, nerEntities);
var sanitizer = new PromptSanitizer(CreateCompositeDetector(nerRunner), new PlaceholderPiiRedactor());
return (sanitizer, nerRunner);
}
public static IPromptSanitizer CreateForScenario(PromptScenario scenario)
{
var nerEntities = NerEntityBuilder.BuildFromScenario(scenario);
return Create(modelAvailable: nerEntities.Count > 0, nerEntities: nerEntities).Sanitizer;
}
public static IPiiDetector CreateCompositeDetector(
bool modelAvailable = false,
IReadOnlyList<Models.PiiEntity>? nerEntities = null) =>
CreateCompositeDetector(CreateNerRunner(modelAvailable, nerEntities));
private static FakeOnnxNerModelRunner CreateNerRunner(
bool modelAvailable,
IReadOnlyList<Models.PiiEntity>? nerEntities) =>
new()
{
IsModelAvailable = modelAvailable,
EntitiesToReturn = nerEntities ?? []
};
}

View File

@@ -0,0 +1,10 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
public sealed record PromptScenario(
string Name,
string Prompt,
string ExpectedSanitized,
PiiEntityType[] ExpectedTypes,
string[] MustNotContainInSanitized);

View File

@@ -0,0 +1,68 @@
using NUnit.Framework;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
/// <summary>
/// Focused end-to-end scenarios that exercise the full sanitizer pipeline beyond what unit tests cover in isolation.
/// </summary>
public static class PromptScenarioCatalog
{
public static IEnumerable<TestCaseData> AllScenarios()
{
foreach (var scenario in BuildScenarios())
{
yield return new TestCaseData(scenario).SetName(scenario.Name);
}
}
private static IEnumerable<PromptScenario> BuildScenarios()
{
yield return CanonicalScenario();
yield return MultiRegexScenario();
yield return DuplicatePeopleScenario();
yield return OverlapScenario();
yield return NoPiiScenario();
}
private static PromptScenario CanonicalScenario()
{
const string prompt =
"Customer Ravi Kumar with email ravi.kumar@gmail.com and phone 9876543210 has LoanNumber LN-456789 and PAN ABCDE1234F. Please summarize this customer issue.";
return new PromptScenario(
"Canonical_DemoPrompt",
prompt,
"Customer <PERSON_1> with email <EMAIL_1> and phone <PHONE_1> has LoanNumber <LOAN_NUMBER_1> and PAN <PAN_1>. Please summarize this customer issue.",
[PiiEntityType.Person, PiiEntityType.Email, PiiEntityType.Phone, PiiEntityType.LoanNumber, PiiEntityType.Pan],
["Ravi Kumar", "ravi.kumar@gmail.com", "9876543210", "LN-456789", "ABCDE1234F"]);
}
private static PromptScenario MultiRegexScenario() => new(
"Full_AllRegexTypes",
"Email a@b.co phone 9001234567 PAN ABCDE1234F aadhaar 1234 5678 9012 card 4111-1111-1111-1111.",
"Email <EMAIL_1> phone <PHONE_1> PAN <PAN_1> aadhaar <AADHAAR_1> card <CREDIT_CARD_1>.",
[PiiEntityType.Email, PiiEntityType.Phone, PiiEntityType.Pan, PiiEntityType.Aadhaar, PiiEntityType.CreditCard],
["a@b.co", "9001234567", "ABCDE1234F", "1234 5678 9012", "4111-1111-1111-1111"]);
private static PromptScenario DuplicatePeopleScenario() => new(
"Duplicate_TwoPeople",
"Customer Ravi Kumar and Customer Priya Nair.",
"Customer <PERSON_2> and Customer <PERSON_1>.",
[PiiEntityType.Person, PiiEntityType.Person],
["Ravi Kumar", "Priya Nair"]);
private static PromptScenario OverlapScenario() => new(
"Overlap_AadhaarAndPhone",
"Aadhaar 987654321012 and phone 9876543210.",
"Aadhaar <AADHAAR_1> and phone <PHONE_1>.",
[PiiEntityType.Aadhaar, PiiEntityType.Phone],
["9876543210"]);
private static PromptScenario NoPiiScenario() => new(
"Negative_NoPii",
"Please summarize the general policy on refunds.",
"Please summarize the general policy on refunds.",
[],
[]);
}