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
- Remove the symlink from the output directory (ls -l outDir to find it, rm the link), then rerun the build.
- Use a clean, dedicated output directory that only the build controls.
- If a symlink is intentional, delete it and let the script create the real file; the library will never write through links.
- 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
- Use a dedicated, private output directory owned by the build process.
- Audit output directories for unexpected symlinks (find outDir -type l), especially in shared/CI caches.
- Don't pre-create output files as links; let the script create regular files.
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
- gate.variant_invalid
- output artifact must be a regular file
- output bundle contains a symlink
- output bundle root must not be a symlink
- output directory must not be a symlink
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)