Skip to content

Commit 1b194a3

Browse files
Merge pull request #4610 from nesquena/stage-4581
Release v0.51.558 — escape-target symlinks display-only (#4581)
2 parents 4c45fc8 + 7641e7d commit 1b194a3

10 files changed

Lines changed: 262 additions & 44 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33

44
## [Unreleased]
55

6+
## [v0.51.558] — 2026-06-21 — Release TQ (workspace tree shows symlinks that point outside the workspace)
7+
8+
### Added
9+
10+
- **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.
11+
12+
### Fixed
13+
14+
- **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.
15+
616
## [v0.51.557] — 2026-06-21 — Release TP (jump-to-question stays discoverable on desktop)
717

818
### Changed

api/workspace.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,32 +1220,54 @@ def _process(name, is_symlink, raw_link, lstat_result, reachable):
12201220
return # target is under link_target — ancestor → cycle
12211221
except ValueError:
12221222
pass
1223-
# Hide symlinks that resolve outside the workspace (can never be opened).
1223+
# Tag symlinks whose resolved target escapes the workspace root.
1224+
# Previously silently dropped; now emitted with target_outside_workspace=True
1225+
# so the workspace tree can show the link exists (display-only — the
1226+
# read/list gate in safe_resolve_ws / open_anchored_fd still blocks
1227+
# navigation through it).
1228+
target_outside_workspace = False
12241229
try:
12251230
link_target.relative_to(ws_resolved)
12261231
except ValueError:
1227-
return
1232+
target_outside_workspace = True
12281233
if _is_blocked_system_path(link_target):
12291234
return
1230-
is_dir = link_target.is_dir()
12311235
display_path = name
12321236
if rel and rel != '.':
12331237
display_path = rel + '/' + display_path
12341238
mtime_ns = lstat_result.st_mtime_ns if lstat_result is not None else None
1235-
entry = {
1236-
'name': name,
1237-
'path': display_path,
1238-
'type': 'symlink',
1239-
'target': str(link_target),
1240-
'is_dir': is_dir,
1241-
'mtime_ns': mtime_ns,
1242-
}
1243-
if not is_dir:
1244-
try:
1245-
entry['size'] = link_target.stat().st_size
1246-
except OSError:
1247-
entry['size'] = None
1248-
entries.append(entry)
1239+
if target_outside_workspace:
1240+
# #4581 hardening: a display-only escape-target symlink must NOT
1241+
# disclose where it points. Emit ONLY display-safe fields — never
1242+
# the resolved outside path, target-derived is_dir, or target size
1243+
# (the row exists to show the link is present; navigation/read
1244+
# through it stays blocked by safe_resolve_ws/open_anchored_fd).
1245+
entry = {
1246+
'name': name,
1247+
'path': display_path,
1248+
'type': 'symlink',
1249+
'is_dir': False,
1250+
'target_outside_workspace': True,
1251+
'mtime_ns': mtime_ns,
1252+
}
1253+
entries.append(entry)
1254+
else:
1255+
is_dir = link_target.is_dir()
1256+
entry = {
1257+
'name': name,
1258+
'path': display_path,
1259+
'type': 'symlink',
1260+
'target': str(link_target),
1261+
'is_dir': is_dir,
1262+
'target_outside_workspace': False,
1263+
'mtime_ns': mtime_ns,
1264+
}
1265+
if not is_dir:
1266+
try:
1267+
entry['size'] = link_target.stat().st_size
1268+
except OSError:
1269+
entry['size'] = None
1270+
entries.append(entry)
12491271
else:
12501272
entry_path = name
12511273
if rel and rel != '.':
@@ -1381,6 +1403,7 @@ def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None =
13811403
'size': entry.get('size'),
13821404
'mtime_ns': entry.get('mtime_ns'),
13831405
'target': entry.get('target'),
1406+
'target_outside_workspace': entry.get('target_outside_workspace'),
13841407
})
13851408
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
13861409
return hashlib.sha256(raw.encode('utf-8')).hexdigest()

