fix(plan): merge scoped path relaxation with quote-aware shell parsing (#1188)

This commit is contained in:
Charles Packer
2026-02-26 22:40:09 -08:00
committed by GitHub
parent 03de29f3d3
commit 34ec2aaa4e
4 changed files with 215 additions and 20 deletions

View File

@@ -483,6 +483,24 @@ test("plan mode - allows read-only Bash commands", () => {
);
expect(chainedResult.decision).toBe("allow");
// absolute path reads should be allowed in plan mode
const absolutePathResult = checkPermission(
"Bash",
{ command: "ls -la /Users/test/.letta/plans" },
permissions,
"/Users/test/project",
);
expect(absolutePathResult.decision).toBe("allow");
// traversal reads should be allowed in plan mode
const traversalResult = checkPermission(
"Bash",
{ command: "cat ../../README.md" },
permissions,
"/Users/test/project",
);
expect(traversalResult.decision).toBe("allow");
// cd && dangerous command should still be denied
const cdDangerousResult = checkPermission(
"Bash",
@@ -491,6 +509,35 @@ test("plan mode - allows read-only Bash commands", () => {
"/Users/test/project",
);
expect(cdDangerousResult.decision).toBe("deny");
// quoted pipes in regex patterns should be allowed
const quotedPipeResult = checkPermission(
"Bash",
{ command: 'rg -n "foo|bar|baz" src/permissions' },
permissions,
"/Users/test/project",
);
expect(quotedPipeResult.decision).toBe("allow");
});
test("plan mode - allows TaskOutput", () => {
permissionMode.setMode("plan");
const permissions: PermissionRules = {
allow: [],
deny: [],
ask: [],
};
const result = checkPermission(
"TaskOutput",
{ task_id: "task_123", block: false },
permissions,
"/Users/test/project",
);
expect(result.decision).toBe("allow");
expect(result.matchedRule).toBe("plan mode");
});
test("plan mode - denies WebFetch", () => {

View File

@@ -6,6 +6,33 @@ import {
} from "../../permissions/readOnlyShell";
describe("isReadOnlyShellCommand", () => {
describe("path restrictions", () => {
test("blocks external paths by default", () => {
expect(isReadOnlyShellCommand("cat /etc/passwd")).toBe(false);
expect(isReadOnlyShellCommand("head -n 20 ../../../.ssh/id_rsa")).toBe(
false,
);
});
test("allows external paths when explicitly enabled", () => {
expect(
isReadOnlyShellCommand("cat /etc/passwd", {
allowExternalPaths: true,
}),
).toBe(true);
expect(
isReadOnlyShellCommand("head -n 20 ../../../.ssh/id_rsa", {
allowExternalPaths: true,
}),
).toBe(true);
expect(
isReadOnlyShellCommand("cd / && cat etc/passwd", {
allowExternalPaths: true,
}),
).toBe(true);
});
});
describe("always safe commands", () => {
test("allows cat", () => {
expect(isReadOnlyShellCommand("cat file.txt")).toBe(true);
@@ -166,6 +193,12 @@ describe("isReadOnlyShellCommand", () => {
expect(isReadOnlyShellCommand("ls -la | grep txt | wc -l")).toBe(true);
});
test("allows pipe characters inside quoted args", () => {
expect(isReadOnlyShellCommand('rg -n "foo|bar|baz" apps/core')).toBe(
true,
);
});
test("blocks pipes with unsafe commands", () => {
expect(isReadOnlyShellCommand("cat file | rm")).toBe(false);
expect(isReadOnlyShellCommand("echo test | bash")).toBe(false);
@@ -187,6 +220,13 @@ describe("isReadOnlyShellCommand", () => {
test("blocks command substitution", () => {
expect(isReadOnlyShellCommand("echo $(rm file)")).toBe(false);
expect(isReadOnlyShellCommand("echo `rm file`")).toBe(false);
expect(isReadOnlyShellCommand('echo "$(rm file)"')).toBe(false);
expect(isReadOnlyShellCommand('echo "`rm file`"')).toBe(false);
});
test("allows literal redirect text inside quotes", () => {
expect(isReadOnlyShellCommand('echo "a > b"')).toBe(true);
expect(isReadOnlyShellCommand("echo 'a >> b'")).toBe(true);
});
});