Merge PR #6733: fix(coding-agent): safely glob memory directories (@usr-bin-roygbiv)

This commit is contained in:
can1357
2026-07-27 04:58:26 +02:00
5 changed files with 182 additions and 8 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `glob` rejecting safe `memory://root/<directory>/**` patterns. Memory globs now resolve their directory prefix inside the project memory root while rejecting traversal and percent-encoded path separators across the complete glob path.
## [17.1.4] - 2026-07-26
### Added
@@ -6,6 +6,7 @@ import { getMnemopiSessionState, type MnemopiScopedMemoryHit, type MnemopiSessio
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";
@@ -45,6 +46,62 @@ function toMemoryValidationError(error: unknown): Error {
return new Error(message.replace("skill://", "memory://"));
}
export interface MemoryGlobPattern {
baseUrl: string;
globPattern: string;
}
/**
* Split a memory:// glob at its first wildcard after validating the complete
* decoded path. The suffix is validated before filesystem globbing so `..`
* cannot escape a safely resolved base directory.
*/
export function splitMemoryGlobPattern(input: string): MemoryGlobPattern {
const urlMatch = input.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)(\/.*)?$/i);
if (!urlMatch) {
throw new Error(`Invalid memory glob URL: ${input}`);
}
// Parse only the scheme and authority. A literal `?` in the path is glob
// syntax, not a query delimiter, and must survive unchanged.
const url = parseInternalUrl(urlMatch[1]);
const namespace = url.rawHost || url.hostname;
if (url.protocol !== "memory:" || namespace !== MEMORY_NAMESPACE) {
throw new Error(`Memory glob patterns require the ${MEMORY_NAMESPACE} namespace: ${input}`);
}
const rawPathname = urlMatch[2] ?? "";
if (/%(?:2f|5c)/i.test(rawPathname)) {
throw new Error(`Encoded path separators are not allowed in memory:// glob patterns: ${input}`);
}
let relativePath: string;
try {
relativePath = decodeURIComponent(rawPathname.replace(/^\//, ""));
} catch {
throw new Error(`Invalid URL encoding in memory:// path: ${input}`);
}
try {
validateRelativePath(relativePath);
} catch (error) {
throw toMemoryValidationError(error);
}
const rawSegments = rawPathname.replace(/^\//, "").split("/");
const firstGlobIndex = rawSegments.findIndex(segment => ["*", "?", "[", "{"].some(char => segment.includes(char)));
if (firstGlobIndex === -1) {
throw new Error(`memory:// URL does not contain a glob pattern: ${input}`);
}
const decodedSegments = relativePath.split("/");
const rawBasePath = rawSegments.slice(0, firstGlobIndex).join("/") || ".";
return {
baseUrl: `memory://${namespace}/${rawBasePath}`,
globPattern: decodedSegments.slice(firstGlobIndex).join("/"),
};
}
/**
* Resolve a memory:// URL to an absolute filesystem path under memory root.
*/
@@ -91,12 +148,14 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise<I
const targetPath = resolveMemoryUrlToPath(url, resolvedRoot);
ensureWithinRoot(targetPath, resolvedRoot);
const parentDir = path.dirname(targetPath);
try {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) throw error;
if (targetPath !== resolvedRoot) {
const parentDir = path.dirname(targetPath);
try {
const realParent = await fs.realpath(parentDir);
ensureWithinRoot(realParent, resolvedRoot);
} catch (error) {
if (!isEnoent(error)) throw error;
}
}
let realTargetPath: string;
@@ -30,7 +30,12 @@ export function validateRelativePath(relativePath: string): void {
}
const normalized = path.normalize(relativePath);
if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) {
if (
relativePath.split(/[\\/]/).includes("..") ||
normalized.startsWith("..") ||
normalized.includes("/../") ||
normalized.includes("/..")
) {
throw new Error("Path traversal (..) is not allowed in skill:// URLs");
}
}
+20 -1
View File
@@ -9,6 +9,7 @@ import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi
import { type } from "arktype";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol";
import type { Theme } from "../modes/theme/theme";
import globDescription from "../prompts/tools/glob.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
@@ -176,7 +177,25 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
);
}
if (hasGlobPathChars(rawPattern)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
if (!/^memory:\/\//i.test(rawPattern)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
}
const memoryGlob = splitMemoryGlobPattern(rawPattern);
const resource = await internalRouter.resolve(memoryGlob.baseUrl, {
cwd: this.session.cwd,
settings: this.session.settings,
signal,
localProtocolOptions: this.session.localProtocolOptions,
skills: this.session.skills,
pathOnly: true,
});
if (!resource.sourcePath) {
throw new ToolError(`Cannot find internal URL without a backing file: ${memoryGlob.baseUrl}`);
}
normalizedPatterns.push(
path.join(resource.sourcePath.replace(/[*?[{]/g, "[$&]"), memoryGlob.globPattern),
);
continue;
}
const resource = await internalRouter.resolve(rawPattern, {
cwd: this.session.cwd,
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { InternalUrlRouter } from "@oh-my-pi/pi-coding-agent/internal-urls";
import { getMemoryRoot } from "@oh-my-pi/pi-coding-agent/memories";
import {
@@ -12,6 +13,8 @@ import {
} from "@oh-my-pi/pi-coding-agent/mnemopi/state";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { GlobTool } from "@oh-my-pi/pi-coding-agent/tools/glob";
import { getAgentDir, removeWithRetries, setAgentDir, TempDir } from "@oh-my-pi/pi-utils";
// Mnemopi state is loaded lazily; preload so `new MnemopiSessionState(...)` can
@@ -55,6 +58,17 @@ async function withMemoryFixture(fn: (fixture: MemoryFixture) => Promise<void>):
}
}
function createGlobTool(cwd: string): GlobTool {
const session: ToolSession = {
cwd,
hasUI: false,
settings: Settings.isolated({}),
getSessionFile: () => null,
getSessionSpawns: () => null,
};
return new GlobTool(session);
}
describe("MemoryProtocolHandler", () => {
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
@@ -234,6 +248,79 @@ describe("MemoryProtocolHandler", () => {
});
});
it("globs nested directories within the memory root", async () => {
await withMemoryFixture(async ({ cwd, memoryRoot }) => {
const nestedSkill = path.join(memoryRoot, "skills", "demo", "nested", "SKILL.md");
await fs.mkdir(path.dirname(nestedSkill), { recursive: true });
await Bun.write(nestedSkill, "nested skill");
await Bun.write(path.join(memoryRoot, "skills", "demo", "notes.txt"), "not markdown");
const tool = createGlobTool(cwd);
const result = await tool.execute("memory-glob", { path: "memory://root/skills/**/*.md" });
expect(result.details?.files).toHaveLength(1);
expect(result.details?.files?.[0]).toEndWith("/skills/demo/nested/SKILL.md");
const rootResult = await tool.execute("memory-root-glob", { path: "memory://root/**/*.md" });
expect(rootResult.details?.files).toHaveLength(1);
expect(rootResult.details?.files?.[0]).toEndWith("/skills/demo/nested/SKILL.md");
});
});
it("preserves literal question-mark wildcards in memory globs", async () => {
await withMemoryFixture(async ({ cwd, memoryRoot }) => {
const skillsDir = path.join(memoryRoot, "skills");
await fs.mkdir(skillsDir, { recursive: true });
await Bun.write(path.join(skillsDir, "a.md"), "single character");
await Bun.write(path.join(skillsDir, "ab.md"), "two characters");
const result = await createGlobTool(cwd).execute("memory-question-glob", {
path: "memory://root/skills/?.md",
});
expect(result.details?.files).toHaveLength(1);
expect(result.details?.files?.[0]).toEndWith("/skills/a.md");
});
});
it("resolves encoded literal glob characters before the wildcard boundary", async () => {
await withMemoryFixture(async ({ cwd, memoryRoot }) => {
const encodedLiteralDir = path.join(memoryRoot, "skills", "[demo]");
await fs.mkdir(encodedLiteralDir, { recursive: true });
await Bun.write(path.join(encodedLiteralDir, "SKILL.md"), "encoded literal directory");
const result = await createGlobTool(cwd).execute("memory-encoded-literal-glob", {
path: "memory://root/skills/%5Bdemo%5D/*.md",
});
expect(result.details?.files).toHaveLength(1);
expect(result.details?.files?.[0]).toEndWith("/skills/[demo]/SKILL.md");
});
});
it.each(["memory://root/skills/**/../*.md", "memory://root/skills/**/%2e%2e/*.md"])(
"rejects traversal in a memory glob suffix: %s",
async pattern => {
await withMemoryFixture(async ({ cwd }) => {
await expect(createGlobTool(cwd).execute("memory-glob-traversal", { path: pattern })).rejects.toThrow(
/traversal/i,
);
});
},
);
it.each(["memory://root/skills/**/demo%2fnested/*.md", "memory://root/skills/**/demo%5cnested/*.md"])(
"rejects encoded separators in a memory glob suffix: %s",
async pattern => {
await withMemoryFixture(async ({ cwd }) => {
await expect(createGlobTool(cwd).execute("memory-glob-separator", { path: pattern })).rejects.toThrow(
/encoded path separator/i,
);
});
},
);
it("throws clear error for missing files", async () => {
await withMemoryFixture(async () => {
const router = InternalUrlRouter.instance();