fix(permissions): harden shell auto-approval path checks (#972)

Co-authored-by: RinZ27 <222222878+RinZ27@users.noreply.github.com>
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-15 21:48:08 -08:00
committed by GitHub
parent d252afd15c
commit 72d43c8a43
4 changed files with 181 additions and 55 deletions

View File

@@ -346,7 +346,7 @@ test("plan mode - allows read-only Bash commands", () => {
// cd && git should be allowed (common CLI pattern)
const cdGitResult = checkPermission(
"Bash",
{ command: "cd /some/path && git status" },
{ command: "cd src && git status" },
permissions,
"/Users/test/project",
);
@@ -355,7 +355,7 @@ test("plan mode - allows read-only Bash commands", () => {
// cd && git show should be allowed
const cdGitShowResult = checkPermission(
"Bash",
{ command: "cd /some/path && git show abc123" },
{ command: "cd src && git show abc123" },
permissions,
"/Users/test/project",
);
@@ -373,7 +373,7 @@ test("plan mode - allows read-only Bash commands", () => {
// cd && dangerous command should still be denied
const cdDangerousResult = checkPermission(
"Bash",
{ command: "cd /some/path && npm install" },
{ command: "cd src && npm install" },
permissions,
"/Users/test/project",
);

View File

@@ -237,6 +237,10 @@ describe("isReadOnlyShellCommand", () => {
expect(isReadOnlyShellCommand(" ")).toBe(false);
});
test("allows relative cd chaining with read-only git", () => {
expect(isReadOnlyShellCommand("cd src && git status")).toBe(true);
});
test("blocks unknown commands", () => {
expect(isReadOnlyShellCommand("rm file")).toBe(false);
expect(isReadOnlyShellCommand("mv a b")).toBe(false);

View File

@@ -0,0 +1,46 @@
import { expect, test } from "bun:test";
import { homedir } from "node:os";
import { resolve } from "node:path";
import {
isMemoryDirCommand,
isReadOnlyShellCommand,
} from "../permissions/readOnlyShell";
test("FIX: isReadOnlyShellCommand should not auto-approve reading sensitive files", () => {
// These used to return true, now should return false
expect(isReadOnlyShellCommand("cat /etc/passwd")).toBe(false);
expect(isReadOnlyShellCommand("grep secret /etc/shadow")).toBe(false);
expect(isReadOnlyShellCommand("head -n 20 ../../../.ssh/id_rsa")).toBe(false);
expect(
isReadOnlyShellCommand("cat C:\\Windows\\System32\\drivers\\etc\\hosts"),
).toBe(false);
// Normal safe commands should still work
expect(isReadOnlyShellCommand("ls")).toBe(true);
expect(isReadOnlyShellCommand("cat README.md")).toBe(true);
// Ensure `cd` cannot be used to bypass path checks via later relative reads
expect(isReadOnlyShellCommand("cd / && cat etc/passwd")).toBe(false);
expect(isReadOnlyShellCommand("cd C:\\ && type Windows\\win.ini")).toBe(
false,
);
});
test("FIX: isMemoryDirCommand should not allow command injection via cd bypass", () => {
const agentId = "agent123";
const home = homedir();
const memoryDir = resolve(home, ".letta", "agents", agentId, "memory");
// This command starts with cd to memory dir, then tries to delete root
const dangerousCommand = `cd ${memoryDir} && rm -rf /`;
const dangerousWindowsCommand = `cd ${memoryDir} && type C:\\Windows\\win.ini`;
expect(isMemoryDirCommand(dangerousCommand, agentId)).toBe(false);
expect(isMemoryDirCommand(dangerousWindowsCommand, agentId)).toBe(false);
// Safe commands in memory dir should still work
expect(isMemoryDirCommand(`cd ${memoryDir} && ls`, agentId)).toBe(true);
expect(isMemoryDirCommand(`cd ${memoryDir} && git status`, agentId)).toBe(
true,
);
});