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

  1. Inspect the path: `ls -la <path>` and `readlink -f <path>` to see where it points
  2. Delete or retarget the symlink so its target is inside TEMP_DIR or the project cwd
  3. Pass a fresh filename that is not an existing symlink
  4. 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

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


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/0dd8a694133b3f78. Report an issue: GitHub.