Skip to content

Commit f4bcfab

Browse files
fix: address multi-agent review findings (security hardening iteration 2)
Based on GPT-5.5 and Claude Opus 4.6 review of the initial implementation: - fix(streaming): ExecuteFunctionCallAsync on rejected tool now streams a recovery response ('tool not available') back to the model instead of yield break, preventing silent stream truncation and OpenAI protocol violations (both agents flagged as blocker) - fix(allowlist): AllowedMcpTools changes from fail-open (empty=allow all) to fail-secure (empty=deny all). Added AllowAllMcpTools bool for explicit dev override. Converted _AllowedMcpTools to FrozenSet<string> at ctor time for O(1) lookup and immutability. Added IsMcpToolAllowed() helper to centralize the check across all three call sites. - fix(rag): Sanitize XML angle brackets in Heading and ChunkText using typographic alternatives (U+2039/U+203A) before inserting into <retrieved_context> block, preventing boundary-escape attacks - fix(cache): ResponseIdValidationService.RecordResponseId sets sliding expiry inside the GetOrCreate factory (not in a separate Set call) eliminating the TOCTOU race between GetOrCreate and cache.Set. Added 500-ID cap per user to prevent unbounded HashSet growth. - fix(docs): Corrected endUserId param comment to say 'reserved for forwarding' rather than claiming it is already forwarded
1 parent 51881d0 commit f4bcfab

3 files changed

Lines changed: 85 additions & 19 deletions

File tree

EssentialCSharp.Chat.Shared/Models/AIOptions.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,18 @@ public class AIOptions
2424

2525
/// <summary>
2626
/// Static allowlist of MCP tool names the AI agent is permitted to invoke.
27-
/// When non-empty, acts as an outer gate independent of the MCP server channel:
28-
/// tools not on this list are neither advertised to the model nor executed.
29-
/// When empty, all tools advertised by the MCP server are allowed (development default).
27+
/// Tools not on this list are neither advertised to the model nor executed.
3028
/// </summary>
29+
/// <remarks>
30+
/// When empty and <see cref="AllowAllMcpTools"/> is <c>false</c> (the default), all MCP tool
31+
/// calls are denied — fail-secure. Set <see cref="AllowAllMcpTools"/> to <c>true</c> to allow
32+
/// all tools without an explicit list (useful in development environments only).
33+
/// </remarks>
3134
public List<string> AllowedMcpTools { get; set; } = [];
35+
36+
/// <summary>
37+
/// When <c>true</c>, bypasses the <see cref="AllowedMcpTools"/> allowlist and permits all
38+
/// MCP tools. Should only be set in non-production environments.
39+
/// </summary>
40+
public bool AllowAllMcpTools { get; set; }
3241
}

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ModelContextProtocol.Client;
55
using ModelContextProtocol.Protocol;
66
using OpenAI.Responses;
7+
using System.Collections.Frozen;
78

89
namespace EssentialCSharp.Chat.Common.Services;
910

@@ -19,12 +20,14 @@ public partial class AIChatService
1920
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
2021
private readonly AISearchService _SearchService;
2122
private readonly ILogger<AIChatService> _Logger;
23+
private readonly FrozenSet<string> _AllowedMcpTools;
2224

