fix(permissions): normalize Windows absolute file-rule matching (#973)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user