723 lines
17 KiB
TypeScript
723 lines
17 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { homedir } from "node:os";
|
|
import { join, relative } from "node:path";
|
|
import { checkPermission } from "../permissions/checker";
|
|
import { permissionMode } from "../permissions/mode";
|
|
import type { PermissionRules } from "../permissions/types";
|
|
|
|
// Clean up after each test
|
|
afterEach(() => {
|
|
permissionMode.reset();
|
|
});
|
|
|
|
// ============================================================================
|
|
// Permission Mode: default
|
|
// ============================================================================
|
|
|
|
test("default mode - no overrides", () => {
|
|
permissionMode.setMode("default");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command: "curl http://example.com" }, // Use non-read-only command
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
// Should fall back to tool default (ask for Bash)
|
|
expect(result.decision).toBe("ask");
|
|
expect(result.reason).toBe("Default behavior for tool");
|
|
});
|
|
|
|
// ============================================================================
|
|
// Permission Mode: bypassPermissions
|
|
// ============================================================================
|
|
|
|
test("bypassPermissions mode - allows all tools", () => {
|
|
permissionMode.setMode("bypassPermissions");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const bashResult = checkPermission(
|
|
"Bash",
|
|
{ command: "rm -rf /" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(bashResult.decision).toBe("allow");
|
|
expect(bashResult.reason).toBe("Permission mode: bypassPermissions");
|
|
|
|
const writeResult = checkPermission(
|
|
"Write",
|
|
{ file_path: "/etc/passwd" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(writeResult.decision).toBe("allow");
|
|
});
|
|
|
|
test("bypassPermissions mode - does NOT override deny rules", () => {
|
|
permissionMode.setMode("bypassPermissions");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: ["Bash(rm -rf:*)"],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command: "rm -rf /" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
// Deny rules take precedence even in bypassPermissions mode
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.reason).toBe("Matched deny rule");
|
|
});
|
|
|
|
// ============================================================================
|
|
// Permission Mode: acceptEdits
|
|
// ============================================================================
|
|
|
|
test("acceptEdits mode - allows Write", () => {
|
|
permissionMode.setMode("acceptEdits");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Write",
|
|
{ file_path: "/tmp/test.txt" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("acceptEdits mode");
|
|
expect(result.reason).toBe("Permission mode: acceptEdits");
|
|
});
|
|
|
|
test("acceptEdits mode - allows Edit", () => {
|
|
permissionMode.setMode("acceptEdits");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Edit",
|
|
{ file_path: "/tmp/test.txt", old_string: "old", new_string: "new" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("acceptEdits mode");
|
|
});
|
|
|
|
test("acceptEdits mode - allows NotebookEdit", () => {
|
|
permissionMode.setMode("acceptEdits");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"NotebookEdit",
|
|
{ notebook_path: "/tmp/test.ipynb", new_source: "code" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("acceptEdits mode");
|
|
});
|
|
|
|
test("acceptEdits mode - does NOT allow Bash", () => {
|
|
permissionMode.setMode("acceptEdits");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command: "curl http://example.com" }, // Use non-read-only command
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
// Bash is not an edit tool, should fall back to default
|
|
expect(result.decision).toBe("ask");
|
|
expect(result.reason).toBe("Default behavior for tool");
|
|
});
|
|
|
|
// ============================================================================
|
|
// Permission Mode: plan
|
|
// ============================================================================
|
|
|
|
test("plan mode - allows Read", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Read",
|
|
{ file_path: "/tmp/test.txt" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
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");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Glob",
|
|
{ pattern: "**/*.ts" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - allows Grep", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Grep",
|
|
{ pattern: "import", path: "/tmp" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - allows TodoWrite", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"TodoWrite",
|
|
{ todos: [] },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - denies Write", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Write",
|
|
{ file_path: "/tmp/test.txt" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
// Reason now includes detailed guidance (planFilePath not set in test, so shows error fallback)
|
|
expect(result.reason).toContain("Plan mode is active");
|
|
});
|
|
|
|
test("plan mode deny reason includes exact apply_patch relative path hint", () => {
|
|
permissionMode.setMode("plan");
|
|
const workingDirectory = join(homedir(), "dev", "repo");
|
|
const planPath = join(homedir(), ".letta", "plans", "unit-test-plan.md");
|
|
const expectedRelativePath = relative(workingDirectory, planPath).replace(
|
|
/\\/g,
|
|
"/",
|
|
);
|
|
permissionMode.setPlanFilePath(planPath);
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Write",
|
|
{ file_path: "/tmp/test.txt" },
|
|
permissions,
|
|
workingDirectory,
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.reason).toContain(
|
|
`If using apply_patch, use this exact relative path in patch headers: ${expectedRelativePath}.`,
|
|
);
|
|
});
|
|
|
|
test("plan mode - allows Write to plan markdown file", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const planPath = join(homedir(), ".letta", "plans", "unit-test-plan.md");
|
|
const result = checkPermission(
|
|
"Write",
|
|
{ file_path: planPath },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - allows ApplyPatch with relative path to plan file", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const workingDirectory = join(homedir(), "dev", "repo");
|
|
const planPath = join(homedir(), ".letta", "plans", "zesty-witty-cloud.md");
|
|
const relativePlanPath = relative(workingDirectory, planPath);
|
|
const patch = `*** Begin Patch
|
|
*** Add File: ${relativePlanPath}
|
|
+## Plan
|
|
*** End Patch`;
|
|
|
|
const result = checkPermission(
|
|
"ApplyPatch",
|
|
{ input: patch },
|
|
permissions,
|
|
workingDirectory,
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - denies ApplyPatch when any target is outside plans dir", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const workingDirectory = join(homedir(), "dev", "repo");
|
|
const planPath = join(homedir(), ".letta", "plans", "zesty-witty-cloud.md");
|
|
const relativePlanPath = relative(workingDirectory, planPath);
|
|
const patch = `*** Begin Patch
|
|
*** Add File: ${relativePlanPath}
|
|
+## Plan
|
|
*** Update File: src/App.tsx
|
|
@@
|
|
-old
|
|
+new
|
|
*** End Patch`;
|
|
|
|
const result = checkPermission(
|
|
"ApplyPatch",
|
|
{ input: patch },
|
|
permissions,
|
|
workingDirectory,
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
expect(result.reason).toContain("Plan mode is active");
|
|
});
|
|
|
|
test("plan mode - denies non-read-only Bash", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command: "npm install" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - allows Bash heredoc write to plan file", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const planPath = join(homedir(), ".letta", "plans", "unit-test-plan.md");
|
|
const command = `cat > ${planPath} <<'EOF'\n# Plan\n- step 1\nEOF`;
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - denies Bash heredoc write outside plans dir", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const command = "cat > /tmp/not-a-plan.md <<'EOF'\n# Plan\nEOF";
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - denies Bash heredoc write when extra commands follow", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const planPath = join(homedir(), ".letta", "plans", "unit-test-plan.md");
|
|
const command = `cat > ${planPath} <<'EOF'\n# Plan\nEOF\necho 'extra command'`;
|
|
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
test("plan mode - allows read-only Bash commands", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
// ls should be allowed
|
|
const lsResult = checkPermission(
|
|
"Bash",
|
|
{ command: "ls -la" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(lsResult.decision).toBe("allow");
|
|
expect(lsResult.matchedRule).toBe("plan mode");
|
|
|
|
// git status should be allowed
|
|
const gitStatusResult = checkPermission(
|
|
"Bash",
|
|
{ command: "git status" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(gitStatusResult.decision).toBe("allow");
|
|
|
|
// git log should be allowed
|
|
const gitLogResult = checkPermission(
|
|
"Bash",
|
|
{ command: "git log --oneline -10" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(gitLogResult.decision).toBe("allow");
|
|
|
|
// git diff should be allowed
|
|
const gitDiffResult = checkPermission(
|
|
"Bash",
|
|
{ command: "git diff HEAD~1" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(gitDiffResult.decision).toBe("allow");
|
|
|
|
// cd && git should be allowed (common CLI pattern)
|
|
const cdGitResult = checkPermission(
|
|
"Bash",
|
|
{ command: "cd src && git status" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(cdGitResult.decision).toBe("allow");
|
|
|
|
// cd && git show should be allowed
|
|
const cdGitShowResult = checkPermission(
|
|
"Bash",
|
|
{ command: "cd src && git show abc123" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(cdGitShowResult.decision).toBe("allow");
|
|
|
|
// chained safe commands with ; should be allowed
|
|
const chainedResult = checkPermission(
|
|
"Bash",
|
|
{ command: "ls; pwd; git status" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(chainedResult.decision).toBe("allow");
|
|
|
|
// quoted pipes in regex patterns should be treated as literals and allowed
|
|
const quotedPipeResult = checkPermission(
|
|
"Bash",
|
|
{
|
|
command:
|
|
'rg -n "memfs|memory filesystem|memory_filesystem|skills/|SKILL.md|git-backed|sync" letta tests -S',
|
|
},
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(quotedPipeResult.decision).toBe("allow");
|
|
|
|
// cd && dangerous command should still be denied
|
|
const cdDangerousResult = checkPermission(
|
|
"Bash",
|
|
{ command: "cd src && npm install" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(cdDangerousResult.decision).toBe("deny");
|
|
|
|
// absolute paths should be allowed in plan mode for read-only analysis
|
|
const absoluteReadResult = checkPermission(
|
|
"Bash",
|
|
{ command: "sed -n '1,80p' /tmp/logs/output.log" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
expect(absoluteReadResult.decision).toBe("allow");
|
|
|
|
// 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(traversalReadResult.decision).toBe("allow");
|
|
});
|
|
|
|
test("plan mode - denies WebFetch", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"WebFetch",
|
|
{ url: "https://example.com" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|
|
|
|
// ============================================================================
|
|
// Precedence Tests
|
|
// ============================================================================
|
|
|
|
test("Deny rules override permission mode", () => {
|
|
permissionMode.setMode("bypassPermissions");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: ["Write(**)"],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"Write",
|
|
{ file_path: "/tmp/test.txt" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
// Deny rule takes precedence over bypassPermissions
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.reason).toBe("Matched deny rule");
|
|
});
|
|
|
|
test("Permission mode takes precedence over CLI allowedTools", () => {
|
|
const { cliPermissions } = require("../permissions/cli");
|
|
cliPermissions.setAllowedTools("Bash");
|
|
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
// Use a non-read-only command to test precedence
|
|
const result = checkPermission(
|
|
"Bash",
|
|
{ command: "npm install" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
// Permission mode denies take precedence over CLI allowedTools
|
|
expect(result.decision).toBe("deny");
|
|
expect(result.reason).toContain("Plan mode is active");
|
|
|
|
// Clean up
|
|
cliPermissions.clear();
|
|
});
|
|
|
|
test("plan mode - remembers and restores previous mode", () => {
|
|
permissionMode.setMode("bypassPermissions");
|
|
expect(permissionMode.getMode()).toBe("bypassPermissions");
|
|
|
|
// Enter plan mode - should remember prior mode.
|
|
permissionMode.setMode("plan");
|
|
expect(permissionMode.getMode()).toBe("plan");
|
|
expect(permissionMode.getModeBeforePlan()).toBe("bypassPermissions");
|
|
|
|
// Exit plan mode by restoring previous mode.
|
|
permissionMode.setMode(permissionMode.getModeBeforePlan() ?? "default");
|
|
expect(permissionMode.getMode()).toBe("bypassPermissions");
|
|
|
|
// Once we leave plan mode, the remembered mode is consumed.
|
|
expect(permissionMode.getModeBeforePlan()).toBe(null);
|
|
});
|
|
|
|
test("plan mode - allows read_file_gemini", () => {
|
|
permissionMode.setMode("plan");
|
|
|
|
const permissions: PermissionRules = {
|
|
allow: [],
|
|
deny: [],
|
|
ask: [],
|
|
};
|
|
|
|
const result = checkPermission(
|
|
"read_file_gemini",
|
|
{ file_path: "/tmp/test.txt" },
|
|
permissions,
|
|
"/Users/test/project",
|
|
);
|
|
|
|
expect(result.decision).toBe("allow");
|
|
expect(result.matchedRule).toBe("plan mode");
|
|
});
|