abhigyanpatwari/GitNexus · error · SandboxError
Compound Engineering plugin file must be regular and non-sym
Error message
Compound Engineering plugin file must be regular and non-symlink: {path} What it means
Raised by _bounded_plugin_bytes when a file inside the Compound Engineering comparator plugin source tree is a symlink or anything other than a regular file (fifo, socket, device). The harness refuses to follow last-component symlinks so a malicious or accidental link cannot point content outside the validated plugin root. The check uses lstat, so the link itself is the violation, not its target.
Source
Thrown at eval/workflow_bench/runtime_mounts.py:356
continue
try:
metadata = directory.lstat()
except OSError as exc:
raise SandboxError(f"Compound Engineering plugin component is unreadable: {directory}: {exc}") from exc
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
raise SandboxError(f"Compound Engineering plugin component must be a real directory: {directory}")
yield from walk(directory, PurePosixPath(name))
def _bounded_plugin_bytes(path: Path) -> tuple[bytes, bool]:
"""Read one stable regular file without following a last-component symlink."""
try:
before = path.lstat()
except OSError as exc:
raise SandboxError(f"Compound Engineering plugin file is unreadable: {path}: {exc}") from exc
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
raise SandboxError(f"Compound Engineering plugin file must be regular and non-symlink: {path}")
if before.st_size > MAX_CE_PLUGIN_FILE_BYTES:
raise SandboxError(f"Compound Engineering plugin file exceeds the per-file limit: {path}")
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
try:
opened = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino) or not stat.S_ISREG(opened.st_mode):
raise SandboxError(f"Compound Engineering plugin file changed during validation: {path}")
chunks: list[bytes] = []
remaining = MAX_CE_PLUGIN_FILE_BYTES + 1
while remaining > 0:
chunk = os.read(descriptor, min(64 * 1024, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
payload = b"".join(chunks)
after = os.fstat(descriptor)
finally:View on GitHub (pinned to d540b00184)
Solutions
- Replace every symlink in the plugin tree with a real file copy: 'cp -RL <plugin_src> <fixed_plugin_src>' then point --ce-plugin-dir at the copy.
- Find offenders before running: 'find <plugin_dir> -type l' and resolve each by copying the target file in place.
- Repackage the plugin so shared files are physically duplicated per skill rather than symlinked.
- If a directory component is involved, confirm it is a real directory (error 540's sibling at line 344 fires for symlinked dirs).
Example fix
# before ln -s ../../shared/prompts.md skills/ce-plan/prompts.md # after cp ../../shared/prompts.md skills/ce-plan/prompts.md
Defensive patterns
Strategy: validation
Validate before calling
import os, stat
from pathlib import Path
def assert_plugin_files_regular(root: Path) -> list[Path]:
bad: list[Path] = []
allowed = ("skills", "scripts", "assets", ".claude-plugin")
for base in allowed:
d = root / base
if not d.exists():
continue
for dirpath, _dirs, files in os.walk(d, followlinks=False):
for name in files:
p = Path(dirpath, name)
mode = p.lstat().st_mode
if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
bad.append(p)
if bad:
raise SystemExit(f"non-regular plugin files: {bad}")
return bad
# run before --ce-plugin-dir
assert_plugin_files_regular(Path("ce-plugin")) Type guard
import stat
from pathlib import Path
def is_regular_nonsymlink(path: Path) -> bool:
try:
mode = path.lstat().st_mode
except OSError:
return False
return stat.S_ISREG(mode) and not stat.S_ISLNK(mode) Try / catch
# Not recoverable mid-run. Pre-validate the tree (above) and fix the source.
# Catching SandboxError only lets you report and exit cleanly:
try:
staged_ce_plugin_snapshot(config, destination_parent=runtime_root)
except SandboxError as exc:
log.error("plugin staging failed: %s", exc)
raise Prevention
- Build plugin release artifacts with 'cp -aL' (dereference) so symlinks never ship.
- Never develop the plugin with 'npm link' against the tree you pass to --ce-plugin-dir; build, then copy.
- Add a CI check: 'find <plugin_dir> -type l' must be empty before release.
When it happens
Trigger: A file under one of _ALLOWED_PLUGIN_DIRS (skills, scripts, assets) or .claude-plugin is a symbolic link, or is a non-regular inode. Common producers: 'npm link'/'pnpm link' dev installs, 'stow', a wrapper that symlinks shared assets, or a checkout where node_modules-style dedup created symlinks.
Common situations: Authoring the CE plugin in a monorepo and symlinking shared code into skills/; using 'ln -s' to assemble a release tree; a CI step that creates relative symlinks for path compatibility; shipping the plugin from a read-only mirror via symlinks.
Related errors
- {label} must be a real non-symlink directory: {path}
- {label} must be a regular non-symlink file: {path}
- skill fingerprint input must be a regular non-symlink file:
- evidence source must be a regular non-symlink file: {path}
- results directory must be a real non-symlink directory: {roo
AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12).
Data as JSON: /api/errors/f669e8e7668ee8bf.
Report an issue: GitHub.