diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 157e1f5bb..5a44d4019 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -143,7 +143,7 @@ CI watching must be bounded. Do not loop indefinitely. ## Status Contract -- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `graph_data_current`, `index_complete`, `index_incomplete_reasons`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `workspace_verified_head_sha`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`, `head_freshness`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `unknown_extension_group_count`, `unknown_extension_groups_truncated`, `unknown_extension_group_limit`, `unknown_extension_group_omitted_count`, `unknown_extension_guidance`, `extractors`, `git_executable`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, WAL checkpoint diagnostics (`read_only_fallback`, `wal_checkpoint_attempted`, `wal_checkpoint_succeeded`, `wal_checkpoint_skipped_reason`, `wal_checkpoint_failure_reason`, `wal_checkpoint_busy`, `wal_checkpoint_log_page_count`, `wal_checkpoint_checkpointed_page_count`, `wal_checkpoint_remaining_page_count`, `read_only_immutable_fallback`, `wal_stale_snapshot_risk`, `wal_stale_snapshot_reason`), `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_failed_or_partial_index_run.progress_persisted`, `last_failed_or_partial_index_run.recovery_hint`, `last_failed_or_partial_index_run.file_errors`, `status_metadata_diagnostics`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. +- `status --json` and related JSON/MCP payloads currently expose the trust fields documented in `README.md` and `DEVELOPER_GUIDE.md`, including `fold_ready`, `fold_ready_reason`, `graph_table_available`, `graph_data_current`, `index_complete`, `index_incomplete_reasons`, `issues_table_available`, `file_issues_data_current`, `migration_in_progress`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `language_readiness`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `workspace_verified_head_sha`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`, `head_freshness`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `unknown_extension_group_count`, `unknown_extension_groups_truncated`, `unknown_extension_group_limit`, `unknown_extension_group_omitted_count`, `unknown_extension_guidance`, `extractors`, `git_executable`, `github_cli_executable`, `path_case_sensitive`, `data_dir`, `data_dir_source`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings` (`journal_mode`, `synchronous`, `wal_autocheckpoint`, `busy_timeout_ms`, `page_count`, `freelist_count`, `page_size`, `auto_vacuum`), `prepared_command_cache` (`count`, `capacity`, `hit_count`, `miss_count`, `eviction_count`), `maintenance_guidance`, WAL checkpoint diagnostics (`read_only_fallback`, `wal_checkpoint_attempted`, `wal_checkpoint_succeeded`, `wal_checkpoint_skipped_reason`, `wal_checkpoint_failure_reason`, `wal_checkpoint_busy`, `wal_checkpoint_log_page_count`, `wal_checkpoint_checkpointed_page_count`, `wal_checkpoint_remaining_page_count`, `read_only_immutable_fallback`, `wal_stale_snapshot_risk`, `wal_stale_snapshot_reason`), `symbol_kinds`, `symbols_by_language`, status kind cap metadata (`symbol_kind_limit`, `symbol_kind_name_limit`, `symbol_kind_total_count`, `symbol_kind_omitted_count`, `symbol_kind_names_truncated`, `symbols_by_language_kind_total_counts`, `symbols_by_language_kind_omitted_counts`, `symbols_by_language_kind_names_truncated`), `process`, `last_index_run`, `last_failed_or_partial_index_run`, `last_failed_or_partial_index_run.progress_persisted`, `last_failed_or_partial_index_run.recovery_hint`, `last_failed_or_partial_index_run.file_errors`, `status_metadata_diagnostics`, `last_workspace_freshened_at`, `hooks`, `hook_diagnostics`, `trust_overrides`, MCP-only `mcp_session`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`, and the `status --check`-only `stale_after_seconds` / `index_age_seconds` threshold audit fields and `repair_commands`. - Persisted status subdocuments for reference-extraction cap hits, rebuild reclaim, and failed-file diagnostics use a 512 KiB UTF-8 input limit and maximum JSON depth 16 before semantic validation. Preserve the shared 50-item writer/reader limits, nested reason and string budgets documented in `README.md` and `DEVELOPER_GUIDE.md`, omission of only the rejected subdocument, and `status_metadata_diagnostics` reasons `raw_size_exceeded`, `invalid_json`, and `semantic_validation_failed`. Human first-failure and recovery-hint fields must pass through the bounded control-flattening formatter while accepted structured JSON remains unchanged. - `status --check` repair actions are structured by `name`, `action`, `args`, `mutation_class`, `safety_class`, and `safety_notes`. Preserve compatibility `reason` as the first trigger and ordered `reasons` as the complete trigger set. Deduplicate only exact structured identities; different targets, options, actions, mutation classes, or safety semantics must remain distinct. JSON and human output must use the same deduplicated order. Human output must preserve platform-aware shell quoting, visibly escape control characters to keep each repair action on one diagnostic line, and leave structured JSON `args` unchanged. - `maintenance_guidance.fts_optimization` is the shared, read-only recommendation contract for status, explain, optimize preview, and optimize execution. Keep `recommended`, `action`, `reason`, `threshold_writes`, `observed_writes`, and `state` synchronized; stale or unavailable snapshots must not recommend mutation. @@ -163,7 +163,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces unsupported-language scan coverage via `unknown_extension_file_count`, stamped by successful full-repository index runs (`cdidx index ` and MCP `index_project`) as the number of readable, text-like, non-indexed files whose filename or extension does not map to a known language. Extensionless files without a recognized shebang or a token-delimited first-line `#compdef` zsh directive are included as ``; binary, oversized, unreadable, explicitly ignored, and internal index-artifact files are excluded. Current scans also stamp a diagnostics contract version and `unknown_extension_files` as a path sample bounded by `unknown_extension_file_path_limit` items and the string-list decoded-character budget, `unknown_extension_files_truncated` when more paths existed than were emitted for either bound, and `unknown_extension_file_path_limit` as the item cap rather than a guarantee that that many paths are returned. Newer scans also expose `unknown_extension_extension_counts`, `unknown_extension_category_counts`, and `unknown_extension_groups`; groups classify common non-code buckets such as repository metadata, licenses, binary assets, configuration, structural metadata, and language-support candidates, and include `recommended_action` values of `ignore_configuration`, `first_class_structural_extraction`, or `language_support`. These fields are omitted on legacy DBs, when the persisted diagnostics version is absent or unsupported, or before a current full scan has stamped them. Guidance for actionable extensionless files starts with adding a recognized shebang, adding a first-line `#compdef` directive for zsh completion functions, or renaming the file with a supported extension. - `status` also surfaces extractor plugin and pattern-config runtime diagnostics via `extractors`, including loaded counts, the zero parent `retained_load_context_count`, the isolated-worker `load_context_lifecycle`, skipped file counts, and a bounded diagnostics list for incompatible or malformed plugin/pattern files. `extractors.pattern_configs[]` reports accepted sidecars with sanitized `path`, `source` (`workspace` or `user`), normalized `language`, and `rule_count`; workspace discovery stops at its explicit root and path identity follows the live filesystem case policy. Pattern rules and their 128-rule budget live in immutable workspace snapshots; reindex replaces the owning snapshot, and timeout cooldowns/diagnostics remain workspace-scoped. `extractors.snapshot_scope` and highest-first `registration_precedence` expose snapshot selection and the fixed `built_in > user_plugin > user_pattern > workspace_plugin > workspace_pattern` resolution order; replacing one workspace snapshot never mutates another. Diagnostic paths, categories, and messages are sanitized before output; `diagnostics[].category` is the stable machine-readable failure code. - `status` surfaces worker-discovered post-extraction hook manifests and callback budgets through `hooks[]`, including stable assembly-qualified `hooks[].id`, `hooks[].callback_budget_ms`, and the worker-only `hooks[].load_context_lifecycle`. Assembly loading, module initialization, type inspection, and constructor validation occur only in deadline-, memory-, and output-bounded discovery workers, which are terminated after returning a manifest. `hook_diagnostics[]` reports sanitized discovery and callback diagnostics such as candidate-limit truncation, assembly load failure, constructor failure, callback failure, and timeout; `hook_diagnostics[].category` is the stable machine-readable failure code and `hook_diagnostics[].hook_id` links a concrete-hook failure to the corresponding manifest. Index runs enforce `CDIDX_HOOK_CALLBACK_BUDGET_MS` (default: 5000 ms) on scratch copies, discard timed-out mutations, and disable only the timed-out assembly-qualified hook ID for the remainder of the current run. -- `status` also surfaces accepted trust-boundary environment overrides through `trust_overrides[]`. Entries include `kind`, `environment_variable`, sanitized `value`, optional sanitized `path`, and `message`; current entries cover `CDIDX_TRUST_WORKSPACE_PLUGINS` workspace plugin discovery, `CDIDX_HOOKS_DIR` hook directory overrides, and the absolute `CDIDX_GIT_EXECUTABLE` executable override. `git_executable` reports the selected source, acceptance, stable reason, sanitized path, owner-only-write result, Unix mode, owner category, owner/ancestor trust (including POSIX sticky-ancestor and Windows ACL policy), and bounded `git --version` execution-probe result even when an explicit Git override is rejected. Build `git_executable` and its matching `trust_overrides[]` entry from the same resolution snapshot. Keep this visible runtime field registered in `status --explain` and in both README status-field tables. +- `status` also surfaces accepted trust-boundary environment overrides through `trust_overrides[]`. Entries include `kind`, `environment_variable`, sanitized `value`, optional sanitized `path`, and `message`; current entries cover `CDIDX_TRUST_WORKSPACE_PLUGINS` workspace plugin discovery, `CDIDX_HOOKS_DIR` hook directory overrides, and the absolute `CDIDX_GIT_EXECUTABLE` / `CDIDX_GH_EXECUTABLE` executable overrides. `git_executable` and `github_cli_executable` report the selected source, acceptance, stable reason, sanitized path, owner-only-write result, Unix mode, owner category, owner/ancestor trust (including POSIX sticky-ancestor and Windows ACL policy), and the bounded tool-specific version-probe result even when an explicit override is rejected. Build each executable field and its matching `trust_overrides[]` entry from the same resolution snapshot. Keep both visible runtime fields registered in `status --explain` and in both README status-field tables. - `status` also surfaces `.cdidx` data-directory permissions via `data_dir_mode` on POSIX filesystems. New `.cdidx` data directories are forced to `0700`; the field is omitted on Windows, URI DBs, or when the directory mode cannot be inspected. - `status` also surfaces database Unix-mode hardening through `db_file_mode`, `database_permission_policy`, and optional `database_permission_diagnostics[]`. The default `best_effort` policy keeps SQLite-capable FUSE/network mounts usable while emitting the stable `database_permission_hardening_failed` warning and support-safe operation/target/reason/remediation entries for `IOException`, `UnauthorizedAccessException`, and `NotSupportedException`. `CDIDX_DB_PERMISSION_POLICY=strict` makes every applicable database/WAL/SHM mode operation mandatory and fails with the same stable error code plus remediation. Windows and explicit SQLite file URIs skip this POSIX-only enforcement. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 092c693fd..1e80be2c6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1365,7 +1365,7 @@ On startup, `cdidx` walks up from the current directory looking for `.cdidx-vers ### Release freshness and upgrade checks -`cdidx --check-updates` and `cdidx status --check-updates` query the GitHub latest-release endpoint through `UpdateChecker`, using the same 24-hour cache and `CDIDX_DISABLE_UPDATE_CHECK=1` opt-out as the `--version` hint. `cdidx upgrade --check-only` reuses that check. `cdidx upgrade` is intentionally a thin wrapper around the signed release installer: it downloads `sha256sums.txt` and `install.sh` into a private temporary directory, independently verifies both exact files with `gh attestation verify` pinned to `github.com/Widthdom/CodeIndex/.github/workflows/release.yml` and `refs/tags/`, and only then trusts the manifest checksum and starts the installer. Missing or failed provenance blocks execution by default; `CDIDX_VERIFY_POLICY=compat` is the explicit audited opt-in. Upgrade JSON distinguishes the mechanism from the observed result through `verification_policy`, `manifest_provenance_verified`, `installer_provenance_verified`, `installer_verification_status`, and `provenance_audit_code`; check-only reports `not_attempted`, success reports `verified`, a strict blocked failure reports `verification_failed`, and a compat bypass reports `compat_bypass` plus `compat_provenance_bypass`. Invalid policy values return the normal structured usage-error JSON when `--json` is selected. After verification, the command checks that the current binary directory is writable, sets `CDIDX_INSTALL_DIR` to that directory, and runs the selected release installer. +`cdidx --check-updates` and `cdidx status --check-updates` query the GitHub latest-release endpoint through `UpdateChecker`, using the same 24-hour cache and `CDIDX_DISABLE_UPDATE_CHECK=1` opt-out as the `--version` hint. `cdidx upgrade --check-only` reuses that check. `cdidx upgrade` is intentionally a thin wrapper around the signed release installer: it downloads `sha256sums.txt` and `install.sh` into a private temporary directory, independently verifies both exact files with `gh attestation verify` pinned to `github.com/Widthdom/CodeIndex/.github/workflows/release.yml` and `refs/tags/`, and only then trusts the manifest checksum and starts the installer. The verifier is selected only from validated known installation paths or the `CDIDX_GH_EXECUTABLE` override; on macOS, known-location discovery enumerates versioned Homebrew Cellar binaries directly so it never launches the `bin/gh` symlink. The override must be an absolute `gh` path (`gh.exe` on Windows) that resolves to a canonical target whose regular-file/image, owner, write mode or ACL, ancestor, executable, and bounded `gh --version` checks all pass. PATH order and the current directory never select the verifier, and an invalid explicit override fails closed instead of falling back. Missing or failed provenance blocks execution by default; `CDIDX_VERIFY_POLICY=compat` is the explicit audited opt-in and reports an unverified bypass rather than verified provenance. Upgrade JSON distinguishes the mechanism from the observed result through `verification_policy`, `manifest_provenance_verified`, `installer_provenance_verified`, `installer_verification_status`, and `provenance_audit_code`; check-only reports `not_attempted`, success reports `verified`, a strict blocked failure reports `verification_failed`, and a compat bypass reports `compat_bypass` plus `compat_provenance_bypass`. Invalid policy values return the normal structured usage-error JSON when `--json` is selected. After verification, the command checks that the current binary directory is writable, sets `CDIDX_INSTALL_DIR` to that directory, and runs the selected release installer. Upgrade installer and git subprocesses scrub the inherited process environment before launch. They forward only the shared subprocess allowlist needed for @@ -5386,8 +5386,14 @@ endpoint を確認します。`cdidx upgrade --check-only` はこの check を `github.com/Widthdom/CodeIndex/.github/workflows/release.yml` と `refs/tags/` に固定した `gh attestation verify` で独立に検証してから manifest checksum を信頼し installer を起動します。既定では verifier 欠如または -provenance 失敗時に実行を拒否し、`CDIDX_VERIFY_POLICY=compat` だけが監査対象の明示的 -opt-in です。upgrade JSON は `verification_policy`、`manifest_provenance_verified`、 +provenance 失敗時に実行を拒否します。verifier は検証済みの既知 install path、または +`CDIDX_GH_EXECUTABLE` override からだけ選択されます。macOS の既知配置探索では version 固定の +Homebrew Cellar binary を直接列挙するため、`bin/gh` symlink を起動しません。override は絶対 `gh` path +(Windows では `gh.exe`)でなければならず、canonical な target に解決したうえで regular file / image、owner、write mode または ACL、 +ancestor、executable、上限付き `gh --version` の全検証を通過する必要があります。PATH 順序や +current directory が verifier を選ぶことはなく、無効な明示 override は fallback せず fail closed +します。`CDIDX_VERIFY_POLICY=compat` だけが監査対象の明示的 opt-in で、verified provenance と +表示せず未検証 bypass を報告します。upgrade JSON は `verification_policy`、`manifest_provenance_verified`、 `installer_provenance_verified`、`installer_verification_status`、 `provenance_audit_code` で method と実測結果を分離し、check-only は `not_attempted`、 成功は `verified`、strict で中断した失敗は `verification_failed`、compat bypass は diff --git a/README.md b/README.md index e55c1e7d5..930be53b9 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ visible here as a compact compatibility index. | Workspace-check path samples | `workspace_check.changed_files`, `workspace_check.missing_files`, `workspace_check.outside_sparse_cone_files`, `workspace_check.unindexed_files`, `workspace_check.unverifiable_files`, and `workspace_check.scan_errors`, each paired with authoritative `*_count`, `*_truncated`, `*_path_limit`, and `*_omitted_count` fields. | | Version compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`. | | Extension and extractor diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `unknown_extension_extension_counts`, `unknown_extension_category_counts`, `unknown_extension_groups`, `unknown_extension_group_count`, `unknown_extension_groups_truncated`, `unknown_extension_group_limit`, `unknown_extension_group_omitted_count`, `unknown_extension_guidance`, `extractors`, `hooks`, `hook_diagnostics`. | -| Runtime trust and permissions | `trust_overrides`, `git_executable`, `path_case_sensitive`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`. | +| Runtime trust and permissions | `trust_overrides`, `git_executable`, `github_cli_executable`, `path_case_sensitive`, `data_dir_mode`, `db_file_mode`, `database_permission_policy`, `database_permission_diagnostics`, `mac_profile`, `mac_profile_diagnostics`. | | Check context and run diagnostics | `stale_after_seconds`, `index_age_seconds`, `query_context.check_mode`, `query_context.stale_after_seconds`, `process`, `last_index_run`, `last_workspace_freshened_at`, `last_failed_or_partial_index_run`, `status_metadata_diagnostics`. | | Last-run detail | `last_index_run.bytes_read_skipped_file_count`, `last_index_run.bytes_read_incomplete`, `last_index_run.diagnostics`, `last_index_run.diagnostic_count`, `last_index_run.diagnostics_truncated`, `last_index_run.reference_extraction_cap_hits`, `last_index_run.rebuild_reclaim`, `last_failed_or_partial_index_run.progress_persisted`, `last_failed_or_partial_index_run.recovery_hint`, `last_failed_or_partial_index_run.file_errors`. | | SQLite and maintenance | `sqlite_connection_policy`, `db_size_bytes`, `wal_size_bytes`, `db_pragma_settings`, `prepared_command_cache`, `maintenance_guidance`, `maintenance_guidance.fts_optimization`, `threshold_writes`, `observed_writes`. | @@ -417,7 +417,7 @@ field group を表に残します。 | workspace-check の path sample | `workspace_check.changed_files`、`workspace_check.missing_files`、`workspace_check.outside_sparse_cone_files`、`workspace_check.unindexed_files`、`workspace_check.unverifiable_files`、`workspace_check.scan_errors`。各一覧には authoritative な `*_count`、`*_truncated`、`*_path_limit`、`*_omitted_count` が対応します。 | | version compatibility | `index_writer_version`、`index_newer_than_reader`、`index_newer_than_reader_reason`。 | | extension / extractor diagnostics | `unknown_extension_file_count`、`unknown_extension_files`、`unknown_extension_files_truncated`、`unknown_extension_file_path_limit`、`unknown_extension_extension_counts`、`unknown_extension_category_counts`、`unknown_extension_groups`、`unknown_extension_group_count`、`unknown_extension_groups_truncated`、`unknown_extension_group_limit`、`unknown_extension_group_omitted_count`、`unknown_extension_guidance`、`extractors`、`hooks`、`hook_diagnostics`。 | -| runtime trust / permissions | `trust_overrides`、`git_executable`、`path_case_sensitive`、`data_dir_mode`、`db_file_mode`、`database_permission_policy`、`database_permission_diagnostics`、`mac_profile`、`mac_profile_diagnostics`。 | +| runtime trust / permissions | `trust_overrides`、`git_executable`、`github_cli_executable`、`path_case_sensitive`、`data_dir_mode`、`db_file_mode`、`database_permission_policy`、`database_permission_diagnostics`、`mac_profile`、`mac_profile_diagnostics`。 | | check context / run diagnostics | `stale_after_seconds`、`index_age_seconds`、`query_context.check_mode`、`query_context.stale_after_seconds`、`process`、`last_index_run`、`last_workspace_freshened_at`、`last_failed_or_partial_index_run`、`status_metadata_diagnostics`。 | | last-run detail | `last_index_run.bytes_read_skipped_file_count`、`last_index_run.bytes_read_incomplete`、`last_index_run.diagnostics`、`last_index_run.diagnostic_count`、`last_index_run.diagnostics_truncated`、`last_index_run.reference_extraction_cap_hits`、`last_index_run.rebuild_reclaim`、`last_failed_or_partial_index_run.progress_persisted`、`last_failed_or_partial_index_run.recovery_hint`、`last_failed_or_partial_index_run.file_errors`。 | | SQLite / maintenance | `sqlite_connection_policy`、`db_size_bytes`、`wal_size_bytes`、`db_pragma_settings`、`prepared_command_cache`、`maintenance_guidance`、`maintenance_guidance.fts_optimization`、`threshold_writes`、`observed_writes`。 | diff --git a/changelog.d/unreleased/5184.security.md b/changelog.d/unreleased/5184.security.md new file mode 100644 index 000000000..330dcfdcc --- /dev/null +++ b/changelog.d/unreleased/5184.security.md @@ -0,0 +1,21 @@ +--- +category: security +issues: + - 5184 +affected: + - src/CodeIndex/Cli/GitHubCliExecutableResolver.cs + - src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs + - src/CodeIndex/Cli/TrustedExecutableValidator.cs + - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.FileIdentity.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Release attestation verification now pins a trusted GitHub CLI executable (#5184)** — `cdidx upgrade` no longer resolves bare `gh` through `PATH`. It validates a canonical absolute executable from known platform locations or `CDIDX_GH_EXECUTABLE`, discovers versioned Homebrew Cellar binaries without launching their symlinks, reports the decision through CLI and MCP `status.github_cli_executable`, blocks strict upgrades when no trusted verifier is available, and keeps compatibility-mode installs explicitly unverified. + +## 日本語 + +- **リリース attestation 検証で信頼済み GitHub CLI 実行ファイルを固定するようにしました (#5184)** — `cdidx upgrade` は bare な `gh` を `PATH` から解決しなくなりました。既知のプラットフォーム配置または `CDIDX_GH_EXECUTABLE` から得た canonical な絶対パスを検証し、symlink を起動せず version 固定の Homebrew Cellar binary を探索し、CLI / MCP の `status.github_cli_executable` で判定を報告します。信頼済み verifier がない場合は strict upgrade を停止し、compatibility mode のインストールは未検証であることを明示します。 diff --git a/src/CodeIndex/Cli/EnvironmentVariableInventory.cs b/src/CodeIndex/Cli/EnvironmentVariableInventory.cs index c8657cbdf..106190863 100644 --- a/src/CodeIndex/Cli/EnvironmentVariableInventory.cs +++ b/src/CodeIndex/Cli/EnvironmentVariableInventory.cs @@ -72,6 +72,7 @@ internal static class EnvironmentVariableInventory Item("CDIDX_GITHUB_TOKEN", "github", SensitivitySecret, "security", "GitHub submission disabled", "no", "GitHub token used for explicit suggestion issue submission and authenticated read-only duplicate preflight.", Location("src/CodeIndex/Cli/GitHubIssueReporter.cs", 35, "GitHubIssueReporter")), Item("CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS", "github", SensitivityPublic, "performance", "10", "no", "GitHub suggestion submission timeout in seconds.", Location("src/CodeIndex/Cli/GitHubIssueReporter.cs", 53, "GitHubIssueReporter")), Item(GitHubHttpClientFactory.ProxyDefaultCredentialsEnvironmentVariable, "github", SensitivityPublic, "security", "disabled", "no", "Allow default proxy credentials for GitHub HTTP calls.", Location("src/CodeIndex/Cli/GitHubHttpClientFactory.cs", 8, "GitHubHttpClientFactory")), + Item(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, "github", SensitivityPublic, "security", "validated known installation path", "no", "Override the trusted GitHub CLI attestation verifier with a validated absolute path.", Location("src/CodeIndex/Cli/GitHubCliExecutableResolver.cs", 9, "GitHubCliExecutableResolver")), Item(GitHelper.GitExecutableEnvironmentVariable, "git", SensitivityPublic, "security", "validated known installation path", "no", "Override the trusted Git executable with a validated absolute path.", Location("src/CodeIndex/Cli/GitHelper.cs", 65, "GitHelper")), @@ -199,6 +200,9 @@ private static string ResolveInvalidValueBehavior(string name, string category, if (name == global::CodeIndex.SubprocessEnvironmentPolicy.TestEnvironmentPrefix + "*") return "only non-empty variables with the exact prefix are copied into isolated worker subprocesses"; + if (name == GitHubCliExecutableResolver.ExecutableEnvironmentVariable) + return "invalid or unsafe executable paths fail closed and are reported by status.github_cli_executable"; + return category switch { "terminal" or "locale" or "output" => diff --git a/src/CodeIndex/Cli/GitHelper.Trust.cs b/src/CodeIndex/Cli/GitHelper.Trust.cs index 98ff99173..6c7399abd 100644 --- a/src/CodeIndex/Cli/GitHelper.Trust.cs +++ b/src/CodeIndex/Cli/GitHelper.Trust.cs @@ -1,16 +1,7 @@ -using System.Buffers.Binary; -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using System.Security.AccessControl; -using System.Security.Principal; -using CodeIndex.Indexer; - namespace CodeIndex.Cli; public static partial class GitHelper { - private const int UnixExecuteAccess = 1; - private const int MaxPortableExecutableHeaderOffset = 16 * 1024 * 1024; private static readonly TimeSpan GitExecutableProbeTimeout = TimeSpan.FromSeconds(5); private static readonly AsyncLocal?> GitVersionProbeOverride = new(); @@ -20,196 +11,6 @@ internal static Func? GitVersionProbeForTesting set => GitVersionProbeOverride.Value = value; } - private static bool TryValidateGitExecutableAncestors(string executablePath, uint? effectiveUserId) - { - try - { - var current = Directory.GetParent(executablePath)?.FullName; - while (current != null) - { - var probe = FileSystemBoundary.TryGetAttributes(current, out var attributes); - if (probe != FileSystemBoundaryProbeStatus.Found - || (attributes & FileAttributes.Directory) == 0 - || FileSystemBoundary.IsSymlinkOrReparsePoint(attributes) - || FileSystemBoundary.IsDevice(attributes)) - { - return false; - } - - if (effectiveUserId is uint userId && !OperatingSystem.IsWindows()) - { - var mode = File.GetUnixFileMode(LongPath.EnsureWindowsPrefix(current)); - if (!FileIndexer.TryGetUnixFileOwnerId(LongPath.EnsureWindowsPrefix(current), out var ownerId) - || (ownerId != userId && ownerId != 0)) - { - return false; - } - - var sharedWritable = (mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0; - var rootOwnedStickyDirectory = ownerId == 0 - && (mode & UnixFileMode.StickyBit) != 0; - if (sharedWritable && !rootOwnedStickyDirectory) - return false; - } - else if (effectiveUserId.HasValue) - { - return false; - } - - current = Directory.GetParent(current)?.FullName; - } - - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - return false; - } - } - - [SupportedOSPlatform("windows")] - private static bool TryValidateWindowsExecutableAcl( - string executablePath, - out string? ownerCategory, - out bool ownerTrusted) - { - ownerCategory = null; - ownerTrusted = false; - try - { - using var identity = WindowsIdentity.GetCurrent(TokenAccessLevels.Query); - var currentUser = identity.User; - if (currentUser == null) - return false; - - var trustedSids = new HashSet(StringComparer.Ordinal) - { - currentUser.Value, - "S-1-5-18", // LocalSystem - "S-1-5-32-544", // Builtin Administrators - "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", // TrustedInstaller - }; - - var current = executablePath; - var finalEntry = true; - while (current != null) - { - var attributes = File.GetAttributes(LongPath.EnsureWindowsPrefix(current)); - var isDirectory = (attributes & FileAttributes.Directory) != 0; - FileSystemSecurity security = isDirectory - ? FileSystemAclExtensions.GetAccessControl( - new DirectoryInfo(current), - AccessControlSections.Owner | AccessControlSections.Access) - : FileSystemAclExtensions.GetAccessControl( - new FileInfo(current), - AccessControlSections.Owner | AccessControlSections.Access); - - var owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; - if (owner == null) - return false; - - var trustedOwner = trustedSids.Contains(owner.Value); - if (finalEntry) - { - ownerCategory = owner.Value == currentUser.Value - ? "current_user" - : owner.Value == "S-1-5-18" - ? "system" - : owner.Value == "S-1-5-32-544" - ? "administrators" - : owner.Value == "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" - ? "trusted_installer" - : "other"; - ownerTrusted = trustedOwner; - } - - if (!trustedOwner || HasUntrustedWindowsWriteRule(security, trustedSids, finalEntry)) - return false; - - finalEntry = false; - current = Directory.GetParent(current)?.FullName; - } - - return true; - } - catch (Exception) - { - return false; - } - } - - [SupportedOSPlatform("windows")] - private static bool HasUntrustedWindowsWriteRule( - FileSystemSecurity security, - IReadOnlySet trustedSids, - bool finalEntry) - { - var dangerousRights = FileSystemRights.Delete - | FileSystemRights.DeleteSubdirectoriesAndFiles - | FileSystemRights.ChangePermissions - | FileSystemRights.TakeOwnership; - if (finalEntry) - { - dangerousRights |= FileSystemRights.WriteData - | FileSystemRights.AppendData - | FileSystemRights.WriteAttributes - | FileSystemRights.WriteExtendedAttributes; - } - - foreach (var rule in security.GetAccessRules( - includeExplicit: true, - includeInherited: true, - typeof(SecurityIdentifier)).OfType()) - { - if (rule.AccessControlType != AccessControlType.Allow - || (rule.PropagationFlags & PropagationFlags.InheritOnly) != 0 - || rule.IdentityReference is not SecurityIdentifier sid - || trustedSids.Contains(sid.Value) - || sid.Value is "S-1-3-0" or "S-1-3-4") - { - continue; - } - - if ((rule.FileSystemRights & dangerousRights) != 0) - return true; - } - - return false; - } - - private static bool TryValidateWindowsExecutableImage(string path) - { - try - { - using var stream = new FileStream( - LongPath.EnsureWindowsPrefix(path), - FileMode.Open, - FileAccess.Read, - FileShare.Read | FileShare.Delete); - Span dosHeader = stackalloc byte[64]; - stream.ReadExactly(dosHeader); - if (dosHeader[0] != (byte)'M' || dosHeader[1] != (byte)'Z') - return false; - - var peOffset = BinaryPrimitives.ReadInt32LittleEndian(dosHeader[0x3c..]); - if (peOffset < dosHeader.Length - || peOffset > MaxPortableExecutableHeaderOffset - || peOffset > stream.Length - 4) - { - return false; - } - - stream.Position = peOffset; - Span peSignature = stackalloc byte[4]; - stream.ReadExactly(peSignature); - return peSignature.SequenceEqual("PE\0\0"u8); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - return false; - } - } - private static bool TryProbeGitVersion(string executablePath) { var probeOverride = GitVersionProbeOverride.Value; @@ -234,73 +35,4 @@ private static bool TryProbeGitVersion(string executablePath) && result.Value.Output.Trim().StartsWith("git version ", StringComparison.OrdinalIgnoreCase); } - private static bool TryResolveRealUnixPath(string path, out string resolvedPath) - => TryResolveRealUnixPathCore(path, out resolvedPath) - && HasExpectedGitExecutableName(resolvedPath); - - private static bool TryResolveRealUnixPathCore(string path, out string resolvedPath) - { - resolvedPath = string.Empty; - IntPtr pointer = IntPtr.Zero; - try - { - pointer = UnixRealPath(path, IntPtr.Zero); - if (pointer == IntPtr.Zero) - return false; - - var value = Marshal.PtrToStringUTF8(pointer); - if (string.IsNullOrEmpty(value)) - return false; - - resolvedPath = PathCasing.NormalizeBoundaryPath(value); - return true; - } - catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentException or IOException or NotSupportedException or PathTooLongException) - { - return false; - } - finally - { - if (pointer != IntPtr.Zero) - UnixFree(pointer); - } - } - - private static bool TryGetEffectiveUnixUserId(out uint userId) - { - userId = 0; - try - { - userId = UnixGetEffectiveUserId(); - return true; - } - catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) - { - return false; - } - } - - private static bool TryAccessUnixExecutable(string path) - { - try - { - return UnixAccess(path, UnixExecuteAccess) == 0; - } - catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) - { - return false; - } - } - - [DllImport("libc", EntryPoint = "realpath", SetLastError = true)] - private static extern IntPtr UnixRealPath(string path, IntPtr resolvedPath); - - [DllImport("libc", EntryPoint = "free")] - private static extern void UnixFree(IntPtr pointer); - - [DllImport("libc", EntryPoint = "geteuid")] - private static extern uint UnixGetEffectiveUserId(); - - [DllImport("libc", EntryPoint = "access", SetLastError = true)] - private static extern int UnixAccess(string path, int mode); } diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index af7891a26..a0c3ac046 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -289,155 +289,26 @@ private static GitExecutableResolution EvaluateGitExecutableCandidate( string source, bool probeVersion = true) { - if (string.IsNullOrWhiteSpace(path)) - return RejectedGitExecutable(source, "path_empty", null, null, null, null, null, null, null); - if (!Path.IsPathFullyQualified(path)) - return RejectedGitExecutable(source, "path_not_absolute", null, null, null, null, null, null, null); - - string fullPath; - try - { - fullPath = Path.GetFullPath(path); - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException) - { - return RejectedGitExecutable(source, "invalid_path", null, null, null, null, null, null, null); - } - - var diagnosticPath = DiagnosticSanitizer.ForPath(fullPath); - if (fullPath.IndexOfAny(['\r', '\n']) >= 0) - return RejectedGitExecutable(source, "path_contains_line_break", diagnosticPath, null, null, null, null, null, null); - if (!HasExpectedGitExecutableName(fullPath)) - return RejectedGitExecutable(source, "unexpected_filename", diagnosticPath, null, null, null, null, null, null); - - FileAttributes attributes; - try - { - attributes = File.GetAttributes(LongPath.EnsureWindowsPrefix(fullPath)); - } - catch (FileNotFoundException) - { - return RejectedGitExecutable(source, "not_found", diagnosticPath, null, null, null, null, null, null); - } - catch (DirectoryNotFoundException) - { - return RejectedGitExecutable(source, "not_found", diagnosticPath, null, null, null, null, null, null); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - return RejectedGitExecutable(source, "attribute_probe_failed", diagnosticPath, null, null, null, null, null, null); - } - - if ((attributes & FileAttributes.Directory) != 0) - return RejectedGitExecutable(source, "not_regular_file", diagnosticPath, null, null, null, null, null, null); - if (FileSystemBoundary.IsSymlinkOrReparsePoint(attributes)) - return RejectedGitExecutable(source, "symlink_or_reparse_point", diagnosticPath, null, null, null, null, null, null); - if (FileSystemBoundary.IsDevice(attributes)) - return RejectedGitExecutable(source, "device", diagnosticPath, null, null, null, null, null, null); - - if (OperatingSystem.IsWindows()) - { - var windowsAncestorsTrusted = TryValidateGitExecutableAncestors(fullPath, effectiveUserId: null); - if (!windowsAncestorsTrusted) - return RejectedGitExecutable(source, "ancestor_untrusted", diagnosticPath, null, null, null, null, null, windowsAncestorsTrusted); - if (!TryValidateWindowsExecutableAcl(fullPath, out var windowsOwner, out var windowsOwnerTrusted)) - { - return RejectedGitExecutable( - source, - "acl_untrusted", - diagnosticPath, - null, - null, - null, - windowsOwner, - windowsOwnerTrusted, - false); - } - if (!TryValidateWindowsExecutableImage(fullPath)) - return RejectedGitExecutable(source, "invalid_executable_format", diagnosticPath, null, null, false, windowsOwner, windowsOwnerTrusted, windowsAncestorsTrusted); - - if (!probeVersion) - return AcceptedGitExecutable(source, fullPath, diagnosticPath, null, null, null, windowsOwner, windowsOwnerTrusted, windowsAncestorsTrusted); - - var windowsExecutable = TryProbeGitVersion(fullPath); - return windowsExecutable - ? AcceptedGitExecutable(source, fullPath, diagnosticPath, null, null, windowsExecutable, windowsOwner, windowsOwnerTrusted, windowsAncestorsTrusted) - : RejectedGitExecutable(source, "execution_probe_failed", diagnosticPath, null, null, windowsExecutable, windowsOwner, windowsOwnerTrusted, windowsAncestorsTrusted); - } - - if (!TryResolveRealUnixPath(fullPath, out fullPath)) - return RejectedGitExecutable(source, "canonicalization_failed", diagnosticPath, null, null, null, null, null, null); - diagnosticPath = DiagnosticSanitizer.ForPath(fullPath); - if (fullPath.IndexOfAny(['\r', '\n']) >= 0) - return RejectedGitExecutable(source, "path_contains_line_break", diagnosticPath, null, null, null, null, null, null); - - UnixFileMode mode; - try - { - mode = File.GetUnixFileMode(LongPath.EnsureWindowsPrefix(fullPath)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - return RejectedGitExecutable(source, "mode_probe_failed", diagnosticPath, null, null, null, null, null, null); - } - - var ownerOnlyWritable = (mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) == 0; - var unixMode = FormatUnixMode(mode); - if (!ownerOnlyWritable) - return RejectedGitExecutable(source, "shared_writable", diagnosticPath, ownerOnlyWritable, unixMode, null, null, null, null); - - if (!TryGetEffectiveUnixUserId(out var effectiveUserId) - || !FileIndexer.TryGetUnixFileOwnerId(LongPath.EnsureWindowsPrefix(fullPath), out var ownerId)) - { - return RejectedGitExecutable(source, "owner_probe_failed", diagnosticPath, ownerOnlyWritable, unixMode, null, null, null, null); - } - - var owner = ownerId == effectiveUserId ? "current_user" : ownerId == 0 ? "root" : "other"; - var ownerTrusted = ownerId == effectiveUserId || ownerId == 0; - if (!ownerTrusted) - return RejectedGitExecutable(source, "owner_untrusted", diagnosticPath, ownerOnlyWritable, unixMode, null, owner, ownerTrusted, null); - - var ancestorsTrusted = TryValidateGitExecutableAncestors(fullPath, effectiveUserId); - if (!ancestorsTrusted) - return RejectedGitExecutable(source, "ancestor_untrusted", diagnosticPath, ownerOnlyWritable, unixMode, null, owner, ownerTrusted, ancestorsTrusted); - - var executable = TryAccessUnixExecutable(fullPath); - if (!executable) - return RejectedGitExecutable(source, "not_executable", diagnosticPath, ownerOnlyWritable, unixMode, executable, owner, ownerTrusted, ancestorsTrusted); - - if (!probeVersion) - return AcceptedGitExecutable(source, fullPath, diagnosticPath, ownerOnlyWritable, unixMode, executable, owner, ownerTrusted, ancestorsTrusted); - - executable = TryProbeGitVersion(fullPath); - if (!executable) - return RejectedGitExecutable(source, "execution_probe_failed", diagnosticPath, ownerOnlyWritable, unixMode, executable, owner, ownerTrusted, ancestorsTrusted); - - return AcceptedGitExecutable(source, fullPath, diagnosticPath, ownerOnlyWritable, unixMode, executable, owner, ownerTrusted, ancestorsTrusted); - } - - private static GitExecutableResolution AcceptedGitExecutable( - string source, - string fullPath, - string diagnosticPath, - bool? ownerOnlyWritable, - string? unixMode, - bool? executable, - string? owner, - bool? ownerTrusted, - bool? ancestorDirectoriesTrusted) - => new( - fullPath, + var validation = TrustedExecutableValidator.Evaluate( + path, + source, + expectedUnixFileName: "git", + expectedWindowsFileName: "git.exe", + executionProbe: probeVersion ? TryProbeGitVersion : null); + return new GitExecutableResolution( + validation.Path, new GitExecutableStatus( - source, - Accepted: true, - "accepted", - diagnosticPath, - ownerOnlyWritable, - unixMode, - executable, - owner, - ownerTrusted, - ancestorDirectoriesTrusted)); + validation.Source, + validation.Accepted, + validation.Reason, + validation.DiagnosticPath, + validation.OwnerOnlyWritable, + validation.UnixMode, + validation.Executable, + validation.Owner, + validation.OwnerTrusted, + validation.AncestorDirectoriesTrusted)); + } private static GitExecutableResolution RejectedGitExecutable( string source, @@ -463,9 +334,6 @@ private static GitExecutableResolution RejectedGitExecutable( ownerTrusted, ancestorDirectoriesTrusted)); - private static string FormatUnixMode(UnixFileMode mode) - => Convert.ToString((int)mode, 8).PadLeft(4, '0'); - private static bool HasExpectedGitExecutableName(string path) => string.Equals( Path.GetFileName(path), @@ -584,7 +452,7 @@ private static bool TryValidateGitMetadataEntry(string path, bool expectDirector if (!OperatingSystem.IsWindows()) { - if (!TryResolveRealUnixPathCore(canonicalPath, out var resolvedPath) + if (!TrustedExecutableValidator.TryResolveRealUnixPath(canonicalPath, out var resolvedPath) || !TryValidateGitMetadataPathComponents(resolvedPath, expectDirectory)) { return false; diff --git a/src/CodeIndex/Cli/GitHubCliExecutableResolver.cs b/src/CodeIndex/Cli/GitHubCliExecutableResolver.cs new file mode 100644 index 000000000..d60d483c9 --- /dev/null +++ b/src/CodeIndex/Cli/GitHubCliExecutableResolver.cs @@ -0,0 +1,281 @@ +using CodeIndex.Database; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Cli; + +internal sealed class TrustedGitHubCliUnavailableException(string message) : InvalidOperationException(message); + +internal static class GitHubCliExecutableResolver +{ + internal const string ExecutableEnvironmentVariable = "CDIDX_GH_EXECUTABLE"; + + private const string UnavailableMessage = + "Could not resolve a trusted GitHub CLI executable. Install gh in a standard location or set CDIDX_GH_EXECUTABLE to a trusted absolute path (gh.exe on Windows)."; + private const int MaxHomebrewCellarVersions = 32; + private static readonly TimeSpan VersionProbeTimeout = TimeSpan.FromSeconds(5); + private static readonly AsyncLocal?> CandidatePathsOverride = new(); + private static readonly AsyncLocal?> VersionProbeOverride = new(); + + internal static IReadOnlyList? CandidatePathsForTesting + { + get => CandidatePathsOverride.Value; + set => CandidatePathsOverride.Value = value; + } + + internal static Func? VersionProbeForTesting + { + get => VersionProbeOverride.Value; + set => VersionProbeOverride.Value = value; + } + + internal static string ResolvePathOrThrow(CancellationToken cancellationToken = default) + => Resolve(cancellationToken).Path ?? throw new TrustedGitHubCliUnavailableException(UnavailableMessage); + + internal static GitExecutableStatus GetStatus(CancellationToken cancellationToken = default) + => Resolve(cancellationToken).Status; + + internal static IReadOnlyList GetAcceptedTrustOverrides(GitExecutableStatus status) + { + if (!status.Accepted || !string.Equals(status.Source, "environment_override", StringComparison.Ordinal)) + return []; + + var modeDetail = status.UnixMode == null + ? "regular non-reparse executable with trusted owner/write ACLs and ancestors" + : $"{status.Owner ?? "trusted"}-owned, owner-only-writable mode {status.UnixMode} executable with trusted ancestors"; + return + [ + new ExtensionTrustOverride( + "github_cli_executable", + ExecutableEnvironmentVariable, + status.Path ?? string.Empty, + status.Path, + $"Absolute GitHub CLI executable override accepted after {modeDetail} validation.") + ]; + } + + internal static IReadOnlyList KnownCandidatePathsForTests() + => EnumerateKnownCandidatePaths().ToList(); + + internal static IReadOnlyList HomebrewCellarCandidatePathsForTests(string prefix) + => EnumerateHomebrewCellarCandidatePaths(prefix).ToList(); + + internal static GitExecutableStatus EvaluateCandidateForTesting( + string path, + bool probeVersion = false) + => EvaluateCandidate(path, "test_candidate", probeVersion, CancellationToken.None).Status; + + private static GitHubCliExecutableResolution Resolve(CancellationToken cancellationToken) + { + var environmentValue = EnvironmentAccess.GetProcessEnvironmentVariable(ExecutableEnvironmentVariable); + if (environmentValue != null) + return EvaluateCandidate(environmentValue, "environment_override", probeVersion: true, cancellationToken); + + var candidates = CandidatePathsOverride.Value ?? EnumerateKnownCandidatePaths().ToList(); + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var resolution = EvaluateCandidate(candidate, "known_location", probeVersion: true, cancellationToken); + if (resolution.Path != null) + return resolution; + } + + return new GitHubCliExecutableResolution( + Path: null, + new GitExecutableStatus( + "known_location", + Accepted: false, + "no_trusted_candidate", + Path: null, + OwnerOnlyWritable: null, + UnixMode: null, + Executable: null, + Owner: null, + OwnerTrusted: null, + AncestorDirectoriesTrusted: null)); + } + + private static GitHubCliExecutableResolution EvaluateCandidate( + string path, + string source, + bool probeVersion, + CancellationToken cancellationToken) + { + var validation = TrustedExecutableValidator.Evaluate( + path, + source, + expectedUnixFileName: "gh", + expectedWindowsFileName: "gh.exe", + executionProbe: probeVersion + ? executablePath => ProbeVersion(executablePath, cancellationToken) + : null, + allowMacHomebrewAdminGroupWrite: IsMacHomebrewCellarGhPath(path)); + return new GitHubCliExecutableResolution( + validation.Path, + new GitExecutableStatus( + validation.Source, + validation.Accepted, + validation.Reason, + validation.DiagnosticPath, + validation.OwnerOnlyWritable, + validation.UnixMode, + validation.Executable, + validation.Owner, + validation.OwnerTrusted, + validation.AncestorDirectoriesTrusted)); + } + + private static IEnumerable EnumerateKnownCandidatePaths() + { + if (OperatingSystem.IsWindows()) + { + foreach (var root in new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + }) + { + if (string.IsNullOrWhiteSpace(root)) + continue; + + yield return root == Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + ? Path.Combine(root, "Programs", "GitHub CLI", "gh.exe") + : Path.Combine(root, "GitHub CLI", "gh.exe"); + } + yield break; + } + + if (OperatingSystem.IsMacOS()) + { + yield return "/opt/homebrew/bin/gh"; + foreach (var candidate in EnumerateHomebrewCellarCandidatePaths("/opt/homebrew")) + yield return candidate; + yield return "/usr/local/bin/gh"; + foreach (var candidate in EnumerateHomebrewCellarCandidatePaths("/usr/local")) + yield return candidate; + yield return "/usr/bin/gh"; + yield break; + } + + yield return "/usr/bin/gh"; + yield return "/usr/local/bin/gh"; + yield return "/bin/gh"; + } + + private static IEnumerable EnumerateHomebrewCellarCandidatePaths(string prefix) + { + var formulaDirectory = Path.Combine(prefix, "Cellar", "gh"); + IEnumerable versionDirectories; + try + { + versionDirectories = Directory + .EnumerateDirectories(formulaDirectory) + .Select(CreateHomebrewCellarVersionCandidate) + .OrderByDescending(static candidate => candidate.Parsed) + .ThenByDescending(static candidate => candidate.Version) + .ThenByDescending(static candidate => candidate.Revision) + .ThenByDescending(static candidate => candidate.Path, StringComparer.Ordinal) + .Take(MaxHomebrewCellarVersions) + .Select(static candidate => candidate.Path) + .ToArray(); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or DirectoryNotFoundException) + { + yield break; + } + + foreach (var versionDirectory in versionDirectories) + yield return Path.Combine(versionDirectory, "bin", "gh"); + } + + private static HomebrewCellarVersionCandidate CreateHomebrewCellarVersionCandidate(string path) + { + var directoryName = Path.GetFileName(path); + var versionText = directoryName; + var revision = 0; + var revisionSeparator = directoryName.LastIndexOf('_'); + if (revisionSeparator >= 0) + { + versionText = directoryName[..revisionSeparator]; + if (!int.TryParse(directoryName[(revisionSeparator + 1)..], out revision) || revision < 0) + return new HomebrewCellarVersionCandidate(path, new Version(0, 0), 0, Parsed: false); + } + + return Version.TryParse(versionText, out var version) + ? new HomebrewCellarVersionCandidate(path, version, revision, Parsed: true) + : new HomebrewCellarVersionCandidate(path, new Version(0, 0), 0, Parsed: false); + } + + private static bool IsMacHomebrewCellarGhPath(string path) + { + if (!OperatingSystem.IsMacOS() || !Path.IsPathFullyQualified(path)) + return false; + + string fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException) + { + return false; + } + + return IsPathUnderHomebrewGhCellar(fullPath, "/opt/homebrew") + || IsPathUnderHomebrewGhCellar(fullPath, "/usr/local"); + } + + private static bool IsPathUnderHomebrewGhCellar(string path, string prefix) + { + var formulaDirectory = Path.Combine(prefix, "Cellar", "gh") + Path.DirectorySeparatorChar; + if (!path.StartsWith(formulaDirectory, StringComparison.Ordinal) + || !string.Equals(Path.GetFileName(path), "gh", StringComparison.Ordinal)) + { + return false; + } + + var relative = path[formulaDirectory.Length..]; + var segments = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + return segments.Length == 3 && string.Equals(segments[1], "bin", StringComparison.Ordinal); + } + + private static bool ProbeVersion(string executablePath, CancellationToken cancellationToken) + { + var probeOverride = VersionProbeOverride.Value; + if (probeOverride != null) + return probeOverride(executablePath, cancellationToken); + + var workingDirectory = Path.GetDirectoryName(executablePath); + if (string.IsNullOrEmpty(workingDirectory)) + return false; + + var startInfo = ProcessLaunchPolicy.CreateNoShellStartInfo( + executablePath, + workingDirectory, + redirectStandardOutput: true, + redirectStandardError: true, + createNoWindow: true); + startInfo.ArgumentList.Add("--version"); + SubprocessEnvironmentPolicy.ApplyUpgradeInstallerEnvironment(startInfo); + var result = ProgramRunner.RunInstallerProcessDetailed( + startInfo, + VersionProbeTimeout, + cancellationToken, + suppressOutput: true); + return result.ExitCode == CommandExitCodes.Success + && !result.OutputTruncated + && result.StdoutTail?.TrimStart().StartsWith("gh version ", StringComparison.OrdinalIgnoreCase) == true; + } + + private sealed record GitHubCliExecutableResolution(string? Path, GitExecutableStatus Status); + + private sealed record HomebrewCellarVersionCandidate( + string Path, + Version Version, + int Revision, + bool Parsed); +} diff --git a/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs b/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs index b81107649..0d4c34fc6 100644 --- a/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs +++ b/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs @@ -65,7 +65,27 @@ private static void RequireUpgradeAssetProvenance( out bool? verified) { var compat = string.Equals(verificationPolicy, "compat", StringComparison.Ordinal); - verified = UpgradeAssetProvenanceVerifier(assetPath, releaseTag, cancellationToken); + try + { + verified = UpgradeAssetProvenanceVerifier(assetPath, releaseTag, cancellationToken); + } + catch (TrustedGitHubCliUnavailableException ex) + { + verified = false; + if (!compat) + { + throw new InvalidDataException( + $"Independent release provenance verification failed for {assetName}; installer execution is blocked. {ex.Message}", + ex); + } + + if (!suppressOutput) + { + CommandErrorWriter.WriteStderr( + $"Warning: AUDIT: CDIDX_VERIFY_POLICY=compat permits {assetName} because no trusted GitHub CLI verifier was available; the asset was not independently verified."); + } + return; + } if (verified == true) { @@ -83,7 +103,7 @@ private static void RequireUpgradeAssetProvenance( private static bool VerifyUpgradeAssetProvenance(string assetPath, string releaseTag, CancellationToken cancellationToken) { - var startInfo = CreateUpgradeAttestationStartInfo(assetPath, releaseTag); + var startInfo = CreateUpgradeAttestationStartInfo(assetPath, releaseTag, cancellationToken); var result = RunInstallerProcessDetailed( startInfo, TimeSpan.FromSeconds(30), @@ -92,11 +112,14 @@ private static bool VerifyUpgradeAssetProvenance(string assetPath, string releas return result.ExitCode == CommandExitCodes.Success; } - internal static ProcessStartInfo CreateUpgradeAttestationStartInfo(string assetPath, string releaseTag) + internal static ProcessStartInfo CreateUpgradeAttestationStartInfo( + string assetPath, + string releaseTag, + CancellationToken cancellationToken = default) { var fullAssetPath = Path.GetFullPath(assetPath); var startInfo = CodeIndex.ProcessLaunchPolicy.CreateNoShellStartInfo( - fileName: "gh", + fileName: GitHubCliExecutableResolver.ResolvePathOrThrow(cancellationToken), workingDirectory: Path.GetDirectoryName(fullAssetPath) ?? string.Empty); CodeIndex.ProcessLaunchPolicy.AddArguments( startInfo, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 322323bb2..ebf39cd56 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -154,6 +154,7 @@ public static int RunStatus( status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages(status.ProjectRoot).OrderBy(l => l).ToList(); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(status.ProjectRoot); status.GitExecutable = GitHelper.GetGitExecutableStatus(); + status.GitHubCliExecutable = GitHubCliExecutableResolver.GetStatus(cancellationToken); var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); var postExtractionHooks = postExtractionHookSnapshot.Hooks; if (postExtractionHookSnapshot.Diagnostics.Count > 0) @@ -161,6 +162,7 @@ public static int RunStatus( var trustOverrides = ExtractorPluginRegistry.GetAcceptedTrustOverrides(status.ProjectRoot) .Concat(postExtractionHookSnapshot.TrustOverrides) .Concat(GitHelper.GetAcceptedTrustOverrides(status.GitExecutable)) + .Concat(GitHubCliExecutableResolver.GetAcceptedTrustOverrides(status.GitHubCliExecutable)) .ToList(); if (trustOverrides.Count > 0) status.TrustOverrides = trustOverrides; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs b/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs index 8043b62c1..f05cac630 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.StatusFields.cs @@ -331,11 +331,11 @@ private sealed record StatusFieldExplanation( "trust_overrides", "Accepted trust overrides", "each entry records a supported trust-boundary override that was explicitly accepted after validation.", - "absence means no supported override widened extractor, hook, or Git executable trust for this status snapshot.", + "absence means no supported override widened extractor, hook, Git, or GitHub CLI executable trust for this status snapshot.", "Audit the named environment variable and sanitized value/path; unset it to return to the default trust boundary.", - Meaning: "Audits accepted environment overrides that authorize workspace plugins, hook directories, or a Git executable.", - Source: "Extractor, hook-directory, and Git executable trust resolvers after validation and sanitization.", - Dependencies: ["extractors", "hooks", "git_executable"], + Meaning: "Audits accepted environment overrides that authorize workspace plugins, hook directories, Git, or GitHub CLI executables.", + Source: "Extractor, hook-directory, Git, and GitHub CLI executable trust resolvers after validation and sanitization.", + Dependencies: ["extractors", "hooks", "git_executable", "github_cli_executable"], Interpretation: "Only accepted overrides appear; rejected candidates remain in their owning diagnostic section."), new( "db_pragma_settings", @@ -425,6 +425,12 @@ private sealed record StatusFieldExplanation( "`accepted=true` means cdidx validated the absolute Git path, metadata type, owner/mode and ancestor trust, then successfully executed `git --version`.", "`accepted=false` includes a stable `reason` identifying the failed path, metadata, owner, mode, ancestor, or execution probe.", $"Set `{GitHelper.GitExecutableEnvironmentVariable}` to a trusted absolute `git` path (`git.exe` on Windows), then inspect the nested diagnostics again."), + new( + "github_cli_executable", + "Trusted GitHub CLI executable selection", + "`accepted=true` means cdidx validated the absolute GitHub CLI path, metadata type, owner/mode and ancestor trust, then successfully executed a bounded `gh --version` probe.", + "`accepted=false` includes a stable `reason` identifying the failed path, metadata, owner, mode, ancestor, image, identity, or execution probe.", + $"Install GitHub CLI in a standard location or set `{GitHubCliExecutableResolver.ExecutableEnvironmentVariable}` to a trusted absolute `gh` path (`gh.exe` on Windows), then inspect the nested diagnostics again."), new( "indexed_head_commit", "Legacy full-scan HEAD stamp", diff --git a/src/CodeIndex/Cli/TrustedExecutableValidator.cs b/src/CodeIndex/Cli/TrustedExecutableValidator.cs new file mode 100644 index 000000000..0b5d5992a --- /dev/null +++ b/src/CodeIndex/Cli/TrustedExecutableValidator.cs @@ -0,0 +1,639 @@ +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal sealed record TrustedExecutableValidation( + string? Path, + string Source, + bool Accepted, + string Reason, + string? DiagnosticPath, + bool? OwnerOnlyWritable, + string? UnixMode, + bool? Executable, + string? Owner, + bool? OwnerTrusted, + bool? AncestorDirectoriesTrusted); + +internal static class TrustedExecutableValidator +{ + private const int UnixExecuteAccess = 1; + private const int MaxPortableExecutableHeaderOffset = 16 * 1024 * 1024; + + internal static TrustedExecutableValidation Evaluate( + string path, + string source, + string expectedUnixFileName, + string expectedWindowsFileName, + Func? executionProbe, + bool allowMacHomebrewAdminGroupWrite = false) + { + var before = EvaluateWithoutExecutionProbe( + path, + source, + expectedUnixFileName, + expectedWindowsFileName, + allowMacHomebrewAdminGroupWrite); + if (!before.Accepted || executionProbe == null) + return before; + + FileIndexer.FileIdentity? initialIdentity = + FileIndexer.TryGetFileIdentity(before.Path!, out var observedIdentity) + ? observedIdentity + : null; + if (!executionProbe(before.Path!)) + return RejectFrom(before, "execution_probe_failed", executable: false); + + var after = EvaluateWithoutExecutionProbe( + before.Path!, + source, + expectedUnixFileName, + expectedWindowsFileName, + allowMacHomebrewAdminGroupWrite); + if (!after.Accepted) + return after; + if (!string.Equals( + before.Path, + after.Path, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + return RejectFrom(after, "identity_changed", executable: false); + if (initialIdentity.HasValue + && (!FileIndexer.TryGetFileIdentity(after.Path!, out var finalIdentity) + || finalIdentity != initialIdentity.Value)) + { + return RejectFrom(after, "identity_changed", executable: false); + } + + return after with { Executable = true }; + } + + private static TrustedExecutableValidation EvaluateWithoutExecutionProbe( + string path, + string source, + string expectedUnixFileName, + string expectedWindowsFileName, + bool allowMacHomebrewAdminGroupWrite) + { + if (string.IsNullOrWhiteSpace(path)) + return Reject(source, "path_empty", null); + if (!Path.IsPathFullyQualified(path)) + return Reject(source, "path_not_absolute", null); + + string fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException) + { + return Reject(source, "invalid_path", null); + } + + var diagnosticPath = DiagnosticSanitizer.ForPath(fullPath); + if (fullPath.IndexOfAny(['\r', '\n']) >= 0) + return Reject(source, "path_contains_line_break", diagnosticPath); + if (!HasExpectedExecutableName(fullPath, expectedUnixFileName, expectedWindowsFileName)) + return Reject(source, "unexpected_filename", diagnosticPath); + + if (!TryValidateRegularEntry(fullPath, source, diagnosticPath, out var entryFailure)) + return entryFailure!; + if (OperatingSystem.IsWindows() + && !TryValidateExecutableAncestors(fullPath, effectiveUserId: null)) + return Reject(source, "ancestor_untrusted", diagnosticPath, ancestorDirectoriesTrusted: false); + + if (OperatingSystem.IsWindows()) + { + if (!TryValidateWindowsExecutableAcl(fullPath, out var windowsOwner, out var windowsOwnerTrusted)) + { + return Reject( + source, + "acl_untrusted", + diagnosticPath, + owner: windowsOwner, + ownerTrusted: windowsOwnerTrusted, + ancestorDirectoriesTrusted: false); + } + if (!TryValidateWindowsExecutableImage(fullPath)) + { + return Reject( + source, + "invalid_executable_format", + diagnosticPath, + executable: false, + owner: windowsOwner, + ownerTrusted: windowsOwnerTrusted, + ancestorDirectoriesTrusted: true); + } + + return Accept( + fullPath, + source, + diagnosticPath, + executable: true, + owner: windowsOwner, + ownerTrusted: windowsOwnerTrusted, + ancestorDirectoriesTrusted: true); + } + + if (!TryGetEffectiveUnixUserId(out var effectiveUserId)) + return Reject(source, "owner_probe_failed", diagnosticPath); + if (!TryResolveRealUnixPath(fullPath, out var canonicalPath)) + return Reject(source, "canonicalization_failed", diagnosticPath); + if (!HasExpectedExecutableName(canonicalPath, expectedUnixFileName, expectedWindowsFileName)) + return Reject(source, "unexpected_filename", DiagnosticSanitizer.ForPath(canonicalPath)); + + fullPath = canonicalPath; + diagnosticPath = DiagnosticSanitizer.ForPath(fullPath); + if (fullPath.IndexOfAny(['\r', '\n']) >= 0) + return Reject(source, "path_contains_line_break", diagnosticPath); + if (!TryValidateRegularEntry(fullPath, source, diagnosticPath, out entryFailure)) + return entryFailure!; + + UnixFileMode mode; + try + { + mode = File.GetUnixFileMode(LongPath.EnsureWindowsPrefix(fullPath)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + return Reject(source, "mode_probe_failed", diagnosticPath); + } + + var ownerOnlyWritable = (mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) == 0; + var unixMode = FormatUnixMode(mode); + if (!ownerOnlyWritable) + { + return Reject( + source, + "shared_writable", + diagnosticPath, + ownerOnlyWritable, + unixMode); + } + + if (!FileIndexer.TryGetUnixFileOwnerId(LongPath.EnsureWindowsPrefix(fullPath), out var ownerId)) + { + return Reject( + source, + "owner_probe_failed", + diagnosticPath, + ownerOnlyWritable, + unixMode); + } + + var owner = ownerId == effectiveUserId ? "current_user" : ownerId == 0 ? "root" : "other"; + var ownerTrusted = ownerId == effectiveUserId || ownerId == 0; + if (!ownerTrusted) + { + return Reject( + source, + "owner_untrusted", + diagnosticPath, + ownerOnlyWritable, + unixMode, + owner: owner, + ownerTrusted: false); + } + + var ancestorsTrusted = TryValidateExecutableAncestors( + fullPath, + effectiveUserId, + allowMacHomebrewAdminGroupWrite); + if (!ancestorsTrusted) + { + return Reject( + source, + "ancestor_untrusted", + diagnosticPath, + ownerOnlyWritable, + unixMode, + owner: owner, + ownerTrusted: true, + ancestorDirectoriesTrusted: false); + } + + var executable = TryAccessUnixExecutable(fullPath); + if (!executable) + { + return Reject( + source, + "not_executable", + diagnosticPath, + ownerOnlyWritable, + unixMode, + executable: false, + owner, + ownerTrusted: true, + ancestorDirectoriesTrusted: true); + } + + return Accept( + fullPath, + source, + diagnosticPath, + ownerOnlyWritable, + unixMode, + executable: true, + owner, + ownerTrusted: true, + ancestorDirectoriesTrusted: true); + } + + private static bool TryValidateRegularEntry( + string path, + string source, + string diagnosticPath, + out TrustedExecutableValidation? failure) + { + failure = null; + FileAttributes attributes; + try + { + attributes = File.GetAttributes(LongPath.EnsureWindowsPrefix(path)); + } + catch (FileNotFoundException) + { + failure = Reject(source, "not_found", diagnosticPath); + return false; + } + catch (DirectoryNotFoundException) + { + failure = Reject(source, "not_found", diagnosticPath); + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + failure = Reject(source, "attribute_probe_failed", diagnosticPath); + return false; + } + + if ((attributes & FileAttributes.Directory) != 0) + failure = Reject(source, "not_regular_file", diagnosticPath); + else if (FileSystemBoundary.IsSymlinkOrReparsePoint(attributes)) + failure = Reject(source, "symlink_or_reparse_point", diagnosticPath); + else if (FileSystemBoundary.IsDevice(attributes)) + failure = Reject(source, "device", diagnosticPath); + return failure == null; + } + + private static TrustedExecutableValidation Accept( + string path, + string source, + string diagnosticPath, + bool? ownerOnlyWritable = null, + string? unixMode = null, + bool? executable = null, + string? owner = null, + bool? ownerTrusted = null, + bool? ancestorDirectoriesTrusted = null) + => new( + path, + source, + Accepted: true, + "accepted", + diagnosticPath, + ownerOnlyWritable, + unixMode, + executable, + owner, + ownerTrusted, + ancestorDirectoriesTrusted); + + private static TrustedExecutableValidation Reject( + string source, + string reason, + string? diagnosticPath, + bool? ownerOnlyWritable = null, + string? unixMode = null, + bool? executable = null, + string? owner = null, + bool? ownerTrusted = null, + bool? ancestorDirectoriesTrusted = null) + => new( + Path: null, + source, + Accepted: false, + reason, + diagnosticPath, + ownerOnlyWritable, + unixMode, + executable, + owner, + ownerTrusted, + ancestorDirectoriesTrusted); + + private static TrustedExecutableValidation RejectFrom( + TrustedExecutableValidation status, + string reason, + bool? executable) + => status with + { + Path = null, + Accepted = false, + Reason = reason, + Executable = executable, + }; + + private static bool HasExpectedExecutableName( + string path, + string expectedUnixFileName, + string expectedWindowsFileName) + => string.Equals( + Path.GetFileName(path), + OperatingSystem.IsWindows() ? expectedWindowsFileName : expectedUnixFileName, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static string FormatUnixMode(UnixFileMode mode) + => Convert.ToString((int)mode, 8).PadLeft(4, '0'); + + private static bool TryValidateExecutableAncestors( + string executablePath, + uint? effectiveUserId, + bool allowMacHomebrewAdminGroupWrite = false) + { + try + { + var current = Directory.GetParent(executablePath)?.FullName; + while (current != null) + { + var probe = FileSystemBoundary.TryGetAttributes(current, out var attributes); + if (probe != FileSystemBoundaryProbeStatus.Found + || (attributes & FileAttributes.Directory) == 0 + || FileSystemBoundary.IsSymlinkOrReparsePoint(attributes) + || FileSystemBoundary.IsDevice(attributes)) + { + return false; + } + + if (effectiveUserId is uint userId && !OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(LongPath.EnsureWindowsPrefix(current)); + if (!FileIndexer.TryGetUnixFileOwnerId(LongPath.EnsureWindowsPrefix(current), out var ownerId) + || (ownerId != userId && ownerId != 0)) + { + return false; + } + + var sharedWritable = (mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0; + var rootOwnedStickyDirectory = ownerId == 0 && (mode & UnixFileMode.StickyBit) != 0; + var trustedMacHomebrewCellar = allowMacHomebrewAdminGroupWrite + && IsTrustedMacHomebrewCellarAncestor(current, ownerId, userId, mode); + if (sharedWritable && !rootOwnedStickyDirectory && !trustedMacHomebrewCellar) + return false; + } + else if (effectiveUserId.HasValue) + { + return false; + } + + current = Directory.GetParent(current)?.FullName; + } + + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + private static bool IsTrustedMacHomebrewCellarAncestor( + string path, + uint ownerId, + uint effectiveUserId, + UnixFileMode mode) + { + const uint macOsAdminGroupId = 80; + if (!OperatingSystem.IsMacOS() + || ownerId != effectiveUserId + || (mode & UnixFileMode.OtherWrite) != 0 + || !FileIndexer.TryGetUnixFileOwnerAndGroupIds( + LongPath.EnsureWindowsPrefix(path), + out var observedOwnerId, + out var groupId) + || observedOwnerId != ownerId + || groupId != macOsAdminGroupId) + { + return false; + } + + var comparison = StringComparison.Ordinal; + return string.Equals(path, "/opt/homebrew/Cellar", comparison) + || string.Equals(path, "/usr/local/Cellar", comparison); + } + + [SupportedOSPlatform("windows")] + private static bool TryValidateWindowsExecutableAcl( + string executablePath, + out string? ownerCategory, + out bool ownerTrusted) + { + ownerCategory = null; + ownerTrusted = false; + try + { + using var identity = WindowsIdentity.GetCurrent(TokenAccessLevels.Query); + var currentUser = identity.User; + if (currentUser == null) + return false; + + var trustedSids = new HashSet(StringComparer.Ordinal) + { + currentUser.Value, + "S-1-5-18", + "S-1-5-32-544", + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + }; + + var current = executablePath; + var finalEntry = true; + while (current != null) + { + var attributes = File.GetAttributes(LongPath.EnsureWindowsPrefix(current)); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + FileSystemSecurity security = isDirectory + ? FileSystemAclExtensions.GetAccessControl( + new DirectoryInfo(current), + AccessControlSections.Owner | AccessControlSections.Access) + : FileSystemAclExtensions.GetAccessControl( + new FileInfo(current), + AccessControlSections.Owner | AccessControlSections.Access); + + var owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + if (owner == null) + return false; + + var trustedOwner = trustedSids.Contains(owner.Value); + if (finalEntry) + { + ownerCategory = owner.Value == currentUser.Value + ? "current_user" + : owner.Value == "S-1-5-18" + ? "system" + : owner.Value == "S-1-5-32-544" + ? "administrators" + : owner.Value == "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + ? "trusted_installer" + : "other"; + ownerTrusted = trustedOwner; + } + + if (!trustedOwner || HasUntrustedWindowsWriteRule(security, trustedSids, finalEntry)) + return false; + + finalEntry = false; + current = Directory.GetParent(current)?.FullName; + } + + return true; + } + catch (Exception) + { + return false; + } + } + + [SupportedOSPlatform("windows")] + private static bool HasUntrustedWindowsWriteRule( + FileSystemSecurity security, + IReadOnlySet trustedSids, + bool finalEntry) + { + var dangerousRights = FileSystemRights.Delete + | FileSystemRights.DeleteSubdirectoriesAndFiles + | FileSystemRights.ChangePermissions + | FileSystemRights.TakeOwnership; + if (finalEntry) + { + dangerousRights |= FileSystemRights.WriteData + | FileSystemRights.AppendData + | FileSystemRights.WriteAttributes + | FileSystemRights.WriteExtendedAttributes; + } + + foreach (var rule in security.GetAccessRules( + includeExplicit: true, + includeInherited: true, + typeof(SecurityIdentifier)).OfType()) + { + if (rule.AccessControlType != AccessControlType.Allow + || (rule.PropagationFlags & PropagationFlags.InheritOnly) != 0 + || rule.IdentityReference is not SecurityIdentifier sid + || trustedSids.Contains(sid.Value) + || sid.Value is "S-1-3-0" or "S-1-3-4") + { + continue; + } + + if ((rule.FileSystemRights & dangerousRights) != 0) + return true; + } + + return false; + } + + private static bool TryValidateWindowsExecutableImage(string path) + { + try + { + using var stream = new FileStream( + LongPath.EnsureWindowsPrefix(path), + FileMode.Open, + FileAccess.Read, + FileShare.Read | FileShare.Delete); + Span dosHeader = stackalloc byte[64]; + stream.ReadExactly(dosHeader); + if (dosHeader[0] != (byte)'M' || dosHeader[1] != (byte)'Z') + return false; + + var peOffset = BinaryPrimitives.ReadInt32LittleEndian(dosHeader[0x3c..]); + if (peOffset < dosHeader.Length + || peOffset > MaxPortableExecutableHeaderOffset + || peOffset > stream.Length - 4) + { + return false; + } + + stream.Position = peOffset; + Span peSignature = stackalloc byte[4]; + stream.ReadExactly(peSignature); + return peSignature.SequenceEqual("PE\0\0"u8); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return false; + } + } + + internal static bool TryResolveRealUnixPath(string path, out string resolvedPath) + { + resolvedPath = string.Empty; + IntPtr pointer = IntPtr.Zero; + try + { + pointer = UnixRealPath(path, IntPtr.Zero); + if (pointer == IntPtr.Zero) + return false; + + var value = Marshal.PtrToStringUTF8(pointer); + if (string.IsNullOrEmpty(value)) + return false; + + resolvedPath = PathCasing.NormalizeBoundaryPath(value); + return true; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentException or IOException or NotSupportedException or PathTooLongException) + { + return false; + } + finally + { + if (pointer != IntPtr.Zero) + UnixFree(pointer); + } + } + + private static bool TryGetEffectiveUnixUserId(out uint userId) + { + userId = 0; + try + { + userId = UnixGetEffectiveUserId(); + return true; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return false; + } + } + + private static bool TryAccessUnixExecutable(string path) + { + try + { + return UnixAccess(path, UnixExecuteAccess) == 0; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return false; + } + } + + [DllImport("libc", EntryPoint = "realpath", SetLastError = true)] + private static extern IntPtr UnixRealPath(string path, IntPtr resolvedPath); + + [DllImport("libc", EntryPoint = "free")] + private static extern void UnixFree(IntPtr pointer); + + [DllImport("libc", EntryPoint = "geteuid")] + private static extern uint UnixGetEffectiveUserId(); + + [DllImport("libc", EntryPoint = "access", SetLastError = true)] + private static extern int UnixAccess(string path, int mode); +} diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.FileIdentity.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.FileIdentity.cs index 4e0d346a4..f4cd8aeed 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.FileIdentity.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.FileIdentity.cs @@ -315,8 +315,12 @@ internal static bool TryGetFileIdentity(string path, out FileIdentity identity, } internal static bool TryGetUnixFileOwnerId(string path, out uint ownerId) + => TryGetUnixFileOwnerAndGroupIds(path, out ownerId, out _); + + internal static bool TryGetUnixFileOwnerAndGroupIds(string path, out uint ownerId, out uint groupId) { ownerId = 0; + groupId = 0; if (!IsLinuxPlatform && !IsMacOSPlatform) return false; @@ -328,6 +332,7 @@ internal static bool TryGetUnixFileOwnerId(string path, out uint ownerId) return false; ownerId = stat.Uid; + groupId = stat.Gid; return true; } @@ -335,6 +340,7 @@ internal static bool TryGetUnixFileOwnerId(string path, out uint ownerId) return false; ownerId = linuxStat.Uid; + groupId = linuxStat.Gid; return true; } catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) @@ -513,6 +519,9 @@ private struct LinuxStatx [FieldOffset(20)] public uint Uid; + [FieldOffset(24)] + public uint Gid; + [FieldOffset(32)] public ulong Inode; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index 3be345118..47d842ee2 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -78,6 +78,7 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages(status.ProjectRoot).OrderBy(l => l).ToList(); status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(status.ProjectRoot); status.GitExecutable = GitHelper.GetGitExecutableStatus(); + status.GitHubCliExecutable = GitHubCliExecutableResolver.GetStatus(requestToken); var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); var postExtractionHooks = postExtractionHookSnapshot.Hooks; if (postExtractionHookSnapshot.Diagnostics.Count > 0) @@ -85,6 +86,7 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) var trustOverrides = ExtractorPluginRegistry.GetAcceptedTrustOverrides(status.ProjectRoot) .Concat(postExtractionHookSnapshot.TrustOverrides) .Concat(GitHelper.GetAcceptedTrustOverrides(status.GitExecutable)) + .Concat(GitHubCliExecutableResolver.GetAcceptedTrustOverrides(status.GitHubCliExecutable)) .ToList(); if (trustOverrides.Count > 0) status.TrustOverrides = trustOverrides; @@ -487,6 +489,8 @@ private JsonObject BuildMcpCompactStatusPayload(StatusResult status, IReadOnlyLi payload["trust_overrides"] = JsonSerializer.SerializeToNode(status.TrustOverrides); if (status.GitExecutable is not null) payload["git_executable"] = JsonSerializer.SerializeToNode(status.GitExecutable); + if (status.GitHubCliExecutable is not null) + payload["github_cli_executable"] = JsonSerializer.SerializeToNode(status.GitHubCliExecutable); return payload; } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 0141265ca..b711a087f 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1354,8 +1354,8 @@ public sealed class StatusQueryContext } /// -/// Runtime diagnostics for the Git executable selected by cdidx. -/// cdidx が選択した Git 実行ファイルの runtime 診断。 +/// Runtime diagnostics for a trusted external executable selected by cdidx. +/// cdidx が選択した信頼済み外部実行ファイルの runtime 診断。 /// public sealed record GitExecutableStatus( [property: JsonPropertyName("source")] string Source, @@ -1754,6 +1754,9 @@ public class StatusResult [JsonPropertyName("git_executable")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public GitExecutableStatus? GitExecutable { get; set; } + [JsonPropertyName("github_cli_executable")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public GitExecutableStatus? GitHubCliExecutable { get; set; } [JsonPropertyName("extractors")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ExtractorRegistryStatus? Extractors { get; set; } diff --git a/tests/CodeIndex.Tests/GitHubCliExecutableResolverTests.cs b/tests/CodeIndex.Tests/GitHubCliExecutableResolverTests.cs new file mode 100644 index 000000000..5be47c429 --- /dev/null +++ b/tests/CodeIndex.Tests/GitHubCliExecutableResolverTests.cs @@ -0,0 +1,365 @@ +using CodeIndex.Cli; +using System.Runtime.Versioning; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public sealed class GitHubCliExecutableResolverTests +{ + [Fact] + public void KnownCandidates_AreAbsoluteExpectedNamesAndNeverUseCurrentDirectory_Issue5184() + { + var candidates = GitHubCliExecutableResolver.KnownCandidatePathsForTests(); + + Assert.NotEmpty(candidates); + Assert.All(candidates, candidate => + { + Assert.True(Path.IsPathFullyQualified(candidate)); + Assert.Equal( + OperatingSystem.IsWindows() ? "gh.exe" : "gh", + Path.GetFileName(candidate), + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + Assert.NotEqual(Path.GetFullPath("gh"), candidate); + }); + } + + [Fact] + public void HomebrewCellarCandidates_OrderSemanticallyBeforeCappingWithoutLaunchingBinSymlinks_Issue5184() + { + var prefix = TestProjectHelper.CreateTempProject("cdidx_gh_homebrew_5184"); + try + { + var expected = Enumerable.Range(9, 32) + .Reverse() + .Select(minor => Path.Combine(prefix, "Cellar", "gh", $"2.{minor}.0", "bin", "gh")) + .ToArray(); + foreach (var minor in Enumerable.Range(1, 40)) + { + Directory.CreateDirectory( + Path.Combine(prefix, "Cellar", "gh", $"2.{minor}.0", "bin")); + } + + var candidates = GitHubCliExecutableResolver.HomebrewCellarCandidatePathsForTests(prefix); + + Assert.Equal(expected, candidates); + Assert.All(candidates, candidate => + { + Assert.True(Path.IsPathFullyQualified(candidate)); + Assert.Equal("gh", Path.GetFileName(candidate)); + Assert.Contains( + $"{Path.DirectorySeparatorChar}Cellar{Path.DirectorySeparatorChar}gh{Path.DirectorySeparatorChar}", + candidate, + StringComparison.Ordinal); + }); + } + finally + { + TestProjectHelper.DeleteDirectory(prefix); + } + } + + [ExternalProcessFact] + public void PathFirstSubstitute_IsNeverExecutedWhenNoTrustedVerifierExists_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_gh_path_substitute_5184"); + var markerPath = Path.Combine(root, "executed.marker"); + var fakePathDirectory = Path.Combine(root, "path-first"); + Directory.CreateDirectory(fakePathDirectory); + var fakeGhPath = Path.Combine(fakePathDirectory, "gh"); + File.WriteAllText(fakeGhPath, $"#!/bin/sh\nprintf executed > '{markerPath}'\nexit 0\n"); + File.SetUnixFileMode( + fakeGhPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + using var env = EnvironmentVariableScope.Capture( + "PATH", + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var oldCandidates = GitHubCliExecutableResolver.CandidatePathsForTesting; + var oldProbe = GitHubCliExecutableResolver.VersionProbeForTesting; + env.Set("PATH", fakePathDirectory); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, null); + GitHubCliExecutableResolver.CandidatePathsForTesting = []; + GitHubCliExecutableResolver.VersionProbeForTesting = null; + try + { + var assetPath = Path.Combine(root, "asset.txt"); + File.WriteAllText(assetPath, "asset"); + + Assert.Throws( + () => ProgramRunner.CreateUpgradeAttestationStartInfo(assetPath, "v9.9.9")); + Assert.False(File.Exists(markerPath)); + } + finally + { + GitHubCliExecutableResolver.CandidatePathsForTesting = oldCandidates; + GitHubCliExecutableResolver.VersionProbeForTesting = oldProbe; + TestProjectHelper.DeleteDirectory(root); + } + } + + [ExternalProcessFact] + public void ValidTrustedVerifier_IsPinnedAndReceivesExactAttestationArguments_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_gh_attestation_5184"); + var argsPath = Path.Combine(root, "args.txt"); + var ghPath = Path.Combine(root, "gh"); + var assetPath = Path.Combine(root, "release asset.txt"); + File.WriteAllText(assetPath, "asset"); + File.WriteAllText( + ghPath, + $""" + #!/bin/sh + if [ "$1" = "--version" ]; then + printf 'gh version 2.99.0\n' + exit 0 + fi + : > '{argsPath}' + for arg in "$@"; do + printf '%s\n' "$arg" >> '{argsPath}' + done + exit 0 + """); + File.SetUnixFileMode( + ghPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, ghPath); + try + { + var startInfo = ProgramRunner.CreateUpgradeAttestationStartInfo(assetPath, "v9.9.9"); + var result = ProgramRunner.RunInstallerProcessDetailed( + startInfo, + TimeSpan.FromSeconds(10), + suppressOutput: true); + + Assert.True(TrustedExecutableValidator.TryResolveRealUnixPath(ghPath, out var canonicalGhPath)); + Assert.Equal(CommandExitCodes.Success, result.ExitCode); + Assert.True(Path.IsPathFullyQualified(startInfo.FileName)); + Assert.Equal(canonicalGhPath, startInfo.FileName); + Assert.Equal( + [ + "attestation", + "verify", + Path.GetFullPath(assetPath), + "-R", + "Widthdom/CodeIndex", + "--signer-workflow", + "github.com/Widthdom/CodeIndex/.github/workflows/release.yml", + "--source-ref", + "refs/tags/v9.9.9", + ], + File.ReadAllLines(argsPath)); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void EnvironmentOverride_HasFailClosedPrecedenceAndProducesTrustDiagnostics_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_gh_override_5184"); + var ghPath = CreatePosixGh(root); + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var oldCandidates = GitHubCliExecutableResolver.CandidatePathsForTesting; + var oldProbe = GitHubCliExecutableResolver.VersionProbeForTesting; + var probeCount = 0; + GitHubCliExecutableResolver.CandidatePathsForTesting = [ghPath]; + GitHubCliExecutableResolver.VersionProbeForTesting = (_, _) => + { + probeCount++; + return true; + }; + try + { + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, "gh"); + var rejected = GitHubCliExecutableResolver.GetStatus(); + + Assert.Equal("environment_override", rejected.Source); + Assert.False(rejected.Accepted); + Assert.Equal("path_not_absolute", rejected.Reason); + Assert.Equal(0, probeCount); + Assert.Empty(GitHubCliExecutableResolver.GetAcceptedTrustOverrides(rejected)); + + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, ghPath); + var accepted = GitHubCliExecutableResolver.GetStatus(); + var trustOverride = Assert.Single( + GitHubCliExecutableResolver.GetAcceptedTrustOverrides(accepted)); + + Assert.True(accepted.Accepted); + Assert.Equal("accepted", accepted.Reason); + Assert.Equal("gh", accepted.Path); + Assert.Equal(1, probeCount); + Assert.Equal("github_cli_executable", trustOverride.Kind); + Assert.Equal( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable, + trustOverride.EnvironmentVariable); + Assert.Contains("mode 0700", trustOverride.Message, StringComparison.Ordinal); + } + finally + { + GitHubCliExecutableResolver.CandidatePathsForTesting = oldCandidates; + GitHubCliExecutableResolver.VersionProbeForTesting = oldProbe; + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void GetStatus_PropagatesCancellationIntoVersionProbe_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_gh_status_cancel_5184"); + var ghPath = CreatePosixGh(root); + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var oldProbe = GitHubCliExecutableResolver.VersionProbeForTesting; + using var cancellation = new CancellationTokenSource(); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, ghPath); + GitHubCliExecutableResolver.VersionProbeForTesting = (_, token) => + { + Assert.True(token.CanBeCanceled); + cancellation.Cancel(); + token.ThrowIfCancellationRequested(); + return true; + }; + try + { + Assert.Throws( + () => GitHubCliExecutableResolver.GetStatus(cancellation.Token)); + } + finally + { + GitHubCliExecutableResolver.VersionProbeForTesting = oldProbe; + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void PosixCandidateValidation_RejectsSymlinkModeExecuteTypeAndAncestorFailures_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_gh_validation_5184"); + try + { + var ghPath = CreatePosixGh(root); + var accepted = GitHubCliExecutableResolver.EvaluateCandidateForTesting(ghPath); + Assert.True(accepted.Accepted); + Assert.True(accepted.OwnerOnlyWritable); + Assert.Equal("0700", accepted.UnixMode); + Assert.True(accepted.Executable); + Assert.True(accepted.OwnerTrusted); + Assert.True(accepted.AncestorDirectoriesTrusted); + + File.SetUnixFileMode( + ghPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupWrite); + Assert.Equal( + "shared_writable", + GitHubCliExecutableResolver.EvaluateCandidateForTesting(ghPath).Reason); + + File.SetUnixFileMode(ghPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + Assert.Equal( + "not_executable", + GitHubCliExecutableResolver.EvaluateCandidateForTesting(ghPath).Reason); + + var directoryCandidate = Path.Combine(root, "directory", "gh"); + Directory.CreateDirectory(directoryCandidate); + Assert.Equal( + "not_regular_file", + GitHubCliExecutableResolver.EvaluateCandidateForTesting(directoryCandidate).Reason); + + var target = Path.Combine(root, "target"); + File.WriteAllText(target, "target"); + var symlinkCandidate = Path.Combine(root, "symlink", "gh"); + Directory.CreateDirectory(Path.GetDirectoryName(symlinkCandidate)!); + File.CreateSymbolicLink(symlinkCandidate, target); + Assert.Equal( + "symlink_or_reparse_point", + GitHubCliExecutableResolver.EvaluateCandidateForTesting(symlinkCandidate).Reason); + + var unsafeDirectory = Path.Combine(root, "unsafe-ancestor"); + Directory.CreateDirectory(unsafeDirectory); + var unsafeGh = CreatePosixGh(unsafeDirectory); + File.SetUnixFileMode( + unsafeDirectory, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute); + Assert.Equal( + "ancestor_untrusted", + GitHubCliExecutableResolver.EvaluateCandidateForTesting(unsafeGh).Reason); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void WindowsCandidateValidation_RequiresTrustedAclAndExecutableImage_Issue5184() + { + if (!OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTrustedWindowsGitDirectory("cdidx_windows_gh_5184"); + var ghPath = Path.Combine(root, "gh.exe"); + try + { + File.WriteAllText(ghPath, "not a PE image"); + var invalidImage = GitHubCliExecutableResolver.EvaluateCandidateForTesting(ghPath); + Assert.False(invalidImage.Accepted); + Assert.Equal("invalid_executable_format", invalidImage.Reason); + + File.Copy(Environment.ProcessPath!, ghPath, overwrite: true); + var oldProbe = GitHubCliExecutableResolver.VersionProbeForTesting; + GitHubCliExecutableResolver.VersionProbeForTesting = (_, _) => true; + try + { + var accepted = GitHubCliExecutableResolver.EvaluateCandidateForTesting( + ghPath, + probeVersion: true); + Assert.True(accepted.Accepted); + Assert.True(accepted.OwnerTrusted); + Assert.True(accepted.AncestorDirectoriesTrusted); + Assert.True(accepted.Executable); + } + finally + { + GitHubCliExecutableResolver.VersionProbeForTesting = oldProbe; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [UnsupportedOSPlatform("windows")] + private static string CreatePosixGh(string root) + { + Directory.CreateDirectory(root); + var ghPath = Path.Combine(root, "gh"); + File.WriteAllText(ghPath, "#!/bin/sh\nprintf 'gh version 2.99.0\\n'\n"); + File.SetUnixFileMode( + ghPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return ghPath; + } +} diff --git a/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs b/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs index 448553ecc..ac6700e75 100644 --- a/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs +++ b/tests/CodeIndex.Tests/JsonOutputSnapshotTests.cs @@ -122,6 +122,18 @@ private static string NormalizeStatusSnapshotConnectionMode(string stdout) gitExecutable["executable"] = true; } + if (root["github_cli_executable"] is JsonObject githubCliExecutable) + { + // Host GitHub CLI availability and ambient CDIDX_GH_EXECUTABLE are intentionally + // outside this golden contract. Dedicated Issue5184 tests pin resolver diagnostics. + githubCliExecutable.Clear(); + githubCliExecutable["source"] = "normalized"; + githubCliExecutable["accepted"] = true; + githubCliExecutable["reason"] = "accepted"; + githubCliExecutable["path"] = ""; + githubCliExecutable["executable"] = true; + } + return root.ToJsonString(); } diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 15cc00ca6..6f4d24617 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -5317,6 +5317,64 @@ public void ToolsCall_Status_CheckCompactExposesReadinessDiagnostics_Issue3541() Assert.False(string.IsNullOrWhiteSpace(structured["log_path"]!.GetValue())); } + [Fact] + public void ToolsCall_Status_CompactReportsRejectedGitHubCliOverride_Issue5184() + { + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, "gh"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":5184,"method":"tools/call","params":{"name":"status","arguments":{"format":"compact"}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + var githubCliExecutable = structured["github_cli_executable"]!; + Assert.Equal("environment_override", githubCliExecutable["source"]!.GetValue()); + Assert.False(githubCliExecutable["accepted"]!.GetValue()); + Assert.Equal("path_not_absolute", githubCliExecutable["reason"]!.GetValue()); + Assert.DoesNotContain( + structured["trust_overrides"]?.AsArray() ?? [], + item => item!["kind"]!.GetValue() == "github_cli_executable"); + } + + [Fact] + public void ToolsCall_Status_CompactReportsAcceptedGitHubCliOverride_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + var root = TestProjectHelper.CreateTempProject("cdidx_mcp_gh_status_5184"); + var ghPath = Path.Combine(root, "gh"); + File.WriteAllText(ghPath, "#!/bin/sh\nprintf 'gh version 2.99.0\\n'\n"); + File.SetUnixFileMode( + ghPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var oldProbe = GitHubCliExecutableResolver.VersionProbeForTesting; + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, ghPath); + GitHubCliExecutableResolver.VersionProbeForTesting = (_, _) => true; + try + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":5184,"method":"tools/call","params":{"name":"status","arguments":{"format":"compact"}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + var githubCliExecutable = structured["github_cli_executable"]!; + Assert.True(githubCliExecutable["accepted"]!.GetValue()); + Assert.Equal("accepted", githubCliExecutable["reason"]!.GetValue()); + Assert.Equal("gh", githubCliExecutable["path"]!.GetValue()); + Assert.Contains( + structured["trust_overrides"]!.AsArray(), + item => item!["kind"]!.GetValue() == "github_cli_executable"); + } + finally + { + GitHubCliExecutableResolver.VersionProbeForTesting = oldProbe; + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void ToolsCall_Status_FieldsProjectsExactCompactFields_Issue4724() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 6d4f6ae4a..337620b5b 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -2218,6 +2218,23 @@ public void EnvironmentVariableInventory_IncludesSecretAndPolicyClassifications( Assert.Equal("no", gitExecutable.ConfigFileSupported); Assert.Contains("fail closed", gitExecutable.InvalidValueBehavior, StringComparison.OrdinalIgnoreCase); + var githubCliExecutable = Assert.Contains( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable, + byName); + Assert.Equal(EnvironmentVariableInventory.DomainTrustBoundary, githubCliExecutable.Domain); + Assert.Equal("github", githubCliExecutable.Category); + Assert.Equal(EnvironmentVariableInventory.SensitivityPublic, githubCliExecutable.Sensitivity); + Assert.Equal("security", githubCliExecutable.Policy); + Assert.Equal("no", githubCliExecutable.ConfigFileSupported); + Assert.Contains( + "fail closed", + githubCliExecutable.InvalidValueBehavior, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "status.github_cli_executable", + githubCliExecutable.InvalidValueBehavior, + StringComparison.Ordinal); + var updateDisable = Assert.Contains(UpdateChecker.DisableEnvVar, byName); Assert.Equal(EnvironmentVariableInventory.DomainUpdateLogging, updateDisable.Domain); Assert.Contains("1 or true", updateDisable.InvalidValueBehavior, StringComparison.Ordinal); @@ -3978,24 +3995,156 @@ public void RunUpgrade_PassesCancellationAndVerifiesBothReleaseAssets_Issue4603( [Fact] public void CreateUpgradeAttestationStartInfo_PinsReleaseWorkflowAndTag_Issue4603() { - var assetPath = Path.GetFullPath(TestProjectHelper.CreateTempFilePath("cdidx-attestation", ".txt")); + if (OperatingSystem.IsWindows()) + return; - var startInfo = ProgramRunner.CreateUpgradeAttestationStartInfo(assetPath, "v9.9.9"); + var root = TestProjectHelper.CreateTempProject("cdidx-attestation"); + var assetPath = Path.Combine(root, "asset.txt"); + var ghPath = Path.Combine(root, "gh"); + File.WriteAllText(assetPath, "asset"); + File.WriteAllText(ghPath, "#!/bin/sh\nprintf 'gh version 2.99.0\\n'\n"); + File.SetUnixFileMode( + ghPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var oldProbe = GitHubCliExecutableResolver.VersionProbeForTesting; + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, ghPath); + GitHubCliExecutableResolver.VersionProbeForTesting = (_, _) => true; + try + { + var startInfo = ProgramRunner.CreateUpgradeAttestationStartInfo(assetPath, "v9.9.9"); - Assert.Equal("gh", startInfo.FileName); - Assert.Equal( - [ - "attestation", - "verify", - assetPath, - "-R", - "Widthdom/CodeIndex", - "--signer-workflow", - "github.com/Widthdom/CodeIndex/.github/workflows/release.yml", - "--source-ref", - "refs/tags/v9.9.9", - ], - startInfo.ArgumentList); + Assert.True(TrustedExecutableValidator.TryResolveRealUnixPath(ghPath, out var canonicalGhPath)); + Assert.Equal(canonicalGhPath, startInfo.FileName); + Assert.Equal( + [ + "attestation", + "verify", + assetPath, + "-R", + "Widthdom/CodeIndex", + "--signer-workflow", + "github.com/Widthdom/CodeIndex/.github/workflows/release.yml", + "--source-ref", + "refs/tags/v9.9.9", + ], + startInfo.ArgumentList); + } + finally + { + GitHubCliExecutableResolver.VersionProbeForTesting = oldProbe; + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void RunUpgrade_StrictPolicy_BlocksBeforeInstallerWhenTrustedGitHubCliIsUnavailable_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + "XDG_CACHE_HOME", + UpdateChecker.DisableEnvVar, + "CDIDX_VERIFY_POLICY", + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var cacheRoot = TestProjectHelper.CreateTempProject("cdidx_update_cache"); + var markerPath = TestProjectHelper.CreateTempFilePath("cdidx_strict_gh_5184", ".marker"); + env.Set("XDG_CACHE_HOME", cacheRoot); + env.Set(UpdateChecker.DisableEnvVar, null); + env.Set("CDIDX_VERIFY_POLICY", null); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, "gh"); + WriteFreshUpdateCheckCache(cacheRoot, "v9.9.9"); + + var installerScript = $"#!/bin/sh\nprintf installed > '{markerPath}'\nexit 0\n"; + var installerSha256 = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(installerScript))) + .ToLowerInvariant(); + var checksumManifest = $"{installerSha256} install.sh\n"; + var previousFactory = ProgramRunner.UpgradeHttpClientFactory; + ProgramRunner.UpgradeHttpClientFactory = () => new HttpClient( + new UpgradeAssetResponseHandler(checksumManifest, installerScript, _ => { })) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.InstallError, exitCode); + Assert.Empty(stdout); + Assert.Contains("installer execution is blocked", stderr, StringComparison.Ordinal); + Assert.Contains("Could not resolve a trusted GitHub CLI executable", stderr, StringComparison.Ordinal); + Assert.False(File.Exists(markerPath)); + } + finally + { + ProgramRunner.UpgradeHttpClientFactory = previousFactory; + TestProjectHelper.DeleteFile(markerPath); + TestProjectHelper.DeleteDirectory(cacheRoot); + } + } + } + + [Fact] + public void RunUpgrade_CompatPolicy_WarnsWithoutClaimingVerificationWhenTrustedGitHubCliIsUnavailable_Issue5184() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + "XDG_CACHE_HOME", + UpdateChecker.DisableEnvVar, + "CDIDX_VERIFY_POLICY", + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + var cacheRoot = TestProjectHelper.CreateTempProject("cdidx_update_cache"); + var markerPath = TestProjectHelper.CreateTempFilePath("cdidx_compat_gh_5184", ".marker"); + env.Set("XDG_CACHE_HOME", cacheRoot); + env.Set(UpdateChecker.DisableEnvVar, null); + env.Set("CDIDX_VERIFY_POLICY", "compat"); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, "gh"); + WriteFreshUpdateCheckCache(cacheRoot, "v9.9.9"); + + var installerScript = $"#!/bin/sh\nprintf installed > '{markerPath}'\nexit 0\n"; + var installerSha256 = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(installerScript))) + .ToLowerInvariant(); + var checksumManifest = $"{installerSha256} install.sh\n"; + var previousFactory = ProgramRunner.UpgradeHttpClientFactory; + ProgramRunner.UpgradeHttpClientFactory = () => new HttpClient( + new UpgradeAssetResponseHandler(checksumManifest, installerScript, _ => { })) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stdout); + Assert.Contains("no trusted GitHub CLI verifier was available", stderr, StringComparison.Ordinal); + Assert.Contains("was not independently verified", stderr, StringComparison.Ordinal); + Assert.DoesNotContain("Verified independent release provenance", stderr, StringComparison.Ordinal); + Assert.True(File.Exists(markerPath)); + } + finally + { + ProgramRunner.UpgradeHttpClientFactory = previousFactory; + TestProjectHelper.DeleteFile(markerPath); + TestProjectHelper.DeleteDirectory(cacheRoot); + } + } } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs index b4b1493bf..a44e0309c 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFilesTests.cs @@ -941,6 +941,46 @@ public void RunStatus_Json_ReportsAcceptedExtensionTrustOverrides_3735() } } + [Fact] + public void RunStatus_Json_ReportsRejectedGitHubCliEnvironmentOverride_Issue5184() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_gh_5184"); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + GitHubCliExecutableResolver.ExecutableEnvironmentVariable); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + env.Set(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, "gh"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--db", dbPath, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var githubCliExecutable = document.RootElement.GetProperty("github_cli_executable"); + Assert.Equal( + "environment_override", + githubCliExecutable.GetProperty("source").GetString()); + Assert.False(githubCliExecutable.GetProperty("accepted").GetBoolean()); + Assert.Equal("path_not_absolute", githubCliExecutable.GetProperty("reason").GetString()); + if (document.RootElement.TryGetProperty("trust_overrides", out var trustOverrides)) + { + Assert.DoesNotContain( + trustOverrides.EnumerateArray(), + item => item.GetProperty("kind").GetString() == "github_cli_executable"); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + } + [Fact] public void RunStatus_Json_CapsSymbolKindCountsAndNames_3134() { @@ -1965,6 +2005,22 @@ public void RunStatus_Explain_PrintsGitExecutableDescriptionWithoutDatabase_Issu Assert.Contains(GitHelper.GitExecutableEnvironmentVariable, stdout); } + [Fact] + public void RunStatus_Explain_PrintsGitHubCliExecutableDescriptionWithoutDatabase_Issue5184() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--explain", "github_cli_executable"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains( + "Trusted GitHub CLI executable selection (github_cli_executable)", + stdout); + Assert.Contains("gh --version", stdout); + Assert.Contains(GitHubCliExecutableResolver.ExecutableEnvironmentVariable, stdout); + } + [Fact] public void RunStatus_Explain_RejectsUnknownStatusField() { diff --git a/tests/CodeIndex.Tests/golden/status.json b/tests/CodeIndex.Tests/golden/status.json index f428da15a..9a0fc9db7 100644 --- a/tests/CodeIndex.Tests/golden/status.json +++ b/tests/CodeIndex.Tests/golden/status.json @@ -145,6 +145,13 @@ "path": "\u003CGIT_EXECUTABLE\u003E", "executable": true }, + "github_cli_executable": { + "source": "normalized", + "accepted": true, + "reason": "accepted", + "path": "\u003CGH_EXECUTABLE\u003E", + "executable": true + }, "extractors": { "plugin_assembly_count": 0, "pattern_config_count": 0,