Skip to main content

The core guarantee

If an agent’s Ed25519 key leaks in full — copied off disk, exfiltrated by a prompt injection, anything — the attacker gains exactly what the on-chain policy already permitted that key to do. Nothing more. The key was never independently powerful; it was always subordinate to a contract the attacker cannot rewrite or bypass. This only holds because of two design decisions, both of which were wrong on the first implementation attempt during this project’s development. Both are worth understanding, because they’re the two ways a “policy-gated agent” design most naturally fails silently.
Not independently audited. agent-policy has 11 unit tests covering the allow-list, spend-cap, and event paths described below, and its core logic has been exercised against real testnet transactions (see Testing) — but it has not been reviewed by a third-party security auditor. Treat “the safety boundary belongs on-chain” as a claim about where the enforcement point is architecturally, not a guarantee that the enforcement code itself is free of bugs. Anyone gating real value with this policy, on mainnet, should get an independent review of contracts/agent-policy/src/lib.rs first.

Mistake #1: an unlimited signer bypasses the policy entirely

The first working version registered the agent’s signer with SignerLimits(None) — unlimited. Every on-chain call succeeded, including calls that should have been rejected. The reason: a wallet’s __check_auth only consults the policy contract if the policy’s SignerKey is actually present in the transaction’s signatures map for that context. An unlimited signer’s own signature is sufficient on its own — verify_context returns true immediately for a SignerLimits(None) candidate, without the policy ever being invoked. The fix: the agent’s signer must be scoped with SignerLimits(Some({ target_contract: Some([Policy(agent_policy)]) })) — unlimited for nothing, and specifically requiring the policy contract as a co-signer for the one target contract it’s allowed to touch at all.
Vertical decision tree: agent call arrives, checked against the allow-list (reject if not found), checked for a capped argument, then checked against the rolling spend cap (reject if exceeded), otherwise approved.
This is the exact logic in contracts/agent-policy/src/lib.rs’s policy__ — an allow-list check first (deny-by-default), then, only for calls with a designated amount argument, a cumulative rolling-window spend check.
The window is a cumulative cap, not a per-call cap. A per-call limit is meaningless against a policy signer with no signature material of its own (Signature::Policy carries no cryptographic proof) — nothing stops many small calls from adding up to an unbounded total unless the contract tracks spend across calls within the window.
Known limitation: the window resets abruptly, not as a true sliding window. policy__ tracks a window_start timestamp and resets spent to zero once WINDOW_SECONDS has elapsed since that start (contracts/agent-policy/src/lib.rs’s policy__) — it does not decay spend continuously. This means an agent can spend up to the full cap right before a reset, then the full cap again right after: two full allowances within a span shorter than WINDOW_SECONDS, at the boundary. This mirrors sample-policy’s own mechanism (not a regression introduced here) and is a reasonable tradeoff for a single-u64-plus-i128 storage footprint, but an operator setting a cap should size it assuming this worst-case boundary burst, not the nominal per-window figure.

Mistake #2: pricing a transaction before it’s signed

Soroban simulates a transaction to discover its resource footprint and fee — but the first simulation, before any signature exists, prices __check_auth against an empty signature. For a custom-account wallet, that means the real ed25519_verify and policy__ invocation costs are never priced by that first pass. Submitting with that price produces invokeHostFunctionResourceLimitExceeded — a real transaction failure, not a simulation quirk.
Vertical flow: build unsigned transaction, simulate once to discover auth entries, sign them by hand, simulate a second time to re-price with the real signature attached, assemble the final transaction, submit and poll for success or on-chain rejection.
The fix: simulate once (discover the required auth entries and nonce), sign those entries, then simulate again with the real signature attached before assembling the transaction that actually gets submitted. This is exactly what src/soroban-tx.ts’s invokeAsWallet does — nothing about this is optional or an approximation.

Why the signing has to be hand-built at all

@stellar/stellar-sdk’s own authorizeEntry helper hardcodes the classic {public_key, signature} shape used by plain Stellar accounts. A passkey-kit smart wallet is a custom account — its __check_auth expects a bespoke Signatures(Map<SignerKey, Signature>) argument that no generic SDK or CLI tool can construct, because the shape is entirely up to the account contract’s own code. stellar-cli itself can only auto-sign auth entries for plain accounts; it errors outright (Missing signing key for account C...) the moment an entry’s credentials address is a contract instead of a keypair.
This is why src/scval-encoders.ts exists as its own module: every one of these XDR shapes is unit-tested against the exact bug class described above — see Testing.

On-chain observability

agent-policy emits #[contractevent]s for every state change: Installed/Uninstalled (signer added/removed), Configured (new allow-list + cap, with the full allow-list in the payload so an indexer never has to fall back to a separate get_config read), and CallApproved (emitted at the tail of policy__, carrying both this call’s own spend and the wallet’s cumulative spend in the current window). There is deliberately no rejection event. policy__ rejects by panicking (panic_with_error!), and a panic aborts the whole host invocation — any event a contract published earlier in that same invocation is discarded along with every other state change, so an event published on the rejection path would never actually be recorded on a real ledger. Rejections are visible on-chain a different way: the transaction itself fails, with PolicyError’s numeric code in the result. The MCP server surfaces that failure to the caller and logs it locally (see src/mcp-server.ts) — that TypeScript-side log, not an on-chain event, is the audit trail for “what did this agent try and get denied.”