Files
my-pi/pi-permission-system/src/auto-review/ledger.ts
T

297 lines
9.8 KiB
TypeScript

import { createHash } from "node:crypto";
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import type { PromptPermissionDetails } from "../service.js";
import type { AutoReviewAnalysisConfig, AutoReviewConfig } from "./config.js";
import type { ReviewAssessment } from "./verdict.js";
const DIRECTORY_MODE = 0o700;
const FILE_MODE = 0o600;
const MAX_PATTERN_LENGTH = 240;
interface LedgerState {
version: 1;
completedSequence: number;
lastAnalyzedSequence: number;
proposalSequence: number;
analysisPending: boolean;
}
export interface AutoReviewLedgerRecord {
version: 1;
sequence: number;
timestamp: string;
requestId: string;
source: string;
agentName: string;
surface: string;
toolName?: string;
normalizedPattern: string;
fingerprint: string;
outcome: "allow" | "deny";
riskLevel: ReviewAssessment["riskLevel"];
userAuthorization: ReviewAssessment["userAuthorization"];
authorizationDependent: boolean;
policyRevision: string;
policyHash: string;
provider: string;
model: string;
}
export interface AutoReviewAnalysisBatch {
fromSequence: number;
toSequence: number;
records: AutoReviewLedgerRecord[];
policyHash: string;
}
export interface StoredAnalysisProposal {
version: 1;
proposalId: string;
createdAt: string;
fromSequence: number;
toSequence: number;
policyHash: string;
proposal: unknown;
}
function initialState(): LedgerState {
return {
version: 1,
completedSequence: 0,
lastAnalyzedSequence: 0,
proposalSequence: 0,
analysisPending: false,
};
}
function hash(value: unknown): string {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function bounded(value: string): string {
return value.length <= MAX_PATTERN_LENGTH
? value
: `${value.slice(0, MAX_PATTERN_LENGTH - 1)}…`;
}
const SAFE_SUBCOMMAND_COMMANDS = new Set([
"bun", "cargo", "docker", "git", "go", "kubectl", "npm", "pnpm",
"sudo", "systemctl", "yarn",
]);
const SAFE_PATH_PREFIXES = new Set([
"app", "apps", "config", "docs", "etc", "lib", "opt", "packages",
"private", "src", "test", "tests", "tmp", "Users", "var", "workspace",
"workspaces",
]);
function normalizedExecutable(value: string): string {
const executable = value.replace(/\\/g, "/").split("/").filter(Boolean).at(-1) ?? "";
return /^[A-Za-z0-9._+-]{1,64}$/u.test(executable) ? executable : "<command>";
}
function normalizeCommand(command: string): string {
const words = command.trim().replace(/\s+/g, " ").split(" ").filter(Boolean);
if (words.length === 0) return "<empty-command>";
const executable = normalizedExecutable(words[0]!);
const subcommand = words[1];
if (
subcommand !== undefined &&
SAFE_SUBCOMMAND_COMMANDS.has(executable) &&
/^[A-Za-z0-9._+-]{1,64}$/u.test(subcommand)
) {
return bounded(`${executable} ${subcommand} *`);
}
return bounded(words.length === 1 ? executable : `${executable} *`);
}
function normalizePath(value: string): string {
const normalized = value.trim().replace(/\\/g, "/");
if (normalized.length === 0) return "<empty-path>";
if (normalized === "~" || normalized.startsWith("~/")) return "~/*";
const absolute = normalized.startsWith("/");
const segments = normalized.split("/").filter(Boolean);
const first = segments[0];
if (first && SAFE_PATH_PREFIXES.has(first)) {
return bounded(`${absolute ? "/" : ""}${first}/*`);
}
if (!absolute && segments.length === 1) {
const extension = normalized.match(/\.[A-Za-z0-9]{1,10}$/u)?.[0];
if (extension) return `*${extension.toLowerCase()}`;
}
return absolute ? "/<path>/*" : "<relative>/*";
}
function derivePattern(details: PromptPermissionDetails): string {
if (typeof details.command === "string") return normalizeCommand(details.command);
if (typeof details.path === "string") return normalizePath(details.path);
const values = details.accessIntent?.matchValues;
if (Array.isArray(values) && typeof values[0] === "string") return normalizePath(values[0]);
if (details.toolName) return bounded(details.toolName);
return bounded(details.surface ?? "unknown");
}
function readJson<T>(path: string): T | undefined {
if (!existsSync(path)) return undefined;
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch {
return undefined;
}
}
export class AutoReviewLedger {
private readonly ledgerDir: string;
private readonly proposalsDir: string;
private readonly statePath: string;
private readonly decisionsPath: string;
private state: LedgerState;
constructor(rootDir: string) {
this.ledgerDir = join(rootDir, "ledger");
this.proposalsDir = join(rootDir, "proposals");
this.statePath = join(rootDir, "state.json");
this.decisionsPath = join(this.ledgerDir, "decisions.jsonl");
for (const dir of [rootDir, this.ledgerDir, this.proposalsDir]) {
mkdirSync(dir, { recursive: true, mode: DIRECTORY_MODE });
try { chmodSync(dir, DIRECTORY_MODE); } catch { /* best effort on non-POSIX */ }
}
this.state = readJson<LedgerState>(this.statePath) ?? initialState();
this.persistState();
}
recordDecision(event: {
details: PromptPermissionDetails;
assessment: ReviewAssessment;
policyRevision: string;
config: AutoReviewConfig;
analysis: AutoReviewAnalysisConfig;
}): AutoReviewLedgerRecord {
const sequence = this.state.completedSequence + 1;
const pattern = derivePattern(event.details);
const surface = bounded(event.details.accessIntent?.surface ?? event.details.surface ?? "unknown");
const toolName = event.details.toolName ? bounded(event.details.toolName) : undefined;
const record: AutoReviewLedgerRecord = {
version: 1,
sequence,
timestamp: new Date().toISOString(),
requestId: bounded(event.details.requestId),
source: bounded(event.details.source),
agentName: bounded(event.details.agentName ?? "main"),
surface,
...(toolName ? { toolName } : {}),
normalizedPattern: pattern,
fingerprint: hash({ surface, toolName, pattern }),
outcome: event.assessment.outcome,
riskLevel: event.assessment.riskLevel,
userAuthorization: event.assessment.userAuthorization,
authorizationDependent: event.assessment.userAuthorization !== "unknown",
policyRevision: bounded(event.policyRevision),
policyHash: hash(event.config),
provider: bounded(event.config.provider),
model: bounded(event.config.model),
};
appendFileSync(this.decisionsPath, `${JSON.stringify(record)}\n`, {
encoding: "utf8",
mode: FILE_MODE,
});
try { chmodSync(this.decisionsPath, FILE_MODE); } catch { /* best effort */ }
this.state.completedSequence = sequence;
if (
event.analysis.enabled &&
sequence - this.state.lastAnalyzedSequence >= event.analysis.threshold
) {
this.state.analysisPending = true;
}
this.persistState();
return record;
}
hasPendingAnalysis(): boolean {
return this.state.analysisPending;
}
prepareBatch(config: AutoReviewAnalysisConfig): AutoReviewAnalysisBatch | undefined {
const records = this.readRecords();
const fromSequence = this.state.lastAnalyzedSequence + 1;
const eligible = records.filter((record) => record.sequence >= fromSequence);
if (eligible.length < config.threshold) return undefined;
const selected = eligible.slice(0, config.threshold);
const policyHash = selected[0]?.policyHash ?? "";
return {
fromSequence,
toSequence: selected.at(-1)!.sequence,
records: selected,
policyHash,
};
}
saveProposal(batch: AutoReviewAnalysisBatch, proposal: unknown): StoredAnalysisProposal {
const next = this.state.proposalSequence + 1;
const proposalId = `proposal-${String(next).padStart(6, "0")}`;
const stored: StoredAnalysisProposal = {
version: 1,
proposalId,
createdAt: new Date().toISOString(),
fromSequence: batch.fromSequence,
toSequence: batch.toSequence,
policyHash: batch.policyHash,
proposal,
};
this.writeAtomic(join(this.proposalsDir, `${proposalId}.json`), stored);
this.state.proposalSequence = next;
this.state.lastAnalyzedSequence = batch.toSequence;
this.state.analysisPending =
this.state.completedSequence - this.state.lastAnalyzedSequence > 0;
this.persistState();
return stored;
}
compact(config: AutoReviewAnalysisConfig): void {
const keep = Math.max(config.threshold, config.threshold * config.retainAnalyzedBatches);
const records = this.readRecords();
const retained = records.slice(-keep);
const content = retained.map((record) => JSON.stringify(record)).join("\n");
this.writeAtomic(this.decisionsPath, content ? `${content}\n` : "");
}
private readRecords(): AutoReviewLedgerRecord[] {
if (!existsSync(this.decisionsPath)) return [];
return readFileSync(this.decisionsPath, "utf8")
.split("\n")
.filter(Boolean)
.flatMap((line) => {
try { return [JSON.parse(line) as AutoReviewLedgerRecord]; }
catch { return []; }
})
.sort((left, right) => left.sequence - right.sequence);
}
private persistState(): void {
this.writeAtomic(this.statePath, this.state);
}
private writeAtomic(path: string, value: unknown): void {
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
const content = typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`;
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
try {
renameSync(temporaryPath, path);
try { chmodSync(path, FILE_MODE); } catch { /* best effort */ }
} catch (error) {
try { unlinkSync(temporaryPath); } catch { /* best effort */ }
throw error;
}
}
}