fix: auto-approve bash commands in agent memory directory (#911)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-11 00:25:12 -08:00
committed by GitHub
parent b95cd9a02a
commit cd19014f60
3 changed files with 413 additions and 2 deletions

View File

@@ -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

View File

@@ -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/<agentId>/memory/
* - ~/.letta/agents/<agentId>/memory-worktrees/
* And if LETTA_PARENT_AGENT_ID is set (subagent context):
* - ~/.letta/agents/<parentAgentId>/memory/
* - ~/.letta/agents/<parentAgentId>/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 <path>`.
* 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 <memory-dir>`, 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;
}

View File

@@ -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);
});
});
});