fix(permissions): canonicalize Windows absolute rules in analyzer (#974)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-15 23:37:03 -08:00
committed by GitHub
parent d038882efe
commit 27ebcee51f
2 changed files with 146 additions and 20 deletions

View File

@@ -2,7 +2,7 @@
// Analyze tool executions and recommend appropriate permission rules
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { dirname, relative, resolve, win32 } from "node:path";
export interface ApprovalContext {
// What rule should be saved if user clicks "approve always"
@@ -29,6 +29,70 @@ export interface ApprovalContext {
*/
type ToolArgs = Record<string, unknown>;
function normalizeOsPath(path: string): string {
return path.replace(/\\/g, "/");
}
function isWindowsPath(path: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(path) || /^\\\\/.test(path);
}
function resolvePathForContext(basePath: string, targetPath: string): string {
const windows = isWindowsPath(basePath) || isWindowsPath(targetPath);
return windows
? win32.resolve(basePath, targetPath)
: resolve(basePath, targetPath);
}
function relativePathForContext(basePath: string, targetPath: string): string {
const windows = isWindowsPath(basePath) || isWindowsPath(targetPath);
return windows
? win32.relative(basePath, targetPath)
: relative(basePath, targetPath);
}
function isPathWithinDirectory(path: string, directory: string): boolean {
const windows = isWindowsPath(path) || isWindowsPath(directory);
const normalizedPath = normalizeOsPath(path);
const normalizedDirectory = normalizeOsPath(directory);
const relativePath = normalizeOsPath(
windows
? win32.relative(
normalizedDirectory.toLowerCase(),
normalizedPath.toLowerCase(),
)
: relativePathForContext(normalizedDirectory, normalizedPath),
);
if (relativePath === "") {
return true;
}
return (
!relativePath.startsWith("../") &&
relativePath !== ".." &&
!relativePath.startsWith("/") &&
!/^[a-zA-Z]:\//.test(relativePath)
);
}
function dirnameForContext(path: string): string {
return isWindowsPath(path) ? win32.dirname(path) : dirname(path);
}
function formatAbsoluteRulePath(path: string): string {
const normalized = normalizeOsPath(path).replace(/\/+$/, "");
if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//")) {
return normalized;
}
return `//${normalized.replace(/^\/+/, "")}`;
}
function formatDisplayPath(path: string): string {
return normalizeOsPath(path).replace(normalizeOsPath(homedir()), "~");
}
export function analyzeApprovalContext(
toolName: string,
toolArgs: ToolArgs,
@@ -97,15 +161,15 @@ function analyzeReadApproval(
filePath: string,
workingDir: string,
): ApprovalContext {
const absolutePath = resolve(workingDir, filePath);
const absolutePath = resolvePathForContext(workingDir, filePath);
// If outside working directory, generalize to parent directory
if (!absolutePath.startsWith(workingDir)) {
const dirPath = dirname(absolutePath);
const displayPath = dirPath.replace(require("node:os").homedir(), "~");
if (!isPathWithinDirectory(absolutePath, workingDir)) {
const dirPath = dirnameForContext(absolutePath);
const displayPath = formatDisplayPath(dirPath);
return {
recommendedRule: `Read(/${dirPath}/**)`,
recommendedRule: `Read(${formatAbsoluteRulePath(dirPath)}/**)`,
ruleDescription: `reading from ${displayPath}/`,
approveAlwaysText: `Yes, allow reading from ${displayPath}/ in this project`,
defaultScope: "project",
@@ -115,9 +179,12 @@ function analyzeReadApproval(
}
// Inside working directory - use relative path
const relativePath = absolutePath.slice(workingDir.length + 1);
const relativePath = normalizeOsPath(
relativePathForContext(workingDir, absolutePath),
);
const relativeDir = dirname(relativePath);
const pattern = relativeDir === "." ? "**" : `${relativeDir}/**`;
const pattern =
relativeDir === "." || relativeDir === "" ? "**" : `${relativeDir}/**`;
return {
recommendedRule: `Read(${pattern})`,
@@ -157,14 +224,14 @@ function analyzeEditApproval(
): ApprovalContext {
// Edit is safer than Write (file must exist)
// Can offer project-level for specific directories
const absolutePath = resolve(workingDir, filePath);
const dirPath = dirname(absolutePath);
const absolutePath = resolvePathForContext(workingDir, filePath);
const dirPath = dirnameForContext(absolutePath);
// If outside working directory, use absolute path with // prefix
if (!dirPath.startsWith(workingDir)) {
const displayPath = dirPath.replace(require("node:os").homedir(), "~");
// If outside working directory, use canonical absolute path pattern
if (!isPathWithinDirectory(dirPath, workingDir)) {
const displayPath = formatDisplayPath(dirPath);
return {
recommendedRule: `Edit(/${dirPath}/**)`,
recommendedRule: `Edit(${formatAbsoluteRulePath(dirPath)}/**)`,
ruleDescription: `editing files in ${displayPath}/`,
approveAlwaysText: `Yes, allow editing files in ${displayPath}/ in this project`,
defaultScope: "project",
@@ -174,8 +241,13 @@ function analyzeEditApproval(
}
// Inside working directory, use relative path
const relativeDirPath = dirPath.slice(workingDir.length + 1);
const pattern = relativeDirPath === "" ? "**" : `${relativeDirPath}/**`;
const relativeDirPath = normalizeOsPath(
relativePathForContext(workingDir, dirPath),
);
const pattern =
relativeDirPath === "" || relativeDirPath === "."
? "**"
: `${relativeDirPath}/**`;
return {
recommendedRule: `Edit(${pattern})`,
@@ -651,13 +723,13 @@ function analyzeSearchApproval(
searchPath: string,
workingDir: string,
): ApprovalContext {
const absolutePath = resolve(workingDir, searchPath);
const absolutePath = resolvePathForContext(workingDir, searchPath);
if (!absolutePath.startsWith(workingDir)) {
const displayPath = absolutePath.replace(require("node:os").homedir(), "~");
if (!isPathWithinDirectory(absolutePath, workingDir)) {
const displayPath = formatDisplayPath(absolutePath);
return {
recommendedRule: `${toolName}(/${absolutePath}/**)`,
recommendedRule: `${toolName}(${formatAbsoluteRulePath(absolutePath)}/**)`,
ruleDescription: `searching in ${displayPath}/`,
approveAlwaysText: `Yes, allow searching in ${displayPath}/ in this project`,
defaultScope: "project",

View File

@@ -417,6 +417,60 @@ test("Grep outside working directory suggests directory pattern", () => {
expect(context.approveAlwaysText).toContain("/Users/test/docs/");
});
test("Read outside Windows working directory emits canonical Windows absolute rule", () => {
const context = analyzeApprovalContext(
"Read",
{ file_path: "C:\\Users\\Test\\docs\\api.md" },
"C:\\Users\\Test\\project",
);
expect(context.recommendedRule).toBe("Read(C:/Users/Test/docs/**)");
expect(context.approveAlwaysText).toContain("C:/Users/Test/docs/");
});
test("Edit outside Windows working directory emits canonical Windows absolute rule", () => {
const context = analyzeApprovalContext(
"Edit",
{ file_path: "C:\\Users\\Test\\docs\\note.md" },
"C:\\Users\\Test\\project",
);
expect(context.recommendedRule).toBe("Edit(C:/Users/Test/docs/**)");
expect(context.approveAlwaysText).toContain("C:/Users/Test/docs/");
});
test("Read inside Windows working directory handles drive-letter case differences", () => {
const context = analyzeApprovalContext(
"Read",
{ file_path: "c:\\users\\test\\project\\src\\index.ts" },
"C:\\Users\\Test\\project",
);
expect(context.recommendedRule).toBe("Read(src/**)");
});
test("Glob outside Windows working directory emits canonical Windows absolute rule", () => {
const context = analyzeApprovalContext(
"Glob",
{ path: "C:\\Users\\Test\\docs" },
"C:\\Users\\Test\\project",
);
expect(context.recommendedRule).toBe("Glob(C:/Users/Test/docs/**)");
expect(context.approveAlwaysText).toContain("C:/Users/Test/docs/");
});
test("Grep outside Windows working directory emits canonical Windows absolute rule", () => {
const context = analyzeApprovalContext(
"Grep",
{ path: "C:\\Users\\Test\\docs" },
"C:\\Users\\Test\\project",
);
expect(context.recommendedRule).toBe("Grep(C:/Users/Test/docs/**)");
expect(context.approveAlwaysText).toContain("C:/Users/Test/docs/");
});
// ============================================================================
// WebFetch Analysis Tests
// ============================================================================