stage #4581 onto master (v0.51.556) — surface escape-target symlinks as display-only workspace rows

claw-io: symlinks whose target resolves outside the workspace root were silently dropped; now emitted
with target_outside_workspace=True (display-only — safe_resolve_ws/open_anchored_fd still block
navigation). Dedup-winner over #4573 (stronger tests). Co-authored-by: claw-io <claw-io@users.noreply.github.com>
This commit is contained in:
nesquena-hermes authored and nesquena-hermes committed 2026-06-21 09:15:22 +00:00
1 parent 4c45fc8a55
commit 1e18c336f1
8 files changed
+188 -28

No files matched your search

+9 -2
View File
@@ -1220,11 +1220,16 @@ def list_dir(workspace: Path, rel: str='.'):
return # target is under link_target — ancestor → cycle return # target is under link_target — ancestor → cycle
except ValueError: except ValueError:
pass 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: try:
link_target.relative_to(ws_resolved) link_target.relative_to(ws_resolved)
except ValueError: except ValueError:
return target_outside_workspace = True
if _is_blocked_system_path(link_target): if _is_blocked_system_path(link_target):
return return
is_dir = link_target.is_dir() is_dir = link_target.is_dir()
@@ -1238,6 +1243,7 @@ def list_dir(workspace: Path, rel: str='.'):
'type': 'symlink', 'type': 'symlink',
'target': str(link_target), 'target': str(link_target),
'is_dir': is_dir, 'is_dir': is_dir,
'target_outside_workspace': target_outside_workspace,
'mtime_ns': mtime_ns, 'mtime_ns': mtime_ns,
} }
if not is_dir: if not is_dir:
@@ -1381,6 +1387,7 @@ def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None =
'size': entry.get('size'), 'size': entry.get('size'),
'mtime_ns': entry.get('mtime_ns'), 'mtime_ns': entry.get('mtime_ns'),
'target': entry.get('target'), 'target': entry.get('target'),
'target_outside_workspace': entry.get('target_outside_workspace'),
}) })
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False) raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest() return hashlib.sha256(raw.encode('utf-8')).hexdigest()
+13
View File
@@ -443,6 +443,7 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`, downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename', double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ', renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ', rename_failed: 'Rename failed: ',
@@ -1988,6 +1989,7 @@ const LOCALES = {
downloading: (name) => `Scaricamento ${name}\u2026`, downloading: (name) => `Scaricamento ${name}\u2026`,
double_click_rename: 'Doppio clic per rinominare', double_click_rename: 'Doppio clic per rinominare',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Rinominato in ', renamed_to: 'Rinominato in ',
rename_failed: 'Rinomina fallita: ', rename_failed: 'Rinomina fallita: ',
@@ -3524,6 +3526,7 @@ const LOCALES = {
downloading: (name) => `${name} をダウンロード中…`, downloading: (name) => `${name} をダウンロード中…`,
double_click_rename: 'ダブルクリックで名前変更', double_click_rename: 'ダブルクリックで名前変更',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。', session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
renamed_to: '名前を変更: ', renamed_to: '名前を変更: ',
rename_failed: '名前変更失敗: ', rename_failed: '名前変更失敗: ',
@@ -4984,6 +4987,7 @@ const LOCALES = {
downloading: (name) => `Скачиваю ${name}…`, downloading: (name) => `Скачиваю ${name}…`,
double_click_rename: 'Дважды щёлкните, чтобы переименовать', double_click_rename: 'Дважды щёлкните, чтобы переименовать',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Переименовано в ', renamed_to: 'Переименовано в ',
rename_failed: 'Не удалось переименовать: ', rename_failed: 'Не удалось переименовать: ',
@@ -6437,6 +6441,7 @@ const LOCALES = {
downloading: (name) => `Descargando ${name}…`, downloading: (name) => `Descargando ${name}…`,
double_click_rename: 'Haz doble clic para renombrar', double_click_rename: 'Haz doble clic para renombrar',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renombrado a ', renamed_to: 'Renombrado a ',
rename_failed: 'Error al renombrar: ', rename_failed: 'Error al renombrar: ',
@@ -7888,6 +7893,7 @@ const LOCALES = {
downloading: (name) => `Lade ${name} herunter\u2026`, downloading: (name) => `Lade ${name} herunter\u2026`,
double_click_rename: 'Doppelklick zum Umbenennen', double_click_rename: 'Doppelklick zum Umbenennen',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Umbenannt in ', renamed_to: 'Umbenannt in ',
rename_failed: 'Umbenennen fehlgeschlagen: ', rename_failed: 'Umbenennen fehlgeschlagen: ',
@@ -9404,6 +9410,7 @@ const LOCALES = {
downloading: (name) => `正在下载 ${name}...`, downloading: (name) => `正在下载 ${name}...`,
double_click_rename: '双击重命名', double_click_rename: '双击重命名',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: '已重命名为 ', renamed_to: '已重命名为 ',
rename_failed: '重命名失败:', rename_failed: '重命名失败:',
@@ -10954,6 +10961,7 @@ const LOCALES = {
downloading: (name) => `正在下載 ${name}…`, downloading: (name) => `正在下載 ${name}…`,
double_click_rename: '按兩下即可重新命名', double_click_rename: '按兩下即可重新命名',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。', session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
renamed_to: '已重新命名為 ', renamed_to: '已重新命名為 ',
rename_failed: '重新命名失敗:', rename_failed: '重新命名失敗:',
@@ -12393,6 +12401,7 @@ const LOCALES = {
downloading: (name) => `Baixando ${name}…`, downloading: (name) => `Baixando ${name}…`,
double_click_rename: 'Duplo clique para renomear', double_click_rename: 'Duplo clique para renomear',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renomeado para ', renamed_to: 'Renomeado para ',
rename_failed: 'Falha ao renomear: ', rename_failed: 'Falha ao renomear: ',
@@ -13823,6 +13832,7 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`, downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename', double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ', renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ', rename_failed: 'Rename failed: ',
@@ -15298,6 +15308,7 @@ const LOCALES = {
file_open_failed: 'Impossible d\'ouvrir le fichier', file_open_failed: 'Impossible d\'ouvrir le fichier',
double_click_rename: 'Double-cliquez pour renommer', double_click_rename: 'Double-cliquez pour renommer',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renommé en', renamed_to: 'Renommé en',
rename_failed: 'Échec du changement de nom :', rename_failed: 'Échec du changement de nom :',
@@ -16839,6 +16850,7 @@ const LOCALES = {
downloading: (name) => `${name} indiriliyor\u2026`, downloading: (name) => `${name} indiriliyor\u2026`,
double_click_rename: 'Yeniden adlandırmak için çift tıklayın', double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Could not start rename — row not found.', session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Yeniden adlandırıldı', renamed_to: 'Yeniden adlandırıldı',
rename_failed: 'Yeniden adlandırma başarısız oldu:', rename_failed: 'Yeniden adlandırma başarısız oldu:',
@@ -18389,6 +18401,7 @@ const LOCALES = {
downloading: (name) => `Pobieranie ${name}…`, downloading: (name) => `Pobieranie ${name}…`,
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę', double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
symlink_link_to: 'Symlink → {target}', symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here. Target: {target}',
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.', session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
renamed_to: 'Nazwa zmieniona na ', renamed_to: 'Nazwa zmieniona na ',
rename_failed: 'Zmiana nazwy nie powiodła się: ', rename_failed: 'Zmiana nazwy nie powiodła się: ',
+1
View File
@@ -12,6 +12,7 @@ const LI_PATHS = {
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>', 'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>', 'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', 'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>', 'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
// Editing / actions // Editing / actions
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>', 'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
+31 -6
View File
@@ -5885,7 +5885,10 @@ function showConfirmDialog(opts={}){
if(title) title.textContent=opts.title||t('dialog_confirm_title'); if(title) title.textContent=opts.title||t('dialog_confirm_title');
if(desc) desc.textContent=opts.message||''; if(desc) desc.textContent=opts.message||'';
if(input){input.style.display='none';input.value='';} 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){ if(confirmBtn){
confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn'); confirmBtn.textContent=opts.confirmLabel||t('dialog_confirm_btn');
confirmBtn.classList.toggle('danger',!!opts.danger); confirmBtn.classList.toggle('danger',!!opts.danger);
@@ -14350,9 +14353,13 @@ function _renderTreeItems(container, entries, depth){
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();}; el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
const isLk = item.type === 'symlink'; const isLk = item.type === 'symlink';
const isDirLike = item.type === 'dir' || (isLk && item.is_dir); const isExternalLink = isLk && item.target_outside_workspace;
const isFileLike = !isDirLike; // 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); el.dataset.wsIsDir = String(isDirLike);
if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;}
if(isDirLike){ if(isDirLike){
// Toggle arrow for directories // Toggle arrow for directories
@@ -14373,9 +14380,11 @@ function _renderTreeItems(container, entries, depth){
// Icon // Icon
const iconEl=document.createElement('span'); const iconEl=document.createElement('span');
iconEl.className='file-icon'; iconEl.className='file-icon';
iconEl.innerHTML = isDirLike iconEl.innerHTML = isExternalLink
? (isLk ? li('link', 14) : li('folder', 14)) ? li('external-link', 14)
: (isLk ? li('link', 14) : fileIcon(item.name, item.type)); : isDirLike
? (isLk ? li('link', 14) : li('folder', 14))
: (isLk ? li('link', 14) : fileIcon(item.name, item.type));
el.appendChild(iconEl); el.appendChild(iconEl);
// Name // Name
@@ -14407,6 +14416,8 @@ function _renderTreeItems(container, entries, depth){
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;} if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
// For directories, double-click navigates (breadcrumb view) // For directories, double-click navigates (breadcrumb view)
if(isDirLike){loadDir(item.path);return;} 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'); const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name; inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation(); inp.onclick=(e2)=>e2.stopPropagation();
@@ -14495,6 +14506,20 @@ function _renderTreeItems(container, entries, depth){
renderFileTree(); renderFileTree();
} }
}; };
}else if(isExternalLink){
// Display-only: show the resolved target, 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').replace('{target}',()=>elideMiddle(item.target||'')),
confirmLabel:t('dialog_confirm_btn'),
danger:false,
hideCancel:true,
focusCancel:false,
});
};
}else{ }else{
el.onclick=async()=>openFile(item.path); el.onclick=async()=>openFile(item.path);
} }
+4 -3
View File
@@ -248,15 +248,16 @@ const trackedClearTimeout = (id) => {
// Symlink locals referenced by the handler block — declared from item // Symlink locals referenced by the handler block — declared from item
// just like _renderTreeItem does before the tooltip assignment. // just like _renderTreeItem does before the tooltip assignment.
const isLk = item.type === 'symlink'; 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 elideMiddle = (s) => s;
const runner = new Function( const runner = new Function(
'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window', 'nameEl', 'el', 'item', 'S', 't', 'loadDir', 'document', 'showToast', 'api', 'window',
'setTimeout', 'clearTimeout', 'isLk', 'isDirLike', 'elideMiddle', 'setTimeout', 'clearTimeout', 'isLk', 'isExternalLink', 'isDirLike', 'elideMiddle',
'(()=>{' + handlerBlock + '})();' '(()=>{' + 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: () => {} }; const evt = { stopPropagation: () => {} };
for (let i = 0; i < clickCount; i++) { for (let i = 0; i < clickCount; i++) {
+20 -12
View File
@@ -9,7 +9,8 @@ recursion. Covers:
- Self-referencing symlink (ln -s . ~/workspace/loop) - Self-referencing symlink (ln -s . ~/workspace/loop)
- Ancestor symlink (ln -s .. ~/workspace/up) - Ancestor symlink (ln -s .. ~/workspace/up)
- Internal symlink entries carry correct type / is_dir / target fields - 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 json
import os import os
@@ -57,8 +58,8 @@ def make_session(created_list, ws=None):
class TestSymlinkCycleDetection: class TestSymlinkCycleDetection:
"""Symlink cycle detection in list_dir / safe_resolve_ws.""" """Symlink cycle detection in list_dir / safe_resolve_ws."""
def test_external_symlink_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory): def test_external_symlink_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory):
"""External symlink dirs should be hidden from workspace listings.""" """External symlink dirs are emitted with target_outside_workspace=True (display-only)."""
ws = tmp_path_factory.mktemp("ws") ws = tmp_path_factory.mktemp("ws")
target = tmp_path_factory.mktemp("target") target = tmp_path_factory.mktemp("target")
(target / "file.txt").write_text("hello") (target / "file.txt").write_text("hello")
@@ -67,8 +68,11 @@ class TestSymlinkCycleDetection:
sid, _ = make_session(cleanup_test_sessions, ws) sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.") listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]] entries = {e["name"]: e for e in listing["entries"]}
assert "ext" not in names assert "ext" in entries
assert entries["ext"]["type"] == "symlink"
assert entries["ext"]["target_outside_workspace"] is True
assert entries["ext"]["is_dir"] is True
def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory): 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.""" """Internal symlink dirs should appear with type='symlink', is_dir=True."""
@@ -149,10 +153,11 @@ class TestSymlinkCycleDetection:
(ws / "ext").symlink_to(target) (ws / "ext").symlink_to(target)
sid, _ = make_session(cleanup_test_sessions, ws) 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=.") listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]] entries = {e["name"]: e for e in listing["entries"]}
assert "ext" not in names assert "ext" in entries
assert entries["ext"]["target_outside_workspace"] is True
# Traversing into ext/subdir crosses the workspace boundary and is blocked. # Traversing into ext/subdir crosses the workspace boundary and is blocked.
try: try:
@@ -177,8 +182,8 @@ class TestSymlinkCycleDetection:
assert link[0]["is_dir"] is False assert link[0]["is_dir"] is False
assert link[0]["size"] == 11 # len("hello world") assert link[0]["size"] == 11 # len("hello world")
def test_external_symlink_file_filtered_from_listing(self, cleanup_test_sessions, tmp_path_factory): def test_external_symlink_file_emitted_as_display_only(self, cleanup_test_sessions, tmp_path_factory):
"""External symlink files should be hidden from workspace listings.""" """External symlink files are emitted with target_outside_workspace=True (display-only)."""
ws = tmp_path_factory.mktemp("ws") ws = tmp_path_factory.mktemp("ws")
real = tmp_path_factory.mktemp("real") real = tmp_path_factory.mktemp("real")
(real / "data.txt").write_text("hello world") (real / "data.txt").write_text("hello world")
@@ -186,8 +191,11 @@ class TestSymlinkCycleDetection:
sid, _ = make_session(cleanup_test_sessions, ws) sid, _ = make_session(cleanup_test_sessions, ws)
listing = get(f"/api/list?session_id={sid}&path=.") listing = get(f"/api/list?session_id={sid}&path=.")
names = [e["name"] for e in listing["entries"]] entries = {e["name"]: e for e in listing["entries"]}
assert "link.txt" not in names 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): def test_path_traversal_still_blocked(self, cleanup_test_sessions, tmp_path_factory):
"""Raw .. traversal must still be blocked even with symlink support.""" """Raw .. traversal must still be blocked even with symlink support."""
+94 -3
View File
@@ -11,13 +11,19 @@ def test_safe_resolve_blocks_external_symlink_directory(tmp_path):
(outside / "secret.txt").write_text("outside", encoding="utf-8") (outside / "secret.txt").write_text("outside", encoding="utf-8")
(workspace / "escape").symlink_to(outside) (workspace / "escape").symlink_to(outside)
# The read/list gate still blocks navigation through the escape symlink.
with pytest.raises(ValueError, match="Path traversal blocked"): with pytest.raises(ValueError, match="Path traversal blocked"):
safe_resolve_ws(workspace, "escape") safe_resolve_ws(workspace, "escape")
with pytest.raises(ValueError, match="Path traversal blocked"): with pytest.raises(ValueError, match="Path traversal blocked"):
list_dir(workspace, "escape") 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
assert entries["escape"]["is_dir"] is True
def test_read_file_blocks_external_symlink_file(tmp_path): def test_read_file_blocks_external_symlink_file(tmp_path):
@@ -28,10 +34,16 @@ def test_read_file_blocks_external_symlink_file(tmp_path):
(outside / "secret.txt").write_text("outside", encoding="utf-8") (outside / "secret.txt").write_text("outside", encoding="utf-8")
(workspace / "secret-link.txt").symlink_to(outside / "secret.txt") (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"): with pytest.raises(ValueError, match="Path traversal blocked"):
read_file_content(workspace, "secret-link.txt") 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): def test_internal_symlink_still_resolves_within_workspace(tmp_path):
@@ -230,7 +242,9 @@ def test_list_read_create_work_on_no_dir_fd_fallback(tmp_path, monkeypatch):
assert "a.txt" in names assert "a.txt" in names
if w._DIR_FD_OK: if w._DIR_FD_OK:
assert "internal" in names # legit internal symlink listed 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" assert w.read_file_content(workspace, "a.txt")["content"] == "hi"
fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt") fd = w.open_anchored_create_fd(workspace, workspace / "new" / "f.txt")
@@ -277,3 +291,80 @@ def test_read_blocked_when_workspace_root_raced_to_symlink(tmp_path):
pass # refused — correct pass # refused — correct
finally: finally:
os.open = real_open 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
assert entries["ext-link.txt"]["target"] == str((outside / "file.txt").resolve())
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")
+16 -2
View File
@@ -27,14 +27,19 @@ class TestIsDirLikeLocals:
assert "const isLk = item.type === 'symlink';" in block, \ assert "const isLk = item.type === 'symlink';" in block, \
"isLk local must be declared inside the per-item loop" "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): def test_isDirLike_local_declared(self):
block = _render_block() 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" "isDirLike local must be declared inside the per-item loop"
def test_isFileLike_local_declared(self): def test_isFileLike_local_declared(self):
block = _render_block() 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" "isFileLike local must be declared for file-like symlink handling"
def test_wsIsDir_dataset_set(self): def test_wsIsDir_dataset_set(self):
@@ -65,6 +70,15 @@ class TestLinkIcon:
assert "li('link', 14)" in block, \ assert "li('link', 14)" in block, \
"icon dispatch must emit li('link', 14) for symlink rows" "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: class TestSymlinkTooltip:
def test_symlink_link_to_key_in_i18n(self): def test_symlink_link_to_key_in_i18n(self):