diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6a60f225fc..ce75087950 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1323,6 +1323,8 @@ Supported schema (top-level keys are snake_case; nested indexing kind keys keep "global_tool_log_dir": "./.cdidx/logs", // → CDIDX_GLOBAL_TOOL_LOG_DIR "stale_after": "2h", // → CDIDX_STALE_AFTER "suggestion_dedup_threshold": 0.85, // → CDIDX_SUGGESTION_DEDUP_THRESHOLD + "suggestion_max_age_days": 365, // → CDIDX_SUGGESTION_MAX_AGE_DAYS + "suggestion_max_count": 5000, // → CDIDX_SUGGESTION_MAX_COUNT "indexing": { "includeKinds": ["class"], // → CDIDX_INDEX_INCLUDE_SYMBOL_KINDS "excludeKinds": ["test_method"] // → CDIDX_INDEX_EXCLUDE_SYMBOL_KINDS @@ -1340,7 +1342,7 @@ Supported schema (top-level keys are snake_case; nested indexing kind keys keep } ``` -JSON5-style line comments (`//`) and trailing commas are accepted so the file stays human-editable. The optional `$schema` key is ignored at runtime; it is honored only so editors that recognize JSON Schema references can offer completion. Setting `disable_persistent_log` to `false` is a no-op (absence already means "logging enabled") — only `true` exports `CDIDX_DISABLE_PERSISTENT_LOG=1`. `stale_after` uses the same compact duration format as `status --check --stale-after`: `30m`, `2h`, or `7d`. `suggestion_dedup_threshold` sets the MCP suggestion fuzzy-deduplication cutoff as a number from `0` to `1`; the built-in default is `0.85`, and `cdidx mcp --suggestion-dedup-threshold <0..1>` overrides it for one MCP session. `indexing.includeKinds` and `indexing.excludeKinds` set the default symbol-kind filter for `cdidx index`; CLI flags `--include-symbol-kind [,]` and `--exclude-symbol-kind [,]` override those env-backed defaults for a single run. +JSON5-style line comments (`//`) and trailing commas are accepted so the file stays human-editable. The optional `$schema` key is ignored at runtime; it is honored only so editors that recognize JSON Schema references can offer completion. Setting `disable_persistent_log` to `false` is a no-op (absence already means "logging enabled") — only `true` exports `CDIDX_DISABLE_PERSISTENT_LOG=1`. `stale_after` uses the same compact duration format as `status --check --stale-after`: `30m`, `2h`, or `7d`. `suggestion_dedup_threshold` sets the MCP suggestion fuzzy-deduplication cutoff as a number from `0` to `1`; the built-in default is `0.85`, and `cdidx mcp --suggestion-dedup-threshold <0..1>` overrides it for one MCP session. `suggestion_max_age_days` and `suggestion_max_count` bound the live `.cdidx/suggestions-*.json` store; pruned records are appended to `.cdidx/suggestions-*.archive.jsonl`. `indexing.includeKinds` and `indexing.excludeKinds` set the default symbol-kind filter for `cdidx index`; CLI flags `--include-symbol-kind [,]` and `--exclude-symbol-kind [,]` override those env-backed defaults for a single run. ## How it works @@ -3302,6 +3304,8 @@ MCP ツールで catch-all まで突き抜けた例外(想定外の SQLite 例 "global_tool_log_dir": "./.cdidx/logs", // → CDIDX_GLOBAL_TOOL_LOG_DIR "stale_after": "2h", // → CDIDX_STALE_AFTER "suggestion_dedup_threshold": 0.85, // → CDIDX_SUGGESTION_DEDUP_THRESHOLD + "suggestion_max_age_days": 365, // → CDIDX_SUGGESTION_MAX_AGE_DAYS + "suggestion_max_count": 5000, // → CDIDX_SUGGESTION_MAX_COUNT "indexing": { "includeKinds": ["class"], // → CDIDX_INDEX_INCLUDE_SYMBOL_KINDS "excludeKinds": ["test_method"] // → CDIDX_INDEX_EXCLUDE_SYMBOL_KINDS @@ -3319,7 +3323,7 @@ MCP ツールで catch-all まで突き抜けた例外(想定外の SQLite 例 } ``` -人手で編集しやすいよう JSON5 形式の行コメント(`//`)と末尾カンマを許容します。任意の `$schema` キーはランタイムでは無視され、JSON Schema 参照をサポートするエディタが補完を提供するためだけに認識されます。`disable_persistent_log` を `false` に設定しても何も起きません(不在のままで "ログ有効" が既定)— `true` の場合のみ `CDIDX_DISABLE_PERSISTENT_LOG=1` を export します。`stale_after` は `status --check --stale-after` と同じ compact duration 形式(`30m` / `2h` / `7d`)です。`suggestion_dedup_threshold` は MCP suggestion の fuzzy deduplication しきい値を `0` から `1` の数値で設定します。組み込み既定値は `0.85` で、`cdidx mcp --suggestion-dedup-threshold <0..1>` は 1 回の MCP session だけこの値を上書きします。`indexing.includeKinds` と `indexing.excludeKinds` は `cdidx index` の symbol-kind filter 既定値を設定し、CLI フラグ `--include-symbol-kind [,]` / `--exclude-symbol-kind [,]` はその env 経由の既定値を 1 回の実行だけ上書きします。 +人手で編集しやすいよう JSON5 形式の行コメント(`//`)と末尾カンマを許容します。任意の `$schema` キーはランタイムでは無視され、JSON Schema 参照をサポートするエディタが補完を提供するためだけに認識されます。`disable_persistent_log` を `false` に設定しても何も起きません(不在のままで "ログ有効" が既定)— `true` の場合のみ `CDIDX_DISABLE_PERSISTENT_LOG=1` を export します。`stale_after` は `status --check --stale-after` と同じ compact duration 形式(`30m` / `2h` / `7d`)です。`suggestion_dedup_threshold` は MCP suggestion の fuzzy deduplication しきい値を `0` から `1` の数値で設定します。組み込み既定値は `0.85` で、`cdidx mcp --suggestion-dedup-threshold <0..1>` は 1 回の MCP session だけこの値を上書きします。`suggestion_max_age_days` と `suggestion_max_count` は live の `.cdidx/suggestions-*.json` store の上限を設定し、prune された record は `.cdidx/suggestions-*.archive.jsonl` に追記されます。`indexing.includeKinds` と `indexing.excludeKinds` は `cdidx index` の symbol-kind filter 既定値を設定し、CLI フラグ `--include-symbol-kind [,]` / `--exclude-symbol-kind [,]` はその env 経由の既定値を 1 回の実行だけ上書きします。 ## 動作の仕組み diff --git a/changelog.d/unreleased/1614.fixed.md b/changelog.d/unreleased/1614.fixed.md new file mode 100644 index 0000000000..5da607de75 --- /dev/null +++ b/changelog.d/unreleased/1614.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1614 +affected: + - src/CodeIndex/Cli/GitHubIssueReporter.cs + - tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +--- + +## English + +- **Sanitized `suggest_improvement` GitHub issue titles (#1614)** — outbound suggestion issue titles now strip markdown link/image syntax characters and defensively cap category text before posting. + +## 日本語 + +- **`suggest_improvement` の GitHub Issue タイトルを sanitize しました (#1614)** — 外部送信用の提案 Issue タイトルから markdown のリンク/画像構文文字を除去し、category 文字列も防御的に上限を設けてから投稿します。 diff --git a/changelog.d/unreleased/1796.fixed.md b/changelog.d/unreleased/1796.fixed.md new file mode 100644 index 0000000000..697ea0d7e8 --- /dev/null +++ b/changelog.d/unreleased/1796.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1796 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Redacted sensitive suggestion text before persistence (#1796)** — `SuggestionStore` now masks common credential and token patterns before writing local suggestion history or submitting to GitHub, with stderr warnings that name only the redaction types. + +## 日本語 + +- **提案テキストを保存前に redaction するようにしました (#1796)** — `SuggestionStore` はローカルの提案履歴保存や GitHub 送信の前に一般的な credential/token パターンをマスクし、stderr には redaction 種別だけを警告します。 diff --git a/changelog.d/unreleased/1921.fixed.md b/changelog.d/unreleased/1921.fixed.md new file mode 100644 index 0000000000..9b5cef8b02 --- /dev/null +++ b/changelog.d/unreleased/1921.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1921 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Validated `suggest_improvement` write targets before locking (#1921)** — the MCP tool now probes the resolved `.cdidx` directory for writability before entering the suggestion-store lock and includes `cdidx_dir` in success and duplicate payloads. + +## 日本語 + +- **`suggest_improvement` の書き込み先を lock 前に検証するようにしました (#1921)** — MCP tool は suggestion-store lock に入る前に解決済み `.cdidx` ディレクトリへ書き込み probe を行い、成功/重複 payload に `cdidx_dir` を含めます。 diff --git a/changelog.d/unreleased/1940.fixed.md b/changelog.d/unreleased/1940.fixed.md new file mode 100644 index 0000000000..b729ed39df --- /dev/null +++ b/changelog.d/unreleased/1940.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1940 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - src/CodeIndex/Cli/CdidxConfigFile.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - USER_GUIDE.md +--- + +## English + +- **Bounded live suggestion history (#1940)** — `SuggestionStore` now prunes stale or over-limit records into `.cdidx/suggestions-*.archive.jsonl`, with configurable `suggestion_max_age_days` and `suggestion_max_count` settings. + +## 日本語 + +- **live の提案履歴に上限を設けました (#1940)** — `SuggestionStore` は古いまたは上限超過の record を `.cdidx/suggestions-*.archive.jsonl` に退避し、`suggestion_max_age_days` と `suggestion_max_count` で設定できます。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index f8a493f88c..7dbc6c7db1 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -41,6 +41,8 @@ internal static class CdidxConfigFile "graph", "folding", "suggestion_dedup_threshold", + "suggestion_max_age_days", + "suggestion_max_count", "mcp", }; @@ -165,6 +167,20 @@ internal static LoadResult LoadAndApply( pending.Add((SuggestionStore.DedupThresholdEnvironmentVariable, value!)); } + if (root.TryGetProperty("suggestion_max_age_days", out var suggestionMaxAgeDays)) + { + if (!TryReadPositiveIntegerAsString(suggestionMaxAgeDays, "suggestion_max_age_days", path, out var value, out var err)) + return new LoadResult(Path: path, Error: err); + pending.Add((SuggestionStore.MaxAgeDaysEnvironmentVariable, value!)); + } + + if (root.TryGetProperty("suggestion_max_count", out var suggestionMaxCount)) + { + if (!TryReadPositiveIntegerAsString(suggestionMaxCount, "suggestion_max_count", path, out var value, out var err)) + return new LoadResult(Path: path, Error: err); + pending.Add((SuggestionStore.MaxCountEnvironmentVariable, value!)); + } + if (root.TryGetProperty("indexing", out var indexing)) { if (indexing.ValueKind != JsonValueKind.Object) @@ -443,6 +459,26 @@ private static bool TryReadNumberAsString(JsonElement element, string key, strin return true; } + private static bool TryReadPositiveIntegerAsString(JsonElement element, string key, string path, out string? value, out string? error) + { + value = null; + error = null; + if (element.ValueKind != JsonValueKind.Number) + { + error = $"[cdidx] {path}: `{key}` must be a number."; + return false; + } + + if (!element.TryGetInt32(out var parsed) || parsed <= 0) + { + error = $"[cdidx] {path}: `{key}` must be a positive integer."; + return false; + } + + value = parsed.ToString(System.Globalization.CultureInfo.InvariantCulture); + return true; + } + private static bool TryReadSearchInteger(JsonElement element, string key, string optionName, bool allowZero, string path, out string? value, out string? error) { value = null; diff --git a/src/CodeIndex/Cli/GitHubIssueReporter.cs b/src/CodeIndex/Cli/GitHubIssueReporter.cs index 21aeeed697..5277ff3f12 100644 --- a/src/CodeIndex/Cli/GitHubIssueReporter.cs +++ b/src/CodeIndex/Cli/GitHubIssueReporter.cs @@ -437,11 +437,15 @@ internal static string ScrubInlineCode(string text) internal static string BuildIssueTitle(string category, string description) { - var prefix = $"[AI Suggestion] {category}: "; + var sanitizedCategory = SanitizeIssueTitleText(category); + if (sanitizedCategory.Length > 40) + sanitizedCategory = sanitizedCategory[..40].TrimEnd(); + + var prefix = $"[AI Suggestion] {sanitizedCategory}: "; if (prefix.Length >= MaxGitHubIssueTitleLength) return prefix[..MaxGitHubIssueTitleLength]; - var scrubbedForTitle = ScrubInlineCode(description).Replace("\r", " ").Replace("\n", " ").Trim(); + var scrubbedForTitle = SanitizeIssueTitleText(ScrubInlineCode(description)); var maxDescriptionLength = MaxGitHubIssueTitleLength - prefix.Length; var shortDesc = TruncateWithEllipsis(scrubbedForTitle, Math.Min(63, maxDescriptionLength)); var title = prefix + shortDesc; @@ -450,6 +454,22 @@ internal static string BuildIssueTitle(string category, string description) : title[..MaxGitHubIssueTitleLength]; } + internal static string SanitizeIssueTitleText(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + var builder = new StringBuilder(value.Length); + foreach (var c in value.Replace("\r", " ").Replace("\n", " ")) + { + if (c is '[' or ']' or '(' or ')' or '`') + continue; + builder.Append(c); + } + + return builder.ToString().Trim(); + } + private static string TruncateWithEllipsis(string value, int maxLength) { if (value.Length <= maxLength) diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 904b8aecc0..6d89abce1a 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using CodeIndex.Indexer; using CodeIndex.Models; @@ -21,11 +22,24 @@ public class SuggestionStore { private readonly string _filePath; private readonly string _lockPath; + private readonly string _archivePath; private static readonly TimeSpan s_inFlightSubmitRetryDelay = TimeSpan.FromMinutes(1); internal const FileShare StreamingReadFileShare = FileShare.ReadWrite | FileShare.Delete; internal const string DedupThresholdEnvironmentVariable = "CDIDX_SUGGESTION_DEDUP_THRESHOLD"; + internal const string MaxAgeDaysEnvironmentVariable = "CDIDX_SUGGESTION_MAX_AGE_DAYS"; + internal const string MaxCountEnvironmentVariable = "CDIDX_SUGGESTION_MAX_COUNT"; internal const double DefaultDedupThreshold = 0.85; + internal const int DefaultMaxAgeDays = 365; + internal const int DefaultMaxCount = 5000; private const int FuzzyDedupRecentLimit = 100; + private const string RedactedAwsAccessKey = "[REDACTED:aws_access_key]"; + private const string RedactedBearerToken = "[REDACTED:bearer_token]"; + private const string RedactedCredential = "[REDACTED:credential]"; + private const string RedactedHighEntropyToken = "[REDACTED:high_entropy_token]"; + private static readonly Regex s_awsAccessKeyRegex = new(@"\bAKIA[0-9A-Z]{16}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex s_bearerTokenRegex = new(@"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex s_namedSecretRegex = new(@"(?i)\b(password|secret)=([^&\s]{1,200})", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex s_highEntropyTokenRegex = new(@"\b(?=[A-Za-z0-9._~+/=-]{32,}\b)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9._~+/=-]+\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly HashSet s_dedupStopWords = new(StringComparer.Ordinal) { @@ -88,6 +102,7 @@ public SuggestionStore(string cdidxDir, string? dbName = null) var safeName = string.IsNullOrWhiteSpace(dbName) ? "codeindex" : dbName; _filePath = Path.Combine(cdidxDir, $"suggestions-{safeName}.json"); _lockPath = Path.Combine(cdidxDir, $"suggestions-{safeName}.lock"); + _archivePath = Path.Combine(cdidxDir, $"suggestions-{safeName}.archive.jsonl"); } /// @@ -117,13 +132,20 @@ public static string ComputeHash(string category, string? language, string descr /// public bool TryAdd(SuggestionRecord record) { + record = RedactRecordForPersistence(record); return WithFileLock(() => { var existing = ReadUnlocked(); + var prunedBeforeDuplicateCheck = PruneUnlocked(existing); if (FindDuplicate(existing, record, ResolveDedupThreshold()).Record != null) + { + if (prunedBeforeDuplicateCheck) + SaveUnlocked(existing); return false; + } existing.Add(record); + PruneUnlocked(existing); SaveUnlocked(existing); return true; }); @@ -185,9 +207,11 @@ public async Task TryAddAndSubmitAsync( SuggestionRecord record, Func>? submitToGitHub) { + record = RedactRecordForPersistence(record); var reservation = WithFileLock(() => { var existing = ReadUnlocked(); + var prunedBeforeDuplicateCheck = PruneUnlocked(existing); var duplicate = FindDuplicate(existing, record, ResolveDedupThreshold()); var found = duplicate.Record; @@ -197,6 +221,7 @@ public async Task TryAddAndSubmitAsync( if (isNew) { existing.Add(record); + PruneUnlocked(existing); SaveUnlocked(existing); found = record; } @@ -219,6 +244,9 @@ public async Task TryAddAndSubmitAsync( isNew ? null : duplicate.Score); } + if (prunedBeforeDuplicateCheck) + SaveUnlocked(existing); + return new SubmitReservation( isNew, alreadySubmitted, @@ -736,6 +764,73 @@ private static bool ShouldAttemptSubmit(SuggestionRecord record) return record.NextRetryAt.Value <= DateTime.UtcNow; } + private bool PruneUnlocked(List records) + { + var maxAge = ResolveMaxAge(); + var maxCount = ResolveMaxCount(); + var cutoff = DateTime.UtcNow.Subtract(maxAge); + var pruned = records + .Where(record => record.CreatedAt != default && record.CreatedAt < cutoff) + .ToList(); + + foreach (var record in pruned) + records.Remove(record); + + var overflow = records.Count - maxCount; + if (overflow > 0) + { + var excess = records + .OrderBy(record => record.CreatedAt == default ? DateTime.MinValue : record.CreatedAt) + .Take(overflow) + .ToList(); + pruned.AddRange(excess); + foreach (var record in excess) + records.Remove(record); + } + + if (pruned.Count == 0) + return false; + + ArchivePrunedRecords(pruned); + try + { + Console.Error.WriteLine($"[cdidx] Pruned {pruned.Count} stale suggestion record(s) to {_archivePath}."); + } + catch (ObjectDisposedException) + { + } + + return true; + } + + private void ArchivePrunedRecords(IEnumerable records) + { + var dir = Path.GetDirectoryName(_archivePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + using var stream = new FileStream(_archivePath, FileMode.Append, FileAccess.Write, FileShare.Read); + using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + foreach (var record in records) + writer.WriteLine(JsonSerializer.Serialize(record, s_jsonOptions)); + } + + internal static TimeSpan ResolveMaxAge() + { + var raw = Environment.GetEnvironmentVariable(MaxAgeDaysEnvironmentVariable); + return int.TryParse(raw, out var days) && days > 0 + ? TimeSpan.FromDays(days) + : TimeSpan.FromDays(DefaultMaxAgeDays); + } + + internal static int ResolveMaxCount() + { + var raw = Environment.GetEnvironmentVariable(MaxCountEnvironmentVariable); + return int.TryParse(raw, out var count) && count > 0 + ? count + : DefaultMaxCount; + } + private static void StampSubmitAttempt(SuggestionRecord record, DateTime timestamp, string? error, DateTime? nextRetryAt) { record.LastSubmitAttempt = timestamp; @@ -780,6 +875,77 @@ private static void StampSubmitResult(SuggestionRecord record, SubmitAttemptResu GitHubIssueUrl = record.GitHubIssueUrl, }; + internal static string RedactSensitiveText(string text, out IReadOnlyCollection redactedTypes) + { + var types = new SortedSet(StringComparer.Ordinal); + var redacted = s_awsAccessKeyRegex.Replace(text, match => + { + types.Add("aws_access_key"); + return RedactedAwsAccessKey; + }); + redacted = s_bearerTokenRegex.Replace(redacted, match => + { + types.Add("bearer_token"); + return RedactedBearerToken; + }); + redacted = s_namedSecretRegex.Replace(redacted, match => + { + types.Add("credential"); + return $"{match.Groups[1].Value}={RedactedCredential}"; + }); + redacted = s_highEntropyTokenRegex.Replace(redacted, match => + { + if (match.Value.StartsWith("[REDACTED:", StringComparison.Ordinal)) + return match.Value; + types.Add("high_entropy_token"); + return RedactedHighEntropyToken; + }); + + redactedTypes = types; + return redacted; + } + + private static SuggestionRecord RedactRecordForPersistence(SuggestionRecord record) + { + var redactedDescription = RedactNullable(record.Description, out var descriptionTypes) ?? string.Empty; + var redactedContext = RedactNullable(record.Context, out var contextTypes); + var redactedToolInvocationContext = RedactNullable(record.ToolInvocationContext, out var toolInvocationTypes); + var allTypes = descriptionTypes.Concat(contextTypes).Concat(toolInvocationTypes).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + + if (allTypes.Length == 0) + return record; + + WriteRedactionWarning(allTypes); + var copy = CloneForSubmit(record); + copy.Description = redactedDescription; + copy.Context = redactedContext; + copy.ToolInvocationContext = redactedToolInvocationContext; + copy.Hash = ComputeHash(copy.Category, copy.Language, copy.Description); + return copy; + } + + private static string? RedactNullable(string? value, out IReadOnlyCollection redactedTypes) + { + if (value == null) + { + redactedTypes = Array.Empty(); + return null; + } + + return RedactSensitiveText(value, out redactedTypes); + } + + private static void WriteRedactionWarning(IReadOnlyCollection redactedTypes) + { + try + { + Console.Error.WriteLine($"[cdidx] Redacted sensitive suggestion text before local persistence/GitHub submission: {string.Join(", ", redactedTypes)}."); + } + catch (ObjectDisposedException) + { + } + } + private sealed record SubmitReservation( bool IsNew, bool AlreadySubmitted, diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 698ea0473b..d45c36361e 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -467,9 +467,11 @@ private JsonNode HandleToolsList(JsonNode? id) "Submit a structured improvement suggestion or error report for cdidx. " + "Call this when you notice a gap (e.g. missing language support, poor ranking) or encounter an unexpected error. " + "Never include source code — describe the gap in natural language only. " + + "The tool writes to the resolved .cdidx directory, which must be writable; responses include cdidx_dir for diagnostics. " + "/ cdidxへの構造化された改善提案またはエラー報告を送信する。" + "ギャップ(言語サポート不足、ランキング不良等)に気づいたとき、または予期せぬエラーに遭遇したときに呼び出す。" - + "ソースコードを含めないこと — 自然言語でのみギャップを記述する。", + + "ソースコードを含めないこと — 自然言語でのみギャップを記述する。" + + "解決された .cdidx ディレクトリへ書き込むため、そのディレクトリは書き込み可能である必要がある。応答には診断用の cdidx_dir が含まれる。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a7f42aa0c0..d4a8191fa5 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3322,9 +3322,14 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo // 5. Resolve .cdidx directory and create if needed // .cdidx ディレクトリを解決し、必要に応じて作成 var cdidxDir = Path.GetDirectoryName(_dbPath); + if (string.IsNullOrEmpty(cdidxDir)) + cdidxDir = Path.GetDirectoryName(Path.GetFullPath(_dbPath)); if (string.IsNullOrEmpty(cdidxDir)) cdidxDir = Path.Combine(Path.GetFullPath("."), ".cdidx"); DataDirectorySecurity.CreatePrivateDirectory(cdidxDir); + cdidxDir = Path.GetFullPath(cdidxDir); + if (!TryProbeCdidxDirectoryWritable(cdidxDir, out var probeError)) + return CreateToolErrorResponse(id, probeError!); // 6. Store locally, reserve a submission attempt under the file lock, // then call GitHub outside the lock so slow remote I/O does not block @@ -3377,6 +3382,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo : "This suggestion has already been recorded.", ["submitted_to_github"] = result.AlreadySubmitted || result.UpstreamUrl != null, ["lifecycle_status"] = JsonNamingPolicy.SnakeCaseLower.ConvertName(result.Status.ToString()), + ["cdidx_dir"] = cdidxDir, }; if (result.DuplicateOfHash != null) dupPayload["duplicate_of"] = result.DuplicateOfHash; @@ -3400,6 +3406,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo ["stored_locally"] = true, ["submitted_to_github"] = result.UpstreamUrl != null, ["lifecycle_status"] = JsonNamingPolicy.SnakeCaseLower.ConvertName(result.Status.ToString()), + ["cdidx_dir"] = cdidxDir, }; if (result.UpstreamUrl != null) { @@ -3409,6 +3416,25 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo return CreateToolResult(id, "Suggestion recorded. Thank you for the feedback.", payload); } + private static bool TryProbeCdidxDirectoryWritable(string cdidxDir, out string? error) + { + var probePath = Path.Combine(cdidxDir, $".write_probe.{Guid.NewGuid():N}.tmp"); + try + { + using (new FileStream(probePath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + } + File.Delete(probePath); + error = null; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + error = $"Cannot write to .cdidx directory {cdidxDir}; check directory ownership, permissions, and read-only mounts. {ex.Message}"; + return false; + } + } + private static CSharpStaticInterfaceWorkspaceSymbols BuildMcpCSharpStaticInterfaceWorkspaceSymbols( DbWriter writer, FileIndexer indexer, diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 031a3d1fa1..04c355772b 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -35,6 +35,8 @@ public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() "global_tool_log_dir": "/tmp/logs", "stale_after": "2h", "suggestion_dedup_threshold": 0.75, + "suggestion_max_age_days": 30, + "suggestion_max_count": 250, "indexing": { "includeKinds": ["class"], "excludeKinds": ["test_method", "generated_parser"] @@ -57,6 +59,8 @@ public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() Assert.Equal("/tmp/logs", env.Writes["CDIDX_GLOBAL_TOOL_LOG_DIR"]); Assert.Equal("2h", env.Writes["CDIDX_STALE_AFTER"]); Assert.Equal("0.75", env.Writes["CDIDX_SUGGESTION_DEDUP_THRESHOLD"]); + Assert.Equal("30", env.Writes["CDIDX_SUGGESTION_MAX_AGE_DAYS"]); + Assert.Equal("250", env.Writes["CDIDX_SUGGESTION_MAX_COUNT"]); Assert.Equal("class", env.Writes["CDIDX_INDEX_INCLUDE_SYMBOL_KINDS"]); Assert.Equal("test_method,generated_parser", env.Writes["CDIDX_INDEX_EXCLUDE_SYMBOL_KINDS"]); Assert.Equal("search,definition", env.Writes["CDIDX_MCP_TOOLS_ALLOW"]); diff --git a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs index fda157a009..3becaf52dc 100644 --- a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +++ b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs @@ -296,6 +296,24 @@ public void BuildIssueTitle_ClampsFinalTitleToGitHubLimit() Assert.True(title.Length <= GitHubIssueReporter.MaxGitHubIssueTitleLength); } + [Fact] + public void BuildIssueTitle_StripsMarkdownSyntaxFromTitleSource() + { + var title = GitHubIssueReporter.BuildIssueTitle( + "other](https://example.com/very-long-category-name-that-should-not-expand-forever)", + " ](https://example.com/x) ![spoofed](https://example.com/y) `secret()` gap"); + + Assert.StartsWith("[AI Suggestion] otherhttps://example.com/very-long-categ: ", title); + var titleSource = title["[AI Suggestion] ".Length..]; + Assert.DoesNotContain("[", titleSource); + Assert.DoesNotContain("]", titleSource); + Assert.DoesNotContain("(", titleSource); + Assert.DoesNotContain(")", titleSource); + Assert.DoesNotContain("`", titleSource); + Assert.DoesNotContain("secret", titleSource); + Assert.DoesNotContain(" :", titleSource); + } + [Fact] public async Task TryCreateIssueAsync_PostPayloadTitleDoesNotExceedGitHubLimit() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 5cd4ca9c5e..9e8321d7be 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8657,6 +8657,7 @@ public void SuggestImprovement_ValidInput_ReturnsSuccess() Assert.Equal("draft", structured["lifecycle_status"]!.GetValue()); Assert.NotNull(structured["hash"]); Assert.True(structured["stored_locally"]!.GetValue()); + Assert.Equal(Path.GetFullPath(Path.GetDirectoryName(_dbPath)!), structured["cdidx_dir"]!.GetValue()); } [Fact] @@ -8737,6 +8738,65 @@ public void SuggestImprovement_DuplicateSubmission_ReturnsDuplicate() var structured = response2["result"]!["structuredContent"]!; Assert.Equal("duplicate", structured["status"]!.GetValue()); Assert.Equal("draft", structured["lifecycle_status"]!.GetValue()); + Assert.Equal(Path.GetFullPath(Path.GetDirectoryName(_dbPath)!), structured["cdidx_dir"]!.GetValue()); + } + + [Fact] + public void SuggestImprovement_UnwritableCdidxDir_ReturnsActionableError() + { + if (OperatingSystem.IsWindows()) + return; + + var dir = TestProjectHelper.CreateTempProject("cdidx_mcp_readonly"); + var originalMode = File.GetUnixFileMode(dir); + try + { + File.SetUnixFileMode(dir, UnixFileMode.UserRead | UnixFileMode.UserExecute); + using var server = new McpServer(Path.Combine(dir, "codeindex.db"), ConsoleUi.LoadVersion()); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"suggest_improvement","arguments":{"category":"other","description":"Permission probe regression"}}}""")!; + + var response = server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var message = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("Cannot write to .cdidx directory", message); + Assert.Contains(Path.GetFullPath(dir), message); + Assert.Contains("check directory ownership, permissions, and read-only mounts", message); + } + finally + { + try { File.SetUnixFileMode(dir, originalMode); } catch { } + TestProjectHelper.DeleteDirectory(dir); + } + } + + [Fact] + public void SuggestImprovement_WriteProbePreservesExistingProbeFile() + { + var cdidxDir = Path.GetDirectoryName(_dbPath)!; + var existingProbe = Path.Combine(cdidxDir, ".write_probe"); + File.WriteAllText(existingProbe, "keep me"); + var uniqueDesc = $"Probe preservation regression {Guid.NewGuid():N}"; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "suggest_improvement", + ["arguments"] = new JsonObject + { + ["category"] = "other", + ["description"] = uniqueDesc, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + Assert.Equal("keep me", File.ReadAllText(existingProbe)); } [Fact] diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 584f0dd5a6..bf7e3e9945 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -7,6 +7,7 @@ namespace CodeIndex.Tests; /// Tests for SuggestionStore (local JSON storage with deduplication). /// SuggestionStoreのテスト(ローカルJSON蓄積 + 重複排除)。 /// +[Collection("SQLite pool sensitive")] public class SuggestionStoreTests : IDisposable { private readonly string _tempDir; @@ -266,6 +267,29 @@ public void LoadAll_PreservesAllFields() Assert.Null(r.GitHubIssueUrl); } + [Fact] + public void TryAdd_RedactsSensitiveTextBeforePersistence() + { + var record = MakeRecord( + "other", + null, + "AWS AKIA1234567890ABCDEF and password=swordfish and Bearer AbCdEfGhIjKlMnOpQrStUvWxYz123456 should not persist"); + record.Context = "token aaBB11ccDD22eeFF33ggHH44iiJJ55kk"; + record.ToolInvocationContext = "secret=hunter2"; + + Assert.True(_store.TryAdd(record)); + + var stored = Assert.Single(_store.LoadAll()); + Assert.Contains("[REDACTED:aws_access_key]", stored.Description); + Assert.Contains("password=[REDACTED:credential]", stored.Description); + Assert.Contains("[REDACTED:bearer_token]", stored.Description); + Assert.Contains("[REDACTED:high_entropy_token]", stored.Context); + Assert.Contains("secret=[REDACTED:credential]", stored.ToolInvocationContext); + Assert.DoesNotContain("AKIA1234567890ABCDEF", stored.Description); + Assert.DoesNotContain("swordfish", stored.Description); + Assert.DoesNotContain("hunter2", stored.ToolInvocationContext); + } + [Fact] public void TryAddAndSubmit_Success_StampsAttemptStateAndClearsError() { @@ -727,6 +751,65 @@ public void AtomicWrite_SurvivesAddAfterCorruption() Assert.Equal("Post-corruption suggestion", all[0].Description); } + [Fact] + public void TryAdd_PrunesStaleRecordsToArchive() + { + var old = MakeRecord("other", null, "Old suggestion"); + old.CreatedAt = DateTime.UtcNow.AddDays(-400); + Assert.True(_store.TryAdd(old)); + + var fresh = MakeRecord("other", null, "Fresh suggestion"); + Assert.True(_store.TryAdd(fresh)); + + var all = _store.LoadAll(); + Assert.Single(all); + Assert.Equal("Fresh suggestion", all[0].Description); + + var archivePath = Path.Combine(_tempDir, "suggestions-codeindex.archive.jsonl"); + Assert.True(File.Exists(archivePath)); + var archive = File.ReadAllText(archivePath); + Assert.Contains("Old suggestion", archive); + } + + [Fact] + public void TryAdd_PrunesOldestRecordsOverConfiguredMaxCount() + { + using var env = EnvironmentVariableScope.Capture(SuggestionStore.MaxCountEnvironmentVariable); + env.Set(SuggestionStore.MaxCountEnvironmentVariable, "2"); + var first = MakeRecord("other", null, "First suggestion"); + first.CreatedAt = DateTime.UtcNow.AddMinutes(-3); + var second = MakeRecord("other", null, "Second suggestion"); + second.CreatedAt = DateTime.UtcNow.AddMinutes(-2); + var third = MakeRecord("other", null, "Third suggestion"); + third.CreatedAt = DateTime.UtcNow.AddMinutes(-1); + + Assert.True(_store.TryAdd(first)); + Assert.True(_store.TryAdd(second)); + Assert.True(_store.TryAdd(third)); + + var all = _store.LoadAll(); + Assert.Equal(new[] { "Second suggestion", "Third suggestion" }, all.Select(record => record.Description)); + Assert.Contains("First suggestion", File.ReadAllText(Path.Combine(_tempDir, "suggestions-codeindex.archive.jsonl"))); + } + + [Fact] + public void TryAdd_DuplicateStillPersistsPrunedRecords() + { + var old = MakeRecord("other", null, "Old suggestion"); + old.CreatedAt = DateTime.UtcNow.AddDays(-400); + var duplicate = MakeRecord("other", null, "Duplicate suggestion"); + Assert.True(_store.TryAdd(old)); + Assert.True(_store.TryAdd(duplicate)); + + Assert.False(_store.TryAdd(MakeRecord("other", null, "Duplicate suggestion"))); + + var all = _store.LoadAll(); + Assert.Single(all); + Assert.Equal("Duplicate suggestion", all[0].Description); + var archivePath = Path.Combine(_tempDir, "suggestions-codeindex.archive.jsonl"); + Assert.Equal(1, File.ReadAllText(archivePath).Split("Old suggestion").Length - 1); + } + [Fact] public void TryAdd_MoveFailure_DoesNotLeaveOrphanTmpFile() {