Files
letta-code/src/permissions/loader.ts
2026-02-22 23:55:37 -08:00

215 lines
5.8 KiB
TypeScript

import { exists, readFile, writeFile } from "../utils/fs.js";
// src/permissions/loader.ts
// Load and merge permission settings from hierarchical sources
import { homedir } from "node:os";
import { join } from "node:path";
import {
normalizePermissionRule,
permissionRulesEquivalent,
} from "./rule-normalization";
import type { PermissionRules } from "./types";
type SettingsFile = {
permissions?: Record<string, string[]>;
[key: string]: unknown;
};
type UserSettingsPathsOptions = {
homeDir?: string;
xdgConfigHome?: string;
};
export function getUserSettingsPaths(options: UserSettingsPathsOptions = {}): {
canonical: string;
legacy: string;
} {
const homeDir = options.homeDir || homedir();
const xdgConfigHome =
options.xdgConfigHome ||
process.env.XDG_CONFIG_HOME ||
join(homeDir, ".config");
return {
canonical: join(homeDir, ".letta", "settings.json"),
legacy: join(xdgConfigHome, "letta", "settings.json"),
};
}
/**
* Load permissions from all settings files and merge them hierarchically.
*
* Precedence (highest to lowest):
* 1. Local project settings (.letta/settings.local.json)
* 2. Project settings (.letta/settings.json)
* 3. User settings (~/.letta/settings.json)
* 4. Legacy user settings (~/.config/letta/settings.json)
*
* Rules are merged by concatenating arrays (more specific settings add to broader ones)
*/
export async function loadPermissions(
workingDirectory: string = process.cwd(),
): Promise<PermissionRules> {
const merged: PermissionRules = {
allow: [],
deny: [],
ask: [],
additionalDirectories: [],
};
// Load in reverse precedence order (lowest to highest)
const { canonical: userSettingsPath, legacy: legacyUserSettingsPath } =
getUserSettingsPaths();
const sources = [
legacyUserSettingsPath, // User legacy
userSettingsPath, // User (canonical)
join(workingDirectory, ".letta", "settings.json"), // Project
join(workingDirectory, ".letta", "settings.local.json"), // Local
];
for (const settingsPath of sources) {
try {
if (exists(settingsPath)) {
const content = await readFile(settingsPath);
const settings = JSON.parse(content) as SettingsFile;
if (settings.permissions) {
mergePermissions(merged, settings.permissions as PermissionRules);
}
}
} catch (_error) {
// Silently skip files that can't be parsed
// (user might have invalid JSON)
}
}
return merged;
}
/**
* Merge permission rules by concatenating arrays
*/
function mergePermissions(
target: PermissionRules,
source: PermissionRules,
): void {
if (source.allow) {
target.allow = mergeRuleList(target.allow, source.allow);
}
if (source.deny) {
target.deny = mergeRuleList(target.deny, source.deny);
}
if (source.ask) {
target.ask = mergeRuleList(target.ask, source.ask);
}
if (source.additionalDirectories) {
target.additionalDirectories = [
...(target.additionalDirectories || []),
...source.additionalDirectories,
];
}
}
function mergeRuleList(
existing: string[] | undefined,
incoming: string[],
): string[] {
const merged = [...(existing || [])];
for (const rule of incoming) {
if (!merged.some((current) => permissionRulesEquivalent(current, rule))) {
merged.push(rule);
}
}
return merged;
}
/**
* Save a permission rule to a specific scope
*/
export async function savePermissionRule(
rule: string,
ruleType: "allow" | "deny" | "ask",
scope: "project" | "local" | "user",
workingDirectory: string = process.cwd(),
): Promise<void> {
// Determine settings file path based on scope
let settingsPath: string;
switch (scope) {
case "user":
settingsPath = getUserSettingsPaths().canonical;
break;
case "project":
settingsPath = join(workingDirectory, ".letta", "settings.json");
break;
case "local":
settingsPath = join(workingDirectory, ".letta", "settings.local.json");
break;
}
// Load existing settings
let settings: SettingsFile = {};
try {
if (exists(settingsPath)) {
const content = await readFile(settingsPath);
settings = JSON.parse(content) as SettingsFile;
}
} catch (_error) {
// Start with empty settings if file doesn't exist or is invalid
}
// Initialize permissions if needed
if (!settings.permissions) {
settings.permissions = {};
}
if (!settings.permissions[ruleType]) {
settings.permissions[ruleType] = [];
}
const normalizedRule = normalizePermissionRule(rule);
// Add rule if not already present (canonicalized comparison for alias/path variants)
if (
!settings.permissions[ruleType].some((existingRule) =>
permissionRulesEquivalent(existingRule, normalizedRule),
)
) {
settings.permissions[ruleType].push(normalizedRule);
}
// Save settings
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
// If saving to .letta/settings.local.json, ensure it's gitignored
if (scope === "local") {
await ensureLocalSettingsIgnored(workingDirectory);
}
}
/**
* Ensure .letta/settings.local.json is in .gitignore
*/
async function ensureLocalSettingsIgnored(
workingDirectory: string,
): Promise<void> {
const gitignorePath = join(workingDirectory, ".gitignore");
const pattern = ".letta/settings.local.json";
try {
let content = "";
if (exists(gitignorePath)) {
content = await readFile(gitignorePath);
}
// Check if pattern already exists
if (!content.includes(pattern)) {
// Add pattern to gitignore
const newContent = `${
content + (content.endsWith("\n") ? "" : "\n") + pattern
}\n`;
await writeFile(gitignorePath, newContent);
}
} catch (_error) {
// Silently fail if we can't update .gitignore
// (might not be a git repo)
}
}