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
- Move (or copy) the referenced image physically under the markdown's input directory.
- Remove or repoint the offending symlink so its realpath stays under inputDir.
- Drop --strict to accept the out-of-tree read with a warn (only if you trust the source).
- 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
- Never symlink assets outside the input directory; copy them in.
- Audit symlinks in the assets folder (`find . -type l -ls`).
- Resolve the inputDir through realpath before passing to make-pdf.
- Treat strict-mode OUTSIDE errors as security signals, not nuisances.
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
- image is not a regular file: ${src}
- Path must be within: ${SAFE_DIRECTORIES.join(', ')}
- remote image blocked (offline posture): ${src} — re-run with
- image not found: ${src} (resolved to ${filePath})
- image exceeds ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/b52adfbcb36fe355.
Report an issue: GitHub.