mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(permission): integrate auto-review and hard guards
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user