Skip to content
On this page

Security model

Probarion splits control between the owner's wallet, the approvers, the vault's own function-call keys and code that runs off chain. This page shows what each can do, what the contract enforces whatever that code does, and the most a leaked key can cost.

LiveTestnetExplanationFor Partners22 min read.md (this page as Markdown)

Warning

Unaudited beta. No third party has audited the contracts or the packages.

A spend is checked by code off chain and again by the contract on chain, and every decision goes into a signed, hash-chained ledger. The contract is the layer that holds whatever the off-chain code does: a leaked agent key can spend only what the mandates naming it still allow, and only to the payees they name. Probarion's servers hold no owner key, and the CLI and the console sign no owner or approver transaction. The owner's full-access key is the one key with no bound inside the vault. Today the vault runs on NEAR testnet, with test tokens, and one small vault, v0.probarion.near, holds a little real USDC on mainnet. Its one spend's decision is pre-ledger: a decision file in the repository, with no signed ledger and no attestation. No Base payout has run on mainnet.

  1. Code decide() in @probarion/core runs the rules on every decision. A model, when one is used, only proposes.
  2. Contract spend runs 14 checks in order. The first one that fails refuses the spend, and the vault stays as it was.
  3. Ledger The SDK signs each decision and its outcome into a hash-chained ledger, and anchors the ledger on the vault.

Shared responsibility#

This table follows the key table of SECURITY.md (opens in a new tab).

WhoCanCannotWhat stops it
The agent key, a function-call key the vault adds to itself, method spend onlyCall spend under a mandate that names this keyChoose the payee. Go past the mandate's max_per_payment, max_total (spent plus reserved), max_uses or expires_at_ns, or the vault's daily backstop. Pay one invoice_hash twice or reuse a ref. Call any other method, or attach a depositspend has no address field, so the destination comes from the mandate in storage. The 14 checks refuse the rest. The NEAR protocol limits a function-call key to its method list and to calls without a deposit
The ledger key, a function-call key on the vault, method anchor onlyAnchor the next ledger range, with seq exactly one above the lastSpend, or change a mandate or a keyThe NEAR protocol limits it to anchor, and anchor checks the key and the seq
Whoever holds the agent key: the agent's operator, or anyone who took it from a serverSpend inside the open mandates that name it, with invoice hashes of its choosingPay anyone a mandate does not name, or exceed a mandate's limits or the daily backstop. Hide a rewrite of a ledger range that was anchoredThe contract's checks, and the anchors: verify fails on the first anchor whose range no longer matches, and names the first entry of that range
The owner, a full-access key on the owner's accountCreate and revoke mandates, add and remove keys, set the ledger key, freeze, unfreeze, and withdraw the USDC and the Intents balance to the owner's own accountChange a mandate's payee, change the owner or the approvers, upgrade the vault, or withdraw to another account. Approve or reject a held spend, unless the owner is also an approverNo method changes a payee, the owner or the approvers, upgrades the vault or withdraws to another account. A new payee needs a new mandate. approve and reject refuse anyone not on the approver list with NotApprover
A function-call key on the owner's or an approver's account, such as a dapp login keyOn the owner's account, call freeze, if the key may call this vault: its receiver is the vault, and its method list is empty or names freezeCall any other owner or approver method: add_agent, remove_agent, set_ledger_key, create_mandate, revoke_mandate, approve, reject, unfreeze, withdraw or withdraw_intentsEach needs a deposit, exactly 1 yoctoNEAR or the storage deposit of create_mandate, and only a full-access key can attach one
An approver, one of the accounts named when the vault was createdApprove or reject a held spend, aloneApprove while the vault is frozen, after the hold expired, or for a revoked mandate. Change a mandate, a key or a settingapprove checks the freeze, the hold time and the mandate. No method lets an approver change anything else
The factory ownerPublish a new vault versionChange, upgrade or take funds from a vault the factory createdSuch a vault starts with no access key and has no upgrade method
The model, on NEAR AI Cloud in a private TEEPropose pay, hold or refuse for one invoiceDecide alone, or widen a mandate with text injected into an invoiceThe code rules run on every decision, and the contract checks every spend
An extension token, held by a browser connected on the site's signed-in extension page, Preview. The server keeps a sha256 of the token and its last four characters, not the token itselfFor 30 days unless revoked, on its own network: read its account's waiting invoices, held spends and active mandates, and queue an invoice (POST /api/ext/invoices, at most 20 per account and network in 10 minutes) for a vault where the account is an owner or an approver, not a Canary vault, under a usable mandate. The server stores the invoice in awaiting_owner only when core's payeeHintContradiction and the contract's check_spend view let it through, and otherwise stores nothingPay, approve, confirm, or change a mandate or a key. A queued invoice runs no cycle and waits for the owner's wallet-signed ConfirmThe extension's server code imports no function that pays or signs, and none that opens a sealed key from outside lib/crypto. secrets.ts opens a sealed value only inside its own opening functions, which that code does not import, and the queue's route reads the master key only to seal the invoice with sealInvoice and to key its duplicate guard, as the console upload does. A structure test checks those imports. Review of each change to that code is the other control. Confirm needs the owner's wallet signature