2325
public AIChatService(IOptions<AIOptions> options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger<AIChatService> logger)
2426
{
2527
_Options = options.Value;
2628
_SearchService = searchService;
2729
_Logger = logger;
30+
_AllowedMcpTools = _Options.AllowedMcpTools.ToFrozenSet(StringComparer.Ordinal);
2831

2932
// Initialize Azure OpenAI client and get the Response Client from it
3033
_AzureClient = azureClient;
@@ -43,7 +46,8 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
4346
/// <param name="tools">Optional tools for the AI to use</param>
4447
/// <param name="reasoningEffortLevel">Optional reasoning effort level for reasoning models</param>
4548
/// <param name="enableContextualSearch">Enable vector search for contextual information</param>
46-
/// <param name="endUserId">Identifier of the authenticated end-user, forwarded to Azure OpenAI for abuse monitoring</param>
49+
/// <param name="endUserId">Authenticated end-user identifier. Currently reserved for forwarding
50+
/// to Azure OpenAI for abuse monitoring once the SDK exposes <c>ResponseCreationOptions.User</c>.</param>
4751
/// <param name="cancellationToken">Cancellation token</param>
4852
/// <returns>The AI response text and response ID for conversation continuity</returns>
4953
public async Task<(string response, string responseId)> GetChatCompletion(
@@ -73,7 +77,8 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
7377
/// <param name="tools">Optional tools for the AI to use</param>
7478
/// <param name="reasoningEffortLevel">Optional reasoning effort level for reasoning models</param>
7579
/// <param name="enableContextualSearch">Enable vector search for contextual information</param>
76-
/// <param name="endUserId">Identifier of the authenticated end-user, forwarded to Azure OpenAI for abuse monitoring</param>
80+
/// <param name="endUserId">Authenticated end-user identifier. Currently reserved for forwarding
81+
/// to Azure OpenAI for abuse monitoring once the SDK exposes <c>ResponseCreationOptions.User</c>.</param>
7782
/// <param name="cancellationToken">Cancellation token</param>
7883
/// <returns>An async enumerable of response text chunks and final response ID</returns>
7984
public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
@@ -135,14 +140,19 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
135140

136141
foreach (var result in searchResults)
137142
{
138-
contextualInfo.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $"**From: {result.Record.Heading}**");
139-
// Truncate individual chunks to limit attack surface from future data poisoning
143+
// Replace XML angle brackets to prevent retrieval content from escaping the sandbox.
144+
// Use typographic alternatives (‹›) to preserve readability of C# generics in headings.
145+
var heading = SanitizeForXmlContext(result.Record.Heading);
146+
contextualInfo.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $"**From: {heading}**");
147+
148+
// Truncate individual chunks to limit attack surface from future data poisoning,
149+
// then sanitize any XML bracket characters.
140150
var chunkText = result.Record.ChunkText;
141151
if (chunkText?.Length > 2000)
142152
{
143153
chunkText = chunkText[..2000];
144154
}
145-
contextualInfo.AppendLine(chunkText);
155+
contextualInfo.AppendLine(SanitizeForXmlContext(chunkText));
146156
contextualInfo.AppendLine();
147157
}
148158

@@ -154,6 +164,13 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
154164
return contextualInfo.ToString();
155165
}
156166

