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 `browse upload` when a file path is absolute AND its realpath-resolved location is not inside one of `SAFE_DIRECTORIES` (which is `[TEMP_DIR, process.cwd()]`, each resolved through `realpathSync` to defeat symlink tricks). This is a security guard: the upload command reads local files and pushes them into the browser, so it confines reads to the temp directory and the project working directory to prevent arbitrary file exfiltration from the host.

Source

Thrown at browse/src/write-commands.ts:605

      const error = await bm.recreateContext();
      if (error) {
        return `User agent set to "${ua}" but: ${error}`;
      }
      return `User agent set: ${ua}`;
    }

    case 'upload': {
      const [selector, ...filePaths] = args;
      if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');

      // Validate paths are within safe directories (same check as cookie-import)
      for (const fp of filePaths) {
        if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
        if (path.isAbsolute(fp)) {
          let resolvedFp: string;
          try { resolvedFp = fs.realpathSync(path.resolve(fp)); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; resolvedFp = path.resolve(fp); }
          if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolvedFp, dir))) {
            throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
          }
        }
        if (path.normalize(fp).includes('..')) {
          throw new Error('Path traversal sequences (..) are not allowed');
        }
      }

      const resolved = await session.resolveRef(selector);
      if ('locator' in resolved) {
        await resolved.locator.setInputFiles(filePaths);
      } else {
        await target.locator(resolved.selector).setInputFiles(filePaths);
      }

      const fileInfo = filePaths.map(fp => {
        const stat = fs.statSync(fp);
        return `${path.basename(fp)} (${stat.size}B)`;
      }).join(', ');

View on GitHub (pinned to 94993f7401)

Solutions

  1. Copy or stage the file under TEMP_DIR (`os.tmpdir()`) or the project cwd before uploading.
  2. If the file legitimately lives elsewhere on disk, start the browse server with cwd set to a parent directory that contains it (if that is acceptable for your threat model).
  3. Avoid symlinks that escape the safe directory — resolve them first and confirm the realpath target is inside a safe dir.
  4. Note relative paths skip this branch (only `path.isAbsolute(fp)` enters it) but still hit the `..` traversal check.

Example fix

// before
await runBrowseCommand(['upload', 'input[type=file]', '/Users/me/Secrets/key.pem']);

// after
import fs from 'fs';
import os from 'os';
import path from 'path';
const staged = path.join(os.tmpdir(), 'key.pem');
fs.copyFileSync('/Users/me/Secrets/key.pem', staged);
await runBrowseCommand(['upload', 'input[type=file]', staged]);
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'fs';
import path from 'path';
import os from 'os';
const SAFE = [os.tmpdir(), process.cwd()].map(d => { try { return fs.realpathSync(d); } catch { return d; } });
function isPathWithin(p: string, dir: string): boolean {
  const rel = path.relative(dir, p);
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
function ensureWithinSafeDirs(fp: string): void {
  const resolved = path.isAbsolute(fp) ? fs.realpathSync(path.resolve(fp)) : fp;
  if (path.isAbsolute(fp) && !SAFE.some(d => isPathWithin(resolved, d))) {
    throw new Error(`Path outside safe dirs: ${fp}`);
  }
}

Type guard

function isWithinSafeDirs(fp: string): boolean {
  if (!path.isAbsolute(fp)) return true;
  let resolved: string;
  try { resolved = fs.realpathSync(path.resolve(fp)); } catch { resolved = path.resolve(fp); }
  return SAFE.some(d => isPathWithin(resolved, d));
}

Prevention

When it happens

Trigger: Passing `/etc/passwd`, `/Users/me/Secrets/key.pem`, or any path outside TEMP_DIR/cwd; passing a path inside a safe dir that is itself a symlink pointing outside (realpathSync follows it and the target fails the check); running the browse server with a cwd different from where the file lives.

Common situations: CI runs the browse server with cwd set to the repo root, but the file was staged in `/tmp/staging/` which is neither TEMP_DIR nor cwd; an agent downloaded a file to a custom cache dir outside the two allowed roots; the user expects `~` paths to work and they live under `/home` which is not allowed.

Related errors


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