Files
harness-tools/extensions/native-hooks/src/policy-loader.ts
T

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 });
}