Parent directory

config.ts

5154 bytes
  1import { existsSync, readFileSync } from "node:fs";
  2import { homedir } from "node:os";
  3import { join } from "node:path";
  4import { parseReviewerConfig } from "../core/config";
  5import type { ReviewerConfig } from "../core/types";
  6
  7export type PermissionAction = "allow" | "check" | "deny";
  8export type ExternalDirectories = string[];
  9
 10export type PiPolicyEngineConfig = {
 11  reviewer: ReviewerConfig;
 12  tools: Record<string, PermissionAction>;
 13  externalDirectories: ExternalDirectories;
 14};
 15
 16function globalConfigPath(): string {
 17  return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "policy-engine.json");
 18}
 19
 20function configPaths(cwd: string): string[] {
 21  return [globalConfigPath(), join(cwd, ".pi", "policy-engine.json")];
 22}
 23
 24export function piPolicyPaths(): { cacheFile: string; auditFile: string } {
 25  const directory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
 26  return {
 27    cacheFile: join(directory, "policy-engine", "cache", "decisions.jsonl"),
 28    auditFile: join(directory, "policy-engine", "logs", "policy.jsonl"),
 29  };
 30}
 31
 32function objectValue(value: unknown): Record<string, unknown> | undefined {
 33  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
 34}
 35
 36function permissionAction(value: unknown, field: string): PermissionAction {
 37  if (value === "allow" || value === "check" || value === "deny") return value;
 38  throw new Error(`${field} must be "allow", "check", or "deny"`);
 39}
 40
 41function toolPermissions(value: unknown): Record<string, PermissionAction> {
 42  const data = objectValue(value);
 43  if (!data) throw new Error("tools must be a JSON object");
 44  return Object.fromEntries(
 45    Object.entries(data).map(([name, action]) => [name, permissionAction(action, `tools.${name}`)]),
 46  );
 47}
 48
 49function externalDirectories(value: unknown): ExternalDirectories {
 50  if (!Array.isArray(value) || !value.every((path) => typeof path === "string" && path.length > 0)) {
 51    throw new Error("externalDirectories must be a JSON array of non-empty strings");
 52  }
 53  return value;
 54}
 55
 56export function parsePiPolicyConfig(value: unknown): PiPolicyEngineConfig {
 57  const data = objectValue(value);
 58  if (!data) throw new Error("policy engine configuration must be a JSON object");
 59  if (data.permission !== undefined) {
 60    throw new Error("permission has been renamed to tools; external_directory has been renamed to externalDirectories");
 61  }
 62  return {
 63    reviewer: parseReviewerConfig(data),
 64    tools: data.tools === undefined ? {} : toolPermissions(data.tools),
 65    externalDirectories: data.externalDirectories === undefined ? [] : externalDirectories(data.externalDirectories),
 66  };
 67}
 68
 69function validatePiPolicyFragment(value: unknown): Record<string, unknown> {
 70  const data = objectValue(value);
 71  if (!data) throw new Error("policy engine configuration must be a JSON object");
 72  if (data.permission !== undefined) {
 73    throw new Error("permission has been renamed to tools; external_directory has been renamed to externalDirectories");
 74  }
 75  if (data.reviewer !== undefined && data.reviewer !== null && !objectValue(data.reviewer)) {
 76    throw new Error("reviewer must be a JSON object");
 77  }
 78  if (data.tools !== undefined) toolPermissions(data.tools);
 79  if (data.externalDirectories !== undefined) externalDirectories(data.externalDirectories);
 80  return data;
 81}
 82
 83function mergeObjects(
 84  base: Record<string, unknown> | undefined,
 85  override: Record<string, unknown> | undefined,
 86): Record<string, unknown> | undefined {
 87  if (!base) return override;
 88  if (!override) return base;
 89  return { ...base, ...override };
 90}
 91
 92function mergeArrays(base: string[] | undefined, override: string[] | undefined): string[] | undefined {
 93  if (!base) return override;
 94  if (!override) return base;
 95  return [...new Set([...base, ...override])];
 96}
 97
 98function mergeConfig(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
 99  const reviewer = mergeObjects(objectValue(base.reviewer), objectValue(override.reviewer));
100  const tools = mergeObjects(objectValue(base.tools), objectValue(override.tools));
101  const externalDirectories = mergeArrays(
102    Array.isArray(base.externalDirectories) ? (base.externalDirectories as string[]) : undefined,
103    Array.isArray(override.externalDirectories) ? (override.externalDirectories as string[]) : undefined,
104  );
105  return {
106    ...base,
107    ...override,
108    ...(reviewer && { reviewer }),
109    ...(tools && { tools }),
110    ...(externalDirectories && { externalDirectories }),
111  };
112}
113
114export function loadPiPolicyConfig(cwd = process.cwd()): PiPolicyEngineConfig {
115  let config: Record<string, unknown> = {};
116  for (const [index, path] of configPaths(cwd).entries()) {
117    if (!existsSync(path)) continue;
118    const source = JSON.parse(readFileSync(path, "utf8"));
119    let data: Record<string, unknown>;
120    if (index === 0) {
121      parsePiPolicyConfig(source);
122      data = source as Record<string, unknown>;
123    } else {
124      data = validatePiPolicyFragment(source);
125    }
126    config = mergeConfig(config, data);
127  }
128  return parsePiPolicyConfig(config);
129}