mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
297 lines
9.8 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|