static-permissions.ts
3671 bytes
1import { realpath } from "node:fs/promises";
2import { homedir } from "node:os";
3import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4import type { ExternalDirectories, PermissionAction, PiPolicyEngineConfig } from "./config";
5import type { Decision, DecisionCategory } from "../core/types";
6
7const PATH_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"]);
8
9type ToolInput = Record<string, unknown>;
10
11function expandedPattern(pattern: string): string {
12 if (pattern === "~") return homedir();
13 if (pattern.startsWith("~/")) return join(homedir(), pattern.slice(2));
14 if (pattern === "$HOME") return homedir();
15 if (pattern.startsWith("$HOME/")) return join(homedir(), pattern.slice(6));
16 return pattern;
17}
18
19export function matchesExternalDirectory(directories: ExternalDirectories | undefined, value: string): boolean {
20 return (
21 directories?.some((directory) => {
22 const expanded = expandedPattern(directory);
23 if (!expanded) return false;
24 const pathFromDirectory = relative(expanded, value);
25 return (
26 pathFromDirectory === "" ||
27 (!pathFromDirectory.startsWith(`..${sep}`) && pathFromDirectory !== ".." && !isAbsolute(pathFromDirectory))
28 );
29 }) ?? false
30 );
31}
32
33async function canonicalPath(path: string, cwd: string): Promise<string> {
34 const absolutePath = resolve(cwd, path.startsWith("@") ? path.slice(1) : path);
35 const suffix: string[] = [];
36 let existingPath = absolutePath;
37
38 while (true) {
39 try {
40 return join(await realpath(existingPath), ...suffix);
41 } catch {
42 const parentPath = dirname(existingPath);
43 if (parentPath === existingPath) return absolutePath;
44 suffix.unshift(existingPath.slice(parentPath.length + 1));
45 existingPath = parentPath;
46 }
47 }
48}
49
50function isInside(path: string, directory: string): boolean {
51 const pathFromDirectory = relative(directory, path);
52 return (
53 pathFromDirectory === "" ||
54 (!pathFromDirectory.startsWith(`..${sep}`) && pathFromDirectory !== ".." && !isAbsolute(pathFromDirectory))
55 );
56}
57
58function staticDecision(
59 action: PermissionAction | undefined,
60 reason: string,
61 category: DecisionCategory,
62): Decision | undefined {
63 if (!action || action === "check") return undefined;
64 return { decision: action, reason, category };
65}
66
67export async function checkStaticPermission(
68 toolType: string,
69 input: ToolInput,
70 cwd: string,
71 config: PiPolicyEngineConfig,
72): Promise<Decision | undefined> {
73 const action = config.tools[toolType] ?? "allow";
74 if (action === "deny") return staticDecision(action, `Static ${toolType} permission`, "config_allow");
75
76 let externalPath: string | undefined;
77 if (PATH_TOOLS.has(toolType)) {
78 const path = typeof input.path === "string" ? input.path : ".";
79 const absolutePath = await canonicalPath(path, cwd);
80 if (["edit", "write"].includes(toolType) && basename(absolutePath) === "policy-engine.json") {
81 return {
82 decision: "ask",
83 reason: "Policy engine configuration changes require confirmation",
84 category: "dangerous",
85 };
86 }
87 const workspacePath = await canonicalPath(cwd, cwd);
88 if (!isInside(absolutePath, workspacePath)) {
89 if (!matchesExternalDirectory(config.externalDirectories, absolutePath)) return undefined;
90 externalPath = absolutePath;
91 }
92 }
93
94 const toolDecision = staticDecision(action, `Static ${toolType} permission`, "config_allow");
95 if (toolDecision) return toolDecision;
96 return externalPath
97 ? { decision: "allow", reason: `Static external path permission: ${externalPath}`, category: "external_directory" }
98 : undefined;
99}