Files
letta-code/src/permissions/mode.ts
2026-03-23 17:53:48 -07:00

483 lines
14 KiB
TypeScript

// src/permissions/mode.ts
// Permission mode management (default, acceptEdits, plan, bypassPermissions)
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { isReadOnlyShellCommand } from "./readOnlyShell";
import { unwrapShellLauncherCommand } from "./shell-command-normalization";
export type PermissionMode =
| "default"
| "acceptEdits"
| "plan"
| "bypassPermissions";
// Use globalThis to ensure singleton across bundle
// This prevents Bun's bundler from creating duplicate instances of the mode manager
const MODE_KEY = Symbol.for("@letta/permissionMode");
const PLAN_FILE_KEY = Symbol.for("@letta/planFilePath");
const MODE_BEFORE_PLAN_KEY = Symbol.for("@letta/permissionModeBeforePlan");
type GlobalWithMode = typeof globalThis & {
[MODE_KEY]: PermissionMode;
[PLAN_FILE_KEY]: string | null;
[MODE_BEFORE_PLAN_KEY]?: PermissionMode | null;
};
function getGlobalMode(): PermissionMode {
const global = globalThis as GlobalWithMode;
if (!global[MODE_KEY]) {
global[MODE_KEY] = "default";
}
return global[MODE_KEY];
}
function setGlobalMode(value: PermissionMode): void {
const global = globalThis as GlobalWithMode;
global[MODE_KEY] = value;
}
function getGlobalPlanFilePath(): string | null {
const global = globalThis as GlobalWithMode;
return global[PLAN_FILE_KEY] || null;
}
function setGlobalPlanFilePath(value: string | null): void {
const global = globalThis as GlobalWithMode;
global[PLAN_FILE_KEY] = value;
}
function getGlobalModeBeforePlan(): PermissionMode | null {
const global = globalThis as GlobalWithMode;
return global[MODE_BEFORE_PLAN_KEY] ?? null;
}
function setGlobalModeBeforePlan(value: PermissionMode | null): void {
const global = globalThis as GlobalWithMode;
global[MODE_BEFORE_PLAN_KEY] = value;
}
function resolvePlanTargetPath(
targetPath: string,
workingDirectory: string,
): string | null {
const trimmedPath = targetPath.trim();
if (!trimmedPath) return null;
if (trimmedPath.startsWith("~/")) {
return resolve(homedir(), trimmedPath.slice(2));
}
if (isAbsolute(trimmedPath)) {
return resolve(trimmedPath);
}
return resolve(workingDirectory, trimmedPath);
}
function isPathInPlansDir(path: string, plansDir: string): boolean {
if (!path.endsWith(".md")) return false;
const rel = relative(plansDir, path);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}
function extractApplyPatchPaths(input: string): string[] {
const paths: string[] = [];
const fileDirectivePattern = /\*\*\* (?:Add|Update|Delete) File:\s*(.+)/g;
const moveDirectivePattern = /\*\*\* Move to:\s*(.+)/g;
for (const match of input.matchAll(fileDirectivePattern)) {
const matchPath = match[1]?.trim();
if (matchPath) paths.push(matchPath);
}
for (const match of input.matchAll(moveDirectivePattern)) {
const matchPath = match[1]?.trim();
if (matchPath) paths.push(matchPath);
}
return paths;
}
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim();
if (trimmed.length < 2) {
return trimmed;
}
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' || first === "'") && last === first) {
return trimmed.slice(1, -1);
}
return trimmed;
}
/**
* Detect commands that are exclusively a heredoc write to a file:
* cat > /path/to/file <<'EOF'\n...\nEOF
* cat <<'EOF' > /path/to/file\n...\nEOF
*
* Returns the target file path when recognized, otherwise null.
*/
function extractPlanFileWritePathFromShellCommand(
command: string | string[] | undefined,
): string | null {
if (!command) {
return null;
}
const commandString =
typeof command === "string" ? command : (command.join(" ") ?? "");
const normalizedCommand = unwrapShellLauncherCommand(commandString).trim();
if (!normalizedCommand) {
return null;
}
const lines = normalizedCommand.split(/\r?\n/);
const firstLine = lines[0]?.trim() ?? "";
if (!firstLine) {
return null;
}
const firstLineMatch = firstLine.match(
/^cat\s+(?:>\s*(?<path1>"[^"]+"|'[^']+'|\S+)\s+<<-?\s*(?<delim1>"[^"]+"|'[^']+'|\S+)|<<-?\s*(?<delim2>"[^"]+"|'[^']+'|\S+)\s+>\s*(?<path2>"[^"]+"|'[^']+'|\S+))\s*$/,
);
if (!firstLineMatch?.groups) {
return null;
}
const rawPath = firstLineMatch.groups.path1 || firstLineMatch.groups.path2;
const rawDelim = firstLineMatch.groups.delim1 || firstLineMatch.groups.delim2;
if (!rawPath || !rawDelim) {
return null;
}
const delimiter = stripMatchingQuotes(rawDelim);
if (!delimiter) {
return null;
}
// Find heredoc terminator line and ensure nothing non-whitespace follows it.
let terminatorLine = -1;
for (let i = 1; i < lines.length; i += 1) {
if ((lines[i] ?? "") === delimiter) {
terminatorLine = i;
break;
}
}
if (terminatorLine === -1) {
return null;
}
for (let i = terminatorLine + 1; i < lines.length; i += 1) {
if ((lines[i] ?? "").trim().length > 0) {
return null;
}
}
return stripMatchingQuotes(rawPath);
}
/**
* Permission mode state for the current session.
* Set via CLI --permission-mode flag or settings.json defaultMode.
*/
class PermissionModeManager {
private get currentMode(): PermissionMode {
return getGlobalMode();
}
private set currentMode(value: PermissionMode) {
setGlobalMode(value);
}
/**
* Set the permission mode for this session
*/
setMode(mode: PermissionMode): void {
const prevMode = this.currentMode;
// If we are entering plan mode, remember what mode we were previously in so
// ExitPlanMode can restore it (e.g. YOLO).
if (mode === "plan" && prevMode !== "plan") {
setGlobalModeBeforePlan(prevMode);
}
this.currentMode = mode;
// Clear plan file path when exiting plan mode
if (mode !== "plan") {
setGlobalPlanFilePath(null);
}
// Once we leave plan mode, the remembered mode has been consumed.
if (prevMode === "plan" && mode !== "plan") {
setGlobalModeBeforePlan(null);
}
}
/**
* Get the permission mode that was active before entering plan mode.
* Used to restore the user's previous setting (e.g., bypassPermissions).
*/
getModeBeforePlan(): PermissionMode | null {
return getGlobalModeBeforePlan();
}
/**
* Get the current permission mode
*/
getMode(): PermissionMode {
return this.currentMode;
}
/**
* Set the plan file path (only relevant when in plan mode)
*/
setPlanFilePath(path: string | null): void {
setGlobalPlanFilePath(path);
}
/**
* Get the current plan file path
*/
getPlanFilePath(): string | null {
return getGlobalPlanFilePath();
}
/**
* Check if a tool should be auto-allowed based on current mode.
* Accepts explicit `mode` and `planFilePath` overrides so callers with a
* scoped PermissionModeState (listener/remote mode) can bypass the global
* singleton without requiring a temporary mutation of global state.
* Returns null if mode doesn't apply to this tool.
*/
checkModeOverride(
toolName: string,
toolArgs?: Record<string, unknown>,
workingDirectory: string = process.cwd(),
modeOverride?: PermissionMode,
planFilePathOverride?: string | null,
): "allow" | "deny" | null {
const effectiveMode = modeOverride ?? this.currentMode;
const _effectivePlanFilePath =
planFilePathOverride !== undefined
? planFilePathOverride
: this.getPlanFilePath();
switch (effectiveMode) {
case "bypassPermissions":
// ExitPlanMode always requires human approval, even in yolo mode
if (toolName === "ExitPlanMode" || toolName === "exit_plan_mode") {
return null;
}
// Auto-allow everything else (except explicit deny rules checked earlier)
return "allow";
case "acceptEdits":
// Auto-allow edit tools: Write, Edit, MultiEdit, NotebookEdit, apply_patch, replace, write_file
if (
[
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
"apply_patch",
"memory_apply_patch",
"replace",
"write_file",
].includes(toolName)
) {
return "allow";
}
return null;
case "plan": {
// Read-only mode: allow analysis tools, deny everything else
const allowedInPlan = [
// Anthropic toolset
"Read",
"Glob",
"Grep",
"NotebookRead",
"TodoWrite",
// Image tools (read-only)
"ViewImage",
"view_image",
// Plan mode tools (must allow exit!)
"ExitPlanMode",
"exit_plan_mode",
"AskUserQuestion",
"ask_user_question",
// Codex toolset (snake_case)
"read_file",
"list_dir",
"grep_files",
"update_plan",
"task_output",
// Codex toolset (PascalCase)
"ReadFile",
"ListDir",
"GrepFiles",
"UpdatePlan",
"TaskOutput",
// Gemini toolset (snake_case)
"read_file_gemini",
"glob_gemini",
"list_directory",
"search_file_content",
"write_todos",
"read_many_files",
// Gemini toolset (PascalCase)
"ReadFileGemini",
"GlobGemini",
"ListDirectory",
"SearchFileContent",
"WriteTodos",
"ReadManyFiles",
];
const writeTools = [
// Anthropic toolset (PascalCase only)
"Write",
"Edit",
"MultiEdit",
// Codex toolset (snake_case and PascalCase)
"apply_patch",
"ApplyPatch",
"memory_apply_patch",
// Gemini toolset (snake_case and PascalCase)
"write_file_gemini",
"WriteFileGemini",
"replace",
"Replace",
];
if (allowedInPlan.includes(toolName)) {
return "allow";
}
// Special case: allow writes to any plan file in ~/.letta/plans/
// NOTE: We allow writing to ANY plan file, not just the assigned one.
// This is intentional - it allows the agent to "resume" planning after
// plan mode was exited/reset by simply writing to any plan file.
if (writeTools.includes(toolName)) {
const plansDir = join(homedir(), ".letta", "plans");
const targetPath =
(toolArgs?.file_path as string) || (toolArgs?.path as string);
let candidatePaths: string[] = [];
// ApplyPatch/apply_patch: extract all file directives.
if (
(toolName === "ApplyPatch" ||
toolName === "apply_patch" ||
toolName === "memory_apply_patch") &&
toolArgs?.input
) {
const input = toolArgs.input as string;
candidatePaths = extractApplyPatchPaths(input);
} else if (typeof targetPath === "string") {
candidatePaths = [targetPath];
}
// Allow only if every target resolves to a .md file within ~/.letta/plans.
if (
candidatePaths.length > 0 &&
candidatePaths.every((path) => {
const resolvedPath = resolvePlanTargetPath(
path,
workingDirectory,
);
return resolvedPath
? isPathInPlansDir(resolvedPath, plansDir)
: false;
})
) {
return "allow";
}
}
// Allow Task tool with read-only subagent types
// These subagents only have access to read-only tools (Glob, Grep, Read, LS, TaskOutput)
const readOnlySubagentTypes = new Set([
"explore",
"Explore",
"plan",
"Plan",
"recall",
"Recall",
]);
if (toolName === "Task" || toolName === "task") {
const subagentType = toolArgs?.subagent_type as string | undefined;
if (subagentType && readOnlySubagentTypes.has(subagentType)) {
return "allow";
}
}
// Allow Skill tool — skills are read-only (load instructions, not modify files)
if (toolName === "Skill" || toolName === "skill") {
return "allow";
}
// Allow read-only shell commands (ls, git status, git log, etc.)
const shellTools = [
"Bash",
"shell",
"Shell",
"shell_command",
"ShellCommand",
"run_shell_command",
"RunShellCommand",
"run_shell_command_gemini",
"RunShellCommandGemini",
];
if (shellTools.includes(toolName)) {
const command = toolArgs?.command as string | string[] | undefined;
if (
command &&
isReadOnlyShellCommand(command, { allowExternalPaths: true })
) {
return "allow";
}
// Special case: allow shell heredoc writes when they ONLY target
// a markdown file in ~/.letta/plans/.
const planWritePath =
extractPlanFileWritePathFromShellCommand(command);
if (planWritePath) {
const plansDir = join(homedir(), ".letta", "plans");
const resolvedPath = resolvePlanTargetPath(
planWritePath,
workingDirectory,
);
if (resolvedPath && isPathInPlansDir(resolvedPath, plansDir)) {
return "allow";
}
}
}
// Everything else denied in plan mode
return "deny";
}
case "default":
// No mode overrides, use normal permission flow
return null;
default:
return null;
}
}
/**
* Reset to default mode
*/
reset(): void {
this.currentMode = "default";
setGlobalPlanFilePath(null);
setGlobalModeBeforePlan(null);
}
}
// Singleton instance
export const permissionMode = new PermissionModeManager();