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 : ""; } function normalizeCommand(command: string): string { const words = command.trim().replace(/\s+/g, " ").split(" ").filter(Boolean); if (words.length === 0) return ""; 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 ""; 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 ? "//*" : "/*"; } 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(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(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; } } }