diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9881fe19d0..239c3dd1de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,16 @@
## [Unreleased]
+## [v0.51.558] — 2026-06-21 — Release TQ (workspace tree shows symlinks that point outside the workspace)
+
+### Added
+
+- **The workspace file tree now shows symlinks whose target resolves outside the workspace, as display-only rows.** Previously these were silently dropped, so a link in your workspace simply didn't appear. They now show with an indicator that they point outside the workspace; they remain non-navigable (the read/list gate still blocks traversing through them), and the row deliberately does **not** disclose the resolved outside path or any target metadata. Thanks @claw-io.
+
+### Fixed
+
+- **A prompt dialog (rename / new file) could open without its Cancel button** right after viewing an outside-symlink's info dialog. The shared dialog's Cancel button is now always restored.
+
## [v0.51.557] — 2026-06-21 — Release TP (jump-to-question stays discoverable on desktop)
### Changed
diff --git a/api/workspace.py b/api/workspace.py
index 5e83d26b99..f736d25ad5 100644
--- a/api/workspace.py
+++ b/api/workspace.py
@@ -1220,32 +1220,54 @@ def _process(name, is_symlink, raw_link, lstat_result, reachable):
return # target is under link_target — ancestor → cycle
except ValueError:
pass
- # Hide symlinks that resolve outside the workspace (can never be opened).
+ # Tag symlinks whose resolved target escapes the workspace root.
+ # Previously silently dropped; now emitted with target_outside_workspace=True
+ # so the workspace tree can show the link exists (display-only — the
+ # read/list gate in safe_resolve_ws / open_anchored_fd still blocks
+ # navigation through it).
+ target_outside_workspace = False
try:
link_target.relative_to(ws_resolved)
except ValueError:
- return
+ target_outside_workspace = True
if _is_blocked_system_path(link_target):
return
- is_dir = link_target.is_dir()
display_path = name
if rel and rel != '.':
display_path = rel + '/' + display_path
mtime_ns = lstat_result.st_mtime_ns if lstat_result is not None else None
- entry = {
- 'name': name,
- 'path': display_path,
- 'type': 'symlink',
- 'target': str(link_target),
- 'is_dir': is_dir,
- 'mtime_ns': mtime_ns,
- }
- if not is_dir:
- try:
- entry['size'] = link_target.stat().st_size
- except OSError:
- entry['size'] = None
- entries.append(entry)
+ if target_outside_workspace:
+ # #4581 hardening: a display-only escape-target symlink must NOT
+ # disclose where it points. Emit ONLY display-safe fields — never
+ # the resolved outside path, target-derived is_dir, or target size
+ # (the row exists to show the link is present; navigation/read
+ # through it stays blocked by safe_resolve_ws/open_anchored_fd).
+ entry = {
+ 'name': name,
+ 'path': display_path,
+ 'type': 'symlink',
+ 'is_dir': False,
+ 'target_outside_workspace': True,
+ 'mtime_ns': mtime_ns,
+ }
+ entries.append(entry)
+ else:
+ is_dir = link_target.is_dir()
+ entry = {
+ 'name': name,
+ 'path': display_path,
+ 'type': 'symlink',
+ 'target': str(link_target),
+ 'is_dir': is_dir,
+ 'target_outside_workspace': False,
+ 'mtime_ns': mtime_ns,
+ }
+ if not is_dir:
+ try:
+ entry['size'] = link_target.stat().st_size
+ except OSError:
+ entry['size'] = None
+ entries.append(entry)
else:
entry_path = name
if rel and rel != '.':
@@ -1381,6 +1403,7 @@ def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None =
'size': entry.get('size'),
'mtime_ns': entry.get('mtime_ns'),
'target': entry.get('target'),
+ 'target_outside_workspace': entry.get('target_outside_workspace'),
})
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
diff --git a/static/i18n.js b/static/i18n.js
index 65ec0863f1..ec0df00f3d 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -443,6 +443,7 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
@@ -1988,6 +1989,7 @@ const LOCALES = {
downloading: (name) => `Scaricamento ${name}\u2026`,
double_click_rename: 'Doppio clic per rinominare',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Rinominato in ',
rename_failed: 'Rinomina fallita: ',
@@ -3524,6 +3526,7 @@ const LOCALES = {
downloading: (name) => `${name} をダウンロード中…`,
double_click_rename: 'ダブルクリックで名前変更',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
renamed_to: '名前を変更: ',
rename_failed: '名前変更失敗: ',
@@ -4984,6 +4987,7 @@ const LOCALES = {
downloading: (name) => `Скачиваю ${name}…`,
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Переименовано в ',
rename_failed: 'Не удалось переименовать: ',
@@ -6437,6 +6441,7 @@ const LOCALES = {
downloading: (name) => `Descargando ${name}…`,
double_click_rename: 'Haz doble clic para renombrar',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renombrado a ',
rename_failed: 'Error al renombrar: ',
@@ -7888,6 +7893,7 @@ const LOCALES = {
downloading: (name) => `Lade ${name} herunter\u2026`,
double_click_rename: 'Doppelklick zum Umbenennen',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Umbenannt in ',
rename_failed: 'Umbenennen fehlgeschlagen: ',
@@ -9404,6 +9410,7 @@ const LOCALES = {
downloading: (name) => `正在下载 ${name}...`,
double_click_rename: '双击重命名',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: '已重命名为 ',
rename_failed: '重命名失败:',
@@ -10954,6 +10961,7 @@ const LOCALES = {
downloading: (name) => `正在下載 ${name}…`,
double_click_rename: '按兩下即可重新命名',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
renamed_to: '已重新命名為 ',
rename_failed: '重新命名失敗:',
@@ -12393,6 +12401,7 @@ const LOCALES = {
downloading: (name) => `Baixando ${name}…`,
double_click_rename: 'Duplo clique para renomear',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renomeado para ',
rename_failed: 'Falha ao renomear: ',
@@ -13823,6 +13832,7 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
@@ -15298,6 +15308,7 @@ const LOCALES = {
file_open_failed: 'Impossible d\'ouvrir le fichier',
double_click_rename: 'Double-cliquez pour renommer',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renommé en',
rename_failed: 'Échec du changement de nom :',
@@ -16839,6 +16850,7 @@ const LOCALES = {
downloading: (name) => `${name} indiriliyor\u2026`,
double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Yeniden adlandırıldı',
rename_failed: 'Yeniden adlandırma başarısız oldu:',
@@ -18389,6 +18401,7 @@ const LOCALES = {
downloading: (name) => `Pobieranie ${name}…`,
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
symlink_link_to: 'Symlink → {target}',
+ external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
renamed_to: 'Nazwa zmieniona na ',
rename_failed: 'Zmiana nazwy nie powiodła się: ',
diff --git a/static/icons.js b/static/icons.js
index 5fe3a28193..68e4190e8a 100644
--- a/static/icons.js
+++ b/static/icons.js
@@ -12,6 +12,7 @@ const LI_PATHS = {
'lightbulb': '',
'folder': '',
'link': '',
+ 'external-link': '',
'list-todo': '',
// Editing / actions
'pencil': '',
diff --git a/static/ui.js b/static/ui.js
index 7346fed8e9..a16c4529b3 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -5885,7 +5885,10 @@ function showConfirmDialog(opts={}){
if(title) title.textContent=opts.title||t('dialog_confirm_title');
if(desc) desc.textContent=opts.message||'';
if(input){input.style.display='none';input.value='';}
- if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
+ if(cancelBtn){
+ if(opts.hideCancel){cancelBtn.style.display='none';}
+ else{cancelBtn.style.display='';cancelBtn.textContent=opts.cancelLabel||t('cancel');}
+ }
if(confirmBtn){
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
confirmBtn.classList.toggle('danger',!!opts.danger);
@@ -5915,7 +5918,13 @@ function showPromptDialog(opts={}){
input.value=prefill;input.placeholder=opts.placeholder||'';
input.autocomplete='off';input.spellcheck=false;
}
- if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
+ if(cancelBtn){
+ // A prior showConfirmDialog({hideCancel:true}) (e.g. the outside-symlink info
+ // dialog, #4581) may have hidden the shared Cancel button; always restore it
+ // so a subsequent prompt keeps its Cancel affordance.
+ cancelBtn.style.display='';
+ cancelBtn.textContent=opts.cancelLabel||t('cancel');
+ }
if(confirmBtn){
confirmBtn.textContent=opts.confirmLabel||t('create');
confirmBtn.classList.toggle('danger',!!opts.danger);
@@ -14350,9 +14359,13 @@ function _renderTreeItems(container, entries, depth){
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
const isLk = item.type === 'symlink';
- const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
- const isFileLike = !isDirLike;
+ const isExternalLink = isLk && item.target_outside_workspace;
+ // External symlinks are display-only: not expandable, not openable.
+ // The read gate (safe_resolve_ws) still blocks navigation through them.
+ const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
+ const isFileLike = !isExternalLink && !isDirLike;
el.dataset.wsIsDir = String(isDirLike);
+ if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;}
if(isDirLike){
// Toggle arrow for directories
@@ -14373,9 +14386,11 @@ function _renderTreeItems(container, entries, depth){
// Icon
const iconEl=document.createElement('span');
iconEl.className='file-icon';
- iconEl.innerHTML = isDirLike
- ? (isLk ? li('link', 14) : li('folder', 14))
- : (isLk ? li('link', 14) : fileIcon(item.name, item.type));
+ iconEl.innerHTML = isExternalLink
+ ? li('external-link', 14)
+ : isDirLike
+ ? (isLk ? li('link', 14) : li('folder', 14))
+ : (isLk ? li('link', 14) : fileIcon(item.name, item.type));
el.appendChild(iconEl);
// Name
@@ -14407,6 +14422,8 @@ function _renderTreeItems(container, entries, depth){
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
// For directories, double-click navigates (breadcrumb view)
if(isDirLike){loadDir(item.path);return;}
+ // External symlinks: show informational dialog, not rename
+ if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;}
const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation();
@@ -14495,6 +14512,21 @@ function _renderTreeItems(container, entries, depth){
renderFileTree();
}
};
+ }else if(isExternalLink){
+ // Display-only: the link points outside the workspace. We do NOT disclose
+ // the resolved outside path (#4581 hardening) and do NOT call openFile —
+ // the read gate (safe_resolve_ws) blocks navigation through the link.
+ el.onclick=async(e)=>{
+ e.stopPropagation();
+ await showConfirmDialog({
+ title:item.name,
+ message:t('external_link_open_confirm'),
+ confirmLabel:t('dialog_confirm_btn'),
+ danger:false,
+ hideCancel:true,
+ focusCancel:false,
+ });
+ };
}else{
el.onclick=async()=>openFile(item.path);
}
diff --git a/tests/test_1707_workspace_filename_click.py b/tests/test_1707_workspace_filename_click.py
index b0011a9f1e..a5fba85ab7 100644
--- a/tests/test_1707_workspace_filename_click.py
+++ b/tests/test_1707_workspace_filename_click.py
@@ -248,15 +248,16 @@ def _run_node_with_clicks(click_count: int, dblclick_after_first: bool, item_typ
// Symlink locals referenced by the handler block — declared from item
// just like _renderTreeItem does before the tooltip assignment.
const isLk = item.type === 'symlink';
-const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
+const isExternalLink = isLk && item.target_outside_workspace;
+const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
const elideMiddle = (s) => s;
const runner = new Function(
'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window',
- 'setTimeout', 'clearTimeout', 'isLk', 'isDirLike', 'elideMiddle',
+ 'setTimeout', 'clearTimeout', 'isLk', 'isExternalLink', 'isDirLike', 'elideMiddle',
'(()=>{' + handlerBlock + '})();'
);
-runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isDirLike, elideMiddle);
+runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isExternalLink, isDirLike, elideMiddle);
const evt = { stopPropagation: () => {} };
for (let i = 0; i < clickCount; i++) {
diff --git a/tests/test_symlink_cycle_detection.py b/tests/test_symlink_cycle_detection.py
index 2878c5c237..a0939e1a64 100644
--- a/tests/test_symlink_cycle_detection.py
+++ b/tests/test_symlink_cycle_detection.py
@@ -9,7 +9,8 @@
- Self-referencing symlink (ln -s . ~/workspace/loop)
- Ancestor symlink (ln -s .. ~/workspace/up)
- Internal symlink entries carry correct type / is_dir / target fields
-- External symlink directories are hidden from listings and cannot be traversed
+- External symlink directories are emitted as display-only rows (target_outside_workspace=True)
+ and cannot be traversed
"""
import json
import os
@@ -57,8 +58,8 @@ def make_session(created_list, ws=None):
class TestSymlinkCycleDetection:
"""Symlink cycle detection in list_dir / safe_resolve_ws."""
- def test_external_symlink_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory):
- """External symlink dirs should be hidden from workspace listings."""
+ def test_external_symlink_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory):
+ """External symlink dirs are emitted with target_outside_workspace=True (display-only)."""
ws = tmp_path_factory.mktemp("ws")
target = tmp_path_factory.mktemp("target")
(target / "file.txt").write_text("hello")
@@ -67,8 +68,14 @@ def test_external_symlink_filtered_from_listing(self, cleanup_test_sessions, tmp
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.")
- names = [e["name"] for e in listing["entries"]]
- assert "ext" not in names
+ entries = {e["name"]: e for e in listing["entries"]}
+ assert "ext" in entries
+ assert entries["ext"]["type"] == "symlink"
+ assert entries["ext"]["target_outside_workspace"] is True
+ # #4581 hardening: display-only escape rows don't disclose target-derived
+ # metadata (is_dir/target), so an external dir symlink reports is_dir=False.
+ assert entries["ext"]["is_dir"] is False
+ assert "target" not in entries["ext"]
def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory):
"""Internal symlink dirs should appear with type='symlink', is_dir=True."""
@@ -149,10 +156,11 @@ def test_symlink_cycle_in_subdir(self, cleanup_test_sessions, tmp_path_factory):
(ws / "ext").symlink_to(target)
sid, _ = make_session(cleanup_test_sessions, ws)
- # List root — should hide the external symlink and not recurse.
+ # List root — external symlink is emitted as display-only, not traversed.
listing = get(f"/api/list?session_id={sid}&path=.")
- names = [e["name"] for e in listing["entries"]]
- assert "ext" not in names
+ entries = {e["name"]: e for e in listing["entries"]}
+ assert "ext" in entries
+ assert entries["ext"]["target_outside_workspace"] is True
# Traversing into ext/subdir crosses the workspace boundary and is blocked.
try:
@@ -177,8 +185,8 @@ def test_internal_symlink_file_entry(self, cleanup_test_sessions, tmp_path_facto
assert link[0]["is_dir"] is False
assert link[0]["size"] == 11 # len("hello world")
- def test_external_symlink_file_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory):
- """External symlink files should be hidden from workspace listings."""
+ def test_external_symlink_file_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory):
+ """External symlink files are emitted with target_outside_workspace=True (display-only)."""
ws = tmp_path_factory.mktemp("ws")
real = tmp_path_factory.mktemp("real")
(real / "data.txt").write_text("hello world")
@@ -186,8 +194,11 @@ def test_external_symlink_file_filtered_from_listing(self, cleanup_test_sessions
sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.")
- names = [e["name"] for e in listing["entries"]]
- assert "link.txt" not in names
+ entries = {e["name"]: e for e in listing["entries"]}
+ assert "link.txt" in entries
+ assert entries["link.txt"]["type"] == "symlink"
+ assert entries["link.txt"]["target_outside_workspace"] is True
+ assert entries["link.txt"]["is_dir"] is False
def test_path_traversal_still_blocked(self, cleanup_test_sessions, tmp_path_factory):
"""Raw .. traversal must still be blocked even with symlink support."""
diff --git a/tests/test_workspace_context_menu_and_rename.py b/tests/test_workspace_context_menu_and_rename.py
index 2a8958efef..672b026cc7 100644
--- a/tests/test_workspace_context_menu_and_rename.py
+++ b/tests/test_workspace_context_menu_and_rename.py
@@ -184,6 +184,20 @@ def _slice_show_prompt_dialog(self) -> str:
break
return self.src[start:end]
+ def test_show_prompt_dialog_restores_hidden_cancel_button(self):
+ """#4581: the outside-symlink info dialog calls showConfirmDialog with
+ hideCancel:true, which sets the shared #appDialogCancel button to
+ display:none. showPromptDialog reuses the same button, so it must restore
+ the display before showing — otherwise the next rename/new-file prompt
+ opens with no Cancel button after a user clicks an external symlink."""
+ body = self._slice_show_prompt_dialog()
+ self.assertIn(
+ "cancelBtn.style.display=''", body.replace(" ", ""),
+ "showPromptDialog must reset cancelBtn.style.display so a prior "
+ "showConfirmDialog({hideCancel:true}) can't leave the Cancel button "
+ "hidden on a subsequent prompt (#4581 regression).",
+ )
+
def test_show_prompt_dialog_accepts_default_value_alias(self):
body = self._slice_show_prompt_dialog()
# Must reference `opts.defaultValue` somewhere — the alias was the
diff --git a/tests/test_workspace_symlink_containment.py b/tests/test_workspace_symlink_containment.py
index b359717e24..2710db21b0 100644
--- a/tests/test_workspace_symlink_containment.py
+++ b/tests/test_workspace_symlink_containment.py
@@ -11,13 +11,23 @@ def test_safe_resolve_blocks_external_symlink_directory(tmp_path):
(outside / "secret.txt").write_text("outside", encoding="utf-8")
(workspace / "escape").symlink_to(outside)
+ # The read/list gate still blocks navigation through the escape symlink.
with pytest.raises(ValueError, match="Path traversal blocked"):
safe_resolve_ws(workspace, "escape")
with pytest.raises(ValueError, match="Path traversal blocked"):
list_dir(workspace, "escape")
- assert "escape" not in {entry["name"] for entry in list_dir(workspace, ".")}
+ # The escape symlink is now emitted (display-only) with target_outside_workspace=True.
+ entries = {e["name"]: e for e in list_dir(workspace, ".")}
+ assert "escape" in entries
+ assert entries["escape"]["type"] == "symlink"
+ assert entries["escape"]["target_outside_workspace"] is True
+ # #4581 hardening: display-only escape rows are uniformly is_dir=False — the
+ # target's real dir/file nature is target-derived metadata we don't disclose
+ # (the row is non-navigable regardless).
+ assert entries["escape"]["is_dir"] is False
+ assert "target" not in entries["escape"]
def test_read_file_blocks_external_symlink_file(tmp_path):
@@ -28,10 +38,16 @@ def test_read_file_blocks_external_symlink_file(tmp_path):
(outside / "secret.txt").write_text("outside", encoding="utf-8")
(workspace / "secret-link.txt").symlink_to(outside / "secret.txt")
+ # The read gate still blocks reading through the escape symlink.
with pytest.raises(ValueError, match="Path traversal blocked"):
read_file_content(workspace, "secret-link.txt")
- assert "secret-link.txt" not in {entry["name"] for entry in list_dir(workspace, ".")}
+ # The escape symlink is now emitted (display-only) with target_outside_workspace=True.
+ entries = {e["name"]: e for e in list_dir(workspace, ".")}
+ assert "secret-link.txt" in entries
+ assert entries["secret-link.txt"]["type"] == "symlink"
+ assert entries["secret-link.txt"]["target_outside_workspace"] is True
+ assert entries["secret-link.txt"]["is_dir"] is False
def test_internal_symlink_still_resolves_within_workspace(tmp_path):
@@ -230,7 +246,9 @@ def test_list_read_create_work_on_no_dir_fd_fallback(tmp_path, monkeypatch):
assert "a.txt" in names
if w._DIR_FD_OK:
assert "internal" in names # legit internal symlink listed
- assert "escape" not in names # external symlink hidden
+ assert "escape" in names # external symlink emitted (display-only)
+ escape_entry = next(e for e in w.list_dir(workspace, ".") if e["name"] == "escape")
+ assert escape_entry["target_outside_workspace"] is True
assert w.read_file_content(workspace, "a.txt")["content"] == "hi"
fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt")
@@ -277,3 +295,84 @@ def racing_open(path, *args, **kwargs):
pass # refused — correct
finally:
os.open = real_open
+
+
+# ── #4510: escape-target symlinks are now emitted as display-only rows ──────
+# The escape filter was widened (not removed): symlinks whose resolved target
+# sits outside the workspace root are now emitted with
+# target_outside_workspace=True instead of being silently dropped. The
+# read/list gate (safe_resolve_ws / open_anchored_fd) is unchanged and still
+# blocks navigation through them. ──────────────────────────────────────────
+
+
+def test_list_dir_in_workspace_symlink_shape(tmp_path):
+ """In-workspace symlinks emit type='symlink' with target_outside_workspace=False."""
+ import api.workspace as w
+
+ if not w._DIR_FD_OK:
+ pytest.skip("symlink listing requires dir_fd support")
+
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ (workspace / "data.txt").write_text("hello", encoding="utf-8")
+ (workspace / "link.txt").symlink_to(workspace / "data.txt")
+
+ entries = {e["name"]: e for e in w.list_dir(workspace, ".")}
+ assert "link.txt" in entries
+ assert entries["link.txt"]["type"] == "symlink"
+ assert entries["link.txt"]["target_outside_workspace"] is False
+ assert entries["link.txt"]["is_dir"] is False
+ assert entries["link.txt"]["target"] == str((workspace / "data.txt").resolve())
+
+
+def test_list_dir_outside_workspace_symlink_emitted_with_flag(tmp_path):
+ """Escape-target symlinks are emitted with target_outside_workspace=True."""
+ workspace = tmp_path / "workspace"
+ outside = tmp_path / "outside"
+ workspace.mkdir()
+ outside.mkdir()
+ (outside / "file.txt").write_text("external", encoding="utf-8")
+ (workspace / "ext-link.txt").symlink_to(outside / "file.txt")
+
+ entries = {e["name"]: e for e in list_dir(workspace, ".")}
+ assert "ext-link.txt" in entries
+ assert entries["ext-link.txt"]["type"] == "symlink"
+ assert entries["ext-link.txt"]["target_outside_workspace"] is True
+ assert entries["ext-link.txt"]["is_dir"] is False
+ # #4581 hardening: a display-only escape-target row must NOT disclose where it
+ # points — no resolved outside path, no target-derived size, no target-derived
+ # metadata. Only the link name/path + the display-only flag are emitted.
+ assert "target" not in entries["ext-link.txt"]
+ assert "size" not in entries["ext-link.txt"]
+
+
+def test_list_dir_external_symlink_blocked_system_path_unchanged(tmp_path):
+ """Symlinks to blocked system paths (/etc, /usr) are still filtered out."""
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ (workspace / "etc-link").symlink_to("/etc")
+ (workspace / "usr-link").symlink_to("/usr")
+
+ names = {e["name"] for e in list_dir(workspace, ".")}
+ assert "etc-link" not in names
+ assert "usr-link" not in names
+
+
+def test_list_dir_escape_symlink_read_still_blocked(tmp_path):
+ """Listing shows the escape symlink (display-only) but read_file_content
+ on the same target still raises ValueError — proving the read gate is intact."""
+ workspace = tmp_path / "workspace"
+ outside = tmp_path / "outside"
+ workspace.mkdir()
+ outside.mkdir()
+ (outside / "secret.txt").write_text("secret", encoding="utf-8")
+ (workspace / "escape.txt").symlink_to(outside / "secret.txt")
+
+ # Listing emits the entry with target_outside_workspace=True
+ entries = {e["name"]: e for e in list_dir(workspace, ".")}
+ assert "escape.txt" in entries
+ assert entries["escape.txt"]["target_outside_workspace"] is True
+
+ # But reading through it is still blocked
+ with pytest.raises(ValueError, match="Path traversal blocked"):
+ read_file_content(workspace, "escape.txt")
diff --git a/tests/test_workspace_symlink_tree_shape.py b/tests/test_workspace_symlink_tree_shape.py
index 5ccf0b418f..787c220a1d 100644
--- a/tests/test_workspace_symlink_tree_shape.py
+++ b/tests/test_workspace_symlink_tree_shape.py
@@ -27,14 +27,19 @@ def test_isLk_local_declared(self):
assert "const isLk = item.type === 'symlink';" in block, \
"isLk local must be declared inside the per-item loop"
+ def test_isExternalLink_local_declared(self):
+ block = _render_block()
+ assert "const isExternalLink = isLk && item.target_outside_workspace;" in block, \
+ "isExternalLink local must be declared for display-only external symlinks"
+
def test_isDirLike_local_declared(self):
block = _render_block()
- assert "const isDirLike = item.type === 'dir' || (isLk && item.is_dir);" in block, \
+ assert "const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));" in block, \
"isDirLike local must be declared inside the per-item loop"
def test_isFileLike_local_declared(self):
block = _render_block()
- assert "const isFileLike = !isDirLike;" in block, \
+ assert "const isFileLike = !isExternalLink && !isDirLike;" in block, \
"isFileLike local must be declared for file-like symlink handling"
def test_wsIsDir_dataset_set(self):
@@ -65,6 +70,15 @@ def test_icon_dispatch_uses_li_link(self):
assert "li('link', 14)" in block, \
"icon dispatch must emit li('link', 14) for symlink rows"
+ def test_external_link_icon_path_exists(self):
+ assert "'external-link':" in ICONS_JS, \
+ "'external-link' key must be in LI_PATHS for display-only external symlinks"
+
+ def test_icon_dispatch_uses_li_external_link(self):
+ block = _render_block()
+ assert "li('external-link', 14)" in block, \
+ "icon dispatch must emit li('external-link', 14) for external symlink rows"
+
class TestSymlinkTooltip:
def test_symlink_link_to_key_in_i18n(self):