feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import type { AnalysisSnapshot, AnalyzerFailure } from "./policy.ts";
|
||||
export interface AnalysisRequest { readonly analyzerKey: string; readonly paths: readonly string[] }
|
||||
export interface AnalyzerAdapter { analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise<AnalysisSnapshot> }
|
||||
export class AnalysisCoordinator {
|
||||
constructor(private readonly adapters: ReadonlyMap<string, AnalyzerAdapter>) {}
|
||||
async run(requests: readonly AnalysisRequest[], revision: number, signal?: AbortSignal): Promise<ReadonlyMap<string, AnalysisSnapshot>> {
|
||||
const grouped = new Map<string, Set<string>>();
|
||||
for (const request of requests) { const paths = grouped.get(request.analyzerKey) ?? new Set<string>(); for (const path of request.paths) paths.add(path); grouped.set(request.analyzerKey, paths); }
|
||||
const entries = await Promise.all([...grouped].sort(([a], [b]) => a.localeCompare(b)).map(async ([key, paths]) => {
|
||||
const adapter = this.adapters.get(key);
|
||||
const sorted = [...paths].sort();
|
||||
if (!adapter) return [key, failed(key, revision, sorted, { kind: "missing", message: `No analyzer registered for ${key}` })] as const;
|
||||
try { return [key, deepFreeze(await adapter.analyze(sorted, revision, signal))] as const; }
|
||||
catch (error) { return [key, failed(key, revision, sorted, { kind: "internal", message: error instanceof Error ? error.message : String(error) })] as const; }
|
||||
}));
|
||||
return readonlyMap(entries);
|
||||
}
|
||||
}
|
||||
function failed(key: string, workspaceRevision: number, paths: readonly string[], failure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: 0, failure: Object.freeze(failure) }); }
|
||||
export function deepFreeze<T>(value: T): T { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const item of Object.values(value as Record<string, unknown>)) deepFreeze(item); } return value; }
|
||||
export function readonlyMap<K, V>(entries: readonly (readonly [K, V])[]): ReadonlyMap<K, V> { const map = new Map(entries); Object.freeze(map); return new Proxy(map, { get(target, prop) { if (prop === "set" || prop === "delete" || prop === "clear") return undefined; const value = Reflect.get(target, prop, target) as unknown; return typeof value === "function" ? (value as Function).bind(target) : value; } }); }
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import type { AnalyzerAdapter } from "../analysis.ts";
|
||||
import type { AnalysisSnapshot, AnalyzerFailure, Finding, Severity } from "../policy.ts";
|
||||
import type { Exec } from "../project-context.ts";
|
||||
|
||||
const MAX_PATHS = 200; const MAX_FINDINGS = 200; const MAX_STDOUT = 2_000_000;
|
||||
export class AstGrepAdapter implements AnalyzerAdapter {
|
||||
private versionChecked?: Promise<AnalyzerFailure | undefined>;
|
||||
constructor(private readonly exec: Exec, private readonly root: string, private readonly configPath: string, private readonly executable = "ast-grep") {}
|
||||
prepare(signal?: AbortSignal): Promise<AnalyzerFailure | undefined> { return this.versionChecked ??= this.checkVersion(signal); }
|
||||
async analyze(paths: readonly string[], workspaceRevision: number, signal?: AbortSignal): Promise<AnalysisSnapshot> {
|
||||
const started = performance.now(); const bounded = paths.slice(0, MAX_PATHS);
|
||||
const versionFailure = await this.prepare(signal);
|
||||
if (versionFailure) return failure("ast-grep:personal", workspaceRevision, bounded, started, versionFailure);
|
||||
const scanPaths = bounded.map((path) => isAbsolute(path) ? path : resolve(this.root, path));
|
||||
const args = ["scan", "--config", this.configPath, "--json=stream", "--color=never", "--max-results", String(MAX_FINDINGS), "--", ...scanPaths];
|
||||
const result = await this.exec(this.executable, args, { timeout: 20_000, ...(signal ? { signal } : {}) });
|
||||
if (result.killed) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "killed", message: "ast-grep was killed or timed out" });
|
||||
if (result.code !== 0) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "nonzero", message: `ast-grep exited ${result.code}: ${result.stderr.slice(0, 500)}` });
|
||||
if (Buffer.byteLength(result.stdout) > MAX_STDOUT) return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: "ast-grep output exceeded 2MB" });
|
||||
try {
|
||||
const findings = result.stdout.split("\n").filter(Boolean).slice(0, MAX_FINDINGS).map((line) => normalize(JSON.parse(line), this.root));
|
||||
return Object.freeze({ analyzerKey: "ast-grep:personal", workspaceRevision, paths: Object.freeze([...bounded]), findings: Object.freeze(findings), durationMs: Math.round(performance.now() - started) });
|
||||
} catch (error) { return failure("ast-grep:personal", workspaceRevision, bounded, started, { kind: "malformed", message: error instanceof Error ? error.message : String(error) }); }
|
||||
}
|
||||
private async checkVersion(signal?: AbortSignal): Promise<AnalyzerFailure | undefined> {
|
||||
const result = await this.exec(this.executable, ["--version"], { timeout: 5000, ...(signal ? { signal } : {}) });
|
||||
if (result.code !== 0) return { kind: "missing", message: `ast-grep is unavailable: ${result.stderr || result.stdout}` };
|
||||
const match = result.stdout.match(/(\d+)\.(\d+)\.(\d+)/); if (!match || Number(match[1]) < 0 || Number(match[2]) < 45) return { kind: "incompatible", message: `ast-grep >=0.45 is required; got ${result.stdout.trim()}` };
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function normalize(value: unknown, root: string): Finding {
|
||||
if (!value || typeof value !== "object") throw new Error("finding is not an object"); const row = value as Record<string, unknown>;
|
||||
const range = row.range as Record<string, unknown>; const start = range?.start as Record<string, unknown>; const end = range?.end as Record<string, unknown>;
|
||||
const text = typeof row.text === "string" ? row.text : ""; const rawPath = requiredString(row.file, "file"); const severity = row.severity;
|
||||
if (!start || !end || !Number.isInteger(start.line) || !Number.isInteger(start.column) || !Number.isInteger(end.line) || !Number.isInteger(end.column)) throw new Error("finding has invalid range");
|
||||
if (severity !== "hint" && severity !== "info" && severity !== "warning") throw new Error(`unsupported steering severity ${String(severity)}`);
|
||||
const path = isAbsolute(rawPath) ? relative(root, rawPath) : relative(root, resolve(root, rawPath));
|
||||
return Object.freeze({ ruleId: requiredString(row.ruleId, "ruleId"), severity: severity as Severity, message: requiredString(row.message, "message"), ...(typeof row.note === "string" ? { note: row.note } : {}), path, range: Object.freeze({ start: Object.freeze({ line: start.line as number, column: start.column as number }), end: Object.freeze({ line: end.line as number, column: end.column as number }) }), matchedTextHash: createHash("sha256").update(text).digest("hex") });
|
||||
}
|
||||
function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value) throw new Error(`finding has invalid ${name}`); return value; }
|
||||
function failure(key: string, revision: number, paths: readonly string[], started: number, analyzerFailure: AnalyzerFailure): AnalysisSnapshot { return Object.freeze({ analyzerKey: key, workspaceRevision: revision, paths: Object.freeze([...paths]), findings: Object.freeze([]), durationMs: Math.round(performance.now() - started), failure: Object.freeze(analyzerFailure) }); }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Diagnostic } from "./policy.ts";
|
||||
export class DiagnosticDelivery {
|
||||
private delivered = new Set<string>();
|
||||
constructor(private readonly limit = 20) {}
|
||||
select(diagnostics: readonly Diagnostic[]): readonly Diagnostic[] {
|
||||
const current = new Set(diagnostics.map(fingerprint));
|
||||
const fresh = diagnostics.filter((item) => !this.delivered.has(fingerprint(item))).slice(0, this.limit);
|
||||
this.delivered = current;
|
||||
return fresh;
|
||||
}
|
||||
clear(): void { this.delivered.clear(); }
|
||||
}
|
||||
export function fingerprint(diagnostic: Diagnostic): string { return createHash("sha256").update([diagnostic.policyId, diagnostic.ruleId, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.matchedTextHash].join("\0")).digest("hex"); }
|
||||
export function renderDiagnostics(diagnostics: readonly Diagnostic[], total = diagnostics.length): string {
|
||||
if (!diagnostics.length) return "";
|
||||
const lines = ["\n\n[pi-native-hooks diagnostics]"];
|
||||
for (const item of diagnostics) { lines.push(`- ${item.path}:${item.line + 1}:${item.column + 1} [${item.ruleId}] ${item.message}`); lines.push(` ${item.guidance}${item.exception ? ` Exception: ${item.exception}` : ""}`); }
|
||||
if (total > diagnostics.length) lines.push(`- … ${total - diagnostics.length} additional finding(s) omitted`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ObservedToolResult { readonly toolCallId: string; readonly toolName: string; readonly input: Record<string, unknown>; readonly content: readonly unknown[]; readonly details: unknown; readonly isError: boolean; readonly usage?: unknown }
|
||||
export interface WaveCall { readonly id: string; readonly toolName: string; readonly args: unknown; readonly sourceIndex: number; readonly potentiallyMutating: boolean; terminal: boolean; result?: ObservedToolResult }
|
||||
export interface CompletedWave { readonly id: string; readonly turnIndex: number; readonly calls: readonly WaveCall[]; readonly carrierId?: string; readonly potentiallyMutating: boolean }
|
||||
export type WavePatches = ReadonlyMap<string, { readonly content?: readonly unknown[]; readonly details?: unknown; readonly isError?: boolean; readonly usage?: unknown }>;
|
||||
interface ActiveWave { id: string; turnIndex: number; calls: WaveCall[]; finalized: boolean; timer?: NodeJS.Timeout; promise: Promise<WavePatches>; resolve(value: WavePatches): void }
|
||||
const READ_ONLY = new Set(["read", "grep", "find", "ls", "pi_hooks_waive_verification"]);
|
||||
export class ExecutionWaveCoordinator {
|
||||
private wave?: ActiveWave; private serial = 0; private turnIndex = 0; private stopped = false;
|
||||
constructor(private readonly finalize: (wave: CompletedWave) => Promise<WavePatches>, private readonly onUncertain: (message: string) => void, private readonly timeoutMs = 30_000) {}
|
||||
turnStarted(turnIndex: number): void { this.turnIndex = turnIndex; }
|
||||
start(toolCallId: string, toolName: string, args: unknown): { waveId: string; isNew: boolean; potentiallyMutating: boolean } | undefined {
|
||||
if (this.stopped) return undefined; const isNew = !this.wave || this.wave.calls.every((call) => call.terminal); if (isNew) this.wave = this.createWave();
|
||||
const wave = this.wave;
|
||||
if (!wave) return undefined;
|
||||
if (wave.calls.some((call) => call.id === toolCallId)) return { waveId: wave.id, isNew: false, potentiallyMutating: !READ_ONLY.has(toolName) };
|
||||
const potentiallyMutating = !READ_ONLY.has(toolName);
|
||||
wave.calls.push({ id: toolCallId, toolName, args, sourceIndex: wave.calls.length, potentiallyMutating, terminal: false });
|
||||
return { waveId: wave.id, isNew, potentiallyMutating };
|
||||
}
|
||||
async result(result: ObservedToolResult): Promise<{ readonly content?: readonly unknown[]; readonly details?: unknown; readonly isError?: boolean; readonly usage?: unknown } | undefined> {
|
||||
const wave = this.wave; if (!wave || this.stopped) return undefined; const call = wave.calls.find((item) => item.id === result.toolCallId); if (!call) { this.onUncertain(`tool_result without start: ${result.toolCallId}`); return undefined; }
|
||||
if (!call.result) call.result = result; call.terminal = true; this.maybeFinalize(wave); return (await wave.promise).get(result.toolCallId);
|
||||
}
|
||||
end(toolCallId: string): void { const wave = this.wave; if (!wave || this.stopped) return; const call = wave.calls.find((item) => item.id === toolCallId); if (!call) { this.onUncertain(`tool_execution_end without start: ${toolCallId}`); return; } if (!call.terminal) call.terminal = true; this.maybeFinalize(wave); }
|
||||
turnEnded(): void { if (this.wave && !this.wave.finalized && this.wave.calls.some((call) => !call.terminal)) this.releaseUncertain(this.wave, "turn ended with unterminated tool calls"); }
|
||||
shutdown(): void { this.stopped = true; if (this.wave && !this.wave.finalized) this.releaseUncertain(this.wave, "session shutdown"); }
|
||||
private createWave(): ActiveWave { let resolve!: (value: WavePatches) => void; const promise = new Promise<WavePatches>((done) => { resolve = done; }); const wave: ActiveWave = { id: `wave-${++this.serial}`, turnIndex: this.turnIndex, calls: [], finalized: false, promise, resolve }; wave.timer = setTimeout(() => this.releaseUncertain(wave, "execution-wave barrier timeout"), this.timeoutMs); wave.timer.unref?.(); return wave; }
|
||||
private maybeFinalize(wave: ActiveWave): void { if (wave.finalized || wave.calls.length === 0 || wave.calls.some((call) => !call.terminal)) return; wave.finalized = true; if (wave.timer) clearTimeout(wave.timer); const mutatingResults = wave.calls.filter((call) => call.potentiallyMutating && call.result); const participating = wave.calls.filter((call) => call.result); const carrier = (mutatingResults.at(-1) ?? participating.at(-1))?.id; const completed = Object.freeze({ id: wave.id, turnIndex: wave.turnIndex, calls: Object.freeze([...wave.calls]), ...(carrier ? { carrierId: carrier } : {}), potentiallyMutating: wave.calls.some((call) => call.potentiallyMutating) }); this.finalize(completed).then(wave.resolve, (error) => { this.onUncertain(`wave finalization failed: ${error instanceof Error ? error.message : String(error)}`); wave.resolve(new Map()); }); }
|
||||
private releaseUncertain(wave: ActiveWave, message: string): void { if (wave.finalized) return; wave.finalized = true; if (wave.timer) clearTimeout(wave.timer); this.onUncertain(message); wave.resolve(new Map()); }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { userInfo } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadPolicies, personalConfigRoot } from "./policy-loader.ts";
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const templateRoot = join(packageRoot, "examples", "personal-config");
|
||||
async function filesUnder(root: string, prefix = ""): Promise<string[]> { const result: string[] = []; for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { const path = join(prefix, entry.name); if (entry.isDirectory()) result.push(...await filesUnder(root, path)); else if (entry.isFile()) result.push(path); } return result.sort(); }
|
||||
export function registerInitializationCommands(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("hooks-init", { description: "Preview or initialize trusted personal native-hook policies", handler: async (args, ctx) => {
|
||||
const root = personalConfigRoot(); const files = await filesUnder(templateRoot); const plan = [`Personal config root: ${root}`, "Trusted executable files to create (existing files are preserved):", ...files.map((path) => `- ${join(root, path)}`)].join("\n");
|
||||
if (args.trim() === "--dry-run" || !args.trim()) { ctx.ui.notify(`${plan}\n\nRun /hooks-init --apply to request authorization.`, "info"); return; }
|
||||
if (args.trim() !== "--apply") { ctx.ui.notify("Usage: /hooks-init --dry-run | --apply", "warning"); return; }
|
||||
if (!ctx.hasUI || !await ctx.ui.confirm("Initialize pi-native-hooks?", `${plan}\n\nThese policies execute with Pi's authority. Continue?`)) { ctx.ui.notify("Initialization cancelled.", "info"); return; }
|
||||
const created: string[] = [];
|
||||
try { for (const path of files) { const target = join(root, path); try { await stat(target); continue; } catch {} await mkdir(dirname(target), { recursive: true, mode: 0o700 }); if (path === "tsconfig.json") { const template = await readFile(join(templateRoot, path), "utf8"); await writeFile(target, template.replaceAll("__PI_NATIVE_HOOKS_PACKAGE_ROOT__", packageRoot), { mode: 0o600 }); } else await copyFile(join(templateRoot, path), target); await chmod(target, 0o600); created.push(target); } ctx.ui.notify(`Created ${created.length} file(s); preserved ${files.length - created.length} existing file(s). Run /hooks-doctor then /reload.`, "info"); }
|
||||
catch (error) { for (const path of created.reverse()) await rm(path, { force: true }).catch(() => {}); ctx.ui.notify(`Initialization failed and newly created files were rolled back: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
||||
}});
|
||||
pi.registerCommand("hooks-doctor", { description: "Check native-hooks config trust and ast-grep availability", handler: async (_args, ctx) => { const registry = await loadPolicies(); const version = await pi.exec("ast-grep", ["--version"], { timeout: 5000 }); const lines = [registry.trustedCodeWarning, `Config: ${registry.root}`, `Policies: ${registry.policies.length}`, `Failures: ${registry.failures.length}`, ...registry.failures.map((failure) => `- ${failure.path}: ${failure.error}`), `ast-grep: ${version.code === 0 ? version.stdout.trim() : `unavailable (${version.stderr.trim()})`}`, `OS user: ${userInfo().username} uid ${userInfo().uid}`]; ctx.ui.notify(lines.join("\n"), registry.failures.length || version.code !== 0 ? "warning" : "info"); }});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type Severity = "hint" | "info" | "warning";
|
||||
|
||||
export interface Finding {
|
||||
readonly ruleId: string;
|
||||
readonly severity: Severity;
|
||||
readonly message: string;
|
||||
readonly note?: string;
|
||||
readonly path: string;
|
||||
readonly range: Readonly<{ start: Readonly<{ line: number; column: number }>; end: Readonly<{ line: number; column: number }> }>;
|
||||
readonly matchedTextHash: string;
|
||||
}
|
||||
|
||||
export interface AnalyzerFailure { readonly kind: "missing" | "incompatible" | "timeout" | "nonzero" | "malformed" | "killed" | "internal"; readonly message: string }
|
||||
export interface AnalysisSnapshot { readonly analyzerKey: string; readonly workspaceRevision: number; readonly paths: readonly string[]; readonly findings: readonly Finding[]; readonly durationMs: number; readonly failure?: AnalyzerFailure }
|
||||
export interface ProjectContext { readonly root: string; readonly packageName?: string; readonly identity: string; readonly git: boolean }
|
||||
export interface PolicyContext { readonly project: ProjectContext; readonly changedPaths: readonly string[]; readonly workspaceRevision: number }
|
||||
export interface Diagnostic { readonly policyId: string; readonly ruleId: string; readonly path: string; readonly line: number; readonly column: number; readonly message: string; readonly guidance: string; readonly exception?: string; readonly matchedTextHash: string }
|
||||
export interface DiagnosticPolicy { readonly kind: "diagnostic"; readonly id: string; readonly analyzerKeys: readonly string[]; appliesTo(project: ProjectContext, paths: readonly string[]): boolean; evaluate(analysis: ReadonlyMap<string, AnalysisSnapshot>, context: PolicyContext): readonly Diagnostic[] }
|
||||
export interface ProjectMatcher { matches(project: ProjectContext): boolean }
|
||||
export interface VerificationRequirement { readonly id: string; readonly command: string; readonly cwd: "project-root"; affects(path: string): boolean }
|
||||
export interface VerificationPolicy { readonly kind: "verification"; readonly id: string; readonly project: ProjectMatcher; readonly requirements: readonly VerificationRequirement[] }
|
||||
export type PersonalPolicy = DiagnosticPolicy | VerificationPolicy;
|
||||
|
||||
const isFn = (value: unknown): value is (...args: never[]) => unknown => typeof value === "function";
|
||||
export function validatePolicy(value: unknown): { ok: true; policy: PersonalPolicy } | { ok: false; error: string } {
|
||||
if (!value || typeof value !== "object") return { ok: false, error: "default export must be an object" };
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.id !== "string" || !candidate.id.trim()) return { ok: false, error: "policy id must be non-empty" };
|
||||
if (candidate.kind === "diagnostic") {
|
||||
if (!Array.isArray(candidate.analyzerKeys) || candidate.analyzerKeys.some((key) => typeof key !== "string" || !key)) return { ok: false, error: "diagnostic analyzerKeys must be non-empty strings" };
|
||||
if (!isFn(candidate.appliesTo) || !isFn(candidate.evaluate)) return { ok: false, error: "diagnostic policy requires appliesTo and evaluate functions" };
|
||||
return { ok: true, policy: value as DiagnosticPolicy };
|
||||
}
|
||||
if (candidate.kind === "verification") {
|
||||
const project = candidate.project as Record<string, unknown> | undefined;
|
||||
if (!project || !isFn(project.matches) || !Array.isArray(candidate.requirements)) return { ok: false, error: "verification policy requires project.matches and requirements" };
|
||||
for (const requirement of candidate.requirements as unknown[]) {
|
||||
const req = requirement as Record<string, unknown> | undefined;
|
||||
if (!req || typeof req.id !== "string" || !req.id || typeof req.command !== "string" || !req.command.trim() || req.cwd !== "project-root" || !isFn(req.affects)) return { ok: false, error: "invalid verification requirement" };
|
||||
}
|
||||
return { ok: true, policy: value as VerificationPolicy };
|
||||
}
|
||||
return { ok: false, error: "policy kind must be diagnostic or verification" };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import type { ProjectContext } from "./policy.ts";
|
||||
|
||||
export interface ExecResult { stdout: string; stderr: string; code: number | null; killed?: boolean }
|
||||
export type Exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => Promise<ExecResult>;
|
||||
|
||||
export async function discoverProject(cwd: string, exec: Exec, signal?: AbortSignal): Promise<ProjectContext> {
|
||||
const top = await exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { timeout: 5000, ...(signal ? { signal } : {}) });
|
||||
const git = top.code === 0;
|
||||
const root = await realpath(git ? top.stdout.trim() : cwd).catch(() => cwd);
|
||||
let packageName: string | undefined;
|
||||
try { const parsed = JSON.parse(await readFile(`${root}/package.json`, "utf8")) as { name?: unknown }; if (typeof parsed.name === "string") packageName = parsed.name; } catch {}
|
||||
let origin = "NO_HEAD";
|
||||
if (git) {
|
||||
const first = await exec("git", ["-C", root, "rev-list", "--max-parents=0", "HEAD"], { timeout: 5000, ...(signal ? { signal } : {}) });
|
||||
if (first.code === 0 && first.stdout.trim()) origin = first.stdout.trim().split("\n").sort()[0] ?? "NO_HEAD";
|
||||
}
|
||||
const identity = createHash("sha256").update(`${root}\0${origin}\0${packageName ?? ""}`).digest("hex");
|
||||
return Object.freeze({ root, ...(packageName ? { packageName } : {}), identity, git });
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { AnalysisCoordinator } from "./analysis.ts";
|
||||
import { AstGrepAdapter } from "./analyzers/ast-grep.ts";
|
||||
import { DiagnosticDelivery, renderDiagnostics } from "./diagnostics.ts";
|
||||
import { ExecutionWaveCoordinator, type CompletedWave, type ObservedToolResult, type WavePatches } from "./execution-wave.ts";
|
||||
import { loadPolicies, type PolicyRegistry } from "./policy-loader.ts";
|
||||
import type { Diagnostic, DiagnosticPolicy, ProjectContext, VerificationPolicy } from "./policy.ts";
|
||||
import { discoverProject } from "./project-context.ts";
|
||||
import { latestState, STATE_TYPE, type PersistedStateV1 } from "./state.ts";
|
||||
import { formatGate, VerificationTracker, type CheckAttempt } from "./verification.ts";
|
||||
import { explicitMutationPaths, WorkspaceInspector, type WorkspaceSnapshot } from "./workspace.ts";
|
||||
|
||||
const MESSAGE_TYPE = "pi-native-hooks-verification";
|
||||
export class NativeHooksRuntime {
|
||||
private registry?: PolicyRegistry; private project?: ProjectContext; private tracker?: VerificationTracker; private workspace?: WorkspaceSnapshot;
|
||||
private revision = 0; private diagnostics = new DiagnosticDelivery(); private lastDiagnosticCount = 0; private lastAnalyzer = "never"; private settlementRunning = false; private analysis?: AnalysisCoordinator; private astGrep?: AstGrepAdapter;
|
||||
private readonly baselines = new Map<string, WorkspaceSnapshot>(); private readonly attempts = new Map<string, readonly CheckAttempt[]>();
|
||||
private readonly inspector: WorkspaceInspector; private readonly waves: ExecutionWaveCoordinator;
|
||||
constructor(private readonly pi: ExtensionAPI) {
|
||||
const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options);
|
||||
this.inspector = new WorkspaceInspector(exec);
|
||||
this.waves = new ExecutionWaveCoordinator((wave) => this.finalizeWave(wave), (message) => { this.tracker?.markUnknown(message); this.baselines.clear(); this.attempts.clear(); this.notify(message, "error"); });
|
||||
this.register();
|
||||
}
|
||||
private register(): void {
|
||||
this.pi.on("session_start", async (_event, ctx) => this.initialize(ctx));
|
||||
this.pi.on("session_tree", async (_event, ctx) => this.restoreBranch(ctx));
|
||||
this.pi.on("turn_start", (event) => this.waves.turnStarted(event.turnIndex));
|
||||
this.pi.on("tool_execution_start", async (event, ctx) => { const started = this.waves.start(event.toolCallId, event.toolName, event.args); if (started?.potentiallyMutating && !this.baselines.has(started.waveId)) this.baselines.set(started.waveId, await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal)); });
|
||||
this.pi.on("tool_call", (event, ctx) => { if (event.toolName === "bash" && this.tracker && typeof event.input.command === "string") this.attempts.set(event.toolCallId, this.tracker.beginCheck(event.toolCallId, event.input.command, ctx.cwd)); });
|
||||
this.pi.on("tool_result", async (event) => this.onToolResult(event));
|
||||
this.pi.on("tool_execution_end", (event) => this.waves.end(event.toolCallId));
|
||||
this.pi.on("turn_end", () => this.waves.turnEnded());
|
||||
this.pi.on("agent_settled", async (_event, ctx) => this.onSettled(ctx));
|
||||
this.pi.on("context", (event) => { const custom = event.messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "custom" && message.customType === MESSAGE_TYPE); const keep = this.tracker?.outstanding().length ? custom.at(-1)?.index : undefined; return { messages: event.messages.filter((message, index) => message.role !== "custom" || message.customType !== MESSAGE_TYPE || index === keep) }; });
|
||||
this.pi.on("session_shutdown", (_event, ctx) => { this.waves.shutdown(); ctx.ui.setStatus("pi-native-hooks", undefined); });
|
||||
const thisRuntime = this;
|
||||
this.pi.registerTool({ name: "pi_hooks_waive_verification", label: "Waive verification", description: "Waive all currently outstanding pi-native-hooks checks for their current revisions. A non-empty reason is required and remains auditable.", promptSnippet: "Waive pending native-hook verification with an explicit reason", parameters: Type.Object({ reason: Type.String({ minLength: 1 }) }), async execute(toolCallId, params) { return thisRuntime.waive(toolCallId, params.reason); } });
|
||||
this.pi.registerCommand("hooks-status", { description: "Show pi-native-hooks policy, analyzer, and verification status", handler: async (_args, ctx) => ctx.ui.notify(this.statusText(), "info") });
|
||||
}
|
||||
private async initialize(ctx: ExtensionContext): Promise<void> {
|
||||
const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options);
|
||||
this.registry = await loadPolicies(); this.project = await discoverProject(ctx.cwd, exec, ctx.signal); this.workspace = await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal);
|
||||
this.astGrep = new AstGrepAdapter(exec, this.project.root, `${this.registry.root}/sgconfig.yml`); this.analysis = new AnalysisCoordinator(this.registry.analyzerConfigSafe ? new Map([["ast-grep:personal", this.astGrep]]) : new Map());
|
||||
if (this.registry.analyzerConfigSafe && this.diagnosticPolicies().length) { const failure = await this.astGrep.prepare(ctx.signal); if (failure) this.lastAnalyzer = `ast-grep:personal degraded (${failure.message})`; }
|
||||
this.tracker = new VerificationTracker(this.project); this.tracker.configure(this.verificationPolicies()); await this.restoreBranch(ctx); this.updateStatus(ctx);
|
||||
if (!this.registry.policies.length && !this.registry.failures.length) this.notify("No personal policies found. Run /hooks-init --dry-run for initialization guidance.", "info", ctx);
|
||||
for (const failure of this.registry.failures) this.notify(`Policy load failed: ${failure.path}: ${failure.error}`, "error", ctx);
|
||||
}
|
||||
private async restoreBranch(ctx: ExtensionContext): Promise<void> {
|
||||
if (!this.registry || !this.project || !this.tracker || !this.workspace) return; const restored = latestState(ctx.sessionManager.getBranch());
|
||||
if (restored.malformed) this.notify(`Ignored ${restored.malformed} malformed native-hooks state entr${restored.malformed === 1 ? "y" : "ies"}.`, "warning", ctx);
|
||||
const state = restored.state; if (!state) return;
|
||||
if (state.projectIdentity !== this.project.identity) { this.tracker.restore(state.requirements.map((record) => ({ ...record, status: record.status === "inactive" ? "inactive" : "unknown" })), state.workspaceRevision); return; }
|
||||
this.tracker.restore(state.requirements, state.workspaceRevision); this.revision = Math.max(this.revision, state.workspaceRevision);
|
||||
if (state.policyDigest !== this.registry.digest) this.tracker.markUnknown("Policy registry changed since evidence was recorded");
|
||||
if (state.workspaceFingerprint !== this.workspace.fingerprint && this.tracker.records().some((record) => record.status !== "inactive")) this.tracker.markUnknown("Workspace changed outside the observed session");
|
||||
}
|
||||
private async onToolResult(event: ToolResultEvent) {
|
||||
const observed: ObservedToolResult = { toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, content: event.content, details: event.details, isError: event.isError, ...(event.usage ? { usage: event.usage } : {}) };
|
||||
const patch = await this.waves.result(observed); if (!patch) return; return patch as { content?: typeof event.content; details?: unknown; isError?: boolean; usage?: typeof event.usage };
|
||||
}
|
||||
private async finalizeWave(wave: CompletedWave): Promise<WavePatches> {
|
||||
if (!this.project || !this.workspace || !this.tracker || !this.registry) return new Map(); const baseline = this.baselines.get(wave.id); this.baselines.delete(wave.id); let changedPaths: readonly string[] = []; let changed = false; let provable = true;
|
||||
if (wave.potentiallyMutating) {
|
||||
const after = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path));
|
||||
if (baseline) { const diff = this.inspector.diff(baseline, after); changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(baseline, after)])].sort()); changed = diff.changed; provable = diff.provable && (baseline.head === after.head || changedPaths.length > 0); }
|
||||
else { changed = true; provable = false; }
|
||||
const explicit = Object.freeze(wave.calls.flatMap((call) => call.result && !call.result.isError ? explicitMutationPaths(call.toolName, call.result.input) : []));
|
||||
if (!provable && explicit.length) { changed = true; changedPaths = explicit; }
|
||||
else if (changed && !changedPaths.length) changedPaths = explicit;
|
||||
if (changed) { this.revision++; this.tracker.observeMutation(changedPaths, this.revision, provable || changedPaths.length > 0); }
|
||||
this.workspace = after;
|
||||
}
|
||||
const mutatingCount = wave.calls.filter((call) => call.potentiallyMutating).length;
|
||||
for (const call of wave.calls) if (call.toolName === "bash" && call.result) { const attempts = this.attempts.get(call.id) ?? []; this.attempts.delete(call.id); const finalCommand = typeof call.result.input.command === "string" ? call.result.input.command : ""; const finalized = attempts.filter((attempt) => attempt.command === finalCommand); this.tracker.finishCheck(finalized, !call.result.isError, mutatingCount === 1 && !changed); }
|
||||
let diagnostics: readonly Diagnostic[] = [];
|
||||
if (changedPaths.length) diagnostics = await this.evaluateDiagnostics(changedPaths);
|
||||
const fresh = this.diagnostics.select(diagnostics); this.lastDiagnosticCount = diagnostics.length;
|
||||
this.persist(changed ? "workspace-revision" : "verification-evidence");
|
||||
if (!fresh.length || !wave.carrierId) return new Map(); const carrier = wave.calls.find((call) => call.id === wave.carrierId)?.result; if (!carrier) return new Map();
|
||||
const block = renderDiagnostics(fresh, diagnostics.length); const content = [...carrier.content, { type: "text", text: block }]; return new Map([[wave.carrierId, { content }]]);
|
||||
}
|
||||
private async evaluateDiagnostics(paths: readonly string[]): Promise<readonly Diagnostic[]> {
|
||||
if (!this.project || !this.registry || !this.analysis) return []; const policies = this.diagnosticPolicies().filter((policy) => { try { return policy.appliesTo(this.project!, paths); } catch (error) { this.notify(`Policy ${policy.id} appliesTo failed: ${String(error)}`, "error"); return false; } });
|
||||
const requests = policies.flatMap((policy) => policy.analyzerKeys.map((analyzerKey) => ({ analyzerKey, paths })));
|
||||
const snapshots = await this.analysis.run(requests, this.revision); this.lastAnalyzer = [...snapshots.values()].map((snapshot) => snapshot.failure ? `${snapshot.analyzerKey}: degraded (${snapshot.failure.message})` : `${snapshot.analyzerKey}: ${snapshot.durationMs}ms`).join(", ") || "not applicable";
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
for (const snapshot of snapshots.values()) if (snapshot.failure) diagnostics.push({ policyId: "pi-native-hooks-analyzer", ruleId: snapshot.analyzerKey, path: paths[0] ?? ".", line: 0, column: 0, message: `Analyzer degraded: ${snapshot.failure.message}`, guidance: "Diagnostics could not establish a clean scan; inspect /hooks-status and /hooks-doctor.", matchedTextHash: snapshot.failure.message });
|
||||
for (const policy of policies) { try { diagnostics.push(...policy.evaluate(snapshots, Object.freeze({ project: this.project, changedPaths: Object.freeze([...paths]), workspaceRevision: this.revision }))); } catch (error) { this.notify(`Policy ${policy.id} evaluation failed: ${String(error)}`, "error"); } }
|
||||
return Object.freeze(diagnostics);
|
||||
}
|
||||
private async onSettled(ctx: ExtensionContext): Promise<void> { if (this.settlementRunning || !this.tracker || !this.project || !this.workspace) return; this.settlementRunning = true; try { const live = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); if (live.fingerprint !== this.workspace.fingerprint) { const before = this.workspace; const diff = this.inspector.diff(before, live); const changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(before, live)])].sort()); this.revision++; this.tracker.observeMutation(changedPaths, this.revision, diff.provable && changedPaths.length > 0); this.workspace = live; this.persist("settlement-refresh"); } const outstanding = this.tracker.outstanding(); this.updateStatus(ctx); if (outstanding.length) this.pi.sendMessage({ customType: MESSAGE_TYPE, content: formatGate(outstanding), display: true, details: { revisions: outstanding.map((record) => [record.key, record.requiredRevision]) } }, { deliverAs: "followUp", triggerTurn: true }); } finally { this.settlementRunning = false; } }
|
||||
private waive(toolCallId: string, reason: string) { if (!this.tracker) throw new Error("pi-native-hooks has not initialized"); const waived = this.tracker.waive(reason, toolCallId); this.persist("waiver"); return { content: [{ type: "text" as const, text: `Verification skipped (not passed) for: ${waived.map((record) => record.key).join(", ")}. Reason: ${reason.trim()}` }], details: { waived: waived.map((record) => ({ key: record.key, revision: record.requiredRevision })), reason: reason.trim() } }; }
|
||||
private persist(reason: string): void { if (!this.registry || !this.project || !this.workspace || !this.tracker) return; const state: PersistedStateV1 = { version: 1, policyDigest: this.registry.digest, projectIdentity: this.project.identity, workspaceRevision: this.revision, workspaceFingerprint: this.workspace.fingerprint, requirements: this.tracker.records(), reason, timestamp: new Date().toISOString() }; this.pi.appendEntry(STATE_TYPE, state); }
|
||||
private pathRelevant(path: string): boolean { for (const policy of this.verificationPolicies()) for (const requirement of policy.requirements) { try { if (requirement.affects(path)) return true; } catch { return true; } } return /\.(?:ts|tsx)$/.test(path); }
|
||||
private diagnosticPolicies(): DiagnosticPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is DiagnosticPolicy => policy.kind === "diagnostic") ?? []; }
|
||||
private verificationPolicies(): VerificationPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is VerificationPolicy => policy.kind === "verification") ?? []; }
|
||||
private statusText(): string { const records = this.tracker?.records() ?? []; return [`Config: ${this.registry?.root ?? "not initialized"}`, this.registry?.trustedCodeWarning ?? "", `Policies: ${this.registry?.policies.map((item) => `${item.policy.id} ${item.digest.slice(0, 12)} (${item.path})`).join(", ") || "none"}`, `Load failures: ${this.registry?.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ") || "none"}`, `Project: ${this.project?.identity ?? "unknown"} (${this.project?.root ?? "unknown"})`, `Workspace revision: ${this.revision}`, `Analyzer: ${this.lastAnalyzer}`, `Diagnostics: ${this.lastDiagnosticCount}`, "Verification:", ...records.map((record) => `- ${record.key}: ${record.status} r${record.requiredRevision ?? "-"}${record.waiver ? ` waived: ${record.waiver.reason}` : ""}${record.lastFailure ? ` (${record.lastFailure})` : ""}`)].filter(Boolean).join("\n"); }
|
||||
private updateStatus(ctx: ExtensionContext): void { const outstanding = this.tracker?.outstanding().length ?? 0; ctx.ui.setStatus("pi-native-hooks", `hooks ${this.lastDiagnosticCount}d ${outstanding ? `${outstanding} pending` : "verified"}`); }
|
||||
private notify(message: string, level: "info" | "warning" | "error", ctx?: ExtensionContext): void { if (ctx) ctx.ui.notify(message, level); else console.error(`[pi-native-hooks] ${message}`); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RequirementRecord } from "./verification.ts";
|
||||
export const STATE_TYPE = "pi-native-hooks-state-v1";
|
||||
export interface PersistedStateV1 { readonly version: 1; readonly policyDigest: string; readonly projectIdentity: string; readonly workspaceRevision: number; readonly workspaceFingerprint: string; readonly requirements: readonly RequirementRecord[]; readonly reason: string; readonly timestamp: string }
|
||||
const statuses = new Set(["inactive", "not-run", "passed", "failed", "stale", "waived", "unknown"]);
|
||||
export function isPersistedState(value: unknown): value is PersistedStateV1 {
|
||||
if (!value || typeof value !== "object") return false; const state = value as Record<string, unknown>;
|
||||
if (state.version !== 1 || typeof state.policyDigest !== "string" || typeof state.projectIdentity !== "string" || !Number.isInteger(state.workspaceRevision) || typeof state.workspaceFingerprint !== "string" || !Array.isArray(state.requirements) || typeof state.reason !== "string" || typeof state.timestamp !== "string") return false;
|
||||
return state.requirements.every((value) => { if (!value || typeof value !== "object") return false; const record = value as Record<string, unknown>; return typeof record.key === "string" && typeof record.policyId === "string" && typeof record.requirementId === "string" && typeof record.command === "string" && statuses.has(record.status as string) && (record.requiredRevision === undefined || Number.isInteger(record.requiredRevision)); });
|
||||
}
|
||||
export function latestState(entries: readonly unknown[]): { state?: PersistedStateV1; malformed: number } {
|
||||
let state: PersistedStateV1 | undefined; let malformed = 0;
|
||||
for (const entry of entries) { const row = entry as { type?: unknown; customType?: unknown; data?: unknown }; if (row?.type !== "custom" || row.customType !== STATE_TYPE) continue; if (isPersistedState(row.data)) state = row.data; else malformed++; }
|
||||
return { ...(state ? { state } : {}), malformed };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { ProjectContext, VerificationPolicy, VerificationRequirement } from "./policy.ts";
|
||||
|
||||
export type RequirementStatus = "inactive" | "not-run" | "passed" | "failed" | "stale" | "waived" | "unknown";
|
||||
export interface Evidence { readonly revision: number; readonly timestamp: string; readonly toolCallId: string; readonly projectIdentity: string }
|
||||
export interface Waiver { readonly revision: number; readonly reason: string; readonly timestamp: string; readonly toolCallId: string; readonly projectIdentity: string }
|
||||
export interface RequirementRecord { readonly key: string; readonly policyId: string; readonly requirementId: string; readonly command: string; readonly status: RequirementStatus; readonly requiredRevision?: number | undefined; readonly evidence?: Evidence | undefined; readonly waiver?: Waiver | undefined; readonly lastFailure?: string | undefined }
|
||||
interface ActiveRequirement { policyId: string; requirement: VerificationRequirement; record: RequirementRecord }
|
||||
export interface CheckAttempt { readonly key: string; readonly command: string; readonly startRevision: number; readonly toolCallId: string; readonly exact: boolean }
|
||||
|
||||
export class VerificationTracker {
|
||||
private readonly active = new Map<string, ActiveRequirement>();
|
||||
private orphaned: RequirementRecord[] = [];
|
||||
constructor(readonly project: ProjectContext, private workspaceRevision = 0) {}
|
||||
configure(policies: readonly VerificationPolicy[]): void {
|
||||
const previous = new Map(this.active);
|
||||
this.active.clear();
|
||||
for (const policy of policies) {
|
||||
let matches = false; try { matches = policy.project.matches(this.project); } catch { continue; } if (!matches) continue;
|
||||
for (const requirement of policy.requirements) {
|
||||
const key = `${policy.id}:${requirement.id}`; const old = previous.get(key)?.record; previous.delete(key);
|
||||
this.active.set(key, { policyId: policy.id, requirement, record: old && old.command === requirement.command.trim() ? old : { key, policyId: policy.id, requirementId: requirement.id, command: requirement.command.trim(), status: "inactive" } });
|
||||
}
|
||||
}
|
||||
this.orphaned = [...previous.values()].map((item) => ({ ...item.record, status: item.record.status === "inactive" ? "inactive" : "unknown", lastFailure: "Previously armed policy is missing or no longer matches" }));
|
||||
}
|
||||
restore(records: readonly RequirementRecord[], restoredRevision: number): void {
|
||||
this.workspaceRevision = Math.max(this.workspaceRevision, restoredRevision);
|
||||
const byKey = new Map(records.map((record) => [record.key, record]));
|
||||
for (const active of this.active.values()) { const restored = byKey.get(active.record.key); if (restored && restored.command === active.record.command) active.record = restored; byKey.delete(active.record.key); }
|
||||
this.orphaned = [...byKey.values()].filter((record) => record.status !== "inactive").map((record) => ({ ...record, status: "unknown", lastFailure: "Policy unavailable after restore" }));
|
||||
}
|
||||
observeMutation(paths: readonly string[], revision: number, provable = true): void {
|
||||
this.workspaceRevision = Math.max(this.workspaceRevision, revision);
|
||||
this.orphaned = this.orphaned.map((record) => record.status === "inactive" ? record : { ...record, status: "unknown", evidence: undefined, waiver: undefined, requiredRevision: revision, lastFailure: "Mutation observed while the original policy was unavailable" });
|
||||
for (const active of this.active.values()) {
|
||||
let affected = false; try { affected = paths.some((path) => active.requirement.affects(path)); } catch { affected = true; }
|
||||
if (!provable && active.record.status !== "inactive") { active.record = { ...active.record, status: "unknown", requiredRevision: revision, evidence: undefined, waiver: undefined, lastFailure: "Workspace mutation could not be proven" }; continue; }
|
||||
if (!affected) continue;
|
||||
const hadEvidence = active.record.evidence !== undefined;
|
||||
active.record = { ...active.record, status: hadEvidence ? "stale" : "not-run", requiredRevision: revision, evidence: undefined, waiver: undefined, lastFailure: undefined };
|
||||
}
|
||||
}
|
||||
markUnknown(message: string): void { for (const active of this.active.values()) if (active.record.status !== "inactive") active.record = { ...active.record, status: "unknown", evidence: undefined, waiver: undefined, lastFailure: message }; }
|
||||
beginCheck(toolCallId: string, command: string, cwd: string): readonly CheckAttempt[] {
|
||||
return [...this.active.values()].filter((active) => active.record.status !== "inactive" && active.requirement.command.trim() === command.trim()).map((active) => ({ key: active.record.key, command, startRevision: this.workspaceRevision, toolCallId, exact: command === active.requirement.command.trim() && samePath(cwd, this.project.root) }));
|
||||
}
|
||||
finishCheck(attempts: readonly CheckAttempt[], success: boolean, stableWave: boolean): void {
|
||||
for (const attempt of attempts) { const active = this.active.get(attempt.key); if (!active || active.record.requiredRevision === undefined) continue;
|
||||
if (!attempt.exact || !stableWave || attempt.startRevision < active.record.requiredRevision || this.workspaceRevision !== attempt.startRevision) continue;
|
||||
if (success) active.record = { ...active.record, status: "passed", evidence: { revision: active.record.requiredRevision, timestamp: new Date().toISOString(), toolCallId: attempt.toolCallId, projectIdentity: this.project.identity }, waiver: undefined, lastFailure: undefined };
|
||||
else active.record = { ...active.record, status: "failed", evidence: undefined, waiver: undefined, lastFailure: `Exact command failed: ${attempt.command}` };
|
||||
}
|
||||
}
|
||||
waive(reason: string, toolCallId: string): readonly RequirementRecord[] {
|
||||
const trimmed = reason.trim(); if (!trimmed) throw new Error("Waiver reason must be non-empty"); const outstanding = this.outstanding(); if (!outstanding.length) throw new Error("No armed verification requirement is outstanding");
|
||||
const keys = new Set(outstanding.map((record) => record.key));
|
||||
for (const [key, active] of this.active) if (keys.has(key) && active.record.requiredRevision !== undefined) active.record = { ...active.record, status: "waived", waiver: { revision: active.record.requiredRevision, reason: trimmed, timestamp: new Date().toISOString(), toolCallId, projectIdentity: this.project.identity }, evidence: undefined };
|
||||
for (const record of this.orphaned) if (keys.has(record.key) && record.requiredRevision !== undefined) Object.assign(record, { status: "waived", waiver: { revision: record.requiredRevision, reason: trimmed, timestamp: new Date().toISOString(), toolCallId, projectIdentity: this.project.identity } });
|
||||
return outstanding;
|
||||
}
|
||||
outstanding(): RequirementRecord[] { return this.records().filter((record) => record.status !== "inactive" && record.status !== "passed" && record.status !== "waived"); }
|
||||
records(): RequirementRecord[] { return [...this.active.values()].map((item) => ({ ...item.record } as RequirementRecord)).concat(this.orphaned.map((record) => ({ ...record }))); }
|
||||
revision(): number { return this.workspaceRevision; }
|
||||
}
|
||||
function samePath(left: string, right: string): boolean { return resolve(left) === resolve(right); }
|
||||
export function formatGate(records: readonly RequirementRecord[]): string { const commands = [...new Set(records.map((record) => record.command))]; return ["Required verification is pending or stale. Run each exact standalone command from the project root, or call pi_hooks_waive_verification with a non-empty reason:", ...commands.map((command) => `- ${command}`), "Outstanding: " + records.map((record) => `${record.key} (${record.status})`).join(", ")].join("\n"); }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readFile, readlink, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Exec } from "./project-context.ts";
|
||||
|
||||
export interface WorkspaceSnapshot { readonly kind: "git" | "fallback" | "unknown"; readonly root: string; readonly fingerprint: string; readonly entries: ReadonlyMap<string, string>; readonly head?: string }
|
||||
export interface WorkspaceDiff { readonly changedPaths: readonly string[]; readonly changed: boolean; readonly provable: boolean }
|
||||
export class WorkspaceInspector {
|
||||
constructor(private readonly exec: Exec) {}
|
||||
async capture(cwd: string, relevant: (path: string) => boolean = () => true, signal?: AbortSignal): Promise<WorkspaceSnapshot> {
|
||||
const top = await this.exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"], opts(signal));
|
||||
if (top.code !== 0) return Object.freeze({ kind: "unknown", root: cwd, fingerprint: "unknown", entries: new Map() });
|
||||
const root = await realpath(top.stdout.trim()).catch(() => top.stdout.trim());
|
||||
const [head, status] = await Promise.all([
|
||||
this.exec("git", ["-C", root, "rev-parse", "HEAD"], opts(signal)),
|
||||
this.exec("git", ["-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"], opts(signal)),
|
||||
]);
|
||||
if (status.code !== 0 || Buffer.byteLength(status.stdout) > 2_000_000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() });
|
||||
const paths = parseStatus(status.stdout); if (paths.size > 2000) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() });
|
||||
const dirtyPaths = [...paths.keys()]; const index = dirtyPaths.length ? await this.exec("git", ["-C", root, "ls-files", "--stage", "-z", "--", ...dirtyPaths], opts(signal)) : { stdout: "", stderr: "", code: 0 };
|
||||
if (index.code !== 0) return Object.freeze({ kind: "unknown", root, fingerprint: "unknown", entries: new Map() });
|
||||
const indexEntries = parseIndex(index.stdout);
|
||||
const entries = new Map<string, string>();
|
||||
for (const [path, state] of paths) {
|
||||
let content = "irrelevant";
|
||||
if (relevant(path)) content = await hashPath(join(root, path));
|
||||
entries.set(path, `${state}\0${indexEntries.get(path) ?? "untracked"}\0${content}`);
|
||||
}
|
||||
const headValue = head.code === 0 ? head.stdout.trim() : "NO_HEAD";
|
||||
const indexHash = createHash("sha256").update(index.stdout).digest("hex");
|
||||
const material = [headValue, indexHash, ...[...entries].sort(([a], [b]) => a.localeCompare(b)).flatMap(([path, value]) => [path, value])].join("\0");
|
||||
return Object.freeze({ kind: "git", root, fingerprint: createHash("sha256").update(material).digest("hex"), entries, head: headValue });
|
||||
}
|
||||
diff(before: WorkspaceSnapshot, after: WorkspaceSnapshot): WorkspaceDiff {
|
||||
if (before.kind !== "git" || after.kind !== "git" || before.root !== after.root) return Object.freeze({ changedPaths: Object.freeze([]), changed: before.fingerprint !== after.fingerprint, provable: false });
|
||||
const paths = new Set([...before.entries.keys(), ...after.entries.keys()]);
|
||||
const changedPaths = [...paths].filter((path) => before.entries.get(path) !== after.entries.get(path)).sort();
|
||||
return Object.freeze({ changedPaths: Object.freeze([...new Set(changedPaths)]), changed: before.fingerprint !== after.fingerprint, provable: true });
|
||||
}
|
||||
async headChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot, signal?: AbortSignal): Promise<readonly string[]> {
|
||||
if (before.kind !== "git" || after.kind !== "git" || !before.head || !after.head || before.head === after.head) return [];
|
||||
const result = await this.exec("git", ["-C", after.root, "diff", "--name-only", "-z", before.head, after.head, "--"], opts(signal));
|
||||
if (result.code !== 0 || Buffer.byteLength(result.stdout) > 2_000_000) return [];
|
||||
return Object.freeze(result.stdout.split("\0").filter(Boolean).sort());
|
||||
}
|
||||
}
|
||||
function opts(signal?: AbortSignal) { return { timeout: 10_000, ...(signal ? { signal } : {}) }; }
|
||||
function parseStatus(output: string): Map<string, string> {
|
||||
const tokens = output.split("\0"); const result = new Map<string, string>();
|
||||
for (let index = 0; index < tokens.length; index++) {
|
||||
const token = tokens[index]; if (!token || token.length < 4) continue;
|
||||
const state = token.slice(0, 2); const path = token.slice(3); result.set(path, state);
|
||||
if (state.includes("R") || state.includes("C")) { const oldPath = tokens[++index]; if (oldPath) result.set(oldPath, `${state}:source`); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function parseIndex(output: string): Map<string, string> { const result = new Map<string, string>(); for (const token of output.split("\0")) { if (!token) continue; const tab = token.indexOf("\t"); if (tab > 0) result.set(token.slice(tab + 1), token.slice(0, tab)); } return result; }
|
||||
async function hashPath(path: string): Promise<string> {
|
||||
try { const stat = await lstat(path); if (stat.isSymbolicLink()) return `symlink:${await readlink(path)}`; if (!stat.isFile()) return `mode:${stat.mode}:size:${stat.size}`; return createHash("sha256").update(await readFile(path)).digest("hex"); }
|
||||
catch { return "missing"; }
|
||||
}
|
||||
export function explicitMutationPaths(toolName: string, input: Record<string, unknown>): readonly string[] { if ((toolName === "write" || toolName === "edit") && typeof input.path === "string") return Object.freeze([input.path]); return Object.freeze([]); }
|
||||
Reference in New Issue
Block a user