garrytan/gstack · warning · StrictModeError

image resolves OUTSIDE the input directory: ${src} → ${realF

Error message

image resolves OUTSIDE the input directory: ${src} → ${realFilePath} — move it under the markdown's directory or drop --strict

What it means

StrictModeError thrown when a local image's REAL path (safeRealpath) escapes the input directory's real root. The check deliberately uses realpath, not a string prefix, so a symlink inside inputDir pointing outside (e.g. to ~/.ssh/config) is caught — a Codex adversarial finding. Non-strict mode warns; strict makes it fatal. This prevents an agent PDF from silently embedding out-of-tree files.

Source

Thrown at make-pdf/src/diagram-prepass.ts:651

    if (!fs.existsSync(filePath)) {
      const msg = `image not found: ${src} (resolved to ${filePath})`;
      if (opts.strict) throw new StrictModeError(msg);
      opts.warn(msg);
      return buildMissingImagePlaceholder(src);
    }

    // Out-of-tree reads are legal (local CLI semantics — like pandoc) but
    // never silent: an agent PDF-ing untrusted markdown should not quietly
    // embed ~/.ssh/config into a shareable document. --strict makes it fatal.
    // Compare REAL paths — a symlink inside the input dir pointing outside
    // would otherwise pass a string-prefix check (Codex adversarial finding).
    // Runs after the existence check: realpath of a missing file can't
    // resolve, and on macOS /var vs /private/var would false-positive.
    const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep;
    const realFilePath = safeRealpath(filePath);
    if (!realFilePath.startsWith(inputRoot)) {
      const msg = `image resolves OUTSIDE the input directory: ${src} → ${realFilePath}`;
      if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory or drop --strict");
      opts.warn(msg);
    }

    // Bound the read BEFORE reading: a markdown image pointing at a special
    // file (fifo, device) would hang readFileSync, and a multi-GB file would
    // exhaust memory before any policy ran.
    let stat: fs.Stats;
    try {
      stat = fs.statSync(filePath);
    } catch {
      opts.warn(`image unreadable: ${src}`);
      return buildMissingImagePlaceholder(src);
    }
    if (!stat.isFile()) {
      const msg = `image is not a regular file: ${src}`;
      if (opts.strict) throw new StrictModeError(msg);
      opts.warn(msg);
      return buildMissingImagePlaceholder(src);

View on GitHub (pinned to 94993f7401)

Solutions

  1. Move (or copy) the referenced image physically under the markdown's input directory.
  2. Remove or repoint the offending symlink so its realpath stays under inputDir.
  3. Drop --strict to accept the out-of-tree read with a warn (only if you trust the source).
  4. Re-run with the real inputDir (resolve symlinks in the path you pass to make-pdf).

Example fix

# before: symlink escapes input dir
ln -s /etc/passwd docs/assets/leak.png
make-pdf --strict report.md  # throws

# after: keep the asset inside the input tree
cp /trusted/logos/acme.png docs/assets/acme.png
make-pdf --strict report.md
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs';
import path from 'node:path';

function findEscapingImages(markdown: string, inputDir: string): string[] {
  const root = fs.realpathSync(path.resolve(inputDir)) + path.sep;
  const re = /!\[[^\]]*\]\((?!https?:|data:)([^)]+)\)/g;
  const bad: string[] = [];
  let m: RegExpExecArray | null;
  while ((m = re.exec(markdown))) {
    const resolved = path.resolve(inputDir, decodeURIComponent(m[1]));
    if (fs.existsSync(resolved) && !fs.realpathSync(resolved).startsWith(root)) bad.push(m[1]);
  }
  return bad;
}

Type guard

import { StrictModeError } from './diagram-prepass';
function isStrictModeError(e: unknown): e is StrictModeError {
  return e instanceof StrictModeError;
}

Prevention

When it happens

Trigger: inlineLocalImages() under --strict where an <img> resolves to a filePath whose safeRealpath does not start with inputRoot+sep. Example: inputDir contains a symlink assets/secret -> /etc/passwd, and markdown references ./assets/secret. The string-prefix check would pass; the realpath check catches it.

Common situations: A symlinked asset (intentional or malicious) pointing outside the project; macOS /var vs /private/var normalisation (handled by realpath but a source of confusion); an inputDir that is itself a symlink whose target changes resolution; CI that symlinks a shared assets folder from outside the repo.

Related errors


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