stablyai/orca · error · Error
spritesheet must not be a symlink.
Error message
spritesheet must not be a symlink.
What it means
Thrown at pet.ts:304-305 when isSymlink(sheetSrc) returns true after the path has been validated to stay inside the bundle. This closes a TOCTOU/symlink-escape hole: an attacker could place a relative, in-bundle path that is itself a symlink pointing outside the bundle (the prefix check at line 301 would pass because the path string is in-bundle, but the symlink target would not be).
Source
Thrown at src/main/ipc/pet.ts:305
isAbsolute(normalizedSpritePath) ||
/^[a-zA-Z]:/.test(manifest.spritesheetPath)
) {
throw new Error('spritesheetPath must be relative to the bundle.')
}
// Why: bundles exported on Windows may be imported on macOS/Linux; normalize separators before resolving.
const sheetSrc = resolve(bundleDir, normalizedSpritePath)
const bundleResolved = resolve(bundleDir)
if (sheetSrc === bundleResolved) {
throw new Error('spritesheetPath must point to a file, not the bundle root.')
}
const bundleRoot = bundleResolved + sep
// Why: Windows volumes are case-insensitive; lowercase the prefix compare so case differences can't bypass the escape check.
const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s
if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {
throw new Error('spritesheetPath escapes the bundle.')
}
if (await isSymlink(sheetSrc)) {
throw new Error('spritesheet must not be a symlink.')
}
const sheetClass = classifyFile(sheetSrc)
if (!sheetClass || sheetClass.ext === '.svg') {
// SVG can't be used as a sprite sheet (no pixel grid).
throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.')
}
let sheetStat: Awaited<ReturnType<typeof stat>>
try {
sheetStat = await stat(sheetSrc)
} catch {
throw new Error('Spritesheet file not found.')
}
if (!sheetStat.isFile()) {
throw new Error('Spritesheet path is not a file.')
}
if (sheetStat.size > MAX_BYTES) {
throw new Error(
`Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).`View on GitHub (pinned to 1136503c6a)
Solutions
- Replace the symlink with a real copy of the image file inside the bundle directory.
- Re-export the bundle with cp -L (dereference) so symlinks are flattened to real files.
Example fix
# before — spritesheet.png is a symlink to /home/user/assets/cat.png ln -s /home/user/assets/cat.png bundle/spritesheet.png # after — copy the real bytes cp /home/user/assets/cat.png bundle/spritesheet.png
Defensive patterns
Strategy: validation
Validate before calling
import { lstat } from 'node:fs/promises'
async function assertNotSymlink(p: string) {
const st = await lstat(p)
if (st.isSymbolicLink()) throw new Error(`${p} is a symlink`)
} Try / catch
try { await importPetBundle(p) }
catch (e) { if (e instanceof Error && e.message === 'spritesheet must not be a symlink.') { /* tell user to replace symlink with a copy */ } else throw e } Prevention
- When building bundles, copy image bytes (cp -L / rsync -L) rather than preserving symlinks.
- Inspect the bundle with 'ls -la' to confirm no symlinks before import.
When it happens
Trigger: The spritesheet file (after resolve + prefix check) is a symbolic link — typically pointing to a file outside the bundle, but any symlink is rejected outright. isSymlink is awaited, so it inspects lstat.
Common situations: Malicious bundle with a symlinked sprite; a developer's local symlink to a shared assets folder; a bundle unzipped in a way that preserved symlinks from a tarball.
Related errors
- Spritesheet exceeded the size limit.
- Managed Claude auth child file is not owned by Orca.
- Upload source escapes selected directory: ${candidatePath}
- File changed during upload staging: '${displayPath}'
- Path escaped upload root during staging: '${displayPath}'
AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12).
Data as JSON: /api/errors/060a3f57e38ada3c.
Report an issue: GitHub.