static/i18n.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ const LOCALES = {
443443
downloading: (name) => `Downloading ${name}\u2026`,
444444
double_click_rename: 'Double-click to rename',
445445
symlink_link_to: 'Symlink → {target}',
446+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
446447
session_rename_failed_no_row: 'Could not start rename — row not found.',
447448
renamed_to: 'Renamed to ',
448449
rename_failed: 'Rename failed: ',
@@ -1988,6 +1989,7 @@ const LOCALES = {
19881989
downloading: (name) => `Scaricamento ${name}\u2026`,
19891990
double_click_rename: 'Doppio clic per rinominare',
19901991
symlink_link_to: 'Symlink → {target}',
1992+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
19911993
session_rename_failed_no_row: 'Could not start rename — row not found.',
19921994
renamed_to: 'Rinominato in ',
19931995
rename_failed: 'Rinomina fallita: ',
@@ -3524,6 +3526,7 @@ const LOCALES = {
35243526
downloading: (name) => `${name} をダウンロード中…`,
35253527
double_click_rename: 'ダブルクリックで名前変更',
35263528
symlink_link_to: 'Symlink → {target}',
3529+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
35273530
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
35283531
renamed_to: '名前を変更: ',
35293532
rename_failed: '名前変更失敗: ',
@@ -4984,6 +4987,7 @@ const LOCALES = {
49844987
downloading: (name) => `Скачиваю ${name}…`,
49854988
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
49864989
symlink_link_to: 'Symlink → {target}',
4990+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
49874991
session_rename_failed_no_row: 'Could not start rename — row not found.',
49884992
renamed_to: 'Переименовано в ',
49894993
rename_failed: 'Не удалось переименовать: ',
@@ -6437,6 +6441,7 @@ const LOCALES = {
64376441
downloading: (name) => `Descargando ${name}…`,
64386442
double_click_rename: 'Haz doble clic para renombrar',
64396443
symlink_link_to: 'Symlink → {target}',
6444+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
64406445
session_rename_failed_no_row: 'Could not start rename — row not found.',
64416446
renamed_to: 'Renombrado a ',
64426447
rename_failed: 'Error al renombrar: ',
@@ -7888,6 +7893,7 @@ const LOCALES = {
78887893
downloading: (name) => `Lade ${name} herunter\u2026`,
78897894
double_click_rename: 'Doppelklick zum Umbenennen',
78907895
symlink_link_to: 'Symlink → {target}',
7896+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
78917897
session_rename_failed_no_row: 'Could not start rename — row not found.',
78927898
renamed_to: 'Umbenannt in ',
78937899
rename_failed: 'Umbenennen fehlgeschlagen: ',
@@ -9404,6 +9410,7 @@ const LOCALES = {
94049410
downloading: (name) => `正在下载 ${name}...`,
94059411
double_click_rename: '双击重命名',
94069412
symlink_link_to: 'Symlink → {target}',
9413+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
94079414
session_rename_failed_no_row: 'Could not start rename — row not found.',
94089415
renamed_to: '已重命名为 ',
94099416
rename_failed: '重命名失败:',
@@ -10954,6 +10961,7 @@ const LOCALES = {
1095410961
downloading: (name) => `正在下載 ${name}…`,
1095510962
double_click_rename: '按兩下即可重新命名',
1095610963
symlink_link_to: 'Symlink → {target}',
10964+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
1095710965
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
1095810966
renamed_to: '已重新命名為 ',
1095910967
rename_failed: '重新命名失敗:',
@@ -12393,6 +12401,7 @@ const LOCALES = {
1239312401
downloading: (name) => `Baixando ${name}…`,
1239412402
double_click_rename: 'Duplo clique para renomear',
1239512403
symlink_link_to: 'Symlink → {target}',
12404+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
1239612405
session_rename_failed_no_row: 'Could not start rename — row not found.',
1239712406
renamed_to: 'Renomeado para ',
1239812407
rename_failed: 'Falha ao renomear: ',
@@ -13823,6 +13832,7 @@ const LOCALES = {
1382313832
downloading: (name) => `Downloading ${name}\u2026`,
1382413833
double_click_rename: 'Double-click to rename',
1382513834
symlink_link_to: 'Symlink → {target}',
13835+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
1382613836
session_rename_failed_no_row: 'Could not start rename — row not found.',
1382713837
renamed_to: 'Renamed to ',
1382813838
rename_failed: 'Rename failed: ',
@@ -15298,6 +15308,7 @@ const LOCALES = {
1529815308
file_open_failed: 'Impossible d\'ouvrir le fichier',
1529915309
double_click_rename: 'Double-cliquez pour renommer',
1530015310
symlink_link_to: 'Symlink → {target}',
15311+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
1530115312
session_rename_failed_no_row: 'Could not start rename — row not found.',
1530215313
renamed_to: 'Renommé en',
1530315314
rename_failed: 'Échec du changement de nom :',
@@ -16839,6 +16850,7 @@ const LOCALES = {
1683916850
downloading: (name) => `${name} indiriliyor\u2026`,
1684016851
double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
1684116852
symlink_link_to: 'Symlink → {target}',
16853+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
1684216854
session_rename_failed_no_row: 'Could not start rename — row not found.',
1684316855
renamed_to: 'Yeniden adlandırıldı',
1684416856
rename_failed: 'Yeniden adlandırma başarısız oldu:',
@@ -18389,6 +18401,7 @@ const LOCALES = {
1838918401
downloading: (name) => `Pobieranie ${name}…`,
1839018402
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
1839118403
symlink_link_to: 'Symlink → {target}',
18404+
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
1839218405
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
1839318406
renamed_to: 'Nazwa zmieniona na ',
1839418407
rename_failed: 'Zmiana nazwy nie powiodła się: ',

static/icons.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

static/ui.js

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5885,7 +5885,10 @@ function showConfirmDialog(opts={}){
58855885
if(title) title.textContent=opts.title||t('dialog_confirm_title');
58865886
if(desc) desc.textContent=opts.message||'';
58875887
if(input){input.style.display='none';input.value='';}
5888-
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
5888+
if(cancelBtn){
5889+
if(opts.hideCancel){cancelBtn.style.display='none';}
5890+
else{cancelBtn.style.display='';cancelBtn.textContent=opts.cancelLabel||t('cancel');}
5891+
}
58895892
if(confirmBtn){
58905893
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
58915894
confirmBtn.classList.toggle('danger',!!opts.danger);
@@ -5915,7 +5918,13 @@ function showPromptDialog(opts={}){
59155918
input.value=prefill;input.placeholder=opts.placeholder||'';
59165919
input.autocomplete='off';input.spellcheck=false;
59175920
}
5918-
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
5921+
if(cancelBtn){
5922+
// A prior showConfirmDialog({hideCancel:true}) (e.g. the outside-symlink info
5923+
// dialog, #4581) may have hidden the shared Cancel button; always restore it
5924+
// so a subsequent prompt keeps its Cancel affordance.
5925+
cancelBtn.style.display='';
5926+
cancelBtn.textContent=opts.cancelLabel||t('cancel');
5927+
}
59195928
if(confirmBtn){
59205929
confirmBtn.textContent=opts.confirmLabel||t('create');
59215930
confirmBtn.classList.toggle('danger',!!opts.danger);
@@ -14350,9 +14359,13 @@ function _renderTreeItems(container, entries, depth){
1435014359
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
1435114360

1435214361
const isLk = item.type === 'symlink';
14353-
const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
14354-
const isFileLike = !isDirLike;
14362+
const isExternalLink = isLk && item.target_outside_workspace;
14363+
// External symlinks are display-only: not expandable, not openable.
14364+
// The read gate (safe_resolve_ws) still blocks navigation through them.
14365+
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
14366+
const isFileLike = !isExternalLink && !isDirLike;
1435514367
el.dataset.wsIsDir = String(isDirLike);
14368+
if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;}
1435614369

1435714370
if(isDirLike){
1435814371
// Toggle arrow for directories
@@ -14373,9 +14386,11 @@ function _renderTreeItems(container, entries, depth){
1437314386
// Icon
1437414387
const iconEl=document.createElement('span');
1437514388
iconEl.className='file-icon';
14376-
iconEl.innerHTML = isDirLike
14377-
? (isLk ? li('link', 14) : li('folder', 14))
14378-
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
14389+
iconEl.innerHTML = isExternalLink
14390+
? li('external-link', 14)
14391+
: isDirLike
14392+
? (isLk ? li('link', 14) : li('folder', 14))
14393+
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
1437914394
el.appendChild(iconEl);
1438014395

1438114396
// Name
@@ -14407,6 +14422,8 @@ function _renderTreeItems(container, entries, depth){
1440714422
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
1440814423
// For directories, double-click navigates (breadcrumb view)
1440914424
if(isDirLike){loadDir(item.path);return;}
14425+
// External symlinks: show informational dialog, not rename
14426+
if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;}
1441014427
const inp=document.createElement('input');
1441114428
inp.className='file-rename-input';inp.value=item.name;
1441214429
inp.onclick=(e2)=>e2.stopPropagation();
@@ -14495,6 +14512,21 @@ function _renderTreeItems(container, entries, depth){
1449514512
renderFileTree();
1449614513
}
1449714514
};
14515+
}else if(isExternalLink){
14516+
// Display-only: the link points outside the workspace. We do NOT disclose
14517+
// the resolved outside path (#4581 hardening) and do NOT call openFile —
14518+
// the read gate (safe_resolve_ws) blocks navigation through the link.
14519+
el.onclick=async(e)=>{
14520+
e.stopPropagation();
14521+
await showConfirmDialog({
14522+
title:item.name,
14523+
message:t('external_link_open_confirm'),
14524+
confirmLabel:t('dialog_confirm_btn'),
14525+
danger:false,
14526+
hideCancel:true,
14527+
focusCancel:false,
14528+
});
14529+
};
1449814530
}else{
1449914531
el.onclick=async()=>openFile(item.path);
1450014532
}

tests/test_1707_workspace_filename_click.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,15 +248,16 @@ def _run_node_with_clicks(click_count: int, dblclick_after_first: bool, item_typ
248248
// Symlink locals referenced by the handler block — declared from item
249249
// just like _renderTreeItem does before the tooltip assignment.
250250
const isLk = item.type === 'symlink';
251-
const isDirLike = item.type === 'dir' || (isLk && item.is_dir);
251+
const isExternalLink = isLk && item.target_outside_workspace;
252+
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
252253
const elideMiddle = (s) => s;
253254
254255
const runner = new Function(
255256
'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window',
256-
'setTimeout', 'clearTimeout', 'isLk', 'isDirLike', 'elideMiddle',
257+
'setTimeout', 'clearTimeout', 'isLk', 'isExternalLink', 'isDirLike', 'elideMiddle',
257258
'(()=>{' + handlerBlock + '})();'
258259
);
259-
runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isDirLike, elideMiddle);
260+
runner(nameEl, el, item, S, t, loadDir, document, showToast, api, {}, trackedSetTimeout, trackedClearTimeout, isLk, isExternalLink, isDirLike, elideMiddle);
260261
261262
const evt = { stopPropagation: () => {} };
262263
for (let i = 0; i < clickCount; i++) {

0 commit comments

Comments
 (0)