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
11 changes: 8 additions & 3 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,14 @@ long backfills.
## Filesystem Permissions

On POSIX filesystems, cdidx creates `.cdidx/` with mode `0700` and applies mode
`0600` to `codeindex.db` plus WAL/SHM sidecars when they exist. `status --json`
reports `data_dir_mode` and `db_file_mode` when the platform exposes Unix file
modes.
`0600` to `codeindex.db` plus WAL/SHM sidecars when they exist. Index lock
metadata sidecars and the active workspace `active.json` state file are also
written as owner-only files and read through small bounded buffers so stale or
corrupted diagnostics cannot expose local paths more broadly or force unbounded
allocation. Database checkpoint roots, snapshot directories, manifest files,
copied DB/WAL/SHM snapshots, and restore staging/backup directories are also
forced owner-only on POSIX. `status --json` reports `data_dir_mode` and
`db_file_mode` when the platform exposes Unix file modes.

## Release Distribution Checklist

Expand Down
21 changes: 21 additions & 0 deletions changelog.d/unreleased/2868.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
category: security
issues:
- 2868
affected:
- src/CodeIndex/Cli/DataDirectorySecurity.cs
- src/CodeIndex/Cli/IndexLock.cs
- src/CodeIndex/Mcp/McpIndexRunLock.cs
- tests/CodeIndex.Tests/DataDirectorySecurityTests.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Index lock metadata is now private and bounded (#2868)** — CLI and MCP index lock `.info` sidecars are written with owner-only permissions on POSIX and ignored when they exceed a small diagnostic read limit.

## 日本語

- **index lock metadata を private かつ bounded にしました (#2868)** — CLI と MCP の index lock `.info` sidecar は POSIX で所有者のみの権限で書き込まれ、小さな診断用 read 上限を超える場合は無視されます。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2869.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2869
affected:
- src/CodeIndex/Cli/ActiveWorkspace.cs
- tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Active workspace state is now private and bounded (#2869)** — `active.json` is written with owner-only permissions on POSIX, oversized state files are ignored before JSON parsing, and invalid state files now emit a non-fatal diagnostic.

## 日本語

- **active workspace state を private かつ bounded にしました (#2869)** — `active.json` は POSIX で所有者のみの権限で書き込まれ、過大な state file は JSON parsing 前に無視され、無効な state file では非 fatal の診断を出力します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2879.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2879
affected:
- src/CodeIndex/Cli/DbCommandRunner.cs
- tests/CodeIndex.Tests/DbCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Database checkpoints now use private snapshot permissions (#2879)** — checkpoint roots, snapshot directories, manifest files, and copied DB/WAL/SHM files are forced owner-only on POSIX filesystems.

## 日本語

- **database checkpoint の snapshot permission を private にしました (#2879)** — checkpoint root、snapshot directory、manifest file、コピーされた DB/WAL/SHM file は POSIX filesystem で所有者のみの権限に強制されます。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2882.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2882
affected:
- src/CodeIndex/Cli/DbCommandRunner.cs
- tests/CodeIndex.Tests/DbCommandRunnerTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **Database restore staging and backup paths now use private permissions (#2882)** — restore temp directories, backup directories, and moved or staged DB/WAL/SHM files are forced owner-only on POSIX filesystems.

## 日本語

- **database restore の staging / backup path を private permission にしました (#2882)** — restore temp directory、backup directory、移動または staging された DB/WAL/SHM file は POSIX filesystem で所有者のみの権限に強制されます。
18 changes: 15 additions & 3 deletions src/CodeIndex/Cli/ActiveWorkspace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ internal sealed record ActiveWorkspaceState(string Name, string Root, string DbP
internal static class ActiveWorkspace
{
internal const string EnvironmentVariable = "CDIDX_ACTIVE_WORKSPACE";
private const int MaxStateBytes = 64 * 1024;

internal static string StatePath
{
Expand All @@ -33,7 +34,14 @@ internal static string StatePath

try
{
using var document = JsonDocument.Parse(File.ReadAllText(LongPath.EnsureWindowsPrefix(path)));
var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxStateBytes, FileShare.ReadWrite);
if (text is null)
{
WriteLoadWarning(path, $"file exceeds {MaxStateBytes} bytes");
return null;
}

using var document = JsonDocument.Parse(text);
var root = document.RootElement;
var name = ReadString(root, "name") ?? "default";
var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory;
Expand All @@ -44,17 +52,21 @@ internal static string StatePath
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
WriteLoadWarning(path, ex.Message);
return null;
}
}

internal static void Save(ActiveWorkspaceState state)
{
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
DataDirectorySecurity.CreateSensitiveDirectory(Path.GetDirectoryName(StatePath)!);
var payload = new ActiveWorkspaceState(state.Name, Path.GetFullPath(state.Root), Path.GetFullPath(state.DbPath));
File.WriteAllText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions()));
DataDirectorySecurity.WritePrivateText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions()));
}

private static string? ReadString(JsonElement element, string name)
=> element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;

private static void WriteLoadWarning(string path, string reason)
=> Console.Error.WriteLine($"[cdidx] Ignoring active workspace state at {path}: {reason}");
}
62 changes: 61 additions & 1 deletion src/CodeIndex/Cli/DataDirectorySecurity.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
using System.Runtime.InteropServices;
using System.Text;
using CodeIndex.Indexer;

namespace CodeIndex.Cli;

internal static class DataDirectorySecurity
{
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

internal const UnixFileMode PrivateDirectoryMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;

private const UnixFileMode PermissionBits =
internal const UnixFileMode PrivateFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite;

internal const UnixFileMode PermissionBits =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
Expand All @@ -20,6 +27,13 @@ public static DirectoryInfo CreatePrivateDirectory(string path)
return directory;
}

public static DirectoryInfo CreateSensitiveDirectory(string path)
{
var directory = Directory.CreateDirectory(path);
ApplyPrivateMode(directory.FullName);
return directory;
}

public static void ApplyPrivateMode(string path)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Expand All @@ -28,6 +42,52 @@ public static void ApplyPrivateMode(string path)
File.SetUnixFileMode(path, PrivateDirectoryMode);
}

public static void ApplyPrivateFileMode(string path)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return;

File.SetUnixFileMode(path, PrivateFileMode);
}

public static void WritePrivateText(string path, string contents, Encoding? encoding = null)
{
var ioPath = LongPath.EnsureWindowsPrefix(path);
using var stream = File.Open(ioPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read);
ApplyPrivateFileMode(ioPath);
stream.SetLength(0);
var outputEncoding = encoding is null || encoding.CodePage == Encoding.UTF8.CodePage ? Utf8NoBom : encoding;
using var writer = new StreamWriter(stream, outputEncoding);
writer.Write(contents);
}

public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read)
{
if (maxBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "Maximum byte count must be positive.");

var ioPath = LongPath.EnsureWindowsPrefix(path);
using var stream = File.Open(ioPath, FileMode.Open, FileAccess.Read, share);
using var output = new MemoryStream(capacity: Math.Min(maxBytes, 8192));
var buffer = new byte[Math.Min(maxBytes + 1, 8192)];
var total = 0;
while (true)
{
var remaining = maxBytes + 1 - total;
var read = stream.Read(buffer, 0, Math.Min(buffer.Length, remaining));
if (read == 0)
break;

total += read;
if (total > maxBytes)
return null;

output.Write(buffer, 0, read);
}

return Encoding.UTF8.GetString(output.ToArray());
}

public static string? GetUnixModeString(string? path)
{
if (string.IsNullOrWhiteSpace(path) ||
Expand Down
52 changes: 30 additions & 22 deletions src/CodeIndex/Cli/DbCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -581,15 +581,15 @@ private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, s
if (Directory.Exists(checkpointPath))
throw new InvalidOperationException($"checkpoint already exists: {name}");

Directory.CreateDirectory(root);
DataDirectorySecurity.CreateSensitiveDirectory(root);
var tempPath = Path.Combine(root, ".tmp-" + name + "-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempPath);
DataDirectorySecurity.CreateSensitiveDirectory(tempPath);
try
{
CopyIfExists(fullDbPath, Path.Combine(tempPath, Path.GetFileName(fullDbPath)));
CopyIfExists(fullDbPath + "-wal", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-wal"));
CopyIfExists(fullDbPath + "-shm", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-shm"));
File.WriteAllText(Path.Combine(tempPath, "manifest.txt"), $"name={name}{Environment.NewLine}created_at_utc={DateTimeOffset.UtcNow:O}{Environment.NewLine}db={fullDbPath}{Environment.NewLine}");
CopyIfExists(fullDbPath, Path.Combine(tempPath, Path.GetFileName(fullDbPath)), privateDestination: true);
CopyIfExists(fullDbPath + "-wal", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true);
CopyIfExists(fullDbPath + "-shm", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true);
DataDirectorySecurity.WritePrivateText(Path.Combine(tempPath, "manifest.txt"), $"name={name}{Environment.NewLine}created_at_utc={DateTimeOffset.UtcNow:O}{Environment.NewLine}db={fullDbPath}{Environment.NewLine}");
Directory.Move(tempPath, checkpointPath);
}
catch
Expand Down Expand Up @@ -633,25 +633,25 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c

var restoreTempPath = fullDbPath + ".restore-tmp-" + DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture);
var backupPath = fullDbPath + ".restore-backup-" + DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture);
Directory.CreateDirectory(restoreTempPath);
DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath);
try
{
CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)));
CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"));
CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"));
CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), privateDestination: true);
CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true);
CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true);
if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)))))
throw new InvalidOperationException($"checkpoint staging failed: {name}");

