reject symlinks pointing outside the workspace (T-102)

resolveUnderRoot already blocked path-layer traversal but explicitly
did NOT follow symlinks — a repo symlink config -> /etc/shadow
passed the containment check because the link path was under root.
clide would then read the target.

Add resolveUnderRootFollowingSymlinks: resolves any symlinks at the
target and re-verifies containment against the resolved real root.
The split keeps pure path math testable without filesystem access.
files.read and files.ls now route through it.

Tests cover: plain non-symlink passthrough, non-existent target
(returns path-layer result so caller surfaces not-found cleanly),
single-hop and chained symlinks whose targets escape the workspace,
and tolerance of symlinks in the root path itself (macOS /tmp).

Also adds the T-101 CHANGELOG entry that the docs commit missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:01:56 +02:00
co-authored by Claude Opus 4.7
parent dd3b38ba85
commit 06b08b7388
5 changed files with 128 additions and 2 deletions
+25
View File
@@ -116,6 +116,31 @@ void main() {
expect(r.error!.message, contains('outside workspace'));
});
test('files.read rejects a symlink whose target is outside the workspace (T-102)', () async {
final outside = await Directory.systemTemp.createTemp('clide_t102_read_');
addTearDown(() async {
if (await outside.exists()) await outside.delete(recursive: true);
});
File('${outside.path}/secret.txt').writeAsStringSync('payload');
Link('${sandbox.path}/leak').createSync('${outside.path}/secret.txt');
final r = await call('files.read', const {'path': 'leak'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('outside workspace'));
});
test('files.ls rejects a symlinked subdir whose target is outside (T-102)', () async {
final outside = await Directory.systemTemp.createTemp('clide_t102_ls_');
addTearDown(() async {
if (await outside.exists()) await outside.delete(recursive: true);
});
Link('${sandbox.path}/leak-dir').createSync(outside.path);
final r = await call('files.ls', const {'path': 'leak-dir'});
expect(r.ok, isFalse);
expect(r.error!.message, contains('outside workspace'));
});
test('files.watch is idempotent: a second call still acks subscription', () async {
final r1 = await call('files.watch', const {});
final r2 = await call('files.watch', const {});