aere-quantum/agents/agent-policy.mjs
Aere Network 2293c1da86 agents/x402: an agent wallet that pays over x402 only what the agent's ledger records and its policy allows, run on the public testnet
The agent does not hold the payment key: the wallet holds it for the owner and signs an EIP-3009 authorization only for a payment the
agent wrote into its signed ledger, verified without trusting the agent under the policy the owner pinned and against the ledger heads
the wallet itself saw (a branch is refused with a proof of equivocation, a backdated entry is refused), naming exactly this purchase,
written now, and within the limit judged also against what the wallet itself has signed. The authorization nonce is sha256(entry hash),
so the on-chain payment names the ledger entry. The policy gains an optional `wallet` field. Also: an x402 v2 client, a minimal resource
server, a local facilitator for tests, and verifica-plati.mjs, which proves from outside that a wallet's on-chain payments were allowed
by the agent's policy (with --all-transfers, that no payment left the wallet without a ledger entry).

Tests: wallet 25/25 and payment verifier 10/10 without a network (the verifier on chain responses recorded on testnet 28001), negative
control 21/21; policy 27/27, agents control 26/26. On the public testnet 28001 through its x402 facilitator: 9/9, with the evidence in
agents/x402/dovezi-28001/. Needs ethers (npm install in agents/x402).
2026-09-30 00:01:34 +03:00

177 lines
13 KiB
JavaScript