Directory.CreateDirectory(backupPath);
MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath)));
MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"));
MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"));
DataDirectorySecurity.CreateSensitiveDirectory(backupPath);
MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath)), privateDestination: true);
MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true);
MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true);

RestoreFailureAfterBackupForTesting?.Invoke();

MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), fullDbPath);
MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal");
MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm");
MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true);
MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true);
MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true);
}
catch
{
Expand Down Expand Up @@ -689,16 +689,24 @@ private static string GetCheckpointPath(string fullDbPath, string name)
return Path.Combine(GetCheckpointRoot(fullDbPath), name);
}

private static void CopyIfExists(string source, string destination)
private static void CopyIfExists(string source, string destination, bool privateDestination = false)
{
if (File.Exists(LongPath.EnsureWindowsPrefix(source)))
{
File.Copy(LongPath.EnsureWindowsPrefix(source), LongPath.EnsureWindowsPrefix(destination), overwrite: false);
if (privateDestination)
DataDirectorySecurity.ApplyPrivateFileMode(destination);
}
}

private static void MoveIfExists(string source, string destination)
private static void MoveIfExists(string source, string destination, bool privateDestination = false)
{
if (File.Exists(LongPath.EnsureWindowsPrefix(source)))
{
File.Move(LongPath.EnsureWindowsPrefix(source), LongPath.EnsureWindowsPrefix(destination));
if (privateDestination)
DataDirectorySecurity.ApplyPrivateFileMode(destination);
}
}