Vaults, roles and keys explains each role and key in full.

Who answers for what#

  • The owner sets every limit, from their own wallet, and keeps the owner account's full-access key safe. Only the owner can withdraw, unfreeze or change a key.
  • The approvers decide each held spend, each from their own wallet.
  • The agent's operator runs the agent and keeps its agent and ledger keys, unless the console made them, and runs one ledger writer per vault.
  • Probarion runs the console and the testnet factory. Its CLI and console hand each owner and approver transaction to that person's wallet, and sign none of them.

What Probarion holds#

No owner or approver key. The owner's and the approvers' full-access keys are never on Probarion's servers, so Probarion cannot change your vault, withdraw from it or approve its spends.

Sealed agent and ledger keys, for keys the console made. When the console's Keys tab makes a vault's agent and ledger keys, Probarion's server generates them and stores each secret sealed with libsodium's secretbox, under a master key held outside the database. Payables, the console's reference agent, signs with them, and so does the Canary, Probarion's own public-challenge vault, which is Live on testnet. The owner adds each key to the vault with its public key only. The Keys tab is Preview, and Payables is Live on testnet. A key you make with agent keygen stays with you, and Probarion never sees it.

An invoice that Payables cannot pay on its own, because no attested model confirmed its amount, waits in the Payables tab for the owner's Confirm. Confirm is a NEP-413 signature from the owner's wallet, not a transaction, and the server checks that a full-access key of the vault's owner signed it before it pays.

A mint-only faucet key, testnet only. The server signs the Top-up tab's test-USDC mint as probarion.testnet, with a function-call key that may call only mint on testnet USDC, and it refuses to sign when the key on chain allows anything more. That key cannot call a vault. The faucet is Preview.

What the vault enforces#

These hold on chain, whatever the SDK, the console or the agent does.

  • The 14 spend checks. spend runs the 14 checks, in order: the agent key, the request's shape, the freeze, the mandate, its expiry and agent, the amount, the uses, the total, the invoice, the ref and the daily backstop. The first one that fails refuses the spend and changes nothing. Mandates covers each limit.
  • No payee in a spend. The request carries a mandate id, an amount, an invoice hash, a ref and a decision hash. The destination comes only from the mandate the owner signed. Why spend has no payee explains why.
  • The one-yocto rule. Most owner methods, and both approver methods, need exactly 1 yoctoNEAR attached. Only a full-access key can attach a deposit, through a wallet prompt, so a dapp login key cannot make these calls, and a script in a web page cannot make them without that prompt. create_mandate takes a storage deposit and sends back the excess. freeze is the exception: it takes no deposit, so the owner can stop the vault fast.
  • Two kinds of key. The vault adds keys to itself only on the owner's instruction: function-call keys for spend (add_agent) and one for anchor (set_ledger_key). A vault the factory creates starts with no access key. Sandbox tests show that the NEAR protocol stops the agent key from calling any other method or attaching a deposit, and stops the ledger key from calling spend. The agent key cannot approve itself replays the attack of the I-HITL paper with the agent key, one refusal at a time.
  • Fixed settings. No method changes the owner, the approvers, the daily backstop or the hold time, and no method edits a mandate. There is no upgrade method: a fix ships as a new vault version, and owners move to a new vault.

