> ## Documentation Index
> Fetch the complete documentation index at: https://acachete.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Security Model

> Why the policy contract, not the MCP server or the agent's key, is the real trust boundary -- and the two protocol-level details that were wrong on first attempt.

## 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.

<Warning>
  **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](/guides/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.
</Warning>

## 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.

<Frame>
  <img src="https://mintcdn.com/acachete/KAJaZsJCqe34mxL9/images/diagrams/03-policy-decision.svg?fit=max&auto=format&n=KAJaZsJCqe34mxL9&q=85&s=c3fc75776907cfaff02c834b5ceb9d50" alt="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." width="1100" height="1279" data-path="images/diagrams/03-policy-decision.svg" />
</Frame>

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.

<Note>
  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.
</Note>

<Warning>
  **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.
</Warning>

## 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.

<Frame>
  <img src="https://mintcdn.com/acachete/KAJaZsJCqe34mxL9/images/diagrams/02-signing-flow.svg?fit=max&auto=format&n=KAJaZsJCqe34mxL9&q=85&s=3aa50b10bb5f4d75895f8f895d477362" alt="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." width="1251" height="2009" data-path="images/diagrams/02-signing-flow.svg" />
</Frame>

**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.

<CodeGroup>
  ```rust agent-policy -- the enforcement (Rust) theme={null}
  fn policy__(env: Env, source: Address, _signer: SignerKey, contexts: Vec<Context>) {
      source.require_auth();
      // deny-by-default: every {contract, method} must be explicitly allowed
      // cumulative spend, only for calls with an amount argument, checked
      // against a rolling 24h window -- see contracts/agent-policy/src/lib.rs
  }
  ```

  ```typescript src/scval-encoders.ts -- the signature shape (TypeScript) theme={null}
  // Signature::Policy carries no signing material -- its mere PRESENCE in
  // the map is what tells __check_auth to invoke policy__.
  export function signaturePolicy(): xdr.ScVal {
    return xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Policy")]);
  }
  ```
</CodeGroup>

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](/guides/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."
