diff --git a/README.md b/README.md index 2ca4d93..515d28c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ command of the verification layer, which needs `ethers`. | [`proof-kinds/`](proof-kinds/) | the AIP-23 envelope builder the verification layer uses: fourteen proof kinds, one envelope format, digests instead of raw content | | [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass | | [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again | +| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check | Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them. @@ -33,12 +34,13 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | proof-kinds | 24/24 with the same verifier (`AERE_VERIFY_PROOF=... node proba-proof-kinds.mjs`) | six negative controls inside the test | | readiness | 6/6 (`node proba-adrese-private.mjs`: the private-address rules, and a local listener no scan may touch) | the rate limit and the queue bound are tested where the service runs, not here (its README says so) | | control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8 | remediation 7/7, compliance report 3/3 in this repository | +| agents | policy 23/23 with the AIP-23 verifier (without it 21 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here) | 25/25 (`node control-negativ-aprobare.mjs`) | Code comments, most function and variable names (also many exported between the files of a component), test names and control messages are in Romanian, and so are the two command words of the KMS HSM tool (explained in its README). Error codes, error -messages, the HTTP APIs, the inventory's module interface (`scan`, `buildCbom`, `renderSummary`, ...) and the documentation are -in English. +messages, the HTTP APIs, the inventory's module interface (`scan`, `buildCbom`, `renderSummary`, ...), the agents' module +interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ...) and the documentation are in English. ## Licence -MIT, see [LICENSE](LICENSE). Files: 98 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 17, readiness 4). +MIT, see [LICENSE](LICENSE). Files: 108 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 8, proof-kinds 3, control-plane 17, agents 10, readiness 4). diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000..fe85522 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,82 @@ +# Agents: policy, post-quantum identity, a signed action ledger, human approval and revocation + +An AI agent that can pay or call tools needs limits that someone other than the agent can check. This component gives an agent a +post-quantum identity (an ML-DSA-65 key), a policy (a spending limit per time window, allowed tools, allowed recipients, which +actions need human approval, who may revoke the agent), and a ledger in which every action the agent 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 and says +whether any allowed action broke it. Humans approve and revoke with their own ML-DSA-65 keys. + +Node.js 24 only (`node:crypto` with ML-DSA), no dependencies, no network. It moves no money: `amount` is the unit the policy names; +moving value is the job of a payment layer, which can ask for this ledger as evidence that an action was allowed. + +## Quick start (command line) + +``` +node agent-cli.mjs id --out agent/ # prints aere-agent:<40 hex>; writes agent.key.pem and agent.pub.pem +node agent-cli.mjs human --out alice/ # an approver or an owner: aere-human:<40 hex> +node agent-cli.mjs policy --spec spec.json --out policy.json +node agent-cli.mjs record --key agent/agent.key.pem --policy policy.json --ledger ledger.json --action pay.json +node agent-cli.mjs approve --key alice/human.key.pem --policy policy.json --action pay.json --out approval.json +node agent-cli.mjs record ... --approvals approval.json,approval2.json +node agent-cli.mjs verify --ledger ledger.json --policy policy.json [--revocations r.json] [--anchors a.json] [--not-before ] +node agent-cli.mjs revoke --key owner/human.key.pem --policy policy.json --out revocation.json +node agent-cli.mjs equivocation --a ledger-a.json --b ledger-b.json --out proof.json +node agent-cli.mjs verify-equivocation --proof proof.json +``` + +A spec is `{ agentId, spend: { amount, windowSeconds, asset? }, tools: [...], recipients: [...], approval: { approvers: [humanId...], +threshold, above?, tools? }, owner: humanId }`; every field but `agentId` is optional. An action is `{ kind: "payment", to, amount, +asset? }` or `{ kind: "tool", tool, args? }`; the ledger sets its time. Exit codes: 0 yes (allowed, valid, found), 1 no (invalid, +not found, refused by a check), 3 an action refused by the policy, 2 wrong usage. Private keys stay in their files and are never +printed; the tool creates them with mode 0600, which has no effect on Windows (protect the folder there). + +## What each part does + +- **Policy** (`agent-policy.mjs`): `definePolicy` returns the policy in a normal form and its hash. `hashPolicy` recomputes the hash + of a policy received from anyone and refuses unknown fields, so a relying party that pinned a hash cannot be handed a looser + policy under it. `checkAction` is pure: a payment must be a strictly positive decimal integer, to an allowed recipient, in the + policy's asset, and within the limit over the window; a tool must be on the list. Approval is a requirement in addition to the + limits, never an exemption. `decisionEnvelope` writes a decision as an AIP-23 envelope (`aere-proof-of-agent-decision`) whose + `actionHash` is the SHA-256 of the action's canonical JSON (keys sorted), so anyone can recompute it. +- **Identity and ledger** (`agent-ledger.mjs`): `agentId` is derived from the public key (nobody can claim another agent's id). Each + entry carries the action, the decision, the approvals it used and optionally the hash of its provenance (for example a Proof of + AI envelope); it is signed by the agent and chained by hash. The spending in the window is derived from the ledger's own allowed + entries, so the limit holds over the whole ledger, across restarts (`resumeLedger` verifies the saved ledger, then continues it). +- **Verifier** (`verifyLedger`): recomputes the policy hash, the identity, every signature and the chain, and re-runs the policy + from the first entry: an "allowed" written over a refusal is caught even when the agent re-signs its whole ledger. Revocations are + given to it separately, by the owner, because an agent can leave its own revocation out of its ledger; the result says which set + of revocations it judged against (`revocations.setHash`). +- **Approval and revocation** (`agent-aprobare.mjs`): an approval signs exactly the action (kind, recipient, amount, asset, tool, and + the hash of the tool arguments, so an approval to deploy to staging does not approve production), the agent and the policy; it has + a validity window of at most seven days and a nonce that counts once per ledger. Only approvers named in the policy count, each + once. A revocation is signed by the owner named in the policy; from its time on every action is refused. + +## What the verifier can and cannot see + +- **Time.** An entry's time is the statement of whoever holds the agent key. The verifier bounds it from above with its clock. It + bounds it from below only with a witness: `anchors` (ledger heads seen by a witness at a known time, for example notarized) or + `notBefore`. Without one, the key holder can backdate entries into past windows and spend the limit several times; the result + says so (`time.lowerBound: "none..."`). With anchors, backdating is limited to the interval between two anchors. The library + itself refuses to write an entry more than 300 seconds away from its clock. +- **One ledger per agent and policy.** The ledger's session is derived from the agent and the policy hash, so a "second ledger" is + a branch of the same one. Two branches signed by the agent are a proof of equivocation (`findEquivocation`, `verifyEquivocation`) + that anyone can check with the public key alone: this is how a double spend or an approval used twice across branches is shown. + Someone who sees only one branch cannot know that another exists; anchors or a second copy reveal it. An agent that loses its + ledger cannot continue it without looking like a branch: keep the ledger durable, or start under a new policy. +- **Revocation time** is the owner's statement, as the agent's entry times are the agent's. +- Nothing here proves that the actions happened in the world, only what the agent recorded and whether the policy allowed it. + +## Tests + +Measured on 2026-09-29 (Node.js 24.14.1, Windows): + +| test | result | +|---|---| +| `node proba-agent-policy.mjs` | 23/23 with the AIP-23 reference verifier (`AERE_VERIFY_PROOF=`); without it 21 run, 2 are reported as skipped and the exit code is 2 | +| `node proba-agent-ledger.mjs` | 51/51: limits over the ledger, identity, tampering, a re-signed false decision, a looser policy under the real hash, backdating (refused when written; caught with anchors or `notBefore`), a branch, equivocation proofs, resuming | +| `node proba-agent-aprobare.mjs` | 39/39: threshold, unnamed or repeated approvers, classical keys, another action or other tool arguments, reused nonces (also after a restart), expiry, approvals across branches, revocation, an omitted revocation | +| `node proba-agent-cli.mjs` | 23/23 on Windows, through files and processes only; on Linux and macOS one more test checks the 0600 mode of the key (not measured here) | +| `node control-negativ-aprobare.mjs` | 25/25: each guard is removed in a copy, one at a time, and the named test must fail for that reason; a test that stops before its summary counts as a failure of the control | + +The data format is version 2 (2026-09-29). Code comments, test names and most internal names are in Romanian; messages, data and +the exported interface are in English. No third party has reviewed this component. diff --git a/agents/agent-aprobare.mjs b/agents/agent-aprobare.mjs new file mode 100644 index 0000000..58e564b --- /dev/null +++ b/agents/agent-aprobare.mjs @@ -0,0 +1,127 @@ +// AERE Agent Approval (roadmap punctul 22, "aprobarea umana, revocarea"): doua lucruri pe care un agent AI NU le poate face singur, +// semnate de oameni cu chei post-cuantice (ML-DSA-65) si verificabile de oricine. +// +// APROBAREA: politica agentului numeste aprobatorii (id-uri 'aere-human:' derivate din cheie, legate prin hash-ul politicii) si +// pragul k; o actiune care cere aprobare (o plata peste un prag, o unealta numita) trece numai cu k aprobari VALIDE de la aprobatori +// distincti. O aprobare semneaza EXACT continutul actiunii (fel, destinatar, suma, activ, unealta, argumentele uneltei prin hash; +// nu momentul, pe care il pune registrul), agentul si politica, are o fereastra de valabilitate [issuedAt, expiresAt] si un nonce +// care se poate folosi O SINGURA DATA in registru. Deci o aprobare pentru 50 catre X nu aproba 500 catre Y, un deploy pe staging nu +// aproba unul pe production, nu se reutilizeaza si nu se muta la alt agent. Registrul unui agent sub o politica e UNUL singur +// (sesiunea lui e derivata din agent si politica, agent-ledger.mjs), deci o aprobare folosita de doua ori in "doua registre" e +// folosita in doua RAMURI ale aceluiasi registru, si doua ramuri semnate de agent sunt o dovada de echivocare (findEquivocation). +// +// REVOCAREA: proprietarul numit in politica semneaza "agentul e revocat de la momentul T"; de atunci orice actiune e refuzata. Un +// agent isi tine propriul registru, deci ar putea omite revocarea din el: verificatorul primeste revocarile SEPARAT (de la proprietar) +// si spune fata de ce set a judecat. Fara revocari date, verificatorul nu poate vedea o revocare omisa, si o spune. +// +// 2026-09-29 (forma 2, revizuirea adversariala B-17): datele si mesajele in engleza; aprobarea leaga hash-ul argumentelor uneltei +// (masurat pe forma 1: aprobarea unui deploy pe staging a trecut pe production). Tot pe forma 1, o singura aprobare pentru 5000 a trecut +// in DOUA registre ale aceluiasi agent, fiindca nonce-ul era unic numai pe registru: de acum cele doua registre sunt doua ramuri ale +// aceluiasi registru, iar ramificarea e dovedibila cu doua intrari semnate. Ce ramane, spus: cine vede O SINGURA ramura nu o poate +// deosebi de registru; o deosebeste numai un martor al capului (`anchors` in verifyLedger) sau cine vede ambele ramuri. +// +// Numai Node 24 (crypto ML-DSA). Nu atinge reteaua. Cheile private nu ies din obiectele lor. +import crypto from 'node:crypto'; +import { canonical } from './agent-policy.mjs'; + +export const VERSION = 'aere-agent-approval/2 (2026-09-29)'; +const ALG = 'ml-dsa-65'; +const FEREASTRA_MAXIMA_S = 7 * 86400; // o aprobare nu poate fi valabila mai mult de o saptamana + +const sha = (s) => crypto.createHash('sha256').update(typeof s === 'string' ? Buffer.from(s, 'utf8') : s).digest('hex'); + +/** id-ul unui om derivat din cheia lui publica (acelasi fel ca agentId, alt prefix: un om nu poate fi confundat cu un agent). */ +export function humanIdFromKey(publicKey) { return 'aere-human:' + sha(publicKey.export({ type: 'spki', format: 'der' })).slice(0, 40); } +export function newHumanIdentity() { + const { publicKey, privateKey } = crypto.generateKeyPairSync(ALG); + return { humanId: humanIdFromKey(publicKey), publicKey, privateKey, publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }) }; +} +/** Identitatea unui om din cheia lui privata (PEM PKCS#8); cheia privata nu iese din obiect. */ +export function humanFromPrivateKeyPem(pem) { + const privateKey = crypto.createPrivateKey(pem); + if (privateKey.asymmetricKeyType !== ALG) throw new Error('agent-approval: the key is not ML-DSA-65'); + const publicKey = crypto.createPublicKey(privateKey); + return { privateKey, publicKey, publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }), humanId: humanIdFromKey(publicKey) }; +} + +/** Continutul actiunii care se aproba: fara momentul ei (il pune registrul la inregistrare); argumentele uneltei prin hash. */ +export function actionContent(a) { + const c = { kind: String(a.kind) }; + if (a.to != null) c.to = String(a.to).toLowerCase(); + if (a.amount != null) c.amount = String(a.amount); + if (a.asset != null) c.asset = String(a.asset); + if (a.tool != null) c.tool = String(a.tool); + if (a.args !== undefined) c.argsHash = sha(canonical(a.args)); + return c; +} +export const actionHash = (a) => sha(canonical(actionContent(a))); + +function semneaza(corp, privateKey) { return crypto.sign(null, Buffer.from(canonical(corp), 'utf8'), privateKey).toString('base64'); } +function cheiaSemnatarului(pem) { + let k; try { k = crypto.createPublicKey(pem); } catch { return null; } + return k.asymmetricKeyType === ALG ? k : null; // numai ML-DSA-65: o cheie clasica nu aproba nimic +} + +/** O aprobare umana pentru o actiune a unui agent. */ +export function approve({ human, agentId, policyHash, action, nonce = crypto.randomBytes(16).toString('hex'), issuedAt, expiresAt }) { + if (!human || !human.privateKey) throw new Error('agent-approval: the approver with their key is required'); + const corp = { v: 2, kind: 'aere-agent-approval', agentId, policyHash: String(policyHash).toLowerCase(), actionHash: actionHash(action), nonce: String(nonce), issuedAt: Number(issuedAt), expiresAt: Number(expiresAt), approverPem: human.publicKeyPem }; + return { ...corp, signature: semneaza(corp, human.privateKey) }; +} + +/** + * Verifica o aprobare pentru o actiune judecata la momentul `at`. NU se uita la nonce (unicitatea o tine registrul, pe tot lantul lui). + * @returns {{ok:boolean, humanId?:string, error?:string}} + */ +export function verifyApproval(ap, { policy, policyHash, action, at }) { + if (!ap || ap.kind !== 'aere-agent-approval' || ap.v !== 2) return { ok: false, error: 'not an aere-agent-approval v2' }; + if (!policy || !policy.approval) return { ok: false, error: 'the policy asks for no approvals' }; + if (ap.agentId !== policy.agentId) return { ok: false, error: 'the approval is for another agent' }; + if (String(ap.policyHash).toLowerCase() !== String(policyHash).toLowerCase()) return { ok: false, error: 'the approval is under another policy' }; + if (ap.actionHash !== actionHash(action)) return { ok: false, error: 'the approval is for another action (kind, recipient, amount, asset, tool or tool arguments)' }; + const e = Number(ap.issuedAt), x = Number(ap.expiresAt), t = Number(at); + if (!Number.isFinite(e) || !Number.isFinite(x) || !(x > e) || x - e > FEREASTRA_MAXIMA_S) return { ok: false, error: 'the approval window is invalid or longer than seven days' }; + if (!(t >= e && t <= x)) return { ok: false, error: `the action (${t}) is outside the approval window [${e}, ${x}]` }; + if (!ap.nonce || typeof ap.nonce !== 'string') return { ok: false, error: 'the approval has no nonce' }; + const k = cheiaSemnatarului(ap.approverPem); + if (!k) return { ok: false, error: 'the approver key is not ML-DSA-65' }; + const humanId = humanIdFromKey(k); + if (!policy.approval.approvers.includes(humanId)) return { ok: false, error: `approver ${humanId} is not named in the policy` }; + const { signature, ...corp } = ap; + let sig = false; try { sig = crypto.verify(null, Buffer.from(canonical(corp), 'utf8'), k, Buffer.from(String(signature), 'base64')); } catch { sig = false; } + if (!sig) return { ok: false, error: 'the approval signature does not verify' }; + return { ok: true, humanId }; +} + +/** Revocarea unui agent, semnata de proprietarul numit in politica. */ +export function revoke({ owner, agentId, policyHash, revokedAt, reason = '' }) { + if (!owner || !owner.privateKey) throw new Error('agent-approval: the owner with their key is required'); + const corp = { v: 2, kind: 'aere-agent-revocation', agentId, policyHash: String(policyHash).toLowerCase(), revokedAt: Number(revokedAt), + reason: String(reason).slice(0, 200), ownerPem: owner.publicKeyPem }; + return { ...corp, signature: semneaza(corp, owner.privateKey) }; +} +/** @returns {{ok:boolean, revokedAt?:number, error?:string}} */ +export function verifyRevocation(rv, { policy, policyHash }) { + if (!rv || rv.kind !== 'aere-agent-revocation' || rv.v !== 2) return { ok: false, error: 'not an aere-agent-revocation v2' }; + if (!policy || !policy.owner) return { ok: false, error: 'the policy names no owner' }; + if (rv.agentId !== policy.agentId) return { ok: false, error: 'the revocation is for another agent' }; + if (String(rv.policyHash).toLowerCase() !== String(policyHash).toLowerCase()) return { ok: false, error: 'the revocation is under another policy' }; + if (!Number.isFinite(Number(rv.revokedAt))) return { ok: false, error: 'the revocation time is not a number' }; + const k = cheiaSemnatarului(rv.ownerPem); + if (!k) return { ok: false, error: 'the owner key is not ML-DSA-65' }; + if (humanIdFromKey(k) !== policy.owner) return { ok: false, error: 'the revocation is not signed by the owner named in the policy' }; + const { signature, ...corp } = rv; + let sig = false; try { sig = crypto.verify(null, Buffer.from(canonical(corp), 'utf8'), k, Buffer.from(String(signature), 'base64')); } catch { sig = false; } + if (!sig) return { ok: false, error: 'the revocation signature does not verify' }; + return { ok: true, revokedAt: Number(rv.revokedAt) }; +} +/** Cea mai timpurie revocare valida dintr-o lista, plus cele respinse (cu motivul), plus amprenta setului judecat. */ +export function validRevocations(list, ctx) { + let revokedAt = null; const rejected = []; + for (const rv of list || []) { + const r = verifyRevocation(rv, ctx); + if (r.ok) revokedAt = revokedAt == null ? r.revokedAt : Math.min(revokedAt, r.revokedAt); + else rejected.push(r.error); + } + return { revokedAt, rejected, setHash: sha(canonical((list || []).map((x) => x && x.signature).sort())) }; +} diff --git a/agents/agent-cli.mjs b/agents/agent-cli.mjs new file mode 100644 index 0000000..c82b995 --- /dev/null +++ b/agents/agent-cli.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// agent-cli.mjs: linia de comanda a agentilor AI (politica, registrul, aprobarea, revocarea, verificarea, dovada de echivocare), ca +// un om sa poata aproba sau revoca si un strain sa poata verifica fara sa scrie cod. Mesajele si iesirile sunt in engleza; cheile +// private stau in fisierele lor (0600) si nu se tiparesc niciodata. Numai Node 24. +// +// node agent-cli.mjs id --out new agent identity; prints the agentId +// node agent-cli.mjs human --out new human identity (approver, owner); prints the humanId +// node agent-cli.mjs policy --spec spec.json [--out p.json] a policy in normal form, with its hash +// node agent-cli.mjs check --policy p.json --action a.json [--spent s.json] +// node agent-cli.mjs record --key agent.key.pem --policy p.json --ledger l.json --action a.json [--approvals a1.json,a2.json] [--provenance f.json] +// node agent-cli.mjs approve --key human.key.pem --policy p.json --action a.json [--valid 600] [--out ap.json] +// node agent-cli.mjs revoke --key owner.key.pem --policy p.json [--at ] [--reason text] [--out rv.json] +// node agent-cli.mjs verify --ledger l.json --policy p.json [--policy-hash 0x..] [--revocations r1.json,r2.json] [--anchors a.json] [--not-before ] +// node agent-cli.mjs equivocation --a l1.json --b l2.json [--out proof.json] +// node agent-cli.mjs verify-equivocation --proof proof.json +// Iesiri: 0 da (permis, valid, gasit), 1 nu (invalid, negasit, refuzat de verificare), 3 actiune refuzata de politica, 2 folosire gresita. +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { definePolicy, hashPolicy, checkAction } from './agent-policy.mjs'; +import { newAgentIdentity, agentFromPrivateKeyPem, openLedger, resumeLedger, verifyLedger, findEquivocation, verifyEquivocation } from './agent-ledger.mjs'; +import { newHumanIdentity, humanFromPrivateKeyPem, approve, revoke, verifyRevocation } from './agent-aprobare.mjs'; + +class Folosire extends Error {} +const citesteJson = (f, ce) => { + if (!f) throw new Folosire(`--${ce} is required`); + try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${ce} file ${f}: ${e.message}`); } +}; +// fisierul unei politici: {policy, policyHash} (cum il scrie `policy`) sau politica singura; hash-ul se recalculeaza mereu +function politica(f) { + const j = citesteJson(f, 'policy'); + const { policy, policyHash } = hashPolicy(j.policy || j); + if (j.policyHash && String(j.policyHash).toLowerCase() !== policyHash) throw new Error(`the policy file names policyHash ${j.policyHash}, but the policy hashes to ${policyHash}`); + return { policy, policyHash }; +} +function scrieAtomic(f, obiect, mod) { + const tmp = `${f}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(obiect, null, 1) + '\n', mod ? { mode: mod } : undefined); + fs.renameSync(tmp, f); +} +const iese = (obiect, out) => { if (out) scrieAtomic(out, obiect); else console.log(JSON.stringify(obiect, null, 1)); }; +const acum = () => Math.floor(Date.now() / 1000); + +export function main(argv) { + const [cmd, ...rest] = argv; + const get = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; }; + const lista = (f) => (get(f) ? String(get(f)).split(',').filter(Boolean) : []); + switch (cmd) { + case 'id': + case 'human': { + const out = get('--out'); if (!out) throw new Folosire(`${cmd} --out `); + const x = cmd === 'id' ? newAgentIdentity() : newHumanIdentity(); + const nume = cmd === 'id' ? 'agent' : 'human'; + fs.mkdirSync(out, { recursive: true }); + const cheie = path.join(out, `${nume}.key.pem`); + if (fs.existsSync(cheie)) throw new Folosire(`${cheie} exists; refusing to overwrite a key`); + fs.writeFileSync(cheie, x.privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }); + fs.writeFileSync(path.join(out, `${nume}.pub.pem`), x.publicKeyPem, { mode: 0o644 }); + console.log(cmd === 'id' ? x.agentId : x.humanId); + return 0; + } + case 'policy': { + const r = definePolicy(citesteJson(get('--spec'), 'spec')); + iese(r, get('--out')); if (get('--out')) console.log(r.policyHash); + return 0; + } + case 'check': { + const { policy } = politica(get('--policy')); + const action = citesteJson(get('--action'), 'action'); + const spent = get('--spent') ? citesteJson(get('--spent'), 'spent') : []; + const d = checkAction(policy, { ...action, at: action.at ?? acum() }, spent); + console.log(JSON.stringify(d)); return d.allowed ? 0 : 3; + } + case 'record': { + const kf = get('--key'); if (!kf) throw new Folosire('--key is required'); + const identity = agentFromPrivateKeyPem(fs.readFileSync(kf, 'utf8')); + const { policy, policyHash } = politica(get('--policy')); + const lf = get('--ledger'); if (!lf) throw new Folosire('--ledger is required'); + const L = fs.existsSync(lf) ? resumeLedger({ identity, policy, policyHash, ledger: citesteJson(lf, 'ledger') }) : openLedger({ identity, policy, policyHash }); + const action = citesteJson(get('--action'), 'action'); // momentul il pune registrul (record suprascrie `at`) + const approvals = lista('--approvals').map((f) => citesteJson(f, 'approvals')); + const provenance = get('--provenance') ? citesteJson(get('--provenance'), 'provenance') : undefined; + const r = L.record(action, { approvals, provenance }); + scrieAtomic(lf, L.export()); + console.log(JSON.stringify({ allowed: r.allowed, reason: r.reason, seq: r.entry.seq, hash: r.entry.hash, rejectedApprovals: r.rejectedApprovals })); + return r.allowed ? 0 : 3; + } + case 'approve': { + const kf = get('--key'); if (!kf) throw new Folosire('--key is required'); + const human = humanFromPrivateKeyPem(fs.readFileSync(kf, 'utf8')); + const { policy, policyHash } = politica(get('--policy')); + if (!policy.approval || !policy.approval.approvers.includes(human.humanId)) throw new Error(`${human.humanId} is not an approver named in the policy`); + const action = citesteJson(get('--action'), 'action'); + const valid = Number(get('--valid') ?? 600); + if (!Number.isInteger(valid) || valid < 1 || valid > 7 * 86400) throw new Folosire('--valid must be 1..604800 seconds'); + const t = acum(); + iese(approve({ human, agentId: policy.agentId, policyHash, action, issuedAt: t, expiresAt: t + valid }), get('--out')); + return 0; + } + case 'revoke': { + const kf = get('--key'); if (!kf) throw new Folosire('--key is required'); + const owner = humanFromPrivateKeyPem(fs.readFileSync(kf, 'utf8')); + const { policy, policyHash } = politica(get('--policy')); + const rv = revoke({ owner, agentId: policy.agentId, policyHash, revokedAt: Number(get('--at') ?? acum()), reason: get('--reason') ?? '' }); + const r = verifyRevocation(rv, { policy, policyHash }); + if (!r.ok) { console.error(`agent: the revocation would not be valid: ${r.error}`); return 1; } + iese(rv, get('--out')); return 0; + } + case 'verify': { + const ledger = citesteJson(get('--ledger'), 'ledger'); + const j = citesteJson(get('--policy'), 'policy'); + const pinned = get('--policy-hash') ?? j.policyHash; + const v = verifyLedger(ledger, { policy: j.policy || j, policyHash: pinned, revocations: lista('--revocations').map((f) => citesteJson(f, 'revocations')), + anchors: get('--anchors') ? citesteJson(get('--anchors'), 'anchors') : [], notBefore: get('--not-before') != null ? Number(get('--not-before')) : null }); + console.log(JSON.stringify(v, null, 1)); return v.ok ? 0 : 1; + } + case 'equivocation': { + const p = findEquivocation(citesteJson(get('--a'), 'a'), citesteJson(get('--b'), 'b')); + if (!p) { console.log(JSON.stringify({ found: false })); return 1; } + iese(p, get('--out')); if (get('--out')) console.log(JSON.stringify({ found: true, seq: p.seq })); return 0; + } + case 'verify-equivocation': { + const r = verifyEquivocation(citesteJson(get('--proof'), 'proof')); + console.log(JSON.stringify(r)); return r.ok ? 0 : 1; + } + default: + console.error('usage: agent-cli.mjs id|human|policy|check|record|approve|revoke|verify|equivocation|verify-equivocation ... (see README.md)'); + return cmd ? 2 : 0; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + let cod; + try { cod = main(process.argv.slice(2)); } + catch (e) { console.error(`agent: ${e.message}`); cod = e instanceof Folosire ? 2 : 1; } + process.exitCode = cod; +} diff --git a/agents/agent-ledger.mjs b/agents/agent-ledger.mjs new file mode 100644 index 0000000..f887bcc --- /dev/null +++ b/agents/agent-ledger.mjs @@ -0,0 +1,335 @@ +// AERE Agent Ledger (roadmap B2 / #35-37, seam-ul care lipsea): identitatea post-cuantica a unui agent AI + registrul lui de actiuni, +// impus CRIPTOGRAFIC si verificabil de oricine, fara incredere in agent. +// +// agent-policy.mjs are checkAction PUR (nu tine stare, primeste cheltuiala din fereastra). O limita de cheltuiala "pe fereastra" nu +// inseamna nimic fara CINEVA care aduna cheltuiala reala si o impune la fiecare actiune - altfel agentul poate spune de fiecare data +// "n-am cheltuit nimic". Aici e acel cineva: un registru per identitate care +// 1. deriva cheltuiala din fereastra din PROPRIILE lui intrari permise (starea, nu vorba agentului), +// 2. cheama checkAction cu ea, deci limita e impusa PE TOATA SESIUNEA, +// 3. semneaza fiecare intrare cu cheia ML-DSA-65 a agentului si o leaga intr-un lant sha256(seq|prev|corp|semnatura), +// 4. leaga, optional, provenienta iesirii (model/versiune/prompt/unealta, digestul unui plic proof-of-ai). +// Identitatea agentului e LEGATA de cheie: agentId = 'aere-agent:' + primii 20 de octeti din sha256(SPKI-ul cheii publice). Nu poti +// pretinde alt agentId cu cheia ta, si nu poti semna in numele altui agent fara cheia lui. +// +// VERIFICATORUL (verifyLedger) NU se increde in ce scrie registrul despre sine: recalculeaza hash-ul politicii primite, reface lantul +// de hash-uri, RE-VERIFICA fiecare semnatura fata de cheia publica a agentului, si RE-RULEAZA checkAction de la inceput ca sa confirme +// ca decizia scrisa (allowed/refuz) e chiar cea pe care o da politica, si ca nicio cheltuiala permisa nu a depasit vreodata limita. O +// intrare cu suma schimbata, semnata de alta cheie, stearsa, sau cu un "allowed" mincinos peste un refuz, e prinsa. +// +// TIMPUL, spus exact (2026-09-29, B-17): momentul unei intrari e declaratia celui care tine cheia agentului. Verificatorul il margineste +// de SUS cu ceasul lui (sau cu momentul unui cap ancorat); de JOS numai daca primeste ancore de la un martor (`anchors`: capete ale +// registrului cu momentul la care martorul le-a vazut) sau un `notBefore`. Fara ele, cine tine cheia poate ANTEDATA intrari si imparti +// o cheltuiala peste ferestre trecute (masurat pe forma 1: 10 plati de 100 facute in aceeasi secunda, declarate in 10 ferestre, sub o +// limita de 100 pe ora, verificator "ok"); cu ancore, antedatarea e marginita la intervalul dintre doua ancore. Registrul cinstit +// (biblioteca de aici) refuza la scriere un moment mai departe de ceasul lui decat TOLERANTA_S, in ambele sensuri. +// +// UN SINGUR REGISTRU pe agent si politica (2026-09-29, B-17): limita "pe fereastra" era impusa pe REGISTRU, iar un registru nou se +// deschidea oricand cu o sesiune aleatoare, deci cine tine cheia putea deschide N registre si cheltui de N ori limita, fiecare +// verificat "ok" (masurat pe forma 1 prin aceeasi aprobare pentru 5000 folosita in doua registre). Acum sesiunea e DERIVATA din agent +// si politica (`sessionId`), deci un al doilea registru nu e "alt registru", ci o RAMURA a aceluiasi: doua intrari semnate de agent cu +// aceeasi sesiune si acelasi seq si continut diferit sunt o dovada de echivocare pe care o verifica oricine (`findEquivocation`, +// `verifyEquivocation`), fara incredere in nimeni. Un agent care reporneste isi continua registrul (`resumeLedger`); unul care si-a +// pierdut registrul nu il poate relua fara sa para o ramura, si asta e pretul cerut: registrul se tine durabil (de pilda in jurnalul +// stratului de verificare), sau se trece la o politica noua. Ce NU se poate: cine vede o singura ramura nu stie ca exista alta; +// o vede un martor al capului (`anchors`) sau cine primeste ambele. +// +// Numai Node 24 (crypto.sign/verify cu ML-DSA, null ca algoritm). Nu atinge reteaua, nu tine bani reali: `amount` e o unitate abstracta +// a politicii (wei AERE sau orice altceva ce numeste politica); mutarea reala a valorii ramane a stratului de plata (x402), care poate +// cere acest registru drept dovada ca actiunea a fost permisa de politica agentului. +import crypto from 'node:crypto'; +import { checkAction, hashPolicy, canonical } from './agent-policy.mjs'; +import { verifyApproval, verifyRevocation, validRevocations } from './agent-aprobare.mjs'; + +export { canonical }; +export const VERSION = 'aere-agent-ledger/2 (2026-09-29)'; +const ALG = 'ml-dsa-65'; +const sha = (s) => crypto.createHash('sha256').update(typeof s === 'string' ? Buffer.from(s, 'utf8') : s).digest('hex'); +const GEN = '0'.repeat(64); +const TOLERANTA_S = 300; // cat se poate departa momentul unei intrari de ceasul care o judeca (registrul la scriere, verificatorul la citire) + +// 2026-09-28 (punctul 22): aprobarea umana si revocarea. O intrare poate purta aprobarile care i-au fost date (`body.approvals`, numai +// cand exista); registrul le verifica, numara aprobatorii distincti VALIZI si ii da lui checkAction. Un nonce de aprobare se poate +// folosi o singura data pe tot lantul: a doua aparitie nu mai numara. Revocarea semnata de proprietar opreste tot de la momentul ei; +// registrul o scrie ca intrare (`body.revocation`), iar verificatorul o primeste si SEPARAT, fiindca un agent isi poate omite revocarea +// din propriul registru. +function aprobatoriValizi(aprobari, { policy, policyHash, action, at, folosite }) { + const valizi = [], respinse = []; + for (const ap of aprobari || []) { + if (ap && folosite.has(String(ap.nonce))) { respinse.push('nonce already used'); continue; } + const r = verifyApproval(ap, { policy, policyHash, action, at }); + if (r.ok) valizi.push(r.humanId); else respinse.push(r.error); + } + return { valizi, respinse }; +} +const nonceuri = (aprobari) => (aprobari || []).filter((a) => a && a.nonce != null).map((a) => String(a.nonce)); + +/** SPKI-ul (DER) al unei chei publice, ca punct unic de adevar al identitatii. */ +function spkiDer(publicKey) { return publicKey.export({ type: 'spki', format: 'der' }); } +/** agentId derivat din cheia publica: nefalsificabil fara cheie. */ +export function agentIdFromKey(publicKey) { return 'aere-agent:' + sha(spkiDer(publicKey)).slice(0, 40); } + +/** + * O identitate noua de agent (cheie ML-DSA-65 + agentId legat de ea). + * @returns {{agentId:string, publicKey:crypto.KeyObject, privateKey:crypto.KeyObject, publicKeyPem:string}} + */ +export function newAgentIdentity() { + const { publicKey, privateKey } = crypto.generateKeyPairSync(ALG); + return { agentId: agentIdFromKey(publicKey), publicKey, privateKey, publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }) }; +} +/** Identitatea unui agent din cheia lui privata (PEM PKCS#8). */ +export function agentFromPrivateKeyPem(pem) { + const privateKey = crypto.createPrivateKey(pem); + if (privateKey.asymmetricKeyType !== ALG) throw new Error('agent-ledger: the key is not ML-DSA-65'); + const publicKey = crypto.createPublicKey(privateKey); + return { agentId: agentIdFromKey(publicKey), publicKey, privateKey, publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }) }; +} +export function publicKeyFromPem(pem) { return crypto.createPublicKey(pem); } +/** Sesiunea registrului unui agent sub o politica: derivata, deci una singura. */ +export function sessionId(agentId, policyHash) { return sha(`aere-agent-ledger-session|${agentId}|${String(policyHash).toLowerCase()}`).slice(0, 32); } + +const ceasulLocal = () => Math.floor(Date.now() / 1000); +function pregateste({ identity, policy: politicaData, policyHash: hashDat }) { + if (!identity || !identity.privateKey || !identity.publicKey) throw new Error('agent-ledger: an identity with privateKey and publicKey is required'); + if (identity.agentId !== agentIdFromKey(identity.publicKey)) throw new Error('agent-ledger: agentId is not derived from the identity key'); + const { policy, policyHash } = hashPolicy(politicaData); + if (hashDat != null && String(hashDat).toLowerCase() !== policyHash) throw new Error('agent-ledger: the policy does not hash to the policyHash given'); + if (policy.agentId !== identity.agentId) throw new Error('agent-ledger: the policy belongs to another agent'); + return { policy, policyHash }; +} + +/** + * Deschide registrul NOU al unei identitati sub o politica (seq 0). `now` e injectabil (fereastra deterministica in probe). Politica se + * normalizeaza si hash-ul ei se RECALCULEAZA: o pereche politica+hash care nu se potriveste e refuzata aici, nu doar la verificare. + * A doua deschidere sub aceeasi politica e o RAMURA a primului registru (aceeasi sesiune): pentru o repornire se foloseste resumeLedger. + * @param {object} p {identity:{agentId,privateKey,publicKey}, policy, policyHash?, now?} + */ +export function openLedger({ identity, policy, policyHash, now = ceasulLocal }) { + return registru(identity, pregateste({ identity, policy, policyHash }), now, [], null); +} + +/** + * Continua un registru exportat (repornirea agentului). Il verifica intai ca un strain (verifyLedger, pe ceasul registrului); refuza + * un registru care nu verifica sau al altei identitati, si reface starea: intrarile, nonce-urile folosite, revocarea. + * @param {object} p {identity, policy, policyHash?, ledger: exportul, now?, revocations?} + */ +export function resumeLedger({ identity, policy, policyHash, ledger, now = ceasulLocal, revocations = [] }) { + const pol = pregateste({ identity, policy, policyHash }); + if (!ledger || ledger.agentId !== identity.agentId) throw new Error('agent-ledger: the ledger to resume belongs to another agent'); + const v = verifyLedger(ledger, { policy: pol.policy, policyHash: pol.policyHash, maxTime: Number(now()) + TOLERANTA_S, revocations }); + if (!v.ok) throw new Error(`agent-ledger: the ledger to resume does not verify${v.seq != null ? ` at seq ${v.seq}` : ''}: ${v.error}`); + return registru(identity, pol, now, JSON.parse(JSON.stringify(ledger.entries)), v.revocations.revokedAt); +} + +function registru(identity, { policy, policyHash }, now, entries, revocatInitial) { + const session = sessionId(identity.agentId, policyHash); + const folosite = new Set(); // nonce-urile aprobarilor deja purtate de intrari + for (const e of entries) for (const n of nonceuri(e.body.approvals)) folosite.add(n); + let revokedAt = revocatInitial; // cea mai timpurie revocare valida primita + // cheltuielile PERMISE, cu momentul lor, pentru fereastra (numai platile allowed; checkAction filtreaza singur dupa fereastra) + function spentInWindow() { + return entries.filter((e) => e.body.decision.allowed && e.body.action.kind === 'payment') + .map((e) => ({ amount: String(e.body.action.amount), at: e.body.action.at })); + } + function scrie(corpFaraSeq) { + const seq = entries.length; + const prev = seq ? entries[seq - 1].hash : GEN; + const body = { ...corpFaraSeq, seq }; + const mesaj = Buffer.from(`${seq}|${prev}|${canonical(body)}`, 'utf8'); + const signature = crypto.sign(null, mesaj, identity.privateKey).toString('base64'); + const hash = sha(`${seq}|${prev}|${canonical(body)}|${signature}`); + const entry = { seq, prev, body, signature, hash }; + entries.push(entry); + return entry; + } + function momentul(opt) { + const at = opt.at ?? now(); + if (!Number.isFinite(Number(at))) throw new Error('agent-ledger: the entry time is not a finite number'); + if (entries.length && Number(at) < Number(entries[entries.length - 1].body.at)) throw new Error('agent-ledger: entry times cannot go backwards'); + const acum = Number(now()); + if (Number(at) > acum + TOLERANTA_S) throw new Error(`agent-ledger: the entry time is more than ${TOLERANTA_S} s ahead of the ledger clock`); + // 2026-09-29 (B-17): si in urma; o intrare antedatata muta o plata intr-o fereastra trecuta + if (Number(at) < acum - TOLERANTA_S) throw new Error(`agent-ledger: the entry time is more than ${TOLERANTA_S} s behind the ledger clock (backdated)`); + return Number(at); + } + const antet = { v: 2, agentId: identity.agentId, policyHash, session }; + return { + agentId: identity.agentId, + session, + policyHash, + /** + * Revocarea agentului, semnata de proprietarul numit in politica. Se scrie ca intrare; de la `revokedAt` totul e refuzat. + * O revocare care nu verifica (alt semnatar, alta politica) e refuzata cu motivul, si nu se scrie. + */ + recordRevocation(rv, opt = {}) { + const r = verifyRevocation(rv, { policy, policyHash }); + if (!r.ok) throw new Error(`agent-ledger: revocation refused: ${r.error}`); + const at = momentul(opt); + revokedAt = revokedAt == null ? r.revokedAt : Math.min(revokedAt, r.revokedAt); + const action = { kind: 'revocation', revokedAt: r.revokedAt, at }; + return scrie({ ...antet, at, action, decision: { allowed: false, reason: `revocation recorded (from ${r.revokedAt})` }, provenance: null, revocation: rv }); + }, + /** + * Propune o actiune. Cheama checkAction cu cheltuiala reala din fereastra; scrie o intrare semnata si legata. + * @param {object} actionIn {kind:'payment'|'tool', amount?, to?, asset?, tool?, args?} + * @param {object} [opt] {provenance?: plic proof-of-ai / obiect (se leaga prin hash), at?: number, approvals?: aprobari umane} + * @returns {{allowed:boolean, reason:string, entry:object, rejectedApprovals:string[]}} + */ + record(actionIn, opt = {}) { + // 2026-09-27 (revizuire adversariala): `at` ales de apelant reseta fereastra la vointa. Timpul unei intrari e NEDESCRESCATOR si + // nu se poate departa de ceasul registrului cu mai mult de TOLERANTA_S; verificatorul cere acelasi lucru fata de ceasul LUI. + const at = momentul(opt); + // checkAction citeste "acum" din action.at si filtreaza fereastra dupa el: injectam at in actiune, si o stocam asa cum a fost judecata + const action = { ...actionIn, at }; + const approvals = Array.isArray(opt.approvals) && opt.approvals.length ? opt.approvals : null; + const { valizi, respinse } = aprobatoriValizi(approvals, { policy, policyHash, action, at, folosite }); + const decizie = checkAction(policy, action, spentInWindow(), { approvers: valizi, revokedAt }); + for (const n of nonceuri(approvals)) folosite.add(n); + const body = { ...antet, at, action, decision: { allowed: !!decizie.allowed, reason: decizie.reason }, + provenance: opt.provenance ? sha(canonical(opt.provenance)) : null }; + if (approvals) body.approvals = approvals; + const entry = scrie(body); + return { allowed: entry.body.decision.allowed, reason: entry.body.decision.reason, entry, rejectedApprovals: respinse }; + }, + /** Registrul de export: identitate (cheie publica), politica (prin hash), sesiunea si intrarile. */ + export() { + return { version: VERSION, agentId: identity.agentId, publicKeyPem: identity.publicKey.export({ type: 'spki', format: 'pem' }), + policyHash, session, entries: entries.slice() }; + }, + entries() { return entries.slice(); }, + }; +} + +/** + * Verifica un registru exportat FARA sa se increada in el: politica primita (recalculata la hash), identitatea legata de cheie, fiecare + * semnatura, lantul de hash-uri, si - decisiv - RE-RULEAZA politica de la inceput ca sa confirme ca fiecare decizie scrisa e cea corecta + * si ca nicio cheltuiala permisa nu a depasit limita. + * @param {object} ledger exportul unui registru + * @param {object} o + * @param {object} o.policy politica (de la oricine: hash-ul ei se recalculeaza si trebuie sa fie al registrului) + * @param {string} [o.policyHash] hash-ul fixat de cel care verifica (de ex. dintr-o autorizare); daca lipseste, cel al politicii + * @param {number} [o.maxTime] marginea de SUS a timpului (secunde unix); implicit ceasul local + TOLERANTA_S + * @param {Array} [o.revocations] revocarile primite de la proprietar, nu din registru + * @param {Array} [o.anchors] [{seq, hash, at}]: capete ale registrului vazute de un martor la momentul `at` + * @param {number} [o.notBefore] marginea de JOS a timpului, de la un martor (de ex. deschiderea sesiunii) + * @param {number} [o.anchorTolerance] cat poate intarzia martorul fata de scriere (implicit TOLERANTA_S) + * @returns {{ok:boolean, seq?:number, error?:string, allowedPayments?:number, spent?:string, humanApproved?:number, revocations:object, time:object}} + */ +export function verifyLedger(ledger, { policy: politicaData, policyHash: hashFixat, maxTime = Math.floor(Date.now() / 1000) + TOLERANTA_S, + revocations = [], anchors = [], notBefore = null, anchorTolerance = TOLERANTA_S } = {}) { + const timp = { maxTime: Number(maxTime), notBefore: notBefore == null ? null : Number(notBefore), anchors: (anchors || []).length, + lowerBound: notBefore != null || (anchors || []).length ? 'witnessed' : 'none: entry times are the statement of the agent key holder' }; + const rezultat = (x, extra = {}) => ({ ...x, ...extra, time: timp }); + if (!ledger || !Array.isArray(ledger.entries)) return rezultat({ ok: false, error: 'a ledger with entries is required' }); + let policy, policyHash; + try { ({ policy, policyHash } = hashPolicy(politicaData)); } catch (e) { return rezultat({ ok: false, error: `the policy given is not valid: ${e.message}` }); } + if (hashFixat != null && String(hashFixat).toLowerCase() !== policyHash) return rezultat({ ok: false, error: 'the policy given does not hash to the pinned policyHash (a different policy)' }); + const ext = validRevocations(revocations, { policy, policyHash }); + let revokedAt = ext.revokedAt; + const judecat = () => ({ revocations: { given: (revocations || []).length, rejected: ext.rejected.length, revokedAt, setHash: ext.setHash } }); + if (String(ledger.policyHash).toLowerCase() !== policyHash) return rezultat({ ok: false, error: 'the ledger names another policy (policyHash differs)' }, judecat()); + if (ledger.session !== sessionId(ledger.agentId, policyHash)) return rezultat({ ok: false, error: 'the ledger session is not the one derived from the agent and the policy' }, judecat()); + let pub; + try { pub = crypto.createPublicKey(ledger.publicKeyPem); } catch { return rezultat({ ok: false, error: 'the public key cannot be read' }, judecat()); } + if (ledger.agentId !== agentIdFromKey(pub)) return rezultat({ ok: false, error: 'agentId is not derived from the public key (false identity)' }, judecat()); + if (policy.agentId !== ledger.agentId) return rezultat({ ok: false, error: 'the policy belongs to another agent' }, judecat()); + // ancorele: fiecare numeste o intrare a ACESTUI registru (altfel e alta ramura sau alt registru) + const anc = []; + for (const a of anchors || []) { + const s = Number(a && a.seq); + if (!Number.isInteger(s) || s < 0 || s >= ledger.entries.length) return rezultat({ ok: false, error: `anchor at seq ${a && a.seq} is outside the ledger` }, judecat()); + if (!ledger.entries[s] || ledger.entries[s].hash !== a.hash) return rezultat({ ok: false, seq: s, error: `anchor at seq ${s} does not match the ledger (another branch or another ledger)` }, judecat()); + if (!Number.isFinite(Number(a.at))) return rezultat({ ok: false, error: `anchor at seq ${s} has no time` }, judecat()); + anc.push({ seq: s, at: Number(a.at) }); + } + const tol = Number(anchorTolerance); + let prev = GEN, humanApproved = 0; + const folosite = new Set(); + const permise = []; // cheltuielile permise re-derivate, pentru fereastra + for (let i = 0; i < ledger.entries.length; i++) { + const e = ledger.entries[i]; + const esec = (error) => rezultat({ ok: false, seq: i, error }, judecat()); + if (e.seq !== i) return esec(`seq ${e.seq} instead of ${i}`); + if (e.prev !== prev) return esec('prev does not link to the previous hash (an entry was removed or reordered)'); + const b = e.body; + if (!b || b.v !== 2 || b.seq !== i || b.agentId !== ledger.agentId || b.session !== ledger.session) return esec('the entry body does not match its header (agent, session or seq)'); + if (String(b.policyHash).toLowerCase() !== policyHash) return esec('the entry names another policy'); + const mesaj = Buffer.from(`${i}|${prev}|${canonical(b)}`, 'utf8'); + let sigOk = false; + try { sigOk = crypto.verify(null, mesaj, pub, Buffer.from(String(e.signature), 'base64')); } catch { sigOk = false; } + if (!sigOk) return esec('the signature does not verify (content changed or another key)'); + const hash = sha(`${i}|${prev}|${canonical(b)}|${e.signature}`); + if (hash !== e.hash) return esec('the entry hash does not reproduce'); + // timpul: nedescrescator, sub marginea de sus, peste marginea de jos si intre ancorele care il incadreaza + const atI = Number(b.at); + if (!Number.isFinite(atI) || !b.action || Number(b.action.at) !== atI) return esec('the entry time is missing or differs from the time judged'); + if (i > 0 && atI < Number(ledger.entries[i - 1].body.at)) return esec('entry times go backwards'); + if (atI > Number(maxTime)) return esec(`entry from the future: ${atI} > ${maxTime} (the verifier clock or an anchored head)`); + if (notBefore != null && atI < Number(notBefore)) return esec(`entry declares ${atI}, before the witnessed start ${notBefore}`); + for (const a of anc) { + if (i <= a.seq && atI > a.at + tol) return esec(`entry declares ${atI}, after the anchor at seq ${a.seq} that covers it was witnessed (${a.at})`); + if (i > a.seq && atI < a.at - tol) return esec(`entry declares ${atI}, before the anchor at seq ${a.seq} that precedes it was witnessed (${a.at}): backdated`); + } + // o intrare de revocare: revocarea din ea trebuie sa verifice, si ea nu permite nimic + if (b.revocation !== undefined) { + const r = verifyRevocation(b.revocation, { policy, policyHash }); + if (!r.ok) return esec(`the revocation entry carries a revocation that does not verify (${r.error})`); + if (b.action.kind !== 'revocation' || Number(b.action.revokedAt) !== r.revokedAt || b.decision.allowed) return esec('the revocation entry does not say what it carries'); + revokedAt = revokedAt == null ? r.revokedAt : Math.min(revokedAt, r.revokedAt); + prev = hash; + continue; + } + if (b.action.kind === 'revocation') return esec('a revocation entry without the revocation'); + if (b.approvals !== undefined && !(Array.isArray(b.approvals) && b.approvals.length)) return esec('the approvals field is empty or not a list'); + const { valizi } = aprobatoriValizi(b.approvals, { policy, policyHash, action: b.action, at: atI, folosite }); + for (const n of nonceuri(b.approvals)) folosite.add(n); + const decizieReala = checkAction(policy, b.action, permise, { approvers: valizi, revokedAt }); + if (!b.decision || !!decizieReala.allowed !== !!b.decision.allowed) { + return esec(`false decision: the ledger says allowed=${b.decision && b.decision.allowed}, the policy gives allowed=${decizieReala.allowed} (${decizieReala.reason})`); + } + if (decizieReala.allowed && decizieReala.approvedBy) humanApproved++; + if (decizieReala.allowed && b.action.kind === 'payment') permise.push({ amount: String(b.action.amount), at: b.action.at }); + prev = hash; + } + const spent = permise.reduce((a, s) => a + BigInt(s.amount), 0n); + return rezultat({ ok: true, seq: ledger.entries.length, allowedPayments: permise.length, spent: String(spent), humanApproved }, judecat()); +} + +// DOVADA DE ECHIVOCARE: doua intrari semnate de aceeasi cheie de agent, cu aceeasi sesiune si acelasi seq, si continut diferit. Cum +// sesiunea e una pe agent si politica, asta inseamna ca agentul a scris doua istorii diferite ale aceluiasi registru (o aprobare +// folosita de doua ori, o cheltuiala dubla). Dovada se verifica fara incredere in cine o aduce: numai cheia publica si doua semnaturi. +function intrareSemnata(e, pub) { + if (!e || !e.body || !Number.isInteger(e.seq) || e.body.seq !== e.seq) return false; + let ok = false; + try { ok = crypto.verify(null, Buffer.from(`${e.seq}|${e.prev}|${canonical(e.body)}`, 'utf8'), pub, Buffer.from(String(e.signature), 'base64')); } catch { ok = false; } + return ok && sha(`${e.seq}|${e.prev}|${canonical(e.body)}|${e.signature}`) === e.hash; +} +/** + * Cauta, in doua exporturi ale aceluiasi agent, prima pozitie la care ele difera; daca ambele intrari de acolo sunt semnate de agent in + * aceeasi sesiune, intoarce dovada. @returns {object|null} + */ +export function findEquivocation(a, b) { + if (!a || !b || a.agentId !== b.agentId || a.session !== b.session) return null; + let pub; try { pub = crypto.createPublicKey(a.publicKeyPem); } catch { return null; } + if (agentIdFromKey(pub) !== a.agentId) return null; + const n = Math.min((a.entries || []).length, (b.entries || []).length); + for (let i = 0; i < n; i++) { + const x = a.entries[i], y = b.entries[i]; + if (x.hash === y.hash) continue; + if (x.body.session !== a.session || y.body.session !== a.session || !intrareSemnata(x, pub) || !intrareSemnata(y, pub)) return null; + return { v: 1, kind: 'aere-agent-equivocation', agentId: a.agentId, session: a.session, publicKeyPem: a.publicKeyPem, seq: i, a: x, b: y }; + } + return null; +} +/** @returns {{ok:boolean, error?:string}} */ +export function verifyEquivocation(p) { + if (!p || p.kind !== 'aere-agent-equivocation' || p.v !== 1) return { ok: false, error: 'not an aere-agent-equivocation v1' }; + let pub; try { pub = crypto.createPublicKey(p.publicKeyPem); } catch { return { ok: false, error: 'the public key cannot be read' }; } + if (agentIdFromKey(pub) !== p.agentId) return { ok: false, error: 'agentId is not derived from the public key' }; + for (const e of [p.a, p.b]) { + if (!e || !e.body || e.seq !== p.seq || e.body.agentId !== p.agentId || e.body.session !== p.session) return { ok: false, error: 'the two entries are not at the same position of the same ledger' }; + if (!intrareSemnata(e, pub)) return { ok: false, error: 'an entry is not signed by the agent' }; + } + if (p.a.body.policyHash !== p.b.body.policyHash || p.session !== sessionId(p.agentId, p.a.body.policyHash)) return { ok: false, error: 'the session is not the one derived from the agent and the policy' }; + if (p.a.hash === p.b.hash) return { ok: false, error: 'the two entries are the same' }; + return { ok: true }; +} diff --git a/agents/agent-policy.mjs b/agents/agent-policy.mjs new file mode 100644 index 0000000..c4e4015 --- /dev/null +++ b/agents/agent-policy.mjs @@ -0,0 +1,168 @@ +'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')) }; +} diff --git a/agents/control-negativ-aprobare.mjs b/agents/control-negativ-aprobare.mjs new file mode 100644 index 0000000..4e7cd39 --- /dev/null +++ b/agents/control-negativ-aprobare.mjs @@ -0,0 +1,73 @@ +// Controlul negativ al probelor agentilor (politica, registrul, aprobarea si revocarea, linia de comanda): fiecare paznic se strica intr-o COPIE a +// dosarului, proba lui ruleaza pe copie si TREBUIE sa iasa rosie, cu probele chiar rulate (rezumatul lor exista); pe copia neatinsa, +// verde. O plantare al carei tipar nu apare exact o data e un esec al controlului, nu o linie informativa. Trei stari: o proba care nu +// ajunge la rezumat e STRICAT, si se numara esec (rosul ei nu masoara nimic). +// 2026-09-29 (B-17): acopera si paznicii noi - argumentele in aprobare, hash-ul politicii recalculat, antedatarea (la scriere si fata +// de ancore), ancora altei ramuri, sesiunea derivata, dovada de echivocare, reluarea registrului, campul necunoscut, activul, actionHash canonic. +// node control-negativ-aprobare.mjs iesire 0 = martorii verzi si toate plantarile rosii +// Proba politicii foloseste verificatorul AIP-23 (AERE_VERIFY_PROOF sau ../aere-proof-protocol/verify.mjs); fara el, martorul ei iese +// cu 2 (partea AIP-23 NEMASURATA) si se accepta ca martor numai asa, cu zero RAU. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const DEV_VERIFY = path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs'); +const VERIFY = process.env.AERE_VERIFY_PROOF || (fs.existsSync(DEV_VERIFY) ? DEV_VERIFY : ''); +const P = { pol: 'proba-agent-policy.mjs', reg: 'proba-agent-ledger.mjs', apr: 'proba-agent-aprobare.mjs', cli: 'proba-agent-cli.mjs' }; +const PLANTARI = [ + // [nume, fisier, tipar, inlocuire, proba] + ['nonce-ul unei aprobari nu mai e unic', 'agent-ledger.mjs', "if (ap && folosite.has(String(ap.nonce))) { respinse.push('nonce already used'); continue; }", '', P.apr], + ['un aprobator nenumit in politica numara', 'agent-aprobare.mjs', 'if (!policy.approval.approvers.includes(humanId)) return', 'if (false) return', P.apr], + ['semnatura aprobarii nu se mai verifica', 'agent-aprobare.mjs', "if (!sig) return { ok: false, error: 'the approval signature does not verify' };", '', P.apr], + ['aprobarea devine scutire de limita', 'agent-policy.mjs', 'if (!d.allowed || !needsApproval(policy, action)) return d;', 'if (needsApproval(policy, action) && (ctx.approvers || []).length >= policy.approval.threshold) return { allowed: true, reason: "approved (exempt)" }; if (!d.allowed || !needsApproval(policy, action)) return d;', P.apr], + ['verificatorul ignora revocarile date separat', 'agent-ledger.mjs', 'let revokedAt = ext.revokedAt;', 'let revokedAt = null;', P.apr], + ['revocarea nu mai cere proprietarul', 'agent-aprobare.mjs', "if (humanIdFromKey(k) !== policy.owner) return { ok: false, error: 'the revocation is not signed by the owner named in the policy' };", '', P.apr], + ['aprobarea nu mai e legata de continutul actiunii', 'agent-aprobare.mjs', 'if (ap.actionHash !== actionHash(action)) return', 'if (false) return', P.apr], + ['argumentele uneltei nu mai intra in continutul aprobat', 'agent-aprobare.mjs', 'if (a.args !== undefined) c.argsHash = sha(canonical(a.args));', '', P.apr], + ['verificatorul crede hash-ul dat in loc sa il recalculeze', 'agent-ledger.mjs', 'if (hashFixat != null && String(hashFixat).toLowerCase() !== policyHash) return', 'if (hashFixat != null) policyHash = String(hashFixat).toLowerCase(); if (false) return', P.reg], + ['registrul accepta la scriere o intrare antedatata', 'agent-ledger.mjs', 'if (Number(at) < acum - TOLERANTA_S) throw', 'if (false) throw', P.reg], + ['ancora nu mai margineste timpul de jos', 'agent-ledger.mjs', 'if (i > a.seq && atI < a.at - tol) return', 'if (false) return', P.reg], + ['ancora nu mai trebuie sa numeasca o intrare a registrului', 'agent-ledger.mjs', 'if (!ledger.entries[s] || ledger.entries[s].hash !== a.hash) return', 'if (false) return', P.reg], + ['verificatorul nu mai cere sesiunea registrului in intrare', 'agent-ledger.mjs', ' || b.session !== ledger.session) return', ') return', P.reg], + ['sesiunea registrului nu mai trebuie sa fie cea derivata', 'agent-ledger.mjs', 'if (ledger.session !== sessionId(ledger.agentId, policyHash)) return', 'if (false) return', P.reg], + ['dovada de echivocare fara semnaturile agentului', 'agent-ledger.mjs', "if (!intrareSemnata(e, pub)) return { ok: false, error: 'an entry is not signed by the agent' };", '', P.reg], + ['un registru atins se poate relua', 'agent-ledger.mjs', 'if (!v.ok) throw new Error(`agent-ledger: the ledger to resume does not verify', 'if (false) throw new Error(`agent-ledger: the ledger to resume does not verify', P.reg], + ['reluarea uita nonce-urile folosite', 'agent-ledger.mjs', 'for (const e of entries) for (const n of nonceuri(e.body.approvals)) folosite.add(n);', '', P.apr], + ['reluarea uita revocarea', 'agent-ledger.mjs', 'JSON.parse(JSON.stringify(ledger.entries)), v.revocations.revokedAt);', 'JSON.parse(JSON.stringify(ledger.entries)), null);', P.apr], + ['linia de comanda suprascrie o cheie existenta', 'agent-cli.mjs', 'if (fs.existsSync(cheie)) throw new Folosire(', 'if (false) throw new Folosire(', P.cli], + ['linia de comanda lasa pe oricine sa aprobe', 'agent-cli.mjs', 'if (!policy.approval || !policy.approval.approvers.includes(human.humanId)) throw', 'if (false) throw', P.cli], + ['verify din linia de comanda uita hash-ul numit in fisierul politicii', 'agent-cli.mjs', "const pinned = get('--policy-hash') ?? j.policyHash;", "const pinned = get('--policy-hash');", P.cli], + ['record deschide mereu un registru nou in loc sa il reia', 'agent-cli.mjs', 'const L = fs.existsSync(lf) ? resumeLedger(', 'const L = false ? resumeLedger(', P.cli], + ['politica cu un camp necunoscut e primita', 'agent-policy.mjs', 'if (necunoscute.length) throw', 'if (false) throw', P.pol], + ['o plata in alt activ trece pe langa limita', 'agent-policy.mjs', 'if (action.asset != null && String(action.asset) !== policy.spend.asset) return', 'if (false) return', P.pol], + ['actionHash-ul plicului nu mai e canonic', 'agent-policy.mjs', "actionHash: sha256(Buffer.from(canonical(action), 'utf8')),", "actionHash: sha256(Buffer.from(JSON.stringify(action), 'utf8')),", P.pol], +]; +const FISIERE = ['agent-policy.mjs', 'agent-ledger.mjs', 'agent-aprobare.mjs', 'agent-cli.mjs', P.pol, P.reg, P.apr, P.cli]; +function copie() { const t = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-b2-ctl-')); for (const f of FISIERE) fs.copyFileSync(path.join(AICI, f), path.join(t, f)); return t; } +function ruleaza(t, proba) { + const env = { ...process.env }; if (VERIFY) env.AERE_VERIFY_PROOF = VERIFY; else delete env.AERE_VERIFY_PROOF; + const r = spawnSync(process.execPath, [path.join(t, proba)], { encoding: 'utf8', timeout: 240000, env }); + const out = (r.stdout || '') + (r.stderr || ''); + const m = /agent-(policy|ledger|aprobare|cli): (\d+)\/(\d+)/.exec(out); + return { cod: r.status, rulat: !!m, rele: (out.match(/^\s*(RAU\s|\[RAU)/gm) || []).length }; +} +let esecuri = 0; +for (const proba of Object.values(P)) { + const t0 = copie(); const m0 = ruleaza(t0, proba); fs.rmSync(t0, { recursive: true, force: true }); + const verde = m0.rulat && m0.rele === 0 && (m0.cod === 0 || (m0.cod === 2 && proba === P.pol && !VERIFY)); + if (verde) console.log(` OK martorul ${proba}: copia neatinsa verde${m0.cod === 2 ? ' (fara verificatorul AIP-23: partea lui NEMASURATA)' : ''}`); + else { esecuri++; console.log(` RAU martorul ${proba} nu e verde (cod ${m0.cod}, ${m0.rulat ? m0.rele + ' RAU' : 'nu a ajuns la rezumat'})`); } +} +for (const [nume, f, din, inl, proba] of PLANTARI) { + const t = copie(); const fp = path.join(t, f); const src = fs.readFileSync(fp, 'utf8'); + if (src.split(din).length !== 2) { esecuri++; console.log(` RAU ${nume}: tiparul nu apare exact o data in ${f}`); fs.rmSync(t, { recursive: true, force: true }); continue; } + fs.writeFileSync(fp, src.replace(din, inl)); + const r = ruleaza(t, proba); fs.rmSync(t, { recursive: true, force: true }); + if (r.rulat && r.cod !== 0 && r.rele > 0) console.log(` OK ${nume}: ${proba} ROSIE (${r.rele} RAU)`); + else { esecuri++; console.log(` RAU ${nume}: ${r.rulat ? `${proba} a ramas verde` : `${proba} nu a ajuns la rezumat (STRICAT)`} (cod ${r.cod})`); } +} +console.log(esecuri ? `RAU: ${esecuri} esecuri ale controlului` : `DOVEDIT: martorii verzi, ${PLANTARI.length} din ${PLANTARI.length} plantari rosii`); +process.exitCode = esecuri ? 1 : 0; diff --git a/agents/proba-agent-aprobare.mjs b/agents/proba-agent-aprobare.mjs new file mode 100644 index 0000000..e48105b --- /dev/null +++ b/agents/proba-agent-aprobare.mjs @@ -0,0 +1,147 @@ +// Proba aprobarii umane si a revocarii (agent-aprobare.mjs + agent-ledger.mjs + agent-policy.mjs, punctul 22). Offline, ceas injectat, +// chei ML-DSA-65 reale. Fiecare afirmatie are perechea ei negativa; adversarul are cheia AGENTULUI (isi poate re-semna registrul), +// dar nu cheile oamenilor. Forma 2 (2026-09-29, B-17): aprobarea leaga argumentele uneltei; un al doilea registru e o ramura. +// node proba-agent-aprobare.mjs iesire 0 = toate cum trebuia +import crypto from 'node:crypto'; +import { definePolicy, checkAction } from './agent-policy.mjs'; +import { newAgentIdentity, openLedger, resumeLedger, verifyLedger, findEquivocation, verifyEquivocation, canonical } from './agent-ledger.mjs'; +import { newHumanIdentity, approve, revoke, verifyApproval } from './agent-aprobare.mjs'; + +let ok = 0, rau = 0; +const cer = (c, ce) => { console.log((c ? ' OK ' : ' RAU ') + ce); c ? ok++ : rau++; }; +const arunca = (fn) => { try { fn(); return null; } catch (e) { return e.message; } }; + +const agent = newAgentIdentity(); +const [H1, H2, H3, O, X] = [newHumanIdentity(), newHumanIdentity(), newHumanIdentity(), newHumanIdentity(), newHumanIdentity()]; +const { policy, policyHash } = definePolicy({ + agentId: agent.agentId, spend: { amount: '5000', windowSeconds: 3600 }, recipients: ['0xbbbb'], tools: ['retrieval', 'deploy'], + approval: { approvers: [H1.humanId, H2.humanId, H3.humanId], threshold: 2, above: '100', tools: ['deploy'] }, owner: O.humanId, +}); +let t = 1000; const now = () => t; +const L = openLedger({ identity: agent, policy, policyHash, now }); +const ap = (h, action, o = {}) => approve({ human: h, agentId: agent.agentId, policyHash, action, + issuedAt: o.issuedAt ?? t - 10, expiresAt: o.expiresAt ?? t + 600, nonce: o.nonce }); +const P500 = { kind: 'payment', to: '0xbbbb', amount: '500' }; + +// 1. politica: validarea campurilor noi, si politicile vechi raman neatinse +cer(!('approval' in definePolicy({ agentId: agent.agentId }).policy) && !('owner' in definePolicy({ agentId: agent.agentId }).policy), '1. o politica fara aprobare nu poarta campurile noi (hash-ul politicilor vechi neschimbat)'); +cer(!!arunca(() => definePolicy({ agentId: 'a', approval: { approvers: [H1.humanId], threshold: 2, above: '1' } })), '1. CONTROL: prag peste numarul aprobatorilor -> refuzat'); +cer(!!arunca(() => definePolicy({ agentId: 'a', approval: { approvers: ['0xabc'], threshold: 1, above: '1' } })), '1. CONTROL: un aprobator care nu e id aere-human -> refuzat'); +cer(!!arunca(() => definePolicy({ agentId: 'a', approval: { approvers: [H1.humanId], threshold: 1 } })), '1. CONTROL: aprobare fara `above` si fara `tools` (nu ar cere nimic) -> refuzata'); + +// 2. pragul de aprobare +cer(L.record({ kind: 'payment', to: '0xbbbb', amount: '50' }).allowed, '2. plata de 50 (sub prag) trece fara aprobare'); +const r0 = L.record(P500); +cer(!r0.allowed && /0 of 2/.test(r0.reason), `2. CONTROL: 500 fara aprobare -> refuzat (${r0.reason})`); +const r1 = L.record(P500, { approvals: [ap(H1, P500)] }); +cer(!r1.allowed && /1 of 2/.test(r1.reason), `2. CONTROL: 500 cu o singura aprobare -> refuzat (${r1.reason})`); +const aprobariBune = [ap(H1, P500), ap(H2, P500)]; +const r2 = L.record(P500, { approvals: aprobariBune }); +cer(r2.allowed && /approved by 2 of 2/.test(r2.reason), `2. 500 cu doua aprobari de la oameni distincti -> permis (${r2.reason})`); + +// 3. ce NU numara ca aprobare +const rX = L.record(P500, { approvals: [ap(H1, P500), ap(X, P500)] }); +cer(!rX.allowed && rX.rejectedApprovals.length === 1 && /not named in the policy/.test(rX.rejectedApprovals[0]), '3. CONTROL: a doua aprobare de la un om nenumit in politica -> respinsa cu motivul ei, actiunea refuzata'); +// al doilea strat al aceluiasi paznic, probat separat (altfel scoaterea unuia nu se vede, fiindca il prinde celalalt) +cer(/not named/.test(verifyApproval(ap(X, P500), { policy, policyHash, action: P500, at: t }).error || ''), '3. CONTROL, stratul aprobarii: aprobarea unui om nenumit nu verifica'); +cer(!checkAction(policy, { ...P500, at: t }, [], { approvers: [H1.humanId, X.humanId] }).allowed, '3. CONTROL, stratul politicii: un id nenumit dat lui checkAction nu numara'); +cer(!L.record(P500, { approvals: [ap(H1, P500), ap(H1, P500)] }).allowed, '3. CONTROL: acelasi om de doua ori (nonce-uri diferite) -> refuzat (distincti)'); +const P600 = { kind: 'payment', to: '0xbbbb', amount: '600' }; +const r3 = L.record(P600, { approvals: [ap(H1, P500), ap(H2, P500)] }); +cer(!r3.allowed && r3.rejectedApprovals.length === 2 && r3.rejectedApprovals.every((m) => /another action/.test(m)), '3. CONTROL: aprobarile pentru 500 folosite la 600 -> respinse (alta actiune)'); +const r4 = L.record(P500, { approvals: aprobariBune }); +cer(!r4.allowed && r4.rejectedApprovals.length === 2 && r4.rejectedApprovals.every((m) => /nonce already used/.test(m)), '3. CONTROL: aceleasi aprobari (aceleasi nonce-uri) a doua oara -> respinse (nonce folosit)'); +const r5 = L.record(P500, { approvals: [ap(H1, P500, { issuedAt: t - 900, expiresAt: t - 1 }), ap(H2, P500)] }); +cer(!r5.allowed && r5.rejectedApprovals.some((m) => /outside the approval window/.test(m)), '3. CONTROL: o aprobare expirata nu numara'); +const clasic = crypto.generateKeyPairSync('ed25519'); +const falsa = { ...ap(H1, P500), approverPem: clasic.publicKey.export({ type: 'spki', format: 'pem' }) }; +cer(/ML-DSA-65/.test(verifyApproval(falsa, { policy, policyHash, action: P500, at: t }).error || ''), '3. CONTROL: o aprobare cu cheie clasica (ed25519) e respinsa'); +const PMARE = { kind: 'payment', to: '0xbbbb', amount: '6000' }; +const r6 = L.record(PMARE, { approvals: [ap(H1, PMARE), ap(H3, PMARE)] }); +cer(!r6.allowed && /over the limit/.test(r6.reason), `3. aprobarea NU scuteste de limita: 6000 cu doua aprobari -> refuzat (${r6.reason})`); + +// 3b. B-17 (2026-09-29). Forma 1: o singura aprobare pentru 5000 a trecut in doua registre ale aceluiasi agent, fiecare verificat "ok". +// Acum al doilea registru sub aceeasi politica e o RAMURA a primului (aceeasi sesiune): cine vede o singura ramura o accepta, dar +// cele doua impreuna sunt o dovada de echivocare semnata de agent. +const L2 = openLedger({ identity: agent, policy, policyHash, now }); +const P700 = { kind: 'payment', to: '0xbbbb', amount: '700' }; +const pentruL = [ap(H1, P700), ap(H2, P700)]; +cer(L.record(P700, { approvals: pentruL }).allowed, '3b. doua aprobari pentru 700 in registrul L -> permis'); +const reluat = L2.record(P700, { approvals: pentruL }); +cer(reluat.allowed && verifyLedger(L2.export(), { policy, policyHash, maxTime: t + 300 }).ok, '3b. ATAC: aceleasi aprobari reluate intr-un "al doilea registru" trec acolo, si ramura, SINGURA, verifica'); +const dov = findEquivocation(L.export(), L2.export()); +cer(!!dov && dov.seq === 0 && verifyEquivocation(dov).ok, '3b. dar L si L2 sunt aceeasi sesiune: impreuna dau o dovada de echivocare verificabila de oricine'); + +// 4. unelte care cer aprobare, cu argumentele legate +const DEP = { kind: 'tool', tool: 'deploy' }; +cer(!L.record(DEP).allowed, '4. CONTROL: unealta "deploy" fara aprobare -> refuzata'); +cer(L.record(DEP, { approvals: [ap(H2, DEP), ap(H3, DEP)] }).allowed, '4. "deploy" cu doua aprobari -> permisa'); +cer(L.record({ kind: 'tool', tool: 'retrieval' }).allowed, '4. "retrieval" (necerand aprobare) trece fara'); +const STAGING = { kind: 'tool', tool: 'deploy', args: { target: 'staging' } }; +const PROD = { kind: 'tool', tool: 'deploy', args: { target: 'production' } }; +const rProd = L.record(PROD, { approvals: [ap(H1, STAGING), ap(H2, STAGING)] }); +cer(!rProd.allowed && rProd.rejectedApprovals.every((m) => /another action/.test(m)), '4. ATAC (B-17): aprobarile unui deploy pe staging folosite pe production -> respinse (argumentele difera)'); +cer(L.record(STAGING, { approvals: [ap(H1, STAGING), ap(H3, STAGING)] }).allowed, '4. CONTROL: aprobarile pentru staging trec pe staging'); + +// 5. verificatorul re-verifica aprobarile; adversarul are cheia agentului si isi poate re-semna registrul +const exp = L.export(); +const v = verifyLedger(exp, { policy, policyHash, maxTime: t + 300 }); +cer(v.ok && v.humanApproved === 4, `5. registrul cinstit verifica; ${v.humanApproved} actiuni aprobate de oameni`); +function resemneaza(reg, i, schimba) { + const r = JSON.parse(JSON.stringify(reg)); + schimba(r.entries[i].body); + for (let k = i; k < r.entries.length; k++) { + const e = r.entries[k]; e.prev = k ? r.entries[k - 1].hash : '0'.repeat(64); + e.signature = crypto.sign(null, Buffer.from(`${k}|${e.prev}|${canonical(e.body)}`, 'utf8'), agent.privateKey).toString('base64'); + e.hash = crypto.createHash('sha256').update(`${k}|${e.prev}|${canonical(e.body)}|${e.signature}`).digest('hex'); + } + return r; +} +const iAprobat = exp.entries.findIndex((e) => e.body.decision.allowed && e.body.approvals && e.body.action.kind === 'payment'); +const fara = resemneaza(exp, iAprobat, (b) => { delete b.approvals; }); +cer(!verifyLedger(fara, { policy, policyHash, maxTime: t + 300 }).ok, '5. CONTROL: aprobarile scoase dintr-o intrare permisa, registrul re-semnat de agent -> prins (decizie falsa)'); +const forjat = resemneaza(exp, iAprobat, (b) => { const s = Buffer.from(b.approvals[1].signature, 'base64'); s[10] ^= 1; b.approvals[1].signature = s.toString('base64'); }); +cer(!verifyLedger(forjat, { policy, policyHash, maxTime: t + 300 }).ok, '5. CONTROL: o semnatura de aprobare falsificata, registrul re-semnat -> prins'); +const iRefuzat = exp.entries.findIndex((e) => !e.body.decision.allowed && /0 of 2/.test(e.body.decision.reason)); +const mintit = resemneaza(exp, iRefuzat, (b) => { b.decision = { allowed: true, reason: 'approved' }; }); +cer(!verifyLedger(mintit, { policy, policyHash, maxTime: t + 300 }).ok, '5. CONTROL: un refuz rescris "permis", re-semnat -> prins'); +// adversarul pune aceeasi aprobare de DOUA ori in acelasi registru (a doua intrare, re-semnata): nonce-ul o prinde +const iDubla = exp.entries.findIndex((e) => e.body.decision.allowed && e.body.approvals && e.body.action.amount === '700'); +const dubla = resemneaza(exp, iDubla + 1, (b) => { b.action = { ...P700, at: b.at }; b.approvals = pentruL; b.decision = { allowed: true, reason: 'under the limit; approved by 2 of 2' }; delete b.revocation; }); +const vDubla = verifyLedger(dubla, { policy, policyHash, maxTime: t + 300 }); +cer(!vDubla.ok && vDubla.seq === iDubla + 1 && /0 of 2 valid approvals/.test(vDubla.error), `5. ATAC: aceleasi aprobari folosite a doua oara in acelasi registru, re-semnat -> prins la seq ${vDubla.seq} (nonce folosit: 0 aprobari numarate)`); + +// 5b. repornirea agentului: registrul reluat tine minte nonce-urile deja folosite +const LR = resumeLedger({ identity: agent, policy, ledger: L.export(), now }); +const rR = LR.record(P500, { approvals: aprobariBune }); +cer(!rR.allowed && rR.rejectedApprovals.every((m) => /nonce already used/.test(m)), '5b. dupa repornire, aprobarile deja folosite inainte raman folosite (nonce-urile se refac din registru)'); + +// 6. revocarea +t = 2000; +cer(!!arunca(() => L.recordRevocation(revoke({ owner: H1, agentId: agent.agentId, policyHash, revokedAt: t }))), '6. CONTROL: o revocare semnata de un aprobator (nu de proprietar) e refuzata de registru'); +const R = revoke({ owner: O, agentId: agent.agentId, policyHash, revokedAt: t, reason: 'the owner stops the agent' }); +L.recordRevocation(R); +t = 2010; +const r7 = L.record({ kind: 'payment', to: '0xbbbb', amount: '10' }); +cer(!r7.allowed && /revoked/.test(r7.reason), `6. dupa revocare, orice actiune e refuzata (${r7.reason})`); +cer(!L.record(P500, { approvals: [ap(H1, P500), ap(H2, P500)] }).allowed, '6. CONTROL: nici doua aprobari valide nu trec peste o revocare'); +cer(verifyLedger(L.export(), { policy, policyHash, maxTime: t + 300 }).ok, '6. registrul cu revocarea inregistrata verifica'); +const LRv = resumeLedger({ identity: agent, policy, ledger: L.export(), now }); +cer(!LRv.record({ kind: 'payment', to: '0xbbbb', amount: '10' }).allowed, '6. dupa repornire, revocarea din registru ramane in vigoare'); + +// 7. agentul isi omite revocarea: un registru paralel, fara ea, cu actiuni permise dupa revokedAt +t = 1000; +const F = openLedger({ identity: agent, policy, policyHash, now }); +F.record({ kind: 'payment', to: '0xbbbb', amount: '50' }); +t = 2010; +F.record({ kind: 'payment', to: '0xbbbb', amount: '60' }); +const vFara = verifyLedger(F.export(), { policy, policyHash, maxTime: t + 300 }); +cer(vFara.ok && vFara.revocations.given === 0, '7. fara revocarile proprietarului, verificatorul NU poate vedea revocarea omisa, si spune ca a judecat 0 revocari'); +const vCu = verifyLedger(F.export(), { policy, policyHash, maxTime: t + 300, revocations: [R] }); +cer(!vCu.ok && /revoked/.test(vCu.error), `7. cu revocarea proprietarului data SEPARAT, actiunea de dupa ea e prinsa (${vCu.error})`); +const RX = revoke({ owner: X, agentId: agent.agentId, policyHash, revokedAt: 1500 }); +const vX = verifyLedger(F.export(), { policy, policyHash, maxTime: t + 300, revocations: [RX] }); +cer(vX.ok && vX.revocations.rejected === 1, '7. CONTROL: o "revocare" semnata de un strain nu opreste agentul si e numarata respinsa'); + +console.log(`\nagent-aprobare: ${ok}/${ok + rau} ${rau ? 'CU ESECURI' : 'cum trebuia'}`); +process.exitCode = rau ? 1 : 0; diff --git a/agents/proba-agent-cli.mjs b/agents/proba-agent-cli.mjs new file mode 100644 index 0000000..c718e92 --- /dev/null +++ b/agents/proba-agent-cli.mjs @@ -0,0 +1,100 @@ +// Proba liniei de comanda a agentilor (agent-cli.mjs), rulata ca un strain: numai fisiere si procese, fara importul modulelor. Fiecare +// drum are perechea lui negativa. Offline; chei ML-DSA-65 reale, generate aici si sterse la sfarsit. +// node proba-agent-cli.mjs iesire 0 = toate cum trebuia +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(AICI, 'agent-cli.mjs'); +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-agent-cli-')); +const f = (n) => path.join(T, n); +let ok = 0, rau = 0; +const cer = (c, ce) => { console.log((c ? ' OK ' : ' RAU ') + ce); c ? ok++ : rau++; }; +const run = (...a) => { const r = spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', timeout: 60000 }); return { cod: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; }; +const scrie = (n, o) => { fs.writeFileSync(f(n), JSON.stringify(o)); return f(n); }; +// iesirea unei comenzi citita ca JSON; o comanda cazuta (iesire goala) da {} si proba iese ROSIE, nu se opreste (STRICAT) +const J = (s) => { try { return JSON.parse(s); } catch { return {}; } }; + +try { + // identitati + const ra = run('id', '--out', f('agent')); + const [h1, h2, h3, own] = ['h1', 'h2', 'h3', 'own'].map((n) => run('human', '--out', f(n))); + cer(ra.cod === 0 && /^aere-agent:[0-9a-f]{40}$/.test(ra.out) && [h1, h2, h3, own].every((r) => r.cod === 0 && /^aere-human:[0-9a-f]{40}$/.test(r.out)), '1. id si human scriu cheile si tiparesc numai id-ul'); + cer(![ra, h1].some((r) => /PRIVATE KEY/.test(r.out + r.err)), '1. nicio cheie privata pe iesire'); + cer(run('id', '--out', f('agent')).cod === 2, '1. CONTROL: o cheie existenta nu se suprascrie (cod 2)'); + if (process.platform !== 'win32') cer((fs.statSync(f('agent/agent.key.pem')).mode & 0o777) === 0o600, '1. cheia privata are drepturile 0600'); + + // politica: 1000 pe ora, aprobare 2 din 3 peste 100, proprietar + const spec = scrie('spec.json', { agentId: ra.out, spend: { amount: '1000', windowSeconds: 3600 }, recipients: ['0xbbbb'], + approval: { approvers: [h1.out, h2.out, h3.out], threshold: 2, above: '100' }, owner: own.out }); + const rp = run('policy', '--spec', spec, '--out', f('p.json')); + const P = JSON.parse(fs.readFileSync(f('p.json'), 'utf8')); + cer(rp.cod === 0 && rp.out === P.policyHash && /^0x[0-9a-f]{64}$/.test(P.policyHash), '2. policy scrie politica normala si hash-ul ei'); + + // check + const a50 = scrie('a50.json', { kind: 'payment', to: '0xbbbb', amount: '50' }); + const a500 = scrie('a500.json', { kind: 'payment', to: '0xbbbb', amount: '500' }); + cer(run('check', '--policy', f('p.json'), '--action', a50).cod === 0, '3. check: 50 -> permis (0)'); + cer(run('check', '--policy', f('p.json'), '--action', scrie('a2000.json', { kind: 'payment', to: '0xbbbb', amount: '2000' })).cod === 3, '3. CONTROL: check 2000 peste limita -> refuzat (3)'); + + // record, cu reluarea registrului din fisier + const L = f('ledger.json'); + const r1 = run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', L, '--action', a50); + cer(r1.cod === 0 && fs.existsSync(L) && J(r1.out).seq === 0, '4. record 50 -> permis, registrul creat (seq 0)'); + const r2 = run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', L, '--action', a500); + cer(r2.cod === 3 && /0 of 2/.test(J(r2.out).reason) && J(r2.out).seq === 1, '4. CONTROL: record 500 fara aprobari -> refuzat (3), scris ca refuz la seq 1'); + // aprobari + const ap1 = run('approve', '--key', f('h1/human.key.pem'), '--policy', f('p.json'), '--action', a500, '--out', f('ap1.json')); + const ap2 = run('approve', '--key', f('h2/human.key.pem'), '--policy', f('p.json'), '--action', a500, '--out', f('ap2.json')); + cer(ap1.cod === 0 && ap2.cod === 0, '5. doi aprobatori numiti semneaza aprobarea pentru 500'); + cer(run('approve', '--key', f('own/human.key.pem'), '--policy', f('p.json'), '--action', a500, '--out', f('ap-own.json')).cod === 1, '5. CONTROL: proprietarul, care nu e aprobator, nu poate aproba (1)'); + const r3 = run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', L, '--action', a500, '--approvals', `${f('ap1.json')},${f('ap2.json')}`); + cer(r3.cod === 0 && /approved by 2 of 2/.test(J(r3.out).reason), '5. record 500 cu cele doua aprobari -> permis'); + const r4 = run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', L, '--action', a500, '--approvals', `${f('ap1.json')},${f('ap2.json')}`); + cer(r4.cod === 3 && (J(r4.out).rejectedApprovals || [0]).every((m) => /nonce already used/.test(m)), '5. CONTROL: aceleasi aprobari a doua oara (registrul reluat din fisier) -> respinse'); + const a600 = scrie('a600.json', { kind: 'payment', to: '0xbbbb', amount: '600' }); + const ap3 = run('approve', '--key', f('h1/human.key.pem'), '--policy', f('p.json'), '--action', a600, '--out', f('ap3.json')); + const ap4 = run('approve', '--key', f('h3/human.key.pem'), '--policy', f('p.json'), '--action', a600, '--out', f('ap4.json')); + const r5 = run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', L, '--action', a600, '--approvals', `${f('ap3.json')},${f('ap4.json')}`); + cer(ap3.cod === 0 && ap4.cod === 0 && r5.cod === 3 && /over the limit/.test(J(r5.out).reason), '5. aprobarea nu scuteste de limita, si cheltuiala se tine peste reluari: 50+500+600 > 1000 -> refuzat'); + + // verificarea ca un strain + const v = run('verify', '--ledger', L, '--policy', f('p.json')); + cer(v.cod === 0 && J(v.out).spent === '550' && J(v.out).humanApproved === 1, '6. verify: registrul verifica, cheltuit 550, o actiune aprobata de oameni'); + const atins = JSON.parse(fs.readFileSync(L, 'utf8')); atins.entries[0].body.action.amount = '1'; + cer(run('verify', '--ledger', scrie('atins.json', atins), '--policy', f('p.json')).cod === 1, '6. CONTROL: un registru atins -> 1'); + const lax = { policy: { ...P.policy, spend: { ...P.policy.spend, amount: '1000000' } }, policyHash: P.policyHash }; + const vl = run('verify', '--ledger', L, '--policy', scrie('lax.json', lax)); + cer(vl.cod === 1 && /does not hash to the pinned policyHash/.test(vl.out), '6. CONTROL: o politica laxa purtand hash-ul celei reale -> 1'); + + // revocarea + const t0 = Math.floor(Date.now() / 1000) - 3000; + const rv = run('revoke', '--key', f('own/human.key.pem'), '--policy', f('p.json'), '--at', String(t0), '--reason', 'test', '--out', f('rv.json')); + cer(rv.cod === 0 && fs.existsSync(f('rv.json')), '7. revoke de catre proprietar -> revocare scrisa'); + cer(run('revoke', '--key', f('h1/human.key.pem'), '--policy', f('p.json'), '--out', f('rv-rau.json')).cod === 1, '7. CONTROL: revoke cu cheia unui aprobator -> 1'); + const vr = run('verify', '--ledger', L, '--policy', f('p.json'), '--revocations', f('rv.json')); + cer(vr.cod === 1 && /revoked/.test(vr.out), '7. verify cu revocarea proprietarului (de dinaintea platilor) -> 1'); + + // echivocarea: doua continuari diferite ale aceluiasi registru + const A = f('ramura-a.json'), B = f('ramura-b.json'); + fs.copyFileSync(L, A); fs.copyFileSync(L, B); + const a10 = scrie('a10.json', { kind: 'payment', to: '0xbbbb', amount: '10' }), a20 = scrie('a20.json', { kind: 'payment', to: '0xbbbb', amount: '20' }); + run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', A, '--action', a10); + run('record', '--key', f('agent/agent.key.pem'), '--policy', f('p.json'), '--ledger', B, '--action', a20); + const e = run('equivocation', '--a', A, '--b', B, '--out', f('dovada.json')); + cer(e.cod === 0 && J(e.out).found === true && run('verify-equivocation', '--proof', f('dovada.json')).cod === 0, '8. doua ramuri ale aceluiasi registru -> dovada de echivocare, verificata'); + cer(run('equivocation', '--a', A, '--b', L).cod === 1, '8. CONTROL: un registru si prefixul lui nu sunt echivocare (1)'); + const d = JSON.parse(fs.readFileSync(f('dovada.json'), 'utf8')); d.b.body.action.amount = '21'; + cer(run('verify-equivocation', '--proof', scrie('dovada-rea.json', d)).cod === 1, '8. CONTROL: o dovada cu o intrare nesemnata -> 1'); + + // folosire gresita + cer(run('nimic').cod === 2 && run('verify', '--ledger', L).cod === 2, '9. CONTROL: comanda necunoscuta sau argument lipsa -> 2'); +} catch (e) { + // un fisier care trebuia scris de o comanda si lipseste: drumul s-a rupt, deci proba e ROSIE cu motivul, nu tacuta + cer(false, `proba s-a oprit la un pas al carui fisier lipseste: ${e.message}`); +} finally { fs.rmSync(T, { recursive: true, force: true }); } +console.log(`\nagent-cli: ${ok}/${ok + rau} ${rau ? 'CU ESECURI' : 'cum trebuia'}`); +process.exitCode = rau ? 1 : 0; diff --git a/agents/proba-agent-ledger.mjs b/agents/proba-agent-ledger.mjs new file mode 100644 index 0000000..522ba91 --- /dev/null +++ b/agents/proba-agent-ledger.mjs @@ -0,0 +1,214 @@ +// Proba registrului de agent (agent-ledger.mjs): identitate PQ + registru semnat, cu limita impusa pe sesiune si verificator care nu se +// increde in registru. Fiecare afirmatie are perechea ei negativa. Offline, deterministic (ceas injectat). Numai Node 24 (ML-DSA). +// Forma 2 (2026-09-29, B-17): politica primita de verificator se recalculeaza la hash, momentul intrarilor e marginit si de jos (la +// scriere de ceasul registrului; la verificare de ancore si notBefore, cand exista un martor), si fiecare atac e reprodus inainte. +import crypto from 'node:crypto'; +import { definePolicy } from './agent-policy.mjs'; +import { newAgentIdentity, agentIdFromKey, openLedger, resumeLedger, verifyLedger, findEquivocation, verifyEquivocation, sessionId, canonical } from './agent-ledger.mjs'; + +let ok = 0, rau = 0; const linii = []; +const cer = (c, ce) => { linii.push((c ? 'OK ' : 'RAU ') + ce); c ? ok++ : rau++; }; +const arunca = (fn) => { try { fn(); return null; } catch (e) { return e.message; } }; + +// identitate legata de cheie +const id = newAgentIdentity(); +cer(id.agentId.startsWith('aere-agent:') && id.agentId === agentIdFromKey(id.publicKey), '1. agentId derivat din cheia publica'); +const id2 = newAgentIdentity(); +cer(id.agentId !== id2.agentId, '1. CONTROL: alta cheie -> alt agentId'); + +// politica: 100 pe fereastra de 3600 s, un destinatar, o unealta +const { policy, policyHash } = definePolicy({ agentId: id.agentId, spend: { amount: '100', windowSeconds: 3600 }, recipients: ['0xbbbb'], tools: ['retrieval'] }); + +// un ceas injectat: sesiunea incepe la t=1000 +let t = 1000; const now = () => t; +const L = openLedger({ identity: id, policy, policyHash, now }); +cer(L.session === sessionId(id.agentId, policyHash) && /^[0-9a-f]{32}$/.test(L.session), `1. sesiunea registrului e derivata din agent si politica (${L.session.slice(0, 8)}...)`); +cer(openLedger({ identity: id, policy, policyHash, now }).session === L.session, '1. un al doilea registru al aceluiasi agent sub aceeasi politica are ACEEASI sesiune (e o ramura, nu alt registru)'); +cer(sessionId(id.agentId, definePolicy({ agentId: id.agentId, tools: ['x'] }).policyHash) !== L.session, '1. CONTROL: sub alta politica, alta sesiune'); + +// limita impusa PE SESIUNE: trei plati de 40 in aceeasi fereastra - a treia depaseste 100 +const a = L.record({ kind: 'payment', to: '0xbbbb', amount: '40' }); +const b = L.record({ kind: 'payment', to: '0xbbbb', amount: '40' }); +const c = L.record({ kind: 'payment', to: '0xbbbb', amount: '40' }); +cer(a.allowed && b.allowed && !c.allowed, `2. limita pe sesiune: 40+40 permise, al treilea 40 REFUZAT (${c.reason})`); +cer(/over the limit/.test(c.reason), '2. motivul refuzului e depasirea limitei'); + +// fereastra se roteste: dupa 3600 s cheltuiala veche nu mai conteaza +t = 1000 + 3601; +const d = L.record({ kind: 'payment', to: '0xbbbb', amount: '90' }); +cer(d.allowed, '3. dupa fereastra, o plata noua de 90 e permisa (cheltuiala veche a expirat)'); + +// destinatar nepermis, unealta nepermisa, unealta permisa +cer(!L.record({ kind: 'payment', to: '0xcccc', amount: '1' }).allowed, '4. CONTROL: destinatar nepermis -> refuzat'); +cer(!L.record({ kind: 'tool', tool: 'shell' }).allowed, '4. CONTROL: unealta nepermisa -> refuzat'); +cer(L.record({ kind: 'tool', tool: 'retrieval' }).allowed, '4. unealta permisa -> allowed'); + +// provenienta legata (proof-of-ai) +const provenance = { model: { name: 'example-model', version: '1.0' }, prompt: 'summarize', tool: 'retrieval' }; +const withProv = L.record({ kind: 'tool', tool: 'retrieval' }, { provenance }); +cer(withProv.entry.body.provenance && withProv.entry.body.provenance.length === 64, '5. provenienta se leaga prin hash in intrare'); + +// --- verificatorul, care NU se increde in registru --- +const reg = L.export(); +const v = verifyLedger(reg, { policy, policyHash, maxTime: t + 300 }); +cer(v.ok && v.seq === reg.entries.length, `6. registrul intreg se verifica (${v.seq} intrari, plati permise ${v.allowedPayments}, cheltuit ${v.spent})`); +cer(v.spent === '170', `6. cheltuiala permisa re-derivata = 40+40+90 = 170 (${v.spent})`); +cer(/none/.test(v.time.lowerBound), `6. fara martor, rezultatul spune ca timpul nu e marginit de jos (${v.time.lowerBound})`); +cer(verifyLedger(reg, { policy, maxTime: t + 300 }).ok, '6. fara hash fixat, verificatorul il recalculeaza din politica si il compara cu al registrului'); + +// CONTROL: suma unei intrari schimbata -> semnatura pica +const t1 = JSON.parse(JSON.stringify(reg)); t1.entries[0].body.action.amount = '39'; +const vt1 = verifyLedger(t1, { policy, policyHash, maxTime: t + 300 }); +cer(!vt1.ok && vt1.seq === 0 && /signature/.test(vt1.error), `7. CONTROL: suma schimbata la seq 0 -> prinsa (${vt1.error})`); + +// re-semnarea de catre adversar: ARE cheia agentului, deci isi poate rescrie si re-lega registrul +function resemneaza(r0, i, schimba) { + const r = JSON.parse(JSON.stringify(r0)); schimba(r); + let prev = i ? r.entries[i - 1].hash : '0'.repeat(64); + for (let k = i; k < r.entries.length; k++) { + const e = r.entries[k]; e.seq = k; e.body.seq = k; e.prev = prev; + e.signature = crypto.sign(null, Buffer.from(`${k}|${prev}|${canonical(e.body)}`, 'utf8'), id.privateKey).toString('base64'); + e.hash = crypto.createHash('sha256').update(`${k}|${prev}|${canonical(e.body)}|${e.signature}`).digest('hex'); + prev = e.hash; + } + return r; +} +// CONTROL: un "allowed" mincinos peste refuz, RE-SEMNAT cu cheia agentului -> verificatorul re-ruleaza politica si prinde decizia falsa +const iRef = reg.entries.findIndex((e) => e.body.action.kind === 'payment' && !e.body.decision.allowed); +const t2 = resemneaza(reg, iRef, (r) => { r.entries[iRef].body.decision = { allowed: true, reason: 'under the limit' }; }); +const vt2 = verifyLedger(t2, { policy, policyHash, maxTime: t + 300 }); +cer(!vt2.ok && vt2.seq === iRef && /false decision/.test(vt2.error), `8. CONTROL: "allowed" mincinos peste refuz, re-semnat corect -> prins (${vt2.error})`); + +// CONTROL: o intrare stearsa -> lantul nu mai leaga +const t3 = JSON.parse(JSON.stringify(reg)); t3.entries.splice(1, 1); t3.entries.forEach((e, i) => { e.seq = i; }); +const vt3 = verifyLedger(t3, { policy, policyHash, maxTime: t + 300 }); +cer(!vt3.ok && /link|header/.test(vt3.error), `9. CONTROL: intrare stearsa -> prinsa (${vt3.error})`); + +// CONTROL: registrul altui agent judecat cu politica noastra +const idX = newAgentIdentity(); +const { policy: polX, policyHash: phX } = definePolicy({ agentId: idX.agentId, spend: { amount: '100', windowSeconds: 3600 }, recipients: ['0xbbbb'] }); +const LX = openLedger({ identity: idX, policy: polX, policyHash: phX, now: () => 1000 }); +LX.record({ kind: 'payment', to: '0xbbbb', amount: '10' }); +const regX = LX.export(); +const vX = verifyLedger(regX, { policy, policyHash, maxTime: 2000 }); +cer(!vX.ok && /another policy|another agent|agentId/.test(vX.error), `10. CONTROL: registru al altui agent judecat cu politica noastra -> refuzat (${vX.error})`); +cer(verifyLedger(regX, { policy: polX, policyHash: phX, maxTime: 2000 }).ok, '10. registrul altui agent cu politica LUI se verifica (metoda e buna)'); + +// CONTROL: cheia publica inlocuita in registru (identitate falsa) -> agentId nu mai deriva din ea +const t4 = JSON.parse(JSON.stringify(reg)); t4.publicKeyPem = idX.publicKey.export({ type: 'spki', format: 'pem' }); +const vt4 = verifyLedger(t4, { policy, policyHash, maxTime: t + 300 }); +cer(!vt4.ok && /false identity/.test(vt4.error), `11. CONTROL: cheia publica inlocuita -> identitate falsa prinsa (${vt4.error})`); + +// --- atacurile gasite de revizuirea adversariala (2026-09-27), fiecare reprodus inainte de reparatie --- +{ + const idA = newAgentIdentity(); + const { policy: pA, policyHash: phA } = definePolicy({ agentId: idA.agentId, spend: { amount: '100', windowSeconds: 3600 }, recipients: ['0xbbbb'] }); + // 12: suma negativa urmata de una uriasa (inainte: amandoua allowed, verificatorul "ok") + const LA = openLedger({ identity: idA, policy: pA, policyHash: phA, now: () => 1000 }); + const neg = LA.record({ kind: 'payment', to: '0xbbbb', amount: '-1000000' }); + const uri = LA.record({ kind: 'payment', to: '0xbbbb', amount: '1000000' }); + cer(!neg.allowed && /invalid amount/.test(neg.reason) && !uri.allowed, `12. ATAC: plata negativa REFUZATA (${neg.reason.slice(0, 60)}), iar 1.000.000 dupa ea refuzata pe limita`); + cer(!LA.record({ kind: 'payment', to: '0xbbbb', amount: '0' }).allowed && !LA.record({ kind: 'payment', to: '0xbbbb', amount: '1.5' }).allowed, '12. suma zero si suma zecimala refuzate'); + cer(LA.record({ kind: 'payment', to: '0xbbbb', amount: '60' }).allowed, '12. CONTROL: o plata valida sub limita tot trece (reparatia nu blocheaza tot)'); + // 13: timpul inapoi si timpul in viitorul ceasului registrului -> refuzate la scriere + cer(/backwards/.test(arunca(() => LA.record({ kind: 'payment', to: '0xbbbb', amount: '1' }, { at: 999 })) || ''), '13. ATAC: timp inapoi refuzat la scriere'); + cer(/ahead of the ledger clock/.test(arunca(() => LA.record({ kind: 'payment', to: '0xbbbb', amount: '100' }, { at: 1000 + 3601 })) || ''), '13. ATAC: salt de fereastra (at = acum + 3601) refuzat la scriere'); + // 14: un registru "din viitor" produs cu un ceas FALSIFICAT al agentului: se scrie, dar verificatorul, pe ceasul LUI, il refuza + let tFals = 1000; const LF = openLedger({ identity: idA, policy: pA, policyHash: phA, now: () => tFals }); + let permise = 0; for (let i = 0; i < 10; i++) { tFals = 1000 + i * 3601; if (LF.record({ kind: 'payment', to: '0xbbbb', amount: '100' }).allowed) permise++; } + const vF = verifyLedger(LF.export(), { policy: pA, policyHash: phA, maxTime: 1000 + 300 }); + cer(permise === 10 && !vF.ok && /future/.test(vF.error), `14. ATAC: 10 x 100 "in 10 ore" cu ceasul agentului falsificat -> verificatorul (ceasul lui) refuza la seq ${vF.seq}`); + const vF2 = verifyLedger(LF.export(), { policy: pA, policyHash: phA, maxTime: 1000 + 10 * 3601 }); + cer(vF2.ok, '14. CONTROL: acelasi registru, verificat la un moment care chiar e dupa cele 10 ore, e valid (timpul real a trecut)'); +} + +// --- revizuirea adversariala din 2026-09-29 (B-17), fiecare atac reprodus pe forma 1 inainte de reparatie --- +{ + const idB = newAgentIdentity(); + const real = definePolicy({ agentId: idB.agentId, spend: { amount: '100', windowSeconds: 3600 }, recipients: ['0xbbbb'] }); + const lax = definePolicy({ agentId: idB.agentId, spend: { amount: '1000000', windowSeconds: 3600 }, recipients: ['0xbbbb'] }); + // 15: politica LAXA sub hash-ul celei reale (forma 1: plata 5000 permisa, verificator "ok" sub o limita reala de 100) + cer(/does not hash/.test(arunca(() => openLedger({ identity: idB, policy: lax.policy, policyHash: real.policyHash, now: () => 1000 })) || ''), '15. ATAC: registrul cinstit refuza sa se deschida cu o politica care nu da hash-ul dat'); + // adversarul ocoleste biblioteca: scrie registrul sub politica laxa si ii pune in antet si in intrari hash-ul celei reale + const Llax = openLedger({ identity: idB, policy: lax.policy, now: () => 1000 }); + Llax.record({ kind: 'payment', to: '0xbbbb', amount: '5000' }); + // (si sesiunea, derivata din politica: adversarul o rescrie si pe ea, ca sa ajunga la judecata politicii) + const sReal = sessionId(idB.agentId, real.policyHash); + const falsificat = resemneaza(Llax.export(), 0, (r) => { r.policyHash = real.policyHash; r.session = sReal; r.entries.forEach((e) => { e.body.policyHash = real.policyHash; e.body.session = sReal; }); }); + // resemneaza foloseste cheia lui id; aici adversarul e idB, deci re-semnez cu cheia lui + for (let k = 0, prev = '0'.repeat(64); k < falsificat.entries.length; k++) { + const e = falsificat.entries[k]; e.prev = prev; + e.signature = crypto.sign(null, Buffer.from(`${k}|${prev}|${canonical(e.body)}`, 'utf8'), idB.privateKey).toString('base64'); + e.hash = crypto.createHash('sha256').update(`${k}|${prev}|${canonical(e.body)}|${e.signature}`).digest('hex'); prev = e.hash; + } + const vLax = verifyLedger(falsificat, { policy: lax.policy, policyHash: real.policyHash, maxTime: 2000 }); + cer(!vLax.ok && /does not hash to the pinned policyHash/.test(vLax.error), `15. ATAC: politica laxa data verificatorului cu hash-ul celei reale -> refuzata (${vLax.error})`); + const vReal = verifyLedger(falsificat, { policy: real.policy, policyHash: real.policyHash, maxTime: 2000 }); + cer(!vReal.ok && /false decision/.test(vReal.error), '15. cu politica reala, plata de 5000 e prinsa ca decizie falsa (limita 100)'); + + // 16: antedatare (forma 1: 10 plati de 100 facute in aceeasi secunda, declarate in 10 ferestre trecute, verificator "ok") + const acum = 100000; + const Lb = openLedger({ identity: idB, policy: real.policy, now: () => acum }); + cer(/behind the ledger clock/.test(arunca(() => Lb.record({ kind: 'payment', to: '0xbbbb', amount: '100' }, { at: acum - 36010 })) || ''), '16. ATAC: registrul cinstit refuza la scriere o intrare antedatata cu peste 300 s'); + cer(Lb.record({ kind: 'payment', to: '0xbbbb', amount: '100' }, { at: acum - 200 }).allowed, '16. CONTROL: o intrare in toleranta (200 s in urma) se scrie'); + // adversarul ocoleste biblioteca: un ceas mincinos care merge in trecut pe masura ce scrie + let tb = acum - 36010; const Lant = openLedger({ identity: idB, policy: real.policy, now: () => tb }); + let permise = 0; for (let i = 0; i < 10; i++) { tb = acum - 36010 + i * 3601; if (Lant.record({ kind: 'payment', to: '0xbbbb', amount: '100' }).allowed) permise++; } + const antedatat = Lant.export(); + const vFaraMartor = verifyLedger(antedatat, { policy: real.policy, maxTime: acum + 300 }); + cer(permise === 10 && vFaraMartor.ok && /none/.test(vFaraMartor.time.lowerBound), '16. fara martor, antedatarea NU se poate vedea, si rezultatul o spune (lowerBound: none)'); + // martorul: capul registrului vazut la 'acum - 3600' (de pilda notarizat), deci intrarile de dupa el nu pot declara mai devreme + const cap = { seq: 4, hash: antedatat.entries[4].hash, at: acum - 3600 }; + const vAncorat = verifyLedger(antedatat, { policy: real.policy, maxTime: acum + 300, anchors: [cap] }); + cer(!vAncorat.ok && vAncorat.seq === 5 && /backdated/.test(vAncorat.error), `16. ATAC: cu un cap ancorat la un martor, prima intrare antedatata de dupa el e prinsa (seq ${vAncorat.seq})`); + const vStart = verifyLedger(antedatat, { policy: real.policy, maxTime: acum + 300, notBefore: acum - 7200 }); + cer(!vStart.ok && vStart.seq === 0 && /witnessed start/.test(vStart.error), '16. cu inceputul sesiunii vazut de martor (notBefore), intrarile de dinainte sunt prinse'); + // controlul pozitiv al ancorelor: un registru cinstit, ancorat la fiecare 3 intrari cu momentul scrierii, trece + let tc = acum; const Lc = openLedger({ identity: idB, policy: real.policy, now: () => tc }); + const ancore = []; + for (let i = 0; i < 6; i++) { tc = acum + i * 1200; const r = Lc.record({ kind: 'payment', to: '0xbbbb', amount: '10' }); if (i % 3 === 2) ancore.push({ seq: r.entry.seq, hash: r.entry.hash, at: tc + 5 }); } + cer(verifyLedger(Lc.export(), { policy: real.policy, maxTime: tc + 300, anchors: ancore, notBefore: acum }).ok, '16. CONTROL: un registru cinstit, ancorat, trece cu martorii lui'); + // o ancora a altei ramuri (acelasi agent, alta istorie) nu se potriveste + const alta = openLedger({ identity: idB, policy: real.policy, now: () => acum }); alta.record({ kind: 'payment', to: '0xbbbb', amount: '11' }); + const vRamura = verifyLedger(Lc.export(), { policy: real.policy, maxTime: tc + 300, anchors: [{ seq: 0, hash: alta.export().entries[0].hash, at: acum }] }); + cer(!vRamura.ok && /another branch/.test(vRamura.error), '16. CONTROL: o ancora a altei istorii nu se potriveste cu registrul (alta ramura sau alt registru)'); + + // 17: intrarea poarta sesiunea registrului; o intrare mutata din alt registru nu se potriveste + const s2 = resemneaza(reg, 0, (r) => { r.entries[0].body.session = 'f'.repeat(32); }); + const vS = verifyLedger(s2, { policy, policyHash, maxTime: t + 300 }); + cer(!vS.ok && vS.seq === 0 && /session/.test(vS.error), `17. CONTROL: o intrare care numeste alta sesiune decat registrul -> prinsa (${vS.error})`); + const s3 = resemneaza(reg, 0, (r) => { r.session = 'f'.repeat(32); r.entries.forEach((e) => { e.body.session = 'f'.repeat(32); }); }); + const vS3 = verifyLedger(s3, { policy, policyHash, maxTime: t + 300 }); + cer(!vS3.ok && /derived/.test(vS3.error), `17. ATAC: un registru cu o sesiune aleasa de agent (nu cea derivata), re-semnat -> refuzat (${vS3.error})`); + + // 18: cheltuiala dubla prin "al doilea registru" = o ramura; doua intrari semnate de agent la aceeasi pozitie sunt dovada + const idC = newAgentIdentity(); + const pc = definePolicy({ agentId: idC.agentId, spend: { amount: '100', windowSeconds: 3600 }, recipients: ['0xbbbb'] }); + const R1 = openLedger({ identity: idC, policy: pc.policy, now: () => 5000 }); + const R2 = openLedger({ identity: idC, policy: pc.policy, now: () => 5000 }); + const d1 = R1.record({ kind: 'payment', to: '0xbbbb', amount: '100' }), d2 = R2.record({ kind: 'payment', to: '0xbbbb', amount: '99' }); + const v1 = verifyLedger(R1.export(), { policy: pc.policy, maxTime: 5300 }), v2 = verifyLedger(R2.export(), { policy: pc.policy, maxTime: 5300 }); + cer(d1.allowed && d2.allowed && v1.ok && v2.ok, '18. ATAC: doua registre ale aceluiasi agent, 100 + 99 sub o limita de 100; fiecare, SINGUR, verifica (cine vede o ramura nu o vede pe cealalta)'); + const dov = findEquivocation(R1.export(), R2.export()); + cer(!!dov && dov.seq === 0 && verifyEquivocation(dov).ok, '18. dar impreuna sunt o dovada de echivocare, verificabila de oricine numai cu cheia publica'); + const dovRau = JSON.parse(JSON.stringify(dov)); dovRau.b.body.action.amount = '98'; + cer(!verifyEquivocation(dovRau).ok, '18. CONTROL: o "dovada" cu o intrare nesemnata de agent (continut schimbat) e respinsa'); + const dovX = { ...JSON.parse(JSON.stringify(dov)), publicKeyPem: idX.publicKey.export({ type: 'spki', format: 'pem' }) }; + cer(!verifyEquivocation(dovX).ok, '18. CONTROL: o "dovada" care pune alta cheie publica e respinsa (agentId nu deriva din ea)'); + cer(findEquivocation(R1.export(), R1.export()) === null, '18. CONTROL: acelasi registru cu el insusi nu e echivocare'); + const R1b = resumeLedger({ identity: idC, policy: pc.policy, ledger: R1.export(), now: () => 5010 }); + R1b.record({ kind: 'tool', tool: 'x' }); + cer(findEquivocation(R1.export(), R1b.export()) === null, '18. CONTROL: un registru si continuarea lui (prefix comun) nu sunt echivocare'); + + // 19: repornirea: resumeLedger continua starea (cheltuiala din fereastra, nonce-urile), si refuza un registru care nu verifica + cer(!R1b.record({ kind: 'payment', to: '0xbbbb', amount: '1' }).allowed, '19. dupa repornire, cheltuiala din fereastra e pastrata: 100 deja cheltuit, inca 1 -> refuzat'); + const R1c = resumeLedger({ identity: idC, policy: pc.policy, ledger: R1b.export(), now: () => 5000 + 3601 }); + cer(R1c.record({ kind: 'payment', to: '0xbbbb', amount: '100' }).allowed && verifyLedger(R1c.export(), { policy: pc.policy, maxTime: 9000 }).ok, '19. dupa fereastra, registrul reluat permite iar si ramane verificabil de la capat'); + const stricat = JSON.parse(JSON.stringify(R1b.export())); stricat.entries[0].body.action.amount = '1'; + cer(/does not verify/.test(arunca(() => resumeLedger({ identity: idC, policy: pc.policy, ledger: stricat, now: () => 5010 })) || ''), '19. CONTROL: un registru atins nu se poate relua'); + cer(/another agent/.test(arunca(() => resumeLedger({ identity: idC, policy: pc.policy, ledger: regX, now: () => 5010 })) || ''), '19. CONTROL: registrul altui agent nu se poate relua'); +} + +for (const l of linii) console.log(l); +console.log(`agent-ledger: ${ok}/${ok + rau} cum trebuia`); +process.exitCode = rau ? 1 : 0; diff --git a/agents/proba-agent-policy.mjs b/agents/proba-agent-policy.mjs new file mode 100644 index 0000000..5a05d16 --- /dev/null +++ b/agents/proba-agent-policy.mjs @@ -0,0 +1,77 @@ +'use strict'; +// Proba motorului de politica al agentilor (B2 m2), cu CONTROALE NEGATIVE: sub limita permis, peste limita refuzat, unealta/destinatar +// nepermis refuzat, iar plicul de decizie verifica sub AIP-23 si leaga policyHash (o politica schimbata -> alt hash, decizia nu se +// poate atribui altei politici). 2026-09-29 (B-17): hash-ul unei politici PRIMITE se recalculeaza (o politica laxa nu poate purta +// hash-ul celei reale), politica se valideaza, un alt activ decat al limitei e refuzat, actionHash-ul plicului e canonic. +// node proba-agent-policy.mjs -> 0 toate cum trebuia, 1 altfel, 2 fara verificatorul AIP-23 (partea lui NEMASURATA) +// Verificatorul AIP-23: AERE_VERIFY_PROOF= sau, in depozitul de dezvoltare, ../aere-proof-protocol/verify.mjs. + +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { definePolicy, hashPolicy, checkAction, decisionEnvelope, canonical } from './agent-policy.mjs'; +import crypto from 'node:crypto'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const VERIFY = process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs'); +const T = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-')); +let ok = 0, rele = 0, sarite = 0; +const cer = (n, c) => { console.log(` [${c ? 'OK ' : 'RAU '}] ${n}`); c ? ok++ : rele++; }; +const arunca = (fn) => { try { fn(); return null; } catch (e) { return e.message; } }; +const verdict = (p) => { try { return JSON.parse(execFileSync(process.execPath, [VERIFY, p, '--json'], { encoding: 'utf8' })).verdict; } catch (e) { try { return JSON.parse(e.stdout || '').verdict; } catch { return '?'; } } }; + +try { + const { policy, policyHash } = definePolicy({ agentId: 'agent-1', spend: { amount: '100', windowSeconds: 3600 }, tools: ['retrieval', 'calc'], recipients: ['0xBBBB'] }); + cer('policyHash e digest 0x+64', /^0x[0-9a-f]{64}$/.test(policyHash)); + + // unelte + cer('unealta permisa -> allowed', checkAction(policy, { kind: 'tool', tool: 'retrieval', at: 1 }).allowed === true); + cer('CONTROL: unealta nepermisa -> denied, cu motivul in engleza', /is not in the allowed list/.test(checkAction(policy, { kind: 'tool', tool: 'shell', at: 1 }).reason)); + + // cheltuiala + cer('plata sub limita, destinatar permis -> allowed', checkAction(policy, { kind: 'payment', to: '0xbbbb', amount: '40', at: 1000 }, [{ amount: '30', at: 999 }]).allowed === true); + const peste = checkAction(policy, { kind: 'payment', to: '0xbbbb', amount: '80', at: 1000 }, [{ amount: '30', at: 999 }]); + cer(`CONTROL: plata care depaseste limita in fereastra -> denied (${peste.reason})`, peste.allowed === false && /over the limit: 110 > 100/.test(peste.reason)); + cer('CONTROL: destinatar nepermis -> denied', checkAction(policy, { kind: 'payment', to: '0xCCCC', amount: '10', at: 1000 }, []).allowed === false); + cer('cheltuiala veche (in afara ferestrei) nu conteaza', checkAction(policy, { kind: 'payment', to: '0xbbbb', amount: '90', at: 100000 }, [{ amount: '90', at: 1 }]).allowed === true); + // activul: implicit al limitei; altul e refuzat, nu adunat + cer('plata cu activul politicii (AERE) scris explicit -> allowed', checkAction(policy, { kind: 'payment', to: '0xbbbb', amount: '10', asset: 'AERE', at: 1 }).allowed === true); + const altActiv = checkAction(policy, { kind: 'payment', to: '0xbbbb', amount: '10', asset: 'USDC', at: 1 }); + cer(`CONTROL: plata in alt activ decat al limitei -> denied (${altActiv.reason})`, !altActiv.allowed && /not the policy's asset/.test(altActiv.reason)); + + // validarea politicii + cer('CONTROL: spend.amount care nu e intreg -> politica refuzata', /spend.amount/.test(arunca(() => definePolicy({ agentId: 'a', spend: { amount: '1e9', windowSeconds: 60 } })) || '')); + cer('CONTROL: fereastra zero -> politica refuzata', /windowSeconds/.test(arunca(() => definePolicy({ agentId: 'a', spend: { amount: '1', windowSeconds: 0 } })) || '')); + + // hashPolicy: hash-ul unei politici PRIMITE se recalculeaza + const dinFisier = JSON.parse(JSON.stringify(policy)); + cer('hashPolicy pe politica citita dintr-un fisier = hash-ul definePolicy', hashPolicy(dinFisier).policyHash === policyHash); + const reordonata = Object.fromEntries(Object.entries(dinFisier).reverse()); + cer('hashPolicy nu depinde de ordinea cheilor din fisier', hashPolicy(reordonata).policyHash === policyHash); + const laxa = { ...dinFisier, spend: { ...dinFisier.spend, amount: '1000000' } }; + cer('CONTROL: o politica LAXA are alt hash (nu poate purta hash-ul celei reale)', hashPolicy(laxa).policyHash !== policyHash); + cer('CONTROL: un camp necunoscut in politica -> refuzat', /unknown policy field/.test(arunca(() => hashPolicy({ ...dinFisier, bypass: true })) || '')); + cer('politicile de dinainte de 2026-09-29 pastreaza hash-ul (forma normala neschimbata)', + definePolicy({ agentId: 'agent-7', spend: { amount: '100', windowSeconds: 3600, asset: 'AERE' }, tools: ['search'], recipients: null }).policyHash === '0xc7d983a9886a0899bd6f3f09fde526514b0075f8f6abe77a1886405d86048c27'); + + // plic de decizie + const act = { kind: 'payment', to: '0xbbbb', amount: '80', at: 1000 }; + const dec = checkAction(policy, act, [{ amount: '30', at: 999 }]); + const env = decisionEnvelope({ policyHash, action: act, decision: dec, createdAt: '2026-09-26T09:00:00Z' }); + cer('plicul leaga policyHash', env.statement.policyHash === policyHash); + cer('plicul spune allowed=false (decizia reala)', env.statement.allowed === false); + const inversa = { at: 1000, amount: '80', to: '0xbbbb', kind: 'payment' }; + cer('actionHash e canonic: aceeasi actiune cu cheile in alta ordine -> acelasi digest', decisionEnvelope({ policyHash, action: inversa, decision: dec, createdAt: '2026-09-26T09:00:00Z' }).statement.actionHash === env.statement.actionHash); + cer('actionHash = sha256 peste JSON-ul canonic al actiunii (reproductibil de un strain)', env.statement.actionHash === '0x' + crypto.createHash('sha256').update(canonical(act)).digest('hex')); + if (fs.existsSync(VERIFY)) { + const f = path.join(T, 'dec.json'); fs.writeFileSync(f, JSON.stringify(env, null, 1)); + cer('plicul de decizie verifica sub AIP-23', verdict(f) === 'VALID'); + const rau = JSON.parse(JSON.stringify(env)); rau.statement.allowed = true; const f2 = path.join(T, 'rau.json'); fs.writeFileSync(f2, JSON.stringify(rau)); + cer('CONTROL: plicul cu decizia rescrisa nu mai verifica sub AIP-23', verdict(f2) !== 'VALID'); + } else { sarite += 2; console.log(` [SARIT] plicul sub AIP-23 (2 probe): verificatorul nu e la ${VERIFY}; AERE_VERIFY_PROOF=`); } + + // o politica schimbata -> alt hash (decizia nu se poate atribui altei politici) + const alt = definePolicy({ agentId: 'agent-1', spend: { amount: '1000000', windowSeconds: 3600 }, tools: ['retrieval'], recipients: ['0xBBBB'] }); + cer('CONTROL: politica cu alta limita -> alt policyHash', alt.policyHash !== policyHash); +} finally { fs.rmSync(T, { recursive: true, force: true }); } +console.log(`\nagent-policy: ${ok}/${ok + rele} cum trebuia${sarite ? `, ${sarite} NEMASURATE (fara verificatorul AIP-23)` : ''}`); +process.exitCode = rele ? 1 : (sarite ? 2 : 0);