Key idea

The model proposes. The code rules and the contract decide.

Loss bounds#

Each bound below is what the code allows, not a promise.

If the agent key leaks#

Whoever holds it can spend only under the open mandates that name that key:

  • per spend, at most the mandate's max_per_payment;
  • in all, at most what is left of max_total (spent plus reserved), within the uses left and before expires_at_ns;
  • across the whole vault, at most the daily_backstop in each UTC day, one counter for every mandate and key;
  • only to the payee each mandate names;
  • a spend above the mandate's approval_above waits for an approver.

Spends at or under approval_above pay at once, and the key can send many of them. So the bound is the mandates' remaining totals, capped by the daily backstop for each day until they expire, not approval_above.

The holder can skip the SDK and its off-chain rules by calling spend directly: the contract checks only that decision_hash is 64 hex characters, and never matches it with a decision. Check d of the verifier then fails, and names the first spend that has no decision of its own in the ledger or that reuses a decision already bound to another spend. That catches a thief who holds only the agent key. One who also holds the ledger key and can write the ledger, as on a breached server, or on a breached Probarion for keys the console made, can sign a decision for each spend, and check d then passes, because the verifier does not run the policy again. This follows from the verifier's code; no test shows it yet. Every call the key signs, refused or not, burns gas from the vault's NEAR, up to the key's allowance: 1 NEAR by default in the console and the CLI.

To stop it, freeze the vault, then remove the key and revoke the mandates that name it. Spends already held still wait for an approver after the key is removed, and an approver can reject them, because approve and reject do not look at the agent key. This follows from the contract's code; no test shows it yet. Remove an agent key has the details.

Owner signs

The owner signs freeze, remove_agent and revoke_mandate from their own wallet. freeze takes no deposit. The other two need exactly 1 yoctoNEAR.

The owner signs this transaction. Probarion never signs it.

If the ledger key leaks#

It cannot move money or change a mandate. The NEAR protocol stops it from calling spend, and its only method is anchor. Whoever holds it can sign new ledger entries and anchor the next range, so they can write a false record after the last anchor. Its gas allowance, 0.25 NEAR by default, caps what it burns.

Rotate it: the owner sets a new ledger key with set_ledger_key, ledger set-key in the CLI. Each anchor stores the key that signed it, so the ranges anchored before the rotation stay verifiable, and the vault deletes the old key's access key, so nothing the old key signs reaches the vault any more. Rotate the ledger key gives the order: anchor first, then rotate.

Owner signs

The owner signs set_ledger_key from their own wallet, with exactly 1 yoctoNEAR attached. The transaction carries the new public key only.

The owner signs this transaction. Probarion never signs it.

If the owner key leaks#

A full-access key on the owner's account acts as the owner, because the vault checks only which account calls. Its holder can do everything the owner can: create a mandate to any payee with an agent key of their own, unfreeze, and withdraw. withdraw sends USDC only to the owner's account, but the holder controls that account too. withdraw also ignores reservations, so it can take out USDC that held spends need: an approved payout then fails and is released. This follows from the contract's code; no test shows it yet. Freezing does not stop the holder, who can unfreeze. There is no bound inside the vault, so keep the owner's full-access key safe.

If the owner key is lost#

