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

  1. 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
  2. If a sync client or watcher is converting files to links, exclude the export directory from it or export elsewhere and copy after completion
  3. On Windows, remove the junction/symlink: rmdir <link> (junctions) or del <link>, then retry
  4. 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

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


AI-assisted analysis of MemPalace/mempalace@06cb6987f0 (2026-08-15). Data as JSON: /api/errors/5e4e3d8bad07898f. Report an issue: GitHub.