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; [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 { 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 { // 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 { 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) } }