257 lines
6.8 KiB
TypeScript
257 lines
6.8 KiB
TypeScript
// src/permissions/mode.ts
|
|
// Permission mode management (default, acceptEdits, plan, bypassPermissions)
|
|
|
|
import { isReadOnlyShellCommand } from "./readOnlyShell";
|
|
|
|
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");
|
|
|
|
type GlobalWithMode = typeof globalThis & {
|
|
[MODE_KEY]: PermissionMode;
|
|
[PLAN_FILE_KEY]: string | 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;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
this.currentMode = mode;
|
|
// Clear plan file path when exiting plan mode
|
|
if (mode !== "plan") {
|
|
setGlobalPlanFilePath(null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* Returns null if mode doesn't apply to this tool
|
|
*/
|
|
checkModeOverride(
|
|
toolName: string,
|
|
toolArgs?: Record<string, unknown>,
|
|
): "allow" | "deny" | null {
|
|
switch (this.currentMode) {
|
|
case "bypassPermissions":
|
|
// Auto-allow everything (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",
|
|
"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",
|
|
// 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",
|
|
// Codex toolset (PascalCase)
|
|
"ReadFile",
|
|
"ListDir",
|
|
"GrepFiles",
|
|
"UpdatePlan",
|
|
// Gemini toolset (snake_case)
|
|
"list_directory",
|
|
"search_file_content",
|
|
"write_todos",
|
|
"read_many_files",
|
|
// Gemini toolset (PascalCase)
|
|
"ListDirectory",
|
|
"SearchFileContent",
|
|
"WriteTodos",
|
|
"ReadManyFiles",
|
|
];
|
|
const writeTools = [
|
|
// Anthropic toolset (PascalCase only)
|
|
"Write",
|
|
"Edit",
|
|
"MultiEdit",
|
|
// Codex toolset (snake_case and PascalCase)
|
|
"apply_patch",
|
|
"ApplyPatch",
|
|
// Gemini toolset (snake_case and PascalCase)
|
|
"write_file_gemini",
|
|
"WriteFileGemini",
|
|
"replace",
|
|
"Replace",
|
|
];
|
|
|
|
if (allowedInPlan.includes(toolName)) {
|
|
return "allow";
|
|
}
|
|
|
|
// Special case: allow writes to the plan file only
|
|
if (writeTools.includes(toolName)) {
|
|
const planFilePath = this.getPlanFilePath();
|
|
let targetPath =
|
|
(toolArgs?.file_path as string) || (toolArgs?.path as string);
|
|
|
|
// ApplyPatch/apply_patch: extract file path from patch input
|
|
if (
|
|
(toolName === "ApplyPatch" || toolName === "apply_patch") &&
|
|
toolArgs?.input
|
|
) {
|
|
const input = toolArgs.input as string;
|
|
// Extract path from "*** Add File: path", "*** Update File: path", or "*** Delete File: path"
|
|
const match = input.match(
|
|
/\*\*\* (?:Add|Update|Delete) File:\s*(.+)/,
|
|
);
|
|
if (match?.[1]) {
|
|
targetPath = match[1].trim();
|
|
}
|
|
}
|
|
|
|
if (planFilePath && targetPath && targetPath === planFilePath) {
|
|
return "allow";
|
|
}
|
|
}
|
|
|
|
// Allow Task tool with read-only subagent types
|
|
// These subagents only have access to read-only tools (Glob, Grep, Read, LS, BashOutput)
|
|
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 read-only shell commands (ls, git status, git log, etc.)
|
|
const shellTools = [
|
|
"Bash",
|
|
"shell",
|
|
"Shell",
|
|
"shell_command",
|
|
"ShellCommand",
|
|
"run_shell_command",
|
|
"RunShellCommand",
|
|
];
|
|
if (shellTools.includes(toolName)) {
|
|
const command = toolArgs?.command as string | string[] | undefined;
|
|
if (command && isReadOnlyShellCommand(command)) {
|
|
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";
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
export const permissionMode = new PermissionModeManager();
|