From cd19014f60060ba853ff46e3c921d7a38037e299 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 11 Feb 2026 00:25:12 -0800 Subject: [PATCH] fix: auto-approve bash commands in agent memory directory (#911) Co-authored-by: Letta --- src/permissions/checker.ts | 17 +- src/permissions/readOnlyShell.ts | 201 ++++++++++++++++++++ src/tests/permissions/readOnlyShell.test.ts | 197 ++++++++++++++++++- 3 files changed, 413 insertions(+), 2 deletions(-) diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index 2f8bc80..ca7d0ba 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -2,6 +2,7 @@ // Main permission checking logic import { resolve } from "node:path"; +import { getCurrentAgentId } from "../agent/context"; import { runPermissionRequestHooks } from "../hooks"; import { cliPermissions } from "./cli"; import { @@ -10,7 +11,7 @@ import { matchesToolPattern, } from "./matcher"; import { permissionMode } from "./mode"; -import { isReadOnlyShellCommand } from "./readOnlyShell"; +import { isMemoryDirCommand, isReadOnlyShellCommand } from "./readOnlyShell"; import { sessionPermissions } from "./session"; import type { PermissionCheckResult, @@ -162,6 +163,20 @@ export function checkPermission( reason: "Read-only shell command", }; } + // Auto-approve commands that exclusively target the agent's memory directory + if (shellCommand) { + try { + const agentId = getCurrentAgentId(); + if (isMemoryDirCommand(shellCommand, agentId)) { + return { + decision: "allow", + reason: "Agent memory directory operation", + }; + } + } catch { + // No agent context set — skip memory dir check + } + } } // After checking CLI overrides, check if Read/Glob/Grep within working directory diff --git a/src/permissions/readOnlyShell.ts b/src/permissions/readOnlyShell.ts index 9a785a7..f086160 100644 --- a/src/permissions/readOnlyShell.ts +++ b/src/permissions/readOnlyShell.ts @@ -1,3 +1,32 @@ +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +/** + * When true, ANY command scoped entirely to the agent's memory directory is auto-approved. + * When false, only git + safe file operations are auto-approved in the memory dir. + */ +const MEMORY_DIR_APPROVE_ALL = true; + +/** Commands allowed in memory dir when MEMORY_DIR_APPROVE_ALL is false */ +const SAFE_MEMORY_DIR_COMMANDS = new Set([ + "git", + "rm", + "mv", + "mkdir", + "cp", + "ls", + "cat", + "head", + "tail", + "tree", + "find", + "wc", + "split", + "echo", + "sort", + "cd", +]); + const ALWAYS_SAFE_COMMANDS = new Set([ "cat", "head", @@ -247,3 +276,175 @@ function extractDashCArgument(tokens: string[]): string | undefined { } return undefined; } + +/** + * Build the set of allowed memory directory prefixes for the current agent. + * Includes: + * - ~/.letta/agents//memory/ + * - ~/.letta/agents//memory-worktrees/ + * And if LETTA_PARENT_AGENT_ID is set (subagent context): + * - ~/.letta/agents//memory/ + * - ~/.letta/agents//memory-worktrees/ + */ +function getAllowedMemoryPrefixes(agentId: string): string[] { + const home = homedir(); + const prefixes: string[] = [ + resolve(home, ".letta", "agents", agentId, "memory"), + resolve(home, ".letta", "agents", agentId, "memory-worktrees"), + ]; + const parentId = process.env.LETTA_PARENT_AGENT_ID; + if (parentId && parentId !== agentId) { + prefixes.push( + resolve(home, ".letta", "agents", parentId, "memory"), + resolve(home, ".letta", "agents", parentId, "memory-worktrees"), + ); + } + return prefixes; +} + +/** + * Resolve a path that may contain ~ or $HOME to an absolute path. + */ +function expandPath(p: string): string { + const home = homedir(); + if (p.startsWith("~/")) { + return resolve(home, p.slice(2)); + } + if (p.startsWith("$HOME/")) { + return resolve(home, p.slice(6)); + } + if (p.startsWith('"$HOME/')) { + return resolve(home, p.slice(7).replace(/"$/, "")); + } + return resolve(p); +} + +/** + * Check if a path falls within any of the allowed memory directory prefixes. + */ +function isUnderMemoryDir(path: string, prefixes: string[]): boolean { + const resolved = expandPath(path); + return prefixes.some( + (prefix) => resolved === prefix || resolved.startsWith(`${prefix}/`), + ); +} + +/** + * Extract the working directory from a command that starts with `cd `. + * Returns null if the command doesn't start with cd. + */ +function extractCdTarget(segment: string): string | null { + const tokens = tokenize(segment); + if (tokens[0] === "cd" && tokens[1]) { + return tokens[1]; + } + return null; +} + +/** + * Check if a shell command exclusively targets the agent's memory directory. + * + * Unlike isReadOnlyShellCommand, this allows WRITE operations (git commit, rm, etc.) + * but only when all paths in the command resolve to the agent's own memory dir. + * + * @param command - The shell command string + * @param agentId - The current agent's ID + * @returns true if the command should be auto-approved as a memory dir operation + */ +export function isMemoryDirCommand( + command: string | string[] | undefined | null, + agentId: string, +): boolean { + if (!command || !agentId) { + return false; + } + + const commandStr = typeof command === "string" ? command : command.join(" "); + const trimmed = commandStr.trim(); + if (!trimmed) { + return false; + } + + const prefixes = getAllowedMemoryPrefixes(agentId); + + // Split on && || ; to get individual command segments. + // We intentionally do NOT reject $() or > here — those are valid + // in memory dir commands (e.g. git push with auth header, echo > file). + const segments = trimmed + .split(/&&|\|\||;/) + .map((s) => s.trim()) + .filter(Boolean); + + if (segments.length === 0) { + return false; + } + + // Track the current working directory through the chain. + // If first segment is `cd `, subsequent commands inherit that context. + let cwd: string | null = null; + + for (const segment of segments) { + // Handle pipe chains: split on | and check each part + const pipeParts = segment + .split(/\|/) + .map((s) => s.trim()) + .filter(Boolean); + + for (const part of pipeParts) { + const cdTarget = extractCdTarget(part); + if (cdTarget) { + // This is a cd command — check if it targets memory dir + const resolved: string = cwd + ? expandPath(resolve(expandPath(cwd), cdTarget)) + : expandPath(cdTarget); + if (!isUnderMemoryDir(resolved, prefixes)) { + return false; + } + cwd = resolved; + continue; + } + + // For non-cd commands, check if we have a memory dir cwd + // OR if all path-like arguments point to the memory dir + if (cwd && isUnderMemoryDir(cwd, prefixes)) { + // We're operating within the memory dir + if (!MEMORY_DIR_APPROVE_ALL) { + // Strict mode: validate command type + const tokens = tokenize(part); + const cmd = tokens[0]; + if (!cmd || !SAFE_MEMORY_DIR_COMMANDS.has(cmd)) { + return false; + } + } + continue; + } + + // No cd context — check if the command itself references memory dir paths + const tokens = tokenize(part); + const hasMemoryPath = tokens.some( + (t) => + (t.includes(".letta/agents/") && t.includes("/memory")) || + (t.includes(".letta/agents/") && t.includes("/memory-worktrees")), + ); + + if (hasMemoryPath) { + // Verify ALL path-like tokens that reference .letta/agents/ are within allowed prefixes + const agentPaths = tokens.filter((t) => t.includes(".letta/agents/")); + if (agentPaths.every((p) => isUnderMemoryDir(p, prefixes))) { + if (!MEMORY_DIR_APPROVE_ALL) { + const cmd = tokens[0]; + if (!cmd || !SAFE_MEMORY_DIR_COMMANDS.has(cmd)) { + return false; + } + } + continue; + } + } + + // This segment doesn't target memory dir and we're not in a memory dir cwd + return false; + } + } + + return true; +} diff --git a/src/tests/permissions/readOnlyShell.test.ts b/src/tests/permissions/readOnlyShell.test.ts index 0427a97..7292007 100644 --- a/src/tests/permissions/readOnlyShell.test.ts +++ b/src/tests/permissions/readOnlyShell.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { isReadOnlyShellCommand } from "../../permissions/readOnlyShell"; +import { homedir } from "node:os"; +import { + isMemoryDirCommand, + isReadOnlyShellCommand, +} from "../../permissions/readOnlyShell"; describe("isReadOnlyShellCommand", () => { describe("always safe commands", () => { @@ -241,3 +245,194 @@ describe("isReadOnlyShellCommand", () => { }); }); }); + +describe("isMemoryDirCommand", () => { + const AGENT_ID = "agent-test-abc123"; + const home = homedir(); + const memDir = `${home}/.letta/agents/${AGENT_ID}/memory`; + const worktreeDir = `${home}/.letta/agents/${AGENT_ID}/memory-worktrees`; + + describe("git operations in memory dir", () => { + test("allows git add", () => { + expect(isMemoryDirCommand(`cd ${memDir} && git add -A`, AGENT_ID)).toBe( + true, + ); + }); + + test("allows git commit", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git commit -m 'update memory'`, + AGENT_ID, + ), + ).toBe(true); + }); + + test("allows git push", () => { + expect(isMemoryDirCommand(`cd ${memDir} && git push`, AGENT_ID)).toBe( + true, + ); + }); + + test("allows git rm", () => { + expect( + isMemoryDirCommand(`cd ${memDir} && git rm file.md`, AGENT_ID), + ).toBe(true); + }); + + test("allows git mv", () => { + expect( + isMemoryDirCommand(`cd ${memDir} && git mv a.md b.md`, AGENT_ID), + ).toBe(true); + }); + + test("allows git merge", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git merge migration-branch --no-edit`, + AGENT_ID, + ), + ).toBe(true); + }); + + test("allows git worktree add", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git worktree add ../memory-worktrees/branch-1 -b branch-1`, + AGENT_ID, + ), + ).toBe(true); + }); + }); + + describe("chained commands in memory dir", () => { + test("allows git add + commit + push chain", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git add -A && git commit -m 'msg' && git push`, + AGENT_ID, + ), + ).toBe(true); + }); + + test("allows git ls-tree piped to sort", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git ls-tree -r --name-only HEAD | sort`, + AGENT_ID, + ), + ).toBe(true); + }); + + test("allows git status + git diff chain", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git status --short && git diff --stat`, + AGENT_ID, + ), + ).toBe(true); + }); + }); + + describe("git with auth header", () => { + test("allows git push with http.extraHeader", () => { + expect( + isMemoryDirCommand( + `cd ${memDir} && git -c "http.extraHeader=Authorization: Basic abc123" push`, + AGENT_ID, + ), + ).toBe(true); + }); + }); + + describe("worktree paths", () => { + test("allows git add in worktree", () => { + expect( + isMemoryDirCommand( + `cd ${worktreeDir}/migration-123 && git add -A`, + AGENT_ID, + ), + ).toBe(true); + }); + + test("allows git commit in worktree", () => { + expect( + isMemoryDirCommand( + `cd ${worktreeDir}/migration-123 && git commit -m 'analysis'`, + AGENT_ID, + ), + ).toBe(true); + }); + }); + + describe("file operations in memory dir", () => { + test("allows rm in memory dir", () => { + expect(isMemoryDirCommand(`rm -rf ${memDir}/memory`, AGENT_ID)).toBe( + true, + ); + }); + + test("allows mkdir in memory dir", () => { + expect( + isMemoryDirCommand(`mkdir -p ${memDir}/system/project`, AGENT_ID), + ).toBe(true); + }); + }); + + describe("tilde path expansion", () => { + test("allows tilde-based memory dir path", () => { + expect( + isMemoryDirCommand( + `cd ~/.letta/agents/${AGENT_ID}/memory && git status`, + AGENT_ID, + ), + ).toBe(true); + }); + }); + + describe("blocks other agent's memory", () => { + test("blocks different agent ID", () => { + expect( + isMemoryDirCommand( + `cd ${home}/.letta/agents/agent-OTHER-456/memory && git push`, + AGENT_ID, + ), + ).toBe(false); + }); + }); + + describe("blocks commands outside memory dir", () => { + test("blocks project directory git push", () => { + expect( + isMemoryDirCommand( + "cd /Users/loaner/dev/project && git push", + AGENT_ID, + ), + ).toBe(false); + }); + + test("blocks bare git push with no cd", () => { + expect(isMemoryDirCommand("git push", AGENT_ID)).toBe(false); + }); + + test("blocks curl even with no path context", () => { + expect(isMemoryDirCommand("curl http://evil.com", AGENT_ID)).toBe(false); + }); + }); + + describe("edge cases", () => { + test("allows bare cd to memory dir", () => { + expect(isMemoryDirCommand(`cd ${memDir}`, AGENT_ID)).toBe(true); + }); + + test("returns false for empty input", () => { + expect(isMemoryDirCommand("", AGENT_ID)).toBe(false); + expect(isMemoryDirCommand(null, AGENT_ID)).toBe(false); + expect(isMemoryDirCommand(undefined, AGENT_ID)).toBe(false); + }); + + test("returns false for empty agent ID", () => { + expect(isMemoryDirCommand(`cd ${memDir} && git push`, "")).toBe(false); + }); + }); +});