Add agents: a post-quantum identity, a policy and a signed action ledger for AI agents, with human approval and revocation
An agent gets an ML-DSA-65 identity and a policy (spending per time window, allowed tools and recipients, which actions need human approval, who may revoke it). Every action it proposes is judged against the policy, signed by the agent and chained; a verifier that does not trust the agent re-runs the policy over the whole ledger. Approvals and revocations are signed by people with their own ML-DSA-65 keys. The ledger of an agent under a policy is one ledger: a second history is a branch, and two branches are a proof of equivocation anyone can check with the public key alone. The README says what the verifier cannot see: entry times are bounded from below only with a witness (anchors or a start time), and someone who sees one branch cannot know of another. Tests: policy 23/23 with the AIP-23 reference verifier (21 run without it), ledger 51/51, approval and revocation 39/39, command line 23/23; negative control 25/25.
This commit is contained in:
parent
a7165164eb
commit
6e62b1ad1a
@ -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 |
|
| [`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 |
|
| [`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 |
|
| [`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.
|
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 |
|
| 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) |
|
| 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 |
|
| 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
|
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 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
|
messages, the HTTP APIs, the inventory's module interface (`scan`, `buildCbom`, `renderSummary`, ...), the agents' module
|
||||||
in English.
|
interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ...) and the documentation are in English.
|
||||||
|
|
||||||
## Licence
|
## 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).
|
||||||
|
|||||||
82
agents/README.md
Normal file
82
agents/README.md
Normal file
@ -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 <unix>]
|
||||||
|
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=<verify-proof.mjs from aere-node>`); 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.
|
||||||
127
agents/agent-aprobare.mjs
Normal file
127
agents/agent-aprobare.mjs
Normal file
@ -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())) };
|
||||||
|
}
|
||||||
137
agents/agent-cli.mjs
Normal file
137
agents/agent-cli.mjs
Normal file
@ -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 <dir> new agent identity; prints the agentId
|
||||||
|
// node agent-cli.mjs human --out <dir> 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 <unix>] [--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 <unix>]
|
||||||
|
// 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 <dir>`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
335
agents/agent-ledger.mjs
Normal file
335
agents/agent-ledger.mjs
Normal file
@ -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 };
|
||||||
|
}
|
||||||
168
agents/agent-policy.mjs
Normal file
168
agents/agent-policy.mjs
Normal file
@ -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')) };
|
||||||
|
}
|
||||||
73
agents/control-negativ-aprobare.mjs
Normal file
73
agents/control-negativ-aprobare.mjs
Normal file
@ -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;
|
||||||
147
agents/proba-agent-aprobare.mjs
Normal file
147
agents/proba-agent-aprobare.mjs
Normal file
@ -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;
|
||||||
100
agents/proba-agent-cli.mjs
Normal file
100
agents/proba-agent-cli.mjs
Normal file
@ -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;
|
||||||
214
agents/proba-agent-ledger.mjs
Normal file
214
agents/proba-agent-ledger.mjs
Normal file
@ -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;
|
||||||
77
agents/proba-agent-policy.mjs
Normal file
77
agents/proba-agent-policy.mjs
Normal file
@ -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=<verify-proof.mjs> 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=<verify-proof.mjs>`); }
|
||||||
|
|
||||||
|
// 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);
|
||||||
Loading…
Reference in New Issue
Block a user