fix: prevent local:// URI from creating local: directory on Linux
On Linux, Node's path.normalize() collapses the double slash in
local://PLAN.md to local:/PLAN.md, creating a directory called local:
in the project root instead of routing through the local:// protocol handler.
Defense-in-depth fixes across 5 layers:
1. resolveToCwd() now throws if a path starts with any internal URL
scheme prefix (local:, agent:, skill:, etc.), preventing all 59
call sites from treating URIs as relative filesystem paths.
2. resolvePlanPath() now matches on local: prefix (not just local://)
and normalizes local:/ to local:// before resolution, catching
all slash variants.
3. Bash URL expansion regex and early-exit checks now also match
local:/ (single slash), and normalize before resolution.
4. Edit preview/diff functions now gracefully skip internal URL paths
instead of crashing via the resolveToCwd guard.
5. All startsWith('local://') checks updated to startsWith('local:')
with normalization in agent-session, interactive-mode, and
approved-plan modules.
Also adds local: to .gitignore to prevent accidental commits of the
leaked directory.
This commit is contained in:
@@ -1 +1,3 @@
|
||||
src/core/export-html/template.generated.ts
|
||||
|
||||
local:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import * as Diff from "diff";
|
||||
import { resolveToCwd } from "../tools/path-utils";
|
||||
import { isInternalUrlPath, resolveToCwd } from "../tools/path-utils";
|
||||
import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
|
||||
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
|
||||
|
||||
@@ -761,6 +761,9 @@ export async function computeEditDiff(
|
||||
if (oldText.length === 0) {
|
||||
return { error: "oldText must not be empty." };
|
||||
}
|
||||
if (isInternalUrlPath(path)) {
|
||||
return { error: `Preview not available for internal URL: ${path}` };
|
||||
}
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
|
||||
try {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
invalidateFsScanAfterWrite,
|
||||
} from "../../tools/fs-cache-invalidation";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
import { resolveToCwd } from "../../tools/path-utils";
|
||||
import { isInternalUrlPath, resolveToCwd } from "../../tools/path-utils";
|
||||
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
||||
import { generateDiffString } from "../diff";
|
||||
import { computeLineHash, formatLineHash } from "../line-hash";
|
||||
@@ -1157,6 +1157,9 @@ export async function computeHashlineDiff(
|
||||
}
|
||||
> {
|
||||
const { path, edits, move } = input;
|
||||
if (isInternalUrlPath(path) || (move && isInternalUrlPath(move))) {
|
||||
return { error: `Preview not available for internal URL: ${path}` };
|
||||
}
|
||||
const absolutePath = resolveToCwd(path, cwd);
|
||||
const movePath = move ? resolveToCwd(move, cwd) : undefined;
|
||||
const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
invalidateFsScanAfterWrite,
|
||||
} from "../../tools/fs-cache-invalidation";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
import { resolveToCwd } from "../../tools/path-utils";
|
||||
import { isInternalUrlPath, resolveToCwd } from "../../tools/path-utils";
|
||||
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
||||
import {
|
||||
ApplyPatchError,
|
||||
@@ -1557,6 +1557,9 @@ export async function computePatchDiff(
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
if (isInternalUrlPath(input.path) || (input.rename && isInternalUrlPath(input.rename))) {
|
||||
return { error: `Preview not available for internal URL: ${input.path}` };
|
||||
}
|
||||
try {
|
||||
const result = await previewPatch(input, {
|
||||
cwd,
|
||||
|
||||
@@ -637,8 +637,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
#resolvePlanFilePath(planFilePath: string): string {
|
||||
if (planFilePath.startsWith("local://")) {
|
||||
return resolveLocalUrlToPath(planFilePath, {
|
||||
if (planFilePath.startsWith("local:")) {
|
||||
const normalized = planFilePath.replace(/^(local:)\/(?!\/)/, "$1//");
|
||||
return resolveLocalUrlToPath(normalized, {
|
||||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ interface RenameApprovedPlanFileOptions {
|
||||
}
|
||||
|
||||
function assertLocalUrl(path: string, label: "source" | "destination"): void {
|
||||
if (!path.startsWith("local://")) {
|
||||
throw new Error(`Approved plan ${label} path must use local:// (received ${path}).`);
|
||||
if (!path.startsWith("local:")) {
|
||||
throw new Error(`Approved plan ${label} path must use local:// scheme (received ${path}).`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2445,8 +2445,8 @@ export class AgentSession {
|
||||
const state = this.#planModeState;
|
||||
if (!state?.enabled) return null;
|
||||
const sessionPlanUrl = "local://PLAN.md";
|
||||
const resolvedPlanPath = state.planFilePath.startsWith("local://")
|
||||
? resolveLocalUrlToPath(state.planFilePath, {
|
||||
const resolvedPlanPath = state.planFilePath.startsWith("local:")
|
||||
? resolveLocalUrlToPath(state.planFilePath.replace(/^(local:)\/(?!\/)/, "$1//"), {
|
||||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
})
|
||||
@@ -2456,7 +2456,7 @@ export class AgentSession {
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
});
|
||||
const displayPlanPath =
|
||||
state.planFilePath.startsWith("local://") || resolvedPlanPath !== resolvedSessionPlan
|
||||
state.planFilePath.startsWith("local:") || resolvedPlanPath !== resolvedSessionPlan
|
||||
? state.planFilePath
|
||||
: sessionPlanUrl;
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ import { ToolError } from "./tool-errors";
|
||||
/** Regex to find skill:// tokens in command text. */
|
||||
const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|skill:\/\/[^\s'")`\\]+/g;
|
||||
|
||||
/** Regex to find supported internal URL tokens in command text. */
|
||||
const INTERNAL_URL_PATTERN =
|
||||
/'(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^\s'")`\\]+/g;
|
||||
const INTERNAL_URL_PATTERN_INCLUDING_NORMALIZED_LOCAL =
|
||||
/'(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^\s'")`\\]+|local:\/[^\s'")`\\]+/g;
|
||||
|
||||
const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "memory", "rule", "local"] as const;
|
||||
|
||||
@@ -146,12 +145,13 @@ function shellEscape(p: string): string {
|
||||
}
|
||||
|
||||
async function resolveInternalUrlToPath(
|
||||
url: string,
|
||||
rawUrl: string,
|
||||
skills: readonly Skill[],
|
||||
internalRouter?: InternalUrlResolver,
|
||||
localOptions?: LocalProtocolOptions,
|
||||
ensureLocalParentDirs?: boolean,
|
||||
): Promise<string> {
|
||||
const url = rawUrl.replace(/^(local:)\/(?!\/)/, "$1//");
|
||||
const scheme = extractScheme(url);
|
||||
if (!scheme) {
|
||||
throw new ToolError(`Unsupported internal URL in bash command: ${url}`);
|
||||
@@ -218,9 +218,9 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
|
||||
* Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
|
||||
*/
|
||||
export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
|
||||
if (!command.includes("://")) return command;
|
||||
if (!command.includes("://") && !command.includes("local:/")) return command;
|
||||
|
||||
const matches = Array.from(command.matchAll(INTERNAL_URL_PATTERN));
|
||||
const matches = Array.from(command.matchAll(INTERNAL_URL_PATTERN_INCLUDING_NORMALIZED_LOCAL));
|
||||
if (matches.length === 0) return command;
|
||||
|
||||
let expanded = command;
|
||||
@@ -230,7 +230,8 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
|
||||
const index = match.index;
|
||||
if (index === undefined) continue;
|
||||
|
||||
const url = unquoteToken(token);
|
||||
const rawUrl = unquoteToken(token);
|
||||
const url = rawUrl.replace(/^(local:)\/(?!\/)/, "$1//");
|
||||
const resolvedPath = await resolveInternalUrlToPath(
|
||||
url,
|
||||
options.skills,
|
||||
|
||||
@@ -512,7 +512,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
||||
: undefined;
|
||||
|
||||
// Resolve protocol URLs (skill://, agent://, etc.) in extracted cwd.
|
||||
if (cwd?.includes("://")) {
|
||||
if (cwd?.includes("://") || cwd?.includes("local:/")) {
|
||||
cwd = await expandInternalUrls(cwd, { ...internalUrlOptions, noEscape: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ function normalizeAtPrefix(filePath: string): string {
|
||||
withoutAt.startsWith("artifact://") ||
|
||||
withoutAt.startsWith("skill://") ||
|
||||
withoutAt.startsWith("rule://") ||
|
||||
withoutAt.startsWith("local://") ||
|
||||
withoutAt.startsWith("local:") ||
|
||||
withoutAt.startsWith("mcp://")
|
||||
) {
|
||||
return withoutAt;
|
||||
@@ -110,6 +110,24 @@ export function expandPath(filePath: string): string {
|
||||
return expandTilde(normalized);
|
||||
}
|
||||
|
||||
function assertNotInternalUrl(expanded: string, original: string): void {
|
||||
for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
|
||||
if (expanded.startsWith(prefix)) {
|
||||
throw new Error(
|
||||
`Path "${original}" uses internal scheme "${prefix}" and must be resolved through the proper protocol handler, not as a filesystem path.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isInternalUrlPath(filePath: string): boolean {
|
||||
const expanded = expandPath(filePath);
|
||||
for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
|
||||
if (expanded.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path relative to the given cwd.
|
||||
* Handles ~ expansion and absolute paths.
|
||||
@@ -120,6 +138,9 @@ export function expandPath(filePath: string): string {
|
||||
*/
|
||||
export function resolveToCwd(filePath: string, cwd: string): string {
|
||||
const expanded = expandPath(filePath);
|
||||
|
||||
assertNotInternalUrl(expanded, filePath);
|
||||
|
||||
if (/^\/+$/.test(expanded)) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ import type { ToolSession } from ".";
|
||||
import { resolveToCwd } from "./path-utils";
|
||||
import { ToolError } from "./tool-errors";
|
||||
|
||||
const LOCAL_URL_PREFIX = "local://";
|
||||
const LOCAL_SCHEME_PREFIX = "local:";
|
||||
|
||||
function normalizeLocalScheme(path: string): string {
|
||||
return path.replace(/^(local:)\/(?!\/)/, "$1//");
|
||||
}
|
||||
|
||||
export function resolvePlanPath(session: ToolSession, targetPath: string): string {
|
||||
if (targetPath.startsWith(LOCAL_URL_PREFIX)) {
|
||||
return resolveLocalUrlToPath(targetPath, {
|
||||
const normalized = normalizeLocalScheme(targetPath);
|
||||
if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
|
||||
return resolveLocalUrlToPath(normalized, {
|
||||
getArtifactsDir: session.getArtifactsDir,
|
||||
getSessionId: session.getSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveToCwd(targetPath, session.cwd);
|
||||
return resolveToCwd(normalized, session.cwd);
|
||||
}
|
||||
|
||||
export function enforcePlanModeWrite(
|
||||
|
||||
Reference in New Issue
Block a user