garrytan/gstack · error · Error
Path must be within: ${SAFE_DIRECTORIES.join(', ')}
Error message
Path must be within: ${SAFE_DIRECTORIES.join(', ')} What it means
Thrown by validateOutputPath when the target file already exists as a symlink and its realpath resolves OUTSIDE the safe directories (TEMP_DIR or process.cwd()). This is a deliberate guard against the 'symlink inside a safe dir' traversal: without it, /tmp/evil.png → /etc/crontab would pass the parent-directory check (parent is /tmp) but the write would follow the symlink into a system file.
Source
Thrown at browse/src/path-security.ts:46
const TEMP_ONLY = [TEMP_DIR].map(d => {
try { return fs.realpathSync(d); } catch { return d; }
});
/** Validate a file path for writing (screenshot, pdf, download, scrape, archive). */
export function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
// If the target already exists and is a symlink, resolve through it.
// Without this, a symlink at /tmp/evil.png → /etc/crontab passes the
// parent-directory check (parent is /tmp, which is safe) but the actual
// write follows the symlink to /etc/crontab.
try {
const stat = fs.lstatSync(resolved);
if (stat.isSymbolicLink()) {
const realTarget = fs.realpathSync(resolved);
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
return; // symlink target verified, no need to check parent
}
} catch (e: any) {
// ENOENT = file doesn't exist yet, fall through to parent-dir check
if (e.code !== 'ENOENT') throw e;
}
// For new files (no existing symlink), verify the parent directory.
// The file itself may not exist yet (e.g., screenshot output).
// This also handles macOS /tmp → /private/tmp transparently.
let dir = path.dirname(resolved);
let realDir: string;
try {
realDir = fs.realpathSync(dir);
} catch {
try {
realDir = fs.realpathSync(path.dirname(dir));View on GitHub (pinned to 94993f7401)
Solutions
- Inspect the path: `ls -la <path>` and `readlink -f <path>` to see where it points
- Delete or retarget the symlink so its target is inside TEMP_DIR or the project cwd
- Pass a fresh filename that is not an existing symlink
- If the escape is intentional, write to a path inside the sandbox and copy out afterward
Example fix
// before: /tmp/shot.png is a symlink → /etc/cron.d/x browse screenshot /tmp/shot.png // throws // after rm /tmp/shot.png && browse screenshot /tmp/shot.png
Defensive patterns
Strategy: validation
Validate before calling
import * as fs from 'fs';
import * as path from 'path';
function isSafeSymlinkTarget(p: string, safeRoots: string[]): boolean {
try {
const stat = fs.lstatSync(p);
if (!stat.isSymbolicLink()) return true; // not a symlink, let validateOutputPath handle it
const real = fs.realpathSync(p);
return safeRoots.some(root => real === root || real.startsWith(root + path.sep));
} catch (e: any) {
if (e.code === 'ENOENT') return true; // doesn't exist yet — no symlink risk
throw e;
}
}
// before calling a write command
if (!isSafeSymlinkTarget(outPath, [require('os').tmpdir(), process.cwd()])) {
throw new Error(`refusing to overwrite symlink that escapes sandbox: ${outPath}`);
} Type guard
function isSymlinkPointingOutside(p: string, safeRoots: string[]): boolean {
try {
if (!fs.lstatSync(p).isSymbolicLink()) return false;
const real = fs.realpathSync(p);
return !safeRoots.some(r => real === r || real.startsWith(r + path.sep));
} catch (e: any) {
if (e.code === 'ENOENT') return false;
return true; // treat unresolvable as unsafe
}
} Try / catch
try {
await runWriteCommand(outPath);
} catch (e: any) {
if (/Path must be within/.test(e.message) && fs.lstatSync(outPath).isSymbolicLink()) {
console.error(`Symlink at ${outPath} escapes the sandbox. readlink -f to inspect, then rm and retry.`);
}
throw e;
} Prevention
- Never write to a path that already exists as a symlink without checking readlink -f first
- Use fresh, unique filenames (e.g., append a timestamp or pid) to avoid collisions with leftover symlinks
- Periodically clean stray symlinks in TEMP_DIR before runs
- Treat any user-supplied output path as untrusted — resolve and bounds-check it before passing to the tool
When it happens
Trigger: Calling a write command (screenshot, pdf, download, scrape, archive, or eval --out) with a path that is an existing symlink whose target resolves outside TEMP_DIR or cwd. lstatSync reports the link as symbolic, realpathSync resolves it, and isPathWithin fails for every SAFE_DIRECTORIES entry.
Common situations: A leftover symlink in /tmp created by another tool or a prior run; a user-created shortcut that happens to point elsewhere; an adversarial test fixture that symlinks into /etc; symlink chain that ultimately escapes the sandbox.
Related errors
- Path must be within: ${TEMP_ONLY.join(', ')} (remote file se
- Path must be within: ${safeDirs.join(', ')}
- Path traversal sequences (..) are not allowed
- image resolves OUTSIDE the input directory: ${src} → ${realF
- Invalid file path in stageSkill: "${relPath}".
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/0dd8a694133b3f78.
Report an issue: GitHub.