can1357/oh-my-pi · error · Error
Security output directory does not have a canonical identity
Error message
Security output directory does not have a canonical identity
What it means
normalizeOutput requires the output directory's canonical identity to match the resolved path: after confirming it is a real directory, realpath(canonicalCandidate) must equal canonicalCandidate exactly. A mismatch means hidden symlinks in the chain (or a link resolving to elsewhere) would make writes land somewhere other than the configured location, so the path is rejected.
Source
Thrown at packages/coding-agent/src/security/preflight.ts:259
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 {
root: string;
archivedTo?: string;
}View on GitHub (pinned to 9690622007)
Solutions
- Resolve the path first and use the real path in config: `realpath <output-path>`, then configure that value.
- Replace the symlinked ancestor with a bind/real directory if you need the literal path.
- On macOS, use /private/tmp instead of /tmp if you need literal equality.
Example fix
// before
const plan = await output({ outputRoot: "/tmp/sec-report", repositoryRoot });
// after (macOS, /tmp -> /private/tmp)
const plan = await output({ outputRoot: "/private/tmp/sec-report", repositoryRoot }); Defensive patterns
Strategy: validation
Validate before calling
import * as path from "node:path";
import * as fs from "node:fs/promises";
const requested = path.resolve(outputRoot);
const canonical = path.join(await fs.realpath(path.dirname(requested)), path.basename(requested));
const real = await fs.realpath(canonical);
if (real !== canonical) {
console.warn(`Using canonical path ${real} instead of ${outputRoot}`);
} Try / catch
try {
await output(options);
} catch (err) {
if ((err as Error).message.includes("does not have a canonical identity")) {
options.outputRoot = await fs.realpath(path.resolve(options.outputRoot));
return output(options); // retry with the resolved path
}
throw err;
} Prevention
- Store realpath-resolved absolute paths in configuration files.
- On macOS prefer /private/tmp over /tmp in persisted config.
- Avoid symlinked ancestors (mount points, /var/aliases) for scan output paths.
When it happens
Trigger: Calling output()/normalizeOutput where some ancestor component of outputRoot is a symlink so realpath differs from path.join(realpath(dirname), basename) — e.g. outputRoot="/var/link/sec-out" where /var/link -> /mnt/data.
Common situations: macOS /tmp -> /private/tmp; home directories symlinked (e.g. /home/user -> /usr/home/user); CI runners exposing symlinked workspace roots; network mounts referenced via link aliases.
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 knowledge base is not a file: ${input}
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/54147a9aea74d8eb.
Report an issue: GitHub.