can1357/oh-my-pi · error

local:// URL escapes local root

Error message

local:// URL escapes local root

What it means

ensureWithinRoot enforces that any local:// target — after path resolution and realpath (symlink) expansion — still lies inside the session's local root directory. It throws when the resolved path is neither the root itself nor a descendant of it. This is a path-traversal / symlink-escape security guard.

Source

Thrown at packages/coding-agent/src/internal-urls/local-protocol.ts:23

import { AgentRegistry } from "../registry/agent-registry";
import { isMarkdownPath } from "../utils/lang-from-path";
import { buildDirectoryResource } from "./filesystem-resource";
import { parseInternalUrl } from "./parse";
import { validateRelativePath } from "./skill-protocol";
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";

export interface LocalProtocolOptions {
	getArtifactsDir?: () => string | null;
	getSessionId?: () => string | null;
}

function parseLocalUrl(input: string): InternalUrl {
	return parseInternalUrl(input);
}

function ensureWithinRoot(targetPath: string, rootPath: string): void {
	if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
		throw new Error("local:// URL escapes local root");
	}
}

function toLocalValidationError(error: unknown): Error {
	const message = error instanceof Error ? error.message : String(error);
	return new Error(message.replace("skill://", "local://"));
}
const WINDOWS_LOCAL_ROOT_MAX_CHARS = 180;

function safeSessionId(options: LocalProtocolOptions): string {
	const raw = options.getSessionId?.() ?? "session";
	const safe = raw.replace(/[^a-zA-Z0-9_.-]/g, "_");
	return safe.length > 0 ? safe : "session";
}

function shortLocalRoot(options: LocalProtocolOptions): string {
	// Derive the short root from the stable session id, never the artifact path,
	// so `SessionManager.moveTo()` and the resume-after-move flow keep finding

View on GitHub (pinned to 9690622007)

Solutions

  1. Remove or replace the symlink inside the local root that points outside it.
  2. Only reference paths genuinely under the session local root (visible via local:// listing).
  3. If you legitimately need external files, read them with the normal file tools, not local://.
Defensive patterns

Strategy: validation

Validate before calling

const rel = url.replace(/^local:\/\//, '');
if (rel.includes('..') || path.isAbsolute(rel)) throw new Error('local:// path must be relative and stay inside the session root');

Try / catch

try { resource = await handler.resolve(url, ctx); } catch (e) { if (e.message === 'local:// URL escapes local root') { /* treat as security rejection; log and refuse */ } else throw e; }

Prevention

When it happens

Trigger: Resolving local://../../etc/passwd (validateRelativePath usually catches ../ first, but realpath-expansion can still expose escapes); a symlink inside the local root pointing at a file outside it, e.g. local://link where link -> /etc/passwd; resolveLocalUrlToPath or resolveLocalTarget with a crafted InternalUrl.

Common situations: A user or LLM writes a symlink into the session's local artifacts dir; sessions whose artifacts dir was relocated without copying contents; hostile input embedded in model-generated local:// URLs.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


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