Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2948,7 +2948,28 @@ If every platform candidate is unavailable, the final temp fallback is a
hashed per-user `cdidx-u.../logs` directory under the OS temp root. Each
candidate is probed with a create/write/delete round trip before the logger
commits to it, so read-only state/cache/runtime mounts fall through to the
next candidate instead of losing the first log write. The file name is
next candidate instead of losing the first log write. Repository-configured
`metrics_path` and `global_tool_log_dir` values from `.cdidxrc.json` or
`.cdidx/config.json` use a stricter boundary: every existing component below
the config workspace is rejected when it is a symbolic link, junction, bind or
cross-device mount point, reparse point, device, or dangling link. The boundary
is revalidated before each mutation; on Linux, path and opened-handle mount IDs
from `statx` or the `/proc/self/fdinfo` fallback also reject same-device bind
mounts. On POSIX, directory creation, append,
permission changes, rotation, replacement, and deletion are additionally anchored
to the workspace directory handle with no-follow relative operations. Directory
parents are rebound from that root after mutation-time validation so a
moved ancestor is not reused. Guarded renames fsync their already-open destination
parent before reporting success. On Windows, the guard retains the workspace root's
final physical path so a retargeted root alias is not followed; root-handle-relative
native opens reject reparse points during name resolution, and guarded replacement
and deletion remain handle/root relative. The same
`global_tool_log_dir` guard covers lifecycle logs, file query traces, and the
bounded `last-failure.json` diagnostic. An unsafe value fails config validation
with the bounded `unsafe_output_path` diagnostic and does not create, append,
rotate, replace, delete, or chmod the external target. Explicit CLI and process
environment destinations retain their existing operator-controlled behavior.
The file name is
`stderr-YYYYMMDD.log`, timestamps inside the file are ISO-8601 UTC
(`yyyy-MM-ddTHH:mm:ss.fffZ`) using invariant culture, and the logger keeps
only the newest 30 daily files. `CDIDX_LOG_FORMAT` / `--log-format` switch
Expand Down Expand Up @@ -6950,7 +6971,26 @@ platform candidate がすべて使えない場合、最後の temp fallback は
配下のユーザー別 hashed `cdidx-u.../logs` ディレクトリです。各 candidate は
logger が採用する前に create/write/delete の往復で probe されるため、
read-only な state/cache/runtime mount は最初の log write を失うのではなく
次の candidate へ fall through します。ファイル名は
次の candidate へ fall through します。`.cdidxrc.json` または
`.cdidx/config.json` の repository config に由来する `metrics_path` と
`global_tool_log_dir` には、より厳格な境界を適用します。config workspace
配下の既存 component が symbolic link、junction、bind mount / cross-device mount point、
reparse point、device、dangling link のいずれかであれば拒否し、各 mutation の
直前にも境界を再検証します。Linux では `statx` または
`/proc/self/fdinfo` fallback から得た path と open 済み handle の mount ID も
比較して同一 device の bind mount を拒否します。POSIX ではさらに directory 作成、
append、permission 変更、rotation、置換、delete を workspace directory handle 起点の
no-follow relative operation へ固定し、mutation 時の検証後に directory parent を root
から再取得するため、外部へ移動済みの ancestor handle を再利用しません。guarded rename は
成功を返す前に open 済みの destination parent を fsync します。Windows では workspace
root の最終的な物理 path を guard が保持するため、差し替えられた root alias を再追跡しません。
root handle 相対の native open は name resolution 中の reparse point を拒否し、guarded
replacement / delete も handle / root 相対で実行します。同じ `global_tool_log_dir` guard を lifecycle log、file
query trace、上限付きの `last-failure.json` 診断にも適用します。安全でない値は
上限付きの `unsafe_output_path` 診断で config validation に失敗し、外部 target の
作成、追記、rotation、置換、削除、chmod は行いません。明示的な CLI と process
environment の保存先は、operator が制御する従来の挙動を維持します。
ファイル名は
`stderr-YYYYMMDD.log`、ファイル内 timestamp は invariant culture の
ISO-8601 UTC(`yyyy-MM-ddTHH:mm:ss.fffZ`)で、logger は新しい 30 日次
ファイルだけを保持します。`CDIDX_LOG_FORMAT` / `--log-format` は text と
Expand Down
14 changes: 14 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2706,6 +2706,13 @@ Run `cdidx status --log-path` to print the active log directory without opening

