diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index db17e9d8..0f48ff65 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,6 +185,17 @@ Operators can dry-run an unsaved policy draft before committing it, so the impac The audit sample is intentionally small and deterministic (newest-first, capped) to keep simulations cheap and reviewable. +### 5.2b Candidate Decision Impact (History Read) + +Operators can see which currently allowed payments a candidate policy version would turn into a denial — and which rule is responsible — before that version is activated. + +1. `GET /api/policy/history?candidate=` loads the active policy and the candidate version from `policy-store` (read-only). +2. `openAllowDecisions` collects the open allow decisions from the audit history (the newest recorded result per action, when that result is `APPROVE`/`WARN`). +3. `diffDecisionImpact` (`src/lib/validation/diff.ts`) diffs the candidate against the active policy by rule id, re-runs each open allow through the decision engine under both policies, and walks the changed rules one at a time to attribute the flip to a single rule id. +4. The response reports `flippedIds`, the responsible `ruleId` per flip, and a `status` of `decision change` or `no decision change`. + +Nothing is written: the candidate is never activated and usage is never consumed. Saving still happens only via `POST /api/policy` (or `POST /api/policy/rollback`). + ### 5.3 Signed Payment Submission 1. Build unsigned transaction via `/api/stellar/build-payment`. diff --git a/src/app/api/policy/history/route.test.ts b/src/app/api/policy/history/route.test.ts index edc409ae..b1c5fb8d 100644 --- a/src/app/api/policy/history/route.test.ts +++ b/src/app/api/policy/history/route.test.ts @@ -1,22 +1,107 @@ -import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/storage/paths", () => { + const dir = `/tmp/fortexa-history-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return { + getFortexaStoreDir: () => dir, + getFortexaStorePath: (fileName: string) => path.join(dir, fileName), + }; +}); + import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { GET } from "@/app/api/policy/history/route"; +import { defaultPolicyConfig } from "@/lib/policy/engine"; +import { + appendAuditEntry, + getDailyUsage, + listAllAuditEntriesByUser, +} from "@/lib/storage/audit-store"; +import { + getPolicyConfig, + getPolicyVersionByNumber, + updatePolicyConfig, +} from "@/lib/storage/policy-store"; +import { diffDecisionImpact, openAllowDecisions } from "@/lib/validation/diff"; +import type { PolicyConfig } from "@/lib/types/domain"; + +const USER_ID = "policy-history-operator"; function operatorCookie() { process.env.FORTEXA_AUTH_SECRET = "integration-test-secret"; const token = createSessionToken({ email: "operator@fortexa.local", role: "operator", - userId: "policy-history-operator", + userId: USER_ID, expiresInSeconds: 120, }); return `${AUTH_COOKIE_KEY}=${token}`; } +/** v2: plain default policy. */ +const permissivePolicy: PolicyConfig = { ...defaultPolicyConfig }; + +/** v3: candidate that blocks a domain of a currently allowed payment. */ +const blockingPolicy: PolicyConfig = { + ...defaultPolicyConfig, + allowedDomains: defaultPolicyConfig.allowedDomains.filter( + (domain) => domain !== "api.safe-research.ai", + ), + blockedDomains: [...defaultPolicyConfig.blockedDomains, "api.safe-research.ai"], +}; + +/** v4 (active): differs from v2 only by a cap change that flips nothing. */ +const activePolicy: PolicyConfig = { ...defaultPolicyConfig, dailyCapXLM: 999 }; + +type HistoryBody = { + entries: Array<{ version: number }>; + decisionImpact: { + candidateVersion: number; + activeVersion: number; + flippedIds: string[]; + flips: Array<{ paymentId: string; ruleId: string; from: string; to: string }>; + hasDecisionChange: boolean; + status: "decision change" | "no decision change"; + } | null; + error?: string; +}; + +async function historyRequest(query: string) { + const request = new NextRequest(`http://localhost/api/policy/history${query}`, { + method: "GET", + headers: { cookie: operatorCookie() }, + }); + + return GET(request); +} + describe("/api/policy/history route", () => { + beforeAll(async () => { + await updatePolicyConfig(permissivePolicy, USER_ID); // v2 + await updatePolicyConfig(blockingPolicy, USER_ID); // v3 (candidate that flips) + await updatePolicyConfig(activePolicy, USER_ID); // v4 (active) + + await appendAuditEntry(USER_ID, { + id: "payment-allow-1", + timestamp: new Date().toISOString(), + action: { + id: "action-allow-1", + name: "Allowed research payment", + kind: "api_payment", + target: "research-pro:query", + domain: "api.safe-research.ai", + amountXLM: 12, + tool: "research-pro", + }, + decision: "APPROVE", + explanation: "seed", + triggeredPolicies: [], + riskFindings: [], + }); + }); + it("returns 401 when unauthenticated", async () => { const request = new NextRequest("http://localhost/api/policy/history", { method: "GET" }); const response = await GET(request); @@ -32,7 +117,76 @@ describe("/api/policy/history route", () => { const response = await GET(request); expect(response.status).toBe(200); - const payload = (await response.json()) as { entries: Array<{ version: number }> }; + const payload = (await response.json()) as HistoryBody; expect(Array.isArray(payload.entries)).toBe(true); + expect(payload.entries.length).toBeGreaterThan(0); + expect(payload.decisionImpact).toBeNull(); + }); + + it("lists the payment a candidate rule edit would flip, without activating it", async () => { + const before = await getPolicyConfig(); + const response = await historyRequest("?limit=8&candidate=3"); + expect(response.status).toBe(200); + + const payload = (await response.json()) as HistoryBody; + expect(payload.decisionImpact).not.toBeNull(); + expect(payload.decisionImpact!.candidateVersion).toBe(3); + expect(payload.decisionImpact!.activeVersion).toBe(before.version); + expect(payload.decisionImpact!.status).toBe("decision change"); + expect(payload.decisionImpact!.hasDecisionChange).toBe(true); + expect(payload.decisionImpact!.flippedIds).toEqual(["payment-allow-1"]); + + const flip = payload.decisionImpact!.flips[0]; + expect(flip.ruleId).toBe("blockedDomains[api.safe-research.ai]"); + expect(flip.to).toBe("BLOCK"); + expect(["APPROVE", "WARN"]).toContain(flip.from); + + // The read never activates the candidate. + const after = await getPolicyConfig(); + expect(after.version).toBe(before.version); + expect(after.policy).toEqual(before.policy); + }); + + it("reports no decision change when the candidate flips nothing", async () => { + const response = await historyRequest("?candidate=2"); + expect(response.status).toBe(200); + + const payload = (await response.json()) as HistoryBody; + expect(payload.decisionImpact!.status).toBe("no decision change"); + expect(payload.decisionImpact!.hasDecisionChange).toBe(false); + expect(payload.decisionImpact!.flippedIds).toEqual([]); + expect(payload.decisionImpact!.flips).toEqual([]); + }); + + it("returns the same flipped ids as the diff helper", async () => { + const { policy: activePolicyNow } = await getPolicyConfig(); + const candidate = await getPolicyVersionByNumber(3); + const entriesByUser = await listAllAuditEntriesByUser(); + const decisions = openAllowDecisions(entriesByUser[USER_ID], await getDailyUsage(USER_ID)); + + const helper = await diffDecisionImpact({ + activePolicy: activePolicyNow, + candidatePolicy: candidate.policy, + decisions, + }); + + const response = await historyRequest("?candidate=3"); + const payload = (await response.json()) as HistoryBody; + + expect(payload.decisionImpact!.flippedIds).toEqual(helper.flippedIds); + expect(payload.decisionImpact!.flips.map((f) => f.ruleId)).toEqual( + helper.flips.map((f) => f.ruleId), + ); + expect(payload.decisionImpact!.status).toBe(helper.status); + }); + + it("rejects an invalid candidate version", async () => { + const response = await historyRequest("?candidate=abc"); + expect(response.status).toBe(400); + }); + + it("returns 404 for an unknown candidate version", async () => { + const response = await historyRequest("?candidate=999"); + expect(response.status).toBe(404); }); }); diff --git a/src/app/api/policy/history/route.ts b/src/app/api/policy/history/route.ts index a9303793..08f6d5a8 100644 --- a/src/app/api/policy/history/route.ts +++ b/src/app/api/policy/history/route.ts @@ -3,7 +3,24 @@ import { NextRequest } from "next/server"; import { requireAuth } from "@/lib/auth/require-auth"; import { jsonWithRequestContext } from "@/lib/observability/http"; import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observability/logger"; -import { getPolicyHistory } from "@/lib/storage/policy-store"; +import { getDailyUsage, listAllAuditEntriesByUser } from "@/lib/storage/audit-store"; +import { getPolicyConfig, getPolicyHistory, getPolicyVersionByNumber } from "@/lib/storage/policy-store"; +import { + diffDecisionImpact, + openAllowDecisions, + type DecisionImpactReport, + type OpenAllowDecision, +} from "@/lib/validation/diff"; + +/** + * History entry plus the candidate preview: which open allow decisions would + * flip to a denial under that candidate version, and which rule is responsible. + * The candidate is evaluated read-only and is never activated here. + */ +type CandidateDecisionImpact = DecisionImpactReport & { + candidateVersion: number; + activeVersion: number; +}; export async function GET(request: NextRequest) { const startedAtMs = Date.now(); @@ -15,20 +32,71 @@ export async function GET(request: NextRequest) { return auth.response; } + const candidateRaw = request.nextUrl.searchParams.get("candidate"); + let candidateVersion: number | null = null; + + if (candidateRaw !== null) { + const parsedCandidate = Number(candidateRaw); + + if (!Number.isInteger(parsedCandidate) || parsedCandidate < 1) { + return jsonWithRequestContext(request, { + route: "/api/policy/history", + startedAtMs, + status: 400, + body: { error: "Invalid candidate version. Expected a positive integer." }, + }); + } + + candidateVersion = parsedCandidate; + } + try { const limitRaw = request.nextUrl.searchParams.get("limit"); const limit = limitRaw ? Math.min(200, Math.max(1, Number(limitRaw))) : 20; const entries = await getPolicyHistory(limit); + let decisionImpact: CandidateDecisionImpact | null = null; + + if (candidateVersion !== null) { + const candidate = await getPolicyVersionByNumber(candidateVersion); + const { policy: activePolicy, version: activeVersion } = await getPolicyConfig(); + + const entriesByUser = await listAllAuditEntriesByUser(); + const decisions: OpenAllowDecision[] = []; + + for (const userId of Object.keys(entriesByUser)) { + decisions.push(...openAllowDecisions(entriesByUser[userId], await getDailyUsage(userId))); + } + + const impact = await diffDecisionImpact({ + activePolicy, + candidatePolicy: candidate.policy, + decisions, + }); + + decisionImpact = { ...impact, candidateVersion, activeVersion }; + + logInfo("Policy candidate decision impact read", { + ...context, + userId: auth.session.userId, + candidateVersion, + activeVersion, + status: impact.status, + flipped: impact.flippedIds.length, + }); + } + logInfo("Policy history read", { ...context, userId: auth.session.userId, count: entries.length }); return jsonWithRequestContext(request, { route: "/api/policy/history", startedAtMs, status: 200, - body: { entries }, + body: { entries, decisionImpact }, }); } catch (error) { + const notFound = error instanceof Error && error.message.includes("not found"); + logError("Policy history internal error", { ...context, userId: auth.session.userId, @@ -38,7 +106,7 @@ export async function GET(request: NextRequest) { return jsonWithRequestContext(request, { route: "/api/policy/history", startedAtMs, - status: 500, + status: notFound ? 404 : 500, body: { error: error instanceof Error ? error.message : "Failed to read policy history." }, }); } diff --git a/src/lib/observability/metrics.test.ts b/src/lib/observability/metrics.test.ts index 6327a244..102578cf 100644 --- a/src/lib/observability/metrics.test.ts +++ b/src/lib/observability/metrics.test.ts @@ -354,11 +354,11 @@ describe("observability metrics", () => { recordDecisionOutcome("G" + "A".repeat(55)); // @ts-expect-error: testing freetext injection guard recordDecisionOutcome("APPROVE; wallet=G123"); - // @ts-expect-error free-text outcome is intentionally invalid + // @ts-expect-error free-text outcome must be rejected by the runtime guard recordDecisionOutcome("free-text"); - // @ts-expect-error unlisted submit result is intentionally invalid + // @ts-expect-error wallet-like payload must be rejected by the runtime guard recordStellarSubmitResult("wallet_leak_payload"); - // @ts-expect-error wallet-like submit result is intentionally invalid + // @ts-expect-error raw wallet address must be rejected by the runtime guard recordStellarSubmitResult("G12345"); expect(getDecisionOutcomeCounts().size).toBe(0); @@ -378,9 +378,9 @@ describe("observability metrics", () => { recordApiMetric({ route: wallet, method: "POST", statusCode: 200, durationMs: 10 }); recordApiMetric({ route: `/api/decision?wallet=${wallet}`, method: "POST", statusCode: 200, durationMs: 10 }); // Also try to inject via outcome/result with wallet-like string (should be dropped) - // @ts-expect-error wallet address is intentionally not an outcome + // @ts-expect-error wallet address cannot be recorded as an outcome recordDecisionOutcome(wallet); - // @ts-expect-error wallet address is intentionally not a submit result + // @ts-expect-error wallet address cannot be recorded as a submit result recordStellarSubmitResult(wallet); const output = toPrometheusText(); diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index 55ef869f..7d19611f 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -160,20 +160,30 @@ async function fetchBlocklistWithTimeout(): Promise<{ status: BlocklistFetchStatus; }> { const { blocklistTimeoutMs } = getAnalyzerConfig(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs); try { const blocklist = await fetchBlocklist(); return { blocklist, status: { blocked: false, timedOut: false } }; } catch (err) { + // fetchBlocklist records the failure in its health before rethrowing, so + // read the health back to classify the failure instead of the error alone. const health = getBlocklistHealth(); const error = health.lastError ?? (err instanceof Error ? err.message : "Unknown fetch error"); const timedOut = - (err instanceof Error && - (err.name === "AbortError" || err.name === "TimeoutError")) || - /abort|timed? ?out/i.test(error); - return { blocklist: [], status: { blocked: true, timedOut, error } }; + controller.signal.aborted || + (err instanceof Error && err.name === "AbortError") || + /abort|timeout/i.test(error); + + return { + blocklist: [], + status: { blocked: true, timedOut, error }, + }; + } finally { + clearTimeout(timeoutId); } } diff --git a/src/lib/validation/diff.test.ts b/src/lib/validation/diff.test.ts new file mode 100644 index 00000000..0dbf72e4 --- /dev/null +++ b/src/lib/validation/diff.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { defaultPolicyConfig } from "@/lib/policy/engine"; +import { defaultDailyUsage } from "@/lib/scenarios/seed"; +import type { AuditEntry, PolicyConfig } from "@/lib/types/domain"; +import { + diffDecisionImpact, + isAllowDecision, + openAllowDecisions, + type OpenAllowDecision, +} from "@/lib/validation/diff"; + +const activePolicy: PolicyConfig = { ...defaultPolicyConfig }; + +const allowedPayment: OpenAllowDecision = { + paymentId: "payment-allow-1", + decision: "APPROVE", + usage: defaultDailyUsage, + action: { + id: "action-allow-1", + name: "Allowed research payment", + kind: "api_payment", + target: "research-pro:query", + domain: "api.safe-research.ai", + amountXLM: 12, + tool: "research-pro", + }, +}; + +/** Candidate that blocks the domain of a currently allowed payment. */ +const blockingCandidate: PolicyConfig = { + ...activePolicy, + allowedDomains: activePolicy.allowedDomains.filter( + (domain) => domain !== "api.safe-research.ai", + ), + blockedDomains: [...activePolicy.blockedDomains, "api.safe-research.ai"], +}; + +/** Candidate whose only change cannot flip the allowed payment. */ +const unrelatedCandidate: PolicyConfig = { + ...activePolicy, + perTxCapXLM: 500, +}; + +function entry(overrides: Partial & Pick): AuditEntry { + return { + timestamp: new Date().toISOString(), + explanation: "seed", + triggeredPolicies: [], + riskFindings: [], + ...overrides, + }; +} + +describe("diffDecisionImpact", () => { + it("lists the payment an allow-flipping rule edit would deny, with the responsible rule id", async () => { + const report = await diffDecisionImpact({ + activePolicy, + candidatePolicy: blockingCandidate, + decisions: [allowedPayment], + }); + + expect(report.status).toBe("decision change"); + expect(report.hasDecisionChange).toBe(true); + expect(report.flippedIds).toEqual(["payment-allow-1"]); + + const flip = report.flips[0]; + expect(flip.paymentId).toBe("payment-allow-1"); + expect(flip.ruleId).toBe("blockedDomains[api.safe-research.ai]"); + expect(flip.to).toBe("BLOCK"); + expect(isAllowDecision(flip.from)).toBe(true); + }); + + it("attributes a tightened per-transaction cap to its rule id", async () => { + const report = await diffDecisionImpact({ + activePolicy, + candidatePolicy: { ...activePolicy, perTxCapXLM: 5 }, + decisions: [allowedPayment], + }); + + expect(report.flippedIds).toEqual(["payment-allow-1"]); + expect(report.flips[0].ruleId).toBe("perTxCapXLM"); + expect(report.flips[0].to).toBe("REQUIRE_APPROVAL"); + }); + + it("reports no decision change when the candidate flips nothing", async () => { + const report = await diffDecisionImpact({ + activePolicy, + candidatePolicy: unrelatedCandidate, + decisions: [allowedPayment], + }); + + expect(report.status).toBe("no decision change"); + expect(report.hasDecisionChange).toBe(false); + expect(report.flippedIds).toEqual([]); + expect(report.flips).toEqual([]); + }); + + it("does not report payments the active policy already denies", async () => { + const alreadyDenied: OpenAllowDecision = { + ...allowedPayment, + paymentId: "payment-blocked-1", + action: { + ...allowedPayment.action, + id: "action-blocked-1", + domain: "wallet-drainer.evil", + }, + }; + + const report = await diffDecisionImpact({ + activePolicy, + candidatePolicy: blockingCandidate, + decisions: [alreadyDenied], + }); + + expect(report.status).toBe("no decision change"); + expect(report.flippedIds).toEqual([]); + }); + + it("does not mutate the policies it evaluates", async () => { + const activeSnapshot = JSON.stringify(activePolicy); + const candidateSnapshot = JSON.stringify(blockingCandidate); + + await diffDecisionImpact({ + activePolicy, + candidatePolicy: blockingCandidate, + decisions: [allowedPayment], + }); + + expect(JSON.stringify(activePolicy)).toBe(activeSnapshot); + expect(JSON.stringify(blockingCandidate)).toBe(candidateSnapshot); + }); +}); + +describe("openAllowDecisions", () => { + it("keeps only the latest open allow per action", () => { + const entries: AuditEntry[] = [ + entry({ + id: "entry-denied-2", + action: { ...allowedPayment.action, id: "action-a" }, + decision: "BLOCK", + }), + entry({ + id: "entry-allowed-1", + action: { ...allowedPayment.action, id: "action-a" }, + decision: "APPROVE", + }), + entry({ + id: "entry-allowed-3", + action: { ...allowedPayment.action, id: "action-b" }, + decision: "WARN", + }), + ]; + + const open = openAllowDecisions(entries, defaultDailyUsage); + + expect(open).toHaveLength(1); + expect(open[0].paymentId).toBe("entry-allowed-3"); + expect(open[0].decision).toBe("WARN"); + expect(open[0].usage).toEqual(defaultDailyUsage); + }); + + it("returns an empty list when every payment was denied", () => { + const entries: AuditEntry[] = [ + entry({ + id: "entry-denied", + action: allowedPayment.action, + decision: "BLOCK", + }), + ]; + + expect(openAllowDecisions(entries, defaultDailyUsage)).toEqual([]); + }); +}); diff --git a/src/lib/validation/diff.ts b/src/lib/validation/diff.ts index 14646bd2..66a2ed4f 100644 --- a/src/lib/validation/diff.ts +++ b/src/lib/validation/diff.ts @@ -1,176 +1,394 @@ -import type { PolicyConfig } from "@/lib/types/domain"; - -export type DiffChangeType = "added" | "removed" | "modified" | "unchanged"; - -export interface DiffChange { - type: DiffChangeType; - path: string; - oldValue?: unknown; - newValue?: unknown; -} - -export interface PolicyDiff { - changes: DiffChange[]; - hasChanges: boolean; - summary: { - added: number; - removed: number; - modified: number; - }; -} - -/** - * Generate a human-readable diff between two policies. - * Returns detailed changes organized by field. - */ -export function generatePolicyDiff( - oldPolicy: PolicyConfig, - newPolicy: PolicyConfig -): PolicyDiff { - const changes: DiffChange[] = []; - - // Compare numeric fields - const numericFields: (keyof PolicyConfig)[] = [ - "perTxCapXLM", - "dailyCapXLM", - "maxToolCallsPerDay", - "riskThreshold", - ]; - - for (const field of numericFields) { - const oldValue = oldPolicy[field]; - const newValue = newPolicy[field]; - - if (oldValue !== newValue) { - changes.push({ - type: "modified", - path: field, - oldValue, - newValue, - }); - } - } - - // Compare allowed hours - const oldHours = oldPolicy.allowedHours; - const newHours = newPolicy.allowedHours; - - if ( - oldHours?.start !== newHours?.start || - oldHours?.end !== newHours?.end - ) { - changes.push({ - type: "modified", - path: "allowedHours", - oldValue: oldHours, - newValue: newHours, - }); - } - - // Compare array fields - const arrayFields: (keyof PolicyConfig)[] = [ - "allowedDomains", - "blockedDomains", - "allowedTools", - "blockedTools", - ]; - - for (const field of arrayFields) { - const oldArray = (oldPolicy[field] as string[]) || []; - const newArray = (newPolicy[field] as string[]) || []; - - const oldSet = new Set(oldArray); - const newSet = new Set(newArray); - - // Check for additions - for (const item of newArray) { - if (!oldSet.has(item)) { - changes.push({ - type: "added", - path: `${field}[${item}]`, - newValue: item, - }); - } - } - - // Check for removals - for (const item of oldArray) { - if (!newSet.has(item)) { - changes.push({ - type: "removed", - path: `${field}[${item}]`, - oldValue: item, - }); - } - } - } - - const summary = { - added: changes.filter((c) => c.type === "added").length, - removed: changes.filter((c) => c.type === "removed").length, - modified: changes.filter((c) => c.type === "modified").length, - }; - - return { - changes, - hasChanges: changes.length > 0, - summary, - }; -} - -/** - * Format a diff change into a human-readable string. - */ -export function formatDiffChange(change: DiffChange): string { - switch (change.type) { - case "added": - return `➕ Added to ${change.path}: ${change.newValue}`; - case "removed": - return `➖ Removed from ${change.path}: ${change.oldValue}`; - case "modified": - return `🔄 Changed ${change.path}: ${change.oldValue} → ${change.newValue}`; - case "unchanged": - return `✓ No change to ${change.path}`; - } -} - -/** - * Group diff changes by category for UI display. - */ -export function groupDiffChanges(diff: PolicyDiff): Record { - const grouped: Record = { - "Caps & Thresholds": [], - "Allowed Domains": [], - "Blocked Domains": [], - "Allowed Tools": [], - "Blocked Tools": [], - Other: [], - }; - - for (const change of diff.changes) { - if ( - change.path === "perTxCapXLM" || - change.path === "dailyCapXLM" || - change.path === "maxToolCallsPerDay" || - change.path === "riskThreshold" || - change.path === "allowedHours" - ) { - grouped["Caps & Thresholds"].push(change); - } else if (change.path.startsWith("allowedDomains")) { - grouped["Allowed Domains"].push(change); - } else if (change.path.startsWith("blockedDomains")) { - grouped["Blocked Domains"].push(change); - } else if (change.path.startsWith("allowedTools")) { - grouped["Allowed Tools"].push(change); - } else if (change.path.startsWith("blockedTools")) { - grouped["Blocked Tools"].push(change); - } else { - grouped["Other"].push(change); - } - } - - // Remove empty categories - return Object.fromEntries( - Object.entries(grouped).filter(([, changes]) => changes.length > 0) - ); -} +import { evaluateDecision } from "@/lib/decision/engine"; +import type { + AgentAction, + AuditEntry, + DailyUsage, + DecisionType, + PolicyConfig, +} from "@/lib/types/domain"; + +export type DiffChangeType = "added" | "removed" | "modified" | "unchanged"; + +export interface DiffChange { + type: DiffChangeType; + path: string; + oldValue?: unknown; + newValue?: unknown; +} + +export interface PolicyDiff { + changes: DiffChange[]; + hasChanges: boolean; + summary: { + added: number; + removed: number; + modified: number; + }; +} + +/** + * Generate a human-readable diff between two policies. + * Returns detailed changes organized by field. + */ +export function generatePolicyDiff( + oldPolicy: PolicyConfig, + newPolicy: PolicyConfig +): PolicyDiff { + const changes: DiffChange[] = []; + + // Compare numeric fields + const numericFields: (keyof PolicyConfig)[] = [ + "perTxCapXLM", + "dailyCapXLM", + "maxToolCallsPerDay", + "riskThreshold", + ]; + + for (const field of numericFields) { + const oldValue = oldPolicy[field]; + const newValue = newPolicy[field]; + + if (oldValue !== newValue) { + changes.push({ + type: "modified", + path: field, + oldValue, + newValue, + }); + } + } + + // Compare allowed hours + const oldHours = oldPolicy.allowedHours; + const newHours = newPolicy.allowedHours; + + if ( + oldHours?.start !== newHours?.start || + oldHours?.end !== newHours?.end + ) { + changes.push({ + type: "modified", + path: "allowedHours", + oldValue: oldHours, + newValue: newHours, + }); + } + + // Compare array fields + const arrayFields: (keyof PolicyConfig)[] = [ + "allowedDomains", + "blockedDomains", + "allowedTools", + "blockedTools", + ]; + + for (const field of arrayFields) { + const oldArray = (oldPolicy[field] as string[]) || []; + const newArray = (newPolicy[field] as string[]) || []; + + const oldSet = new Set(oldArray); + const newSet = new Set(newArray); + + // Check for additions + for (const item of newArray) { + if (!oldSet.has(item)) { + changes.push({ + type: "added", + path: `${field}[${item}]`, + newValue: item, + }); + } + } + + // Check for removals + for (const item of oldArray) { + if (!newSet.has(item)) { + changes.push({ + type: "removed", + path: `${field}[${item}]`, + oldValue: item, + }); + } + } + } + + const summary = { + added: changes.filter((c) => c.type === "added").length, + removed: changes.filter((c) => c.type === "removed").length, + modified: changes.filter((c) => c.type === "modified").length, + }; + + return { + changes, + hasChanges: changes.length > 0, + summary, + }; +} + +/** + * Format a diff change into a human-readable string. + */ +export function formatDiffChange(change: DiffChange): string { + switch (change.type) { + case "added": + return `➕ Added to ${change.path}: ${change.newValue}`; + case "removed": + return `➖ Removed from ${change.path}: ${change.oldValue}`; + case "modified": + return `🔄 Changed ${change.path}: ${change.oldValue} → ${change.newValue}`; + case "unchanged": + return `✓ No change to ${change.path}`; + } +} + +/** + * Group diff changes by category for UI display. + */ +export function groupDiffChanges(diff: PolicyDiff): Record { + const grouped: Record = { + "Caps & Thresholds": [], + "Allowed Domains": [], + "Blocked Domains": [], + "Allowed Tools": [], + "Blocked Tools": [], + Other: [], + }; + + for (const change of diff.changes) { + if ( + change.path === "perTxCapXLM" || + change.path === "dailyCapXLM" || + change.path === "maxToolCallsPerDay" || + change.path === "riskThreshold" || + change.path === "allowedHours" + ) { + grouped["Caps & Thresholds"].push(change); + } else if (change.path.startsWith("allowedDomains")) { + grouped["Allowed Domains"].push(change); + } else if (change.path.startsWith("blockedDomains")) { + grouped["Blocked Domains"].push(change); + } else if (change.path.startsWith("allowedTools")) { + grouped["Allowed Tools"].push(change); + } else if (change.path.startsWith("blockedTools")) { + grouped["Blocked Tools"].push(change); + } else { + grouped["Other"].push(change); + } + } + + // Remove empty categories + return Object.fromEntries( + Object.entries(grouped).filter(([, changes]) => changes.length > 0) + ); +} + +/** Decisions that let a payment proceed. */ +export function isAllowDecision(decision: DecisionType): boolean { + return decision === "APPROVE" || decision === "WARN"; +} + +/** + * Cap on how many open allow decisions are re-run for a single report so a + * large audit history cannot stall this read. The newest decisions are kept. + */ +export const MAX_OPEN_ALLOW_DECISIONS = 100; + +/** An allow decision that is still open for a payment. */ +export interface OpenAllowDecision { + /** Id of the payment/decision record (audit entry) carrying the allow. */ + paymentId: string; + action: AgentAction; + /** Decision recorded for the payment the last time it was evaluated. */ + decision: DecisionType; + /** Daily usage snapshot the payment was evaluated against. */ + usage: DailyUsage; +} + +/** A payment whose result would change under the candidate policy. */ +export interface FlippedPayment { + paymentId: string; + /** Rule id (the diff path) of the change responsible for the flip. */ + ruleId: string; + from: DecisionType; + to: DecisionType; +} + +/** Read-only report of how a candidate policy would flip open allow decisions. */ +export interface DecisionImpactReport { + /** Payment ids whose result would change from an allow to a denial. */ + flippedIds: string[]; + flips: FlippedPayment[]; + hasDecisionChange: boolean; + /** Explicit "no decision change" status when the candidate flips nothing. */ + status: "decision change" | "no decision change"; +} + +const RULE_LIST_FIELDS = [ + "allowedDomains", + "blockedDomains", + "allowedTools", + "blockedTools", +] as const; + +type RuleListField = (typeof RULE_LIST_FIELDS)[number]; + +function isRuleListField(field: string): field is RuleListField { + return (RULE_LIST_FIELDS as readonly string[]).includes(field); +} + +/** + * Apply one diff change (one rule id) to a policy and return the patched copy. + * The input policy is never mutated. + */ +function applyRuleChange(policy: PolicyConfig, change: DiffChange): PolicyConfig { + const bracketIndex = change.path.indexOf("["); + + if (bracketIndex !== -1) { + const field = change.path.slice(0, bracketIndex); + if (!isRuleListField(field)) { + return policy; + } + + const current = policy[field]; + const next = + change.type === "added" + ? [...current, String(change.newValue)] + : current.filter((item) => item !== change.oldValue); + + return { ...policy, [field]: next }; + } + + switch (change.path) { + case "perTxCapXLM": + return { ...policy, perTxCapXLM: Number(change.newValue) }; + case "dailyCapXLM": + return { ...policy, dailyCapXLM: Number(change.newValue) }; + case "maxToolCallsPerDay": + return { ...policy, maxToolCallsPerDay: Number(change.newValue) }; + case "riskThreshold": + return { ...policy, riskThreshold: Number(change.newValue) }; + case "allowedHours": + return { ...policy, allowedHours: change.newValue as PolicyConfig["allowedHours"] }; + default: + return policy; + } +} + +/** + * Collect the open allow decisions from audit entries (newest first). + * + * A decision stays open only while it is the latest recorded result for its + * action: a later decision supersedes the earlier one, and a payment whose + * latest result is a denial is not an open allow. + */ +export function openAllowDecisions( + entries: AuditEntry[], + usage: DailyUsage, +): OpenAllowDecision[] { + const seen = new Set(); + const open: OpenAllowDecision[] = []; + + for (const entry of entries) { + if (seen.has(entry.action.id)) { + continue; + } + seen.add(entry.action.id); + + if (!isAllowDecision(entry.decision)) { + continue; + } + + open.push({ + paymentId: entry.id, + action: entry.action, + decision: entry.decision, + usage, + }); + } + + return open; +} + +/** + * Apply the rule changes one at a time until one of them turns the allow into + * a denial. That change is the rule id responsible for the flip. + */ +async function attributeRuleChange(params: { + activePolicy: PolicyConfig; + changes: DiffChange[]; + action: AgentAction; + usage: DailyUsage; +}): Promise { + const { activePolicy, changes, action, usage } = params; + let current = activePolicy; + + for (const change of changes) { + current = applyRuleChange(current, change); + const result = await evaluateDecision(action, current, usage); + if (!isAllowDecision(result.decision)) { + return change.path; + } + } + + // Applying every change reproduces the candidate, which is already known to + // deny, so this fallback only guards against a change that could not apply. + return changes[changes.length - 1]?.path ?? "policy"; +} + +/** + * Re-run open allow decisions against a candidate policy and report which + * payments would flip to a denial, plus the rule id responsible. + * + * Pure read: the candidate is evaluated but never activated. Every payment is + * re-checked against the active policy first so only candidate-caused flips + * are reported. + */ +export async function diffDecisionImpact(params: { + activePolicy: PolicyConfig; + candidatePolicy: PolicyConfig; + decisions: OpenAllowDecision[]; +}): Promise { + const { activePolicy, candidatePolicy, decisions } = params; + const changes = generatePolicyDiff(activePolicy, candidatePolicy).changes; + const flips: FlippedPayment[] = []; + + if (changes.length > 0) { + for (const decision of decisions.slice(0, MAX_OPEN_ALLOW_DECISIONS)) { + if (!isAllowDecision(decision.decision)) { + continue; + } + + const before = await evaluateDecision(decision.action, activePolicy, decision.usage); + if (!isAllowDecision(before.decision)) { + // Already denied by the active policy: not a candidate-caused flip. + continue; + } + + const after = await evaluateDecision(decision.action, candidatePolicy, decision.usage); + if (isAllowDecision(after.decision)) { + continue; + } + + flips.push({ + paymentId: decision.paymentId, + ruleId: await attributeRuleChange({ + activePolicy, + changes, + action: decision.action, + usage: decision.usage, + }), + from: before.decision, + to: after.decision, + }); + } + } + + const flippedIds = flips.map((flip) => flip.paymentId); + + return { + flippedIds, + flips, + hasDecisionChange: flippedIds.length > 0, + status: flippedIds.length > 0 ? "decision change" : "no decision change", + }; +}