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}