From 9f9375bb9e0cfd2ed114b5f23cb2c4beb7117087 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Tue, 1 Sep 2026 12:36:11 -0600 Subject: [PATCH 1/6] docs: add autonomous agents page under contract accounts Covers bounding a long-running agent's authority with two composable restriction layers (callee-side instruction-set design, key-side smart account policy) and three runtime pitfalls that don't show up until the agent is actually operating: simulation not verifying auth, stale cached authority in a long-running loop, and where LLM discretion should stop versus hard-coded limits. Sits alongside the existing Advanced contract account patterns page, which covers the design-time guardrail primitives this page composes for a specifically autonomous, unattended caller. Co-Authored-By: Claude Sonnet 5 --- .../contract-accounts/autonomous-agents.mdx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/build/guides/contract-accounts/autonomous-agents.mdx diff --git a/docs/build/guides/contract-accounts/autonomous-agents.mdx b/docs/build/guides/contract-accounts/autonomous-agents.mdx new file mode 100644 index 0000000000..fa7172549e --- /dev/null +++ b/docs/build/guides/contract-accounts/autonomous-agents.mdx @@ -0,0 +1,42 @@ +--- +title: Autonomous agents +description: Bound a long-running agent's authority with layered restrictions, and the pitfalls that only surface once it's actually running. +sidebar_position: 45 +--- + +A contract account restricts _who_ can act and _under what conditions_ (see [Advanced contract account patterns](./advanced-patterns.mdx)). Those guardrails are usually installed once, for a human session. An autonomous agent is different: the same signing key stays live indefinitely, deciding on its own when to act, often many times a day. The restriction has to hold not just at setup, but for as long as the agent keeps running — including the day someone revokes it while the agent is mid-loop. + +This page covers two layered ways to bound an agent's authority, and three pitfalls that only show up once the agent is actually operating, not when you install the policy. + +## Two ways to bound authority, and why you want both + +**Restrict the callee.** The safest scope is a capability the contract you're calling never implements in the first place. A vault that only exposes `Invest(strategy, amount)` and `Unwind(strategy, amount)`, with the destination hardcoded to the vault's own address in every code path, cannot pay out to an arbitrary address no matter what the caller's key can sign. Withdrawal isn't denied by a check; it's absent from the interface. [DeFindex's vault](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example: its `rebalance()` instruction set is `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, and none of the four take a destination argument — every branch resolves the transfer to `e.current_contract_address()`. + +**Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. + +Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract can't move funds anywhere but back into itself. A testnet deployment combining both layers this way — Nirium's treasury agent, scoped to a single DeFindex vault — is verifiable on-chain at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), built on the [OpenZeppelin Smart Accounts framework](https://developers.stellar.org/docs/tools/openzeppelin-contracts). + +## What only shows up once the agent is running + +### Simulation records authorization, it doesn't verify it + +[Recording-mode simulation](https://developers.stellar.org/docs/learn/fundamentals/contract-development/contract-interactions/transaction-simulation#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. To test the deny path, submit the transaction in enforcement mode and confirm it actually fails on-chain — don't infer it from a clean simulation. + +### Re-read authority from chain every cycle, never cache it + +A human signer revokes access once, and the session ends there. A long-running agent process keeps looping regardless of what changed underneath it, so if it caches "am I still authorized" from the start of the process, a revocation made ten minutes into a multi-day run does nothing until the process restarts. Read the current role or permission state from chain at the top of every cycle, not once at boot. + +### Draw the line between the agent's judgment and the code's limits, and don't let the agent move it + +If an LLM or other model is choosing _when_ to act and _what_ to attempt, treat its output like any other untrusted input: it can request an action, but the ceiling on that action — maximum amount, allowed destinations, acceptable slippage — has to be enforced by code the model's own output cannot alter. In practice this means the boundary values live in a small, deterministic, separately reviewed piece of the system, and the model never gets a code path that writes to them. + +## What this pattern doesn't resolve + +A destination-less instruction set and a policy account with no default rule are technical facts you can verify on-chain. Whether they add up to something like custody or intermediation under a specific jurisdiction's securities or money-transmission law is a separate, non-technical question. Get that answered early with real counsel, not inferred from the contract's design after the fact. + +## Where to go next + +- [Advanced contract account patterns](./advanced-patterns.mdx) for the individual guardrail primitives this page composes. +- [OpenZeppelin Smart Accounts](https://developers.stellar.org/docs/tools/openzeppelin-contracts) — context rules, signers, and policies, audited by OpenZeppelin's security team, with formal verification by Certora in progress. +- [CAP-71](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md), authentication delegation and address-bound Soroban credentials. +- [DeFindex's vault contract](https://github.com/defindex-io/stellar-contracts), a real example of an instruction set with no destination parameter. From bc56704bdac3fdc37abfa191661324c46f96e654 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Tue, 1 Sep 2026 13:06:03 -0600 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20precision=20fix=20=E2=80=94=20deplo?= =?UTF-8?q?yed-with-correct-rule=20vs=20exercised-end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composed-layers paragraph implied the key-wrapping (smart account) layer was operating alongside the callee-side restriction. It isn't yet: the deployed contract's context_rules are confirmed correct by reading them on-chain, but nothing has authorized a real signed transaction through it. Only the callee-side restriction (DeFindex's Invest/Unwind) has a track record of real signed calls. Correcting before that distinction gets cited as settled. Co-Authored-By: Claude Sonnet 5 --- docs/build/guides/contract-accounts/autonomous-agents.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/build/guides/contract-accounts/autonomous-agents.mdx b/docs/build/guides/contract-accounts/autonomous-agents.mdx index fa7172549e..077fa7e990 100644 --- a/docs/build/guides/contract-accounts/autonomous-agents.mdx +++ b/docs/build/guides/contract-accounts/autonomous-agents.mdx @@ -14,7 +14,9 @@ This page covers two layered ways to bound an agent's authority, and three pitfa **Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. -Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract can't move funds anywhere but back into itself. A testnet deployment combining both layers this way — Nirium's treasury agent, scoped to a single DeFindex vault — is verifiable on-chain at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), built on the [OpenZeppelin Smart Accounts framework](https://developers.stellar.org/docs/tools/openzeppelin-contracts). +Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract can't move funds anywhere but back into itself. + +Worth being precise about what "deployed" proves versus what "operating" proves, since the two layers don't reach the same bar in practice at the same time. Nirium's treasury agent has the callee-side restriction backing real, signed `Invest`/`Unwind` calls against a live DeFindex vault. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](https://developers.stellar.org/docs/tools/openzeppelin-contracts) and deployed on testnet at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), has its intended rule confirmed by reading `context_rules` on-chain — but hasn't yet authorized a real signed transaction through it. Deployed-with-the-right-rule and exercised-end-to-end are different claims; verify which one you're actually looking at before relying on either. ## What only shows up once the agent is running From 33b80b7dd75c1c49cc7a662b18a6bf27838cd832 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Tue, 1 Sep 2026 14:00:27 -0600 Subject: [PATCH 3/6] docs: add the delegated-signer discovery gap as a fourth runtime pitfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed empirically (needsNonInvokerSigningBy(), raw simulation auth entries, both authMode values) that a Signer::Delegated's own signing requirement never surfaces via the SDK's standard discovery flow — full detail and repro in OpenZeppelin/stellar-contracts#863. Framed with the same precision as that issue: what's empirically confirmed vs. what's inferred about recording mode's internal behavior, not independently verified against the host implementation. Co-Authored-By: Claude Sonnet 5 --- docs/build/guides/contract-accounts/autonomous-agents.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/build/guides/contract-accounts/autonomous-agents.mdx b/docs/build/guides/contract-accounts/autonomous-agents.mdx index 077fa7e990..8ef6003570 100644 --- a/docs/build/guides/contract-accounts/autonomous-agents.mdx +++ b/docs/build/guides/contract-accounts/autonomous-agents.mdx @@ -6,7 +6,7 @@ sidebar_position: 45 A contract account restricts _who_ can act and _under what conditions_ (see [Advanced contract account patterns](./advanced-patterns.mdx)). Those guardrails are usually installed once, for a human session. An autonomous agent is different: the same signing key stays live indefinitely, deciding on its own when to act, often many times a day. The restriction has to hold not just at setup, but for as long as the agent keeps running — including the day someone revokes it while the agent is mid-loop. -This page covers two layered ways to bound an agent's authority, and three pitfalls that only show up once the agent is actually operating, not when you install the policy. +This page covers two layered ways to bound an agent's authority, and four pitfalls that only show up once the agent is actually operating, not when you install the policy. ## Two ways to bound authority, and why you want both @@ -24,6 +24,10 @@ Worth being precise about what "deployed" proves versus what "operating" proves, [Recording-mode simulation](https://developers.stellar.org/docs/learn/fundamentals/contract-development/contract-interactions/transaction-simulation#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. To test the deny path, submit the transaction in enforcement mode and confirm it actually fails on-chain — don't infer it from a clean simulation. +### A delegated signer's own requirement doesn't show up in signer discovery either + +If the key-wrapping layer uses a delegated signer (the account authenticates by requiring a _second_, separate address to also authorize, rather than checking a signature itself), don't rely on the SDK's standard signer-discovery helpers to tell you that second signature is needed. Confirmed empirically against a real deployment: the delegate's address is absent from `AssembledTransaction.needsNonInvokerSigningBy()`'s output and from the simulation's own raw list of authorization entries, in both authorization modes the RPC exposes ([full repro and both `authMode` results](https://github.com/OpenZeppelin/stellar-contracts/issues/863)) — even though that second signature is genuinely required for the call to succeed. This lines up with recording-mode simulation never executing the account's own authorization logic during discovery, so a requirement that only arises as a side effect of running that logic has nothing to discover ahead of time — a documented pitfall inferred from that behavior, not something independently confirmed against the host implementation itself. Build and attach that signature by hand; don't trust a clean discovery result to mean nothing else needs signing. + ### Re-read authority from chain every cycle, never cache it A human signer revokes access once, and the session ends there. A long-running agent process keeps looping regardless of what changed underneath it, so if it caches "am I still authorized" from the start of the process, a revocation made ten minutes into a multi-day run does nothing until the process restarts. Read the current role or permission state from chain at the top of every cycle, not once at boot. From 7d464d15a27a82e457a43134b174186e99448ae6 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Thu, 3 Sep 2026 09:52:20 -0600 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20defend=20the=20accurate=20claims,=20fix=20the=20rea?= =?UTF-8?q?l=20ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each of Copilot's six review points against real source before responding, per the standing discipline: - DeFindex rebalance(): Copilot was right. The router's own source shows a swap's input leg transfers to the trading pool mid-execution, not only ever back to the vault. Narrowed the claim. - Nirium Invest/Unwind evidence: Copilot was wrong (or working from a stale source) — there are two of each, not one Invest and zero Unwind. Verified b7bf6d70... (Unwind) directly against Horizon before citing it; both hashes now linked explicitly instead of an unbacked claim. - Hardcoded published-site URLs (3 instances): fixed to relative links, verified with the repo's own scripts/check-relative-links.sh. - Caching/revocation: Copilot was right — __check_auth re-reads current rules from chain every check, so an on-chain revocation is effective regardless of what a process cached. Reframed: the real cost of caching is wasted cycles and delayed self-awareness, not a security bypass. - "Enforcement mode": fixed to name the real authMode values (enforce/ record/record_allow_nonroot) and be precise that a real submission, not a special mode, is what actually enforces auth. - AuthMode count/API usage: Copilot's core technical point (three modes, not two) is correct per the real CHANGELOG, even though the specific file path it cited doesn't exist in this repo. Checking this surfaced a real methodological gap in the linked issue's own multi-mode claim (OpenZeppelin/stellar-contracts#863) — softened this page's citation to what's independently confirmed, added a real on-chain settlement of the delegated-signer case as stronger evidence. pnpm exec prettier -c and scripts/check-relative-links.sh --range both pass clean. Co-Authored-By: Claude Sonnet 5 --- .../guides/contract-accounts/autonomous-agents.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/build/guides/contract-accounts/autonomous-agents.mdx b/docs/build/guides/contract-accounts/autonomous-agents.mdx index 8ef6003570..634ebb36dc 100644 --- a/docs/build/guides/contract-accounts/autonomous-agents.mdx +++ b/docs/build/guides/contract-accounts/autonomous-agents.mdx @@ -10,27 +10,27 @@ This page covers two layered ways to bound an agent's authority, and four pitfal ## Two ways to bound authority, and why you want both -**Restrict the callee.** The safest scope is a capability the contract you're calling never implements in the first place. A vault that only exposes `Invest(strategy, amount)` and `Unwind(strategy, amount)`, with the destination hardcoded to the vault's own address in every code path, cannot pay out to an arbitrary address no matter what the caller's key can sign. Withdrawal isn't denied by a check; it's absent from the interface. [DeFindex's vault](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example: its `rebalance()` instruction set is `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, and none of the four take a destination argument — every branch resolves the transfer to `e.current_contract_address()`. +**Restrict the callee.** The safest scope is a capability the contract you're calling never implements in the first place. A vault that only exposes `Invest(strategy, amount)` and `Unwind(strategy, amount)`, with no destination argument anywhere in the interface, cannot be told to pay out to an arbitrary address no matter what the caller's key can sign. Withdrawal isn't denied by a check; it's absent from the interface. [DeFindex's vault](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example: its `rebalance()` instruction set is `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, and none of the four take a destination argument. That's a narrower guarantee than "funds only ever move back into the vault," though — a swap's input leg does transfer out to the trading pool mid-execution ([verified directly against the router's own source](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/router.rs)), and the pool address itself is resolved on-chain from the token pair, not supplied by the caller. What the caller genuinely cannot do is redirect a payout or a swap's output to an address of their choosing. **Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract can't move funds anywhere but back into itself. -Worth being precise about what "deployed" proves versus what "operating" proves, since the two layers don't reach the same bar in practice at the same time. Nirium's treasury agent has the callee-side restriction backing real, signed `Invest`/`Unwind` calls against a live DeFindex vault. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](https://developers.stellar.org/docs/tools/openzeppelin-contracts) and deployed on testnet at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), has its intended rule confirmed by reading `context_rules` on-chain — but hasn't yet authorized a real signed transaction through it. Deployed-with-the-right-rule and exercised-end-to-end are different claims; verify which one you're actually looking at before relying on either. +Worth being precise about what "deployed" proves versus what "operating" proves, since the two layers don't reach the same bar in practice at the same time. Nirium's treasury agent has the callee-side restriction backing real, signed calls against a live DeFindex vault: [an `Unwind`](https://stellar.expert/explorer/public/tx/b7bf6d7079aa128081ac2a5091cbff2da2b1e9f2157232209cf03eab00f840a9) and [an `Invest`](https://stellar.expert/explorer/public/tx/9f0e7e03cf3f17655b7e1b755c6a3fb5e65c499835424e440e601ecc8b68f033), both settled, decoded from the raw operation rather than taken from a label. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](../../../tools/openzeppelin-contracts.mdx) and deployed on testnet at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), has its intended rule confirmed by reading `context_rules` on-chain — but hasn't yet authorized a real signed transaction through it. Deployed-with-the-right-rule and exercised-end-to-end are different claims; verify which one you're actually looking at before relying on either. ## What only shows up once the agent is running ### Simulation records authorization, it doesn't verify it -[Recording-mode simulation](https://developers.stellar.org/docs/learn/fundamentals/contract-development/contract-interactions/transaction-simulation#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. To test the deny path, submit the transaction in enforcement mode and confirm it actually fails on-chain — don't infer it from a clean simulation. +[Recording-mode simulation](../../../learn/fundamentals/contract-development/contract-interactions/transaction-simulation.mdx#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. `simulateTransaction` takes an `authMode` of `enforce`, `record`, or `record_allow_nonroot` — to test the deny path, actually submit the transaction (a real submission always enforces authorization, `authMode` or not) and confirm it fails on-chain. Don't infer a deny path from a clean recording-mode simulation. ### A delegated signer's own requirement doesn't show up in signer discovery either -If the key-wrapping layer uses a delegated signer (the account authenticates by requiring a _second_, separate address to also authorize, rather than checking a signature itself), don't rely on the SDK's standard signer-discovery helpers to tell you that second signature is needed. Confirmed empirically against a real deployment: the delegate's address is absent from `AssembledTransaction.needsNonInvokerSigningBy()`'s output and from the simulation's own raw list of authorization entries, in both authorization modes the RPC exposes ([full repro and both `authMode` results](https://github.com/OpenZeppelin/stellar-contracts/issues/863)) — even though that second signature is genuinely required for the call to succeed. This lines up with recording-mode simulation never executing the account's own authorization logic during discovery, so a requirement that only arises as a side effect of running that logic has nothing to discover ahead of time — a documented pitfall inferred from that behavior, not something independently confirmed against the host implementation itself. Build and attach that signature by hand; don't trust a clean discovery result to mean nothing else needs signing. +If the key-wrapping layer uses a delegated signer (the account authenticates by requiring a _second_, separate address to also authorize, rather than checking a signature itself), don't rely on the SDK's standard signer-discovery helpers to tell you that second signature is needed. Confirmed empirically against a real deployment: the delegate's address is absent from `AssembledTransaction.needsNonInvokerSigningBy()`'s output and from the simulation's own raw list of authorization entries ([full repro](https://github.com/OpenZeppelin/stellar-contracts/issues/863)), even though that second signature is genuinely required for the call to succeed. Hand-constructing that second entry and submitting it for real settles the point beyond simulation: [a delegated signer authorizing a live call](https://stellar.expert/explorer/testnet/tx/428021a6ef648937bf0edeec96d42f13e44447eac9b036c127c90cf4bebdd71b), confirmed successful on-chain. This lines up with recording-mode simulation never executing the account's own authorization logic during discovery, so a requirement that only arises as a side effect of running that logic has nothing to discover ahead of time — a documented pitfall inferred from that behavior, not something independently confirmed against the host implementation itself. Build and attach that signature by hand; don't trust a clean discovery result to mean nothing else needs signing. ### Re-read authority from chain every cycle, never cache it -A human signer revokes access once, and the session ends there. A long-running agent process keeps looping regardless of what changed underneath it, so if it caches "am I still authorized" from the start of the process, a revocation made ten minutes into a multi-day run does nothing until the process restarts. Read the current role or permission state from chain at the top of every cycle, not once at boot. +An on-chain revocation is effective the moment it lands, regardless of what any process has cached: `__check_auth` re-reads the account's current rules from chain on every check, so the very next transaction the agent tries to sign is rejected whether or not its own process noticed anything changed. What caching actually costs you is time and signal, not security. A long-running agent that only reads "am I still authorized" once at boot keeps looping anyway, spending cycles building and submitting transactions that fail on-chain one after another, instead of noticing the moment its authority changed and stopping or alerting. Read the current role or permission state from chain at the top of every cycle — not to keep the revocation itself effective, but so the agent finds out it's been cut off immediately, not after a string of failed submissions. ### Draw the line between the agent's judgment and the code's limits, and don't let the agent move it @@ -43,6 +43,6 @@ A destination-less instruction set and a policy account with no default rule are ## Where to go next - [Advanced contract account patterns](./advanced-patterns.mdx) for the individual guardrail primitives this page composes. -- [OpenZeppelin Smart Accounts](https://developers.stellar.org/docs/tools/openzeppelin-contracts) — context rules, signers, and policies, audited by OpenZeppelin's security team, with formal verification by Certora in progress. +- [OpenZeppelin Smart Accounts](../../../tools/openzeppelin-contracts.mdx) — context rules, signers, and policies, audited by OpenZeppelin's security team, with formal verification by Certora in progress. - [CAP-71](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0071.md), authentication delegation and address-bound Soroban credentials. - [DeFindex's vault contract](https://github.com/defindex-io/stellar-contracts), a real example of an instruction set with no destination parameter. From 96eed8a8e3dd35d4327d6d35acf8d325d8d85f90 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Thu, 3 Sep 2026 15:05:20 -0600 Subject: [PATCH 5/6] docs: address second Copilot review round on autonomous-agents.mdx Three real findings, all fixed, all verified against this repo's own transaction-simulation.mdx and signing-soroban-invocations.mdx before writing the fix, not assumed from the review comment alone: - The 'composed guarantee' summary sentence still claimed a leaked key plus a restricted callee 'can't move funds anywhere but back into itself', the broader claim already narrowed earlier in the same page (DeFindex's swap-input leg does transfer out). Aligned both sentences to the same, narrower guarantee. - 'To test the deny path, actually submit the transaction' overstated what's needed. Enforcing-mode simulation already validates signatures and executes __check_auth for real ('basically equivalent to running the transaction on-chain', per transaction-simulation.mdx's own description) at no cost, per signing-soroban-invocations.mdx's own 'Failed simulations cost nothing; failed submissions cost real fees.' Submission still works, but isn't the free, recommended check. - 'The very next transaction the agent tries to sign is rejected' conflated signing (a local, offline step that never consults __check_auth) with the actual enforcement points (enforcing-mode simulation or a real submission). Corrected. Co-Authored-By: Claude Sonnet 5 --- docs/build/guides/contract-accounts/autonomous-agents.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/build/guides/contract-accounts/autonomous-agents.mdx b/docs/build/guides/contract-accounts/autonomous-agents.mdx index 634ebb36dc..ea868e2fd7 100644 --- a/docs/build/guides/contract-accounts/autonomous-agents.mdx +++ b/docs/build/guides/contract-accounts/autonomous-agents.mdx @@ -14,7 +14,7 @@ This page covers two layered ways to bound an agent's authority, and four pitfal **Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. -Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract can't move funds anywhere but back into itself. +Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract's own interface leaves no way to redirect a payout or a swap's output to an address of the caller's choosing — the same limited guarantee established above, not the broader "funds only ever move back into the vault" claim that example's own swap-input leg already contradicts. Worth being precise about what "deployed" proves versus what "operating" proves, since the two layers don't reach the same bar in practice at the same time. Nirium's treasury agent has the callee-side restriction backing real, signed calls against a live DeFindex vault: [an `Unwind`](https://stellar.expert/explorer/public/tx/b7bf6d7079aa128081ac2a5091cbff2da2b1e9f2157232209cf03eab00f840a9) and [an `Invest`](https://stellar.expert/explorer/public/tx/9f0e7e03cf3f17655b7e1b755c6a3fb5e65c499835424e440e601ecc8b68f033), both settled, decoded from the raw operation rather than taken from a label. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](../../../tools/openzeppelin-contracts.mdx) and deployed on testnet at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), has its intended rule confirmed by reading `context_rules` on-chain — but hasn't yet authorized a real signed transaction through it. Deployed-with-the-right-rule and exercised-end-to-end are different claims; verify which one you're actually looking at before relying on either. @@ -22,7 +22,7 @@ Worth being precise about what "deployed" proves versus what "operating" proves, ### Simulation records authorization, it doesn't verify it -[Recording-mode simulation](../../../learn/fundamentals/contract-development/contract-interactions/transaction-simulation.mdx#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. `simulateTransaction` takes an `authMode` of `enforce`, `record`, or `record_allow_nonroot` — to test the deny path, actually submit the transaction (a real submission always enforces authorization, `authMode` or not) and confirm it fails on-chain. Don't infer a deny path from a clean recording-mode simulation. +[Recording-mode simulation](../../../learn/fundamentals/contract-development/contract-interactions/transaction-simulation.mdx#recording-mode) records every `require_auth` call as successful, and "never emulates authorization failures... failing authorization is always an 'exceptional' situation." That's the right behavior for building a transaction to sign, and the wrong tool for testing that a restriction actually holds. A policy that looks correctly restrictive under simulation can still pass simulation for an action it should deny, because simulation was never checking the signature in the first place. `simulateTransaction` takes an `authMode` of `enforce`, `record`, or `record_allow_nonroot` — to test the deny path, simulate again in [enforcing mode](../../../learn/fundamentals/contract-development/contract-interactions/transaction-simulation.mdx#enforcing-mode), which validates signatures and actually executes `__check_auth`, "basically equivalent to running the transaction on-chain," per that page's own description. A real on-chain submission would fail the same way, but at the cost of a real fee to prove what a free enforcing-mode simulation already proves. Don't infer a deny path from a clean recording-mode simulation, which skips signature checking entirely. ### A delegated signer's own requirement doesn't show up in signer discovery either @@ -30,7 +30,7 @@ If the key-wrapping layer uses a delegated signer (the account authenticates by ### Re-read authority from chain every cycle, never cache it -An on-chain revocation is effective the moment it lands, regardless of what any process has cached: `__check_auth` re-reads the account's current rules from chain on every check, so the very next transaction the agent tries to sign is rejected whether or not its own process noticed anything changed. What caching actually costs you is time and signal, not security. A long-running agent that only reads "am I still authorized" once at boot keeps looping anyway, spending cycles building and submitting transactions that fail on-chain one after another, instead of noticing the moment its authority changed and stopping or alerting. Read the current role or permission state from chain at the top of every cycle — not to keep the revocation itself effective, but so the agent finds out it's been cut off immediately, not after a string of failed submissions. +An on-chain revocation is effective the moment it lands, regardless of what any process has cached: `__check_auth` re-reads the account's current rules from chain on every check, so the very next transaction the agent tries to submit, or even simulate in enforcing mode, is rejected whether or not its own process noticed anything changed. Signing itself is a local, offline step that never consults `__check_auth`, so it's unaffected either way; revocation only bites once a transaction actually reaches enforcing-mode simulation or the network. What caching actually costs you is time and signal, not security. A long-running agent that only reads "am I still authorized" once at boot keeps looping anyway, spending cycles building and submitting transactions that fail on-chain one after another, instead of noticing the moment its authority changed and stopping or alerting. Read the current role or permission state from chain at the top of every cycle — not to keep the revocation itself effective, but so the agent finds out it's been cut off immediately, not after a string of failed submissions. ### Draw the line between the agent's judgment and the code's limits, and don't let the agent move it From 1eb406e3771ce77e8678c494854a6d68ed881734 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Thu, 3 Sep 2026 15:45:07 -0600 Subject: [PATCH 6/6] fix(docs): scope the destination-less claim to rebalance(), not the whole vault; make the role-assignment dependency explicit Two real points from the latest review, both verified against DeFindex's actual source (interface.rs, access.rs, lib.rs), not just its docs: - interface.rs genuinely exposes withdraw() and Manager-gated admin functions (set_manager, set_fee_receiver, set_emergency_manager, upgrade, distribute_fees, rescue) -- the vault as a whole isn't destination-less, only rebalance()'s own instruction set is. Scoped the claim accordingly. withdraw() itself turned out to be self-service (from.require_auth(), pays from itself) -- not an attack surface, so it's called out as such rather than lumped in with the admin functions. - CallContract(vault) restricts which contract a smart account can reach, not which function on it -- access.rs shows set_manager()/upgrade() are gated by require_role(Manager), a completely separate on-chain fact from the smart account's own context rules. Made explicit that the composed guarantee additionally depends on this address holding only the vault's RebalanceManager role, verifiable via get_rebalance_manager()/get_manager()/get_emergency_manager()/get_fee_receiver(). -f content= --- docs/build/guides/contract-accounts/autonomous-agents.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/build/guides/contract-accounts/autonomous-agents.mdx b/docs/build/guides/contract-accounts/autonomous-agents.mdx index ea868e2fd7..a16180ec6d 100644 --- a/docs/build/guides/contract-accounts/autonomous-agents.mdx +++ b/docs/build/guides/contract-accounts/autonomous-agents.mdx @@ -10,11 +10,11 @@ This page covers two layered ways to bound an agent's authority, and four pitfal ## Two ways to bound authority, and why you want both -**Restrict the callee.** The safest scope is a capability the contract you're calling never implements in the first place. A vault that only exposes `Invest(strategy, amount)` and `Unwind(strategy, amount)`, with no destination argument anywhere in the interface, cannot be told to pay out to an arbitrary address no matter what the caller's key can sign. Withdrawal isn't denied by a check; it's absent from the interface. [DeFindex's vault](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example: its `rebalance()` instruction set is `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, and none of the four take a destination argument. That's a narrower guarantee than "funds only ever move back into the vault," though — a swap's input leg does transfer out to the trading pool mid-execution ([verified directly against the router's own source](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/router.rs)), and the pool address itself is resolved on-chain from the token pair, not supplied by the caller. What the caller genuinely cannot do is redirect a payout or a swap's output to an address of their choosing. +**Restrict the callee.** The safest scope is a capability the contract you're calling never implements in the first place. A vault that only exposes `Invest(strategy, amount)` and `Unwind(strategy, amount)`, with no destination argument anywhere in the interface, cannot be told to pay out to an arbitrary address no matter what the caller's key can sign. Withdrawal isn't denied by a check; it's absent from the interface. [DeFindex's `rebalance()` entry point](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/models.rs) is a real example of this — not DeFindex's vault as a whole, which also exposes `withdraw` (self-service for depositors, gated by `from.require_auth()` and paying `from` itself, not an attack surface for redirecting funds elsewhere) and several administrative functions gated by the Manager role ([`interface.rs`](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/interface.rs)). The destination-less property is scoped to `rebalance()`'s own instruction set: `Unwind`, `Invest`, `SwapExactIn`, and `SwapExactOut`, none of which take a destination argument. That's a narrower guarantee than "funds only ever move back into the vault," though — a swap's input leg does transfer out to the trading pool mid-execution ([verified directly against the router's own source](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/router.rs)), and the pool address itself is resolved on-chain from the token pair, not supplied by the caller. What the caller genuinely cannot do, calling `rebalance()`, is redirect a payout or a swap's output to an address of their choosing. -**Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. +**Restrict the key.** The complementary layer is scoping the caller's own credential, using the account-abstraction primitives already covered on this page and the [previous one](./advanced-patterns.mdx): a smart account whose only context rule is `CallContract(the one vault you want)`, with no default/catch-all rule installed. The absence of a fallback rule is what does the work — a context that isn't covered by an explicit rule has no rule that authorizes it. `CallContract` scopes the *contract address*, not which function on it gets called — it says nothing about whether the account invokes `rebalance()` or `set_manager()`. The function-level bound has to come from the callee's own access control instead, gated on which role the callee's contract has assigned to this specific address. -Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later. Composed, an agent's key can only reach one contract, and that contract's own interface leaves no way to redirect a payout or a swap's output to an address of the caller's choosing — the same limited guarantee established above, not the broader "funds only ever move back into the vault" claim that example's own swap-input leg already contradicts. +Neither layer is sufficient alone for an unattended agent. Restrict only the callee, and a leaked key can still authorize anything the callee's own logic happens to allow — a swap at bad slippage, say, if the callee supports swaps at all. Restrict only the key, and you're trusting that the callee's logic never grows a footgun later — and, per the point above, `CallContract(the vault)` alone doesn't stop that key from *attempting* `set_manager()` or `upgrade()` on the same contract; DeFindex's [`access.rs`](https://github.com/defindex-io/stellar-contracts/blob/main/vault/src/access.rs) gates those behind `require_role(&RolesDataKey::Manager)`, a check this address only passes if it was independently granted the Manager role — a mistake in the vault's own setup, not something the smart account's context rules would catch. Composed *correctly* — `CallContract(the vault)` as the sole context rule, and this address holding only the vault's RebalanceManager role (verifiable on-chain: `get_rebalance_manager()` returns this address, `get_manager()`/`get_emergency_manager()`/`get_fee_receiver()` don't) — an agent's key can only reach one contract, and the one entry point DeFindex's own role check actually lets it call leaves no way to redirect a payout or a swap's output to an address of the caller's choosing — the same limited guarantee established above, not the broader "funds only ever move back into the vault" claim that example's own swap-input leg already contradicts. Worth being precise about what "deployed" proves versus what "operating" proves, since the two layers don't reach the same bar in practice at the same time. Nirium's treasury agent has the callee-side restriction backing real, signed calls against a live DeFindex vault: [an `Unwind`](https://stellar.expert/explorer/public/tx/b7bf6d7079aa128081ac2a5091cbff2da2b1e9f2157232209cf03eab00f840a9) and [an `Invest`](https://stellar.expert/explorer/public/tx/9f0e7e03cf3f17655b7e1b755c6a3fb5e65c499835424e440e601ecc8b68f033), both settled, decoded from the raw operation rather than taken from a label. The key-wrapping layer, built on the [OpenZeppelin Smart Accounts framework](../../../tools/openzeppelin-contracts.mdx) and deployed on testnet at [`CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML`](https://stellar.expert/explorer/testnet/contract/CCZW2WIFAD7OQX35U5AILTNF32TCHQUYVPNB32GGKEKKPII2HF7B5LML), has its intended rule confirmed by reading `context_rules` on-chain — but hasn't yet authorized a real signed transaction through it. Deployed-with-the-right-rule and exercised-end-to-end are different claims; verify which one you're actually looking at before relying on either.