diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6dd925e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: validate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Audit production dependencies + run: npm audit --omit=dev diff --git a/README.md b/README.md index a23ed7a..5a4bdb7 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,29 @@ # AgentLedger -**The financial control plane for autonomous agents.** +

+ AgentLedger +

+ +

The financial control plane for autonomous agents.

+ +

Financial state and policy in; machine-readable recommendations and capital decisions out.

+ +

+ CI status + MIT license +

> Financial execution requires financial governance. -AgentLedger converts financial state and policy into machine-readable recommendations and capital decisions for autonomous agents. It is an API-only, deterministic, explainable service that sits between agent intent and capital movement. +Autonomous agents can already spend, trade and purchase services. AgentLedger provides the deterministic, explainable financial decision layer between agent intent and capital movement. -```text -Calling agent - ↓ -financial state + proposed action + policy - ↓ -AgentLedger - ↓ -normalize → analyze → evaluate policy - ↓ -recommendation / APPROVE / CAUTION / REJECT -``` +| Service | Question | Price | +| --- | --- | --- | +| Financial Health | How am I? | Free | +| Recommendations | What should I do next? | 0.01 USDT/call | +| Policy Guard | Should this capital move? | 0.02 USDT/call | -Autonomous agents can already spend, trade and purchase services. AgentLedger supplies the financial decision layer that helps them understand operating health and govern spending before money moves. +Quick links: [Production API](https://agentledger-one.vercel.app) · [OpenAPI](https://agentledger-one.vercel.app/openapi.json) · [GitHub](https://github.com/chin0312/agentledger) · [OKX.AI ASP #11336](https://www.okx.ai/agents/11336) AgentLedger is not a wallet, accounting system, tax system, investment adviser, portfolio manager, trading bot or execution engine. It never signs or executes capital movement. @@ -42,15 +47,6 @@ AgentLedger is not a wallet, accounting system, tax system, investment adviser, `POST /api/evaluate-action` evaluates `service_purchase` and `capital_allocation` actions against caller-supplied financial state and policy. It returns an explainable `approve`, `caution` or `reject` decision. The hosted route uses x402 on X Layer. -Compatibility primitives remain available and free: - -```text -POST /api/can-i-spend -POST /api/can-i-allocate -``` - -They are not initial Marketplace services. - ## Agent-native architecture AgentLedger does not need control of an agent's wallet. A calling agent can use Agentic Wallet, exchange APIs, wallet tools, accounting tools, execution systems or internal strategy state to gather factual financial state and send it to AgentLedger. @@ -107,6 +103,17 @@ curl -X POST http://localhost:3000/api/demo/company-health \ The seeded company has marketplace, research-service and client inflows, repeated payments to agent vendors, stablecoin activity across USDT/USDC/USDG, spend acceleration and one failed transaction. +## Compatibility API + +The original specialist primitives remain available and free for compatibility: + +```text +POST /api/can-i-spend +POST /api/can-i-allocate +``` + +They are not initial Marketplace services; use Policy Guard for the Marketplace-facing generalized control flow. + ## API examples ### Financial Health from caller-provided state diff --git a/SECURITY.md b/SECURITY.md index e9a7b95..d021292 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a vulnerability -Please report suspected vulnerabilities privately to the repository maintainers before opening a public issue. Include the affected endpoint or file, reproduction steps, impact and a suggested mitigation when available. Do not include credentials, private keys or other sensitive material in the report. +Please use GitHub's private [Report a vulnerability](https://github.com/chin0312/agentledger/security/advisories/new) flow from the repository Security tab before opening a public issue. Include the affected endpoint or file, reproduction steps, impact and a suggested mitigation when available. Do not include credentials, private keys or other sensitive material in the report. If the private reporting form is unavailable, open a minimal public issue asking maintainers for a secure contact path without including exploit details. ## Credential handling diff --git a/app/openapi.json/route.ts b/app/openapi.json/route.ts index f782e86..1f9d0db 100644 --- a/app/openapi.json/route.ts +++ b/app/openapi.json/route.ts @@ -245,18 +245,18 @@ export function GET(request: Request): Promise { monthlySpendLimit: { type: "number", minimum: 0 }, reservePct: { type: "number", minimum: 0, maximum: 100, default: 20 }, cautionBudgetUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 75 }, criticalBudgetUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 90 }, maxProviderConcentrationPct: { type: "number", minimum: 0, maximum: 100, default: 35 }, maxSingleActionAmount: { type: "number", minimum: 0 }, maxStrategyAllocationPct: { type: "number", minimum: 0, maximum: 100, default: 35 }, maxStrategyAllocationAbsolute: { type: "number", minimum: 0 }, dailyLossLimit: { type: "number", minimum: 0 }, cautionLossUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 70 }, criticalLossUtilizationPct: { type: "number", minimum: 0, maximum: 100, default: 90 }, }, }, - FinancialProvider: { type: "object", required: ["provider", "spend", "transactions"], additionalProperties: false, properties: { provider: { type: "string", minLength: 1, maxLength: 256 }, spend: { type: "number", minimum: 0 }, transactions: { type: "integer", minimum: 0 }, failedTransactions: { type: "integer", minimum: 0 } } }, + FinancialProvider: { type: "object", required: ["provider", "spend", "transactions"], additionalProperties: false, description: "Provider-level successful outgoing activity. failedTransactions counts failed outgoing attempts separately and may be greater than transactions.", properties: { provider: { type: "string", minLength: 1, maxLength: 256 }, spend: { type: "number", minimum: 0 }, transactions: { type: "integer", minimum: 0, description: "Successful outgoing payments to this provider." }, failedTransactions: { type: "integer", minimum: 0, description: "Failed outgoing attempts to this provider; not included in transactions or spend." } } }, ProvidedFinancialState: { type: "object", required: ["periodDays", "cashFlow"], additionalProperties: false, - description: "Caller-supplied factual financial state. AgentLedger derives conclusions and does not independently attest to these values.", + description: "Caller-supplied factual financial state. AgentLedger derives conclusions and does not independently attest to these values. Provider lists may be partial. Provider spend cannot exceed total outflows by more than $0.01; successful provider transaction counts cannot exceed explicit successful outgoingTransactions; and explicit failedTransactions must include all provider failedTransactions.", properties: { periodDays: { type: "integer", minimum: 1, maximum: 90 }, - cashFlow: { type: "object", required: ["inflows", "outflows"], additionalProperties: false, properties: { inflows: { type: "number", minimum: 0 }, outflows: { type: "number", minimum: 0 }, incomingTransactions: { type: "integer", minimum: 0 }, outgoingTransactions: { type: "integer", minimum: 0 } } }, - spendVelocity: { type: "object", additionalProperties: false, properties: { firstHalf: { type: "number", minimum: 0 }, secondHalf: { type: "number", minimum: 0 } } }, - providers: { type: "array", maxItems: 100, items: { $ref: "#/components/schemas/FinancialProvider" } }, - failedTransactions: { type: "integer", minimum: 0 }, + cashFlow: { type: "object", required: ["inflows", "outflows"], additionalProperties: false, description: "Inflows and outflows for the period. Transaction counts describe successful transactions; failed attempts are tracked separately.", properties: { inflows: { type: "number", minimum: 0 }, outflows: { type: "number", minimum: 0 }, incomingTransactions: { type: "integer", minimum: 0, description: "Successful incoming transactions." }, outgoingTransactions: { type: "integer", minimum: 0, description: "Successful outgoing transactions. Provider transaction totals cannot exceed this value when supplied." } } }, + spendVelocity: { type: "object", additionalProperties: false, description: "Observed spend in the two half-periods. The values are used for velocity and are not required to equal total outflows when the caller reports a partial observation.", properties: { firstHalf: { type: "number", minimum: 0 }, secondHalf: { type: "number", minimum: 0 } } }, + providers: { type: "array", maxItems: 100, description: "Successful outgoing provider payments. Entries with the same provider name are aggregated case-insensitively; the list may be a subset of total spending.", items: { $ref: "#/components/schemas/FinancialProvider" } }, + failedTransactions: { type: "integer", minimum: 0, description: "Total failed transactions. If omitted, known provider-level failures are derived; if supplied, it must be at least their sum." }, }, }, ProvidedCompanyHealthRequest: { type: "object", required: ["source", "financialState"], properties: { source: { type: "string", enum: ["provided_state"] }, financialState: { $ref: "#/components/schemas/ProvidedFinancialState" }, address: { type: "string", description: "Optional caller label; it is not verified by AgentLedger." }, monthlyBudget: { type: "number", minimum: 0 }, policy: { $ref: "#/components/schemas/FinancialPolicy" } } }, diff --git a/docs/assets/agentledger.png b/docs/assets/agentledger.png new file mode 100644 index 0000000..ac98755 Binary files /dev/null and b/docs/assets/agentledger.png differ diff --git a/lib/financial-context.ts b/lib/financial-context.ts index 0866651..d7ee532 100644 --- a/lib/financial-context.ts +++ b/lib/financial-context.ts @@ -1,5 +1,8 @@ import type { NormalizedTransaction, ProvidedFinancialState } from "./types"; +export const FINANCIAL_STATE_TOLERANCE = 0.01; +export const MAX_AGGREGATE_TRANSACTION_COUNT = 1_000_000; + export type NormalizedProviderState = { provider: string; spend: number; @@ -83,12 +86,33 @@ export function normalizeFinancialState(state: ProvidedFinancialState): Normaliz } } - const failedTransactions = state.failedTransactions ?? 0; + const providerSpendTotal = [...providers.values()].reduce((sum, provider) => sum + provider.spend, 0); + if (providerSpendTotal > state.cashFlow.outflows + FINANCIAL_STATE_TOLERANCE) { + throw new Error("financialState.providers.spend total must not exceed cashFlow.outflows"); + } + + const providerSuccessfulTransactionTotal = [...providers.values()].reduce((sum, provider) => sum + provider.transactions, 0); + if (state.cashFlow.outgoingTransactions !== undefined && providerSuccessfulTransactionTotal > state.cashFlow.outgoingTransactions) { + throw new Error("financialState.providers.transactions total must not exceed cashFlow.outgoingTransactions"); + } + if (providerSuccessfulTransactionTotal > MAX_AGGREGATE_TRANSACTION_COUNT) { + throw new Error("financialState provider transaction totals are unreasonably large"); + } + + const providerFailedTotal = [...providers.values()].reduce((sum, provider) => sum + provider.failedTransactions, 0); + if (providerFailedTotal > MAX_AGGREGATE_TRANSACTION_COUNT) { + throw new Error("financialState provider failed transaction totals are unreasonably large"); + } + if (state.failedTransactions !== undefined && state.failedTransactions < providerFailedTotal) { + throw new Error("financialState.failedTransactions must include all provider failedTransactions"); + } + + const failedTransactions = state.failedTransactions ?? providerFailedTotal; assertCount(failedTransactions, "failedTransactions"); const incomingTransactions = state.cashFlow.incomingTransactions ?? (state.cashFlow.inflows > 0 ? 1 : 0); const outgoingTransactions = state.cashFlow.outgoingTransactions ?? ( providers.size > 0 - ? [...providers.values()].reduce((sum, provider) => sum + provider.transactions, 0) + ? providerSuccessfulTransactionTotal : state.cashFlow.outflows > 0 ? 1 : 0 diff --git a/lib/recommendations.ts b/lib/recommendations.ts index facbe7d..145f780 100644 --- a/lib/recommendations.ts +++ b/lib/recommendations.ts @@ -1,12 +1,14 @@ import type { CompanyHealthReport, FinancialRecommendation, + FinancialRiskSignalType, FinancialStatus, FinancialPolicy, RecommendationsReport, } from "./types"; import { APP_NAME, APP_VERSION } from "./types"; import { resolveFinancialPolicy } from "./policy"; +import { deriveFinancialRiskSignals } from "./risk"; type Candidate = { score: number; @@ -23,65 +25,36 @@ function display(value: number): string { return round(value).toFixed(2).replace(/\.00$/, ""); } -function percentage(value: number, total: number): number { - if (total <= 0) { - return 0; - } - - return round((value / total) * 100); -} - function severityScore(severity: FinancialRecommendation["severity"]): number { return { critical: 100, high: 70, medium: 40, low: 20 }[severity]; } export function getFinancialStatus(report: CompanyHealthReport, policy?: FinancialPolicy): FinancialStatus { - const resolved = resolveFinancialPolicy(policy); - const budgetPct = report.budget?.usedPct ?? 0; - const concentrationPct = report.topCounterparties[0]?.shareOfSpendPct ?? 0; - const hardConcentrationPct = Math.min(100, resolved.maxProviderConcentrationPct + 15); - const hasCriticalRisk = budgetPct > resolved.criticalBudgetUtilizationPct || concentrationPct > hardConcentrationPct; - - if (hasCriticalRisk) { + const signals = deriveFinancialRiskSignals(report, policy); + if (signals.some((signal) => signal.severity === "critical")) { return "critical"; } - - const hasModerateRisk = - budgetPct > resolved.cautionBudgetUtilizationPct || - concentrationPct > resolved.maxProviderConcentrationPct || - report.spendVelocity.changePct > 50 || - report.cashFlow.net < 0 || - report.transactions.failed > 0; - - return hasModerateRisk ? "caution" : "healthy"; + return signals.length > 0 ? "caution" : "healthy"; } function buildExecutiveSummary(report: CompanyHealthReport, status: FinancialStatus, policy?: FinancialPolicy): string { - const resolved = resolveFinancialPolicy(policy); - const hardConcentrationPct = Math.min(100, resolved.maxProviderConcentrationPct + 15); if (status === "healthy") { return "Financial activity is healthy, with controlled spending and no material budget, concentration, cash-flow, or velocity risks identified."; } const drivers: string[] = []; - if ((report.budget?.usedPct ?? 0) > resolved.criticalBudgetUtilizationPct) { - drivers.push("critical budget utilization"); - } else if ((report.budget?.usedPct ?? 0) > resolved.cautionBudgetUtilizationPct) { - drivers.push("elevated budget utilization"); - } - if ((report.topCounterparties[0]?.shareOfSpendPct ?? 0) > hardConcentrationPct) { - drivers.push("high provider concentration"); - } else if ((report.topCounterparties[0]?.shareOfSpendPct ?? 0) > resolved.maxProviderConcentrationPct) { - drivers.push("provider concentration"); - } - if (report.cashFlow.net < 0) { - drivers.push("negative operating cash flow"); - } - if (report.spendVelocity.changePct > 50) { - drivers.push("accelerating spend"); - } - if (report.transactions.failed > 0) { - drivers.push("failed transactions"); + for (const signal of deriveFinancialRiskSignals(report, policy)) { + drivers.push( + signal.type === "budget_utilization" + ? signal.severity === "critical" ? "critical budget utilization" : "elevated budget utilization" + : signal.type === "provider_concentration" + ? signal.severity === "critical" ? "high provider concentration" : "provider concentration" + : signal.type === "negative_cash_flow" + ? "negative operating cash flow" + : signal.type === "spend_acceleration" + ? "accelerating spend" + : "failed transactions", + ); } const driverText = drivers.length > 0 ? drivers.slice(0, 3).join(", ") : "moderate financial-control signals"; @@ -100,62 +73,50 @@ function addCandidate(candidates: Candidate[], order: number, recommendation: Om export function buildFinancialRecommendations(report: CompanyHealthReport, policy?: FinancialPolicy): FinancialRecommendation[] { const resolved = resolveFinancialPolicy(policy); + const riskSignals = deriveFinancialRiskSignals(report, policy); const cautionBudgetPct = resolved.cautionBudgetUtilizationPct; - const criticalBudgetPct = resolved.criticalBudgetUtilizationPct; const providerLimitPct = resolved.maxProviderConcentrationPct; - const hardProviderLimitPct = Math.min(100, providerLimitPct + 15); const reservePct = resolved.reservePct; const candidates: Candidate[] = []; let order = 0; const budget = report.budget; const largestCounterparty = report.topCounterparties[0]; + const riskFor = (type: FinancialRiskSignalType) => riskSignals.find((signal) => signal.type === type); - if (budget && budget.usedPct > criticalBudgetPct) { + if (budget && riskFor("budget_utilization")) { + const budgetRisk = riskFor("budget_utilization"); + const isCritical = budgetRisk?.severity === "critical"; addCandidate( candidates, order++, { type: "reduce_budget_pressure", - severity: "critical", - title: "Reduce discretionary spending immediately", - finding: `Normalized monthly spend is ${display(budget.usedPct)}% of the configured budget.`, - implication: "The operating plan is close to exhausting its approved spending capacity.", - recommendation: "Freeze or sharply reduce discretionary spending until budget utilization returns below a safer threshold.", - action: { - type: "set_monthly_spend_cap", - recommendedValue: round(budget.limit * (1 - reservePct / 100)), - unit: "USD per month", - }, - target: { metric: "budgetUtilizationPct", current: budget.usedPct, desired: round(100 - reservePct) }, - expectedImpact: "Create budget headroom and restore a more resilient operating reserve.", - }, - budget.usedPct - criticalBudgetPct, - ); - } else if (budget && budget.usedPct > cautionBudgetPct) { - addCandidate( - candidates, - order++, - { - type: "reduce_budget_pressure", - severity: "high", - title: "Protect remaining operating budget", + severity: isCritical ? "critical" : "high", + title: isCritical ? "Reduce discretionary spending immediately" : "Protect remaining operating budget", finding: `Normalized monthly spend is ${display(budget.usedPct)}% of the configured budget.`, - implication: "Only a limited share of the approved monthly budget remains for non-essential work.", - recommendation: "Prioritize essential services and reduce discretionary spending.", + implication: isCritical + ? "The operating plan is close to exhausting its approved spending capacity." + : "Only a limited share of the approved monthly budget remains for non-essential work.", + recommendation: isCritical + ? "Freeze or sharply reduce discretionary spending until budget utilization returns below a safer threshold." + : "Prioritize essential services and reduce discretionary spending.", action: { type: "set_monthly_spend_cap", - recommendedValue: round(budget.limit * (cautionBudgetPct / 100)), + recommendedValue: isCritical ? round(budget.limit * (1 - reservePct / 100)) : round(budget.limit * (cautionBudgetPct / 100)), unit: "USD per month", }, - target: { metric: "budgetUtilizationPct", current: budget.usedPct, desired: cautionBudgetPct }, - expectedImpact: "Preserve budget capacity for essential operating activity.", + target: { metric: "budgetUtilizationPct", current: budget.usedPct, desired: isCritical ? round(100 - reservePct) : cautionBudgetPct }, + expectedImpact: isCritical + ? "Create budget headroom and restore a more resilient operating reserve." + : "Preserve budget capacity for essential operating activity.", }, - budget.usedPct - cautionBudgetPct, + Math.max(0, budget.usedPct - (budgetRisk?.threshold ?? cautionBudgetPct)), ); } - if (largestCounterparty && largestCounterparty.shareOfSpendPct > providerLimitPct) { - const isCritical = largestCounterparty.shareOfSpendPct > hardProviderLimitPct; + const concentrationRisk = riskFor("provider_concentration"); + if (largestCounterparty && concentrationRisk) { + const isCritical = concentrationRisk.severity === "critical"; addCandidate( candidates, order++, @@ -185,8 +146,9 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic ); } - if (report.spendVelocity.changePct > 50 && report.spendVelocity.secondHalf > report.spendVelocity.firstHalf) { - const isCritical = report.spendVelocity.changePct > 100; + const accelerationRisk = riskFor("spend_acceleration"); + if (accelerationRisk) { + const isCritical = accelerationRisk.severity === "critical"; const temporaryCap = report.spendVelocity.firstHalf > 0 ? round(report.spendVelocity.firstHalf * 1.5) : 0; addCandidate( candidates, @@ -210,7 +172,7 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic ); } - if (report.cashFlow.net < 0) { + if (riskFor("negative_cash_flow")) { const deficit = round(Math.abs(report.cashFlow.net)); addCandidate( candidates, @@ -259,7 +221,7 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic ); } - if (report.transactions.failed > 0) { + if (riskFor("failed_transactions")) { const failedProvider = report.failedCounterparties[0]; const providerText = failedProvider && failedProvider.transactions > 1 ? ` Multiple failures share provider ${failedProvider.address}.` @@ -282,8 +244,8 @@ export function buildFinancialRecommendations(report: CompanyHealthReport, polic } const budgetIsLow = budget && budget.usedPct <= cautionBudgetPct; - const concentrationIsHealthy = !largestCounterparty || largestCounterparty.shareOfSpendPct <= providerLimitPct; - const velocityIsHealthy = report.spendVelocity.changePct <= 50; + const concentrationIsHealthy = !riskFor("provider_concentration"); + const velocityIsHealthy = !riskFor("spend_acceleration"); if (report.cashFlow.net > 0 && budgetIsLow && concentrationIsHealthy && velocityIsHealthy) { const reserveTarget = round((budget?.limit ?? 0) * (reservePct / 100)); const availableCapacity = round(Math.max(0, (budget?.limit ?? 0) * (1 - reservePct / 100) - (budget?.normalizedUsed ?? 0))); diff --git a/lib/risk.ts b/lib/risk.ts new file mode 100644 index 0000000..2c0d3bf --- /dev/null +++ b/lib/risk.ts @@ -0,0 +1,66 @@ +import type { CompanyHealthReport, FinancialPolicy, FinancialRiskSignal } from "./types"; +import { resolveFinancialPolicy } from "./policy"; + +/** + * Derives material financial-control signals shared by status and + * recommendations. Informational and positive observations are intentionally + * excluded because they should not downgrade a healthy financial status. + */ +export function deriveFinancialRiskSignals( + report: CompanyHealthReport, + policy?: FinancialPolicy, +): FinancialRiskSignal[] { + const resolved = resolveFinancialPolicy(policy); + const signals: FinancialRiskSignal[] = []; + const budgetPct = report.budget?.usedPct; + if (budgetPct !== undefined && budgetPct > resolved.cautionBudgetUtilizationPct) { + signals.push({ + type: "budget_utilization", + severity: budgetPct > resolved.criticalBudgetUtilizationPct ? "critical" : "high", + metric: budgetPct, + threshold: budgetPct > resolved.criticalBudgetUtilizationPct + ? resolved.criticalBudgetUtilizationPct + : resolved.cautionBudgetUtilizationPct, + }); + } + + const concentrationPct = report.topCounterparties[0]?.shareOfSpendPct ?? 0; + if (concentrationPct > resolved.maxProviderConcentrationPct) { + const hardLimit = Math.min(100, resolved.maxProviderConcentrationPct + 15); + signals.push({ + type: "provider_concentration", + severity: concentrationPct > hardLimit ? "critical" : "high", + metric: concentrationPct, + threshold: concentrationPct > hardLimit ? hardLimit : resolved.maxProviderConcentrationPct, + }); + } + + if (report.spendVelocity.secondHalf > report.spendVelocity.firstHalf && report.spendVelocity.changePct > 50) { + signals.push({ + type: "spend_acceleration", + severity: report.spendVelocity.changePct > 100 ? "critical" : "high", + metric: report.spendVelocity.changePct, + threshold: report.spendVelocity.changePct > 100 ? 100 : 50, + }); + } + + if (report.cashFlow.net < 0) { + signals.push({ + type: "negative_cash_flow", + severity: "high", + metric: report.cashFlow.net, + threshold: 0, + }); + } + + if (report.transactions.failed > 0) { + signals.push({ + type: "failed_transactions", + severity: "medium", + metric: report.transactions.failed, + threshold: 0, + }); + } + + return signals; +} diff --git a/lib/types.ts b/lib/types.ts index 740e245..1f938ab 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,5 @@ export const APP_NAME = "AgentLedger" as const; -export const APP_VERSION = "0.3.2" as const; +export const APP_VERSION = "0.3.3" as const; export const SUPPORTED_ASSETS = ["USDT", "USDC", "USDG"] as const; @@ -66,22 +66,43 @@ export type ProvidedFinancialState = { cashFlow: { inflows: number; outflows: number; + /** Successful incoming transactions in the selected period. */ incomingTransactions?: number; + /** Successful outgoing provider transactions in the selected period. */ outgoingTransactions?: number; }; spendVelocity?: { + /** Spend observed in the first half of the selected period. */ firstHalf: number; + /** Spend observed in the second half of the selected period. */ secondHalf: number; }; providers?: Array<{ provider: string; + /** Successful outgoing payments to this provider. */ spend: number; transactions: number; + /** Failed outgoing attempts; separate from successful transactions. */ failedTransactions?: number; }>; + /** Total failed transactions, including failures outside the provider subset. */ failedTransactions?: number; }; +export type FinancialRiskSignalType = + | "budget_utilization" + | "provider_concentration" + | "spend_acceleration" + | "negative_cash_flow" + | "failed_transactions"; + +export type FinancialRiskSignal = { + type: FinancialRiskSignalType; + severity: "critical" | "high" | "medium"; + metric: number; + threshold: number; +}; + export type CompanyHealthRequest = { source?: "provided_state" | "okx"; address?: string; diff --git a/lib/validation.ts b/lib/validation.ts index 0756516..bb3c142 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { FINANCIAL_STATE_TOLERANCE, MAX_AGGREGATE_TRANSACTION_COUNT } from "./financial-context"; import { financialPolicySchema, MAX_FINANCIAL_VALUE } from "./policy"; const daysSchema = z.number().int("days must be an integer").min(1, "days must be at least 1").max(90, "days must be at most 90"); @@ -35,22 +36,59 @@ const providedFinancialStateSchema = z transactions: transactionCountSchema("financialState.providers.transactions"), failedTransactions: transactionCountSchema("financialState.providers.failedTransactions").optional(), }) - .strict() - .superRefine((provider, context) => { - if ((provider.failedTransactions ?? 0) > provider.transactions) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ["failedTransactions"], - message: "financialState.providers.failedTransactions must not exceed transactions", - }); - } - }), + .strict(), ) .max(100, "financialState.providers must contain at most 100 entries") .optional(), failedTransactions: transactionCountSchema("financialState.failedTransactions").optional(), }) - .strict(); + .strict() + .superRefine((state, context) => { + const providers = state.providers ?? []; + const providerSpendTotal = providers.reduce((sum, provider) => sum + provider.spend, 0); + if (providerSpendTotal > state.cashFlow.outflows + FINANCIAL_STATE_TOLERANCE) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["providers"], + message: "financialState.providers.spend total must not exceed cashFlow.outflows", + }); + } + + const providerSuccessfulTransactionTotal = providers.reduce((sum, provider) => sum + provider.transactions, 0); + if (providerSuccessfulTransactionTotal > MAX_AGGREGATE_TRANSACTION_COUNT) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["providers"], + message: "financialState provider transaction totals are unreasonably large", + }); + } + if ( + state.cashFlow.outgoingTransactions !== undefined && + providerSuccessfulTransactionTotal > state.cashFlow.outgoingTransactions + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["cashFlow", "outgoingTransactions"], + message: "financialState.providers.transactions total must not exceed cashFlow.outgoingTransactions", + }); + } + + const providerFailedTotal = providers.reduce((sum, provider) => sum + (provider.failedTransactions ?? 0), 0); + if (providerFailedTotal > MAX_AGGREGATE_TRANSACTION_COUNT) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["providers"], + message: "financialState provider failed transaction totals are unreasonably large", + }); + } + if (state.failedTransactions !== undefined && state.failedTransactions < providerFailedTotal) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["failedTransactions"], + message: "financialState.failedTransactions must include all provider failedTransactions", + }); + } + }); export const companyHealthSchema = z .object({ diff --git a/package-lock.json b/package-lock.json index 83a448a..d7e7c75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentledger", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentledger", - "version": "0.3.2", + "version": "0.3.3", "dependencies": { "@okxweb3/x402-core": "^0.1.0", "@okxweb3/x402-evm": "^0.2.1", diff --git a/package.json b/package.json index 1451f5f..2a0db55 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "agentledger", - "version": "0.3.2", + "version": "0.3.3", "private": true, "description": "The financial control plane for autonomous agents.", "scripts": { - "dev": "WATCHPACK_POLLING=true next dev", + "dev": "next dev", + "dev:poll": "WATCHPACK_POLLING=true next dev", "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", diff --git a/tests/hardening.test.ts b/tests/hardening.test.ts new file mode 100644 index 0000000..82211e7 --- /dev/null +++ b/tests/hardening.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { POST as companyHealthPost } from "../app/api/company-health/route"; +import { analyzeProvidedFinancialState, buildSpendDecision } from "../lib/analysis"; +import { buildAllocationDecision } from "../lib/allocation"; +import { normalizeFinancialState } from "../lib/financial-context"; +import { buildFinancialRecommendations, getFinancialStatus } from "../lib/recommendations"; +import { deriveFinancialRiskSignals } from "../lib/risk"; +import { evaluateCapitalAllocationPolicy, evaluateServicePurchasePolicy } from "../lib/policy"; +import { companyHealthSchema } from "../lib/validation"; + +const NOW = Date.parse("2026-08-30T00:00:00.000Z"); + +function state(overrides: Record = {}) { + return { + periodDays: 30, + cashFlow: { inflows: 200, outflows: 100 }, + ...overrides, + } as Parameters[0]; +} + +function report(overrides: Record = {}, monthlyBudget?: number) { + return analyzeProvidedFinancialState({ + wallet: "provided-state", + financialState: state(overrides), + monthlyBudget, + now: NOW, + }); +} + +function request(body: unknown): Request { + return new Request("http://localhost/api/company-health", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("provider spend above outflows is rejected by request validation", async () => { + const body = state({ providers: [{ provider: "provider-a", spend: 100.02, transactions: 1 }] }); + assert.equal(companyHealthSchema.safeParse(body).success, false); + const response = await companyHealthPost(request({ source: "provided_state", financialState: body })); + assert.equal(response.status, 400); + assert.match((await response.json()).error.message, /must not exceed cashFlow\.outflows/); +}); + +test("partial provider spend below outflows is accepted", () => { + const normalized = normalizeFinancialState(state({ + providers: [{ provider: "provider-a", spend: 20, transactions: 2 }], + })); + assert.equal(normalized.outflows, 100); + assert.equal(normalized.providers[0]?.spend, 20); +}); + +test("duplicate providers aggregate case-insensitively including failed attempts", () => { + const normalized = normalizeFinancialState(state({ + providers: [ + { provider: "Provider-A", spend: 20, transactions: 2, failedTransactions: 3 }, + { provider: " provider-a ", spend: 10, transactions: 1, failedTransactions: 2 }, + ], + })); + assert.deepEqual(normalized.providers, [{ provider: "Provider-A", spend: 30, transactions: 3, failedTransactions: 5 }]); + assert.deepEqual(normalized.failedProviders, [{ provider: "Provider-A", transactions: 5 }]); + assert.equal(normalized.failedTransactions, 5); +}); + +test("failed provider attempts are independent from successful transactions", () => { + const normalized = normalizeFinancialState(state({ + providers: [{ provider: "provider-a", spend: 20, transactions: 1, failedTransactions: 4 }], + })); + assert.equal(normalized.providers[0]?.transactions, 1); + assert.equal(normalized.providers[0]?.failedTransactions, 4); +}); + +test("explicit outgoing transaction total must cover known successful provider transactions", () => { + const result = companyHealthSchema.safeParse(state({ + cashFlow: { inflows: 200, outflows: 100, outgoingTransactions: 2 }, + providers: [{ provider: "provider-a", spend: 20, transactions: 3 }], + })); + assert.equal(result.success, false); +}); + +test("known provider failures must be included in an explicit top-level failure count", () => { + const result = companyHealthSchema.safeParse(state({ + providers: [{ provider: "provider-a", spend: 20, transactions: 1, failedTransactions: 2 }], + failedTransactions: 1, + })); + assert.equal(result.success, false); +}); + +test("provider failures are derived when top-level failures are omitted", () => { + const normalized = normalizeFinancialState(state({ + providers: [{ provider: "provider-a", spend: 20, transactions: 1, failedTransactions: 2 }], + })); + assert.equal(normalized.failedTransactions, 2); +}); + +test("financial risk signals expose critical budget risk consistently", () => { + const current = report({}, 100); + const signals = deriveFinancialRiskSignals(current); + assert.equal(signals.find((signal) => signal.type === "budget_utilization")?.severity, "critical"); + assert.equal(getFinancialStatus(current), "critical"); + assert.equal(buildFinancialRecommendations(current)[0]?.severity, "critical"); +}); + +test("critical provider concentration is shared by status and recommendations", () => { + const current = report({ + cashFlow: { inflows: 200, outflows: 100 }, + providers: [ + { provider: "provider-a", spend: 60, transactions: 1 }, + { provider: "provider-b", spend: 40, transactions: 1 }, + ], + }); + assert.equal(getFinancialStatus(current), "critical"); + assert.equal(buildFinancialRecommendations(current)[0]?.severity, "critical"); +}); + +test("extreme spend acceleration produces a critical status and recommendation", () => { + const current = report({ + cashFlow: { inflows: 400, outflows: 235 }, + spendVelocity: { firstHalf: 65, secondHalf: 170 }, + }); + assert.equal(getFinancialStatus(current), "critical"); + assert.equal(buildFinancialRecommendations(current).find((item) => item.type === "control_spend_acceleration")?.severity, "critical"); +}); + +test("moderate spend acceleration produces caution rather than critical status", () => { + const current = report({ spendVelocity: { firstHalf: 50, secondHalf: 90 } }); + assert.equal(getFinancialStatus(current), "caution"); + assert.equal(buildFinancialRecommendations(current).find((item) => item.type === "control_spend_acceleration")?.severity, "high"); +}); + +test("negative cash flow is a caution signal unless another risk is critical", () => { + const current = report({ cashFlow: { inflows: 20, outflows: 100 } }); + assert.equal(getFinancialStatus(current), "caution"); + assert.equal(deriveFinancialRiskSignals(current).find((signal) => signal.type === "negative_cash_flow")?.severity, "high"); +}); + +test("failed transactions produce a caution signal", () => { + const current = report({ failedTransactions: 1 }); + assert.equal(getFinancialStatus(current), "caution"); + assert.equal(deriveFinancialRiskSignals(current).find((signal) => signal.type === "failed_transactions")?.severity, "medium"); +}); + +test("positive controlled activity remains healthy", () => { + const current = report({ cashFlow: { inflows: 100, outflows: 20 } }, 100); + assert.equal(getFinancialStatus(current), "healthy"); + assert.equal(buildFinancialRecommendations(current)[0]?.type, "use_available_financial_capacity"); +}); + +test("recommendations remain capped at three and ordered deterministically", () => { + const current = report({ + cashFlow: { inflows: 20, outflows: 100 }, + spendVelocity: { firstHalf: 10, secondHalf: 90 }, + providers: [ + { provider: "provider-a", spend: 60, transactions: 3 }, + { provider: "provider-b", spend: 40, transactions: 1 }, + ], + failedTransactions: 1, + }, 100); + const first = buildFinancialRecommendations(current).map((item) => `${item.priority}:${item.type}:${item.severity}`); + const second = buildFinancialRecommendations(current).map((item) => `${item.priority}:${item.type}:${item.severity}`); + assert.deepEqual(first, second); + assert.ok(first.length <= 3); +}); + +test("zero total capital produces finite deterministic allocation output", () => { + const result = evaluateCapitalAllocationPolicy({ + action: { type: "capital_allocation", amount: 0, strategy: "zero" }, + currentStrategyAllocation: 0, + totalCapital: 0, + dailyLossUsed: 0, + }, { maxStrategyAllocationPct: 35 }); + assert.equal(result.decision, "approve"); + assert.equal(Number.isFinite(result.strategyAllocationPct), true); + assert.equal(Number.isFinite(result.strategyLimitUtilizationPct), true); +}); + +test("zero budget service purchase remains finite and deterministic", () => { + const current = report({ cashFlow: { inflows: 0, outflows: 0 } }); + const result = buildSpendDecision({ report: current, proposedSpend: 0, monthlyBudget: 0 }); + assert.equal(result.decision, "approve"); + assert.equal(result.projectedBudgetUsedPct, 0); + assert.equal(Number.isFinite(result.budgetRemainingAfterSpend), true); +}); + +test("budget caution and critical equality boundaries remain non-crossing", () => { + const caution = evaluateServicePurchasePolicy({ + action: { type: "service_purchase", amount: 0, provider: "provider-a" }, + currentSpend: 75, + projectedSpend: 75, + currentNormalizedSpend: 75, + projectedNormalizedSpend: 75, + currentProviderConcentrationPct: 0, + projectedProviderConcentrationPct: 0, + }, { monthlySpendLimit: 100, reservePct: 0, cautionBudgetUtilizationPct: 75, criticalBudgetUtilizationPct: 90 }); + assert.equal(caution.decision, "approve"); + + const critical = evaluateServicePurchasePolicy({ + action: { type: "service_purchase", amount: 0, provider: "provider-a" }, + currentSpend: 90, + projectedSpend: 90, + currentNormalizedSpend: 90, + projectedNormalizedSpend: 90, + currentProviderConcentrationPct: 0, + projectedProviderConcentrationPct: 0, + }, { monthlySpendLimit: 100, reservePct: 0, cautionBudgetUtilizationPct: 75, criticalBudgetUtilizationPct: 90 }); + assert.equal(critical.decision, "caution"); +}); + +test("loss caution and critical equality boundaries remain deterministic", () => { + const caution = evaluateCapitalAllocationPolicy({ + action: { type: "capital_allocation", amount: 0, strategy: "loss" }, + currentStrategyAllocation: 0, + totalCapital: 1000, + dailyLossUsed: 70, + }, { dailyLossLimit: 100, cautionLossUtilizationPct: 70, criticalLossUtilizationPct: 90 }); + assert.equal(caution.decision, "approve"); + + const critical = evaluateCapitalAllocationPolicy({ + action: { type: "capital_allocation", amount: 0, strategy: "loss" }, + currentStrategyAllocation: 0, + totalCapital: 1000, + dailyLossUsed: 90, + }, { dailyLossLimit: 100, cautionLossUtilizationPct: 70, criticalLossUtilizationPct: 90 }); + assert.equal(critical.decision, "reject"); +}); + +test("allocation recommended maximum never becomes negative when current state is over limit", () => { + const result = buildAllocationDecision({ + agent: "agent", + strategy: "strategy", + proposedCapital: 0, + currentStrategyAllocation: 100, + totalCapital: 50, + strategyCapitalLimit: 75, + dailyLossUsed: 0, + dailyLossLimit: 100, + }); + assert.equal(result.recommendedAction.recommendedMaximum, 0); +}); diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts index 7fed224..6b733d1 100644 --- a/tests/upgrade.test.ts +++ b/tests/upgrade.test.ts @@ -421,7 +421,7 @@ test("recommendations report contains a concise summary and at most three action ], 100), new Date(NOW).toISOString(), ); - assert.equal(result.version, "0.3.2"); + assert.equal(result.version, "0.3.3"); assert.ok(result.executiveSummary.length > 0); assert.ok(result.recommendedActions.length <= 3); });