Files
clide/lib/src/files/path_safety.dart
T
jpmschweitzerandClaude Opus 4.8 0bb89a2e58 read user-scope Claude config files via a read allow-list (D-80)
The reader opened repo-local .claude markdown but rejected user-scope
files under ~/.claude with "path outside workspace" — that dir is
global, outside the repo, and files.read was repo-confined (T-102).

Per D-76 the Claude config surface is clide-managed, so files.read now
resolves a path under an allow-list: the workspace root plus trusted
extra read roots (FilesService.extraReadRoots), wired in main.dart to
~/.claude when present. Reads widen; writes stay repo-confined, and the
symlink re-check still refuses a config-root symlink that escapes. Off-
root paths and `..` traversal are rejected as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:08:32 +02:00

134 lines
6.0 KiB
Dart

/// Workspace-relative path validation. Rejects paths that resolve
/// outside the workspace root (path traversal via `..`, absolute
/// paths, symlink-out attempts).
library;
import 'dart:io';
class PathOutsideRoot implements Exception {
PathOutsideRoot(this.requested, this.resolved, this.root);
final String requested;
final String resolved;
final String root;
@override
String toString() => 'path outside workspace root: $requested$resolved (root: $root)';
}
/// Resolve [relative] against [root] and verify the result is
/// contained within [root]. Returns the absolute, normalized path.
/// Throws [PathOutsideRoot] on traversal attempts.
///
/// Path-layer check only — does NOT follow symlinks. Callers that
/// read or list the filesystem should use [resolveUnderRootFollowingSymlinks]
/// instead, which adds a second containment check against the real
/// path. The two-step split exists so pure path math can be tested
/// without touching disk (T-102).
String resolveUnderRoot(Directory root, String relative) {
final rootPath = _normalize(root.absolute.path);
// An absolute input is normalized as-is rather than joined onto the
// root — otherwise a path already under the root gets doubled
// (`/repo` + `/repo/x` → `/repo/repo/x`) and resolves to nothing.
// Containment is still enforced below, so an absolute path *outside*
// the root is rejected exactly as a `..` traversal is.
final joined = relative.startsWith(Platform.pathSeparator) ? _normalize(relative) : _normalize('$rootPath${Platform.pathSeparator}$relative');
// Containment check: joined must equal rootPath, or start with
// rootPath + separator. Equality covers `relative == ''` (the
// root itself); the separator check prevents `/repo` matching
// `/repository`.
if (joined != rootPath && !joined.startsWith('$rootPath${Platform.pathSeparator}')) {
throw PathOutsideRoot(relative, joined, rootPath);
}
return joined;
}
/// Like [resolveUnderRoot] but also resolves any symlinks at the
/// target and re-verifies containment against the real path. Use this
/// for any operation that will read/list/write the filesystem — the
/// path-layer check alone does not defend against a symlink under the
/// workspace whose target lives outside (T-102, e.g. `config ->
/// /etc/shadow`).
///
/// Returns the **resolved real path** (with symlinks followed) when
/// the target exists. When the target does not exist, returns the
/// path-layer result so callers surface a clean "not found" error from
/// their filesystem op (rather than this layer throwing first).
///
/// Symlinks in the workspace root path itself are tolerated: both
/// sides of the containment check are resolved.
String resolveUnderRootFollowingSymlinks(Directory root, String relative) {
final pathResolved = resolveUnderRoot(root, relative);
if (FileSystemEntity.typeSync(pathResolved, followLinks: false) == FileSystemEntityType.notFound) {
return pathResolved;
}
final realRoot = Directory(root.absolute.path).resolveSymbolicLinksSync();
final realPath = File(pathResolved).resolveSymbolicLinksSync();
if (realPath != realRoot && !realPath.startsWith('$realRoot${Platform.pathSeparator}')) {
throw PathOutsideRoot(relative, realPath, realRoot);
}
return realPath;
}
/// Like [resolveUnderRoot], but an **absolute** [path] is also accepted
/// when it falls under any of [extraReadRoots] — trusted read-only roots
/// such as the Claude config dirs (`~/.claude`, `<repo>/.claude`) that
/// clide surfaces but which may live outside the workspace (D-80).
/// Relative paths always resolve under the primary [root]. Throws
/// [PathOutsideRoot] when the path is contained by none of the roots.
///
/// This widens *reads* only; writes stay confined to the workspace via
/// the single-root variant.
String resolveUnderRoots(Directory root, List<Directory> extraReadRoots, String path) {
if (!path.startsWith(Platform.pathSeparator)) {
return resolveUnderRoot(root, path); // relative → workspace-relative
}
final norm = _normalize(path);
for (final r in [root, ...extraReadRoots]) {
final rp = _normalize(r.absolute.path);
if (norm == rp || norm.startsWith('$rp${Platform.pathSeparator}')) return norm;
}
throw PathOutsideRoot(path, norm, _normalize(root.absolute.path));
}
/// Symlink-following multi-root resolver — the [resolveUnderRoots]
/// analogue of [resolveUnderRootFollowingSymlinks]. Re-verifies the real
/// (symlink-resolved) path is contained by one of the allowed roots.
String resolveUnderRootsFollowingSymlinks(Directory root, List<Directory> extraReadRoots, String path) {
final pathResolved = resolveUnderRoots(root, extraReadRoots, path);
if (FileSystemEntity.typeSync(pathResolved, followLinks: false) == FileSystemEntityType.notFound) {
return pathResolved;
}
final realPath = File(pathResolved).resolveSymbolicLinksSync();
for (final r in [root, ...extraReadRoots]) {
final String realRoot;
try {
realRoot = Directory(r.absolute.path).resolveSymbolicLinksSync();
} catch (_) {
continue; // a non-existent allowed root can't contain anything
}
if (realPath == realRoot || realPath.startsWith('$realRoot${Platform.pathSeparator}')) {
return realPath;
}
}
throw PathOutsideRoot(path, realPath, _normalize(root.absolute.path));
}
String _normalize(String path) {
// Use Uri to collapse `..` and `.` segments without hitting the
// filesystem (Directory(...).resolveSymbolicLinksSync would also
// resolve symlinks, which we don't want here — symlink handling
// belongs at the filesystem-access layer, not the path layer).
final segments = <String>[];
for (final raw in path.split(Platform.pathSeparator)) {
if (raw.isEmpty || raw == '.') continue;
if (raw == '..') {
if (segments.isNotEmpty) segments.removeLast();
continue;
}
segments.add(raw);
}
final prefix = path.startsWith(Platform.pathSeparator) ? Platform.pathSeparator : '';
return '$prefix${segments.join(Platform.pathSeparator)}';
}