Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3098.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3098
affected:
- src/CodeIndex/Mcp/McpServer.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **MCP out-of-band client responses are capped before cloning (#3098)** — client-supplied result and error payloads are measured with a bounded JSON writer before they are retained, and oversized responses are rejected with payload-free diagnostics.

## 日本語

- **MCP の out-of-band client response を clone 前に上限チェックするようになりました (#3098)** — client supplied な result / error payload は保持前に bounded JSON writer で測定され、過大な応答は payload を含まない診断で拒否されます。
18 changes: 18 additions & 0 deletions changelog.d/unreleased/3105.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: fixed
issues:
- 3105
affected:
- src/CodeIndex/Mcp/McpServer.cs
- src/CodeIndex/Mcp/AuditLogSink.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- tests/CodeIndex.Tests/McpAuditLogTests.cs
---

## English

- **MCP audit argument metadata now reports bounded key truncation counters (#3105)** — audit and telemetry events cap argument key count and key display length with explicit omitted/truncated counters so large argument maps cannot silently inflate record metadata.

## 日本語

- **MCP audit の引数メタデータが bounded なキー切り詰めカウンタを報告するようになりました (#3105)** — audit / telemetry event は引数キー数とキー表示長を上限内に収め、省略数と切り詰めキー数を明示するため、大きな引数 map がレコードメタデータを静かに膨らませないようになります。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3107.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 3107
affected:
- src/CodeIndex/Mcp/AuditLogSink.cs
- tests/CodeIndex.Tests/AuditLogSinkTests.cs
---

## English

- **MCP audit log events are now serialized through a per-record byte cap (#3107)** — oversized audit records are reduced with an explicit `event_truncated` marker before writing, preventing one event from bypassing rotation by forcing a large serialized line.

## 日本語

- **MCP audit log event をレコード単位の byte 上限内で serialize するようになりました (#3107)** — 過大な audit record は書き込み前に明示的な `event_truncated` marker 付きで縮小され、単一eventが大きなserialized lineを作ってrotationを迂回することを防ぎます。
343 changes: 251 additions & 92 deletions src/CodeIndex/Mcp/AuditLogSink.cs

Large diffs are not rendered by default.

109 changes: 92 additions & 17 deletions src/CodeIndex/Mcp/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ public partial class McpServer : IDisposable
internal const int MaxLineByteLength = 1_048_576;
internal const int DefaultMaxResponseBytes = 10 * 1024 * 1024;
internal const int MaxConfiguredResponseBytes = 64 * 1024 * 1024;
internal const int MaxClientResponseJsonBytes = 1 * 1024 * 1024;
internal const int MaxMcpPaginationOffset = 10_000;
internal const double MinKeepAliveIntervalSeconds = 1.0;
internal const double MaxKeepAliveIntervalSeconds = 300.0;
Expand Down Expand Up @@ -978,16 +979,40 @@ private bool TryCompletePendingClientRequest(JsonNode request)
return false;

if (obj.TryGetPropertyValue("error", out var error) && error is not null)
pending.TrySetException(new InvalidOperationException(error.ToJsonString(_jsonOptions)));
{
if (!TrySerializeClientResponseError(error, out var serializedError, out var errorBytes))
{
DeferFrameLog(BuildClientResponseTooLargeLog("error", errorBytes));
pending.TrySetException(new InvalidOperationException(BuildClientResponseTooLargeMessage(errorBytes)));
}
else
{
pending.TrySetException(new InvalidOperationException(serializedError));
}
}
else if (!TryCloneClientResponsePayload(obj["result"], out var resultClone, out var resultBytes))
{
DeferFrameLog(BuildClientResponseTooLargeLog("result", resultBytes));
pending.TrySetException(new InvalidOperationException(BuildClientResponseTooLargeMessage(resultBytes)));
}
else
pending.TrySetResult(obj["result"]?.DeepClone());
{
pending.TrySetResult(resultClone);
}
return true;
}

private async Task<JsonNode?> SendClientRequestAsync(string method, JsonObject? @params, CancellationToken cancellationToken)
{
if (ClientRequestHandlerForTests is { } handler)
return handler(method, @params)?.DeepClone();
{
if (!TryCloneClientResponsePayload(handler(method, @params), out var handlerClone, out var handlerBytes))
{
DeferFrameLog(BuildClientResponseTooLargeLog("result", handlerBytes));
return null;
}
return handlerClone;
}

var writer = _currentOutOfBandFrameWriter.Value;
if (writer is null || !_canAwaitClientResponses.Value)
Expand Down Expand Up @@ -1036,6 +1061,29 @@ private bool TryCompletePendingClientRequest(JsonNode request)
}
}

internal bool TryCloneClientResponsePayloadForTests(JsonNode? payload, out JsonNode? clone, out int bytesWritten)
=> TryCloneClientResponsePayload(payload, out clone, out bytesWritten);

internal bool TrySerializeClientResponseErrorForTests(JsonNode error, out string? serialized, out int bytesWritten)
=> TrySerializeClientResponseError(error, out serialized, out bytesWritten);

private bool TryCloneClientResponsePayload(JsonNode? payload, out JsonNode? clone, out int bytesWritten)
{
clone = null;
bytesWritten = 0;
if (payload is null)
return true;

if (!TryMeasureJsonUtf8BytesWithinLimit(payload, _jsonOptions, MaxClientResponseJsonBytes, out bytesWritten))
return false;

clone = payload.DeepClone();
return true;
}

private bool TrySerializeClientResponseError(JsonNode error, out string? serialized, out int bytesWritten)
=> TrySerializeJsonNodeWithinByteLimit(error, _jsonOptions, MaxClientResponseJsonBytes, captureSerialized: true, out serialized, out bytesWritten);

private static string? TryGetMcpTraceParent(JsonNode request)
{
if (request is not JsonObject obj ||
Expand Down Expand Up @@ -2621,7 +2669,9 @@ private void EmitToolInvocationTelemetry(string toolName, JsonNode? args, JsonNo
out _,
out _,
out var argKeysTruncated,
out var argKeyTruncationReasons);
out var argKeyTruncationReasons,
out var argKeysOmittedCount,
out var argKeyNamesTruncatedCount);
var toolDisplay = BoundToolNameForDisplay(toolName);
var argsObject = new JsonObject();
foreach (var pair in argLengths)
Expand All @@ -2643,7 +2693,7 @@ private void EmitToolInvocationTelemetry(string toolName, JsonNode? args, JsonNo
["arg_lengths"] = argsObject,
};
toolDisplay.AddMetadata(evt, "tool");
AddArgKeyMetadata(evt, argKeyLengths);
AddArgKeyMetadata(evt, argKeyLengths, argKeysOmittedCount, argKeyNamesTruncatedCount);
if (argKeysTruncated)
evt["arg_keys_truncated"] = true;
if (argKeyTruncationReasons.Count > 0)
Expand Down Expand Up @@ -2786,7 +2836,9 @@ private void TryEmitAudit(string toolName, JsonNode? id, JsonNode? args, JsonNod
out var argValueTruncationReasons,
out var argValuesSerializedBytes,
out var argKeysTruncated,
out var argKeyTruncationReasons);
out var argKeyTruncationReasons,
out var argKeysOmittedCount,
out var argKeyNamesTruncatedCount);
var toolDisplay = BoundToolNameForDisplay(toolName);
var requestId = SerializeRequestId(id);
BoundedMcpText? requestIdDisplay = requestId is null
Expand All @@ -2810,6 +2862,8 @@ private void TryEmitAudit(string toolName, JsonNode? id, JsonNode? args, JsonNod
ArgKeyLengths: argKeyLengths,
ArgKeysTruncated: argKeysTruncated,
ArgKeyTruncationReasons: argKeyTruncationReasons,
ArgKeysOmittedCount: argKeysOmittedCount,
ArgKeyNamesTruncatedCount: argKeyNamesTruncatedCount,
ArgValuesRedacted: argValuesRedacted,
ArgValuesTruncated: argValuesTruncated,
ArgValueTruncationReasons: argValueTruncationReasons,
Expand Down Expand Up @@ -2897,7 +2951,7 @@ internal static (int Code, string? Type) ExtractErrorCode(JsonNode response)
/// </summary>
internal static (IReadOnlyList<string> Keys, IReadOnlyList<KeyValuePair<string, int>> Lengths, IReadOnlyList<KeyValuePair<string, int>> KeyLengths, JsonNode? ValuesEcho)
SanitizeArgs(JsonNode? args, bool includeValues)
=> SanitizeArgs(args, includeValues, out _, out _, out _, out _, out _, out _);
=> SanitizeArgs(args, includeValues, out _, out _, out _, out _, out _, out _, out _, out _);

private static (IReadOnlyList<string> Keys, IReadOnlyList<KeyValuePair<string, int>> Lengths, IReadOnlyList<KeyValuePair<string, int>> KeyLengths, JsonNode? ValuesEcho)
SanitizeArgs(
Expand All @@ -2908,13 +2962,17 @@ private static (IReadOnlyList<string> Keys, IReadOnlyList<KeyValuePair<string, i
out IReadOnlyList<string> argValueTruncationReasons,
out int? argValuesSerializedBytes,
out bool argKeysTruncated,
out IReadOnlyList<string> argKeyTruncationReasons)
out IReadOnlyList<string> argKeyTruncationReasons,
out int argKeysOmittedCount,
out int argKeyNamesTruncatedCount)
{
argValuesRedacted = false;
argValuesTruncated = false;
argValueTruncationReasons = Array.Empty<string>();
argValuesSerializedBytes = null;
argKeysTruncated = false;
argKeysOmittedCount = 0;
argKeyNamesTruncatedCount = 0;
var argKeyReasons = new List<string>();
argKeyTruncationReasons = argKeyReasons;
if (args is not JsonObject argsObj)
Expand All @@ -2933,18 +2991,20 @@ private static (IReadOnlyList<string> Keys, IReadOnlyList<KeyValuePair<string, i
if (argumentCount >= AuditLogSink.MaxAuditArgumentCount)
{
argKeysTruncated = true;
argKeysOmittedCount = argsObj.Count - argumentCount;
AddUniqueReason(argKeyReasons, "arg_key_count_limit");
break;
}

var keyDisplay = McpBoundedText.ForDisplay(key);
var keyDisplay = McpBoundedText.ForDisplay(key, AuditLogSink.MaxAuditArgumentKeyChars);
var displayKey = MakeUniqueArgumentDisplayKey(key, keyDisplay, usedKeys);
keys.Add(displayKey);
lengths.Add(new KeyValuePair<string, int>(displayKey, AuditLogSink.MeasureArgLength(value)));
if (keyDisplay.Truncated)
{
keyLengths.Add(new KeyValuePair<string, int>(displayKey, keyDisplay.OriginalLength));
argKeysTruncated = true;
argKeyNamesTruncatedCount++;
AddUniqueReason(argKeyReasons, "arg_key_length_limit");
}
if (echoObject is not null && !argValueBudgetExhausted)
Expand Down Expand Up @@ -3021,15 +3081,24 @@ private static string ShortStableHash(string value)
return Convert.ToHexString(bytes.AsSpan(0, 4)).ToLowerInvariant();
}

private static void AddArgKeyMetadata(JsonObject target, IReadOnlyList<KeyValuePair<string, int>> argKeyLengths)
private static void AddArgKeyMetadata(
JsonObject target,
IReadOnlyList<KeyValuePair<string, int>> argKeyLengths,
int argKeysOmittedCount,
int argKeyNamesTruncatedCount)
{
if (argKeyLengths.Count == 0)
return;
var lengths = new JsonObject();
foreach (var pair in argKeyLengths)
lengths[pair.Key] = pair.Value;
target["arg_key_lengths"] = lengths;
target["arg_keys_truncated"] = true;
if (argKeyLengths.Count > 0)
{
var lengths = new JsonObject();
foreach (var pair in argKeyLengths)
lengths[pair.Key] = pair.Value;
target["arg_key_lengths"] = lengths;
target["arg_keys_truncated"] = true;
}
if (argKeysOmittedCount > 0)
target["arg_keys_omitted_count"] = argKeysOmittedCount;
if (argKeyNamesTruncatedCount > 0)
target["arg_key_names_truncated_count"] = argKeyNamesTruncatedCount;
}

private static string? SerializeRequestId(JsonNode? id)
Expand Down Expand Up @@ -3082,6 +3151,12 @@ internal static string BuildResponseWriteErrorLog(string detail) =>
internal static string BuildToolErrorLog(string toolName, string detail) =>
$"[cdidx-mcp] Tool error ({BoundToolNameForDisplay(toolName).Text}): {detail}. Fix the tool arguments, refresh the index if needed, then retry.";

internal static string BuildClientResponseTooLargeLog(string member, int bytesWritten) =>
$"[cdidx-mcp] Client response {member} exceeded the server byte limit ({bytesWritten} > {MaxClientResponseJsonBytes}); rejecting without retaining the payload.";

private static string BuildClientResponseTooLargeMessage(int bytesWritten) =>
$"MCP client response exceeded the server byte limit ({bytesWritten} > {MaxClientResponseJsonBytes}).";

// Stderr log emitted when the rate limiter denies a tool call. Mirrors the JSON-RPC
// `-32000` payload (tool + caller + retry_after_ms) so operators tailing the MCP log
// can correlate spikes with the structured error returned on the wire (#1560).
Expand Down
44 changes: 42 additions & 2 deletions tests/CodeIndex.Tests/AuditLogSinkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ public void SanitizeArgValue_TruncatesNestedObjectKeys_Issue3237()
}

[Fact]
public void SerializeEvent_DropsArgValues_WhenRecordExceedsEventBudget_Issue3237()
public void SerializeEvent_DropsArgValues_WhenRecordExceedsEventBudget_Issue3237_Issue3107()
{
var evt = new AuditLogSink.AuditEvent(
Timestamp: DateTimeOffset.UtcNow,
Expand All @@ -250,13 +250,17 @@ public void SerializeEvent_DropsArgValues_WhenRecordExceedsEventBudget_Issue3237
Assert.True(Encoding.UTF8.GetByteCount(json) <= AuditLogSink.MaxSerializedEventBytes);
using var doc = JsonDocument.Parse(json);
Assert.False(doc.RootElement.TryGetProperty("arg_values", out _));
Assert.True(doc.RootElement.GetProperty("event_truncated").GetBoolean());
Assert.Equal(AuditLogSink.MaxSerializedEventBytes, doc.RootElement.GetProperty("event_max_bytes").GetInt32());
Assert.Contains(doc.RootElement.GetProperty("event_truncation_reasons").EnumerateArray(),
reason => reason.GetString() == "event_size_limit");
Assert.True(doc.RootElement.GetProperty("arg_values_truncated").GetBoolean());
Assert.Contains(doc.RootElement.GetProperty("arg_values_truncation_reasons").EnumerateArray(),
reason => reason.GetString() == "event_size_limit");
}

[Fact]
public void SerializeEvent_DropsArgKeyMetadata_WhenRecordExceedsEventBudget_Issue3237()
public void SerializeEvent_DropsArgKeyMetadata_WhenRecordExceedsEventBudget_Issue3237_Issue3107()
{
var keys = new List<string>();
var lengths = new List<KeyValuePair<string, int>>();
Expand Down Expand Up @@ -292,11 +296,47 @@ public void SerializeEvent_DropsArgKeyMetadata_WhenRecordExceedsEventBudget_Issu
Assert.Empty(root.GetProperty("arg_keys").EnumerateArray());
Assert.Empty(root.GetProperty("arg_lengths").EnumerateObject());
Assert.False(root.TryGetProperty("arg_key_lengths", out _));
Assert.True(root.GetProperty("event_truncated").GetBoolean());
Assert.Equal(AuditLogSink.MaxSerializedEventBytes, root.GetProperty("event_max_bytes").GetInt32());
Assert.True(root.GetProperty("arg_keys_truncated").GetBoolean());
Assert.Contains(root.GetProperty("arg_key_truncation_reasons").EnumerateArray(),
reason => reason.GetString() == "event_size_limit");
}

[Fact]
public void SerializeEvent_BoundsScalarFieldsBeforeBudgetedSerialization_Issue3107()
{
var huge = new string('z', AuditLogSink.MaxSerializedEventBytes + 1000);
var evt = new AuditLogSink.AuditEvent(
Timestamp: DateTimeOffset.UtcNow,
Tool: huge,
CallerName: huge,
CallerVersion: huge,
RequestId: huge,
ArgKeys: new[] { huge },
ArgLengths: new[] { new KeyValuePair<string, int>(huge, huge.Length) },
ArgValues: null,
ResultCount: 0,
ElapsedMs: 1.0,
ErrorCode: 0,
ErrorType: huge,
ArgKeyLengths: new[] { new KeyValuePair<string, int>(huge, huge.Length) });

var json = AuditLogSink.SerializeEvent(evt, includeValues: false);

Assert.True(Encoding.UTF8.GetByteCount(json) <= AuditLogSink.MaxSerializedEventBytes);
Assert.DoesNotContain(huge, json, StringComparison.Ordinal);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.True(root.GetProperty("tool_truncated").GetBoolean());
Assert.True(root.GetProperty("caller_truncated").GetBoolean());
Assert.True(root.GetProperty("caller_version_truncated").GetBoolean());
Assert.True(root.GetProperty("request_id_truncated").GetBoolean());
Assert.True(root.GetProperty("event_truncated").GetBoolean());
Assert.Contains(root.GetProperty("event_truncation_reasons").EnumerateArray(),
reason => reason.GetString() == "event_size_limit");
}

[Fact]
public void MeasureArgLength_ReportsTypeSpecificCounts()
{
Expand Down
10 changes: 6 additions & 4 deletions tests/CodeIndex.Tests/McpAuditLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,12 @@ public void ToolsCall_UnknownTool_TruncatesAuditToolName_Issue3118()
}

[Fact]
public void ToolsCall_IncludeValues_TruncatesArgumentKeysInAuditValues_Issue3117()
public void ToolsCall_IncludeValues_TruncatesArgumentKeysInAuditValues_Issue3117_Issue3105()
{
using var sink = new AuditLogSink(_auditPath, AuditLogSink.DefaultMaxBytes, includeValues: true);
using var server = CreateServer(sink);
var argumentName = new string('k', McpBoundedText.MaxDiagnosticDisplayChars + 25);
var display = McpBoundedText.ForDisplay(argumentName);
var argumentName = new string('k', AuditLogSink.MaxAuditArgumentKeyChars + 25);
var display = McpBoundedText.ForDisplay(argumentName, AuditLogSink.MaxAuditArgumentKeyChars);
var request = new JsonObject
{
["jsonrpc"] = "2.0",
Expand All @@ -294,6 +294,7 @@ public void ToolsCall_IncludeValues_TruncatesArgumentKeysInAuditValues_Issue3117
Assert.Equal(display.Text, record.GetProperty("arg_keys")[1].GetString());
Assert.Equal(argumentName.Length, record.GetProperty("arg_key_lengths").GetProperty(display.Text).GetInt32());
Assert.True(record.GetProperty("arg_keys_truncated").GetBoolean());
Assert.Equal(1, record.GetProperty("arg_key_names_truncated_count").GetInt32());
Assert.True(record.GetProperty("arg_values").TryGetProperty(display.Text, out _));
}

Expand Down Expand Up @@ -432,7 +433,7 @@ public void ToolsCall_IncludeValues_ChargesTopLevelArgumentKeysToValueBudget_Iss
}

[Fact]
public void ToolsCall_CapsAuditArgumentKeyCount_Issue3237()
public void ToolsCall_CapsAuditArgumentKeyCount_Issue3237_Issue3105()
{
using var sink = new AuditLogSink(_auditPath, AuditLogSink.DefaultMaxBytes, includeValues: true);
using var server = CreateServer(sink);
Expand All @@ -458,6 +459,7 @@ public void ToolsCall_CapsAuditArgumentKeyCount_Issue3237()
Assert.True(record.GetProperty("arg_keys_truncated").GetBoolean());
Assert.Contains(record.GetProperty("arg_key_truncation_reasons").EnumerateArray(),
reason => reason.GetString() == "arg_key_count_limit");
Assert.Equal(3, record.GetProperty("arg_keys_omitted_count").GetInt32());
Assert.False(record.GetProperty("arg_values").TryGetProperty(
$"arg{AuditLogSink.MaxAuditArgumentCount.ToString(CultureInfo.InvariantCulture)}", out _));
}
Expand Down
Loading
Loading