diff --git a/README.md b/README.md
index 2f13f8e2b9..950f88246a 100644
--- a/README.md
+++ b/README.md
@@ -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
`, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` 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 ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; 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 ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; 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 ` 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`. |
@@ -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 `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 |
| 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 ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、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 ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書き、最新30件の trace file を保持します。 |
| drift checks | `cdidx diff ` は 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 言語を検出し、対応言語ではシンボルとグラフも利用可能です。 |
diff --git a/changelog.d/unreleased/2843.security.md b/changelog.d/unreleased/2843.security.md
new file mode 100644
index 0000000000..c90c0ee913
--- /dev/null
+++ b/changelog.d/unreleased/2843.security.md
@@ -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 を要求するようになり、コマンド引数やローカルパスが書き込まれてから権限を締めるまでの隙間を避けます。
diff --git a/changelog.d/unreleased/2853.security.md b/changelog.d/unreleased/2853.security.md
new file mode 100644
index 0000000000..513009750a
--- /dev/null
+++ b/changelog.d/unreleased/2853.security.md
@@ -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 します。
diff --git a/changelog.d/unreleased/2854.security.md b/changelog.d/unreleased/2854.security.md
new file mode 100644
index 0000000000..b52eb5fa10
--- /dev/null
+++ b/changelog.d/unreleased/2854.security.md
@@ -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 後に再作成されるファイルも同じです。
diff --git a/changelog.d/unreleased/2884.security.md b/changelog.d/unreleased/2884.security.md
new file mode 100644
index 0000000000..a6f8625ffb
--- /dev/null
+++ b/changelog.d/unreleased/2884.security.md
@@ -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 します。
diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs
index 23d355d9d5..99217b9fff 100644
--- a/src/CodeIndex/Cli/GlobalToolLog.cs
+++ b/src/CodeIndex/Cli/GlobalToolLog.cs
@@ -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);
@@ -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)
{
@@ -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)
{
@@ -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)
{
diff --git a/src/CodeIndex/Cli/MetricsSink.cs b/src/CodeIndex/Cli/MetricsSink.cs
index e0cffe2014..4ca2c9b6b9 100644
--- a/src/CodeIndex/Cli/MetricsSink.cs
+++ b/src/CodeIndex/Cli/MetricsSink.cs
@@ -20,8 +20,16 @@ internal static class MetricsSink
{
private static readonly AsyncLocal 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))
@@ -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;
}
@@ -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; }
@@ -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
{
@@ -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)
diff --git a/src/CodeIndex/Cli/PrivateLogFile.cs b/src/CodeIndex/Cli/PrivateLogFile.cs
new file mode 100644
index 0000000000..9dd6d06feb
--- /dev/null
+++ b/src/CodeIndex/Cli/PrivateLogFile.cs
@@ -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.
+ }
+ }
+}
diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs
index 6a439d799f..a73f46b85a 100644
--- a/src/CodeIndex/Cli/ProgramRunner.cs
+++ b/src/CodeIndex/Cli/ProgramRunner.cs
@@ -16,6 +16,7 @@ namespace CodeIndex.Cli;
internal static class ProgramRunner
{
+ private const int RetainedQueryTraceFileCount = 30;
internal const string QuietEnvironmentVariable = "CDIDX_QUIET";
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;
@@ -1271,8 +1272,16 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub
var directory = GlobalToolLog.ResolveLogDirectoryForStatus();
Directory.CreateDirectory(directory);
- var path = Path.Combine(directory, $"query-trace-{TimeProvider.GetUtcNow().UtcDateTime:yyyyMMdd}.jsonl");
- File.AppendAllText(path, payload + Environment.NewLine);
+ PrivateLogFile.HardenExisting(directory, "query-trace-*.jsonl");
+ var path = ResolveQueryTracePath(directory);
+ var encoded = Encoding.UTF8.GetBytes(payload + Environment.NewLine);
+ using (var stream = PrivateLogFile.OpenAppend(path, FileShare.ReadWrite))
+ {
+ stream.Write(encoded, 0, encoded.Length);
+ stream.Flush();
+ }
+ PrivateLogFile.TrySetPrivatePermissions(path);
+ PrivateLogFile.PruneOldFiles(directory, "query-trace-*.jsonl", RetainedQueryTraceFileCount);
}
catch
{
@@ -1280,6 +1289,12 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub
}
}
+ private static string ResolveQueryTracePath(string directory)
+ {
+ var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+ return Path.Combine(directory, $"query-trace-{date}.jsonl");
+ }
+
private static string BuildQueryTraceJson(string commandName, string[] subArgs, DateTimeOffset timestamp, double elapsedMs, int exitCode, int? resultCount)
{
var payload = new JsonObject
diff --git a/src/CodeIndex/Mcp/AuditLogSink.cs b/src/CodeIndex/Mcp/AuditLogSink.cs
index 898bf2219a..6065ceb497 100644
--- a/src/CodeIndex/Mcp/AuditLogSink.cs
+++ b/src/CodeIndex/Mcp/AuditLogSink.cs
@@ -4,6 +4,7 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
+using CodeIndex.Cli;
using CodeIndex.Indexer;
namespace CodeIndex.Mcp;
@@ -57,10 +58,11 @@ internal AuditLogSink(string path, long maxBytes, bool includeValues)
// 構築時に append open を試行する。既存ディレクトリや書き込み不可ファイルなど
// 設定不備を、ProgramRunner が「audit 有効で起動」と表示する前に検出する。
// 後で Record() が握り潰すと操作者には audit 有効に見えるがログは空、となる。
- using (var probe = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
+ using (var probe = PrivateLogFile.OpenAppend(_path, FileShare.ReadWrite))
{
_bytesWritten = probe.Length;
}
+ PrivateLogFile.TrySetPrivatePermissions(_path);
}
/// Path the sink writes to (absolute, post normalisation).
@@ -102,7 +104,7 @@ internal void Record(AuditEvent evt)
// 1 レコードごとに open/write/close する。外部 `tail -F` の rotation 追従と
// rename 中のロック回避のため。ツール呼び出し頻度はループのホットパスではない
// ので open のコストは許容範囲。
- using (var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
+ using (var stream = PrivateLogFile.OpenAppend(_path, FileShare.ReadWrite))
{
stream.Write(encoded, 0, encoded.Length);
stream.Flush();
diff --git a/tests/CodeIndex.Tests/AuditLogSinkTests.cs b/tests/CodeIndex.Tests/AuditLogSinkTests.cs
index 7221dba02b..92b42aa530 100644
--- a/tests/CodeIndex.Tests/AuditLogSinkTests.cs
+++ b/tests/CodeIndex.Tests/AuditLogSinkTests.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
+using CodeIndex.Cli;
using CodeIndex.Mcp;
namespace CodeIndex.Tests;
@@ -177,6 +178,81 @@ public void Record_AppendsJsonlLineToConfiguredPath()
}
}
+ [Fact]
+ public void Constructor_OnUnixCreatesPrivateLogFile()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ var path = Path.Combine(Path.GetTempPath(), $"cdidx_audit_private_{Guid.NewGuid():N}.jsonl");
+ try
+ {
+ using var sink = new AuditLogSink(path, AuditLogSink.DefaultMaxBytes, includeValues: false);
+
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(path));
+ }
+ finally
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Record_OnUnixCreatesPrivateLogFileAfterRotation()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ var path = Path.Combine(Path.GetTempPath(), $"cdidx_audit_private_rotation_{Guid.NewGuid():N}.jsonl");
+ var rotation1 = path + ".1";
+ try
+ {
+ using var sink = new AuditLogSink(path, AuditLogSink.MinMaxBytes, includeValues: true);
+ var payloadKey = new string('k', 32);
+ var bigEvent = new AuditLogSink.AuditEvent(
+ Timestamp: DateTimeOffset.UtcNow,
+ Tool: "search",
+ CallerName: "test",
+ CallerVersion: "1.0.0",
+ RequestId: "1",
+ ArgKeys: new[] { payloadKey },
+ ArgLengths: new[] { new KeyValuePair(payloadKey, 5000) },
+ ArgValues: JsonNode.Parse($"{{\"{payloadKey}\":\"{new string('x', 5000)}\"}}"),
+ ResultCount: 0,
+ ElapsedMs: 1.0,
+ ErrorCode: 0,
+ ErrorType: null);
+ var smallEvent = new AuditLogSink.AuditEvent(
+ Timestamp: DateTimeOffset.UtcNow,
+ Tool: "ping",
+ CallerName: null,
+ CallerVersion: null,
+ RequestId: "2",
+ ArgKeys: Array.Empty(),
+ ArgLengths: Array.Empty>(),
+ ArgValues: null,
+ ResultCount: 0,
+ ElapsedMs: 0.5,
+ ErrorCode: 0,
+ ErrorType: null);
+
+ sink.Record(bigEvent);
+ sink.Record(smallEvent);
+
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(rotation1));
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(path));
+ }
+ finally
+ {
+ foreach (var p in new[] { path, rotation1, path + ".2" })
+ {
+ if (File.Exists(p))
+ File.Delete(p);
+ }
+ }
+ }
+
[Fact]
public void Record_RotatesOnceMaxBytesExceeded()
{
diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs
index 2bcd21b4e7..198488a7ea 100644
--- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs
+++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs
@@ -8,6 +8,29 @@ namespace CodeIndex.Tests;
[Collection("SQLite pool sensitive")]
public class GlobalToolLogTests
{
+ [Fact]
+ public void PrivateLogFile_OpenAppend_OnUnixCreatesPrivateFile()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ var path = Path.Combine(Path.GetTempPath(), $"cdidx_private_log_{Guid.NewGuid():N}.log");
+ try
+ {
+ using (var stream = PrivateLogFile.OpenAppend(path))
+ {
+ stream.WriteByte((byte)'x');
+ }
+
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(path));
+ }
+ finally
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ }
+
[Fact]
public void FormatArgs_RedactsSensitiveArgumentsByDefault()
{
diff --git a/tests/CodeIndex.Tests/MetricsSinkTests.cs b/tests/CodeIndex.Tests/MetricsSinkTests.cs
index 4fdb4225ca..c5adb56050 100644
--- a/tests/CodeIndex.Tests/MetricsSinkTests.cs
+++ b/tests/CodeIndex.Tests/MetricsSinkTests.cs
@@ -100,6 +100,71 @@ public void Run_WithMetricsFlag_AppendsJsonlRecordForEachInvocation()
}
}
+ [Fact]
+ public void Run_WithMetricsFlag_OnUnixCreatesPrivateFile()
+ {
+ if (OperatingSystem.IsWindows())
+ return;
+
+ var metricsPath = Path.Combine(Path.GetTempPath(), $"cdidx_metrics_private_{Guid.NewGuid():N}.jsonl");
+ try
+ {
+ var (exitCode, _, _) = CaptureConsole(() => ProgramRunner.Run(
+ ["--metrics", metricsPath, "definitely-not-a-command"],
+ appVersion: "1.10.0"));
+
+ Assert.Equal(CommandExitCodes.UsageError, exitCode);
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath));
+ }
+ finally
+ {
+ if (File.Exists(metricsPath))
+ File.Delete(metricsPath);
+ }
+ }
+
+ [Fact]
+ public void Record_RotatesMetricsLogAtMaxBytes()
+ {
+ var metricsPath = Path.Combine(Path.GetTempPath(), $"cdidx_metrics_rotate_{Guid.NewGuid():N}.jsonl");
+ try
+ {
+ using var session = MetricsSink.TryStartForTesting(metricsPath, maxBytes: 1024);
+ Assert.NotNull(session);
+
+ MetricsSink.Record(new MetricsEvent(
+ Timestamp: DateTimeOffset.UtcNow,
+ Tool: "search",
+ Source: "cli",
+ ElapsedMs: 1.0,
+ ExitCode: 1,
+ Error: new string('x', 2000)));
+ MetricsSink.Record(new MetricsEvent(
+ Timestamp: DateTimeOffset.UtcNow,
+ Tool: "status",
+ Source: "cli",
+ ElapsedMs: 1.0,
+ ExitCode: 0));
+
+ Assert.True(File.Exists(metricsPath + ".1"));
+ Assert.True(File.Exists(metricsPath));
+ Assert.False(File.Exists(metricsPath + ".3"));
+ if (!OperatingSystem.IsWindows())
+ {
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath + ".1"));
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath));
+ }
+ }
+ finally
+ {
+ foreach (var path in new[] { metricsPath, metricsPath + ".1", metricsPath + ".2", metricsPath + ".3" })
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ }
+ }
+
[Fact]
public void Run_WithEnvVarFallback_StillEmitsMetrics()
{
diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs
index a3600b0c3f..dd8bb1e014 100644
--- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs
+++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs
@@ -155,6 +155,8 @@ public void Run_QueryTraceFile_AppendsDailyJsonl()
Assert.DoesNotContain('{', stderr);
var tracePath = Path.Combine(logRoot, $"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl");
Assert.True(File.Exists(tracePath));
+ if (!OperatingSystem.IsWindows())
+ Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(tracePath));
var line = File.ReadAllLines(tracePath).Single();
using var document = JsonDocument.Parse(line);
Assert.Equal("search", document.RootElement.GetProperty("tool").GetString());
@@ -167,6 +169,50 @@ public void Run_QueryTraceFile_AppendsDailyJsonl()
}
}
+ [Fact]
+ public void Run_QueryTraceFile_PrunesToThirtyTraceFiles()
+ {
+ var projectRoot = TestProjectHelper.CreateTempProject("query-trace-prune");
+ var logRoot = Path.Combine(Path.GetTempPath(), $"cdidx_query_trace_prune_{Guid.NewGuid():N}");
+ try
+ {
+ Directory.CreateDirectory(logRoot);
+ for (var i = 0; i < 35; i++)
+ {
+ var date = new DateTime(2024, 1, 1).AddDays(i);
+ var path = Path.Combine(logRoot, $"query-trace-{date:yyyyMMdd}.jsonl");
+ File.WriteAllText(path, $"old {i}");
+ File.SetLastWriteTimeUtc(path, date);
+ }
+
+ var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
+ TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "public class App { public void Needle() { } }");
+ using var env = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR");
+ env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logRoot);
+
+ var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run(
+ ["search", "Needle", "--db", dbPath, "--trace=file"],
+ appVersion: "1.10.0"));
+
+ Assert.Equal(CommandExitCodes.Success, exitCode);
+ Assert.DoesNotContain('{', stderr);
+
+ var traces = Directory.GetFiles(logRoot, "query-trace-*.jsonl", SearchOption.TopDirectoryOnly)
+ .Select(Path.GetFileName)
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToArray();
+ Assert.Equal(30, traces.Length);
+ Assert.DoesNotContain("query-trace-20240101.jsonl", traces);
+ Assert.Contains($"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl", traces);
+ }
+ finally
+ {
+ TestProjectHelper.DeleteDirectory(projectRoot);
+ if (Directory.Exists(logRoot))
+ Directory.Delete(logRoot, recursive: true);
+ }
+ }
+
[Fact]
public void TryConsumeSuggestionDedupThresholdFlag_SetsEnvironmentAndRemovesFlag()
{