From f7c43c37891b1fbd46e56018b5cbbfbb62d7e3de Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:56:11 +0900 Subject: [PATCH 1/7] Sanitize plugin and hook diagnostics --- changelog.d/unreleased/3240.security.md | 19 ++++++ .../Diagnostics/DiagnosticSanitizer.cs | 66 +++++++++++++++++++ .../Extensibility/ExtractorPluginRegistry.cs | 22 ++++--- .../Indexer/Hooks/PostExtractionHooks.cs | 66 ++++++++++++------- .../ExtractorPluginRegistryTests.cs | 2 + .../PostExtractionHookTests.cs | 11 +++- 6 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 changelog.d/unreleased/3240.security.md create mode 100644 src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs diff --git a/changelog.d/unreleased/3240.security.md b/changelog.d/unreleased/3240.security.md new file mode 100644 index 0000000000..f3b034c8d5 --- /dev/null +++ b/changelog.d/unreleased/3240.security.md @@ -0,0 +1,19 @@ +--- +category: security +issues: + - 3240 +affected: + - src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs + - tests/CodeIndex.Tests/PostExtractionHookTests.cs +--- + +## English + +- **Plugin and hook diagnostics now sanitize paths and exception text (#3240)** — diagnostics exposed through status or index warnings use bounded, redacted paths and generic failure messages instead of raw local paths or exception messages. + +## 日本語 + +- **plugin / hook diagnostics が path と exception text を sanitize するようになりました (#3240)** — status や index warning に出る diagnostics は raw local path や exception message ではなく、上限付きで redaction された path と generic failure message を使います。 diff --git a/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs b/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs new file mode 100644 index 0000000000..f8b1d09f60 --- /dev/null +++ b/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; + +namespace CodeIndex.Diagnostics; + +internal static class DiagnosticSanitizer +{ + private const int MaxDiagnosticFieldLength = 240; + private static readonly Regex AbsolutePathPattern = new( + @"(?:[A-Za-z]:)?[/\\][^\s'"";:,)]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(50)); + + public static string ForPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + var normalized = NormalizeSeparators(TryGetFullPath(path.Trim())); + var cdidxIndex = normalized.IndexOf("/.cdidx/", StringComparison.OrdinalIgnoreCase); + if (cdidxIndex >= 0) + return Truncate(normalized[(cdidxIndex + 1)..]); + + var configIndex = normalized.IndexOf("/.config/cdidx/", StringComparison.OrdinalIgnoreCase); + if (configIndex >= 0) + return Truncate("/" + normalized[(configIndex + "/.config/cdidx/".Length)..]); + + var fileName = Path.GetFileName(normalized); + return Truncate(string.IsNullOrWhiteSpace(fileName) ? "" : fileName); + } + + public static string? ForOptionalLabel(string? value) + => string.IsNullOrWhiteSpace(value) ? value : ForMessage(value); + + public static string ForMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + return string.Empty; + + var singleLine = message + .Replace('\r', ' ') + .Replace('\n', ' ') + .Replace('\t', ' '); + var withoutPaths = AbsolutePathPattern.Replace(singleLine, ""); + return Truncate(Regex.Replace(withoutPaths, @"\s{2,}", " ", RegexOptions.None, TimeSpan.FromMilliseconds(50)).Trim()); + } + + private static string TryGetFullPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException) + { + return path; + } + } + + private static string NormalizeSeparators(string value) + => value.Replace('\\', '/'); + + private static string Truncate(string value) + => value.Length <= MaxDiagnosticFieldLength + ? value + : value[..MaxDiagnosticFieldLength] + "..."; +} diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 23eefe7467..d151459c39 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using CodeIndex.Diagnostics; using Microsoft.Win32.SafeHandles; namespace CodeIndex.Indexer.Extensibility; @@ -267,7 +268,7 @@ private static IEnumerable EnumeratePluginAssemblyPaths(IEnumerable } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - ReportPluginDirectorySkipped(directory, ex.Message); + ReportPluginDirectorySkipped(directory, "could not enumerate plugin directory"); return false; } } @@ -855,14 +856,14 @@ private static void TryLoadPlugin(string pluginPath) TryRegisterPluginType(type, fullPath); } } - catch (Exception ex) + catch (Exception) { RecordDiagnostic( "plugin", fullPath, typeName: null, severity: "error", - $"Failed to load plugin assembly: {ex.Message}", + "Failed to load plugin assembly.", countsAsSkippedFile: true); } } @@ -881,7 +882,7 @@ private static bool PluginAssemblyCandidateIsWithinBudget(string fullPath) fullPath, typeName: null, severity: "error", - $"Plugin assembly skipped: could not inspect file ({ex.Message}).", + "Plugin assembly skipped: could not inspect file.", countsAsSkippedFile: true); return false; } @@ -941,14 +942,14 @@ private static void TryRegisterPluginType(Type type, string pluginPath) Register(referenceExtractor); } } - catch (Exception ex) + catch (Exception) { RecordDiagnostic( "plugin_type", pluginPath, type.FullName, severity: "error", - $"Failed to instantiate plugin type: {ex.Message}", + "Failed to instantiate plugin type.", countsAsSkippedFile: false); } } @@ -967,7 +968,12 @@ private static void RecordDiagnostic( if (countsAsSkippedFile) skippedFileCount++; if (Diagnostics.Count < DiagnosticLimit) - Diagnostics.Add(new ExtractorRegistryDiagnostic(kind, path, typeName, severity, message)); + Diagnostics.Add(new ExtractorRegistryDiagnostic( + DiagnosticSanitizer.ForMessage(kind), + DiagnosticSanitizer.ForPath(path), + DiagnosticSanitizer.ForOptionalLabel(typeName), + DiagnosticSanitizer.ForMessage(severity), + DiagnosticSanitizer.ForMessage(message))); } } diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs index 632d0f3c22..35639fb57b 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Reflection; using System.Runtime.Loader; +using CodeIndex.Diagnostics; using CodeIndex.Models; namespace CodeIndex.Indexer.Hooks; @@ -71,9 +72,9 @@ public static PostExtractionHookRunner Discover(string? hooksDirectory) var loadContext = new AssemblyLoadContext($"cdidx-hook:{Path.GetFileNameWithoutExtension(dllPath)}", isCollectible: true); assembly = loadContext.LoadFromAssemblyPath(Path.GetFullPath(dllPath)); } - catch (Exception ex) + catch (Exception) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic(dllPath, null, $"Failed to load hook assembly: {ex.Message}")); + runner.EnqueueDiagnostic(dllPath, null, "Failed to load hook assembly."); continue; } @@ -82,9 +83,9 @@ public static PostExtractionHookRunner Discover(string? hooksDirectory) { types = assembly.GetTypes(); } - catch (ReflectionTypeLoadException ex) + catch (ReflectionTypeLoadException) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic(dllPath, null, $"Failed to inspect hook assembly: {ex.Message}")); + runner.EnqueueDiagnostic(dllPath, null, "Failed to inspect hook assembly."); continue; } @@ -103,9 +104,9 @@ public static PostExtractionHookRunner Discover(string? hooksDirectory) new PostExtractionHookInfo(type.Name, Path.GetFullPath(dllPath), type.FullName ?? type.Name), AssemblyLoadContext.GetLoadContext(type.Assembly))); } - catch (Exception ex) + catch (Exception) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic(dllPath, type.FullName, $"Failed to instantiate hook: {ex.Message}")); + runner.EnqueueDiagnostic(dllPath, type.FullName, "Failed to instantiate hook."); } } } @@ -127,10 +128,10 @@ private static IReadOnlyList EnumerateHookAssemblyPaths( { if (candidates.Count >= discoveryLimit) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( hooksDirectory, null, - $"Hook discovery skipped remaining assemblies after the {discoveryLimit} DLL candidate limit.")); + $"Hook discovery skipped remaining assemblies after the {discoveryLimit} DLL candidate limit."); break; } @@ -150,10 +151,10 @@ private static IReadOnlyList EnumerateHookAssemblyPaths( } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( hooksDirectory, null, - $"Failed to enumerate hook directory: {ex.Message}")); + "Failed to enumerate hook directory."); return null; } } @@ -170,37 +171,37 @@ private static bool HookAssemblyCandidateIsWithinBudget( } catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( dllPath, null, - $"Hook assembly skipped: could not inspect file ({ex.Message}).")); + "Hook assembly skipped: could not inspect file."); return false; } if (!fileInfo.Exists) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( dllPath, null, - "Hook assembly skipped: file does not exist.")); + "Hook assembly skipped: file does not exist."); return false; } if ((fileInfo.Attributes & FileAttributes.Directory) != 0) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( dllPath, null, - "Hook assembly skipped: path is a directory.")); + "Hook assembly skipped: path is a directory."); return false; } if (fileInfo.Length > maxAssemblyBytes) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( dllPath, null, - $"Hook assembly skipped: file is too large ({fileInfo.Length} bytes; maximum {maxAssemblyBytes}).")); + $"Hook assembly skipped: file is too large ({fileInfo.Length} bytes; maximum {maxAssemblyBytes})."); return false; } @@ -224,10 +225,10 @@ private static bool TryMoveNextHookFile( } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - runner.diagnostics.Enqueue(new PostExtractionHookDiagnostic( + runner.EnqueueDiagnostic( hooksDirectory, null, - $"Failed to enumerate hook directory: {ex.Message}")); + "Failed to enumerate hook directory."); return false; } } @@ -299,29 +300,44 @@ private bool InvokeHookWithBudget(LoadedPostExtractionHook hook, string callback stopwatch.ElapsedMilliseconds, (long)Math.Ceiling(callbackBudget.TotalMilliseconds)); disabledHooks.TryAdd(hook.Info.TypeName, 0); - diagnostics.Enqueue(new PostExtractionHookDiagnostic( + EnqueueDiagnostic( hook.Info.AssemblyPath, hook.Info.TypeName, $"{callback} exceeded the {callbackBudget.TotalMilliseconds:0} ms callback budget; hook disabled for this index run.", callback, - timeoutDurationMs)); + timeoutDurationMs); return false; } stopwatch.Stop(); if (failure != null) { - diagnostics.Enqueue(new PostExtractionHookDiagnostic( + EnqueueDiagnostic( hook.Info.AssemblyPath, hook.Info.TypeName, - $"{callback} failed: {failure.Message}", + $"{callback} failed.", callback, - stopwatch.ElapsedMilliseconds)); + stopwatch.ElapsedMilliseconds); } return true; } + private void EnqueueDiagnostic( + string assemblyPath, + string? typeName, + string message, + string? callback = null, + long? durationMs = null) + { + diagnostics.Enqueue(new PostExtractionHookDiagnostic( + DiagnosticSanitizer.ForPath(assemblyPath), + DiagnosticSanitizer.ForOptionalLabel(typeName), + DiagnosticSanitizer.ForMessage(message), + DiagnosticSanitizer.ForOptionalLabel(callback), + durationMs)); + } + private static TimeSpan ResolveCallbackBudget() { if (CallbackBudgetForTesting != null) diff --git a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs index 8ae6adf87b..4c01137593 100644 --- a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +++ b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs @@ -137,6 +137,8 @@ public void LoadPlugin_SkipsOversizeAssemblyCandidate() var diagnostic = Assert.Single(status.Diagnostics!); Assert.Equal("plugin", diagnostic.Kind); Assert.Equal("skipped", diagnostic.Severity); + Assert.Equal("oversize.dll", diagnostic.Path); + Assert.DoesNotContain(projectRoot, diagnostic.Path, StringComparison.Ordinal); Assert.Contains("too large", diagnostic.Message, StringComparison.Ordinal); Assert.Contains(ExtractorPluginRegistry.MaxPluginAssemblyBytes.ToString(), diagnostic.Message, StringComparison.Ordinal); } diff --git a/tests/CodeIndex.Tests/PostExtractionHookTests.cs b/tests/CodeIndex.Tests/PostExtractionHookTests.cs index 4abef4bc3d..49cfd50962 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookTests.cs @@ -64,7 +64,10 @@ public void CallbackExceptions_AreDiagnosticsAndDoNotBlockOtherHooks() runner.OnSymbolsExtracted(context, symbols); Assert.Contains(symbols, symbol => symbol.Name == "AppDomainTag"); - Assert.Contains(runner.Diagnostics, diagnostic => diagnostic.TypeName == typeof(ThrowingPostExtractionHook).FullName); + var diagnostic = Assert.Single( + runner.Diagnostics, + diagnostic => diagnostic.TypeName == typeof(ThrowingPostExtractionHook).FullName); + Assert.DoesNotContain("boom", diagnostic.Message, StringComparison.Ordinal); } CollectUnloadedHookAssemblies(); } @@ -170,7 +173,8 @@ public void Discover_CapsHookAssemblyCandidates() Assert.Equal(3, runner.Diagnostics.Count); Assert.Contains( runner.Diagnostics, - diagnostic => diagnostic.AssemblyPath == hooksDir + diagnostic => diagnostic.AssemblyPath.EndsWith("hooks", StringComparison.Ordinal) + && !diagnostic.AssemblyPath.Contains(projectRoot, StringComparison.Ordinal) && diagnostic.Message.Contains("candidate limit", StringComparison.Ordinal)); Assert.Equal( 2, @@ -206,7 +210,8 @@ public void Discover_SkipsOversizeHookAssemblyCandidate() Assert.Empty(runner.Hooks); var diagnostic = Assert.Single(runner.Diagnostics); - Assert.Equal(hookPath, diagnostic.AssemblyPath); + Assert.EndsWith("oversize.dll", diagnostic.AssemblyPath, StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, diagnostic.AssemblyPath, StringComparison.Ordinal); Assert.Contains("too large", diagnostic.Message, StringComparison.Ordinal); Assert.Contains("maximum 16", diagnostic.Message, StringComparison.Ordinal); } From 3e45a59275f8d3e586b3a4f3af7abc8fd97b7f2c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:58:57 +0900 Subject: [PATCH 2/7] Use project root for workspace plugin discovery --- .../Extensibility/ExtractorPluginRegistry.cs | 43 +++++++++++++++---- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 1 + .../SymbolExtractorConfiguredPatternTests.cs | 37 ++++++++++------ 3 files changed, 59 insertions(+), 22 deletions(-) diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index d151459c39..5b88e61d46 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -25,6 +25,7 @@ public static class ExtractorPluginRegistry private static readonly object Gate = new(); private static readonly Dictionary SymbolExtractors = new(StringComparer.Ordinal); private static readonly Dictionary ReferenceExtractors = new(StringComparer.Ordinal); + private static readonly HashSet LoadedPluginAssemblyPaths = new(StringComparer.OrdinalIgnoreCase); private static readonly HashSet LoadedPatternConfigPaths = new(StringComparer.OrdinalIgnoreCase); private static readonly List Diagnostics = []; private const int DiagnosticLimit = 20; @@ -126,6 +127,7 @@ internal static void ResetForTests() { SymbolExtractors.Clear(); ReferenceExtractors.Clear(); + LoadedPluginAssemblyPaths.Clear(); LoadedPatternConfigPaths.Clear(); Diagnostics.Clear(); pluginAssemblyCount = 0; @@ -143,6 +145,7 @@ internal static void ReloadForTests() { SymbolExtractors.Clear(); ReferenceExtractors.Clear(); + LoadedPluginAssemblyPaths.Clear(); LoadedPatternConfigPaths.Clear(); Diagnostics.Clear(); pluginAssemblyCount = 0; @@ -157,6 +160,9 @@ internal static void ReloadForTests() internal static IReadOnlyList EnumeratePluginAssemblyPathsForTests() => EnumeratePluginAssemblyPaths().ToArray(); + internal static IReadOnlyList EnumeratePluginAssemblyPathsForTests(string? projectRoot) + => EnumeratePluginAssemblyPaths(EnumeratePluginDirectories(projectRoot)).ToArray(); + internal static IReadOnlyList EnumeratePluginAssemblyPathsForTests(IReadOnlyList directories) => EnumeratePluginAssemblyPaths(directories).ToArray(); @@ -166,12 +172,22 @@ internal static void LoadPluginAssembliesForTests(IReadOnlyList director internal static void LoadPluginForTests(string pluginPath) => TryLoadPlugin(pluginPath); + internal static void LoadPluginsForProjectRoot(string? projectRoot) + { + EnsurePluginsLoaded(); + if (string.IsNullOrWhiteSpace(projectRoot) || !WorkspacePluginsTrusted()) + return; + + LoadPluginAssemblies(EnumerateWorkspacePluginDirectories(Path.GetFullPath(projectRoot))); + } + internal static void LoadPatternConfigsForProjectRoot(string? projectRoot) { EnsurePluginsLoaded(); if (string.IsNullOrWhiteSpace(projectRoot)) return; + LoadPluginsForProjectRoot(projectRoot); foreach (var patternPath in EnumeratePatternConfigPaths(Path.GetFullPath(projectRoot))) TryLoadPatternConfig(patternPath); } @@ -204,10 +220,7 @@ private static void EnsurePluginsLoaded() if (pluginsLoaded) return; - LoadPluginAssemblies(EnumeratePluginDirectories()); - foreach (var patternPath in EnumeratePatternConfigPaths(Environment.CurrentDirectory)) - TryLoadPatternConfig(patternPath); - + LoadPluginAssemblies(EnumeratePluginDirectories(projectRoot: null)); pluginsLoaded = true; } } @@ -220,7 +233,7 @@ private static void LoadPluginAssemblies(IEnumerable directories) } private static IEnumerable EnumeratePluginAssemblyPaths() - => EnumeratePluginAssemblyPaths(EnumeratePluginDirectories()); + => EnumeratePluginAssemblyPaths(EnumeratePluginDirectories(projectRoot: null)); private static IEnumerable EnumeratePluginAssemblyPaths(IEnumerable directories) { @@ -291,16 +304,24 @@ private static bool TryMoveNextPluginFile(string directory, IEnumerator } } - private static IEnumerable EnumeratePluginDirectories() + private static IEnumerable EnumeratePluginDirectories(string? projectRoot) { - if (WorkspacePluginsTrusted()) - yield return Path.Combine(Environment.CurrentDirectory, ".cdidx", "plugins"); + if (WorkspacePluginsTrusted() && !string.IsNullOrWhiteSpace(projectRoot)) + { + foreach (var directory in EnumerateWorkspacePluginDirectories(Path.GetFullPath(projectRoot))) + yield return directory; + } var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (!string.IsNullOrWhiteSpace(home)) yield return Path.Combine(home, ".cdidx", "plugins"); } + private static IEnumerable EnumerateWorkspacePluginDirectories(string projectRoot) + { + yield return Path.Combine(projectRoot, ".cdidx", "plugins"); + } + private static IEnumerable EnumeratePatternConfigPaths(string workspaceRoot, bool includeUserDirectory = true) { foreach (var path in EnumeratePatternConfigPathsFromDirectory( @@ -817,6 +838,12 @@ private static void TryLoadPlugin(string pluginPath) try { fullPath = Path.GetFullPath(pluginPath); + lock (Gate) + { + if (!LoadedPluginAssemblyPaths.Add(fullPath)) + return; + } + if (!PluginAssemblyCandidateIsWithinBudget(fullPath)) return; diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 007f5e71f2..ed2299a625 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -997,6 +997,7 @@ internal FileIndexer( _directoryIgnoreCaseCache = new Dictionary(StringComparer.Ordinal); _maxFileSizeBytes = ResolveMaxFileSizeBytes(maxFileSizeBytes); _symlinkPolicy = symlinkPolicy; + ExtractorPluginRegistry.LoadPluginsForProjectRoot(_projectRoot); var pathComparer = _ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; (_submodulePaths, _submoduleAncestorPaths, _submoduleLoadWarnings) = LoadGitSubmodulePaths(_ignoreRuleRoot, _projectRoot, pathComparer); } diff --git a/tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs b/tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs index a7d63b624a..d7ca2cea55 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs @@ -8,38 +8,47 @@ namespace CodeIndex.Tests; public partial class SymbolExtractorTests { [Fact] - public void EnumeratePluginAssemblyPaths_SkipsWorkspacePluginsUnlessTrusted() + public void EnumeratePluginAssemblyPaths_UsesExplicitProjectRootForWorkspacePlugins() { lock (TestConsoleLock.Gate) { using var env = EnvironmentVariableScope.Capture(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable); - var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx_workspace_plugins_{Guid.NewGuid():N}"); + var projectRoot = Path.Combine(Path.GetTempPath(), $"cdidx_workspace_plugins_project_{Guid.NewGuid():N}"); + var cwdRoot = Path.Combine(Path.GetTempPath(), $"cdidx_workspace_plugins_cwd_{Guid.NewGuid():N}"); var originalDirectory = Environment.CurrentDirectory; try { - var pluginDir = Path.Combine(tempDir, ".cdidx", "plugins"); - Directory.CreateDirectory(pluginDir); - var pluginFileName = $"demo_{Guid.NewGuid():N}.dll"; - var pluginPath = Path.Combine(pluginDir, pluginFileName); - File.WriteAllText(pluginPath, "not a real dll"); - Environment.CurrentDirectory = tempDir; + var projectPluginDir = Path.Combine(projectRoot, ".cdidx", "plugins"); + var cwdPluginDir = Path.Combine(cwdRoot, ".cdidx", "plugins"); + Directory.CreateDirectory(projectPluginDir); + Directory.CreateDirectory(cwdPluginDir); + var projectPluginFileName = $"project_{Guid.NewGuid():N}.dll"; + var cwdPluginFileName = $"cwd_{Guid.NewGuid():N}.dll"; + File.WriteAllText(Path.Combine(projectPluginDir, projectPluginFileName), "not a real dll"); + File.WriteAllText(Path.Combine(cwdPluginDir, cwdPluginFileName), "not a real dll"); + Environment.CurrentDirectory = cwdRoot; env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, null); - var untrustedPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(); + var untrustedPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(projectRoot); - Assert.DoesNotContain(untrustedPaths, path => Path.GetFileName(path) == pluginFileName); + Assert.DoesNotContain(untrustedPaths, path => Path.GetFileName(path) == projectPluginFileName); env.Set(ExtractorPluginRegistry.TrustWorkspacePluginsEnvironmentVariable, "1"); - var trustedPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(); + var trustedPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(projectRoot); + var defaultPaths = ExtractorPluginRegistry.EnumeratePluginAssemblyPathsForTests(); - Assert.Contains(trustedPaths, path => Path.GetFileName(path) == pluginFileName); + Assert.Contains(trustedPaths, path => Path.GetFileName(path) == projectPluginFileName); + Assert.DoesNotContain(trustedPaths, path => Path.GetFileName(path) == cwdPluginFileName); + Assert.DoesNotContain(defaultPaths, path => Path.GetFileName(path) == cwdPluginFileName); } finally { Environment.CurrentDirectory = originalDirectory; - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, recursive: true); + if (Directory.Exists(projectRoot)) + Directory.Delete(projectRoot, recursive: true); + if (Directory.Exists(cwdRoot)) + Directory.Delete(cwdRoot, recursive: true); } } } From eeb6d9e82fd84f44e669b16b2b3f577da4cfe888 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 03:02:13 +0900 Subject: [PATCH 3/7] Use project root for pattern discovery --- changelog.d/unreleased/3143-3190.fixed.md | 19 ++++++++ .../Extensibility/ExtractorPluginRegistry.cs | 9 ++++ .../ExtractorPluginRegistryTests.cs | 44 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 changelog.d/unreleased/3143-3190.fixed.md diff --git a/changelog.d/unreleased/3143-3190.fixed.md b/changelog.d/unreleased/3143-3190.fixed.md new file mode 100644 index 0000000000..a5c431abf6 --- /dev/null +++ b/changelog.d/unreleased/3143-3190.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3143 + - 3190 +affected: + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs + - tests/CodeIndex.Tests/SymbolExtractorConfiguredPatternTests.cs +--- + +## English + +- **Workspace plugin and pattern discovery now uses the active project root (#3143, #3190)** — workspace-local `.cdidx/plugins` and `.cdidx/patterns` discovery no longer falls back to the process current directory when indexing or reporting status for an explicit project root. + +## 日本語 + +- **workspace plugin / pattern discovery が active project root を使うようになりました (#3143, #3190)** — 明示 project root を index または status reporting する際、workspace-local な `.cdidx/plugins` と `.cdidx/patterns` の discovery が process current directory へ fallback しなくなりました。 diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 5b88e61d46..55edc1866d 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -208,6 +208,9 @@ internal static void LoadPatternConfigsForPath(string? path) TryLoadPatternConfig(patternPath); directory = Directory.GetParent(directory)?.FullName ?? string.Empty; } + + foreach (var patternPath in EnumerateUserPatternConfigPaths()) + TryLoadPatternConfig(patternPath); } private static void EnsurePluginsLoaded() @@ -334,6 +337,12 @@ private static IEnumerable EnumeratePatternConfigPaths(string workspaceR if (!includeUserDirectory) yield break; + foreach (var path in EnumerateUserPatternConfigPaths()) + yield return path; + } + + private static IEnumerable EnumerateUserPatternConfigPaths() + { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (!string.IsNullOrWhiteSpace(home)) { diff --git a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs index 4c01137593..1a91aaa87c 100644 --- a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +++ b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs @@ -192,4 +192,48 @@ public void LoadPatternConfigs_BoundsDiagnosticsAndCountsSkippedFiles() } } } + + [Fact] + public void LoadPatternConfigsForProjectRoot_UsesExplicitRootInsteadOfCurrentDirectory() + { + var projectRoot = TestProjectHelper.CreateTempProject("extractor_registry_project_patterns"); + var cwdRoot = TestProjectHelper.CreateTempProject("extractor_registry_cwd_patterns"); + lock (TestConsoleLock.Gate) + { + var originalDirectory = Environment.CurrentDirectory; + try + { + ExtractorPluginRegistry.ReloadForTests(); + WritePatternConfig( + projectRoot, + "project.yaml", + "language: \"projectdsl\"\nextensions:\n - extension: \".projecttoy\"\npatterns:\n - kind: \"class\"\n regex: \"^project (?\\\\w+)\"\n"); + WritePatternConfig( + cwdRoot, + "cwd.yaml", + "language: \"cwddsl\"\nextensions:\n - extension: \".cwdtoy\"\npatterns:\n - kind: \"class\"\n regex: \"^cwd (?\\\\w+)\"\n"); + Environment.CurrentDirectory = cwdRoot; + + ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(projectRoot); + var extensions = ExtractorPluginRegistry.LanguageExtensions; + + Assert.Equal("projectdsl", extensions[".projecttoy"]); + Assert.False(extensions.ContainsKey(".cwdtoy")); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + ExtractorPluginRegistry.ResetForTests(); + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(cwdRoot); + } + } + } + + private static void WritePatternConfig(string projectRoot, string fileName, string content) + { + var path = Path.Combine(projectRoot, ".cdidx", "patterns", fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } } From cb6c3f2b31f990eb79b4d07f1377f913725b3133 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 03:03:28 +0900 Subject: [PATCH 4/7] Report hook status without loading assemblies --- AGENT_GUIDE.md | 4 +-- DEVELOPER_GUIDE.md | 8 ++--- README.md | 8 ++--- changelog.d/unreleased/3142.fixed.md | 18 ++++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 6 ++-- .../Indexer/Hooks/PostExtractionHooks.cs | 29 +++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 6 ++-- .../QueryCommandRunnerFilesTests.cs | 35 +++++++++++++++++++ 8 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/3142.fixed.md diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index d698511c9e..f63e472d9b 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -138,8 +138,8 @@ CI watching must be bounded. Do not loop indefinitely. - `index_writer_version` records the `cdidx` version that last wrote to the DB (stamped into `codeindex_meta` as `cdidx_writer_version` on every full scan, update, and MCP index). `index_newer_than_reader` flips to `true` whenever any persisted numeric contract stamp in `codeindex_meta` (or unknown `PRAGMA user_version` readiness bits) exceeds the current binary's compiled maximum, so an older CLI re-opening a DB written by a newer CLI degrades loudly with an audit trail instead of silently dropping back to text-search fallbacks. `index_newer_than_reader_reason` enumerates the specific newer-than-reader stamps. - `status` also surfaces indexed-HEAD freshness via `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, and `commits_ahead_of_indexed_head`. They are stamped by `cdidx index` on every successful run (full scan AND partial update, distinct from `indexed_head_commit` which is full-scan only) on a best-effort basis (never blocks an otherwise-successful index) and omitted on non-git workspaces, detached HEAD (branch only), or legacy DBs created before this contract. - `status` also surfaces unknown-extension scan coverage via `unknown_extension_file_count`, stamped by successful full-repository index runs (`cdidx index ` and MCP `index_project`) as the number of non-indexed files with non-empty extensions that do not map to a known language. Current scans also stamp `unknown_extension_files` as a bounded path sample, `unknown_extension_files_truncated` when the count exceeds the sample, and `unknown_extension_file_path_limit` as the cap. These fields are omitted on legacy DBs or before a current full scan has stamped them. -- `status` also surfaces extractor plugin and pattern-config runtime diagnostics via `extractors`, including loaded counts, skipped file counts, and a bounded diagnostics list for incompatible or malformed plugin/pattern files. -- `status` also surfaces post-extraction hook callback budgets through `hooks[].callback_budget_ms`. Index runs enforce `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms) on scratch copies, discard timed-out mutations, and disable timed-out hooks for the remainder of the current run. +- `status` also surfaces extractor plugin and pattern-config runtime diagnostics via `extractors`, including loaded counts, skipped file counts, and a bounded diagnostics list for incompatible or malformed plugin/pattern files. Diagnostic paths and messages are sanitized before output. +- `status` also surfaces metadata-only post-extraction hook candidates and callback budgets through `hooks[]` / `hooks[].callback_budget_ms` without loading hook assemblies. Index runs still enforce `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms) on scratch copies, discard timed-out mutations, and disable timed-out hooks for the remainder of the current run. - `status` also surfaces `.cdidx` data-directory permissions via `data_dir_mode` on POSIX filesystems. New `.cdidx` data directories are forced to `0700`; the field is omitted on Windows, URI DBs, or when the directory mode cannot be inspected. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. It is omitted on non-Linux hosts, unconstrained processes, or unreadable proc attributes (#1768). diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9c32f790ca..e20d5bcc0d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -967,8 +967,8 @@ Runtime diagnostic subcontracts: | `hotspot_family_degraded_reason` stable codes | `hotspot_family_support_not_indexed`, `hotspot_family_metadata_stale`, `hotspot_family_disabled_at_index_time`, `partial_family_key_population`, and `hotspot_family_marker_fingerprint_incomplete`. | | `partial_family_key_population` | Some indexed symbols still lack family keys and need a rebuild/restamp. | | `hotspot_family_marker_fingerprint_incomplete` | Marker fingerprint traversal hit safety caps during the last index run; narrow or ignore generated/vendor marker trees before rebuilding. | -| `extractors` | Reports runtime extractor plugin and pattern-config health, including loaded plugin assembly and pattern counts, symbol/reference extractor counts, skipped file counts, and a bounded diagnostic list for incompatible or malformed files. | -| `hooks[]` | Includes `callback_budget_ms`, mirroring the post-extraction callback budget enforced by `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms). Timed-out callback mutations are discarded because hooks run on a scratch copy before their results are applied. | +| `extractors` | Reports runtime extractor plugin and pattern-config health, including loaded plugin assembly and pattern counts, symbol/reference extractor counts, skipped file counts, and a bounded diagnostic list for incompatible or malformed files. Diagnostic paths and messages are sanitized before output. | +| `hooks[]` | Includes metadata-only hook candidates and `callback_budget_ms`, mirroring the post-extraction callback budget enforced by `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms). `status` does not load hook assemblies; index runs still load hooks and discard timed-out callback mutations because hooks run on a scratch copy before their results are applied. | `references` already prefixes each human-readable row with `reference_kind`, and `callers` does the same for its grouped caller rows. When one grouped container mixes kinds (for example `call` and `subscribe` on the same event member), the human-readable label joins the distinct kinds with `+` (for example `call+subscribe`) instead of collapsing to a single preferred label, and the reference-kind column widens dynamically to fit the longest label in the batch so mixed rows do not overrun the neighbouring column. JSON output for `callers` and `callees` keeps the scalar `reference_kind` for back-compat (it reports the preferred summary kind `instantiate` > `subscribe` > `MIN(call)`) and adds a sorted `reference_kinds` array plus a `has_mixed_reference_kinds` bool so consumers can detect mixed containers without trusting a single collapsed label. This lets terminal users distinguish `call` / `instantiate` / `subscribe` / mixed without re-running the command with `--json` and lets AI clients answer mixed-kind questions without chasing a second `--exact` query. @@ -2948,8 +2948,8 @@ runtime diagnostic subcontract: | `hotspot_family_degraded_reason` stable code | `hotspot_family_support_not_indexed`, `hotspot_family_metadata_stale`, `hotspot_family_disabled_at_index_time`, `partial_family_key_population`, `hotspot_family_marker_fingerprint_incomplete`。 | | `partial_family_key_population` | 一部の indexed symbol に family key がまだ無く、rebuild / restamp が必要です。 | | `hotspot_family_marker_fingerprint_incomplete` | 前回 index run で marker fingerprint traversal が safety cap に当たったことを示します。rebuild 前に generated / vendor marker tree を narrow または ignore してください。 | -| `extractors` | runtime extractor plugin と pattern-config の health を報告します。loaded plugin assembly / pattern count、symbol/reference extractor count、skipped file count、incompatible / malformed file 用の bounded diagnostic list を含みます。 | -| `hooks[]` | `callback_budget_ms` を含みます。これは `CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定 5000 ms)が enforce する post-extraction callback budget と対応します。hook は result 適用前の scratch copy 上で動くため、timeout した callback mutation は破棄されます。 | +| `extractors` | runtime extractor plugin と pattern-config の health を報告します。loaded plugin assembly / pattern count、symbol/reference extractor count、skipped file count、incompatible / malformed file 用の bounded diagnostic list を含みます。diagnostic の path と message は出力前に sanitization されます。 | +| `hooks[]` | metadata-only の hook candidate と `callback_budget_ms` を含みます。これは `CDIDX_HOOK_CALLBACK_BUDGET_MS`(既定 5000 ms)が enforce する post-extraction callback budget と対応します。`status` は hook assembly を読み込まず、index run は従来どおり hook を読み込んだうえで scratch copy 上の timeout した callback mutation を破棄します。 | `references` は以前から人間向け出力の各行先頭に `reference_kind` を表示しており、`callers` も grouped caller 行に対して同じタグを出す。1 つの grouped container で kind が混在する場合(例: 同じ event メンバに対する `call` と `subscribe`)は、単一 preferred label へ潰さずに `call+subscribe` のように distinct kind を `+` で連結して表示する。reference-kind 列の幅はバッチ内で最も長いラベルに合わせて動的に広がるため、mixed 行が隣接列を押し出さない。`callers` / `callees` の JSON 出力では、後方互換のため scalar な `reference_kind`(preferred 順 `instantiate` > `subscribe` > `MIN(call)` の要約 kind)を残しつつ、ソート済みの `reference_kinds` 配列と `has_mixed_reference_kinds` bool も追加した。これにより consumer は単一 summary label に騙されずに mixed container を検出できる。端末上でも `call` / `instantiate` / `subscribe` / mixed を `--json` なしで見分けられ、AI クライアントも `--exact` を改めて投げ直さずに mixed-kind の問いに答えられる。 diff --git a/README.md b/README.md index 8fdbe28abc..39a08bc09d 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,9 @@ When any readiness field is degraded, `degraded_root_cause` identifies the prima After a current full-repository scan, `unknown_extension_file_count` reports how many skipped files had unmapped non-empty extensions, while `unknown_extension_files` lists up to `unknown_extension_file_path_limit` paths and `unknown_extension_files_truncated` marks when more paths exist. -`extractors` reports runtime extractor plugin and pattern-config diagnostics, including loaded counts, skipped file counts, and a bounded diagnostics list for load failures. +`extractors` reports runtime extractor plugin and pattern-config diagnostics, including loaded counts, skipped file counts, and a bounded diagnostics list for load failures. Diagnostic paths and messages are sanitized before they are surfaced. -`hooks[]` includes `callback_budget_ms`. `CDIDX_HOOK_CALLBACK_BUDGET_MS` bounds each post-extraction hook callback in milliseconds (default: 5000); callbacks that exceed the budget emit index warnings, drop timed-out mutations, and disable that hook for the current index run. +`hooks[]` includes metadata-only hook candidates and `callback_budget_ms`; `status` does not load hook assemblies. `CDIDX_HOOK_CALLBACK_BUDGET_MS` bounds each post-extraction hook callback in milliseconds (default: 5000); callbacks that exceed the budget emit sanitized index warnings, drop timed-out mutations, and disable that hook for the current index run. For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. @@ -505,9 +505,9 @@ readiness field のいずれかが degraded の場合、`degraded_root_cause` 現行の全体 scan 後、`unknown_extension_file_count` は未知の非空拡張子で skip された件数を返し、`unknown_extension_files` は `unknown_extension_file_path_limit` 件までの path sample、`unknown_extension_files_truncated` は sample より多くの path があることを示します。 -`extractors` は extractor plugin と pattern config の runtime 診断で、読み込み済み件数、skip されたファイル数、読み込み失敗の上限付き diagnostics list を含みます。 +`extractors` は extractor plugin と pattern config の runtime 診断で、読み込み済み件数、skip されたファイル数、読み込み失敗の上限付き diagnostics list を含みます。diagnostic の path と message は表面化前に sanitization されます。 -`hooks[]` は `callback_budget_ms` を含みます。`CDIDX_HOOK_CALLBACK_BUDGET_MS` は post-extraction hook callback ごとの上限ミリ秒を指定します(既定値: 5000)。上限を超えた callback は index warning を出し、timeout した変更を捨て、その index run 中は該当 hook を無効化します。 +`hooks[]` は metadata-only の hook candidate と `callback_budget_ms` を含み、`status` は hook assembly を読み込みません。`CDIDX_HOOK_CALLBACK_BUDGET_MS` は post-extraction hook callback ごとの上限ミリ秒を指定します(既定値: 5000)。上限を超えた callback は sanitized index warning を出し、timeout した変更を捨て、その index run 中は該当 hook を無効化します。 MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。 diff --git a/changelog.d/unreleased/3142.fixed.md b/changelog.d/unreleased/3142.fixed.md new file mode 100644 index 0000000000..e37bc073c5 --- /dev/null +++ b/changelog.d/unreleased/3142.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3142 +affected: + - src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +--- + +## English + +- **`status` now reports hook candidates without loading hook assemblies (#3142)** — CLI and MCP status use metadata-only hook discovery so lightweight status calls do not inspect or instantiate post-extraction hook assemblies. + +## 日本語 + +- **`status` が hook assembly を読み込まずに hook candidate を報告するようになりました (#3142)** — CLI と MCP の status は metadata-only hook discovery を使うため、軽量な status 呼び出しで post-extraction hook assembly を inspect / instantiate しなくなりました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 4fd7dc0883..f0ad016497 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3139,8 +3139,8 @@ public static int RunStatus( status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); - using var postExtractionHookRunner = PostExtractionHookRunner.DiscoverDefault(); - var postExtractionHooks = postExtractionHookRunner.Hooks; + var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); + var postExtractionHooks = postExtractionHookSnapshot.Hooks; if (postExtractionHooks.Count > 0) { status.Hooks = postExtractionHooks @@ -3149,7 +3149,7 @@ public static int RunStatus( Name = hook.Name, AssemblyPath = hook.AssemblyPath, TypeName = hook.TypeName, - CallbackBudgetMs = (long)Math.Round(postExtractionHookRunner.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero), + CallbackBudgetMs = (long)Math.Round(postExtractionHookSnapshot.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero), }) .ToList(); } diff --git a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs index 35639fb57b..30f2180fac 100644 --- a/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs +++ b/src/CodeIndex/Indexer/Hooks/PostExtractionHooks.cs @@ -25,6 +25,11 @@ public sealed record PostExtractionHookDiagnostic( string? Callback = null, long? DurationMs = null); +public sealed record PostExtractionHookDiscoverySnapshot( + IReadOnlyList Hooks, + IReadOnlyList Diagnostics, + TimeSpan CallbackBudget); + public sealed class PostExtractionHookRunner : IDisposable { public const string CallbackBudgetEnvironmentVariable = "CDIDX_HOOK_CALLBACK_BUDGET_MS"; @@ -52,6 +57,30 @@ private PostExtractionHookRunner(List hooks, TimeSpan public static PostExtractionHookRunner DiscoverDefault() => Discover(GetDefaultHooksDirectory()); + public static PostExtractionHookDiscoverySnapshot DiscoverDefaultMetadata() + => DiscoverMetadata(GetDefaultHooksDirectory()); + + public static PostExtractionHookDiscoverySnapshot DiscoverMetadata(string? hooksDirectory) + { + var loaded = new List(); + var runner = new PostExtractionHookRunner(loaded, ResolveCallbackBudget()); + if (string.IsNullOrWhiteSpace(hooksDirectory) || !Directory.Exists(hooksDirectory)) + return new PostExtractionHookDiscoverySnapshot([], runner.Diagnostics, runner.CallbackBudget); + + var hooks = EnumerateHookAssemblyPaths(hooksDirectory, runner, ResolveDiscoveryLimit()) + .Select(dllPath => + { + var fullPath = Path.GetFullPath(dllPath); + return new PostExtractionHookInfo( + Path.GetFileNameWithoutExtension(fullPath), + DiagnosticSanitizer.ForPath(fullPath), + string.Empty); + }) + .ToArray(); + + return new PostExtractionHookDiscoverySnapshot(hooks, runner.Diagnostics, runner.CallbackBudget); + } + public static PostExtractionHookRunner Discover(string? hooksDirectory) { var loaded = new List(); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 828d8c1a87..8791ce0c6f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2075,8 +2075,8 @@ private JsonNode ExecuteStatus(JsonNode? id) status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); - using var postExtractionHookRunner = PostExtractionHookRunner.DiscoverDefault(); - var postExtractionHooks = postExtractionHookRunner.Hooks; + var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); + var postExtractionHooks = postExtractionHookSnapshot.Hooks; if (postExtractionHooks.Count > 0) { status.Hooks = postExtractionHooks @@ -2085,7 +2085,7 @@ private JsonNode ExecuteStatus(JsonNode? id) Name = hook.Name, AssemblyPath = hook.AssemblyPath, TypeName = hook.TypeName, - CallbackBudgetMs = (long)Math.Round(postExtractionHookRunner.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero), + CallbackBudgetMs = (long)Math.Round(postExtractionHookSnapshot.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero), }) .ToList(); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index 369a5dc87d..f436ccb042 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs @@ -143,6 +143,41 @@ public void RunStatus_Json_ReportsHotspotFamilyTrustSignals() } } + [Fact] + public void RunStatus_Json_ReportsHookCandidatesWithoutLoadingAssemblies_3142() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_hook_metadata_3142"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture("CDIDX_HOOKS_DIR"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var hooksDir = Path.Combine(projectRoot, "hooks"); + Directory.CreateDirectory(hooksDir); + var hookPath = Path.Combine(hooksDir, "broken.dll"); + File.WriteAllText(hookPath, "not a real assembly"); + env.Set("CDIDX_HOOKS_DIR", hooksDir); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--db", dbPath, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var hook = Assert.Single(document.RootElement.GetProperty("hooks").EnumerateArray()); + Assert.Equal("broken", hook.GetProperty("name").GetString()); + Assert.EndsWith("broken.dll", hook.GetProperty("assembly_path").GetString(), StringComparison.Ordinal); + Assert.Equal(string.Empty, hook.GetProperty("type_name").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + [Fact] public void RunStatus_Json_ReportsFoldOnlyRemediationHint() { From c4e2d8c861bc9a327ddb4968d33f0cb0c90de402 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 03:04:20 +0900 Subject: [PATCH 5/7] Sanitize pattern config diagnostics --- changelog.d/unreleased/3243.security.md | 17 ++++++++++ .../Extensibility/ExtractorPluginRegistry.cs | 18 +++++------ .../ExtractorPluginRegistryTests.cs | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/3243.security.md diff --git a/changelog.d/unreleased/3243.security.md b/changelog.d/unreleased/3243.security.md new file mode 100644 index 0000000000..2b8b60827e --- /dev/null +++ b/changelog.d/unreleased/3243.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3243 +affected: + - src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs + - src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs + - tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +--- + +## English + +- **Pattern config diagnostics now sanitize paths and rejection reasons (#3243)** — rejected pattern configs report bounded `.cdidx`-relative paths and normalized reasons instead of raw absolute paths or parser details. + +## 日本語 + +- **pattern config diagnostics が path と rejection reason を sanitize するようになりました (#3243)** — reject された pattern config は raw absolute path や parser details ではなく、上限付きの `.cdidx` relative path と normalized reason を報告します。 diff --git a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs index 55edc1866d..12e3121462 100644 --- a/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs +++ b/src/CodeIndex/Indexer/Extensibility/ExtractorPluginRegistry.cs @@ -375,7 +375,7 @@ private static IEnumerable EnumeratePatternFiles(string directory, strin } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - ReportPatternDirectoryRejected(directory, ex.Message); + ReportPatternDirectoryRejected(directory, "could not enumerate pattern directory"); yield break; } @@ -414,7 +414,7 @@ private static bool DirectoryIsSymlinkOrReparsePoint(string directory) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - ReportPatternDirectoryRejected(directory, ex.Message); + ReportPatternDirectoryRejected(directory, "could not inspect pattern directory"); return true; } } @@ -481,9 +481,9 @@ private static void TryLoadPatternConfig(string path) RegexOptions.Compiled | RegexOptions.CultureInvariant, PatternRegexTimeout); } - catch (ArgumentException ex) + catch (ArgumentException) { - ReportPatternConfigRejected(path, $"invalid regex for kind '{pendingKind}': {ex.Message}"); + ReportPatternConfigRejected(path, $"invalid regex for kind '{DiagnosticSanitizer.ForMessage(pendingKind)}'"); return; } @@ -505,15 +505,15 @@ private static void TryLoadPatternConfig(string path) ReportPatternConfigSkipped(path, "missing language or regex patterns"); } } - catch (Exception ex) + catch (Exception) { - ReportPatternConfigRejected(path, ex.Message); + ReportPatternConfigRejected(path, "could not parse pattern config"); } } private static void ReportPatternConfigRejected(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern config '{path}': {reason}."); + Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern", path, @@ -525,7 +525,7 @@ private static void ReportPatternConfigRejected(string path, string reason) private static void ReportPatternConfigSkipped(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern config '{path}': {reason}."); + Console.Error.WriteLine($"[cdidx] Skipped pattern config '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern", path, @@ -537,7 +537,7 @@ private static void ReportPatternConfigSkipped(string path, string reason) private static void ReportPatternDirectoryRejected(string path, string reason) { - Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{path}': {reason}."); + Console.Error.WriteLine($"[cdidx] Skipped pattern directory '{DiagnosticSanitizer.ForPath(path)}': {DiagnosticSanitizer.ForMessage(reason)}."); RecordDiagnostic( "pattern_directory", path, diff --git a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs index 1a91aaa87c..7554baebd4 100644 --- a/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs +++ b/tests/CodeIndex.Tests/ExtractorPluginRegistryTests.cs @@ -183,6 +183,7 @@ public void LoadPatternConfigs_BoundsDiagnosticsAndCountsSkippedFiles() Assert.Equal("pattern", diagnostic.Kind); Assert.Equal("error", diagnostic.Severity); Assert.EndsWith(".yaml", diagnostic.Path); + Assert.DoesNotContain(projectRoot, diagnostic.Path, StringComparison.Ordinal); }); } finally @@ -230,6 +231,36 @@ public void LoadPatternConfigsForProjectRoot_UsesExplicitRootInsteadOfCurrentDir } } + [Fact] + public void LoadPatternConfigs_SanitizesRejectedPathAndReason_3243() + { + var projectRoot = TestProjectHelper.CreateTempProject("extractor_registry_sanitized_pattern"); + lock (TestConsoleLock.Gate) + { + try + { + ExtractorPluginRegistry.ResetForTests(); + WritePatternConfig( + projectRoot, + "broken.yaml", + "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"(?\"\n"); + + ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(projectRoot); + var diagnostic = Assert.Single(ExtractorPluginRegistry.GetStatusSnapshot().Diagnostics!); + + Assert.Equal(".cdidx/patterns/broken.yaml", diagnostic.Path); + Assert.DoesNotContain(projectRoot, diagnostic.Path, StringComparison.Ordinal); + Assert.Contains("invalid regex", diagnostic.Message, StringComparison.Ordinal); + Assert.DoesNotContain("(?", diagnostic.Message, StringComparison.Ordinal); + } + finally + { + ExtractorPluginRegistry.ResetForTests(); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + private static void WritePatternConfig(string projectRoot, string fileName, string content) { var path = Path.Combine(projectRoot, ".cdidx", "patterns", fileName); From c6c1fd58f6518795a210d605df542e27c414bb41 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 03:20:04 +0900 Subject: [PATCH 6/7] Load status plugins before graph languages --- src/CodeIndex/Cli/QueryCommandRunner.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index f0ad016497..ef2710b9b2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3136,8 +3136,8 @@ public static int RunStatus( } // Attach runtime metadata / ランタイムメタデータを付加 status.SymbolKinds = reader.GetSymbolKindCounts(); - status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); + status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); var postExtractionHooks = postExtractionHookSnapshot.Hooks; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 8791ce0c6f..574cbb4eb8 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2072,8 +2072,8 @@ private JsonNode ExecuteStatus(JsonNode? id) var status = reader.GetStatus(); WorkspaceMetadataEnricher.Enrich(status, _dbPath, _dbPathExplicit); status.MacProfile = MacProfileDetector.DetectCurrent(); - status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); + status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages().OrderBy(l => l).ToList(); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(); var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); var postExtractionHooks = postExtractionHookSnapshot.Hooks; From efe5786a976418cc93c32ded182b3baac1e15803 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 03:32:26 +0900 Subject: [PATCH 7/7] Load project patterns before scanning (#3190) --- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 2 +- tests/CodeIndex.Tests/FileIndexerTests.cs | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index ed2299a625..004b67d339 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -997,7 +997,7 @@ internal FileIndexer( _directoryIgnoreCaseCache = new Dictionary(StringComparer.Ordinal); _maxFileSizeBytes = ResolveMaxFileSizeBytes(maxFileSizeBytes); _symlinkPolicy = symlinkPolicy; - ExtractorPluginRegistry.LoadPluginsForProjectRoot(_projectRoot); + ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(_projectRoot); var pathComparer = _ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; (_submodulePaths, _submoduleAncestorPaths, _submoduleLoadWarnings) = LoadGitSubmodulePaths(_ignoreRuleRoot, _projectRoot, pathComparer); } diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 13b3e6f20e..b8c76abe62 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -6,6 +6,7 @@ using CodeIndex.Database; using CodeIndex.Cli; using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -1003,6 +1004,48 @@ public void ScanFiles_IndexesIssue189FilenameAndExtensionCoverage() } } + [Fact] + public void ScanFiles_LoadsProjectRootPatternConfigsBeforeLanguageDetection_3190() + { + var projectRoot = Path.Combine(Path.GetTempPath(), $"cdidx-pattern-scan-project-{Guid.NewGuid():N}"); + var cwdRoot = Path.Combine(Path.GetTempPath(), $"cdidx-pattern-scan-cwd-{Guid.NewGuid():N}"); + lock (TestConsoleLock.Gate) + { + var originalDirectory = Environment.CurrentDirectory; + try + { + ExtractorPluginRegistry.ResetForTests(); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(cwdRoot); + WriteFileIndexerPatternConfig( + projectRoot, + "project.yaml", + "language: \"projectdsl\"\nextensions:\n - extension: \".projecttoy\"\npatterns:\n - kind: \"class\"\n regex: \"^project (?\\\\w+)\"\n"); + WriteFileIndexerPatternConfig( + cwdRoot, + "cwd.yaml", + "language: \"cwddsl\"\nextensions:\n - extension: \".cwdtoy\"\npatterns:\n - kind: \"class\"\n regex: \"^cwd (?\\\\w+)\"\n"); + File.WriteAllText(Path.Combine(projectRoot, "Project.projecttoy"), "project Widget\n"); + File.WriteAllText(Path.Combine(projectRoot, "CwdLeak.cwdtoy"), "cwd Widget\n"); + Environment.CurrentDirectory = cwdRoot; + + var scanned = new FileIndexer(projectRoot).ScanFiles() + .Select(path => Path.GetRelativePath(projectRoot, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.Equal([".cdidx/patterns/project.yaml", "Project.projecttoy"], scanned); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + ExtractorPluginRegistry.ResetForTests(); + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(cwdRoot); + } + } + } + [Fact] public void ScanFiles_IndexesPythonProjectManifests() { @@ -5280,6 +5323,13 @@ public void IsGeneratedCodeFile_HandwrittenFile_ReturnsFalse() Assert.False(FileIndexer.IsGeneratedCodeFile("src/Foo.cs", "// This file is not auto-generated.\nclass Foo { }\n")); } + private static void WriteFileIndexerPatternConfig(string projectRoot, string fileName, string content) + { + var path = Path.Combine(projectRoot, ".cdidx", "patterns", fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + private static int CountFiles(SqliteConnection connection) { using var cmd = connection.CreateCommand();