From ff98513bfce6ded5d5e5444e2763894b3c68dc6f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 19:57:31 +0900 Subject: [PATCH 1/5] Redact support recovery paths by default (#4860) --- DEVELOPER_GUIDE.md | 12 +++- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4860.security.md | 19 +++++ src/CodeIndex/Cli/CliFlagSchema.cs | 7 +- src/CodeIndex/Cli/ConsoleUi.cs | 20 +++--- .../Cli/ExcerptRecoveryCommandFormatter.cs | 70 ++++++++++++++++--- ...eryCommandRunner.ArgumentParser.General.cs | 6 ++ .../Cli/QueryCommandRunner.ArgumentParser.cs | 2 + .../Cli/QueryCommandRunner.BodyExcerpts.cs | 38 +++++----- .../Cli/QueryCommandRunner.Definition.cs | 2 +- .../Cli/QueryCommandRunner.Dependencies.cs | 2 +- .../Cli/QueryCommandRunner.Excerpt.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 6 +- .../Cli/QueryCommandRunner.Inspect.cs | 4 +- .../Cli/QueryCommandRunner.Status.cs | 25 ++++++- src/CodeIndex/Cli/QueryCommandRunner.cs | 1 + src/CodeIndex/Models/QueryResults.cs | 2 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 10 +-- .../ExcerptRecoveryCommandFormatterTests.cs | 68 ++++++++++++++++-- .../McpServerToolsCallTests.cs | 7 +- tests/CodeIndex.Tests/ProgramCliTests.cs | 7 +- .../QueryCommandRunnerInspectTests.cs | 9 ++- .../QueryCommandRunnerSearchTests.cs | 8 ++- .../QueryCommandRunnerSymbolTests.cs | 11 ++- .../QueryCommandRunnerTests.cs | 31 +++++++- 25 files changed, 299 insertions(+), 74 deletions(-) create mode 100644 changelog.d/unreleased/4860.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 29f2f390a..f11a0e0f0 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1681,7 +1681,11 @@ When the match line falls inside an indexed symbol range, `search --json` and MC `find --json` remains line-delimited for repeated matches and adds bounded match-span/truncation metadata to each row: `length` reports the 1-based `column` span length, `original_line_length` reports the source line length before any line-width clamp, and `snippet_truncation_context.line_count` / `char_counts` / `total_chars` / optional `reason` describe snippet clamping. `reason` is `line_width` when `--max-line-width` elides one or more snippet lines. -`excerpt --json` includes `semantic_tokens`, a lightweight range list with 1-based source start/end positions, token `type`, and `modifiers`, so IDE and LLM clients can render or post-process excerpt spans without reparsing the raw `content` string. C# excerpts and LSP `textDocument/semanticTokens/full` share the same source classifier for keywords/modifiers, namespaces and types, methods and properties, parameters, variables and fields, and declaration modifiers. Excerpt classification uses indexed-source context, applies the output token budget only after filtering to visible source lines, and falls back to classifying the visible content when a bounded source scan cannot reach it; narrow or late-file excerpts therefore retain context when available without becoming empty because earlier tokens exhausted the output budget. Excerpt range mapping and LSP delta encoding only translate coordinates and do not choose semantic kinds. `semantic_token_coordinate_space` is `source`; when `--max-line-width` clamps returned content, `content_line_spans` maps each returned content line and visible content-column span back to the matching source line and source-column span, while clamp markers remain unmapped and are not emitted as semantic tokens. Excerpt rows also expose `requested_start_line`, `requested_end_line`, `effective_start_line`, `effective_end_line`, `content_truncation_reasons`, and optional `content_recovery` so clients can tell when `--max-line-width` caused `line_width_cap` and replay the omitted text. `content_recovery` and the matching `body_content_recovery` use `argv` as their primary machine-readable contract; the array begins with the current apphost or `dotnet` plus the running assembly, so recovery stays on the same build and database contract. Root-level paths beginning with `-` are preceded by the supported `--` end-of-options marker so the recovery CLI does not parse them as options. The legacy `command` is display-only, accompanied by `command_display_only: true` and `command_shell` (`posix-sh` or `powershell`), and is escaped for that declared shell. MCP emits the equivalent camelCase field names. Body-bearing JSON rows also use matching `body_requested_*`, `body_effective_*`, and `body_content_truncation_reasons` fields; body reasons include `body_line_cap` for snippet/body line caps and `body_byte_cap` for definition body byte caps. +`excerpt --json` includes `semantic_tokens`, a lightweight range list with 1-based source start/end positions, token `type`, and `modifiers`, so IDE and LLM clients can render or post-process excerpt spans without reparsing the raw `content` string. C# excerpts and LSP `textDocument/semanticTokens/full` share the same source classifier for keywords/modifiers, namespaces and types, methods and properties, parameters, variables and fields, and declaration modifiers. Excerpt classification uses indexed-source context, applies the output token budget only after filtering to visible source lines, and falls back to classifying the visible content when a bounded source scan cannot reach it; narrow or late-file excerpts therefore retain context when available without becoming empty because earlier tokens exhausted the output budget. Excerpt range mapping and LSP delta encoding only translate coordinates and do not choose semantic kinds. `semantic_token_coordinate_space` is `source`; when `--max-line-width` clamps returned content, `content_line_spans` maps each returned content line and visible content-column span back to the matching source line and source-column span, while clamp markers remain unmapped and are not emitted as semantic tokens. Excerpt rows also expose `requested_start_line`, `requested_end_line`, `effective_start_line`, `effective_end_line`, `content_truncation_reasons`, and optional `content_recovery` so clients can tell when `--max-line-width` caused `line_width_cap` and replay the omitted text. Body-bearing JSON rows use matching `body_requested_*`, `body_effective_*`, and `body_content_truncation_reasons` fields; body reasons include `body_line_cap` for snippet/body line caps and `body_byte_cap` for definition body byte caps. + +`content_recovery` and `body_content_recovery` use `argv` as their primary machine-readable contract. Shared CLI JSON and MCP responses redact machine-specific absolute apphost, assembly, source, and database paths by default with the structured path sanitizer, before rendering `command`; they never regex-rewrite the rendered shell string. Default metadata reports `paths_redacted: true`, `command_display_only: true`, and `requires_local_path_substitution: true` when any argument was replaced. Root-level paths beginning with `-` retain the supported `--` end-of-options marker, and `command_shell` (`posix-sh` or `powershell`) identifies the escaping contract. CLI `definition`, `references`, `callers`, `callees`, `excerpt`, `inspect`, and `impact` accept `--redact-paths` as the explicit default and `--show-paths` as the local-only opt-in. `--show-paths` emits the resolved apphost or `dotnet` plus running assembly, source, and database arguments, sets `paths_redacted: false` and `command_display_only: false`, and produces a safely quoted command for the declared shell. MCP remains support-safe and emits the equivalent camelCase metadata. + +`status --config` follows the same policy: `db_path`, `data_dir`, and `global_tool_log_dir` are redacted by default, the top-level `redaction.paths_redacted` field records the mode, and `--show-paths` is the only local opt-in for resolved values. `--redact-paths` makes the default explicit; either path-display flag is rejected by plain `status` without `--config`. `inspect` and MCP `analyze_symbol` bundle the primary definition, nearby symbols from the same file, references, callers, callees, file metadata, workspace freshness/git metadata, and graph-support metadata into one response. When those bundled graph sections actually depend on SQL-backed reads, the payload also mirrors `sql_graph_contract_ready` / `sql_graph_contract_degraded_reason` (plus the existing camelCase aliases on MCP responses); mixed-language bundles that only return C# / JS / etc. graph rows omit the SQL trust signal entirely. This is intended for symbol-oriented AI workflows that would otherwise need several back-to-back calls. Call graph sections remain language-aware: for unsupported languages, clients can now distinguish "unsupported" from "no hits" via `graphSupported` / `graphSupportReason`, and should prefer `search` instead of assuming graph data will exist. @@ -4949,7 +4953,11 @@ literal-safe な `search` query は reader 層で FTS5 sanitization 前に 1000 `find --json` は繰り返し一致でも line-delimited のまま維持し、各 row に bounded な match span / truncation metadata を追加します。`length` は 1-based の `column` から始まる一致長、`original_line_length` は行幅クランプ前のソース行長、`snippet_truncation_context.line_count` / `char_counts` / `total_chars` / 任意の `reason` は snippet クランプを表します。`--max-line-width` によって snippet 行が省略された場合、`reason` は `line_width` になります。 -`excerpt --json` は 1-based の source 開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。C# の excerpt と LSP `textDocument/semanticTokens/full` は、keyword/modifier、namespace と type、method と property、parameter、variable と field、declaration modifier を判定する同じ source classifier を共有します。excerpt の分類は indexed source の context を利用し、出力 token budget を可視 source 行へ絞った後に適用します。bounded source scan が可視範囲まで到達できない場合は可視 content の分類へ fallback するため、狭い範囲では利用可能な context を維持し、file 後半の excerpt が手前の token に出力 budget を消費されて空になることも防ぎます。excerpt の range mapping と LSP の delta encoding は座標変換だけを担当し、semantic kind を選びません。`semantic_token_coordinate_space` は `source` です。`--max-line-width` で返却内容がクランプされた場合、`content_line_spans` は返却 content 行と可視 content column span を、対応する source 行と source column span に対応付けます。clamp marker は未対応領域として扱い、semantic token には含めません。excerpt row は `requested_start_line`、`requested_end_line`、`effective_start_line`、`effective_end_line`、`content_truncation_reasons`、任意の `content_recovery` も返すため、`--max-line-width` による `line_width_cap` を検出して省略部分を再取得できます。`content_recovery` と対応する `body_content_recovery` では、`argv` が一次的な機械可読契約です。この配列は現在の apphost、または `dotnet` と実行中 assembly の組から始まるため、同じ build と database contract で復旧できます。先頭が `-` の root-level path の前には、対応済みの `--` end-of-options marker を置き、復旧 CLI が option として解釈しないようにします。従来の `command` は表示専用で、`command_display_only: true` と `command_shell`(`posix-sh` または `powershell`)を伴い、宣言した shell 向けに escape されます。MCP では同等の camelCase field 名を返します。body を持つ JSON row は対応する `body_requested_*`、`body_effective_*`、`body_content_truncation_reasons` も返します。body reason には snippet/body 行数上限の `body_line_cap` と definition body byte 上限の `body_byte_cap` があります。 +`excerpt --json` は 1-based の source 開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。C# の excerpt と LSP `textDocument/semanticTokens/full` は、keyword/modifier、namespace と type、method と property、parameter、variable と field、declaration modifier を判定する同じ source classifier を共有します。excerpt の分類は indexed source の context を利用し、出力 token budget を可視 source 行へ絞った後に適用します。bounded source scan が可視範囲まで到達できない場合は可視 content の分類へ fallback するため、狭い範囲では利用可能な context を維持し、file 後半の excerpt が手前の token に出力 budget を消費されて空になることも防ぎます。excerpt の range mapping と LSP の delta encoding は座標変換だけを担当し、semantic kind を選びません。`semantic_token_coordinate_space` は `source` です。`--max-line-width` で返却内容がクランプされた場合、`content_line_spans` は返却 content 行と可視 content column span を、対応する source 行と source column span に対応付けます。clamp marker は未対応領域として扱い、semantic token には含めません。excerpt row は `requested_start_line`、`requested_end_line`、`effective_start_line`、`effective_end_line`、`content_truncation_reasons`、任意の `content_recovery` も返すため、`--max-line-width` による `line_width_cap` を検出して省略部分を再取得できます。body を持つ JSON row は対応する `body_requested_*`、`body_effective_*`、`body_content_truncation_reasons` も返します。body reason には snippet/body 行数上限の `body_line_cap` と definition body byte 上限の `body_byte_cap` があります。 + +`content_recovery` と `body_content_recovery` では `argv` が一次的な機械可読契約です。共有用の CLI JSON と MCP response は既定で、機械固有の apphost、assembly、source、database の絶対パスを構造化 path sanitizer で伏せてから `command` を生成し、render 済み shell 文字列を regex で置換しません。既定の metadata は `paths_redacted: true`、`command_display_only: true` を返し、いずれかの引数を置換した場合は `requires_local_path_substitution: true` も返します。先頭が `-` の root-level path には対応済みの `--` end-of-options marker を維持し、`command_shell`(`posix-sh` または `powershell`)で escape 契約を示します。CLI の `definition`、`references`、`callers`、`callees`、`excerpt`、`inspect`、`impact` は、既定を明示する `--redact-paths` と、ローカル用途だけの opt-in である `--show-paths` を受け付けます。`--show-paths` は解決済みの apphost、または `dotnet` と実行中 assembly、source、database の各引数を出力し、`paths_redacted: false` と `command_display_only: false` を設定し、宣言した shell 向けに安全に quote した command を生成します。MCP は常にサポート共有向けの安全な既定を使い、同等の camelCase metadata を返します。 + +`status --config` も同じ policy に従います。`db_path`、`data_dir`、`global_tool_log_dir` は既定で伏せられ、top-level の `redaction.paths_redacted` が mode を記録します。解決済み値を出すローカル opt-in は `--show-paths` だけです。`--redact-paths` は既定を明示し、どちらの path 表示 flag も `--config` のない通常の `status` では拒否されます。 `inspect` と MCP の `analyze_symbol` は、主定義、同一ファイル内の近傍シンボル、参照、caller、callee、ファイルメタデータ、さらにワークスペース鮮度/git メタデータと graph 対応メタデータを1レスポンスにまとめます。bundle 内の graph 節が実際に SQL ベースの read に依存する場合だけ、`sql_graph_contract_ready` / `sql_graph_contract_degraded_reason`(MCP では既存の camelCase alias も)も返します。mixed-language bundle で C# / JS などの graph row しか返っていない場合は SQL trust signal を出さないため、無関係なクエリが stale SQL state に引きずられません。複数の連続クエリを避けたい AI ワークフロー向けです。call graph 系の節は言語差分を考慮しており、未対応言語では `graphSupported` / `graphSupportReason` によって「未対応」と「ヒットなし」を区別できます。その場合は `search` を優先して使う前提です。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 2841accfb..086bbdcb9 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -74,7 +74,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Pure or mostly pure behavior tests with in-memory inputs. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. - Recovery-command coverage asserts structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths, and display-only escaping for both POSIX sh and PowerShell, including spaces, quotes, dollar signs, and shell metacharacters. + Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, and file-URI database options. Default-output assertions must reject the fixture's full absolute paths. Pair this with `status --config` coverage for default DB/data/log path redaction and explicit `--show-paths`. Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay. - `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs` Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. @@ -979,7 +979,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 - recovery command の coverage では構造化 argv、現在の `dotnet` / apphost prefix の維持、option と紛らわしい path の再実行、POSIX sh と PowerShell 双方の表示専用 escaping を、空白、quote、dollar sign、shell metacharacter を含めて検証します。 + recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、file-URI database option を含め、既定出力に fixture の完全な絶対パスが残らないことを assertion にします。`status --config` の DB/data/log path の既定 redaction と明示的 `--show-paths` も対で検証してください。 console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。 - `SymbolExtractor*Tests.cs` と `ReferenceExtractor*Tests.cs` extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 diff --git a/changelog.d/unreleased/4860.security.md b/changelog.d/unreleased/4860.security.md new file mode 100644 index 000000000..e7ddecda7 --- /dev/null +++ b/changelog.d/unreleased/4860.security.md @@ -0,0 +1,19 @@ +--- +category: security +issues: + - 4860 +affected: + - src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs + - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Models/QueryResults.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Recovery commands and `status --config` now redact machine-specific paths by default (#4860)** — CLI and MCP recovery metadata now sanitizes absolute binary, assembly, source, and database arguments before rendering support-shareable commands, while `status --config` applies the same default to DB, data, and log paths. `paths_redacted`, substitution, and redaction metadata make the result explicit; local CLI users can opt into safely quoted resolved values with `--show-paths`. This completes the residual support-output hardening identified after #4313. + +## 日本語 + +- **復旧コマンドと `status --config` が機械固有のパスを既定で伏せるようになりました (#4860)** — CLI/MCP の復旧 metadata は、サポート共有用 command を生成する前に binary、assembly、source、database の絶対パスを sanitization し、`status --config` も DB、data、log path に同じ既定を適用します。`paths_redacted`、置換要否、redaction metadata で状態を明示し、ローカル CLI では `--show-paths` により安全に quote された解決済み値を opt-in できます。これは #4313 後に確認された残余の support-output hardening を完了する変更です。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 881c5d1d0..ffc97b845 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -177,6 +177,10 @@ public static bool HasAuthoritativeHelpOptions(string command) => private static readonly string[] BodyCommands = ["definition", "references", "callers", "callees", "impact", "inspect"]; private static readonly string[] InspectFieldCommands = ["inspect"]; private static readonly string[] InspectSourceExcerptCommands = ["inspect", "excerpt"]; + private static readonly string[] RecoveryPathCommands = + [ + "definition", "references", "callers", "callees", "excerpt", "inspect", "impact", + ]; private static readonly string[] MaxLineWidthCommands = [ @@ -438,7 +442,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--debounce", ValuePlaceholder = "", Description = "Watch only: coalesce file events into one update after of quiet (default 500)", PrimaryCommands = Set("index") }, new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "", Description = "Watch only: changed-path queue limit before full-rescan fallback", PrimaryCommands = Set("index") }, new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "", Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, - new() { Name = "--redact-paths", Description = "Report: accepted for doctor/report parity; report paths are redacted by default", PrimaryCommands = Set("report") }, + new() { Name = "--redact-paths", Description = "Redact machine-specific paths (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report"]).ToArray()) }, + new() { Name = "--show-paths", Description = "Include resolved local paths in recovery commands or status --config output", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status"]).ToArray()) }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, new() { Name = "--include-args", Description = "Include args in bundle log", PrimaryCommands = Set("report") }, new() { Name = "--log-lines", ValuePlaceholder = "", Description = "Number of log lines to include in bundle (clamped to 2000)", PrimaryCommands = Set("report") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 3555ddc6d..2e4399f61 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -87,20 +87,20 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("recipes", "cdidx recipes [list] [--query ] [--names|--summary-only] [--json] [--pretty] [--format ] [--max-json-bytes ]"), ("recipes-list", "cdidx recipes list [--query ] [--names|--summary-only] [--json] [--pretty] [--format ] [--max-json-bytes ]"), ("audit", "cdidx audit [search filters] [--json[=ndjson]] [--format ] [--summary-only] [--limit ] [--total-limit ] [--results-only] [--search-fields ] [--first-per-file] [--sample ] [--max-json-bytes ] [--snippet-lines ]"), - ("definition", "cdidx definition |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), + ("definition", "cdidx definition |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), - ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), - ("callers", "cdidx callers |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), - ("callees", "cdidx callees |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), + ("references", "cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), + ("callers", "cdidx callers |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), + ("callees", "cdidx callees |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json[=ndjson|array]] [--compact] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--group-partials] [--since ]"), ("files", "cdidx files [query||--query |-- ] [--db ] [--json[=ndjson|array]] [--format ] [--summary-only] [--cursor ] [--max-json-bytes ] [--allow-partial] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--context ] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--line-scan-limit ] [--allow-partial] [--exact] [--regex] [--count]"), - ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), + ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]"), - ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), - ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), + ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), + ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--sort ] [--kind ] [--outline-fields ]"), - ("status", "cdidx status [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), + ("status", "cdidx status [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config [--redact-paths|--show-paths]] [--check-updates]"), ("workspace", "cdidx workspace [name-or-relative-path] [--json]"), ("workspace-list", "cdidx workspace list [--json]"), ("workspace-status", "cdidx workspace status [--json]"), @@ -129,7 +129,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ] [--offset ]"), ("report", "cdidx report --output [--overwrite] [--db ] [--json] [--redact-paths] [--log-lines ] [--no-log] [--include-args]"), ("validate", "cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]"), - ("impact", "cdidx impact |--query |-- [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--exact-name] [--count] [--with-paths]"), + ("impact", "cdidx impact |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--exact-name] [--count] [--with-paths]"), ("deps", "cdidx deps [--db ] [--json] [--format ] [--summary-only] [--max-json-bytes ] [--verbose] [--limit |--top ] [--cursor ] [--graph-budget ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse] [--cycles] [--suppress-noise] [--symbol ] [--symbol-family ]"), ("unused", "cdidx unused [--db ] [--json] [--compact] [--summary-only] [--verbose] [--limit |--top ] [--cursor ] [--audit-scope ] [--kind ] [--bucket ] [--min-confidence |--confidence ] [--actionable] [--all] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--by-bucket]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--summary-only] [--max-json-bytes ] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), @@ -183,8 +183,10 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("references", "`refs` is a compatibility alias for `references`; prefer `references` in scripts and documentation."), ("excerpt", "Line ranges can be supplied as `path:line` or `path:start-end`; explicit --start/--end flags override range parsing."), ("excerpt", "--focus-line may be used alone; without --focus-column, clamping keeps the leading window. --focus-length still requires --focus-column."), + ("excerpt", "Recovery-command JSON redacts machine-specific absolute paths by default; use --show-paths only when a locally executable command is required."), ("inspect", "In query mode --path is a glob filter; in line mode --path --line selects a source location."), ("status", "`stats` is a compatibility alias for `status`; prefer `status` in scripts and documentation."), + ("status", "--config redacts DB, data, and log paths by default; use --show-paths only for local inspection."), ("backfill-fold", "`fold` is a compatibility alias for `backfill-fold`; prefer `backfill-fold` in scripts and documentation."), ("batch", "Each stdin line may be a JSON string array such as [\"search\",\"Needle\",\"--json\"] or an object such as {\"command\":\"search\",\"args\":[\"Needle\",\"--json\"]}; blank lines are skipped."), ("batch", "By default child commands stream their normal stdout/stderr; with --json-summary each non-blank line writes a batch_result or batch_error envelope."), diff --git a/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs b/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs index d76428d97..5f15daa2e 100644 --- a/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs +++ b/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs @@ -1,4 +1,5 @@ using CodeIndex.Database; +using CodeIndex.Diagnostics; using System.Globalization; namespace CodeIndex.Cli; @@ -14,18 +15,18 @@ internal static class ExcerptRecoveryCommandFormatter private static readonly AsyncLocal ScopedInvocation = new(); private static readonly RecoveryInvocation DefaultInvocation = new(["cdidx"], ResolveCurrentShell()); - public static void ApplyDbPath(FileExcerptResult excerpt, string dbPath) + public static void ApplyDbPath(FileExcerptResult excerpt, string dbPath, bool redactPaths = true) { - ApplyDbPath(excerpt.ContentRecovery, excerpt.Path, dbPath); + ApplyDbPath(excerpt.ContentRecovery, excerpt.Path, dbPath, redactPaths); } - public static void ApplyDbPath(ExcerptRecoveryHint? recovery, string path, string dbPath) + public static void ApplyDbPath(ExcerptRecoveryHint? recovery, string path, string dbPath, bool redactPaths = true) { if (recovery is null) return; var invocation = ScopedInvocation.Value ?? DefaultInvocation; - ApplyDbPath(recovery, path, dbPath, invocation.ArgvPrefix, invocation.Shell); + ApplyDbPath(recovery, path, dbPath, invocation.ArgvPrefix, invocation.Shell, redactPaths); } internal static void ApplyDbPath( @@ -33,13 +34,19 @@ internal static void ApplyDbPath( string path, string dbPath, IReadOnlyList invocationPrefix, - RecoveryCommandShell shell) + RecoveryCommandShell shell, + bool redactPaths = true) { - var argv = BuildArgv(path, recovery.StartLine, recovery.EndLine, dbPath, invocationPrefix); - recovery.Argv = argv; - recovery.Command = RenderDisplayCommand(argv, shell); + var resolvedArgv = BuildArgv(path, recovery.StartLine, recovery.EndLine, dbPath, invocationPrefix); + var outputArgv = redactPaths + ? BuildSupportSafeArgv(resolvedArgv, invocationPrefix.Count, path.StartsWith("-", StringComparison.Ordinal)) + : resolvedArgv; + recovery.Argv = outputArgv; + recovery.Command = RenderDisplayCommand(outputArgv, shell); recovery.CommandShell = FormatShell(shell); - recovery.CommandDisplayOnly = true; + recovery.CommandDisplayOnly = redactPaths; + recovery.PathsRedacted = redactPaths; + recovery.RequiresLocalPathSubstitution = redactPaths && !resolvedArgv.SequenceEqual(outputArgv, StringComparer.Ordinal); } internal static IDisposable UseCurrentProcessInvocation() @@ -111,6 +118,51 @@ private static string NormalizeDbPath(string dbPath) : Path.GetFullPath(normalized); } + private static List BuildSupportSafeArgv( + IReadOnlyList resolvedArgv, + int invocationPrefixCount, + bool hasEndOfOptionsMarker) + { + var output = resolvedArgv.ToList(); + for (var index = 0; index < invocationPrefixCount; index++) + output[index] = RedactAbsolutePathArgument(output[index]); + + var sourcePathIndex = invocationPrefixCount + (hasEndOfOptionsMarker ? 2 : 1); + if (sourcePathIndex < output.Count) + output[sourcePathIndex] = RedactAbsolutePathArgument(output[sourcePathIndex]); + + var dbFlagIndex = output.IndexOf("--db"); + if (dbFlagIndex >= 0 && dbFlagIndex + 1 < output.Count) + output[dbFlagIndex + 1] = RedactAbsolutePathArgument(output[dbFlagIndex + 1]); + + return output; + } + + private static string RedactAbsolutePathArgument(string value) + { + if (value.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + var queryIndex = value.IndexOf('?'); + var pathEnd = queryIndex >= 0 ? queryIndex : value.Length; + var path = value["file:".Length..pathEnd]; + var query = queryIndex >= 0 ? value[queryIndex..] : string.Empty; + return "file:" + DiagnosticSanitizer.ForPath(path) + query; + } + + return IsAbsolutePathArgument(value) + ? DiagnosticSanitizer.ForPath(value) + : value; + } + + private static bool IsAbsolutePathArgument(string value) + => Path.IsPathRooted(value) + || value.StartsWith(@"\\", StringComparison.Ordinal) + || value.StartsWith("//", StringComparison.Ordinal) + || (value.Length >= 3 + && char.IsAsciiLetter(value[0]) + && value[1] == ':' + && value[2] is '/' or '\\'); + private static string QuoteShellArgument(string value, RecoveryCommandShell shell) { if (IsSafeShellArgument(value, shell)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs index c57bca380..3a192d991 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs @@ -77,6 +77,12 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri else AddParseError(dataDirError!); break; + case "--redact-paths": + redactPaths = true; + break; + case "--show-paths": + redactPaths = false; + break; case "--json": if (inlineValue == null) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index 209022d41..ef2771d24 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -125,6 +125,7 @@ private sealed partial class QueryArgumentParser private string outputFormat = OutputFormatText; private bool outputFormatExplicit; private bool statusConfig; + private bool? redactPaths; private bool limitExplicit; private bool snippetLinesExplicit; private bool maxLineWidthExplicit; @@ -480,6 +481,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r StatusExplainField = statusExplainField, StatusLogPath = statusLogPath, StatusConfig = statusConfig, + RedactPaths = redactPaths, RankMode = rankMode, SymbolSortMode = symbolSortMode, SortValue = sortValue, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs b/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs index 30369802e..e4c58fbfd 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.BodyExcerpts.cs @@ -187,10 +187,10 @@ private static void AddExcerptTruncation(FileExcerptResult excerpt, string reaso private static List? CopyTruncationReasons(FileExcerptResult excerpt) => excerpt.ContentTruncationReasons.Count > 0 ? [.. excerpt.ContentTruncationReasons] : null; - private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath) + private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); } private static int WriteDefinitionJsonResult(DefinitionResult result, QueryCommandOptions options, ExactQuerySignal? exactSignal, JsonSerializerOptions jsonOptions) @@ -266,44 +266,44 @@ private static void OmitDefinitionBodyContent(JsonObject definition) definition.Remove("body_content_recovery"); } - private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath) + private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); } - private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath) + private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); } - private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath) + private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); } - private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath) + private static void ApplyBodyRecoveryCommands(IEnumerable results, string dbPath, bool redactPaths) { foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, dbPath, redactPaths); } - private static void ApplyBodyRecoveryCommands(SymbolAnalysisResult result, string dbPath) + private static void ApplyBodyRecoveryCommands(SymbolAnalysisResult result, string dbPath, bool redactPaths) { - ApplyBodyRecoveryCommands(result.Definitions, dbPath); - ApplyBodyRecoveryCommands(result.References, dbPath); - ApplyBodyRecoveryCommands(result.Callers, dbPath); - ApplyBodyRecoveryCommands(result.Callees, dbPath); + ApplyBodyRecoveryCommands(result.Definitions, dbPath, redactPaths); + ApplyBodyRecoveryCommands(result.References, dbPath, redactPaths); + ApplyBodyRecoveryCommands(result.Callers, dbPath, redactPaths); + ApplyBodyRecoveryCommands(result.Callees, dbPath, redactPaths); if (result.CandidateBundles != null) { foreach (var bundle in result.CandidateBundles) { - ApplyBodyRecoveryCommands([bundle.Definition], dbPath); - ApplyBodyRecoveryCommands(bundle.References, dbPath); - ApplyBodyRecoveryCommands(bundle.Callers, dbPath); - ApplyBodyRecoveryCommands(bundle.Callees, dbPath); + ApplyBodyRecoveryCommands([bundle.Definition], dbPath, redactPaths); + ApplyBodyRecoveryCommands(bundle.References, dbPath, redactPaths); + ApplyBodyRecoveryCommands(bundle.Callers, dbPath, redactPaths); + ApplyBodyRecoveryCommands(bundle.Callees, dbPath, redactPaths); } } } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs index 9a365527a..d35fb8c94 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Definition.cs @@ -215,7 +215,7 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti return ZeroResultExitCode(options); } - ApplyBodyRecoveryCommands(results, options.DbPath); + ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); if (options.Json) { if (TryWriteFormattedLocations( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs index 7c366e05f..3498d4224 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Dependencies.cs @@ -77,7 +77,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) JsonEnvelopeWrapper.GetBoundedImpactCollection()); if (options.IncludeBody) AttachBodyExcerpts(reader, analysis.Callers, options.SnippetLines, options.MaxLineWidth); - ApplyBodyRecoveryCommands(analysis.Callers, options.DbPath); + ApplyBodyRecoveryCommands(analysis.Callers, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignal( reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests), DbReader.IsSqlLanguage(options.Lang) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs b/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs index 5dbf5aaad..a8a0cd93f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Excerpt.cs @@ -147,7 +147,7 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions } if (options.Json) { - ExcerptRecoveryCommandFormatter.ApplyDbPath(excerpt, options.DbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(excerpt, options.DbPath, options.RedactPaths ?? true); if (!options.NoSemanticTokens) excerpt.SemanticTokens = BuildExcerptSemanticTokens(excerpt, reader); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index b150cc5fc..26d375c59 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -66,7 +66,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti var results = reader.SearchReferences(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.MaxLineWidth, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("references")); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); - ApplyBodyRecoveryCommands(results, options.DbPath); + ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Lang), options.Lang, exactGraphLanguage); var exactSignal = reader.GetReferencesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( @@ -205,7 +205,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions var results = reader.GetCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callers")); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); - ApplyBodyRecoveryCommands(results, options.DbPath); + ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Lang), options.Lang, exactGraphLanguage); var exactSignal = reader.GetCallersExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( @@ -344,7 +344,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions var results = reader.GetCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees")); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); - ApplyBodyRecoveryCommands(results, options.DbPath); + ApplyBodyRecoveryCommands(results, options.DbPath, options.RedactPaths ?? true); var sqlGraphSignal = NarrowSqlGraphContractSignalByLanguages(baseSqlGraphSignal, results.Select(result => result.Lang), options.Lang, exactGraphLanguage); var exactSignal = reader.GetCalleesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index a3925cad6..1fa48379f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -239,14 +239,14 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions analysis, queryFingerprint, generation.Fingerprint); - ApplyBodyRecoveryCommands(analysis, options.DbPath); + ApplyBodyRecoveryCommands(analysis, options.DbPath, options.RedactPaths ?? true); var payload = JsonSerializer.SerializeToNode(analysis, CliJsonSerializerContextFactory.Create(jsonOptions).SymbolAnalysisResult)!.AsObject(); AddSqlGraphContractJsonFields(payload, sqlGraphSignal); if (compactTruncation != null) AddCompactJsonFields(payload, compactLimit, compactTruncation); if (sourceExcerpt != null) { - ExcerptRecoveryCommandFormatter.ApplyDbPath(sourceExcerpt, options.DbPath); + ExcerptRecoveryCommandFormatter.ApplyDbPath(sourceExcerpt, options.DbPath, options.RedactPaths ?? true); sourceExcerpt.SemanticTokens = BuildExcerptSemanticTokens(sourceExcerpt, reader); payload["source_excerpt"] = JsonSerializer.SerializeToNode(sourceExcerpt, CliJsonSerializerContextFactory.Create(jsonOptions).FileExcerptResult); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 8a52cad7e..971986224 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Database; +using CodeIndex.Diagnostics; using CodeIndex.Indexer; using CodeIndex.Indexer.Extensibility; using CodeIndex.Indexer.Hooks; @@ -55,6 +56,18 @@ public static int RunStatus( Console.WriteLine(BuildEffectiveConfigJson(options, cmdArgs, appVersion).ToJsonString(jsonOptions)); return CommandExitCodes.Success; } + if (options.RedactPaths.HasValue) + { + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + "--redact-paths and --show-paths are only supported with status --config.", + CommandExitCodes.UsageError, + "Add --config to inspect effective path settings, or remove the path display option.", + GetUsageLineOrThrow("status"), + CommandErrorCodes.UsageError, + category: "usage"); + } if (options.StatusLogPath) { if (options.CheckWorkspace) @@ -1024,6 +1037,8 @@ private static string LimitStatusSymbolKindName(string kind, ref bool namesTrunc private static JsonObject BuildEffectiveConfigJson(QueryCommandOptions options, string[] cmdArgs, string? appVersion) { + var redactPaths = options.RedactPaths ?? true; + JsonObject Entry(T? value, string source) { var entry = new JsonObject @@ -1035,21 +1050,25 @@ JsonObject Entry(T? value, string source) return entry; } + string? PathValue(string? value) + => redactPaths && value != null ? DiagnosticSanitizer.ForPath(value) : value; + var staleAfterEnvValue = CdidxEnvironment.GetEnvironmentVariable(StaleAfterEnvironmentVariable); var payload = new JsonObject { ["api_version"] = "1", + ["redaction"] = JsonSerializer.SerializeToNode(new DoctorRedactionJsonResult(redactPaths, true)), ["effective_config"] = new JsonObject { - ["db_path"] = Entry(options.DbPath, ResolveDbPathConfigSource(options)), - ["data_dir"] = Entry(options.DataDir, options.DataDirSource ?? "flag"), + ["db_path"] = Entry(PathValue(options.DbPath), ResolveDbPathConfigSource(options)), + ["data_dir"] = Entry(PathValue(options.DataDir), options.DataDirSource ?? "flag"), ["limit"] = Entry(options.Limit, ResolveNumericConfigSource(cmdArgs, "--limit", "--top", DefaultLimitEnvironmentVariable)), ["snippet_lines"] = Entry(options.SnippetLines, ResolveNumericConfigSource(cmdArgs, "--snippet-lines", null, DefaultSnippetLinesEnvironmentVariable)), ["max_line_width"] = Entry(options.MaxLineWidth, ResolveNumericConfigSource(cmdArgs, "--max-line-width", null, DefaultMaxLineWidthEnvironmentVariable)), ["json"] = Entry(options.Json, HasOption(cmdArgs, "--json") ? "flag" : "default"), ["stale_after"] = Entry(options.StaleAfter?.ToString() ?? staleAfterEnvValue, options.StaleAfter.HasValue ? "flag" : ResolveEnvSource(StaleAfterEnvironmentVariable)), - ["global_tool_log_dir"] = Entry(GlobalToolLog.ResolveLogDirectoryForStatus(), ResolveEnvSource("CDIDX_GLOBAL_TOOL_LOG_DIR")), + ["global_tool_log_dir"] = Entry(PathValue(GlobalToolLog.ResolveLogDirectoryForStatus()), ResolveEnvSource("CDIDX_GLOBAL_TOOL_LOG_DIR")), ["version"] = Entry(appVersion ?? ConsoleUi.LoadVersion(), "build"), }, }; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 85e3c1967..247c7ec03 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -173,6 +173,7 @@ public sealed class QueryCommandOptions public string? StatusExplainField { get; init; } public bool StatusLogPath { get; init; } public bool StatusConfig { get; init; } + public bool? RedactPaths { get; init; } public ReferenceRankMode RankMode { get; init; } = ReferenceRankMode.Weighted; public SymbolSortMode SymbolSortMode { get; init; } = SymbolSortMode.Name; public string? SortValue { get; init; } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index f4d617f3c..4a6153ed8 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -462,6 +462,8 @@ public class ExcerptRecoveryHint public string Command { get; set; } = string.Empty; public string CommandShell { get; set; } = string.Empty; public bool CommandDisplayOnly { get; set; } = true; + public bool PathsRedacted { get; set; } = true; + public bool RequiresLocalPathSubstitution { get; set; } } public class FileFindResult diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 97a21dc64..e6b8b7959 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -402,10 +402,10 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx callers |--query |-- ", output); Assert.Contains("cdidx callees |--query |-- ", output); AssertSearchUsageFragments(output); - Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); - Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); - Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); + Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); + Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); + Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); + Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); Assert.Contains("cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--sort ] [--kind ] [--outline-fields ]", output); Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); @@ -436,7 +436,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("--optimize index only: optimize the existing FTS5 table for this project's DB without scanning files", output); Assert.Contains("--duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms", output); Assert.Contains("--ascii Use ASCII spinner/progress glyphs", output); - Assert.Contains("cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]", output); + Assert.Contains("cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]", output); Assert.Contains("--focus-column find/excerpt: focus a specific 1-based column", output); Assert.Contains("--focus-line find/excerpt: focus a line", output); Assert.Contains("excerpt keeps the leading window when no column is supplied", output); diff --git a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs index 165cff9ba..650b6344f 100644 --- a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs +++ b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs @@ -17,7 +17,8 @@ public void ApplyDbPath_PosixSh_ReturnsArgvAndEscapesDisplayMetacharacters_Issue path, dbPath, ["/opt/dot net/dotnet", "/repo/cdidx'build.dll"], - RecoveryCommandShell.PosixSh); + RecoveryCommandShell.PosixSh, + redactPaths: false); Assert.Equal( [ @@ -37,7 +38,9 @@ public void ApplyDbPath_PosixSh_ReturnsArgvAndEscapesDisplayMetacharacters_Issue ], recovery.Argv); Assert.Equal("posix-sh", recovery.CommandShell); - Assert.True(recovery.CommandDisplayOnly); + Assert.False(recovery.CommandDisplayOnly); + Assert.False(recovery.PathsRedacted); + Assert.False(recovery.RequiresLocalPathSubstitution); Assert.Equal( """'/opt/dot net/dotnet' '/repo/cdidx'\''build.dll' excerpt 'src/space '\''quote'\'' $dollar &meta.py' --db 'file:/tmp/db path'\''quote?$x&mode=ro' --start 2 --end 3 --max-line-width 0 --json""", recovery.Command); @@ -55,10 +58,13 @@ public void ApplyDbPath_PowerShell_ReturnsArgvAndEscapesDisplayMetacharacters_Is path, dbPath, [@"C:\Program Files\dotnet\dotnet.exe", @"C:\repo\cdidx'build.dll"], - RecoveryCommandShell.PowerShell); + RecoveryCommandShell.PowerShell, + redactPaths: false); Assert.Equal("powershell", recovery.CommandShell); - Assert.True(recovery.CommandDisplayOnly); + Assert.False(recovery.CommandDisplayOnly); + Assert.False(recovery.PathsRedacted); + Assert.False(recovery.RequiresLocalPathSubstitution); Assert.Equal( """& 'C:\Program Files\dotnet\dotnet.exe' 'C:\repo\cdidx''build.dll' excerpt 'src/space ''quote'' $dollar &meta.py' --db 'file:C:\db path''quote?$x&mode=ro' --start 2 --end 3 --max-line-width 0 --json""", recovery.Command); @@ -66,6 +72,60 @@ public void ApplyDbPath_PowerShell_ReturnsArgvAndEscapesDisplayMetacharacters_Is Assert.Equal(dbPath, recovery.Argv[5]); } + [Theory] + [InlineData( + "/Users/alice/My Repo/src/space file.cs", + "file:/Users/alice/My Repo/.cdidx/codeindex.db?mode=ro", + "/Users/alice/.dotnet/dotnet", + "/Users/alice/My Repo/cdidx.dll", + "dotnet", + "space file.cs", + "file:.cdidx/codeindex.db?mode=ro")] + [InlineData( + @"C:\Users\alice\My Repo\src\space file.cs", + @"file:C:\Users\alice\My Repo\.cdidx\codeindex.db?mode=ro", + @"C:\Users\alice\Program Files\dotnet.exe", + @"\\build-server\alice\share\cdidx.dll", + "dotnet.exe", + "space file.cs", + "file:.cdidx/codeindex.db?mode=ro")] + [InlineData( + @"\\workstation\alice\repo\src\space file.cs", + @"file:\\workstation\alice\repo\.cdidx\codeindex.db?mode=ro", + @"\\workstation\alice\tools\cdidx.exe", + @"C:\Users\alice\repo\cdidx.dll", + "cdidx.exe", + "space file.cs", + "file:.cdidx/codeindex.db?mode=ro")] + public void ApplyDbPath_DefaultRedaction_RemovesMachineSpecificAbsolutePaths_Issue4860( + string path, + string dbPath, + string executablePath, + string assemblyPath, + string expectedExecutable, + string expectedSourcePath, + string expectedDbPath) + { + var recovery = FileExcerptResult.CreateRecoveryHint(path, 2, 3); + + ExcerptRecoveryCommandFormatter.ApplyDbPath( + recovery, + path, + dbPath, + [executablePath, assemblyPath], + RecoveryCommandShell.PowerShell); + + Assert.Equal([expectedExecutable, "cdidx.dll"], recovery.Argv.Take(2)); + Assert.Equal(expectedSourcePath, recovery.Argv[3]); + Assert.Equal(expectedDbPath, recovery.Argv[5]); + Assert.True(recovery.CommandDisplayOnly); + Assert.True(recovery.PathsRedacted); + Assert.True(recovery.RequiresLocalPathSubstitution); + Assert.DoesNotContain("alice", recovery.Command, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Users", recovery.Command, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("workstation", recovery.Command, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ResolveInvocationPrefix_PreservesDotnetAssemblyOrNativeApphost_Issue4567() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 814c63f12..6d271fd85 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -4566,15 +4566,18 @@ public void ToolsCall_Excerpt_ClampsLongSingleLineContent() Assert.Equal(1, recovery["startLine"]!.GetValue()); Assert.Equal(1, recovery["endLine"]!.GetValue()); Assert.Equal( - ["cdidx", "excerpt", "dist/data.txt", "--db", Path.GetFullPath(_dbPath), "--start", "1", "--end", "1", "--max-line-width", "0", "--json"], + ["cdidx", "excerpt", "dist/data.txt", "--db", DiagnosticSanitizer.ForPath(_dbPath), "--start", "1", "--end", "1", "--max-line-width", "0", "--json"], recovery["argv"]!.AsArray().Select(argument => argument!.GetValue()).ToArray()); Assert.Equal(OperatingSystem.IsWindows() ? "powershell" : "posix-sh", recovery["commandShell"]!.GetValue()); Assert.True(recovery["commandDisplayOnly"]!.GetValue()); var recoveryCommand = recovery["command"]!.GetValue(); Assert.Contains("cdidx excerpt dist/data.txt", recoveryCommand); Assert.Contains("--db", recoveryCommand); - Assert.Contains(_dbPath, recoveryCommand); + Assert.DoesNotContain(_dbPath, recoveryCommand); + Assert.Contains(DiagnosticSanitizer.ForPath(_dbPath), recoveryCommand); Assert.Contains("--start 1 --end 1 --max-line-width 0 --json", recoveryCommand); + Assert.True(recovery["pathsRedacted"]!.GetValue()); + Assert.True(recovery["requiresLocalPathSubstitution"]!.GetValue()); } [Fact] diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index ac44d7b6e..146e307e7 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -20,7 +20,7 @@ namespace CodeIndex.Tests; public class ProgramCliTests { [ProductionRuntimeFact] - public void ExcerptRecovery_PreservesPrefixAndReplaysOptionLikeMetacharacterArgv_Issue4567() + public void ExcerptRecovery_ShowPathsPreservesPrefixAndReplaysOptionLikeMetacharacterArgv_Issue4567_Issue4860() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_recovery_argv_4567"); try @@ -49,6 +49,7 @@ public void ExcerptRecovery_PreservesPrefixAndReplaysOptionLikeMetacharacterArgv "--focus-length", "6", "--json", + "--show-paths", ]); Assert.Equal(CommandExitCodes.Success, exitCode); @@ -72,7 +73,9 @@ public void ExcerptRecovery_PreservesPrefixAndReplaysOptionLikeMetacharacterArgv Assert.Equal("0", argv[12]); Assert.Equal("--json", argv[13]); Assert.Equal(OperatingSystem.IsWindows() ? "powershell" : "posix-sh", recovery.GetProperty("command_shell").GetString()); - Assert.True(recovery.GetProperty("command_display_only").GetBoolean()); + Assert.False(recovery.GetProperty("command_display_only").GetBoolean()); + Assert.False(recovery.GetProperty("paths_redacted").GetBoolean()); + Assert.False(recovery.GetProperty("requires_local_path_substitution").GetBoolean()); Assert.False(recovery.GetProperty("command").GetString()!.StartsWith("cdidx ", StringComparison.Ordinal)); var (replayExitCode, replayStdout, replayStderr) = RunCliInSubprocess(argv.Skip(2).ToArray()); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 5311219d1..0ca51e5ee 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Diagnostics; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -651,7 +652,7 @@ public void Run({{parameters}}) { } } [Fact] - public void RunInspect_JsonBodyRecoveryCommandIncludesActiveDb_Issue3562() + public void RunInspect_JsonBodyRecoveryCommandRedactsActiveDbByDefault_Issue3562_Issue4860() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_body_recovery_db_3562"); try @@ -683,10 +684,14 @@ public void RunInspect_JsonBodyRecoveryCommandIncludesActiveDb_Issue3562() Assert.Contains("--db", recoveryCommand); Assert.Contains("file:", recoveryCommand); var expectedReadOnlyUri = DbContext.ToReadOnlyUri(dbPath); - Assert.Contains(expectedReadOnlyUri, recoveryCommand); + Assert.DoesNotContain(expectedReadOnlyUri, recoveryCommand); + Assert.DoesNotContain(projectRoot, recoveryCommand); + Assert.Contains(DiagnosticSanitizer.ForPath(dbPath), recoveryCommand); Assert.Contains("immutable=1", recoveryCommand); Assert.Contains("mode=ro", recoveryCommand); Assert.Contains("--start 2 --end 3 --max-line-width 0 --json", recoveryCommand); + Assert.True(recovery.GetProperty("paths_redacted").GetBoolean()); + Assert.True(recovery.GetProperty("requires_local_path_substitution").GetBoolean()); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index f8ff79ed9..0f424909a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Diagnostics; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -10299,15 +10300,18 @@ public void RunExcerpt_JsonClampsLongSingleLineContentAroundFocus() Assert.Equal(1, recovery.GetProperty("start_line").GetInt32()); Assert.Equal(1, recovery.GetProperty("end_line").GetInt32()); Assert.Equal( - ["cdidx", "excerpt", "dist/data.txt", "--db", Path.GetFullPath(dbPath), "--start", "1", "--end", "1", "--max-line-width", "0", "--json"], + ["cdidx", "excerpt", "dist/data.txt", "--db", DiagnosticSanitizer.ForPath(dbPath), "--start", "1", "--end", "1", "--max-line-width", "0", "--json"], recovery.GetProperty("argv").EnumerateArray().Select(argument => argument.GetString()!).ToArray()); Assert.Equal(OperatingSystem.IsWindows() ? "powershell" : "posix-sh", recovery.GetProperty("command_shell").GetString()); Assert.True(recovery.GetProperty("command_display_only").GetBoolean()); var recoveryCommand = recovery.GetProperty("command").GetString(); Assert.Contains("cdidx excerpt dist/data.txt", recoveryCommand); Assert.Contains("--db", recoveryCommand); - Assert.Contains(dbPath, recoveryCommand); + Assert.DoesNotContain(dbPath, recoveryCommand); + Assert.Contains(DiagnosticSanitizer.ForPath(dbPath), recoveryCommand); Assert.Contains("--start 1 --end 1 --max-line-width 0 --json", recoveryCommand); + Assert.True(recovery.GetProperty("paths_redacted").GetBoolean()); + Assert.True(recovery.GetProperty("requires_local_path_substitution").GetBoolean()); Assert.DoesNotContain(longLine, json.GetProperty("content").GetString()); Assert.Contains("TARGET", json.GetProperty("content").GetString()); Assert.True(json.GetProperty("content").GetString()!.Length <= 96); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index ffad6fc9f..29ed81bc5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Diagnostics; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -520,8 +521,11 @@ public void RunDefinition_JsonBodyIncludesTruncationMetadata_Issue3131() var recoveryCommand = recovery.GetProperty("command").GetString(); Assert.Contains("cdidx excerpt src/long_body.py", recoveryCommand); Assert.Contains("--db", recoveryCommand); - Assert.Contains(dbPath, recoveryCommand); + Assert.DoesNotContain(dbPath, recoveryCommand); + Assert.Contains(DiagnosticSanitizer.ForPath(dbPath), recoveryCommand); Assert.Contains("--max-line-width 0 --json", recoveryCommand); + Assert.True(recovery.GetProperty("paths_redacted").GetBoolean()); + Assert.True(recovery.GetProperty("requires_local_path_substitution").GetBoolean()); Assert.False(json.TryGetProperty("complexity", out _)); Assert.False(json.TryGetProperty("content", out _)); Assert.True(json.GetProperty("content_omitted").GetBoolean()); @@ -640,8 +644,11 @@ public void RunDefinition_JsonBodyReportsByteCapRecovery_Issue3562() var recoveryCommand = recovery.GetProperty("command").GetString(); Assert.Contains("cdidx excerpt src/huge_body.py", recoveryCommand); Assert.Contains("--db", recoveryCommand); - Assert.Contains(dbPath, recoveryCommand); + Assert.DoesNotContain(dbPath, recoveryCommand); + Assert.Contains(DiagnosticSanitizer.ForPath(dbPath), recoveryCommand); Assert.Contains("--start 2 --end 3 --max-line-width 0 --json", recoveryCommand); + Assert.True(recovery.GetProperty("paths_redacted").GetBoolean()); + Assert.True(recovery.GetProperty("requires_local_path_substitution").GetBoolean()); Assert.False(json.TryGetProperty("complexity", out _)); } finally diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index dfccf2cd7..32d07cf3d 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -610,10 +610,24 @@ public void RunStatusConfig_PrintsEffectiveConfigWithoutOpeningDb() Assert.Equal(string.Empty, stderr); using var document = ParseJsonOutput(stdout); var effective = document.RootElement.GetProperty("effective_config"); - Assert.Equal(missingDb, effective.GetProperty("db_path").GetProperty("value").GetString()); + Assert.Equal(Path.GetFileName(missingDb), effective.GetProperty("db_path").GetProperty("value").GetString()); Assert.Equal("flag", effective.GetProperty("db_path").GetProperty("source").GetString()); Assert.Equal(33, effective.GetProperty("limit").GetProperty("value").GetInt32()); Assert.Equal($"env:{QueryCommandRunner.DefaultLimitEnvironmentVariable}", effective.GetProperty("limit").GetProperty("source").GetString()); + Assert.True(document.RootElement.GetProperty("redaction").GetProperty("paths_redacted").GetBoolean()); + Assert.DoesNotContain(Path.GetTempPath(), stdout, StringComparison.Ordinal); + + var (showExitCode, showStdout, showStderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--config", "--db", missingDb, "--json", "--show-paths"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, showExitCode); + Assert.Equal(string.Empty, showStderr); + using var showDocument = ParseJsonOutput(showStdout); + Assert.Equal( + missingDb, + showDocument.RootElement.GetProperty("effective_config").GetProperty("db_path").GetProperty("value").GetString()); + Assert.False(showDocument.RootElement.GetProperty("redaction").GetProperty("paths_redacted").GetBoolean()); } [Fact] @@ -660,10 +674,23 @@ public void RunStatusConfig_ReportsConfigFileSourceForSearchDefaults() Assert.Equal("config_file", staleAfter.GetProperty("source_kind").GetString()); Assert.Equal("config.json", staleAfter.GetProperty("source_detail").GetString()); var logPath = document.RootElement.GetProperty("effective_config").GetProperty("global_tool_log_dir"); - Assert.Equal(logDir, logPath.GetProperty("value").GetString()); + Assert.Equal(Path.GetFileName(logDir), logPath.GetProperty("value").GetString()); Assert.Equal("config:config.json", logPath.GetProperty("source").GetString()); Assert.Equal("config_file", logPath.GetProperty("source_kind").GetString()); Assert.Equal("config.json", logPath.GetProperty("source_detail").GetString()); + Assert.DoesNotContain(projectRoot, stdout, StringComparison.Ordinal); + } + + [Fact] + public void RunStatus_PathDisplayOptionWithoutConfig_IsUsageError_Issue4860() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--show-paths"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stdout); + Assert.Contains("only supported with status --config", stderr, StringComparison.Ordinal); } [Fact] From 4554f82855d6de981947c4cbc7465f3a7d9c3772 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 20:05:34 +0900 Subject: [PATCH 2/5] Integrate shared path display options (#4860) --- src/CodeIndex/Cli/CliFlagSchema.cs | 3 +-- .../Cli/QueryCommandRunner.ArgumentParser.General.cs | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index fe0543aee..b095cbe2c 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -426,7 +426,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--optimize", Description = "Optimize the existing FTS5 table without scanning files", PrimaryCommands = Set("index") }, new() { Name = "--symbols-only", Description = "Build chunks and symbols while skipping reference graph extraction", PrimaryCommands = Set("index") }, new() { Name = "--dry-run", Description = "Preview without writing", PrimaryCommands = Set("index", "backfill-fold", "optimize", "vacuum") }, - new() { Name = "--show-paths", Description = "Show full database paths in maintenance diagnostics; paths are redacted by default", PrimaryCommands = Set("index", "backfill-fold", "optimize", "vacuum", "db") }, + new() { Name = "--show-paths", Description = "Show resolved local paths in maintenance diagnostics, recovery commands, or status --config output", PrimaryCommands = Set(RecoveryPathCommands.Concat(["index", "backfill-fold", "optimize", "vacuum", "db", "status"]).ToArray()) }, new() { Name = "--dry-run-path-limit", ValuePlaceholder = "", Description = "Dry run only: candidate path processing limit before truncated lower-bound estimates", PrimaryCommands = Set("index") }, new() { Name = "--no-checkpoint", Description = "Skip the automatic DB checkpoint before maintenance", PrimaryCommands = Set("backfill-fold") }, new() { Name = "--force", Description = "Bypass the per-database index lock", PrimaryCommands = Set("index") }, @@ -444,7 +444,6 @@ private static IReadOnlyList BuildAll() new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "", Description = "Watch only: changed-path queue limit before full-rescan fallback", PrimaryCommands = Set("index") }, new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "", Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, new() { Name = "--redact-paths", Description = "Redact machine-specific paths (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report"]).ToArray()) }, - new() { Name = "--show-paths", Description = "Include resolved local paths in recovery commands or status --config output", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status"]).ToArray()) }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, new() { Name = "--include-args", Description = "Include args in bundle log", PrimaryCommands = Set("report") }, new() { Name = "--log-lines", ValuePlaceholder = "", Description = "Number of log lines to include in bundle (clamped to 2000)", PrimaryCommands = Set("report") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs index 1f7dc25d3..a56813095 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs @@ -45,6 +45,7 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri break; case "--show-paths": showPaths = true; + redactPaths = false; break; case "--pretty": break; @@ -82,9 +83,7 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri break; case "--redact-paths": redactPaths = true; - break; - case "--show-paths": - redactPaths = false; + showPaths = false; break; case "--json": if (inlineValue == null) From 4db25b37b06adad7592bc01a106c622bec8249a6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 20:39:16 +0900 Subject: [PATCH 3/5] Close recovery path redaction bypasses (#4860) --- DEVELOPER_GUIDE.md | 8 +-- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4860.security.md | 5 +- .../Cli/ExcerptRecoveryCommandFormatter.cs | 12 +--- .../Cli/QueryCommandRunner.Status.cs | 6 +- .../Diagnostics/DiagnosticSanitizer.cs | 65 +++++++++++++++++++ .../ExcerptRecoveryCommandFormatterTests.cs | 45 +++++++++++++ .../QueryCommandRunnerTests.cs | 39 +++++++++++ 8 files changed, 166 insertions(+), 18 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 68b5e143a..ddedfe90c 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1688,9 +1688,9 @@ When the match line falls inside an indexed symbol range, `search --json` and MC `excerpt --json` includes `semantic_tokens`, a lightweight range list with 1-based source start/end positions, token `type`, and `modifiers`, so IDE and LLM clients can render or post-process excerpt spans without reparsing the raw `content` string. C# excerpts and LSP `textDocument/semanticTokens/full` share the same source classifier for keywords/modifiers, namespaces and types, methods and properties, parameters, variables and fields, and declaration modifiers. Excerpt classification uses indexed-source context, applies the output token budget only after filtering to visible source lines, and falls back to classifying the visible content when a bounded source scan cannot reach it; narrow or late-file excerpts therefore retain context when available without becoming empty because earlier tokens exhausted the output budget. Excerpt range mapping and LSP delta encoding only translate coordinates and do not choose semantic kinds. `semantic_token_coordinate_space` is `source`; when `--max-line-width` clamps returned content, `content_line_spans` maps each returned content line and visible content-column span back to the matching source line and source-column span, while clamp markers remain unmapped and are not emitted as semantic tokens. Excerpt rows also expose `requested_start_line`, `requested_end_line`, `effective_start_line`, `effective_end_line`, `content_truncation_reasons`, and optional `content_recovery` so clients can tell when `--max-line-width` caused `line_width_cap` and replay the omitted text. Body-bearing JSON rows use matching `body_requested_*`, `body_effective_*`, and `body_content_truncation_reasons` fields; body reasons include `body_line_cap` for snippet/body line caps and `body_byte_cap` for definition body byte caps. -`content_recovery` and `body_content_recovery` use `argv` as their primary machine-readable contract. Shared CLI JSON and MCP responses redact machine-specific absolute apphost, assembly, source, and database paths by default with the structured path sanitizer, before rendering `command`; they never regex-rewrite the rendered shell string. Default metadata reports `paths_redacted: true`, `command_display_only: true`, and `requires_local_path_substitution: true` when any argument was replaced. Root-level paths beginning with `-` retain the supported `--` end-of-options marker, and `command_shell` (`posix-sh` or `powershell`) identifies the escaping contract. CLI `definition`, `references`, `callers`, `callees`, `excerpt`, `inspect`, and `impact` accept `--redact-paths` as the explicit default and `--show-paths` as the local-only opt-in. `--show-paths` emits the resolved apphost or `dotnet` plus running assembly, source, and database arguments, sets `paths_redacted: false` and `command_display_only: false`, and produces a safely quoted command for the declared shell. MCP remains support-safe and emits the equivalent camelCase metadata. +`content_recovery` and `body_content_recovery` use `argv` as their primary machine-readable contract. Shared CLI JSON and MCP responses redact machine-specific absolute apphost, assembly, source, and database paths by default with the structured path sanitizer, before rendering `command`; they never regex-rewrite the rendered shell string. SQLite file-URI query segments are processed independently so safe controls such as `mode=ro` remain visible while path-valued or sensitive query values are sanitized. The database option is located only after the known source-argument position, so an option-like source name such as `--db` cannot bypass DB-path redaction. Default metadata reports `paths_redacted: true`, `command_display_only: true`, and `requires_local_path_substitution: true` when any argument was replaced. Root-level paths beginning with `-` retain the supported `--` end-of-options marker, and `command_shell` (`posix-sh` or `powershell`) identifies the escaping contract. CLI `definition`, `references`, `callers`, `callees`, `excerpt`, `inspect`, and `impact` accept `--redact-paths` as the explicit default and `--show-paths` as the local-only opt-in. `--show-paths` emits the resolved apphost or `dotnet` plus running assembly, source, and database arguments, sets `paths_redacted: false` and `command_display_only: false`, and produces a safely quoted command for the declared shell. MCP remains support-safe and emits the equivalent camelCase metadata. -`status --config` follows the same policy: `db_path`, `data_dir`, and `global_tool_log_dir` are redacted by default, the top-level `redaction.paths_redacted` field records the mode, and `--show-paths` is the only local opt-in for resolved values. `--redact-paths` makes the default explicit; either path-display flag is rejected by plain `status` without `--config`. +`status --config` follows the same policy: `db_path`, `data_dir`, and `global_tool_log_dir` are redacted by default, including path-valued and sensitive SQLite file-URI query segments. The top-level `redaction.paths_redacted` field records the path mode, while secrets remain redacted in both modes. `--show-paths` is the only local opt-in for resolved path values. `--redact-paths` makes the default explicit; either path-display flag is rejected by plain `status` without `--config`. `inspect` and MCP `analyze_symbol` bundle the primary definition, nearby symbols from the same file, references, callers, callees, file metadata, workspace freshness/git metadata, and graph-support metadata into one response. When those bundled graph sections actually depend on SQL-backed reads, the payload also mirrors `sql_graph_contract_ready` / `sql_graph_contract_degraded_reason` (plus the existing camelCase aliases on MCP responses); mixed-language bundles that only return C# / JS / etc. graph rows omit the SQL trust signal entirely. This is intended for symbol-oriented AI workflows that would otherwise need several back-to-back calls. Call graph sections remain language-aware: for unsupported languages, clients can now distinguish "unsupported" from "no hits" via `graphSupported` / `graphSupportReason`, and should prefer `search` instead of assuming graph data will exist. @@ -4965,9 +4965,9 @@ literal-safe な `search` query は reader 層で FTS5 sanitization 前に 1000 `excerpt --json` は 1-based の source 開始/終了位置、token `type`、`modifiers` を持つ軽量 range list の `semantic_tokens` を返すため、IDE や LLM クライアントは生の `content` 文字列を再パースせずに抜粋範囲を描画・後処理できます。C# の excerpt と LSP `textDocument/semanticTokens/full` は、keyword/modifier、namespace と type、method と property、parameter、variable と field、declaration modifier を判定する同じ source classifier を共有します。excerpt の分類は indexed source の context を利用し、出力 token budget を可視 source 行へ絞った後に適用します。bounded source scan が可視範囲まで到達できない場合は可視 content の分類へ fallback するため、狭い範囲では利用可能な context を維持し、file 後半の excerpt が手前の token に出力 budget を消費されて空になることも防ぎます。excerpt の range mapping と LSP の delta encoding は座標変換だけを担当し、semantic kind を選びません。`semantic_token_coordinate_space` は `source` です。`--max-line-width` で返却内容がクランプされた場合、`content_line_spans` は返却 content 行と可視 content column span を、対応する source 行と source column span に対応付けます。clamp marker は未対応領域として扱い、semantic token には含めません。excerpt row は `requested_start_line`、`requested_end_line`、`effective_start_line`、`effective_end_line`、`content_truncation_reasons`、任意の `content_recovery` も返すため、`--max-line-width` による `line_width_cap` を検出して省略部分を再取得できます。body を持つ JSON row は対応する `body_requested_*`、`body_effective_*`、`body_content_truncation_reasons` も返します。body reason には snippet/body 行数上限の `body_line_cap` と definition body byte 上限の `body_byte_cap` があります。 -`content_recovery` と `body_content_recovery` では `argv` が一次的な機械可読契約です。共有用の CLI JSON と MCP response は既定で、機械固有の apphost、assembly、source、database の絶対パスを構造化 path sanitizer で伏せてから `command` を生成し、render 済み shell 文字列を regex で置換しません。既定の metadata は `paths_redacted: true`、`command_display_only: true` を返し、いずれかの引数を置換した場合は `requires_local_path_substitution: true` も返します。先頭が `-` の root-level path には対応済みの `--` end-of-options marker を維持し、`command_shell`(`posix-sh` または `powershell`)で escape 契約を示します。CLI の `definition`、`references`、`callers`、`callees`、`excerpt`、`inspect`、`impact` は、既定を明示する `--redact-paths` と、ローカル用途だけの opt-in である `--show-paths` を受け付けます。`--show-paths` は解決済みの apphost、または `dotnet` と実行中 assembly、source、database の各引数を出力し、`paths_redacted: false` と `command_display_only: false` を設定し、宣言した shell 向けに安全に quote した command を生成します。MCP は常にサポート共有向けの安全な既定を使い、同等の camelCase metadata を返します。 +`content_recovery` と `body_content_recovery` では `argv` が一次的な機械可読契約です。共有用の CLI JSON と MCP response は既定で、機械固有の apphost、assembly、source、database の絶対パスを構造化 path sanitizer で伏せてから `command` を生成し、render 済み shell 文字列を regex で置換しません。SQLite file URI の query segment は個別に処理するため、`mode=ro` などの安全な control は維持しつつ、path 値や機密値を持つ query は sanitization されます。database option は既知の source 引数位置より後だけで探索するため、`--db` のように option と紛らわしい source 名でも DB path redaction を迂回できません。既定の metadata は `paths_redacted: true`、`command_display_only: true` を返し、いずれかの引数を置換した場合は `requires_local_path_substitution: true` も返します。先頭が `-` の root-level path には対応済みの `--` end-of-options marker を維持し、`command_shell`(`posix-sh` または `powershell`)で escape 契約を示します。CLI の `definition`、`references`、`callers`、`callees`、`excerpt`、`inspect`、`impact` は、既定を明示する `--redact-paths` と、ローカル用途だけの opt-in である `--show-paths` を受け付けます。`--show-paths` は解決済みの apphost、または `dotnet` と実行中 assembly、source、database の各引数を出力し、`paths_redacted: false` と `command_display_only: false` を設定し、宣言した shell 向けに安全に quote した command を生成します。MCP は常にサポート共有向けの安全な既定を使い、同等の camelCase metadata を返します。 -`status --config` も同じ policy に従います。`db_path`、`data_dir`、`global_tool_log_dir` は既定で伏せられ、top-level の `redaction.paths_redacted` が mode を記録します。解決済み値を出すローカル opt-in は `--show-paths` だけです。`--redact-paths` は既定を明示し、どちらの path 表示 flag も `--config` のない通常の `status` では拒否されます。 +`status --config` も同じ policy に従います。`db_path`、`data_dir`、`global_tool_log_dir` は既定で伏せられ、SQLite file URI query 内の path 値と機密値も対象です。top-level の `redaction.paths_redacted` が path mode を記録し、secret はどちらの mode でも伏せられます。解決済み path 値を出すローカル opt-in は `--show-paths` だけです。`--redact-paths` は既定を明示し、どちらの path 表示 flag も `--config` のない通常の `status` では拒否されます。 `inspect` と MCP の `analyze_symbol` は、主定義、同一ファイル内の近傍シンボル、参照、caller、callee、ファイルメタデータ、さらにワークスペース鮮度/git メタデータと graph 対応メタデータを1レスポンスにまとめます。bundle 内の graph 節が実際に SQL ベースの read に依存する場合だけ、`sql_graph_contract_ready` / `sql_graph_contract_degraded_reason`(MCP では既存の camelCase alias も)も返します。mixed-language bundle で C# / JS などの graph row しか返っていない場合は SQL trust signal を出さないため、無関係なクエリが stale SQL state に引きずられません。複数の連続クエリを避けたい AI ワークフロー向けです。call graph 系の節は言語差分を考慮しており、未対応言語では `graphSupported` / `graphSupportReason` によって「未対応」と「ヒットなし」を区別できます。その場合は `search` を優先して使う前提です。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 7f0421c2e..e4a7cb49e 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -74,7 +74,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Pure or mostly pure behavior tests with in-memory inputs. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. - Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, and file-URI database options. Default-output assertions must reject the fixture's full absolute paths. Pair this with `status --config` coverage for default DB/data/log path redaction and explicit `--show-paths`. + Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths or sensitive values. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`. Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay. - `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs` Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. @@ -982,7 +982,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 - recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、file-URI database option を含め、既定出力に fixture の完全な絶対パスが残らないことを assertion にします。`status --config` の DB/data/log path の既定 redaction と明示的 `--show-paths` も対で検証してください。 + recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path や機密値を含む file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。 console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。 - `SymbolExtractor*Tests.cs` と `ReferenceExtractor*Tests.cs` extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 diff --git a/changelog.d/unreleased/4860.security.md b/changelog.d/unreleased/4860.security.md index e7ddecda7..7dfe1c096 100644 --- a/changelog.d/unreleased/4860.security.md +++ b/changelog.d/unreleased/4860.security.md @@ -5,6 +5,7 @@ issues: affected: - src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs - src/CodeIndex/Models/QueryResults.cs - DEVELOPER_GUIDE.md - TESTING_GUIDE.md @@ -12,8 +13,8 @@ affected: ## English -- **Recovery commands and `status --config` now redact machine-specific paths by default (#4860)** — CLI and MCP recovery metadata now sanitizes absolute binary, assembly, source, and database arguments before rendering support-shareable commands, while `status --config` applies the same default to DB, data, and log paths. `paths_redacted`, substitution, and redaction metadata make the result explicit; local CLI users can opt into safely quoted resolved values with `--show-paths`. This completes the residual support-output hardening identified after #4313. +- **Recovery commands and `status --config` now redact machine-specific paths by default (#4860)** — CLI and MCP recovery metadata now sanitizes absolute binary, assembly, source, and database arguments—including path-valued or sensitive file-URI query segments—before rendering support-shareable commands, while `status --config` applies the same default to DB, data, log, and URI-query paths and always masks secrets. Option-like source names cannot be mistaken for the DB flag. `paths_redacted`, substitution, and redaction metadata make the result explicit; local CLI users can opt into safely quoted resolved path values with `--show-paths`. This completes the residual support-output hardening identified after #4313. ## 日本語 -- **復旧コマンドと `status --config` が機械固有のパスを既定で伏せるようになりました (#4860)** — CLI/MCP の復旧 metadata は、サポート共有用 command を生成する前に binary、assembly、source、database の絶対パスを sanitization し、`status --config` も DB、data、log path に同じ既定を適用します。`paths_redacted`、置換要否、redaction metadata で状態を明示し、ローカル CLI では `--show-paths` により安全に quote された解決済み値を opt-in できます。これは #4313 後に確認された残余の support-output hardening を完了する変更です。 +- **復旧コマンドと `status --config` が機械固有のパスを既定で伏せるようになりました (#4860)** — CLI/MCP の復旧 metadata は、path 値や機密値を持つ file URI query segment を含む binary、assembly、source、database の絶対パスを、サポート共有用 command の生成前に sanitization します。`status --config` も DB、data、log、URI query の path に同じ既定を適用し、secret は常に伏せます。option と紛らわしい source 名を DB flag と取り違えることもありません。`paths_redacted`、置換要否、redaction metadata で状態を明示し、ローカル CLI では `--show-paths` により安全に quote された解決済み path 値を opt-in できます。これは #4313 後に確認された残余の support-output hardening を完了する変更です。 diff --git a/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs b/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs index 5f15daa2e..79f1f1612 100644 --- a/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs +++ b/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs @@ -131,7 +131,7 @@ private static List BuildSupportSafeArgv( if (sourcePathIndex < output.Count) output[sourcePathIndex] = RedactAbsolutePathArgument(output[sourcePathIndex]); - var dbFlagIndex = output.IndexOf("--db"); + var dbFlagIndex = output.FindIndex(sourcePathIndex + 1, argument => argument == "--db"); if (dbFlagIndex >= 0 && dbFlagIndex + 1 < output.Count) output[dbFlagIndex + 1] = RedactAbsolutePathArgument(output[dbFlagIndex + 1]); @@ -141,16 +141,10 @@ private static List BuildSupportSafeArgv( private static string RedactAbsolutePathArgument(string value) { if (value.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var queryIndex = value.IndexOf('?'); - var pathEnd = queryIndex >= 0 ? queryIndex : value.Length; - var path = value["file:".Length..pathEnd]; - var query = queryIndex >= 0 ? value[queryIndex..] : string.Empty; - return "file:" + DiagnosticSanitizer.ForPath(path) + query; - } + return DiagnosticSanitizer.ForSupportSafePath(value); return IsAbsolutePathArgument(value) - ? DiagnosticSanitizer.ForPath(value) + ? DiagnosticSanitizer.ForSupportSafePath(value) : value; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 971986224..05bda913a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -1051,7 +1051,11 @@ JsonObject Entry(T? value, string source) } string? PathValue(string? value) - => redactPaths && value != null ? DiagnosticSanitizer.ForPath(value) : value; + => value == null + ? null + : redactPaths + ? DiagnosticSanitizer.ForSupportSafePath(value) + : DiagnosticRedactor.RedactSensitiveText(value); var staleAfterEnvValue = CdidxEnvironment.GetEnvironmentVariable(StaleAfterEnvironmentVariable); diff --git a/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs b/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs index f56444976..b92d78c42 100644 --- a/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs +++ b/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs @@ -26,6 +26,29 @@ public static string ForPath(string? path) return Truncate(string.IsNullOrWhiteSpace(fileName) ? "" : fileName); } + public static string ForSupportSafePath(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var trimmed = value.Trim(); + if (!trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return ForPath(DiagnosticRedactor.RedactSensitiveText(trimmed)); + + var queryIndex = trimmed.IndexOf('?'); + var pathEnd = queryIndex >= 0 ? queryIndex : trimmed.Length; + var path = trimmed["file:".Length..pathEnd]; + var redactedPath = ForPath(DiagnosticRedactor.RedactSensitiveText(path)); + if (queryIndex < 0) + return "file:" + redactedPath; + + var query = trimmed[(queryIndex + 1)..]; + var redactedSegments = query + .Split('&', StringSplitOptions.None) + .Select(RedactFileUriQuerySegment); + return "file:" + redactedPath + "?" + string.Join('&', redactedSegments); + } + public static string? ForOptionalLabel(string? value) => string.IsNullOrWhiteSpace(value) ? value : ForMessage(value); @@ -76,6 +99,48 @@ private static string TryGetFullPath(string path) } } + private static string RedactFileUriQuerySegment(string segment) + { + var secretsRedacted = DiagnosticRedactor.RedactSensitiveText(segment); + if (!string.Equals(secretsRedacted, segment, StringComparison.Ordinal)) + return secretsRedacted; + + var equalsIndex = segment.IndexOf('='); + if (equalsIndex < 0) + return DiagnosticRedactor.RedactSensitiveText(segment, "", redactPaths: true); + + var keyPrefix = segment[..(equalsIndex + 1)]; + var value = segment[(equalsIndex + 1)..]; + var decodedValue = TryUnescapeDataString(value); + var redactedValue = IsAbsolutePath(decodedValue) + ? ForPath(decodedValue) + : DiagnosticRedactor.RedactSensitiveText(decodedValue, "", redactPaths: true); + return string.Equals(redactedValue, decodedValue, StringComparison.Ordinal) + ? segment + : keyPrefix + redactedValue; + } + + private static string TryUnescapeDataString(string value) + { + try + { + return Uri.UnescapeDataString(value); + } + catch (UriFormatException) + { + return value; + } + } + + private static bool IsAbsolutePath(string value) + => Path.IsPathRooted(value) + || value.StartsWith(@"\\", StringComparison.Ordinal) + || value.StartsWith("//", StringComparison.Ordinal) + || (value.Length >= 3 + && IsAsciiLetter(value[0]) + && value[1] == ':' + && IsPathSeparator(value[2])); + private static string NormalizeSeparators(string value) => value.Replace('\\', '/'); diff --git a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs index 650b6344f..54d2e5f56 100644 --- a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs +++ b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs @@ -126,6 +126,51 @@ public void ApplyDbPath_DefaultRedaction_RemovesMachineSpecificAbsolutePaths_Iss Assert.DoesNotContain("workstation", recovery.Command, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void ApplyDbPath_DefaultRedaction_DoesNotTreatOptionLikeSourceAsDbFlag_Issue4860() + { + const string path = "--db"; + const string dbPath = "/tmp/private-workspace/codeindex.db"; + var recovery = FileExcerptResult.CreateRecoveryHint(path, 2, 3); + + ExcerptRecoveryCommandFormatter.ApplyDbPath( + recovery, + path, + dbPath, + ["/opt/cdidx"], + RecoveryCommandShell.PosixSh); + + Assert.Equal( + ["cdidx", "excerpt", "--", "--db", "--db", "codeindex.db"], + recovery.Argv.Take(6)); + Assert.DoesNotContain("/tmp/private-workspace", recovery.Command, StringComparison.Ordinal); + Assert.True(recovery.PathsRedacted); + Assert.True(recovery.RequiresLocalPathSubstitution); + } + + [Fact] + public void ApplyDbPath_DefaultRedaction_SanitizesFileUriQueryPathsAndSecrets_Issue4860() + { + const string dbPath = + "file:/tmp/private-workspace/codeindex.db?mode=ro&aux=/Users/alice/private-cache&token=visible4860&encoded=%2FUsers%2Falice%2Fsecret"; + var recovery = FileExcerptResult.CreateRecoveryHint("src/app.cs", 2, 3); + + ExcerptRecoveryCommandFormatter.ApplyDbPath( + recovery, + "src/app.cs", + dbPath, + ["/opt/cdidx"], + RecoveryCommandShell.PosixSh); + + Assert.Contains( + "file:codeindex.db?mode=ro&aux=private-cache&token=&encoded=secret", + recovery.Argv); + Assert.DoesNotContain("private-workspace", recovery.Command, StringComparison.Ordinal); + Assert.DoesNotContain("Users", recovery.Command, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("alice", recovery.Command, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("visible4860", recovery.Command, StringComparison.Ordinal); + } + [Fact] public void ResolveInvocationPrefix_PreservesDotnetAssemblyOrNativeApphost_Issue4567() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 4a5be5e2e..97ef84df3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -630,6 +630,45 @@ public void RunStatusConfig_PrintsEffectiveConfigWithoutOpeningDb() Assert.False(showDocument.RootElement.GetProperty("redaction").GetProperty("paths_redacted").GetBoolean()); } + [Fact] + public void RunStatusConfig_FileUriRedactsQueryPathsAndSecrets_Issue4860() + { + var missingDb = Path.Combine(Path.GetTempPath(), $"cdidx_missing_{Guid.NewGuid():N}.db"); + var dbUri = + $"file:{missingDb}?mode=ro&aux=/Users/alice/private-cache&token=visible4860"; + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--config", "--db", dbUri, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var dbPath = document.RootElement + .GetProperty("effective_config") + .GetProperty("db_path") + .GetProperty("value") + .GetString(); + Assert.Equal( + $"file:{Path.GetFileName(missingDb)}?mode=ro&aux=private-cache&token=", + dbPath); + Assert.DoesNotContain(Path.GetTempPath(), stdout, StringComparison.Ordinal); + Assert.DoesNotContain("Users", stdout, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("alice", stdout, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("visible4860", stdout, StringComparison.Ordinal); + + var (showExitCode, showStdout, showStderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--config", "--db", dbUri, "--json", "--show-paths"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, showExitCode); + Assert.Equal(string.Empty, showStderr); + Assert.Contains(missingDb, showStdout, StringComparison.Ordinal); + Assert.Contains("/Users/alice/private-cache", showStdout, StringComparison.Ordinal); + Assert.DoesNotContain("visible4860", showStdout, StringComparison.Ordinal); + Assert.Contains("token=\\u003Credacted\\u003E", showStdout, StringComparison.Ordinal); + } + [Fact] public void RunStatusConfig_ReportsConfigFileSourceForSearchDefaults() { From e98a8819c1f5927b92c80bfd131cfb13153a3598 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 21:09:50 +0900 Subject: [PATCH 4/5] Harden file URI query redaction (#4860) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4860.security.md | 4 +- .../Cli/QueryCommandRunner.Status.cs | 2 +- .../Diagnostics/DiagnosticSanitizer.cs | 69 ++++++++++++++----- .../ExcerptRecoveryCommandFormatterTests.cs | 5 +- .../QueryCommandRunnerTests.cs | 6 +- 6 files changed, 63 insertions(+), 27 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e4a7cb49e..4f4bd9715 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -74,7 +74,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Pure or mostly pure behavior tests with in-memory inputs. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. - Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths or sensitive values. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`. + Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths, percent-encoded sensitive keys, or path values with embedded sensitive assignments. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`. Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay. - `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs` Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. @@ -982,7 +982,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 - recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path や機密値を含む file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。 + recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path、percent-encoded な機密 key、機密 assignment を内包する path 値を持つ file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。 console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。 - `SymbolExtractor*Tests.cs` と `ReferenceExtractor*Tests.cs` extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 diff --git a/changelog.d/unreleased/4860.security.md b/changelog.d/unreleased/4860.security.md index 7dfe1c096..6e32bd6b8 100644 --- a/changelog.d/unreleased/4860.security.md +++ b/changelog.d/unreleased/4860.security.md @@ -13,8 +13,8 @@ affected: ## English -- **Recovery commands and `status --config` now redact machine-specific paths by default (#4860)** — CLI and MCP recovery metadata now sanitizes absolute binary, assembly, source, and database arguments—including path-valued or sensitive file-URI query segments—before rendering support-shareable commands, while `status --config` applies the same default to DB, data, log, and URI-query paths and always masks secrets. Option-like source names cannot be mistaken for the DB flag. `paths_redacted`, substitution, and redaction metadata make the result explicit; local CLI users can opt into safely quoted resolved path values with `--show-paths`. This completes the residual support-output hardening identified after #4313. +- **Recovery commands and `status --config` now redact machine-specific paths by default (#4860)** — CLI and MCP recovery metadata now sanitizes absolute binary, assembly, source, and database arguments—including path-valued or sensitive file-URI query segments—before rendering support-shareable commands, while `status --config` applies the same default to DB, data, log, and URI-query paths and always masks secrets. Percent-encoded sensitive keys and path values containing sensitive assignments are sanitized without exposing either component. Option-like source names cannot be mistaken for the DB flag. `paths_redacted`, substitution, and redaction metadata make the result explicit; local CLI users can opt into safely quoted resolved path values with `--show-paths`. This completes the residual support-output hardening identified after #4313. ## 日本語 -- **復旧コマンドと `status --config` が機械固有のパスを既定で伏せるようになりました (#4860)** — CLI/MCP の復旧 metadata は、path 値や機密値を持つ file URI query segment を含む binary、assembly、source、database の絶対パスを、サポート共有用 command の生成前に sanitization します。`status --config` も DB、data、log、URI query の path に同じ既定を適用し、secret は常に伏せます。option と紛らわしい source 名を DB flag と取り違えることもありません。`paths_redacted`、置換要否、redaction metadata で状態を明示し、ローカル CLI では `--show-paths` により安全に quote された解決済み path 値を opt-in できます。これは #4313 後に確認された残余の support-output hardening を完了する変更です。 +- **復旧コマンドと `status --config` が機械固有のパスを既定で伏せるようになりました (#4860)** — CLI/MCP の復旧 metadata は、path 値や機密値を持つ file URI query segment を含む binary、assembly、source、database の絶対パスを、サポート共有用 command の生成前に sanitization します。`status --config` も DB、data、log、URI query の path に同じ既定を適用し、secret は常に伏せます。percent-encoded な機密 key と、機密 assignment を含む path 値も、どちらの成分も露出させず sanitization します。option と紛らわしい source 名を DB flag と取り違えることもありません。`paths_redacted`、置換要否、redaction metadata で状態を明示し、ローカル CLI では `--show-paths` により安全に quote された解決済み path 値を opt-in できます。これは #4313 後に確認された残余の support-output hardening を完了する変更です。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 05bda913a..85a883e9a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -1055,7 +1055,7 @@ JsonObject Entry(T? value, string source) ? null : redactPaths ? DiagnosticSanitizer.ForSupportSafePath(value) - : DiagnosticRedactor.RedactSensitiveText(value); + : DiagnosticSanitizer.ForPathWithSecretsRedacted(value); var staleAfterEnvValue = CdidxEnvironment.GetEnvironmentVariable(StaleAfterEnvironmentVariable); diff --git a/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs b/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs index b92d78c42..d7eb2e07c 100644 --- a/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs +++ b/src/CodeIndex/Diagnostics/DiagnosticSanitizer.cs @@ -27,25 +27,35 @@ public static string ForPath(string? path) } public static string ForSupportSafePath(string? value) + => ForPathValue(value, redactPaths: true); + + public static string ForPathWithSecretsRedacted(string? value) + => ForPathValue(value, redactPaths: false); + + private static string ForPathValue(string? value, bool redactPaths) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; var trimmed = value.Trim(); if (!trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - return ForPath(DiagnosticRedactor.RedactSensitiveText(trimmed)); + { + var secretsRedacted = DiagnosticRedactor.RedactSensitiveText(trimmed); + return redactPaths ? ForPath(secretsRedacted) : secretsRedacted; + } var queryIndex = trimmed.IndexOf('?'); var pathEnd = queryIndex >= 0 ? queryIndex : trimmed.Length; var path = trimmed["file:".Length..pathEnd]; - var redactedPath = ForPath(DiagnosticRedactor.RedactSensitiveText(path)); + var secretsRedactedPath = DiagnosticRedactor.RedactSensitiveText(path); + var redactedPath = redactPaths ? ForPath(secretsRedactedPath) : secretsRedactedPath; if (queryIndex < 0) return "file:" + redactedPath; var query = trimmed[(queryIndex + 1)..]; var redactedSegments = query .Split('&', StringSplitOptions.None) - .Select(RedactFileUriQuerySegment); + .Select(segment => RedactFileUriQuerySegment(segment, redactPaths)); return "file:" + redactedPath + "?" + string.Join('&', redactedSegments); } @@ -99,25 +109,43 @@ private static string TryGetFullPath(string path) } } - private static string RedactFileUriQuerySegment(string segment) + private static string RedactFileUriQuerySegment(string segment, bool redactPaths) { - var secretsRedacted = DiagnosticRedactor.RedactSensitiveText(segment); - if (!string.Equals(secretsRedacted, segment, StringComparison.Ordinal)) - return secretsRedacted; - var equalsIndex = segment.IndexOf('='); if (equalsIndex < 0) - return DiagnosticRedactor.RedactSensitiveText(segment, "", redactPaths: true); - - var keyPrefix = segment[..(equalsIndex + 1)]; - var value = segment[(equalsIndex + 1)..]; - var decodedValue = TryUnescapeDataString(value); - var redactedValue = IsAbsolutePath(decodedValue) - ? ForPath(decodedValue) - : DiagnosticRedactor.RedactSensitiveText(decodedValue, "", redactPaths: true); - return string.Equals(redactedValue, decodedValue, StringComparison.Ordinal) + { + var decodedSegment = TryUnescapeDataString(segment); + var redactedSegment = RedactFileUriQueryComponent(decodedSegment, redactPaths); + return !redactPaths && string.Equals(redactedSegment, decodedSegment, StringComparison.Ordinal) + ? segment + : EscapeFileUriQueryComponent(redactedSegment); + } + + var rawKey = segment[..equalsIndex]; + var rawValue = segment[(equalsIndex + 1)..]; + var decodedKey = TryUnescapeDataString(rawKey); + var decodedValue = TryUnescapeDataString(rawValue); + var redactedKey = RedactFileUriQueryComponent(decodedKey, redactPaths); + var redactedValue = DiagnosticRedactor.IsSensitiveName(decodedKey) + ? "" + : RedactFileUriQueryComponent(decodedValue, redactPaths); + var changed = !string.Equals(redactedKey, decodedKey, StringComparison.Ordinal) + || !string.Equals(redactedValue, decodedValue, StringComparison.Ordinal); + return !redactPaths && !changed ? segment - : keyPrefix + redactedValue; + : EscapeFileUriQueryComponent(redactedKey) + "=" + EscapeFileUriQueryComponent(redactedValue); + } + + private static string RedactFileUriQueryComponent(string value, bool redactPaths) + { + var secretsRedacted = DiagnosticRedactor.RedactSensitiveText(value); + if (!redactPaths) + return secretsRedacted; + if (secretsRedacted.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return ForSupportSafePath(secretsRedacted); + if (IsAbsolutePath(secretsRedacted)) + return ForPath(secretsRedacted); + return DiagnosticRedactor.RedactSensitiveText(secretsRedacted, "", redactPaths: true); } private static string TryUnescapeDataString(string value) @@ -132,6 +160,11 @@ private static string TryUnescapeDataString(string value) } } + private static string EscapeFileUriQueryComponent(string value) + => Uri.EscapeDataString(value) + .Replace("%3Credacted%3E", "", StringComparison.OrdinalIgnoreCase) + .Replace("%3Cpath%3E", "", StringComparison.OrdinalIgnoreCase); + private static bool IsAbsolutePath(string value) => Path.IsPathRooted(value) || value.StartsWith(@"\\", StringComparison.Ordinal) diff --git a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs index 54d2e5f56..9728b3c8a 100644 --- a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs +++ b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs @@ -152,7 +152,7 @@ public void ApplyDbPath_DefaultRedaction_DoesNotTreatOptionLikeSourceAsDbFlag_Is public void ApplyDbPath_DefaultRedaction_SanitizesFileUriQueryPathsAndSecrets_Issue4860() { const string dbPath = - "file:/tmp/private-workspace/codeindex.db?mode=ro&aux=/Users/alice/private-cache&token=visible4860&encoded=%2FUsers%2Falice%2Fsecret"; + "file:/tmp/private-workspace/codeindex.db?mode=ro&aux=/Users/alice/private-cache&aux2=/Users/alice/cache-token=visible4860&%74oken=encoded4860&encoded=%2FUsers%2Falice%2Fsecret"; var recovery = FileExcerptResult.CreateRecoveryHint("src/app.cs", 2, 3); ExcerptRecoveryCommandFormatter.ApplyDbPath( @@ -163,12 +163,13 @@ public void ApplyDbPath_DefaultRedaction_SanitizesFileUriQueryPathsAndSecrets_Is RecoveryCommandShell.PosixSh); Assert.Contains( - "file:codeindex.db?mode=ro&aux=private-cache&token=&encoded=secret", + "file:codeindex.db?mode=ro&aux=private-cache&aux2=cache-token%3D&token=&encoded=secret", recovery.Argv); Assert.DoesNotContain("private-workspace", recovery.Command, StringComparison.Ordinal); Assert.DoesNotContain("Users", recovery.Command, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("alice", recovery.Command, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("visible4860", recovery.Command, StringComparison.Ordinal); + Assert.DoesNotContain("encoded4860", recovery.Command, StringComparison.Ordinal); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 97ef84df3..34ba619e1 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -635,7 +635,7 @@ public void RunStatusConfig_FileUriRedactsQueryPathsAndSecrets_Issue4860() { var missingDb = Path.Combine(Path.GetTempPath(), $"cdidx_missing_{Guid.NewGuid():N}.db"); var dbUri = - $"file:{missingDb}?mode=ro&aux=/Users/alice/private-cache&token=visible4860"; + $"file:{missingDb}?mode=ro&aux=/Users/alice/private-cache&aux2=/Users/alice/cache-token=visible4860&%74oken=encoded4860"; var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( ["--config", "--db", dbUri, "--json"], @@ -650,12 +650,13 @@ public void RunStatusConfig_FileUriRedactsQueryPathsAndSecrets_Issue4860() .GetProperty("value") .GetString(); Assert.Equal( - $"file:{Path.GetFileName(missingDb)}?mode=ro&aux=private-cache&token=", + $"file:{Path.GetFileName(missingDb)}?mode=ro&aux=private-cache&aux2=cache-token%3D&token=", dbPath); Assert.DoesNotContain(Path.GetTempPath(), stdout, StringComparison.Ordinal); Assert.DoesNotContain("Users", stdout, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("alice", stdout, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("visible4860", stdout, StringComparison.Ordinal); + Assert.DoesNotContain("encoded4860", stdout, StringComparison.Ordinal); var (showExitCode, showStdout, showStderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( ["--config", "--db", dbUri, "--json", "--show-paths"], @@ -666,6 +667,7 @@ public void RunStatusConfig_FileUriRedactsQueryPathsAndSecrets_Issue4860() Assert.Contains(missingDb, showStdout, StringComparison.Ordinal); Assert.Contains("/Users/alice/private-cache", showStdout, StringComparison.Ordinal); Assert.DoesNotContain("visible4860", showStdout, StringComparison.Ordinal); + Assert.DoesNotContain("encoded4860", showStdout, StringComparison.Ordinal); Assert.Contains("token=\\u003Credacted\\u003E", showStdout, StringComparison.Ordinal); } From b39353ee756e4c56541f81354cb83ce470333c0a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 02:27:41 +0900 Subject: [PATCH 5/5] Make status URI test JSON-safe on Windows (#4860) --- .../QueryCommandRunnerTests.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 34ba619e1..f144a6add 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -664,11 +664,22 @@ public void RunStatusConfig_FileUriRedactsQueryPathsAndSecrets_Issue4860() Assert.Equal(CommandExitCodes.Success, showExitCode); Assert.Equal(string.Empty, showStderr); - Assert.Contains(missingDb, showStdout, StringComparison.Ordinal); - Assert.Contains("/Users/alice/private-cache", showStdout, StringComparison.Ordinal); - Assert.DoesNotContain("visible4860", showStdout, StringComparison.Ordinal); - Assert.DoesNotContain("encoded4860", showStdout, StringComparison.Ordinal); - Assert.Contains("token=\\u003Credacted\\u003E", showStdout, StringComparison.Ordinal); + using var showDocument = ParseJsonOutput(showStdout); + var shownDbPath = showDocument.RootElement + .GetProperty("effective_config") + .GetProperty("db_path") + .GetProperty("value") + .GetString(); + Assert.NotNull(shownDbPath); + Assert.Contains(missingDb, shownDbPath, StringComparison.Ordinal); + Assert.Contains("/Users/alice/private-cache", shownDbPath, StringComparison.Ordinal); + Assert.Contains( + "aux2=%2FUsers%2Falice%2Fcache-token%3D", + shownDbPath, + StringComparison.Ordinal); + Assert.EndsWith("&token=", shownDbPath, StringComparison.Ordinal); + Assert.DoesNotContain("visible4860", shownDbPath, StringComparison.Ordinal); + Assert.DoesNotContain("encoded4860", shownDbPath, StringComparison.Ordinal); } [Fact]