Content filtering in enterprise LLM applications isn’t optional — it’s a compliance requirement, a brand protection mechanism, and a cost control lever all at once. Semantic Kernel provides several layers for this, but the documentation treats them as isolated features rather than a cohesive strategy. This guide walks through building a production-grade filtering pipeline that handles prompt injection, PII leakage, policy violations, and model-specific guardrails without adding unacceptable latency.
Understand the filtering surface area
Before writing code, map what needs filtering and where it lives in the request lifecycle. In a typical Semantic Kernel application, you have four intervention points:
- User input — before the prompt reaches the kernel
- Function arguments — before plugins execute
- Model input — the final rendered prompt sent to the LLM
- Model output — before the response returns to the user
Each point catches different threat vectors. User input filtering catches prompt injection early but misses context assembled by planners. Model output filtering catches hallucinated PII but can’t prevent a plugin from leaking data upstream. You need defense in depth.
Start with the built-in IPromptRenderFilter
Semantic Kernel’s IPromptRenderFilter interface lets you intercept the prompt after template rendering but before model invocation. This is your first line of defense for prompt injection and policy keywords.
public sealed class PolicyPromptFilter : IPromptRenderFilter
{
private static readonly Regex[] InjectionPatterns =
[
new(@"ignore\s+previous\s+instructions", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"system\s*:\s*you\s+are\s+now", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"<\|im_start\|>system", RegexOptions.IgnoreCase | RegexOptions.Compiled),
];
private readonly HashSet<string> _blockedTerms;
public PolicyPromptFilter(IConfiguration config)
{
_blockedTerms = new HashSet<string>(
config.GetSection("ContentFiltering:BlockedTerms").Get<string[]>() ?? [],
StringComparer.OrdinalIgnoreCase);
}
public async Task OnPromptRenderAsync(PromptRenderContext context, Func<PromptRenderContext, Task> next)
{
var rendered = context.RenderedPrompt;
foreach (var pattern in InjectionPatterns)
{
if (pattern.IsMatch(rendered))
{
context.RenderedPrompt = "[BLOCKED: Prompt injection detected]";
_logger.LogWarning("Prompt injection blocked for user {UserId}", context.Arguments["userId"]);
return;
}
}
foreach (var term in _blockedTerms)
{
if (rendered.Contains(term, StringComparison.OrdinalIgnoreCase))
{
context.RenderedPrompt = $"[BLOCKED: Policy violation - {term}]";
return;
}
}
await next(context);
}
}
Register it in Program.cs:
builder.Services.AddSingleton<IPromptRenderFilter, PolicyPromptFilter>();
builder.Services.AddKernel()
.AddFilter<PolicyPromptFilter>();
Pitfall: This filter runs after the prompt template renders. If your template includes user-controlled data (chat history, RAG chunks), the filter sees the fully assembled prompt — which is exactly what you want. But it also means you can’t distinguish between “user said X” and “system template included X” without additional metadata.
Add function-level filtering with IFunctionInvocationFilter
Plugins execute arbitrary code. A SQL plugin might receive a malicious WHERE clause; an email plugin might get injected headers. IFunctionInvocationFilter intercepts every function call with full argument visibility.
public sealed class FunctionArgumentFilter : IFunctionInvocationFilter
{
private readonly PiiDetector _piiDetector;
public FunctionArgumentFilter(PiiDetector piiDetector) => _piiDetector = piiDetector;
public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
{
foreach (var (name, value) in context.Arguments)
{
if (value is string str && _piiDetector.ContainsPii(str))
{
var violation = new ContentFilterViolation
{
Type = ViolationType.PiiInFunctionArgument,
FunctionName = context.Function.Name,
ArgumentName = name,
DetectedAt = DateTimeOffset.UtcNow
};
_auditLog.Write(violation);
throw new ContentFilterException($"PII detected in argument '{name}' for function '{context.Function.Name}'");
}
if (value is string sql && SqlInjectionDetector.LooksLikeInjection(sql))
{
throw new ContentFilterException($"SQL injection pattern detected in argument '{name}'");
}
}
await next(context);
}
}
Register alongside the prompt filter:
builder.Services.AddSingleton<IFunctionInvocationFilter, FunctionArgumentFilter>();
Tradeoff: Function filters add latency to every plugin call. In a planner-driven workflow with 10+ function invocations, this compounds. Cache detection results where possible, and consider async non-blocking audit writes.
Integrate Azure AI Content Safety for model I/O
Regex and heuristics don’t catch semantic violations — hate speech, self-harm, sexual content, violence. Azure AI Content Safety (or the equivalent AWS/GCP service) provides calibrated classifiers. Wire it into a custom IAutoFunctionInvocationFilter for model output, and a prompt filter for input.
public sealed class AzureContentSafetyFilter : IAutoFunctionInvocationFilter
{
private readonly ContentSafetyClient _client;
private readonly ContentFilterConfig _config;
public AzureContentSafetyFilter(ContentSafetyClient client, IOptions<ContentFilterConfig> config)
{
_client = client;
_config = config.Value;
}
public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func<AutoFunctionInvocationContext, Task> next)
{
await next(context);
if (context.Result is not string output) return;
var request = new AnalyzeTextOptions(output)
{
Categories = [TextCategory.Hate, TextCategory.SelfHarm, TextCategory.Sexual, TextCategory.Violence],
BlocklistNames = _config.CustomBlocklistNames
};
var response = await _client.AnalyzeTextAsync(request);
foreach (var category in response.CategoriesAnalysis)
{
if (category.Severity >= _config.BlockThreshold)
{
_metrics.Increment("content_filter.blocked", tags: new { category = category.Category.ToString() });
context.Result = $"[BLOCKED: {category.Category} content detected (severity {category.Severity})]";
return;
}
}
// Log near-misses for threshold tuning
foreach (var category in response.CategoriesAnalysis.Where(c => c.Severity >= _config.ReviewThreshold))
{
_metrics.Increment("content_filter.review", tags: new { category = category.Category.ToString(), severity = c.Severity });
}
}
}
For input filtering, create a prompt render filter that calls the same client before the model sees the prompt. This catches adversarial inputs designed to elicit prohibited outputs.
Configuration note: Store thresholds in config, not code. Different tenants, jurisdictions, and use cases need different sensitivity. A healthcare copilot needs stricter self-harm thresholds than a code assistant.
Compose filters in the correct order
Filter execution order matters. Semantic Kernel runs filters in registration order for each type. The effective pipeline for a single model call looks like:
User Input → PromptRenderFilters (in order) → Model → AutoFunctionInvocationFilters (in order) → User Output
↓
FunctionInvocationFilters (per function call)
Register in this sequence:
// 1. Cheap, fast heuristics first — fail fast
builder.Services.AddSingleton<IPromptRenderFilter, PolicyPromptFilter>();
builder.Services.AddSingleton<IPromptRenderFilter, PiPromptRenderFilter>();
// 2. Expensive external calls last
builder.Services.AddSingleton<IPromptRenderFilter, AzureContentSafetyInputFilter>();
// 3. Function filters
builder.Services.AddSingleton<IFunctionInvocationFilter, FunctionArgumentFilter>();
// 4. Output filters
builder.Services.AddSingleton<IAutoFunctionInvocationFilter, AzureContentSafetyFilter>();
builder.Services.AddSingleton<IAutoFunctionInvocationFilter, PiiOutputFilter>();
Why this order: If a $0.001 regex catches 90% of injection attempts, you save the $0.01 API call to Content Safety. Fail fast, fail cheap.
Handle streaming responses
Streaming breaks the simple “filter after completion” model. You can’t classify a partial token stream reliably, but you can buffer and classify per chunk or per sentence boundary.
public sealed class StreamingContentFilter : IStreamingContentFilter
{
private readonly ContentSafetyClient _client;
private readonly StringBuilder _buffer = new();
private int _tokensSinceCheck;
public async IAsyncEnumerable<StreamingKernelContent> FilterAsync(
IAsyncEnumerable<StreamingKernelContent> stream,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var chunk in stream.WithCancellation(ct))
{
if (chunk is StreamingTextContent text)
{
_buffer.Append(text.Text);
_tokensSinceCheck++;
// Check every ~50 tokens or at sentence boundaries
if (_tokensSinceCheck >= 50 || SentenceEnds(text.Text))
{
var violation = await CheckBufferAsync(ct);
if (violation != null)
{
yield return new StreamingTextContent($"[BLOCKED: {violation}]");
yield break;
}
_tokensSinceCheck = 0;
}
}
yield return chunk;
}
}
}
Pitfall: Buffering adds latency visible to the user. Tune the check interval — too frequent kills throughput, too sparse lets prohibited content render before blocking. For most enterprise apps, 50-100 tokens is a reasonable balance.
Build observability from day one
You cannot tune what you cannot measure. Every filter decision — allow, block, review — must emit structured logs and metrics.
public static class ContentFilterMetrics
{
private static readonly Meter Meter = new("SemanticKernel.ContentFiltering");
private static readonly Counter<long> Blocked = Meter.CreateCounter<long>("content_filter.blocked_total");
private static readonly Counter<long> Allowed = Meter.CreateCounter<long>("content_filter.allowed_total");
private static readonly Counter<long> Review = Meter.CreateCounter<long>("content_filter.review_total");
private static readonly Histogram<double> Latency = Meter.CreateHistogram<double>("content_filter.latency_ms");
public static void RecordBlock(string filterName, string category, string tenantId)
{
Blocked.Add(1, new KeyValuePair<string, object?>("filter", filterName),
new("category", category), new("tenant", tenantId));
}
public static void RecordLatency(string filterName, double ms)
{
Latency.Record(ms, new KeyValuePair<string, object?>("filter", filterName));
}
}
Wrap each filter invocation:
using var _ = ContentFilterMetrics.MeasureLatency("azure_content_safety");
var result = await _client.AnalyzeTextAsync(request);
if (result.IsBlocked) ContentFilterMetrics.RecordBlock("azure_content_safety", category, tenantId);
Dashboard essentials: Block rate by category, false positive estimates (via review queue), p99 latency per filter, tenant-level breakdowns. Alert on block rate spikes — they indicate either an attack or a threshold regression.
Test with adversarial datasets
Unit tests with happy-path inputs are useless for content filtering. Build a test harness that runs your full pipeline against:
- Prompt injection corpus — the injection dataset from Agency, plus your own internal red-team findings
- PII permutations — emails, phones, SSNs, credit cards in various formats, embedded in markdown, JSON, base64
- Policy edge cases — “How do I make a bomb?” vs “How do I defuse a bomb in a movie script?” vs “Write a story where a character makes a bomb”
- Multilingual attacks — injection attempts in Spanish, Chinese, Arabic, encoded in Unicode variants
[Theory]
[MemberData(nameof(InjectionAttempts))]
public async Task PromptFilter_BlocksInjectionAttempts(string attack)
{
var context = CreateContext(attack);
await _filter.OnPromptRenderAsync(context, _ => Task.CompletedTask);
context.RenderedPrompt.Should().Contain("[BLOCKED");
}
Run this in CI on every PR. Treat filter bypasses as P0 bugs.
Plan for false positives and appeals
Legitimate enterprise content gets blocked. Legal contracts trigger “violence” classifiers (force majeure, termination clauses). Medical notes trigger “self-harm” (patient history). Code reviews trigger “sexual content” (variable names like master/slave, sanitize_input).
Build an appeal path:
public sealed class FilterAppealService
{
private readonly IAppealQueue _queue;
public async Task<AppealResult> SubmitAppealAsync(ContentFilterViolation violation, string userId, string justification)
{
var appeal = new FilterAppeal
{
Violation = violation,
UserId = userId,
Justification = justification,
SubmittedAt = DateTimeOffset.UtcNow,
Status = AppealStatus.Pending
};
await _queue.EnqueueAsync(appeal);
return new AppealResult { AppealId = appeal.Id, EstimatedReviewTime = TimeSpan.FromHours(4) };
}
}
Expose this via an API endpoint your frontend can call when a block occurs. Log every appeal outcome — approved appeals are training data for threshold tuning.
Common pitfalls summary
| Pitfall | Symptom | Fix |
|---|---|---|
| Filtering only user input | Planner assembles malicious prompt from trusted components | Filter at model input (post-render) |
| No function argument filtering | SQL injection via plugin, PII leaked to email plugin | Implement IFunctionInvocationFilter |
| Synchronous external calls | p99 latency > 5s under load | Async audit, cache classifications, circuit breaker |
| Hardcoded thresholds | Can’t adjust per tenant/jurisdiction | Config-driven thresholds, feature flags |
| No streaming support | Prohibited content renders before block | Buffer + periodic classification |
| Single filter type | Hate speech caught, prompt injection misses | Layer: regex → heuristic → ML classifier |
| No observability | Can’t explain why legitimate request blocked | Structured logs, metrics, appeal trail |
What this looks like in production
A request flows through:
- API gateway validates auth, rate limits, extracts tenant ID
- Prompt render filters run: regex injection check → PII heuristic → Azure Content Safety (cached for repeated prompts)
- Kernel executes planner → functions (each intercepted by function argument filter)
- Model streams → streaming filter buffers, classifies per 50 tokens
- Auto function invocation filter runs final output classification
- Response returns with any blocks replaced by policy messages
- Metrics emitted for every decision point
Total added latency: 50-150ms p99 for the filtering layer, dominated by the Content Safety API call. Cache prompt-level classifications with a TTL keyed by prompt hash + tenant + model to amortize this.
The semantic kernel enterprise content filtering strategy isn’t a single component — it’s a pipeline where each stage handles what the previous stage couldn’t. Start simple, measure aggressively, and treat every bypass as a process failure, not a model failure.