fix(permissions): normalize Windows absolute file-rule matching (#973)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-15 22:46:23 -08:00
committed by GitHub
parent 72d43c8a43
commit d038882efe
3 changed files with 210 additions and 7 deletions

View File

@@ -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:

View File

@@ -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
// ============================================================================

View File

@@ -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);
});