If every full-access key on the owner's account is lost, no other account can withdraw, unfreeze, revoke a mandate or change a key. The vault has no recovery method and no method that changes its owner, so no one can withdraw its USDC to an account: only spends to the payees of open mandates still move it. Agent keys can still spend under those mandates until they expire or run out, and approvers can still decide held spends. No method withdraws the vault's NEAR either: what leaves it is the gas its keys burn, within their allowances, and the 1 yoctoNEAR a payout attaches to its token transfer. This follows from the vault's method list; no test shows it yet.

If an approver key leaks#

Its holder can approve every held spend that is still alive. One approver is enough: there is no quorum. Each payout still goes to its mandate's payee, for the amount reserved when the spend was held. approve does not check the mandate's expiry, uses, total or the daily backstop again, because the reservation already counted them: this follows from the contract's code, and no test shows it yet. The holder can also reject held spends, which releases them.

The approver list holds accounts and cannot change, so the approver's account stays an approver of this vault. The approver deletes the leaked key from their own account, if they still hold another full-access key on it. Until then, the owner stops approvals by freezing the vault or revoking the mandates of the held spends. If the approver has lost control of the account, the owner moves to a new vault to drop it. Holds and approvals covers what approve checks.

Approver signs

An approver signs approve or reject from their own wallet, with exactly 1 yoctoNEAR attached. A login key cannot attach it, so only a full-access key on the approver's account can decide a held spend.

An approver signs this transaction. Probarion never signs it.

If Probarion is breached#

A vault whose agent and ledger keys the console made has them on Probarion's servers, so the agent key and ledger key bounds above apply to it. A vault whose keys you made with agent keygen has no key there. The threat model's blast radius, checked against the code:

An attacker in full control of Probarion's servers canBut cannot
Call spend with the stolen agent keysPay anyone not named in a mandate
Submit fake invoice hashes against open mandates, or pay a waiting invoice without the owner's wallet-signed ConfirmExceed a mandate's per-payment cap, total cap, uses or expiry, or the vault's daily backstop. A spend above approval_above still waits for an approver
Rewrite off-chain historyHide a rewrite of a range already anchored: check c fails on the first anchor whose root no longer matches, and names the first entry of its range. An anchor pins a whole range, so check c cannot say which entry inside it changed
Stop the serviceLock the owner out of the funds: the owner can freeze, withdraw and remove keys with their own keys, for example with near-cli-rs, without Probarion
Use a hijacked owner or approver session, or a function-call key on their account, such as a dapp login keyCreate or revoke a mandate, approve or reject, withdraw, add or rotate keys, or unfreeze: each needs a deposit, exactly 1 yoctoNEAR or the storage deposit of create_mandate, which only a full-access key can attach, through a wallet prompt

Whoever holds the master key and a copy of the database holds every sealed agent and ledger key, and can forge console sessions. That is still bounded by the table above. If the master key, KEY_ENCRYPTION_SECRET, is lost, the sealed keys are lost with it, not the funds: remove those keys from each vault and add new ones.

Stop a vault#

The fastest stop is in the console. Signed in as the vault's owner, open the "Overview" tab: the "Vault" card has a "Freeze vault" button, and your wallet signs the call. Its confirmation reads "Freeze stops every spend and every approval until you unfreeze. Continue?" When the vault's config cannot be read, the tab still offers the button, on an "Emergency stop" card.

The CLI is the stop that does not depend on Probarion's console. It prints the transaction, with no deposit, for your own wallet or near-cli-rs, and signs nothing. Install builds it from a clone.

Owner signs

The owner signs freeze with no deposit, from the owner's full-access key or from a function-call key on the owner's account whose receiver is the vault and whose method list is empty or names freeze, such as a dapp login key scoped to the vault. A key scoped to another contract cannot call the vault at all. unfreeze needs exactly 1 yoctoNEAR and fails without it with RequiresOneYocto, so a login key that froze the vault cannot unfreeze it.

