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.

Solutions

  1. Resolve the path first and use the real path in config: `realpath <output-path>`, then configure that value.
  2. Replace the symlinked ancestor with a bind/real directory if you need the literal path.
  3. 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

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


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/54147a9aea74d8eb. Report an issue: GitHub.

Appendix: 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)