import { createHash } from "node:crypto"; import { readdir, readFile, lstat } from "node:fs/promises"; import { userInfo } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { validatePolicy, type PersonalPolicy } from "./policy.ts"; export interface LoadedPolicy { readonly path: string; readonly digest: string; readonly policy: PersonalPolicy } export interface PolicyLoadFailure { readonly path: string; readonly error: string } export interface PolicyRegistry { readonly root: string; readonly policies: readonly LoadedPolicy[]; readonly failures: readonly PolicyLoadFailure[]; readonly digest: string; readonly analyzerConfigSafe: boolean; readonly trustedCodeWarning: string } export interface LoaderIO { lstat(path: string): Promise<{ uid: number; mode: number; isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }>; readdir(path: string): Promise; readFile(path: string): Promise; importModule(url: string): Promise<{ default?: unknown }>; } const nativeIO: LoaderIO = { lstat, readdir: async (path) => readdir(path), readFile, importModule: (url) => import(url) }; export const TRUST_WARNING = "Personal TypeScript policies are trusted executable code and run with Pi's filesystem, process, network, credential, and sandbox authority."; export function personalConfigRoot(): string { return join(userInfo().homedir, ".config", "pi-native-hooks"); } async function safePath(io: LoaderIO, path: string, uid: number, kind: "file" | "directory"): Promise { let stat; try { stat = await io.lstat(path); } catch (error) { return error instanceof Error ? error.message : String(error); } if (stat.isSymbolicLink()) return "symlinks are not allowed"; if (stat.uid !== uid) return `must be owned by uid ${uid}`; if ((stat.mode & 0o022) !== 0) return "must not be writable by group or others"; if (kind === "file" ? !stat.isFile() : !stat.isDirectory()) return `must be a regular ${kind}`; return undefined; } async function validateTree(io: LoaderIO, root: string, uid: number, failures: PolicyLoadFailure[]): Promise { let safe = true; for (const name of await io.readdir(root)) { const path = join(root, name); let stat; try { stat = await io.lstat(path); } catch (error) { failures.push({ path, error: String(error) }); safe = false; continue; } const kind = stat.isDirectory() ? "directory" : "file"; const error = await safePath(io, path, uid, kind); if (error) { failures.push({ path, error }); safe = false; continue; } if (kind === "directory" && !await validateTree(io, path, uid, failures)) safe = false; } return safe; } export async function loadPolicies(options: { root?: string; uid?: number; io?: LoaderIO } = {}): Promise { const root = resolve(options.root ?? personalConfigRoot()); const uid = options.uid ?? userInfo().uid; const io = options.io ?? nativeIO; const failures: PolicyLoadFailure[] = []; const rootError = await safePath(io, root, uid, "directory"); if (rootError) return Object.freeze({ root, policies: Object.freeze([]), failures: Object.freeze(rootError.includes("ENOENT") ? [] : [{ path: root, error: rootError }]), digest: createHash("sha256").update("empty").digest("hex"), analyzerConfigSafe: false, trustedCodeWarning: TRUST_WARNING }); let analyzerConfigSafe = true; const configPath = join(root, "sgconfig.yml"); const configError = await safePath(io, configPath, uid, "file"); if (configError) { analyzerConfigSafe = false; failures.push({ path: configPath, error: configError }); } for (const directory of [join(root, "ast-grep"), join(root, "ast-grep", "rules")]) { const error = await safePath(io, directory, uid, "directory"); if (error) { analyzerConfigSafe = false; failures.push({ path: directory, error }); } } if (analyzerConfigSafe && !await validateTree(io, join(root, "ast-grep", "rules"), uid, failures)) analyzerConfigSafe = false; const policiesDir = join(root, "policies"); const dirError = await safePath(io, policiesDir, uid, "directory"); if (dirError) return Object.freeze({ root, policies: Object.freeze([]), failures: Object.freeze([...failures, { path: policiesDir, error: dirError }]), digest: createHash("sha256").update("empty").digest("hex"), analyzerConfigSafe: false, trustedCodeWarning: TRUST_WARNING }); const names = (await io.readdir(policiesDir)).filter((name) => name.endsWith(".ts") && !name.includes("/") && !name.includes("\\")).sort(); const loaded: LoadedPolicy[] = []; const ids = new Map(); for (const name of names) { const path = join(policiesDir, name); const pathError = await safePath(io, path, uid, "file"); if (pathError) { failures.push({ path, error: pathError }); continue; } try { const content = await io.readFile(path); const digest = createHash("sha256").update(content).digest("hex"); const module = await io.importModule(`${pathToFileURL(path).href}?native-hooks=${digest}`); const result = validatePolicy(module.default); if (!result.ok) { failures.push({ path, error: result.error }); continue; } const duplicate = ids.get(result.policy.id); if (duplicate) { failures.push({ path, error: `duplicate policy id ${result.policy.id}; first loaded from ${duplicate}` }); continue; } ids.set(result.policy.id, path); loaded.push(Object.freeze({ path, digest, policy: result.policy })); } catch (error) { failures.push({ path, error: `${error instanceof Error ? error.message : String(error)}. Policies must use Node's erasable TypeScript subset (no enums, namespaces, parameter properties, JSX, or tsconfig transforms).` }); } } const digest = createHash("sha256").update(loaded.map((item) => `${item.policy.id}\0${item.digest}`).join("\0")).digest("hex"); return Object.freeze({ root, policies: Object.freeze(loaded), failures: Object.freeze(failures), digest, analyzerConfigSafe, trustedCodeWarning: TRUST_WARNING }); }