The owner signs this transaction. Probarion never signs it.

Shell
node packages/cli/dist/bin.js --network testnet vault freeze --vault <vault>

Illustrative, shortened: the two lines after the command, with the args file's sha256, and the last line, about a Ledger wallet, are left out.

Text
OWNER SIGNS: probarion never signs owner or approver transactions. Review the arguments, then sign with your own wallet or near-cli-rs:

near contract call-function as-transaction <vault> freeze file-args ./freeze.<12 hex>.args.json prepaid-gas '30.0 Tgas' attached-deposit '0 NEAR' sign-as <owner-account-id> network-config testnet sign-with-keychain send

freeze arguments (./freeze.<12 hex>.args.json):
{}

Every new spend and every approve is refused until unfreeze. reject and expire_held still work.

While the vault is frozen:

  • spend refuses with Frozen, and the check_spend dry run answers Frozen.
  • approve refuses with Frozen.
  • Payouts already sent still settle or are released by their callbacks.
  • reject and expire_held still work.
  • The owner can still withdraw and change keys: no withdraw or key method checks the freeze. This follows from the contract's code; no test shows it yet.

Probarion can freeze only the vaults it owns: freeze checks the vault's owner, so the owner of any other vault freezes it from their own wallet. Freeze is the exception shows the code.

Known gaps#

  • No Content-Security-Policy yet. An XSS in the console has no second wall in the browser. It still cannot attach a deposit, but it could show the owner a false page, start a wallet prompt, or mint a 30-day extension token or a pairing code. Source: THREAT_MODEL.md, known gaps (opens in a new tab).
  • Extension tokens outlive the session that made them. A token lasts 30 days unless it is revoked. Signing out, the session's 12-hour expiry and a new KEY_ENCRYPTION_SECRET do not end it, because the database keeps it as a plain sha256, not sealed under the master key. It reads the waiting list and can add to it: up to 20 invoices in 10 minutes, each under a usable mandate, to the payee that mandate already names. It cannot pay: each invoice waits for the owner's wallet-signed Confirm, and the owner can reject it. A queued invoice can block a later upload of the same invoice until the owner rejects it. The account a token belongs to revokes it on the extension page, and the vault's owner cannot revoke an approver's token. A pairing code the session made outlives it too, for up to 10 minutes. Source: THREAT_MODEL.md, known gaps (opens in a new tab).
  • wee_alloc in the shipped contracts. near-sdk 5.29.1 turns on its wee_alloc allocator by default, and Dependabot flags it as unmaintained (GHSA-rc23-xxgq-x27g). Turning it off changes the code hash, so the fix ships only as a new vault version. Source: THREAT_MODEL.md, known gaps (opens in a new tab) and contracts/Cargo.lock (opens in a new tab).
  • cargo audit not run. CI has no dependency-audit job. Dependabot alerts are triaged, and the threat model's review log records each triage. Source: THREAT_MODEL.md, known gaps (opens in a new tab).
  • Secret scanning looks for provider patterns only. Private vulnerability reporting, secret scanning, push protection and Dependabot security updates are on for the repository; secret scanning's non-provider patterns and validity checks are off. CI's gitleaks job scans the history on pushes and pull requests to main and dev. Source: THREAT_MODEL.md, known gaps (opens in a new tab).
  • No security email address. A report goes by a direct message on X or the repository's private GitHub form. There is no email address until one passes a delivery test. Source: THREAT_MODEL.md, known gaps (opens in a new tab).
  • The TEE measurement is not pinned. The attestation checks show that a model's signature, response and recorded proposal agree, not that it ran a given model image. Source: SECURITY.md, what is not proven yet (opens in a new tab).
  • Real attested decisions do not show which image ran. The NEAR AI Cloud adapter is tested against a fake NEAR AI Cloud. On testnet, the Canary's ledger of 2026-11-10, published in the repository, holds two real attested decisions that pass A1 to A7 offline and, on 2026-11-11, O1 and O2 online (online output (opens in a new tab)). The testnet demo vault's ledger holds five more, made by Payables, which pass A1 to A7 offline; four of them, entries 10 to 13, also passed O1 and O2 online on 2026-11-12 (online output (opens in a new tab)). The code allowed the last two of them, and the vault paid both invoices, 2.00 test USDC each. The TEE measurement is not pinned, so this does not show which model image ran, and A8 was not run on the Canary's two; on the demo vault's five it passed on 2026-11-12, with the invoices committed in the repository (output (opens in a new tab)). Source: SECURITY.md, what is not proven yet (opens in a new tab), the Canary's evidence (opens in a new tab), the demo vault's (opens in a new tab) and its second payment (opens in a new tab).
  • A8 needs the invoice text. The ledger stores no invoice text, so only someone who holds the invoices can show that the model saw the standard request for each one. The CLI built from the repository runs A8 with --invoice <file> or --invoices <dir>, live or with --offline; release 0.1.1 on npm does not have these flags. Source: SECURITY.md, what is not proven yet (opens in a new tab).
  • Offline, a snapshot is checked against itself. verify --offline takes the chain data from the same file as the ledger, so a forged snapshot with re-signed entries passes. Take the snapshot yourself, or run live with --vault. Source: SECURITY.md, what is not proven yet (opens in a new tab).
  • Entries after the last anchor are checked against the ledger key only, since no anchor pins them yet. The gap after the last anchor explains it. Source: SECURITY.md, what is not proven yet (opens in a new tab).
  • v0.probarion.testnet runs an older build, from before the set_ledger_key fix of 2026-11-03. Vaults the testnet factory creates run the fixed build. Source: README, status (opens in a new tab).
  • The factory account keeps a full-access key. vaults.probarion.testnet was created with one, the runbook has no step that deletes it (its optional lock step is for the mainnet factory), and no lock is recorded. Whoever holds that key could deploy new code to the factory, and with it change the version registry that checks f and i read. A version added that way would pass check f for the vaults the factory creates from it, and check i too. Vaults the factory already created keep their code. Source: docs/runbooks/factory.md (opens in a new tab) and contracts/deployments/testnet.json (opens in a new tab).

