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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ downgrading `cdidx`.
| Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. |
| Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir <dir>`, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db <path>` still wins. |
| DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. |
| Security defaults | On POSIX systems, `.cdidx` is created with `0700` permissions and `status --json` reports the effective `data_dir_mode` when available. |
| Diagnostics | `doctor` prints a redacted environment summary for bug reports. `status --config` prints effective configuration with source attribution, and `status --explain <field>` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms <n>`, and <code>--trace=stderr&#124;file&#124;none</code>; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log. |
| Security defaults | On POSIX systems, `.cdidx` is created with `0700` permissions, lifecycle, metrics, MCP audit, and query trace logs are created owner-read/write from the start, metrics and audit logs rotate to bounded slots, query trace logs are pruned to a bounded retained set, and `status --json` reports the effective `data_dir_mode` when available. |
| Diagnostics | `doctor` prints a redacted environment summary for bug reports. `status --config` prints effective configuration with source attribution, and `status --explain <field>` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms <n>`, and <code>--trace=stderr&#124;file&#124;none</code>; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log and retain the newest 30 trace files. |
| Query exit codes | Valid zero-result query commands exit `0` by default. Pass `--strict-not-found` when scripts should treat zero rows as exit code `2`. |
| Drift checks | `cdidx diff <db1> <db2>` compares schema, file, symbol, and reference deltas with stable exit codes: `0` identical, `1` drift, `2` schema mismatch, `3` unreadable DB. |
| Extensibility and feedback | Post-extraction hooks from `~/.config/cdidx/hooks/*.dll` or `CDIDX_HOOKS_DIR` can enrich symbols and references. `cdidx suggestions` lists, inspects, and exports local suggestion history, with fuzzy MCP suggestion deduplication controlled by CLI, env, or `.cdidxrc.json`. |
Expand Down Expand Up @@ -430,8 +430,8 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し
| freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 |
| storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir <dir>`、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db <path>` は引き続き最優先です。 |
| DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。成功した writer 実行は WAL を `TRUNCATE` checkpoint します。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`cdidx db schema` は on-disk schema を出力し、`cdidx db prune --dry-run|--apply` は orphaned DB rows を検査・削除します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 |
| security defaults | POSIX では `.cdidx` を `0700` 権限で作成します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 |
| diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain <field>` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms <n>`、<code>--trace=stderr&#124;file&#124;none</code> に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 |
| security defaults | POSIX では `.cdidx` を `0700` 権限で作成し、lifecycle log、metrics log、MCP audit log、query trace log は作成時点から owner read/write のみで作成します。metrics log と audit log は bounded slot へ rotation し、query trace log は bounded な保持件数へ pruning します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 |
| diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain <field>` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms <n>`、<code>--trace=stderr&#124;file&#124;none</code> に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書き、最新30件の trace file を保持します。 |
| drift checks | `cdidx diff <db1> <db2>` は schema、file、symbol、reference の差分を比較します。exit code は `0` identical、`1` drift、`2` schema mismatch、`3` unreadable DB です。 |
| extensibility / feedback | `~/.config/cdidx/hooks/*.dll` または `CDIDX_HOOKS_DIR` の post-extraction hook で永続化前のシンボルと参照を拡張できます。`cdidx suggestions` はローカル提案履歴の一覧表示、詳細表示、エクスポートに対応し、MCP 提案の近似重複排除しきい値は CLI、env、`.cdidxrc.json` で調整できます。 |
| language coverage | 78 言語を検出し、対応言語ではシンボルとグラフも利用可能です。 |
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/2843.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: security
issues:
- 2843
affected:
- src/CodeIndex/Cli/PrivateLogFile.cs
- src/CodeIndex/Cli/GlobalToolLog.cs
- tests/CodeIndex.Tests/GlobalToolLogTests.cs
- README.md
---

## English

