affaan-m/ECC · error · Error

output destination must not be a symlink

Error message

output destination must not be a symlink

What it means

Before writing, outputPaths() lstat's each destination ('<file> MASTER.md' and '<file> MASTER.docx'). If either exists and is a symbolic link, it throws this error and aborts before any write. This prevents a pre-planted symlink in the output directory from redirecting the generated agreement writes to an arbitrary target.

Solutions

  1. Remove the symlink from the output directory (ls -l outDir to find it, rm the link), then rerun the build.
  2. Use a clean, dedicated output directory that only the build controls.
  3. If a symlink is intentional, delete it and let the script create the real file; the library will never write through links.
  4. Note the source comment: these checks do not defend against concurrent hostile filesystem changes — keep outDir private during the run.

Example fix

// before
$ ln -s /etc/passwd out/NDA\ MASTER.docx
$ node build-agreement.js ... # throws: output destination must not be a symlink
// after
$ rm out/NDA\ MASTER.docx
$ node build-agreement.js ... # succeeds
Defensive patterns

Strategy: validation

Validate before calling

const fs = require('fs');
function ensureNoSymlinks(outDir, file) {
  for (const ext of ['md', 'docx']) {
    const p = `${outDir}/${file} MASTER.${ext}`;
    try {
      if (fs.lstatSync(p).isSymbolicLink()) throw new Error(`symlink at ${p}`);
    } catch (e) { if (e.code !== 'ENOENT') throw e; }
  }
}
ensureNoSymlinks(outDir, spec.file);

Type guard

null

Try / catch

try {
  outputPaths(outDir, spec.file);
} catch (e) {
  if (e.message === 'output destination must not be a symlink') {
    console.error('Remove pre-existing symlinks from the output directory before building');
  } else throw e;
}

Prevention

When it happens

Trigger: A symlink named e.g. 'NDA MASTER.docx' already exists in outDir (planted by an attacker or left over from an earlier manual ln -s), and build() runs with that outDir.

Common situations: Shared/temp output directories where another process created symlinks; developer experimentation with ln -s pointing outputs elsewhere; CI caches that restored symlinked artifacts.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/583e0f25377b120c. Report an issue: GitHub.

Appendix: source

Thrown at skills/master-agreement-generator/scripts/build-agreement.js:155

function outputPaths(outDir, file) {
  const root = path.resolve(outDir);
  const destinations = ['md', 'docx'].map(extension => path.resolve(root, `${file} MASTER.${extension}`));
  for (const destination of destinations) {
    if (path.dirname(destination) !== root) {
      throw new Error('spec.file must keep generated files directly inside the output directory');
    }
    // lstat also detects dangling links. Check BOTH outputs before the first write,
    // even when conversion is disabled. The caller must control this directory;
    // these checks do not isolate concurrent hostile filesystem changes.
    let stat;
    try {
      stat = fs.lstatSync(destination);
    } catch (error) {
      if (error.code !== 'ENOENT') throw error;
    }
    if (stat?.isSymbolicLink()) {
      throw new Error('output destination must not be a symlink');
    }
  }
  return { root, mdPath: destinations[0], docxPath: destinations[1] };
}

function build(templatePath, specPath, outDir, options = {}) {
  const template = fs.readFileSync(templatePath, 'utf8');
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
  const markdown = render(template, spec, options.now);
  const { root, mdPath, docxPath } = outputPaths(outDir, spec.file);
  fs.mkdirSync(root, { recursive: true });
  fs.writeFileSync(mdPath, markdown, 'utf8');

  // Generated DOCX is replaceable output. Never leave a stale or partial copy
  // beside a newly built Markdown draft, including explicit Markdown-only builds.
  fs.rmSync(docxPath, { force: true });
  const result = { markdown: mdPath, docx: null, docxSkipped: false, documentStatus: 'draft' };
  if (options.markdownOnly === true) {

View on GitHub (pinned to 8321021c54)