75 lines
5.9 KiB
TypeScript
75 lines
5.9 KiB
TypeScript
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<string[]>;
|
|
readFile(path: string): Promise<Buffer>;
|
|
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<string | undefined> {
|
|
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<boolean> {
|
|
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<PolicyRegistry> {
|
|
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<string, string>();
|
|
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 });
|
|
}
|