From d038882efef5fcedabe869d4ab663f90e56d0875 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 15 Feb 2026 22:46:23 -0800 Subject: [PATCH] fix(permissions): normalize Windows absolute file-rule matching (#973) Co-authored-by: Letta --- src/permissions/matcher.ts | 100 ++++++++++++++++++++++++-- src/tests/permissions-checker.test.ts | 47 ++++++++++++ src/tests/permissions-matcher.test.ts | 70 ++++++++++++++++++ 3 files changed, 210 insertions(+), 7 deletions(-) diff --git a/src/permissions/matcher.ts b/src/permissions/matcher.ts index df9e990..2edc967 100644 --- a/src/permissions/matcher.ts +++ b/src/permissions/matcher.ts @@ -15,6 +15,85 @@ function normalizePath(p: string): string { return p.replace(/\\/g, "/"); } +function isWindowsDrivePath(p: string): boolean { + return /^[a-zA-Z]:\//.test(p); +} + +function isWindowsUncPath(p: string): boolean { + return /^\/\/[^/]+\/[^/]+/.test(p); +} + +function isWindowsExtendedDrivePath(p: string): boolean { + return /^\/\/\?\/[a-zA-Z]:\//.test(p); +} + +function isWindowsExtendedUncPath(p: string): boolean { + return /^\/\/\?\/UNC\/[^/]+\/[^/]+/i.test(p); +} + +function isWindowsContext(workingDirectory: string): boolean { + const normalizedWorkingDir = normalizePath(workingDirectory); + return ( + process.platform === "win32" || + isWindowsDrivePath(normalizedWorkingDir) || + normalizedWorkingDir.startsWith("//") + ); +} + +function canonicalizeWindowsAbsolutePath(path: string): string { + let normalized = normalizePath(path); + + if (isWindowsExtendedUncPath(normalized)) { + normalized = `//${normalized.slice("//?/UNC/".length)}`; + } else if (isWindowsExtendedDrivePath(normalized)) { + normalized = normalized.slice("//?/".length); + } + + if (/^\/+[a-zA-Z]:\//.test(normalized)) { + normalized = normalized.replace(/^\/+/, ""); + } + + if (isWindowsDrivePath(normalized)) { + normalized = `${normalized[0]?.toUpperCase() ?? ""}${normalized.slice(1)}`; + } + + return normalized; +} + +function isWindowsAbsolutePath(path: string): boolean { + const canonicalPath = canonicalizeWindowsAbsolutePath(path); + return isWindowsDrivePath(canonicalPath) || isWindowsUncPath(canonicalPath); +} + +function normalizeAbsolutePattern( + globPattern: string, + workingDirectory: string, +): string { + if (isWindowsContext(workingDirectory)) { + return canonicalizeWindowsAbsolutePath(globPattern); + } + + // Claude-style Unix absolute path prefix: //absolute/path -> /absolute/path + if (globPattern.startsWith("//")) { + return globPattern.slice(1); + } + + return globPattern; +} + +function resolveFilePathForMatching( + filePath: string, + workingDirectory: string, + windowsContext: boolean, +): string { + if (windowsContext && isWindowsAbsolutePath(filePath)) { + return canonicalizeWindowsAbsolutePath(filePath); + } + + const resolved = normalizePath(resolve(workingDirectory, filePath)); + return windowsContext ? canonicalizeWindowsAbsolutePath(resolved) : resolved; +} + /** * Check if a file path matches a permission pattern. * @@ -70,17 +149,24 @@ export function matchesFilePattern( globPattern = globPattern.replace(/^~/, homedir); } - // Handle absolute paths (Claude Code uses // prefix) - if (globPattern.startsWith("//")) { - globPattern = globPattern.slice(1); // Remove one slash to make it absolute - } + globPattern = normalizeAbsolutePattern(globPattern, workingDirectory); // Resolve file path to absolute and normalize separators - const absoluteFilePath = normalizePath(resolve(workingDirectory, filePath)); + const windowsContext = isWindowsContext(workingDirectory); + const absoluteFilePath = resolveFilePathForMatching( + filePath, + workingDirectory, + windowsContext, + ); // If pattern is absolute, compare directly - if (globPattern.startsWith("/")) { - return minimatch(absoluteFilePath, globPattern); + if (globPattern.startsWith("/") || isWindowsAbsolutePath(globPattern)) { + const patternToMatch = windowsContext + ? canonicalizeWindowsAbsolutePath(globPattern) + : globPattern; + return minimatch(absoluteFilePath, patternToMatch, { + nocase: windowsContext, + }); } // If pattern is relative, compare against both: diff --git a/src/tests/permissions-checker.test.ts b/src/tests/permissions-checker.test.ts index dbe1084..35f730b 100644 --- a/src/tests/permissions-checker.test.ts +++ b/src/tests/permissions-checker.test.ts @@ -272,6 +272,53 @@ test("Allow exact Bash command", () => { expect(result2.decision).toBe("ask"); // Doesn't match exact }); +test("Issue #969: legacy Windows Edit allow rule matches memory project file", () => { + const permissions: PermissionRules = { + allow: [ + "Edit(/C:\\Users\\Aaron\\.letta\\agents\\agent-7dcc\\memory\\system\\project/**)", + ], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Edit", + { + file_path: + "C:\\Users\\Aaron\\.letta\\agents\\agent-7dcc\\memory\\system\\project\\tech_stack.md", + }, + permissions, + "C:\\Users\\Aaron\\repo", + ); + + expect(result.decision).toBe("allow"); + expect(result.matchedRule).toBe( + "Edit(/C:\\Users\\Aaron\\.letta\\agents\\agent-7dcc\\memory\\system\\project/**)", + ); +}); + +test("Issue #969 guardrail: Windows legacy Edit rule does not over-match sibling subtree", () => { + const permissions: PermissionRules = { + allow: [ + "Edit(/C:\\Users\\Aaron\\.letta\\agents\\agent-7dcc\\memory\\system\\project/**)", + ], + deny: [], + ask: [], + }; + + const result = checkPermission( + "Edit", + { + file_path: + "C:\\Users\\Aaron\\.letta\\agents\\agent-7dcc\\memory\\system\\other\\x.md", + }, + permissions, + "C:\\Users\\Aaron\\repo", + ); + + expect(result.decision).toBe("ask"); +}); + // ============================================================================ // Ask Rule Tests // ============================================================================ diff --git a/src/tests/permissions-matcher.test.ts b/src/tests/permissions-matcher.test.ts index 2c41934..6dbb3dd 100644 --- a/src/tests/permissions-matcher.test.ts +++ b/src/tests/permissions-matcher.test.ts @@ -349,3 +349,73 @@ test("File pattern: Windows absolute path in working directory", () => { ), ).toBe(true); }); + +test("File pattern: Windows absolute variants are equivalent", () => { + const query = + "Edit(C:\\Users\\Aaron\\.letta\\agents\\agent-1\\memory\\system\\project\\tech_stack.md)"; + const workingDir = "C:\\Users\\Aaron\\repo"; + + expect( + matchesFilePattern( + query, + "Edit(/C:/Users/Aaron/.letta/agents/agent-1/memory/system/project/**)", + workingDir, + ), + ).toBe(true); + + expect( + matchesFilePattern( + query, + "Edit(//C:/Users/Aaron/.letta/agents/agent-1/memory/system/project/**)", + workingDir, + ), + ).toBe(true); + + expect( + matchesFilePattern( + query, + "Edit(C:/Users/Aaron/.letta/agents/agent-1/memory/system/project/**)", + workingDir, + ), + ).toBe(true); +}); + +test("File pattern: Windows drive-letter matching is case-insensitive", () => { + const query = "Edit(c:\\users\\aaron\\repo\\src\\file.ts)"; + const workingDir = "C:\\Users\\Aaron\\repo"; + + expect( + matchesFilePattern(query, "Edit(C:/Users/Aaron/repo/src/**)", workingDir), + ).toBe(true); +}); + +test("File pattern: UNC absolute path matches normalized UNC pattern", () => { + const query = "Edit(\\\\server\\share\\folder\\file.md)"; + const workingDir = "C:\\Users\\Aaron\\repo"; + + expect( + matchesFilePattern(query, "Edit(//server/share/folder/**)", workingDir), + ).toBe(true); +}); + +test("File pattern: extended Windows drive path matches canonical drive pattern", () => { + const query = String.raw`Edit(\\?\C:\Users\Aaron\folder\file.md)`; + const workingDir = String.raw`C:\Users\Aaron\repo`; + + expect( + matchesFilePattern(query, "Edit(C:/Users/Aaron/folder/**)", workingDir), + ).toBe(true); +}); + +test("File pattern: extended UNC pattern matches UNC query path", () => { + const query = String.raw`Edit(\\server\share\folder\file.md)`; + const workingDir = String.raw`C:\Users\Aaron\repo`; + + expect( + matchesFilePattern( + query, + "Edit(//?/UNC/server/share/folder/**)", + workingDir, + ), + ).toBe(true); +});