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>
This commit is contained in:
2026-06-01 09:08:32 +02:00
co-authored by Claude Opus 4.8
parent ade8a88b75
commit 0bb89a2e58
10 changed files with 143 additions and 3 deletions
+8 -2
View File
@@ -26,6 +26,7 @@ class FilesService {
required this.root,
required this.events,
IgnoreSet? ignore,
this.extraReadRoots = const [],
}) : ignore = ignore ?? _defaultIgnore(root);
/// Build from the current working directory, walking up to the git
@@ -39,6 +40,10 @@ class FilesService {
final IgnoreSet ignore;
final DaemonEventSink events;
/// Trusted read-only roots outside the workspace that `files.read`
/// also accepts (the Claude config dirs, D-80). Writes ignore these.
final List<Directory> extraReadRoots;
FileWatcher? _watcher;
Future<void> startWatching() async {
@@ -81,8 +86,9 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
final String absPath;
try {
// Follow symlinks + re-check containment so a `config -> /etc/shadow`
// symlink under the workspace can't be read (T-102).
absPath = resolveUnderRootFollowingSymlinks(files.root, path);
// symlink can't be read (T-102). Accepts the workspace root plus
// the trusted extra read roots (Claude config dirs, D-80).
absPath = resolveUnderRootsFollowingSymlinks(files.root, files.extraReadRoots, path);
} on PathOutsideRoot {
return IpcResponse.err(id: req.id, error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'));
}
+44
View File
@@ -70,6 +70,50 @@ String resolveUnderRootFollowingSymlinks(Directory root, String relative) {
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