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:
@@ -107,6 +107,26 @@ void main() {
|
||||
expect(r.data['content'], 'hi');
|
||||
});
|
||||
|
||||
test('files.read reads an absolute path under an extra read root (D-80)', () async {
|
||||
final extra = await Directory.systemTemp.createTemp('clide-extra-claude-');
|
||||
addTearDown(() async => extra.existsSync() ? extra.deleteSync(recursive: true) : null);
|
||||
File('${extra.path}/SKILL.md').writeAsStringSync('# peon');
|
||||
final svc = FilesService(root: sandbox, events: RecordingEventSink(), ignore: IgnoreSet.builtin(), extraReadRoots: [extra]);
|
||||
final d = DaemonDispatcher();
|
||||
registerFilesCommands(d, svc);
|
||||
addTearDown(svc.shutdown);
|
||||
|
||||
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'files.read', args: {'path': '${extra.absolute.path}/SKILL.md'}));
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['content'], '# peon');
|
||||
});
|
||||
|
||||
test('files.read still rejects an absolute path outside all roots', () async {
|
||||
final r = await call('files.read', const {'path': '/etc/passwd'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.message, contains('outside workspace'));
|
||||
});
|
||||
|
||||
test('files.read without a path returns toolError', () async {
|
||||
final r = await call('files.read', const {});
|
||||
expect(r.ok, isFalse);
|
||||
|
||||
@@ -136,4 +136,45 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveUnderRoots (extra read roots, D-80)', () {
|
||||
late Directory extra;
|
||||
setUp(() => extra = Directory.systemTemp.createTempSync('clide_extra_root_'));
|
||||
tearDown(() => extra.existsSync() ? extra.deleteSync(recursive: true) : null);
|
||||
|
||||
test('a relative path still resolves under the primary root', () {
|
||||
expect(resolveUnderRoots(root, [extra], 'file.txt'), '${root.absolute.path}/file.txt');
|
||||
});
|
||||
|
||||
test('an absolute path under the primary root is accepted', () {
|
||||
final abs = '${root.absolute.path}/.claude/x.md';
|
||||
expect(resolveUnderRoots(root, [extra], abs), abs);
|
||||
});
|
||||
|
||||
test('an absolute path under an extra read root is accepted', () {
|
||||
final abs = '${extra.absolute.path}/skills/peon/SKILL.md';
|
||||
expect(resolveUnderRoots(root, [extra], abs), abs);
|
||||
});
|
||||
|
||||
test('an absolute path outside every root is rejected', () {
|
||||
expect(() => resolveUnderRoots(root, [extra], '/etc/passwd'), throwsA(isA<PathOutsideRoot>()));
|
||||
});
|
||||
|
||||
test('following symlinks: a real file under an extra root resolves', () {
|
||||
File('${extra.path}/SKILL.md').writeAsStringSync('# skill');
|
||||
final out = resolveUnderRootsFollowingSymlinks(root, [extra], '${extra.absolute.path}/SKILL.md');
|
||||
expect(out, endsWith('/SKILL.md'));
|
||||
});
|
||||
|
||||
test('following symlinks: a symlink under an extra root pointing outside is rejected', () {
|
||||
final outside = Directory.systemTemp.createTempSync('clide_extra_leak_');
|
||||
addTearDown(() => outside.existsSync() ? outside.deleteSync(recursive: true) : null);
|
||||
File('${outside.path}/secret.txt').writeAsStringSync('payload');
|
||||
Link('${extra.path}/leak').createSync('${outside.path}/secret.txt');
|
||||
expect(
|
||||
() => resolveUnderRootsFollowingSymlinks(root, [extra], '${extra.absolute.path}/leak'),
|
||||
throwsA(isA<PathOutsideRoot>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user