### Project-local configuration file (`.cdidx/config.json` / `.cdidxrc.json`)

Repository-configured `metrics_path` and `global_tool_log_dir` values must remain
inside the config workspace. Existing components below that workspace must not be
symbolic links, junctions, bind or cross-device mount points, reparse points,
devices or special files, or dangling links; unsafe values fail validation with
`unsafe_output_path`. Use the CLI flag or a real environment variable when an
outside destination is intentional.

You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). Before a config-dependent command runs, `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as scoped environment settings — so every existing env-var consumer picks them up without process-global mutation. Static commands that do not consume project settings (`license`, `--version`, help forms, shell completions, and any command's `--help`) skip config discovery and remain usable even when an unrelated project config is malformed. Discovery stops after checking a directory that contains `.git`, `.hg`, `.svn`, `cdidx.workspace.json`, or `.cdidx-workspace.json`, so a child workspace does not inherit a config file from an unrelated parent.

Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. For config-dependent commands, a malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and all detected offending fields. JSON mode returns the versioned command-error envelope with `error_code: "E024_CONFIG_INVALID"` and `category: "configuration"` instead of writing human-only text to stderr. Set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely.
Expand Down Expand Up @@ -6308,6 +6315,13 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が

### プロジェクト固有の設定ファイル (`.cdidx/config.json` / `.cdidxrc.json`)

repository config の `metrics_path` と `global_tool_log_dir` は config workspace
内に収まる必要があります。workspace 配下の既存 component には symbolic link、
junction、bind mount または cross-device mount、reparse point、device / special
file、dangling link を含めることはできず、安全でない値は
`unsafe_output_path` で検証に失敗します。意図的に外部の出力先を使う場合は CLI
flag または実際の環境変数を使ってください。

シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。config に依存する command の実行前に、`cdidx` はカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーを scoped environment setting として注入します。これにより、process-global な環境変数を変更せずに、既存の環境変数コンシューマが同じ値を受け取れます。プロジェクト設定を使用しない static command(`license`、`--version`、help 形式、shell completion、および各 command の `--help`)は config 探索を省略するため、無関係なプロジェクト設定が不正でも利用できます。探索は `.git`、`.hg`、`.svn`、`cdidx.workspace.json`、`.cdidx-workspace.json` を含むディレクトリを確認した後で停止するため、子 workspace が無関係な親ディレクトリの設定ファイルを継承しません。

優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。config に依存する command では、不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと検出できた該当フィールドすべてを示して終了コード `1` で終了します。JSON mode では human-only text を stderr に書く代わりに、`error_code: "E024_CONFIG_INVALID"` と `category: "configuration"` を持つ versioned command-error envelope を返します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。
Expand Down
25 changes: 25 additions & 0 deletions changelog.d/unreleased/5181.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
category: security
issues:
- 5181
affected:
- src/CodeIndex/Cli/CdidxConfigFile.cs
- src/CodeIndex/Cli/RepositoryOutputPathBoundary.cs
- src/CodeIndex/Cli/MetricsSink.cs
- src/CodeIndex/Cli/GlobalToolLog.cs
- src/CodeIndex/Cli/ProgramRunner.Metrics.cs
- src/CodeIndex/Cli/LastFailureEventStore.cs
- src/CodeIndex/Cli/PrivateLogFile.cs
- tests/CodeIndex.Tests/CdidxConfigFileTests.cs
- tests/CodeIndex.Tests/MetricsSinkTests.cs
- tests/CodeIndex.Tests/GlobalToolLogTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Repository-configured metrics and global-log paths can no longer escape through filesystem aliases (#5181)** — `metrics_path` and `global_tool_log_dir` now reject symbolic-link, junction, bind-mount, cross-device-mount, reparse-point, device/special-file, and dangling-link components below the config workspace. Mutations are revalidated, Linux path/open-handle mount IDs reject same-device bind mounts, and POSIX writes, directory creation, permission changes, rotation, replacement, and deletion are anchored to the workspace directory handle with mutation-time parent rebinding; guarded renames also fsync the destination parent before returning success. Windows guards retain the workspace root's final physical path and use root-handle-relative no-reparse native operations, so retargeting a root alias cannot redirect later mutations. The global-log guard covers query traces and `last-failure.json`, so an untrusted checkout cannot redirect them to an external target.

## 日本語

- **repository config の metrics / global-log path が filesystem alias を経由して外部へ逸脱できないようになりました (#5181)** — `metrics_path` と `global_tool_log_dir` は config workspace 配下の symbolic link、junction、bind mount、cross-device mount、reparse point、device / special file、dangling link を拒否します。mutation 前の再検証に加え、Linux では path / open 済み handle の mount ID で同一 device の bind mount も拒否し、POSIX の書き込み、directory 作成、permission 変更、rotation、置換、削除を mutation 時の parent 再取得を伴う workspace directory handle に固定します。guarded rename は成功を返す前に destination parent を fsync します。Windows guard は workspace root の最終的な物理 path を保持し、root handle 相対の no-reparse native operation を使うため、root alias を差し替えても後続 mutation を外部へリダイレクトできません。global-log guard は query trace と `last-failure.json` にも適用し、信頼できない checkout が外部 target へリダイレクトできないようにしました。
51 changes: 15 additions & 36 deletions src/CodeIndex/Cli/CdidxConfigFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ private static bool IsConfigDiscoveryBoundary(DirectoryInfo directory)
return false;
}

private static string ResolveConfigWorkspaceRoot(string configPath)
internal static string ResolveConfigWorkspaceRoot(string configPath)
{
var fullConfigPath = Path.GetFullPath(configPath);
var configDirectory = Path.GetDirectoryName(fullConfigPath) ?? Path.GetFullPath(".");
Expand Down Expand Up @@ -1005,45 +1005,24 @@ private static bool TryReadWorkspaceOutputPath(JsonElement element, string key,
return false;

var workspaceRoot = ResolveConfigWorkspaceRoot(path);
if (!TryResolveWorkspaceOutputPath(raw!, workspaceRoot, out value, out var pathError))
{
error = pathError;
var destinationIsDirectory = string.Equals(key, "global_tool_log_dir", StringComparison.Ordinal);
if (!RepositoryOutputPathBoundary.TryResolveConfiguredPath(
raw!,
workspaceRoot,
destinationIsDirectory,
out var resolvedPath,
out var pathFailure))
{
error = pathFailure == "outside_workspace"
? $"{FormatConfigDiagnosticPrefix(path)} `{key}` must resolve inside the config workspace root `{FormatConfigDiagnosticPath(workspaceRoot)}`."
: pathFailure == RepositoryOutputPathBoundary.UnsafeReason
? $"{FormatConfigDiagnosticPrefix(path)} `{key}` is unsafe ({RepositoryOutputPathBoundary.UnsafeReason}); symbolic links, junctions, bind or cross-device mount points, reparse points, devices, and dangling links are not allowed below the config workspace root."
: $"{FormatConfigDiagnosticPrefix(path)} `{key}` path is invalid (invalid_path).";
return false;
}

value = resolvedPath;
return true;

bool TryResolveWorkspaceOutputPath(string rawPath, string root, out string? resolved, out string? pathError)
{
resolved = null;
pathError = null;
try
{
var normalizedRoot = PathCasing.NormalizeBoundaryPath(root);
var fullPath = Path.IsPathRooted(rawPath)
? Path.GetFullPath(rawPath)
: Path.GetFullPath(Path.Combine(normalizedRoot, rawPath));
var normalizedPath = PathCasing.NormalizeBoundaryPath(fullPath);

if (!PathCasing.IsPathEqualOrParent(normalizedRoot, normalizedPath))
{
pathError = $"{FormatConfigDiagnosticPrefix(path)} `{key}` must resolve inside the config workspace root `{FormatConfigDiagnosticPath(normalizedRoot)}`.";
return false;
}

resolved = fullPath;
return true;
}
catch (Exception ex) when (ex is ArgumentException
or IOException
or NotSupportedException
or PathTooLongException
or UnauthorizedAccessException)
{
pathError = $"{FormatConfigDiagnosticPrefix(path)} `{key}` path is invalid (invalid_path).";
return false;
}
}
}

private static bool TryReadStringArray(JsonElement element, string key, string path, out string[]? value, out string? error)
Expand Down
Loading
Loading