- **Lifecycle logs are created private from the start on POSIX (#2843)** — new lifecycle log files now request owner-read/write mode at creation time, avoiding the window where command arguments and local paths could be appended before permissions were tightened.

## 日本語

- **POSIX の lifecycle log を作成時点から private にしました (#2843)** — 新しい lifecycle log ファイルは作成時に owner read/write mode を要求するようになり、コマンド引数やローカルパスが書き込まれてから権限を締めるまでの隙間を避けます。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2853.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2853
affected:
- src/CodeIndex/Cli/MetricsSink.cs
- tests/CodeIndex.Tests/MetricsSinkTests.cs
- README.md
---

## English

- **Metrics JSONL files are private and bounded on POSIX (#2853)** — metrics output now creates new files with owner-read/write permissions and rotates the configured JSONL destination into bounded slots when it reaches the size limit.

## 日本語

- **POSIX の metrics JSONL を private かつ bounded にしました (#2853)** — metrics 出力は新規ファイルを owner read/write 権限で作成し、設定された JSONL 出力先がサイズ上限に達したら bounded slot へ rotation します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2854.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2854
affected:
- src/CodeIndex/Mcp/AuditLogSink.cs
- tests/CodeIndex.Tests/AuditLogSinkTests.cs
- README.md
---

## English

- **MCP audit logs are created private on POSIX (#2854)** — audit log probe and record writes now create new files with owner-read/write permissions, including files recreated after rotation.

## 日本語

- **POSIX の MCP audit log を private に作成するようにしました (#2854)** — audit log の probe と record 書き込みは、新規ファイルを owner read/write 権限で作成します。rotation 後に再作成されるファイルも同じです。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2884.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2884
affected:
- src/CodeIndex/Cli/ProgramRunner.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
- README.md
---

## English

- **Query trace files are private and retention-bounded on POSIX (#2884)** — `--trace=file` now creates query trace JSONL files with owner-read/write permissions and prunes trace output to the newest 30 files.

## 日本語

- **POSIX の query trace file を private かつ保持件数 bounded にしました (#2884)** — `--trace=file` は query trace JSONL ファイルを owner read/write 権限で作成し、trace 出力を最新30ファイルに pruning します。
20 changes: 4 additions & 16 deletions src/CodeIndex/Cli/GlobalToolLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,7 @@ internal static class GlobalToolLog
}

private static StreamWriter CreateLogWriter(string logPath) =>
new(new FileStream(logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), new UTF8Encoding(false))
{
AutoFlush = true,
};
PrivateLogFile.OpenAppendText(logPath);

internal static void Info(string message) => CurrentSession.Value?.Write("INFO", message);

Expand Down Expand Up @@ -406,15 +403,7 @@ private static void PruneOldLogs(string logDirectory, int retainedLogFileCount)
{
try
{
var oldLogs = new DirectoryInfo(logDirectory)
.EnumerateFiles("stderr-*.log", SearchOption.TopDirectoryOnly)
.OrderByDescending(file => file.LastWriteTimeUtc)
.ThenByDescending(file => file.Name, StringComparer.Ordinal)
.Skip(retainedLogFileCount)
.ToList();

foreach (var file in oldLogs)
file.Delete();
PrivateLogFile.PruneOldFiles(logDirectory, "stderr-*.log", retainedLogFileCount);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Expand All @@ -429,8 +418,7 @@ private static void HardenLogFiles(string logDirectory)

try
{
foreach (var file in new DirectoryInfo(logDirectory).EnumerateFiles("stderr-*.log", SearchOption.TopDirectoryOnly))
SetLogFilePermissions(file.FullName);
PrivateLogFile.HardenExisting(logDirectory, "stderr-*.log");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Expand All @@ -445,7 +433,7 @@ private static void SetLogFilePermissions(string logPath)

try
{
File.SetUnixFileMode(logPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
PrivateLogFile.TrySetPrivatePermissions(logPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Expand Down
58 changes: 40 additions & 18 deletions src/CodeIndex/Cli/MetricsSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ internal static class MetricsSink
{
private static readonly AsyncLocal<Session?> CurrentSession = new();
internal const string EnvVarName = "CDIDX_METRICS";
internal const long DefaultMaxBytes = 50L * 1024 * 1024;
internal const int RotationKeep = 3;

internal static IDisposable? TryStart(string? explicitPath)
internal static IDisposable? TryStart(string? explicitPath) =>
TryStart(explicitPath, DefaultMaxBytes);

internal static IDisposable? TryStartForTesting(string? explicitPath, long maxBytes) =>
TryStart(explicitPath, maxBytes);

private static IDisposable? TryStart(string? explicitPath, long maxBytes)
{
var path = ResolvePath(explicitPath);
if (string.IsNullOrWhiteSpace(path))
Expand All @@ -34,12 +42,14 @@ internal static class MetricsSink
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);

var stream = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
var writer = new StreamWriter(stream, new UTF8Encoding(false))
long bytesWritten;
using (var probe = PrivateLogFile.OpenAppend(fullPath, FileShare.ReadWrite))
{
AutoFlush = true,
};
var session = new Session(writer, fullPath);
bytesWritten = probe.Length;
}
PrivateLogFile.TrySetPrivatePermissions(fullPath);

var session = new Session(fullPath, maxBytes, bytesWritten);
CurrentSession.Value = session;
return session;
}
Expand Down Expand Up @@ -72,13 +82,16 @@ internal static void Record(MetricsEvent evt)
internal sealed class Session : IDisposable
{
private readonly object _gate = new();
private readonly StreamWriter _writer;
private readonly Encoding _utf8NoBom = new UTF8Encoding(false);
private readonly long _maxBytes;
private long _bytesWritten;
private bool _disposed;

public Session(StreamWriter writer, string path)
public Session(string path, long maxBytes, long bytesWritten)
{
_writer = writer;
Path = path;
_maxBytes = maxBytes;
_bytesWritten = bytesWritten;
}

public string Path { get; }
Expand All @@ -92,7 +105,15 @@ public void Write(MetricsEvent evt)

try
{
_writer.WriteLine(SerializeEvent(evt));
RotateIfNeededLocked();
var encoded = _utf8NoBom.GetBytes(SerializeEvent(evt) + Environment.NewLine);
using (var stream = PrivateLogFile.OpenAppend(Path, FileShare.ReadWrite))
{
stream.Write(encoded, 0, encoded.Length);
stream.Flush();
}
_bytesWritten += encoded.Length;
RotateIfNeededLocked();
}
catch
{
Expand All @@ -110,16 +131,17 @@ public void Dispose()

_disposed = true;
CurrentSession.Value = null;
try
{
_writer.Dispose();
}
catch
{
// Best-effort only / ベストエフォートのみ
}
}
}

private void RotateIfNeededLocked()
{
if (_bytesWritten < _maxBytes)
return;

if (PrivateLogFile.TryRotateSlots(Path, RotationKeep))
_bytesWritten = 0;
}
}

internal static string SerializeEvent(MetricsEvent evt)
Expand Down
130 changes: 130 additions & 0 deletions src/CodeIndex/Cli/PrivateLogFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Text;
using CodeIndex.Indexer;

namespace CodeIndex.Cli;

internal static class PrivateLogFile
{
internal const UnixFileMode PrivateFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;

internal static FileStream OpenAppend(string path, FileShare share = FileShare.ReadWrite)
{
if (OperatingSystem.IsWindows())
return new FileStream(path, FileMode.Append, FileAccess.Write, share);

return new FileStream(path, new FileStreamOptions
{
Mode = FileMode.Append,
Access = FileAccess.Write,
Share = share,
UnixCreateMode = PrivateFileMode,
});
}

internal static StreamWriter OpenAppendText(string path)
=> new(OpenAppend(path), new UTF8Encoding(false))
{
AutoFlush = true,
};

internal static void TrySetPrivatePermissions(string path)
{
if (OperatingSystem.IsWindows())
return;

try
{
File.SetUnixFileMode(path, PrivateFileMode);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
// Best-effort only / ベストエフォートのみ
}
}

internal static void HardenExisting(string directory, string pattern)
{
if (OperatingSystem.IsWindows())
return;

try
{
foreach (var file in new DirectoryInfo(directory).EnumerateFiles(pattern, SearchOption.TopDirectoryOnly))
TrySetPrivatePermissions(file.FullName);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort only / ベストエフォートのみ
}
}

internal static void PruneOldFiles(string directory, string pattern, int retainedFileCount)
{
try
{
var oldFiles = new DirectoryInfo(directory)
.EnumerateFiles(pattern, SearchOption.TopDirectoryOnly)
.OrderByDescending(file => file.LastWriteTimeUtc)
.ThenByDescending(file => file.Name, StringComparer.Ordinal)
.Skip(retainedFileCount)
.ToList();

foreach (var file in oldFiles)
file.Delete();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort only / ベストエフォートのみ
}
}

internal static bool TryRotateSlots(string path, int retainedFileCount)
{
try
{
SafeDelete(SlotPath(path, retainedFileCount - 1));

for (var slot = retainedFileCount - 2; slot >= 1; slot--)
{
var current = LongPath.EnsureWindowsPrefix(SlotPath(path, slot));
var next = LongPath.EnsureWindowsPrefix(SlotPath(path, slot + 1));
if (!File.Exists(current))
continue;
if (File.Exists(next))
SafeDelete(next);
File.Move(current, next);
}

var ioPath = LongPath.EnsureWindowsPrefix(path);
if (File.Exists(ioPath))
{
var first = LongPath.EnsureWindowsPrefix(SlotPath(path, 1));
if (File.Exists(first))
SafeDelete(first);
File.Move(ioPath, first);
}

return true;
}
catch
{
return false;
}
}

private static string SlotPath(string path, int slot)
=> slot <= 0 ? path : path + "." + slot.ToString(System.Globalization.CultureInfo.InvariantCulture);

private static void SafeDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// Ignore: rotation is best-effort.
}
}
}
Loading
Loading