MemPalace/mempalace · warning · ValueError
refusing to write: {path!r} is a symbolic link.
Error message
refusing to write: {path!r} is a symbolic link. What it means
Raised by _safe_open_for_write when opening the export target hits ELOOP — on POSIX this happens because the open uses O_NOFOLLOW, so a last-instant symlink at the path makes the kernel reject the open. This closes the TOCTOU window between the earlier islink() check (_reject_symlink) and the actual open: even if a symlink is placed after the check, the O_NOFOLLOW flag makes the open itself fail, and the ELOOP errno is translated into this clear ValueError. On Windows (no O_NOFOLLOW) a pre-check os.path.islink raises the same message.
Source
Thrown at mempalace/exporter.py:60
def _safe_open_for_write(path: str, mode: str, encoding: str = "utf-8"):
"""Open a file for writing, refusing to follow a symlink at the target path.
On POSIX (O_NOFOLLOW available) the open itself fails with ELOOP if path is
a symlink — closing the TOCTOU window between an islink check and the open.
On platforms without O_NOFOLLOW (Windows), pre-checks ``os.path.islink``,
which is narrower than no check at all.
"""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
if o_nofollow:
flags = os.O_WRONLY | os.O_CREAT | o_nofollow
flags |= os.O_APPEND if "a" in mode else os.O_TRUNC
try:
fd = os.open(path, flags, 0o600)
except OSError as e:
if e.errno == errno.ELOOP:
raise ValueError(f"refusing to write: {path!r} is a symbolic link.") from None
raise
return os.fdopen(fd, mode, encoding=encoding)
if os.path.islink(path):
raise ValueError(f"refusing to write: {path!r} is a symbolic link.")
return open(path, mode, encoding=encoding)
def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
"""Export all palace drawers as markdown files organized by wing/room.
Streams drawers in batches of 1000 and writes each wing/room file
incrementally, keeping memory usage proportional to batch size rather
than total palace size.
Args:
palace_path: Path to the ChromaDB palace directory.
output_dir: Where to write the exported markdown tree.
format: Output format (currently only "markdown").View on GitHub (pinned to 06cb6987f0)
Solutions
- Treat this as a security signal: inspect what created the link — ls -la <path> shows the target; remove it (rm <path>) and retry the export
- If a sync client or watcher is converting files to links, exclude the export directory from it or export elsewhere and copy after completion
- On Windows, remove the junction/symlink: rmdir <link> (junctions) or del <link>, then retry
- Export to a private, non-shared directory to avoid concurrent path manipulation
Example fix
# before: retry loop blindly re-exporting into a swapped path
export_palace(palace, output_dir=shared_dir) # ValueError: refusing to write
export_palace(palace, output_dir=shared_dir) # same failure
# after: inspect, remove, retry once
import os
p = os.path.join(shared_dir, 'wing.md')
if os.path.islink(p):
os.unlink(p) # investigate what pointed where first: os.readlink(p)
export_palace(palace, output_dir=shared_dir) Defensive patterns
Strategy: validation
Validate before calling
import os
def no_symlink_at(path: str) -> bool:
return not os.path.islink(path)
# check immediately before export; O_NOFOLLOW still guards the race at open time Try / catch
try:
export_palace(palace_path, output_dir=out)
except ValueError as e:
if "refusing to write" in str(e):
target = os.readlink(path) if os.path.islink(path) else '(replaced concurrently)'
log.warning("symlink attack or race detected; link pointed at %s", target)
os.unlink(path)
export_palace(palace_path, output_dir=out) Prevention
- Export to a private directory no other process watches or rewrites
- Exclude export dirs from sync clients and file watchers that swap in links/placeholders
- Treat an ELOOP-derived error as a security event: log the link target before removing it
- On Windows, avoid NTFS junctions inside the export tree
When it happens
Trigger: A race where a symlink is created at the export file path between _reject_symlink's check and the open (concurrent process, malicious local user, or automation tooling that swaps paths); or on Windows, an NTFS symlink/junction at the target file path.
Common situations: Multi-process exports to the same directory; watched-folder daemons replacing files with links; Windows directory-junction setups; hostile shared machines; file-sync clients converting files to placeholders/links.
Related errors
- refusing to export: {label} is a symbolic link ({path!r}). R
- update requires at least one of documents, metadatas, embedd
- qdrant marker is unreadable: {marker_path}
- LLM_ENDPOINT must use http:// or https:// (got scheme {schem
- {field_name} contains invalid path characters
AI-assisted analysis of MemPalace/mempalace@06cb6987f0 (2026-08-15).
Data as JSON: /api/errors/5e4e3d8bad07898f.
Report an issue: GitHub.