import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, DEFAULT_AUTO_REVIEW_CONFIG, } from "#src/auto-review/config"; import { AutoReviewLedger } from "#src/auto-review/ledger"; import type { PromptPermissionDetails } from "#src/service"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); function details(requestId: string): PromptPermissionDetails { return { requestId, source: "tool", surface: "bash", toolName: "bash", command: "npm install example", agentName: "main", } as unknown as PromptPermissionDetails; } describe("AutoReviewLedger", () => { it("marks analysis pending only after the configured number of completed verdicts", () => { const root = mkdtempSync(join(tmpdir(), "auto-review-ledger-")); roots.push(root); const ledger = new AutoReviewLedger(root); const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 2 }; const assessment = { outcome: "allow" as const, riskLevel: "low" as const, userAuthorization: "high" as const, rationale: "directly authorized", }; ledger.recordDecision({ details: details("request-1"), assessment, policyRevision: "policy-a", config: DEFAULT_AUTO_REVIEW_CONFIG, analysis, }); expect(ledger.hasPendingAnalysis()).toBe(false); expect(ledger.prepareBatch(analysis)).toBeUndefined(); ledger.recordDecision({ details: details("request-2"), assessment, policyRevision: "policy-a", config: DEFAULT_AUTO_REVIEW_CONFIG, analysis, }); expect(ledger.hasPendingAnalysis()).toBe(true); const batch = ledger.prepareBatch(analysis); expect(batch?.fromSequence).toBe(1); expect(batch?.toSequence).toBe(2); expect(batch?.records).toHaveLength(2); expect(batch?.records[0]).not.toHaveProperty("rationale"); expect(batch?.records[0].normalizedPattern).toBe("npm install *"); }); it("persists owner-only state and resumes a pending batch", () => { const root = mkdtempSync(join(tmpdir(), "auto-review-ledger-")); roots.push(root); const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 1 }; const ledger = new AutoReviewLedger(root); ledger.recordDecision({ details: details("request-1"), assessment: { outcome: "deny", riskLevel: "high", userAuthorization: "unknown", rationale: "unsafe", }, policyRevision: "policy-b", config: DEFAULT_AUTO_REVIEW_CONFIG, analysis, }); const resumed = new AutoReviewLedger(root); expect(resumed.hasPendingAnalysis()).toBe(true); expect(resumed.prepareBatch(analysis)?.records[0].outcome).toBe("deny"); expect(statSync(join(root, "state.json")).mode & 0o777).toBe(0o600); expect(readFileSync(join(root, "ledger", "decisions.jsonl"), "utf8")).not.toContain("unsafe"); }); it("does not retain command arguments, secret path segments, or rationale", () => { const root = mkdtempSync(join(tmpdir(), "auto-review-ledger-")); roots.push(root); const ledger = new AutoReviewLedger(root); const analysis = { ...DEFAULT_AUTO_REVIEW_ANALYSIS_CONFIG, threshold: 10 }; const assessment = { outcome: "allow" as const, riskLevel: "medium" as const, userAuthorization: "high" as const, rationale: "secret-rationale", }; const commandRecord = ledger.recordDecision({ details: { requestId: "request-command", source: "tool", agentName: "main", surface: "bash", toolName: "bash", command: "curl https://token.example/?api_key=secret-value", } as unknown as PromptPermissionDetails, assessment, policyRevision: "policy-c", config: DEFAULT_AUTO_REVIEW_CONFIG, analysis, }); const pathRecord = ledger.recordDecision({ details: { requestId: "request-path", source: "tool", agentName: "main", surface: "path", toolName: "read", path: "clients/acme-secret/token.txt", } as unknown as PromptPermissionDetails, assessment, policyRevision: "policy-c", config: DEFAULT_AUTO_REVIEW_CONFIG, analysis, }); expect(commandRecord.normalizedPattern).toBe("curl *"); expect(pathRecord.normalizedPattern).toBe("/*"); const persisted = readFileSync(join(root, "ledger", "decisions.jsonl"), "utf8"); expect(persisted).not.toContain("secret-value"); expect(persisted).not.toContain("acme-secret"); expect(persisted).not.toContain("secret-rationale"); }); });