Skip to content
19 changes: 19 additions & 0 deletions changelog.d/unreleased/3759.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: security
issues:
- 3759
affected:
- src/CodeIndex/Indexer/IsolatedWorkerProcessLauncher.cs
- src/CodeIndex/WorkerProtocolJsonValidator.cs
- src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs
- src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
---

## English

- **Isolated workers now scrub inherited environments and validate protocol payload shape (#3759)** — symbol extraction and hook callback workers now start with a small environment allowlist, keep `CDIDX_TEST_*` only for test harnesses, and reject oversized JSON property/string payloads before deserialization.

## 日本語

- **isolated worker が継承環境を scrub し、protocol payload 形状を検証するようになりました (#3759)** — symbol extraction worker と hook callback worker は小さな環境 allowlist で起動し、test harness 用の `CDIDX_TEST_*` だけを保持し、deserialize 前に過大な JSON property/string payload を拒否します。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/3790.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: fixed
issues:
- 3790
affected:
- src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs
- src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs
- src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs
- tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs
- tests/CodeIndex.Tests/PostExtractionHookTests.cs
---

## English

- **Plugin and hook assembly inspection now has type-count and lifetime bounds (#3790)** — plugin duplicate path checks now follow the probed filesystem casing policy, plugin and hook assemblies with excessive type counts are skipped with bounded diagnostics, and hook assemblies that retain no hook types unload their collectible load context.

## 日本語

- **plugin/hook assembly inspection に type-count と lifetime の上限を追加しました (#3790)** — plugin duplicate path 判定は probe 済み filesystem casing policy に従い、type count が過大な plugin/hook assembly は bounded diagnostics 付きで skipped になり、hook type を保持しない hook assembly は collectible load context を unload します。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/3821.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: fixed
issues:
- 3821
affected:
- src/CodeIndex/Indexer/Extensibility/ConfiguredSymbolExtractor.cs
- src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.Diagnostics.cs
- src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PatternConfig.cs
- src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.PluginLoading.cs
- tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs
---

## English

- **Pattern extractor timeouts now appear in structured registry diagnostics (#3821)** — pattern regex timeouts are reported through extractor registry status as bounded diagnostics, pattern kind rejection messages use the same sanitizer path as invalid regex diagnostics, and plugin loading no longer materializes the candidate path list before loading.

## 日本語

- **pattern extractor timeout が構造化された registry diagnostics に表示されるようになりました (#3821)** — pattern regex timeout は extractor registry status の bounded diagnostics として報告され、pattern kind の rejection message は invalid regex diagnostics と同じ sanitizer 経路を使い、plugin loading は load 前に candidate path list を materialize しないようになりました。
21 changes: 21 additions & 0 deletions changelog.d/unreleased/3836.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
category: fixed
issues:
- 3836
affected:
- src/CodeIndex/BoundedLineReader.cs
- src/CodeIndex/BoundedTextWriter.cs
- src/CodeIndex/WorkerOutputBuffer.cs
- src/CodeIndex/SafeDiagnosticFormatter.cs
- src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs
- src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs
- tests/CodeIndex.Tests/BoundedLineReaderTests.cs
---

## English

- **Worker protocol reads and diagnostics are more tightly bounded (#3836)** — async protocol line reads now buffer safely without dropping following lines, isolated worker stderr tails are capped and sanitized in exit diagnostics, and hook callback workers bound captured console output while accepting caller cancellation for response reads.

## 日本語

- **worker protocol の読み取りと診断の上限を強化しました (#3836)** — async protocol line read は後続行を落とさず安全にバッファし、isolated worker の stderr tail は exit 診断内で上限付き・sanitize 済みにし、hook callback worker は captured console output を bounded にして response read で caller cancellation を受け付けるようになりました。
52 changes: 49 additions & 3 deletions src/CodeIndex/BoundedLineReader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Runtime.CompilerServices;

namespace CodeIndex;

Expand Down Expand Up @@ -48,6 +49,9 @@ internal static int ResolveForSourceFileBytes(long? maxFileSizeBytes)

internal static class BoundedLineReader
{
private const int AsyncReadBufferSize = 4096;
private static readonly ConditionalWeakTable<TextReader, AsyncReadBuffer> AsyncBuffers = new();

internal static string? ReadLine(TextReader reader, int maxCharacters, int maxUtf8Bytes)
{
ArgumentNullException.ThrowIfNull(reader);
Expand All @@ -72,16 +76,58 @@ internal static class BoundedLineReader
{
ArgumentNullException.ThrowIfNull(reader);
var state = new LineState(maxCharacters, maxUtf8Bytes);
var buffer = new char[1];
var asyncBuffer = AsyncBuffers.GetValue(reader, _ => new AsyncReadBuffer(AsyncReadBufferSize));

while (true)
{
var read = await reader.ReadAsync(buffer.AsMemory(0, 1), cancellationToken).ConfigureAwait(false);
while (asyncBuffer.TryReadBufferedChar(out var bufferedChar))
{
if (state.Process(bufferedChar, out var bufferedLine))
return bufferedLine;
}

var buffer = asyncBuffer.Buffer;
var read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false);
if (read == 0)
return state.HasAnyInput ? state.CompleteLine() : null;

if (state.Process(buffer[0], out var line))
for (var index = 0; index < read; index++)
{
if (!state.Process(buffer[index], out var line))
continue;

asyncBuffer.StoreRemainder(index + 1, read);
return line;
}
}
}

private sealed class AsyncReadBuffer(int size)
{
private int _start;
private int _length;

internal char[] Buffer { get; } = new char[size];

internal bool TryReadBufferedChar(out char ch)
{
if (_length == 0)
{
ch = default;
return false;
}

ch = Buffer[_start++];
_length--;
if (_length == 0)
_start = 0;
return true;
}

internal void StoreRemainder(int start, int read)
{
_start = start;
_length = Math.Max(0, read - start);
}
}

Expand Down
63 changes: 63 additions & 0 deletions src/CodeIndex/BoundedTextWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Text;

namespace CodeIndex;

internal sealed class BoundedTextWriter(int maxChars) : TextWriter
{
private readonly StringBuilder builder = new();
private bool truncated;

public override Encoding Encoding => Encoding.UTF8;

public override void Write(char value)
{
if (builder.Length < maxChars)
{
builder.Append(value);
return;
}

truncated = true;
}

public override void Write(string? value)
{
if (string.IsNullOrEmpty(value))
return;

Append(value.AsSpan());
}

public override void Write(char[] buffer, int index, int count)
=> Append(buffer.AsSpan(index, count));

internal string GetCapturedText()
{
if (!truncated)
return builder.ToString();

return builder
.AppendLine()
.Append("[cdidx] captured worker console output truncated.")
.ToString();
}

private void Append(ReadOnlySpan<char> value)
{
var remaining = maxChars - builder.Length;
if (remaining <= 0)
{
truncated = true;
return;
}

if (value.Length <= remaining)
{
builder.Append(value);
return;
}

builder.Append(value[..remaining]);
truncated = true;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using CodeIndex.Cli;
using CodeIndex.Diagnostics;
using CodeIndex.Models;

namespace CodeIndex.Indexer.Extensibility;
Expand All @@ -13,7 +14,7 @@ internal sealed class ConfiguredSymbolExtractor(
private readonly HashSet<PatternRule> disabledTimeoutPatterns = [];
private readonly HashSet<string> timeoutWarnings = new(StringComparer.Ordinal);

internal sealed record PatternRule(string Kind, Regex Regex);
internal sealed record PatternRule(string Kind, Regex Regex, string SourcePath = "");

public string Language { get; } = language;

Expand Down Expand Up @@ -84,7 +85,8 @@ private void DisablePatternAfterTimeout(PatternRule pattern)
if (!shouldReport)
return;

ExtractorPluginRegistry.ReportPatternExtractorTimeout(pattern.SourcePath, Language, pattern.Kind);
CommandErrorWriter.WriteStderr(
$"[cdidx] Pattern extractor for language '{Language}' kind '{pattern.Kind}' timed out after {(int)ExtractorPluginRegistry.PatternRegexTimeout.TotalMilliseconds}ms; skipped this pattern.");
$"[cdidx] Pattern extractor for language '{DiagnosticSanitizer.ForMessage(Language)}' kind '{DiagnosticSanitizer.ForMessage(pattern.Kind)}' timed out after {(int)ExtractorPluginRegistry.PatternRegexTimeout.TotalMilliseconds}ms; skipped this pattern.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ private static void ReportPluginDirectorySkipped(string path, string reason, str
category: category);
}

internal static void ReportPatternExtractorTimeout(string path, string language, string kind)
{
RecordDiagnostic(
"pattern",
path,
typeName: null,
severity: "warning",
$"Pattern extractor timeout: language '{DiagnosticSanitizer.ForMessage(language)}' kind '{DiagnosticSanitizer.ForMessage(kind)}'.",
countsAsSkippedFile: false,
category: "pattern_regex_timeout");
}

private static void RecordDiagnostic(
string kind,
string path,
Expand All @@ -87,14 +99,22 @@ private static void RecordDiagnostic(
diagnosticTotalCount++;
if (countsAsSkippedFile)
skippedFileCount++;
var diagnostic = new ExtractorRegistryDiagnostic(
DiagnosticSanitizer.ForMessage(kind),
DiagnosticSanitizer.ForPath(path),
DiagnosticSanitizer.ForOptionalLabel(typeName),
DiagnosticSanitizer.ForMessage(severity),
DiagnosticSanitizer.ForMessage(category),
DiagnosticSanitizer.ForMessage(message));
if (Diagnostics.Count < DiagnosticLimit)
Diagnostics.Add(new ExtractorRegistryDiagnostic(
DiagnosticSanitizer.ForMessage(kind),
DiagnosticSanitizer.ForPath(path),
DiagnosticSanitizer.ForOptionalLabel(typeName),
DiagnosticSanitizer.ForMessage(severity),
DiagnosticSanitizer.ForMessage(category),
DiagnosticSanitizer.ForMessage(message)));
{
Diagnostics.Add(diagnostic);
}
else if (category.EndsWith("_candidate_limit_exceeded", StringComparison.Ordinal)
&& !Diagnostics.Any(item => item.Category == diagnostic.Category && item.Path == diagnostic.Path))
{
Diagnostics[^1] = diagnostic;
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ private static void TryLoadPatternConfig(string path)

if (value.Length > MaxPatternRegexLength)
{
ReportPatternConfigRejected(path, $"regex for kind '{pendingKind}' is too long ({value.Length} characters; maximum {MaxPatternRegexLength})");
ReportPatternConfigRejected(path, $"regex for kind '{DiagnosticSanitizer.ForMessage(pendingKind)}' is too long ({value.Length} characters; maximum {MaxPatternRegexLength})");
return;
}

Expand All @@ -112,7 +112,8 @@ private static void TryLoadPatternConfig(string path)

patterns.Add(new ConfiguredSymbolExtractor.PatternRule(
pendingKind,
regex));
regex,
path));
pendingKind = null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ public static partial class ExtractorPluginRegistry
{
private static void LoadPluginAssemblies(IEnumerable<string> directories)
{
var pluginPaths = EnumeratePluginAssemblyPaths(directories).ToArray();
foreach (var pluginPath in pluginPaths)
foreach (var pluginPath in EnumeratePluginAssemblyPaths(directories))
TryLoadPlugin(pluginPath);
}

Expand All @@ -20,7 +19,7 @@ private static void TryLoadPlugin(string pluginPath)
fullPath = Path.GetFullPath(pluginPath);
lock (Gate)
{
if (!LoadedPluginAssemblyPaths.Add(fullPath))
if (!TryMarkPluginAssemblyPathLoaded(fullPath))
return;
}

Expand Down Expand Up @@ -78,6 +77,9 @@ private static void TryLoadPlugin(string pluginPath)
return;
}

if (!PluginAssemblyTypesAreWithinBudget(fullPath, types))
return;

lock (Gate)
{
pluginAssemblyCount++;
Expand Down Expand Up @@ -182,6 +184,23 @@ private static bool PluginAssemblyCandidateIsWithinBudget(string fullPath)
return true;
}

private static bool PluginAssemblyTypesAreWithinBudget(string fullPath, IReadOnlyCollection<Type> types)
{
var limit = ResolveTypeInspectionLimit();
if (types.Count <= limit)
return true;

RecordDiagnostic(
"plugin",
fullPath,
typeName: null,
severity: "skipped",
$"Plugin assembly skipped: too many loadable types ({types.Count}; maximum {limit}).",
countsAsSkippedFile: true,
category: "plugin_type_limit_exceeded");
return false;
}

private static void TryRegisterPluginType(Type type, string pluginPath)
{
try
Expand Down
Loading
Loading