detect symlinks from the lister entity, never descend them (T-365)

stat() follows links, so `stat.type == link` was always false: every
FileEntry reported isSymlink=false and walkFiles happily descended
symlinked directories — an escape hatch out of the workspace and a
cycle risk for the search engine built on the walk. The lister already
runs with followLinks: false, so the Link entity itself is the signal.
listDir keeps reporting the target type for the UI; walkFiles skips
descent into symlinked dirs and still emits file symlinks as entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:32:24 +02:00
co-authored by Claude Fable 5
parent 34821fbc39
commit 8477e302ef
5 changed files with 66 additions and 5 deletions
@@ -3945,3 +3945,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:26:15', '2026-06-11 22:26:15', '2026-06-11 22:26:15', NULL, '05386705854fd48ee75d7a3dfbfd5bc9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:26:38', '2026-06-11 22:26:38', '2026-06-11 22:26:38', NULL, '6d14a673d6733b5024726bfc227f6711', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:30:24', '2026-06-11 22:30:24', '2026-06-11 22:30:24', NULL, 'aa1fe975e147be83905f24c63662254d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:30:48', '2026-06-11 22:30:48', '2026-06-11 22:30:48', NULL, '2c5c8e5cdf68c96e25705f014dcd6acc', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:32:08', '2026-06-11 22:32:08', '2026-06-11 22:32:08', NULL, 'f3dbb31fb6f6f416a2d4d8ef37723316', 2) ON CONFLICT(hash) DO NOTHING;
+6
View File
@@ -4001,3 +4001,9 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:30:24', NULL, 'b64b55f7d40149848fc687efe1cf8f32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 22:30:48', NULL, 'aa0f09e575dd2d3232f34485982c1ec5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBSG6356MZJ2DCCCSBMBGM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'listDir symlink detection is dead code; walkFiles descends symlinked dirs', 'lib/src/files/listing.dart:46-54 — stat() follows links, so isSymlink is always false; walkFiles therefore descends symlinked directories the docs claim it skips (escape hatch out of the workspace, plus cycle risk).
Fix: use lstat (FileStat via Link check / FileSystemEntity.isLinkSync on the raw path) for symlink detection. Tests: symlinked dir is reported as symlink and not descended; symlink cycle does not hang the walk.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:55:49', '2026-06-11 22:32:08', NULL, 'f83e31fd330c98a311a3da10cba1c655', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5
View File
@@ -52,6 +52,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form
instead of being mangled. (T-369)
- **File listing flags symlinks again and the workspace walk no longer
follows them.** Symlink detection was dead code, so `files.walk` (and the
search engine on top of it) silently descended symlinked directories —
including ones pointing outside the workspace. (T-365)
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
session out of plan mode, but clide's tracked permission mode didn't follow,
+11 -5
View File
@@ -43,6 +43,11 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
await for (final e in resolved.list(followLinks: false)) {
final name = e.uri.pathSegments.isNotEmpty ? e.uri.pathSegments.where((s) => s.isNotEmpty).last : '';
final rel = dir.isEmpty ? name : '$dir/$name';
// With followLinks: false the lister yields Link entities for symlinks —
// that's the symlink signal. stat() follows the link (target type/size,
// notFound for broken links), so its type can never be `link` and must
// not be used for detection (T-365).
final isLink = e is Link;
final stat = await e.stat();
final isDir = stat.type == FileSystemEntityType.directory;
if (ignore.isIgnored(rel, isDirectory: isDir)) continue;
@@ -51,7 +56,7 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
name: name,
path: rel,
isDirectory: isDir,
isSymlink: stat.type == FileSystemEntityType.link,
isSymlink: isLink,
sizeBytes: isDir ? null : stat.size,
modifiedMs: stat.modified.millisecondsSinceEpoch,
),
@@ -79,9 +84,10 @@ class WalkResult {
/// Recursively walk [root], returning every non-ignored *file*
/// (directories are descended into but not emitted), pruned by
/// [ignore]. Reuses [listDir] per directory, so ignore filtering,
/// symlink-escape safety (`followLinks: false`), and per-directory
/// sorting are inherited.
/// [ignore]. Reuses [listDir] per directory, so ignore filtering and
/// per-directory sorting are inherited. Symlinks are never descended —
/// a symlinked directory would be an escape hatch out of the workspace
/// and a cycle risk (T-365); symlinks to files are emitted as entries.
///
/// Capped at [maxFiles] to bound work on pathological trees; when the
/// cap is hit the walk stops early and [WalkResult.truncated] is set so
@@ -97,7 +103,7 @@ Future<WalkResult> walkFiles({required Directory root, required IgnoreSet ignore
final entries = await listDir(root: root, dir: dir, ignore: ignore);
for (final e in entries) {
if (e.isDirectory) {
stack.add(e.path);
if (!e.isSymlink) stack.add(e.path);
} else {
out.add(e);
if (out.length >= maxFiles) {
+42
View File
@@ -56,4 +56,46 @@ void main() {
expect(r.files, isEmpty);
expect(r.truncated, isFalse);
});
// T-365: stat() follows links, so the old detection (stat.type == link)
// was always false and walkFiles descended symlinked directories —
// an escape hatch out of the workspace.
group('symlinks (T-365)', () {
test('listDir reports a symlinked directory as a symlink', () async {
final outside = await Directory.systemTemp.createTemp('clide-walk-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
File('${outside.path}/secret.txt').writeAsStringSync('s');
Link('${root.path}/linked').createSync(outside.path);
final entries = await listDir(root: root, dir: '', ignore: IgnoreSet([]));
final linked = entries.singleWhere((e) => e.name == 'linked');
expect(linked.isSymlink, isTrue);
expect(linked.isDirectory, isTrue, reason: 'target type still reported for the UI');
});
test('walkFiles does not descend a symlinked directory', () async {
final outside = await Directory.systemTemp.createTemp('clide-walk-outside-');
addTearDown(() => outside.deleteSync(recursive: true));
File('${outside.path}/secret.txt').writeAsStringSync('s');
Link('${root.path}/linked').createSync(outside.path);
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
expect(r.files.map((e) => e.path), isNot(contains('linked/secret.txt')));
});
test('a symlink cycle does not hang the walk', () async {
Link('${root.path}/lib/loop').createSync(root.path);
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
expect(r.truncated, isFalse);
expect(r.files.map((e) => e.path), contains('README.md'));
});
test('a symlink to a file is emitted as a file entry, flagged', () async {
Link('${root.path}/readme-link').createSync('${root.path}/README.md');
final r = await walkFiles(root: root, ignore: IgnoreSet([]));
final e = r.files.singleWhere((e) => e.path == 'readme-link');
expect(e.isSymlink, isTrue);
expect(e.isDirectory, isFalse);
});
});
}