'use strict';
// AERE Agent Policy (roadmap B2 / #35-37): politica unui agent AI impusa CRIPTOGRAFIC - limita de cheltuiala pe fereastra, uneltele
// permise, destinatarii permisi - cu fiecare decizie inregistrata ca dovada AERE Proof Protocol (AIP-23), astfel incat "agentul nu a
// depasit politica" sa fie verificabil de un tert, nu o promisiune. Politica e ancorata printr-un hash: o decizie leaga hash-ul
// politicii, deci nu poti pretinde ca ai aplicat alta politica. Numai Node 24, node:crypto, fara dependinte.
//
// 2026-09-29 (revizuirea adversariala B-17, forma 2): motivele deciziilor si mesajele sunt in engleza (intra in plicuri si registre
// publice); `hashPolicy` RECALCULEAZA hash-ul unei politici primite (verificatorul se increzuse in perechea politica+hash data de
// agent, deci o politica LAXA sub hash-ul celei reale trecea: masurat, o plata de 5000 sub o limita reala de 100, verificator "ok");
// politica se valideaza (suma si fereastra), o plata intr-un alt activ decat al limitei e refuzata, iar actionHash-ul plicului de
// decizie e canonic (chei sortate), ca un strain sa il reproduca din actiune indiferent de ordinea cheilor.
import crypto from 'node:crypto';
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
/** serializare canonica (chei sortate, recursiv): aceeasi valoare pe orice masina */
export function canonical(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
}
const CHEI_POLITICA = new Set(['v', 'kind', 'agentId', 'spend', 'tools', 'recipients', 'approval', 'owner', 'wallet']);
const HUMAN_ID = /^aere-human:[0-9a-f]{40}$/;
const INTREG = /^[0-9]+$/;
// Forma normala a unei politici, singurul loc care o construieste: definePolicy o scrie, hashPolicy o reface din ce primeste, deci
// hash-ul unei politici e acelasi oricum ar fi ordonat fisierul ei. Campurile `approval` si `owner` intra NUMAI cand sunt date, ca
// hash-ul politicilor de dinainte de 2026-09-28 sa ramana acelasi.
function formaNormala(p) {
if (!p || !p.agentId || typeof p.agentId !== 'string') throw new Error('agent-policy: agentId is required');
let spend = null;
if (p.spend) {
const amount = String(p.spend.amount), windowSeconds = Number(p.spend.windowSeconds);
if (!INTREG.test(amount)) throw new Error('agent-policy: spend.amount must be a decimal integer');
if (!Number.isInteger(windowSeconds) || windowSeconds < 1) throw new Error('agent-policy: spend.windowSeconds must be a positive integer');
spend = { amount, windowSeconds, asset: p.spend.asset ? String(p.spend.asset) : 'AERE' };
}
if (p.tools != null && !(Array.isArray(p.tools) && p.tools.every((x) => typeof x === 'string'))) throw new Error('agent-policy: tools must be a list of names');
if (p.recipients != null && !(Array.isArray(p.recipients) && p.recipients.every((x) => typeof x === 'string'))) throw new Error('agent-policy: recipients must be a list of addresses');
const policy = {
v: 1, kind: 'aere-agent-policy', agentId: p.agentId,
spend,
tools: Array.isArray(p.tools) ? [...p.tools].sort() : null,
recipients: Array.isArray(p.recipients) ? [...p.recipients].map((x) => x.toLowerCase()).sort() : null,
};
if (p.approval) {
const ap = p.approval, aprobatori = Array.isArray(ap.approvers) ? [...new Set(ap.approvers)].sort() : [];
if (!aprobatori.length || !aprobatori.every((x) => HUMAN_ID.test(x))) throw new Error('agent-policy: approval.approvers must be distinct aere-human: ids');
const k = Number(ap.threshold);
if (!Number.isInteger(k) || k < 1 || k > aprobatori.length) throw new Error(`agent-policy: approval.threshold must be between 1 and ${aprobatori.length}`);
if (ap.above == null && !(Array.isArray(ap.tools) && ap.tools.length)) throw new Error('agent-policy: approval needs `above` (payments) or `tools`, otherwise it asks for nothing');
if (ap.above != null && !INTREG.test(String(ap.above))) throw new Error('agent-policy: approval.above must be a decimal integer');
policy.approval = { approvers: aprobatori, threshold: k, above: ap.above == null ? null : String(ap.above), tools: Array.isArray(ap.tools) ? [...ap.tools].sort() : null };
}
if (p.owner != null) {
if (!HUMAN_ID.test(String(p.owner))) throw new Error('agent-policy: owner must be an aere-human: id');
policy.owner = String(p.owner);
}
// 2026-09-29 (punctul 23/25): portofelul din care agentul are voie sa plateasca (o adresa EVM). Cand e numit, o plata trebuie sa
// spuna `from` si sa fie chiar el; x402/wallet.mjs refuza sa semneze pentru o politica ce numeste alt portofel.
if (p.wallet != null) {
if (!/^0x[0-9a-fA-F]{40}$/.test(String(p.wallet))) throw new Error('agent-policy: wallet must be an EVM address (0x + 40 hex)');
policy.wallet = String(p.wallet).toLowerCase();
}
return policy;
}
const hashFormei = (policy) => sha256(Buffer.from(JSON.stringify(policy), 'utf8'));
/**
* Defineste o politica de agent. Intoarce {policy, policyHash} - hash-ul e amprenta politicii in forma ei normala (ancora deciziilor).
* @param {object} p
* @param {string} p.agentId
* @param {{amount:string, windowSeconds:number, asset?:string}} [p.spend] limita de cheltuiala pe fereastra
* @param {string[]} [p.tools] uneltele permise (lista alba); lipsa = niciuna permisa
* @param {string[]} [p.recipients] destinatarii permisi pentru plati (lista alba); lipsa = oricine
* @param {{approvers:string[], threshold:number, above?:string, tools?:string[]}} [p.approval] aprobarea umana (2026-09-28):
* o plata cu suma STRICT peste `above`, sau o unealta din `tools`, cere `threshold` aprobari de la aprobatori distincti
* din `approvers` (id-uri 'aere-human:' derivate din cheile lor ML-DSA-65; vezi agent-aprobare.mjs)
* @param {string} [p.owner] proprietarul ('aere-human:'), singurul care poate revoca agentul
* @param {string} [p.wallet] (2026-09-29) adresa EVM din care agentul are voie sa plateasca; o plata trebuie sa o numeasca in `from`
*/
export function definePolicy(p) {
const policy = formaNormala(p);
return { policy, policyHash: hashFormei(policy) };
}
/**
* Hash-ul unei politici PRIMITE (de la agent, dintr-un fisier), recalculat din forma ei normala. Refuza o politica cu campuri pe
* care motorul nu le stie (un camp necunoscut ar putea fi ceva ce cititorul crede ca se aplica si motorul ignora).
* @returns {{policy:object, policyHash:string}} politica normala, cea care se judeca, si hash-ul ei
*/
export function hashPolicy(policy) {
if (!policy || policy.kind !== 'aere-agent-policy' || policy.v !== 1) throw new Error('agent-policy: not an aere-agent-policy v1');
const necunoscute = Object.keys(policy).filter((k) => !CHEI_POLITICA.has(k));
if (necunoscute.length) throw new Error(`agent-policy: unknown policy field(s): ${necunoscute.join(', ')}`);
return definePolicy(policy);
}
/** Cere actiunea aprobare umana dupa politica? (o plata strict peste `above`, sau o unealta numita) */
export function needsApproval(policy, action) {
const ap = policy && policy.approval;
if (!ap) return false;
if (action.kind === 'payment' && ap.above != null && INTREG.test(String(action.amount))) return BigInt(action.amount) > BigInt(ap.above);
if (action.kind === 'tool' && ap.tools) return ap.tools.includes(action.tool);
return false;
}
/**
* Verifica o actiune fata de politica + istoricul de cheltuiala din fereastra. Pur: nu tine stare; primeste cheltuiala deja facuta.
* @param {object} policy din definePolicy / hashPolicy
* @param {object} action { kind:'tool'|'payment', tool?, args?, to?, amount?, asset?, at:epochSeconds }
* @param {Array} spentInWindow [{amount, at}] platile deja facute (checkAction le filtreaza singur la fereastra)
* @param {{approvers?:string[], revokedAt?:number|null}} [ctx] (2026-09-28) id-urile aprobatorilor ale caror aprobari au fost DEJA
* verificate criptografic de apelant (registrul, verificatorul) si momentul revocarii valide, daca exista. checkAction ramane
* pur: nu verifica semnaturi, numara ce i se da; cine ii da aprobatori neverificati isi minte singur politica.
* @returns {{allowed:boolean, reason:string, remaining?:string, approvedBy?:number}}
*/
export function checkAction(policy, action, spentInWindow = [], ctx = {}) {
if (!policy || policy.kind !== 'aere-agent-policy') throw new Error('agent-policy: invalid policy');
// revocarea bate orice: de la momentul ei nicio actiune nu mai e permisa
if (ctx.revokedAt != null && Number(action.at) >= Number(ctx.revokedAt)) return { allowed: false, reason: `agent revoked by its owner at ${ctx.revokedAt}` };
const d = faraAprobare(policy, action, spentInWindow);
if (!d.allowed || !needsApproval(policy, action)) return d;
// aprobarea e o cerinta IN PLUS, nu o scutire: actiunea trebuie sa respecte si limitele
const ap = policy.approval;
const valizi = new Set((ctx.approvers || []).filter((x) => ap.approvers.includes(x)));
if (valizi.size < ap.threshold) return { allowed: false, reason: `human approval required: ${valizi.size} of ${ap.threshold} valid approvals` };
return { ...d, reason: `${d.reason}; approved by ${valizi.size} of ${ap.threshold}`, approvedBy: valizi.size };
}
function faraAprobare(policy, action, spentInWindow) {
if (action.kind === 'tool') {
if (!policy.tools || !policy.tools.includes(action.tool)) return { allowed: false, reason: `tool "${action.tool}" is not in the allowed list` };
return { allowed: true, reason: 'tool allowed' };
}
if (action.kind === 'payment') {
// 2026-09-27 (revizuire adversariala): suma nu era validata, deci o plata NEGATIVA scadea cheltuiala din fereastra si o plata de
// 1.000.000 trecea sub o limita de 100 (masurat: allowed, si registrul verificat "ok"). Se cere un intreg zecimal STRICT pozitiv,
// si un moment finit; altfel refuz, cu motivul numit.
if (!INTREG.test(String(action.amount)) || BigInt(action.amount) === 0n) return { allowed: false, reason: `invalid amount (${String(action.amount).slice(0, 24)}): a strictly positive decimal integer is required` };
if (!Number.isFinite(Number(action.at))) return { allowed: false, reason: 'invalid time: action.at is not a finite number' };
if (policy.wallet && String(action.from || '').toLowerCase() !== policy.wallet) return { allowed: false, reason: `payment from ${String(action.from || 'an unnamed wallet').slice(0, 42)} is not from the wallet the policy names` };
if (policy.recipients && !policy.recipients.includes(String(action.to).toLowerCase())) return { allowed: false, reason: `recipient ${action.to} is not allowed` };
if (policy.spend) {
// 2026-09-29: o plata in alt activ decat al limitei nu se aduna la ea si nu trece pe langa ea: e refuzata
if (action.asset != null && String(action.asset) !== policy.spend.asset) return { allowed: false, reason: `asset ${String(action.asset).slice(0, 24)} is not the policy's asset (${policy.spend.asset})` };
const fereastra = policy.spend.windowSeconds;
const acum = Number(action.at);
const cheltuit = spentInWindow.filter((s) => acum - Number(s.at) < fereastra).reduce((a, s) => a + BigInt(s.amount), 0n);
const limita = BigInt(policy.spend.amount);
const nou = BigInt(action.amount);
if (cheltuit + nou > limita) return { allowed: false, reason: `over the limit: ${cheltuit + nou} > ${limita} per ${fereastra}s`, remaining: String(limita - cheltuit > 0n ? limita - cheltuit : 0n) };
return { allowed: true, reason: 'under the limit', remaining: String(limita - cheltuit - nou) };
}
return { allowed: true, reason: 'no spending limit in the policy' };
}
return { allowed: false, reason: `unknown action kind: ${String(action.kind).slice(0, 40)}` };
}
/**
* Inregistreaza o decizie ca plic AIP-23 (fel aere-proof-of-agent-decision). Leaga policyHash (nefalsificabil) + digestul actiunii,
* calculat pe forma CANONICA a actiunii (chei sortate), ca oricine sa il reproduca din actiune.
*/
export function decisionEnvelope({ policyHash, action, decision, createdAt }) {
if (!policyHash || !action || !decision || !createdAt) throw new Error('agent-policy: decisionEnvelope needs policyHash, action, decision, createdAt');
const statement = {
v: 1, kind: 'aere-proof-of-agent-decision',
policyHash: policyHash.toLowerCase(),
actionHash: sha256(Buffer.from(canonical(action), 'utf8')),
allowed: !!decision.allowed,
reason: decision.reason,
createdAt,
};
return { v: 1, kind: 'aere-proof-of-agent-decision-attestation', statement, statementHash: sha256(Buffer.from(JSON.stringify(statement), 'utf8')) };
}