fix: auto-approve bash commands in agent memory directory (#911)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user