can1357/oh-my-pi · error

Managed skill "${name}" resolves through a symlink; refusing

Error message

Managed skill "${name}" resolves through a symlink; refusing to write outside the managed directory.

What it means

Inside writeManagedSkill, after checking the root, the code lstats the per-skill directory <managed-skills>/<name> and refuses if that directory is itself a symlink — the write could then land outside the managed root. This closes the traversal hole the root check cannot catch (a legit root containing a linked subdirectory).

Source

Thrown at packages/coding-agent/src/autolearn/managed-skills.ts:186

	const bytes = Buffer.byteLength(content, "utf8");
	if (bytes > MAX_MANAGED_SKILL_BYTES) {
		throw new Error(
			`Managed skill is ${bytes} bytes; the limit is ${MAX_MANAGED_SKILL_BYTES}. Trim the body or description.`,
		);
	}
	return serializeSkillMutation(name, async () => {
		await assertManagedRootSafe();
		const dir = path.join(getManagedSkillsDir(), name);
		const file = path.join(dir, "SKILL.md");
		// Reject a symlinked skill directory: an intermediate symlink would let the
		// write escape the isolated managed root. lstat does not follow the final
		// component, so a symlinked `dir` is caught here.
		const dirStat = await fs.lstat(dir).catch(err => {
			if (isEnoent(err)) return null;
			throw err;
		});
		if (dirStat?.isSymbolicLink()) {
			throw new Error(
				`Managed skill "${name}" resolves through a symlink; refusing to write outside the managed directory.`,
			);
		}
		if (input.action === "create") {
			await fs.mkdir(dir, { recursive: true });
			// O_CREAT|O_EXCL ("wx"): atomic create that fails if the file already
			// exists (closing the check-then-write race) and refuses a symlinked SKILL.md.
			try {
				await fs.writeFile(file, content, { flag: "wx" });
			} catch (err) {
				if ((err as { code?: string }).code === "EEXIST") {
					throw new Error(`Managed skill "${name}" already exists. Use action "update" to change it.`);
				}
				throw err;
			}
			return { path: file };
		}
		// update: the file must already exist, be a plain managed file, and must

View on GitHub (pinned to 9690622007)

Solutions

  1. Remove the symlinked skill directory: `rm ~/.omp/agent/managed-skills/<name>` (removes the link only), then retry — writeManagedSkill will mkdir a real directory
  2. Never symlink directories into managed-skills; copy content instead or use the authored skills dir
  3. Audit ~/.omp/agent/managed-skills with `find -type l` to find remaining symlinks

Example fix

// before (shell)
ln -s /shared/skills/foo ~/.omp/agent/managed-skills/foo
// after (shell)
rm ~/.omp/agent/managed-skills/foo  # drop the link; retry writeManagedSkill to recreate a real dir
Defensive patterns

Strategy: validation

Validate before calling

import { lstat } from "node:fs/promises";
import * as path from "node:path";
const dir = path.join(getManagedSkillsDir(), name);
const st = await lstat(dir).catch(() => null);
if (st?.isSymbolicLink()) throw new Error("skill dir must not be a symlink");

Type guard

function isRealDirStat(st: { isSymbolicLink(): boolean; isDirectory(): boolean } | null): boolean {
  return st !== null && st.isDirectory() && !st.isSymbolicLink();
}

Try / catch

try {
  await writeManagedSkill(input);
} catch (err) {
  if (String((err as Error).message).includes("resolves through a symlink")) {
    // rm the linked skill dir and retry so a real directory is created
  } else throw err;
}

Prevention

When it happens

Trigger: writeManagedSkill targets a skill name whose directory under the managed root is a symlink to another location.

Common situations: A user symlinked a managed skill folder to a shared/location-controlled directory; stow-style dotfile management linked whole skill directories; a malicious or buggy setup script pre-created linked dirs to redirect auto-generated content.

Related errors


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