167+
/// <summary>
168+
/// Replaces XML angle bracket characters in retrieval content to prevent boundary escapes.
169+
/// Uses typographic alternatives (‹›) rather than stripping to preserve code readability.
170+
/// </summary>
171+
private static string SanitizeForXmlContext(string? input) =>
172+
input?.Replace("<", "\u2039").Replace(">", "\u203A") ?? string.Empty;
173+
157174
/// <summary>
158175
/// Processes streaming updates from the OpenAI Responses API, handling both regular responses and function calls
159176
/// </summary>
@@ -219,10 +236,27 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
219236
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
220237
{
221238
// Defense-in-depth: validate tool name against static allowlist before executing.
222-
var allowedTools = _Options.AllowedMcpTools;
223-
if (allowedTools.Count > 0 && !allowedTools.Contains(functionCallItem.FunctionName))
239+
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
224240
{
225241
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName);
242+
// Feed a benign error back to the model so it can recover gracefully,
243+
// mirroring what GetChatCompletionCore does on the non-streaming path.
244+
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
245+
var errorItems = new List<ResponseItem>
246+
{
247+
functionCallItem,
248+
new FunctionCallOutputResponseItem(
249+
functionCallItem.CallId,
250+
$"Tool '{functionCallItem.FunctionName}' is not available.")
251+
};
252+
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
253+
var recoveryStream = _ResponseClient.CreateResponseStreamingAsync(
254+
errorItems, responseOptions, cancellationToken);
255+
await foreach (var result in ProcessStreamingUpdatesAsync(
256+
recoveryStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
257+
{
258+
yield return result;
259+
}
226260
yield break;
227261
}
228262

@@ -324,13 +358,11 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
324358
if (mcpClient is not null)
325359
{
326360
var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
327-
var allowedTools = _Options.AllowedMcpTools;
328361

329362
foreach (McpClientTool tool in mcpTools)
330363
{
331364
// Outer gate: skip tools not on the static config-driven allowlist.
332-
// When the allowlist is empty (development default), all tools are allowed.
333-
if (allowedTools.Count > 0 && !allowedTools.Contains(tool.Name))
365+
if (!IsMcpToolAllowed(tool.Name))
334366
{
335367
LogMcpToolSkippedNotAllowed(_Logger, tool.Name);
336368
continue;
@@ -396,8 +428,7 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
396428
{
397429
// Defense-in-depth: validate tool name against static allowlist before executing.
398430
// This catches cases where the model hallucinates a tool name not on the list.
399-
var allowedTools = _Options.AllowedMcpTools;
400-
if (allowedTools.Count > 0 && !allowedTools.Contains(functionCallItem.FunctionName))
431+
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
401432
{
402433
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName);
403434
// Return a benign error to the model so it can respond gracefully
@@ -454,6 +485,17 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
454485
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
455486
}
456487

488+
/// <summary>
489+
/// Returns <c>true</c> when the named MCP tool is permitted to execute.
490+
/// Respects <see cref="AIOptions.AllowAllMcpTools"/> and the <see cref="AIOptions.AllowedMcpTools"/> allowlist.
491+
/// Fails secure: an empty allowlist with <see cref="AIOptions.AllowAllMcpTools"/> = false denies all tools.
492+
/// </summary>
493+
private bool IsMcpToolAllowed(string toolName)
494+
{
495+
if (_Options.AllowAllMcpTools) return true;
496+
return _AllowedMcpTools.Contains(toolName);
497+
}
498+
457499
[LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration}")]
458500
private static partial void LogMcpToolCallInvoked(ILogger logger, string toolName, int iteration);
459501

EssentialCSharp.Web/Services/ResponseIdValidationService.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ namespace EssentialCSharp.Web.Services;
1414
/// </remarks>
1515
public sealed class ResponseIdValidationService(IMemoryCache cache)
1616
{
17-
private static readonly MemoryCacheEntryOptions _CacheOptions =
17+
private const int MaxTrackedIdsPerUser = 500;
18+
19+
private static MemoryCacheEntryOptions MakeCacheOptions() =>
1820
new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromHours(2));
1921

2022
/// <summary>
@@ -28,13 +30,26 @@ public void RecordResponseId(string? userId, string? responseId)
2830
}
2931

3032
var key = CacheKey(userId);
31-
var ids = cache.GetOrCreate(key, _ => new HashSet<string>(StringComparer.Ordinal))!;
33+
// Set the sliding expiry inside the factory so there is no window between
34+
// GetOrCreate and a separate Set call where the entry could be evicted and
35+
// recreated as a different HashSet instance.
36+
var ids = cache.GetOrCreate(key, entry =>
37+
{
38+
entry.SetSlidingExpiration(TimeSpan.FromHours(2));
39+
return new HashSet<string>(StringComparer.Ordinal);
40+
})!;
41+
3242
lock (ids)
3343
{
3444
ids.Add(responseId);
45+
// Cap the set to prevent unbounded memory growth for users with many requests.
46+
if (ids.Count > MaxTrackedIdsPerUser)
47+
{
48+
// Remove an arbitrary element; we only need the most recent IDs
49+
// to be present — older ones falling off is acceptable.
50+
ids.Remove(ids.First());
51+
}
3552
}
36-
// Re-set to refresh the sliding expiry
37-
cache.Set(key, ids, _CacheOptions);
3853
}
3954

4055
/// <summary>

0 commit comments

Comments
 (0)