Parent directory

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}