can1357/oh-my-pi · error · ToolError

skill:// path resolves outside the plugin root: ${url}

Error message

skill:// path resolves outside the plugin root: ${url}

What it means

For skills with a containRoot (e.g. agent-plugin packages), the resolved path must canonically resolve inside the plugin root, checked via resolveContainedPathSync (which follows symlinks/realpaths). If realpath resolution lands outside the root, this ToolError is thrown — fail-closed so a write through bash cannot create a target outside the package. Symlinks to other files inside the same package remain allowed.

Source

Thrown at packages/coding-agent/src/tools/bash-skill-urls.ts:114

	} catch (err) {
		const message = err instanceof Error ? err.message : String(err);
		throw new ToolError(message);
	}

	const targetPath = path.join(skill.baseDir, relativePath);
	const resolvedPath = path.resolve(targetPath);
	const resolvedBaseDir = path.resolve(skill.baseDir);
	if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
		throw new ToolError("Path traversal is not allowed in skill:// URLs");
	}
	// Agent Plugin skills (§4.1): the resource must canonically resolve within
	// the plugin root. Fail closed: a dangling or unresolvable path is rejected
	// rather than handed to bash, where writing through it could create the
	// outside target. Symlinks may target other files inside the same package.
	if (skill.containRoot) {
		const contained = resolveContainedPathSync(skill.containRoot, resolvedPath);
		if (contained.status === "outside") {
			throw new ToolError(`skill:// path resolves outside the plugin root: ${url}`);
		}
		if (contained.status === "missing") {
			throw new ToolError(`skill:// path does not exist: ${url}`);
		}
		return contained.realPath;
	}

	return resolvedPath;
}

/**
 * Match a raw skill segment against registered skills using longest-prefix match.
 * Handles colons in both skill names (namespacing) and suffixes (line ranges).
 *
 * For "superpowers:brainstorming:1-5" with skill "superpowers:brainstorming":
 *   -> skill = superpowers:brainstorming, suffix = "1-5"
 * For "brainstorming" with skill "brainstorming":
 *   -> skill = brainstorming, suffix = undefined

View on GitHub (pinned to 9690622007)

Solutions

  1. Retarget the symlink so it points at a file within the plugin root.
  2. Replace the out-of-root symlink with a copy of the target inside the package.
  3. Fix the plugin packaging so baseDir lies within containRoot.
  4. If access to the external file is genuinely needed, use a non-skill mechanism with explicit permission.

Example fix

// before
ln -s ~/notes.txt $PLUGIN/skills/a/notes.txt
// after
cp ~/notes.txt $PLUGIN/skills/a/notes.txt  # symlink → real file inside root
Defensive patterns

Strategy: validation

Validate before calling

import * as fs from "node:fs";
const target = resolveSkillUrlToPathUnchecked(url, skills); // your own pre-check
const real = fs.realpathSync.native(target);
if (!real.startsWith(fs.realpathSync(skill.containRoot))) {
  throw new Error(`symlink target outside plugin root: ${real}`);
}

Try / catch

try {
  return resolveSkillUrlToPath(url, skills);
} catch (e) {
  if (e instanceof ToolError && e.message.includes("outside the plugin root")) {
    // flag the plugin package as mis-packaged; do not retry the URL
  } else throw e;
}

Prevention

When it happens

Trigger: A skill:// URL for a containRoot skill whose target — after symlink resolution — points outside the plugin root, e.g. a symlink inside the skill pointing at /etc/hosts, or a baseDir placed outside containRoot.

Common situations: A plugin package contains a symlink to a user home file; a dev setup links the skill directory to an external folder; a mis-packaged plugin where baseDir and containRoot diverge.

Related errors


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