diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a1ac5bfbd8..79e4f4cabe 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1205,11 +1205,11 @@ The shared GitHub HTTP client uses an explicit 10-second submission timeout by d ### Heuristic source code guard (not a security boundary) -The description, context, and optional tool invocation context fields pass through `SourceCodeDetector` before storage and optional GitHub submission. This heuristic rejects common pasted code patterns (multi-line blocks, fenced code, import runs, function definitions) but intentionally allows short inline code examples so gap descriptions remain useful. It is **not a security boundary** — a determined agent could bypass it. The guard is a best-effort filter to catch accidental code inclusion, not a guarantee that no code-like text will ever be transmitted. +The description, context, and optional tool invocation context fields pass through `SourceCodeDetector` before storage and optional GitHub submission. This heuristic rejects common pasted code patterns (multi-line blocks, backtick or tilde fenced code, import runs, function definitions) but intentionally allows short inline code examples so gap descriptions remain useful. Rejections return a bounded `source_code_rejection` object with the rejected field name and stable `reason_code`; they do not echo the rejected text. It is **not a security boundary** — a determined agent could bypass it. The guard is a best-effort filter to catch accidental code inclusion, not a guarantee that no code-like text will ever be transmitted. ### SourceCodeDetector design -`SourceCodeDetector` uses five independent heuristics to reject text that looks like pasted source code. Each heuristic is implemented as a clearly named private method with detailed comments explaining what it detects and why. The class is designed for readability: anyone reviewing the open-source code can verify the detection logic and confirm that no source code passes through. +`SourceCodeDetector` uses six independent heuristics to reject text that looks like pasted source code. Each heuristic is implemented as a clearly named private method with detailed comments explaining what it detects and why, and maps to a stable reason code such as `statement-ending`, `indented-code-lines`, `block-structure`, `repeated-imports`, `function-definition`, or `fenced-code-block`. The class is designed for readability: anyone reviewing the open-source code can verify the detection logic and confirm that no source code passes through. The detector intentionally allows short inline code examples (e.g. `` `const foo = () => {}` ``) and only rejects multi-line code blocks. False negatives (missing some code) are acceptable; false positives (rejecting valid descriptions) are not. @@ -3435,11 +3435,11 @@ upstream Issue を作成する前に、`GitHubIssueReporter` は同じ SHA256 ### ヒューリスティックなソースコードガード(セキュリティ境界ではない) -description、context、および任意の tool invocation context フィールドは、保存およびオプションの GitHub 送信前に `SourceCodeDetector` を通過する。このヒューリスティックは一般的なコードコピペパターン(複数行ブロック、フェンスドコード、import の連打、関数定義)を拒否するが、ギャップの説明として有用な短いインラインコード例は意図的に許容する。これは**セキュリティ境界ではない** — 意図的に回避しようとするエージェントは回避できる。このガードはコードの誤混入を防ぐベストエフォートのフィルタであり、コード的テキストが一切送信されないことの保証ではない。 +description、context、および任意の tool invocation context フィールドは、保存およびオプションの GitHub 送信前に `SourceCodeDetector` を通過する。このヒューリスティックは一般的なコードコピペパターン(複数行ブロック、バッククォートまたはチルダのフェンスドコード、import の連打、関数定義)を拒否するが、ギャップの説明として有用な短いインラインコード例は意図的に許容する。拒否時は、拒否対象フィールド名と安定した `reason_code` を持つ上限付きの `source_code_rejection` object を返し、拒否された本文は反映しない。これは**セキュリティ境界ではない** — 意図的に回避しようとするエージェントは回避できる。このガードはコードの誤混入を防ぐベストエフォートのフィルタであり、コード的テキストが一切送信されないことの保証ではない。 ### SourceCodeDetector の設計 -`SourceCodeDetector` は5つの独立したヒューリスティックを使って、コピペされたソースコードに見えるテキストを拒否する。各ヒューリスティックは明確な名前の private メソッドとして実装され、何を検出し、なぜそれがソースコードの兆候なのかを詳細なコメントで説明している。可読性を重視して設計されており、オープンソースのコードをレビューする誰もが検出ロジックを検証し、ソースコードが通過しないことを確認できる。 +`SourceCodeDetector` は6つの独立したヒューリスティックを使って、コピペされたソースコードに見えるテキストを拒否する。各ヒューリスティックは明確な名前の private メソッドとして実装され、何を検出し、なぜそれがソースコードの兆候なのかを詳細なコメントで説明している。また、`statement-ending`、`indented-code-lines`、`block-structure`、`repeated-imports`、`function-definition`、`fenced-code-block` のような安定した理由コードに対応する。可読性を重視して設計されており、オープンソースのコードをレビューする誰もが検出ロジックを検証し、ソースコードが通過しないことを確認できる。 短いインラインコード例(例: `` `const foo = () => {}` ``)は意図的に許容し、複数行のコードブロックのみを拒否する。偽陰性(一部のコードの見逃し)は許容する。偽陽性(有効な説明の拒否)は許容しない。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 361bd8540a..dc82f45a1e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2343,7 +2343,7 @@ The MCP `tools/list` response includes an `examples` array for every registered | `backfill_fold` | Upgrade folded-name keys in an existing DB without reparsing source files | | `suggest_improvement` | Submit structured improvement suggestions or error reports | -`suggest_improvement` always stores accepted suggestions locally. Its response includes `submitted_to_github` and `github_submission_reason` so clients can distinguish `submitted`, `token_not_configured`, `repo_not_configured`, `network_error`, and `api_error`; failed GitHub attempts also include `github_submission_error`. +`suggest_improvement` always stores accepted suggestions locally. Its response includes `submitted_to_github` and `github_submission_reason` so clients can distinguish `submitted`, `token_not_configured`, `repo_not_configured`, `network_error`, and `api_error`; failed GitHub attempts also include `github_submission_error`. If the source-code guard rejects `description`, `context`, or `toolInvocationContext`, the error `structuredContent` includes `source_code_rejection.field` and `source_code_rejection.reason_code` without echoing the rejected text. The MCP `index` tool returns a `diagnostics` object when non-fatal indexing problems occur. It includes category counts and up to 50 bounded items for recoverable indexing errors and skipped file-size measurements; item paths are project-relative when possible, and messages are redacted and bounded so permission or path failures can be acted on without leaking local absolute paths or token-shaped values. @@ -4876,7 +4876,7 @@ OpenAI Codex CLI (`codex.json` または `~/.codex/config.json`): | `backfill_fold` | 既存 DB の folded-name key をソース再解析なしで更新 | | `suggest_improvement` | 構造化された改善提案またはエラー報告を送信 | -`suggest_improvement` は受理した提案を常にローカル保存します。応答には `submitted_to_github` と `github_submission_reason` が含まれ、クライアントは `submitted`、`token_not_configured`、`repo_not_configured`、`network_error`、`api_error` を区別できます。GitHub 送信に失敗した場合は `github_submission_error` も含まれます。 +`suggest_improvement` は受理した提案を常にローカル保存します。応答には `submitted_to_github` と `github_submission_reason` が含まれ、クライアントは `submitted`、`token_not_configured`、`repo_not_configured`、`network_error`、`api_error` を区別できます。GitHub 送信に失敗した場合は `github_submission_error` も含まれます。ソースコードガードが `description`、`context`、または `toolInvocationContext` を拒否した場合、エラーの `structuredContent` には拒否された本文を反映せずに `source_code_rejection.field` と `source_code_rejection.reason_code` が含まれます。 MCP の `index` tool は、致命的ではない indexing 問題が発生した場合に `diagnostics` object を返します。recoverable な indexing error と file-size 測定 skip について category count と最大 50 件の bounded item を含み、path は可能な限り project-relative、message は redaction と上限適用済みなので、local absolute path や token 風の値を漏らさず permission / path 問題を判断できます。 diff --git a/changelog.d/unreleased/3830.fixed.md b/changelog.d/unreleased/3830.fixed.md new file mode 100644 index 0000000000..72fec00554 --- /dev/null +++ b/changelog.d/unreleased/3830.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3830 +affected: + - src/CodeIndex/Cli/SourceCodeDetector.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Source-code guard rejections now report stable reasons without echoing source text (#3830)** — `suggest_improvement` source-code guard errors now include a bounded `source_code_rejection.reason_code`, and the detector recognizes tilde Markdown fences in addition to backtick fences. + +## 日本語 + +- **ソースコードガードの拒否がソース本文を反映せず安定した理由を返すようになりました (#3830)** — `suggest_improvement` のソースコードガードエラーは上限付きの `source_code_rejection.reason_code` を含むようになり、detector はバッククォート fence に加えてチルダの Markdown fence も認識します。 diff --git a/src/CodeIndex/Cli/SourceCodeDetector.cs b/src/CodeIndex/Cli/SourceCodeDetector.cs index 1c28369943..eb9ea2cb9c 100644 --- a/src/CodeIndex/Cli/SourceCodeDetector.cs +++ b/src/CodeIndex/Cli/SourceCodeDetector.cs @@ -3,6 +3,17 @@ namespace CodeIndex.Cli; +/// +/// Source-code detection result with a stable reason code for diagnostics. +/// 診断用の安定した理由コードを持つソースコード検出結果。 +/// +public readonly record struct SourceCodeDetectionResult(bool ContainsSourceCode, string? ReasonCode) +{ + public static SourceCodeDetectionResult NoSourceCode { get; } = new(false, null); + + public static SourceCodeDetectionResult Detected(string reasonCode) => new(true, reasonCode); +} + /// /// Detects whether a text string likely contains source code. /// テキスト文字列にソースコードが含まれている可能性があるかを検出する。 @@ -87,6 +98,13 @@ public static class SourceCodeDetector /// private const int ConsecutiveImportThreshold = 3; + public const string ReasonStatementEnding = "statement-ending"; + public const string ReasonIndentedCodeLines = "indented-code-lines"; + public const string ReasonBlockStructure = "block-structure"; + public const string ReasonRepeatedImports = "repeated-imports"; + public const string ReasonFunctionDefinition = "function-definition"; + public const string ReasonFencedCodeBlock = "fenced-code-block"; + // --------------------------------------------------------------- // Public API / 公開API // --------------------------------------------------------------- @@ -101,10 +119,18 @@ public static class SourceCodeDetector /// /// The text to inspect / 検査するテキスト /// true if source code is likely present / ソースコードが含まれる可能性がある場合 true - public static bool ContainsSourceCode(string text) + public static bool ContainsSourceCode(string? text) => Detect(text).ContainsSourceCode; + + /// + /// Returns a detection result with a stable reason code when source code is likely present. + /// ソースコードが含まれる可能性がある場合、安定した理由コード付きの検出結果を返す。 + /// + /// The text to inspect / 検査するテキスト + /// Detection result / 検出結果 + public static SourceCodeDetectionResult Detect(string? text) { if (string.IsNullOrWhiteSpace(text)) - return false; + return SourceCodeDetectionResult.NoSourceCode; // Run each heuristic independently. // Any single match is sufficient to flag the text. @@ -112,24 +138,24 @@ public static bool ContainsSourceCode(string text) // 1つでもマッチすればテキストをフラグする。 if (HasCodeStatementPattern(text)) - return true; + return SourceCodeDetectionResult.Detected(ReasonStatementEnding); if (HasConsecutiveCodeLines(text)) - return true; + return SourceCodeDetectionResult.Detected(ReasonIndentedCodeLines); if (HasBlockStructure(text)) - return true; + return SourceCodeDetectionResult.Detected(ReasonBlockStructure); if (HasRepeatedImports(text)) - return true; + return SourceCodeDetectionResult.Detected(ReasonRepeatedImports); if (HasMultiLineFunctionDefinition(text)) - return true; + return SourceCodeDetectionResult.Detected(ReasonFunctionDefinition); if (HasFencedCodeBlock(text)) - return true; + return SourceCodeDetectionResult.Detected(ReasonFencedCodeBlock); - return false; + return SourceCodeDetectionResult.NoSourceCode; } // --------------------------------------------------------------- @@ -522,24 +548,24 @@ private static bool IsFunctionDefinitionLine(string trimmedLine) /// private static bool HasFencedCodeBlock(string text) { - bool inFence = false; + char activeMarker = '\0'; + int activeMarkerLength = 0; int contentLines = 0; foreach (var rawLine in EnumerateLines(text)) { - var trimmed = rawLine.Trim(); - - // Check for fence delimiter (``` with optional language tag) - // フェンス区切り(```+任意の言語タグ)を検査 - if (trimmed.StartsWith("```", StringComparison.Ordinal)) + // Check for fence delimiter (``` or ~~~ with optional language tag) + // フェンス区切り(``` または ~~~+任意の言語タグ)を検査 + if (TryReadMarkdownFence(rawLine, out var marker, out var markerLength)) { - if (!inFence) + if (activeMarker == '\0') { // Opening fence / 開始フェンス - inFence = true; + activeMarker = marker; + activeMarkerLength = markerLength; contentLines = 0; } - else + else if (marker == activeMarker && markerLength >= activeMarkerLength) { // Closing fence — if there was at least 1 content line, // this is a fenced code block. @@ -547,18 +573,53 @@ private static bool HasFencedCodeBlock(string text) // フェンスドコードブロックである。 if (contentLines >= 1) return true; - inFence = false; + activeMarker = '\0'; + activeMarkerLength = 0; + } + else + { + contentLines++; } continue; } - if (inFence && trimmed.Length > 0) + if (activeMarker != '\0' && rawLine.Trim().Length > 0) contentLines++; } return false; } + private static bool TryReadMarkdownFence(string line, out char marker, out int markerLength) + { + marker = '\0'; + markerLength = 0; + var index = 0; + while (index < line.Length && line[index] is ' ' or '\t') + index++; + if (index >= line.Length) + return false; + + var candidate = line[index]; + if (candidate is not ('`' or '~')) + return false; + + while (index < line.Length && line[index] == candidate) + { + markerLength++; + index++; + } + + if (markerLength < 3) + { + markerLength = 0; + return false; + } + + marker = candidate; + return true; + } + private static IEnumerable EnumerateLines(string text) { var start = 0; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 8a59967b4a..4418dd0291 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -6803,13 +6803,35 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo // 3. Source code leak detection — reject if code is detected // ソースコード漏洩検出 — コードが検出されたら拒否 - if (SourceCodeDetector.ContainsSourceCode(description)) - return CreateToolErrorResponse(id, "Description appears to contain source code. Please describe the gap in natural language without including code."); + var descriptionDetection = SourceCodeDetector.Detect(description); + if (descriptionDetection.ContainsSourceCode) + return CreateSourceCodeDetectedErrorResponse( + id, + "description", + descriptionDetection, + "Description appears to contain source code. Please describe the gap in natural language without including code."); + + if (context != null) + { + var contextDetection = SourceCodeDetector.Detect(context); + if (contextDetection.ContainsSourceCode) + return CreateSourceCodeDetectedErrorResponse( + id, + "context", + contextDetection, + "Context appears to contain source code. Please describe what you were trying to do without including code."); + } - if (context != null && SourceCodeDetector.ContainsSourceCode(context)) - return CreateToolErrorResponse(id, "Context appears to contain source code. Please describe what you were trying to do without including code."); - if (toolInvocationContext != null && SourceCodeDetector.ContainsSourceCode(toolInvocationContext)) - return CreateToolErrorResponse(id, "Tool invocation context appears to contain source code. Please describe the invocation without including code."); + if (toolInvocationContext != null) + { + var invocationDetection = SourceCodeDetector.Detect(toolInvocationContext); + if (invocationDetection.ContainsSourceCode) + return CreateSourceCodeDetectedErrorResponse( + id, + "toolInvocationContext", + invocationDetection, + "Tool invocation context appears to contain source code. Please describe the invocation without including code."); + } var samplingDecision = ResolveSuggestionSamplingDecision(); var samplingAttempt = await TrySampleSuggestionMetadataAsync( @@ -6939,6 +6961,29 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo return CreateToolResult(id, "Suggestion recorded. Thank you for the feedback.", payload); } + private JsonObject CreateSourceCodeDetectedErrorResponse( + JsonNode? id, + string field, + SourceCodeDetectionResult detection, + string message) + { + var extraData = new JsonObject + { + ["source_code_rejection"] = new JsonObject + { + ["field"] = field, + ["reason_code"] = detection.ReasonCode ?? "unknown", + }, + }; + return CreateToolErrorResponse( + id, + message, + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Describe the gap in natural language without including code.", + retrySafe: false, + extraData: extraData); + } + private static string[]? ReadEvidencePaths(JsonNode? node, out string? error) { error = null; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 3e33748812..b674dbe504 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -14840,6 +14840,37 @@ public void SuggestImprovement_SourceCodeInDescription_ReturnsError() Assert.True(response["result"]!["isError"]!.GetValue()); Assert.Contains("source code", response["result"]!["content"]![0]!["text"]!.GetValue()); + var rejection = response["result"]!["structuredContent"]!["source_code_rejection"]!; + Assert.Equal("description", rejection["field"]!.GetValue()); + Assert.Equal(SourceCodeDetector.ReasonStatementEnding, rejection["reason_code"]!.GetValue()); + } + + [Fact] + public void SuggestImprovement_SourceCodeFenceRejectionIncludesBoundedReason_Issue3830() + { + const string leakedToken = "SHOULD_NOT_APPEAR_3830"; + var desc = "The tool should explain this failure:\n" + + "~~~csharp\n" + + leakedToken + "\n" + + "~~~"; + var json = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "suggest_improvement", + ["arguments"] = new JsonObject { ["category"] = "other", ["description"] = desc } + } + }; + var response = _server.HandleMessage(json)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var rejection = response["result"]!["structuredContent"]!["source_code_rejection"]!; + Assert.Equal("description", rejection["field"]!.GetValue()); + Assert.Equal(SourceCodeDetector.ReasonFencedCodeBlock, rejection["reason_code"]!.GetValue()); + Assert.DoesNotContain(leakedToken, response.ToJsonString()); } [Fact] diff --git a/tests/CodeIndex.Tests/SourceCodeDetectorTests.cs b/tests/CodeIndex.Tests/SourceCodeDetectorTests.cs index 86c09427db..780df1220f 100644 --- a/tests/CodeIndex.Tests/SourceCodeDetectorTests.cs +++ b/tests/CodeIndex.Tests/SourceCodeDetectorTests.cs @@ -89,6 +89,7 @@ public void AllowsEmptyOrWhitespace() Assert.False(SourceCodeDetector.ContainsSourceCode("")); Assert.False(SourceCodeDetector.ContainsSourceCode(" ")); Assert.False(SourceCodeDetector.ContainsSourceCode(null!)); + Assert.Null(SourceCodeDetector.Detect(null).ReasonCode); } [Fact] @@ -102,6 +103,18 @@ public void AllowsTwoLineCodeSnippet() Assert.False(SourceCodeDetector.ContainsSourceCode(text)); } + [Fact] + public void AllowsProseWithBackticksAndTildes_Issue3830() + { + var text = "The docs mention `inline code`, ``` fences, and ~~~ fences in prose, " + + "but no fenced block is present."; + + var result = SourceCodeDetector.Detect(text); + + Assert.False(result.ContainsSourceCode); + Assert.Null(result.ReasonCode); + } + // ================================================================ // REJECTED inputs — these SHOULD be flagged as source code. // 拒否される入力 — ソースコードとしてフラグされるべき。 @@ -124,6 +137,34 @@ public void RejectsMultiLineCodeBlock() Assert.True(SourceCodeDetector.ContainsSourceCode(text)); } + [Theory] + [InlineData( + "alpha;\nbeta;\ngamma;\ndelta;\nepsilon;", + SourceCodeDetector.ReasonStatementEnding)] + [InlineData( + " var current = 1\n return current\n result.ToString()", + SourceCodeDetector.ReasonIndentedCodeLines)] + [InlineData( + "section {\nalpha\nbeta\ngamma\n}", + SourceCodeDetector.ReasonBlockStructure)] + [InlineData( + "import alpha\nimport beta\nimport gamma", + SourceCodeDetector.ReasonRepeatedImports)] + [InlineData( + "def process():\n return 1", + SourceCodeDetector.ReasonFunctionDefinition)] + [InlineData( + "Here is the snippet:\n~~~csharp\nreturn token;\n~~~", + SourceCodeDetector.ReasonFencedCodeBlock)] + public void Detect_ReturnsStableReasonCode_Issue3830(string text, string expectedReason) + { + var result = SourceCodeDetector.Detect(text); + + Assert.True(result.ContainsSourceCode); + Assert.Equal(expectedReason, result.ReasonCode); + Assert.True(SourceCodeDetector.ContainsSourceCode(text)); + } + [Fact] public void RejectsCrLfMultiLineCodeBlock_Issue3068() { @@ -370,6 +411,47 @@ public void RejectsFencedCodeBlockMultipleLines() Assert.True(SourceCodeDetector.ContainsSourceCode(text)); } + [Fact] + public void RejectsTildeFencedCodeBlock_Issue3830() + { + var text = "The problem:\n" + + "~~~python\n" + + "token\n" + + "~~~"; + + var result = SourceCodeDetector.Detect(text); + + Assert.True(result.ContainsSourceCode); + Assert.Equal(SourceCodeDetector.ReasonFencedCodeBlock, result.ReasonCode); + } + + [Fact] + public void RejectsIndentedTildeFencedCodeBlock_Issue3830() + { + var text = "The problem:\n" + + " ~~~python\n" + + "token\n" + + " ~~~"; + + var result = SourceCodeDetector.Detect(text); + + Assert.True(result.ContainsSourceCode); + Assert.Equal(SourceCodeDetector.ReasonFencedCodeBlock, result.ReasonCode); + } + + [Theory] + [InlineData(" ```csharp\nreturn token;\n ```")] + [InlineData("\t```csharp\nreturn token;\n\t```")] + [InlineData(" ~~~csharp\nreturn token;\n ~~~")] + [InlineData("\t~~~csharp\nreturn token;\n\t~~~")] + public void RejectsListIndentedFencedCodeBlocks_Issue3830(string text) + { + var result = SourceCodeDetector.Detect(text); + + Assert.True(result.ContainsSourceCode); + Assert.Equal(SourceCodeDetector.ReasonFencedCodeBlock, result.ReasonCode); + } + [Fact] public void AllowsEmptyFencedBlock() { @@ -382,6 +464,20 @@ public void AllowsEmptyFencedBlock() Assert.False(SourceCodeDetector.ContainsSourceCode(text)); } + [Fact] + public void AllowsEmptyTildeFencedBlock_Issue3830() + { + var text = "See:\n" + + "~~~\n" + + "~~~\n" + + "Nothing there."; + + var result = SourceCodeDetector.Detect(text); + + Assert.False(result.ContainsSourceCode); + Assert.Null(result.ReasonCode); + } + [Fact] public void RejectsShortUnindentedCodeInFence() {