fix: permissions shell rule normalization (#1189)
This commit is contained in:
@@ -203,6 +203,30 @@ test("Unknown command suggests exact match", () => {
|
||||
expect(context.allowPersistence).toBe(true);
|
||||
});
|
||||
|
||||
test("Wrapped shell launcher suggests unwrapped read-only wildcard rule", () => {
|
||||
const context = analyzeApprovalContext(
|
||||
"Bash",
|
||||
{
|
||||
command: "bash -lc \"sed -n '150,360p' src/permissions/mode.ts\"",
|
||||
},
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(context.recommendedRule).toBe("Bash(sed -n:*)");
|
||||
expect(context.approveAlwaysText).toContain("sed -n");
|
||||
});
|
||||
|
||||
test("Read-only rg command suggests wildcard rule", () => {
|
||||
const context = analyzeApprovalContext(
|
||||
"Bash",
|
||||
{ command: "rg -n analyzeBashApproval src/permissions/analyzer.ts" },
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(context.recommendedRule).toBe("Bash(rg:*)");
|
||||
expect(context.safetyLevel).toBe("safe");
|
||||
});
|
||||
|
||||
test("Skill script in bundled skill suggests bundled-scope message", () => {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
|
||||
@@ -235,6 +235,35 @@ test("Save permission doesn't create duplicates", async () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Save permission dedupes wrapped shell launcher variants", async () => {
|
||||
const projectDir = join(testDir, "project");
|
||||
await savePermissionRule(
|
||||
`Bash(bash -lc "sed -n '150,360p' src/permissions/mode.ts")`,
|
||||
"allow",
|
||||
"project",
|
||||
projectDir,
|
||||
);
|
||||
await savePermissionRule(
|
||||
"Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
"allow",
|
||||
"project",
|
||||
projectDir,
|
||||
);
|
||||
|
||||
const settingsPath = join(projectDir, ".letta", "settings.json");
|
||||
const file = Bun.file(settingsPath);
|
||||
const settings = await file.json();
|
||||
|
||||
expect(settings.permissions.allow).toContain(
|
||||
"Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
);
|
||||
expect(
|
||||
settings.permissions.allow.filter(
|
||||
(r: string) => r === "Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Save permission preserves existing rules", async () => {
|
||||
const projectDir = join(testDir, "project");
|
||||
|
||||
|
||||
@@ -257,6 +257,24 @@ test("Bash pattern: skill-scoped prefix does not match other skills", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("Bash pattern: exact rules match wrapped shell launchers", () => {
|
||||
expect(
|
||||
matchesBashPattern(
|
||||
`Bash(bash -lc "sed -n '150,360p' src/permissions/mode.ts")`,
|
||||
"Bash(sed -n '150,360p' src/permissions/mode.ts)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Bash pattern: wildcard rules match wrapped shell launchers", () => {
|
||||
expect(
|
||||
matchesBashPattern(
|
||||
`Bash(sh -c "rg -n 'analyzeBashApproval' src/permissions")`,
|
||||
"Bash(rg:*)",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Tool Pattern Matching Tests
|
||||
// ============================================================================
|
||||
|
||||
@@ -197,6 +197,26 @@ test("plan mode - allows Read", () => {
|
||||
expect(result.matchedRule).toBe("plan mode");
|
||||
});
|
||||
|
||||
test("plan mode - allows TaskOutput", () => {
|
||||
permissionMode.setMode("plan");
|
||||
|
||||
const permissions: PermissionRules = {
|
||||
allow: [],
|
||||
deny: [],
|
||||
ask: [],
|
||||
};
|
||||
|
||||
const result = checkPermission(
|
||||
"TaskOutput",
|
||||
{ task_id: "task_1" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule).toBe("plan mode");
|
||||
});
|
||||
|
||||
test("plan mode - allows Glob", () => {
|
||||
permissionMode.setMode("plan");
|
||||
|
||||
@@ -483,24 +503,6 @@ 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",
|
||||
@@ -510,34 +512,23 @@ test("plan mode - allows read-only Bash commands", () => {
|
||||
);
|
||||
expect(cdDangerousResult.decision).toBe("deny");
|
||||
|
||||
// quoted pipes in regex patterns should be allowed
|
||||
const quotedPipeResult = checkPermission(
|
||||
// absolute paths should be allowed in plan mode for read-only analysis
|
||||
const absoluteReadResult = checkPermission(
|
||||
"Bash",
|
||||
{ command: 'rg -n "foo|bar|baz" src/permissions' },
|
||||
{ command: "sed -n '1,80p' /tmp/logs/output.log" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
expect(quotedPipeResult.decision).toBe("allow");
|
||||
});
|
||||
expect(absoluteReadResult.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 },
|
||||
// traversal paths should also be allowed in plan mode for read-only analysis
|
||||
const traversalReadResult = checkPermission(
|
||||
"Bash",
|
||||
{ command: "cat ../shared/config.json" },
|
||||
permissions,
|
||||
"/Users/test/project",
|
||||
);
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.matchedRule).toBe("plan mode");
|
||||
expect(traversalReadResult.decision).toBe("allow");
|
||||
});
|
||||
|
||||
test("plan mode - denies WebFetch", () => {
|
||||
|
||||
@@ -6,33 +6,6 @@ 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);
|
||||
@@ -71,6 +44,22 @@ describe("isReadOnlyShellCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sed command", () => {
|
||||
test("allows read-only sed", () => {
|
||||
expect(isReadOnlyShellCommand("sed -n '1,40p' file.txt")).toBe(true);
|
||||
expect(isReadOnlyShellCommand("sed 's/foo/bar/g' file.txt")).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks in-place sed edits", () => {
|
||||
expect(isReadOnlyShellCommand("sed -i 's/foo/bar/g' file.txt")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isReadOnlyShellCommand("sed --in-place 's/foo/bar/g' file.txt"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git commands", () => {
|
||||
test("allows read-only git commands", () => {
|
||||
expect(isReadOnlyShellCommand("git status")).toBe(true);
|
||||
@@ -193,12 +182,6 @@ 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);
|
||||
@@ -220,13 +203,6 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +263,29 @@ describe("isReadOnlyShellCommand", () => {
|
||||
expect(isReadOnlyShellCommand("chmod 755 file")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("curl http://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("blocks external paths by default", () => {
|
||||
expect(isReadOnlyShellCommand("cat /tmp/file.txt")).toBe(false);
|
||||
expect(isReadOnlyShellCommand("cat ../file.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("allows external paths when explicitly enabled", () => {
|
||||
expect(
|
||||
isReadOnlyShellCommand("cat /tmp/file.txt", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReadOnlyShellCommand("cat ../file.txt", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReadOnlyShellCommand("cd /tmp && git status", {
|
||||
allowExternalPaths: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user