An agent gets an ML-DSA-65 identity and a policy (spending per time window, allowed tools and recipients, which actions need human approval, who may revoke it). Every action it proposes is judged against the policy, signed by the agent and chained; a verifier that does not trust the agent re-runs the policy over the whole ledger. Approvals and revocations are signed by people with their own ML-DSA-65 keys. The ledger of an agent under a policy is one ledger: a second history is a branch, and two branches are a proof of equivocation anyone can check with the public key alone. The README says what the verifier cannot see: entry times are bounded from below only with a witness (anchors or a start time), and someone who sees one branch cannot know of another. Tests: policy 23/23 with the AIP-23 reference verifier (21 run without it), ledger 51/51, approval and revocation 39/39, command line 23/23; negative control 25/25.
169 lines
12 KiB
JavaScript
169 lines
12 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']);
|
|
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);
|
|
}
|
|
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
|
|
*/
|
|
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.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')) };
|
|
}
|