confine editor.open/editor.save to the workspace (T-363)

The editor registry resolved buffer paths with a string join that
passed absolute paths through verbatim and never normalized `..` —
an unconfined read and write primitive over IPC while files.read was
carefully guarded. Buffer paths now resolve through
resolveUnderRootFollowingSymlinks: traversal, absolute escapes, and
symlinks-out are rejected at open, and re-checked at save so a
symlink swapped in under an open buffer's path can't redirect the
write. D-80's extra read roots deliberately do not apply — a buffer
is a write surface. Handlers map PathOutsideRoot to the same error
files.read uses. Also merges a duplicate Added heading that had crept
into the Unreleased changelog section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:39:23 +02:00
co-authored by Claude Fable 5
parent 88d72789f4
commit ba6ab51118
6 changed files with 87 additions and 9 deletions
+6 -2
View File
@@ -9,6 +9,7 @@ library;
import 'dart:convert';
import 'dart:io';
import '../files/path_safety.dart';
import '../ipc/envelope.dart';
import '../panes/event_sink.dart';
import 'buffer.dart';
@@ -212,10 +213,13 @@ class EditorRegistry {
events.emit(IpcEvent(subsystem: 'editor', kind: kind, timestamp: DateTime.now().toUtc(), data: data));
}
/// Resolve a buffer path to disk under the workspace root, with the
/// same traversal/symlink containment as files.* (T-363). A buffer is
/// a WRITE surface (save), so the D-80 extra read roots do not apply —
/// strictly workspace-confined. Throws [PathOutsideRoot] on escape.
String _absolutePathOf(String repoRelative) {
if (repoRelative.startsWith('/')) return repoRelative;
final sep = Platform.pathSeparator;
return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}';
return resolveUnderRootFollowingSymlinks(workspaceRoot, repoRelative.replaceAll('/', sep));
}
// Support JSON decode of Selection from IPC args.