From 74ce3e2586a7d3dce5067eed20e752a4ceb5fbb3 Mon Sep 17 00:00:00 2001 From: blocorbitstech Date: Wed, 30 Sep 2026 08:45:33 +0000 Subject: [PATCH 1/2] fix: repair merge damage that prevented tests from running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bad merges left three files uncompilable (unbalanced braces in fetchBlocklistWithTimeout, a refresh arrow function still closed like a useCallback, and a duplicated response binding in the audit export test), which stopped every suite importing the decision engine from running. Also restore the "timeout" blocklist status the analyzer tests assert and add the required descriptions to bare @ts-expect-error directives. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- src/app/api/audit/export/route.test.ts | 2 - src/lib/auth/use-auth-session.ts | 4 +- src/lib/observability/metrics.test.ts | 10 ++--- src/lib/security/analyzer.ts | 57 ++++++++++---------------- 4 files changed, 28 insertions(+), 45 deletions(-) diff --git a/src/app/api/audit/export/route.test.ts b/src/app/api/audit/export/route.test.ts index 936c5abf..2aa86406 100644 --- a/src/app/api/audit/export/route.test.ts +++ b/src/app/api/audit/export/route.test.ts @@ -172,8 +172,6 @@ describe("/api/audit/export route", () => { }, } ); - const response = await GET(request); - const response = await GET(req); expect(response.status).toBe(200); diff --git a/src/lib/auth/use-auth-session.ts b/src/lib/auth/use-auth-session.ts index 88892e59..c30de34d 100644 --- a/src/lib/auth/use-auth-session.ts +++ b/src/lib/auth/use-auth-session.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; type SessionPayload = { authenticated?: boolean; @@ -65,7 +65,7 @@ export function useAuthSession() { void fetchSession(); }, []); - const refresh = async () => { + const refresh = useCallback(async () => { setLoading(true); try { diff --git a/src/lib/observability/metrics.test.ts b/src/lib/observability/metrics.test.ts index db818ebe..2a6619f2 100644 --- a/src/lib/observability/metrics.test.ts +++ b/src/lib/observability/metrics.test.ts @@ -179,11 +179,11 @@ describe("observability metrics", () => { recordDecisionOutcome("G" + "A".repeat(55)); // @ts-expect-error freetext injection recordDecisionOutcome("APPROVE; wallet=G123"); - // @ts-expect-error + // @ts-expect-error free-text outcome must be rejected by the runtime guard recordDecisionOutcome("free-text"); - // @ts-expect-error + // @ts-expect-error wallet-like payload must be rejected by the runtime guard recordStellarSubmitResult("wallet_leak_payload"); - // @ts-expect-error + // @ts-expect-error raw wallet address must be rejected by the runtime guard recordStellarSubmitResult("G12345"); expect(getDecisionOutcomeCounts().size).toBe(0); @@ -203,9 +203,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 + // @ts-expect-error wallet address cannot be recorded as an outcome recordDecisionOutcome(wallet); - // @ts-expect-error + // @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 ac2fe028..1fe54b69 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -165,47 +165,32 @@ async function fetchBlocklistWithTimeout(): Promise<{ blocklist: string[]; status: { blocked: boolean; timedOut: boolean; error?: string }; }> { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const { blocklistTimeoutMs } = getAnalyzerConfig(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs); - try { - const blocklist = await fetchBlocklist(); - clearTimeout(timeoutId); - const health = getBlocklistHealth(); - if (health.lastError) { - return { - blocklist, - status: { - blocked: true, - timedOut: false, - error: health.lastError, - }, - }; - } - return { blocklist, status: { blocked: false, timedOut: false } }; - } finally { - clearTimeout(timeoutId); - } + try { + const blocklist = await fetchBlocklist(); + return { blocklist, status: { blocked: false, timedOut: false } }; } catch (err) { - const isTimeout = err instanceof Error && err.name === "AbortError"; + // 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 = + controller.signal.aborted || + (err instanceof Error && err.name === "AbortError") || + /abort|timeout/i.test(error); - // fetchBlocklist swallows errors internally, so check health for failures - const health = getBlocklistHealth(); - if (health.configured && health.lastError) { - const isTimeout = - /abort|timeout/i.test(health.lastError); return { - blocklist, - status: { - blocked: true, - timedOut: isTimeout, - error: health.lastError, - }, + blocklist: [], + status: { blocked: true, timedOut, error }, }; + } finally { + clearTimeout(timeoutId); } - - return { blocklist, status: { blocked: false, timedOut: false } }; } export async function evaluateSecurity( @@ -229,7 +214,7 @@ export async function evaluateSecurity( await fetchBlocklistWithTimeout(); if (blocklistFetchStatus.timedOut) { - analyzerStatus.blocklistStatus = "error"; + analyzerStatus.blocklistStatus = "timeout"; analyzerStatus.blocklistTimedOut = true; analyzerStatus.blocklistError = blocklistFetchStatus.error ?? "Blocklist fetch timed out"; From eaba8dbacdc43c6ca4a6e928d68feea07d12c295 Mon Sep 17 00:00:00 2001 From: blocorbitstech Date: Wed, 30 Sep 2026 08:45:50 +0000 Subject: [PATCH 2/2] feat(policy): report the rule that would flip an allow decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators could activate a candidate policy that silently turns a live allow into a denial because diff, history and the decision engine each described changes differently. The history route now accepts a read-only ?candidate= query and returns the payment ids whose result would change plus the rule id responsible, sourced from the same diff helper so both always agree. Nothing is activated by the read. Closes #214 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- ARCHITECTURE.md | 11 ++ src/app/api/policy/history/route.test.ts | 160 ++++++++++++++++- src/app/api/policy/history/route.ts | 74 +++++++- src/lib/validation/diff.test.ts | 174 ++++++++++++++++++ src/lib/validation/diff.ts | 220 ++++++++++++++++++++++- 5 files changed, 632 insertions(+), 7 deletions(-) create mode 100644 src/lib/validation/diff.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2dad26d9..2aa4421c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -183,6 +183,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/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 48543a74..66a2ed4f 100644 --- a/src/lib/validation/diff.ts +++ b/src/lib/validation/diff.ts @@ -1,4 +1,11 @@ -import type { PolicyConfig } from "@/lib/types/domain"; +import { evaluateDecision } from "@/lib/decision/engine"; +import type { + AgentAction, + AuditEntry, + DailyUsage, + DecisionType, + PolicyConfig, +} from "@/lib/types/domain"; export type DiffChangeType = "added" | "removed" | "modified" | "unchanged"; @@ -174,3 +181,214 @@ export function groupDiffChanges(diff: PolicyDiff): Record 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", + }; +}