Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<version>` 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`.
Expand Down
160 changes: 157 additions & 3 deletions src/app/api/policy/history/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);
});
});
74 changes: 71 additions & 3 deletions src/app/api/policy/history/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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." },
});
}
Expand Down
10 changes: 5 additions & 5 deletions src/lib/observability/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
18 changes: 14 additions & 4 deletions src/lib/security/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Loading