215 lines
5.8 KiB
TypeScript
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)
|
|
}
|
|
}
|