can1357/oh-my-pi · error · ToolError

${scheme}:// path escapes its root: ${rawPath}

Error message

${scheme}:// path escapes its root: ${rawPath}

What it means

As a final confinement check, `resolveUnderRoot` resolves the normalized path against the root and verifies the result still lies inside it (`resolved === rootPath` or starts with `rootPath + sep`). If the resolved path lands outside — e.g. via symlinks or root-edge tricks that earlier textual checks miss — this ToolError is thrown.

Source

Thrown at packages/coding-agent/src/eval/js/shared/helpers.ts:141

function resolveUnderRoot(scheme: string, root: string, rawRelative: string, rawPath: string): string {
	let relative: string;
	try {
		relative = decodeURIComponent(rawRelative.replaceAll("\\", "/"));
	} catch {
		throw new ToolError(`Invalid URL encoding in ${scheme}:// path: ${rawPath}`);
	}
	const rootPath = path.resolve(root);
	if (relative === "") return rootPath;
	if (path.isAbsolute(relative)) {
		throw new ToolError(`Absolute paths are not allowed in ${scheme}:// URLs: ${rawPath}`);
	}
	const normalized = path.normalize(relative);
	if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) {
		throw new ToolError(`Path traversal (..) is not allowed in ${scheme}:// URLs: ${rawPath}`);
	}
	const resolved = path.resolve(rootPath, normalized);
	if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) {
		throw new ToolError(`${scheme}:// path escapes its root: ${rawPath}`);
	}
	return resolved;
}

async function resolveRegularFile(
	ctx: HelperContext,
	rawPath: string,
): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
	const filePath = resolveHelperPath(ctx, rawPath, "read");
	const file = Bun.file(filePath);
	const stat = await file.stat();
	if (stat.isDirectory()) {
		throw new ToolError(`Directory paths are not supported by read(): ${filePath}`);
	}
	return { filePath, file, size: stat.size };
}

function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number {

View on GitHub (pinned to 9690622007)

Solutions

  1. Use a plain file path relative to the root without edge segments (`.` or `..`).
  2. Ensure the localRoot passed by the host is a fully resolved, symlink-free directory (host-side fix: `path.realpathSync(root)`).
  3. Read the root directory itself via a dedicated listing helper if available rather than resolving the root edge path.

Example fix

// before
await read("local://.."); // resolves outside root
// after
await read("local://sub/dir/file.md");
Defensive patterns

Strategy: validation

Validate before calling

const root = path.resolve(mountedRoot);
const resolved = path.resolve(root, rel);
if (resolved !== root && !resolved.startsWith(root + path.sep)) throw new Error("escapes root");

Try / catch

try {
	return await read(url);
} catch (err) {
	if (String(err?.message).includes("escapes its root")) {
		// root likely symlinked; ask host to re-mount with realpath or use plain path
	}
	throw err;
}

Prevention

When it happens

Trigger: A `scheme://` relative path whose resolution escapes the mounted root despite passing the textual `..` checks — for instance `local://..` exactly at the root edge, or a relative path combined with a symlinked root resolved to a different prefix.

Common situations: Edge-case paths like `local://.` or `local://..` that survive string checks; roots mounted through symlinks so `path.resolve` produces a prefix differing from the configured root string.

Related errors


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