private static void RestoreBackedUpFiles(string fullDbPath, string backupPath)
Expand All @@ -709,9 +717,9 @@ private static void RestoreBackedUpFiles(string fullDbPath, string backupPath)
DeleteIfExists(fullDbPath);
DeleteIfExists(fullDbPath + "-wal");
DeleteIfExists(fullDbPath + "-shm");
MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath);
MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal");
MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm");
MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true);
MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true);
MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true);
}

private static void DeleteIfExists(string path)
Expand Down
12 changes: 4 additions & 8 deletions src/CodeIndex/Cli/IndexLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ namespace CodeIndex.Cli;
/// </summary>
internal sealed class IndexLock : IDisposable
{
private const int MaxInfoBytes = 16 * 1024;

private readonly FileStream _stream;
private readonly string _lockPath;
private readonly string _infoPath;
Expand Down Expand Up @@ -100,7 +102,7 @@ public static IndexLock Acquire(string lockPath, string projectPath)
StartedAt: DateTime.UtcNow,
Host: Environment.MachineName,
ProjectPath: Path.GetFullPath(projectPath));
File.WriteAllText(infoPath, SerializeInfo(info), Encoding.UTF8);
DataDirectorySecurity.WritePrivateText(infoPath, SerializeInfo(info), Encoding.UTF8);
}
catch (Exception)
{
Expand Down Expand Up @@ -129,13 +131,7 @@ public static IndexLock Acquire(string lockPath, string projectPath)
// diagnostic read is never rejected for share-mode mismatch.
// 保持者が .info を上書きしている可能性があるため FileShare.ReadWrite で
// 開き、共有モード不一致で読みを失敗させない。
using var stream = new FileStream(
ioInfoPath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
var text = reader.ReadToEnd();
var text = DataDirectorySecurity.ReadTextWithinLimit(ioInfoPath, MaxInfoBytes, FileShare.ReadWrite);
if (string.IsNullOrWhiteSpace(text))
return null;
return ParseInfo(text);
Expand Down
Loading
Loading