can1357/oh-my-pi · error · Error
Security output directory must not be a symbolic link
Error message
Security output directory must not be a symbolic link
What it means
During normalizeOutput, an lstat on the canonical output candidate reveals an existing entry that is a symbolic link; this is rejected. Symlinks are disallowed so the canonical identity check (realpath must equal the resolved path) can guarantee scan output cannot be redirected through a link to an unexpected location.
Source
Thrown at packages/coding-agent/src/security/preflight.ts:256
}
return results.sort((left, right) => left.path.localeCompare(right.path));
}
async function normalizeOutput(
repositoryRoot: string,
outputRoot: string,
archiveExisting: boolean,
): Promise<SecurityOutputPlan> {
const requested = path.resolve(outputRoot);
const parent = await fs.realpath(path.dirname(requested));
const canonicalCandidate = path.join(parent, path.basename(requested));
if (pathIsWithin(canonicalCandidate, repositoryRoot)) {
throw new Error("Security output directory must be outside the scanned repository");
}
let existingState: SecurityOutputPlan["existingState"] = "absent";
try {
const stats = await fs.lstat(canonicalCandidate);
if (stats.isSymbolicLink()) throw new Error("Security output directory must not be a symbolic link");
if (!stats.isDirectory()) throw new Error("Security output path exists and is not a directory");
const real = await fs.realpath(canonicalCandidate);
if (real !== canonicalCandidate) throw new Error("Security output directory does not have a canonical identity");
const entries = await fs.readdir(canonicalCandidate);
existingState = entries.length === 0 ? "empty" : "archivable";
if (entries.length > 0 && !archiveExisting) {
throw new Error("Security output directory is not empty; enable archiveExisting or choose another directory");
}
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
await fs.mkdir(canonicalCandidate, { recursive: false, mode: 0o700 });
existingState = "empty";
}
if (process.platform !== "win32") await fs.chmod(canonicalCandidate, 0o700);
return { root: canonicalCandidate, archiveExisting, existingState };
}
export interface PreparedSecurityOutput {View on GitHub (pinned to 9690622007)
Solutions
- Remove the symlink (`rm <path>`) and create a real directory at that path (`mkdir <path>`).
- Choose a different outputRoot that is a real directory.
- If the data lives elsewhere, configure outputRoot to the actual target location (the real directory), not the link.
Example fix
// shell # before: ~/sec-out -> /mnt/data/sec-out (symlink) rm ~/sec-out mkdir ~/sec-out # real directory // after: ~/sec-out is a real directory
Defensive patterns
Strategy: validation
Validate before calling
import * as fs from "node:fs/promises";
try {
const st = await fs.lstat(outputPath);
if (st.isSymbolicLink()) throw new Error(`${outputPath} is a symlink; replace it with a real directory`);
} catch (e) {
if (!(e as NodeJS.ErrnoException).code || (e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
} Type guard
function isRealDirectoryError(err: unknown): err is Error & { message: string } {
return err instanceof Error && err.message.includes("must not be a symbolic link");
} Try / catch
try {
await output(options);
} catch (err) {
if ((err as Error).message.includes("must not be a symbolic link")) {
await fs.unlink(outputPath);
await fs.mkdir(outputPath);
return output(options); // retry once with a real directory
}
throw err;
} Prevention
- Avoid symlinked directories for scan output; use real paths.
- If using dotfile managers (stow/chezmoi), exclude output directories from symlink management.
- lstat the configured path before running to detect links early.
When it happens
Trigger: Calling output()/normalizeOutput where outputRoot already exists on disk and lstat reports it as a symlink — e.g. ~/sec-out -> /mnt/data/sec-out.
Common situations: dotfile managers (stow, chezmoi) symlink config dirs; users symlink a shared output folder into their home; previous tooling replaced the dir with a link; copying a config between machines where a path became a symlink.
Related errors
- The managed-skills root is a symlink; refusing to operate ou
- Managed skill "${name}" SKILL.md is a symlink; refusing to o
- Managed skill "${name}" resolves through a symlink; refusing
- Managed skill "${safe}" is a symlink; refusing to delete out
- Security output directory does not have a canonical identity
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/ebcb55d1b402e2ef.
Report an issue: GitHub.