Report a vulnerability#

Do not put the details of a vulnerability in a public issue, pull request or post.

Say what is affected, how to reproduce it, and what an attacker could do with it. The aim is to acknowledge a report within 72 hours. There is no bug bounty. Test only against vaults you deployed yourself, on testnet or in the near-workspaces sandbox, and do not try to move funds that are not yours. SECURITY.md (opens in a new tab) is the full policy.

Where to go next#

What this does not do#

  • It does not run the bridge or the NEAR Intents contract. The POA (Proof of Authority) bridge releases funds on Base and Solana for valid burns, and Probarion trusts it to. intents.near, the NEAR Intents contract, has DAO roles that can force-withdraw or lock a balance there, which is why a vault keeps little inside intents. Probarion's verify command cannot prove what either one does. On testnet intents-stub stands in for intents.near, and no burn leaves NEAR.
  • It does not check that an invoice is authentic. The ledger stores an invoice's hash, not its text.
  • It does not let hosted Payables and your own SDK agent share a vault. They cannot run on one vault at once: process() refuses unless its ledger key is the vault's. Switching the vault's ledger key from one to the other leaves two histories, and the new writer's ledger fails checks a and c against the earlier anchors. This follows from the code; no test shows it yet. Give an SDK agent a vault of its own, as one writer per vault explains.
  • It has no HTTP API an agent pays through. The console's invoice API needs a signed-in wallet session. An agent pays from its own process through the SDK, through the MCP server (Live), whose tools take no address and which holds only the agent and ledger keys, or through hosted Payables in the console.

Built from 4ab41ca · reviewed

Show

Loading the search index