| Threat | Mitigation |
|---|---|
| LLM produces unintended/malicious intent | Intent schema validation + deterministic policy engine deny-by-default |
| Unauthorized transaction signing | Private keys never in source; signing isolated behind Signer interface; simulation gate |
| Asset theft via unexpected asset | Allowed-assets allowlist (deny by default) |
| Funds sent to wrong/blacklisted destination | Allowed-destinations allowlist + destination format validation |
| Excessive spending | Per-tx and daily spending limits; approval threshold |
| Replay attacks | Stellar sequence numbers + timebounds on all transactions |
| Policy bypass via malformed intent | Strict schema validation before policy evaluation |
| Signer bypass via direct invocation | Pipeline is the only public entry point; signer is not exposed to callers |
| Secret leakage through API responses | Signer interface has no key-returning methods; activity store asserts no secrets |
| Frontend compromise exposes secrets | Frontend never receives private keys; all signing server-side |
| Daily limit race | Daily spending limit uses read-then-write across the activity store; two near-simultaneous intents can both pass the check before either record lands. Not safe for concurrent writers yet |
| Contract call injection | Contract calls disabled by default; explicit allowlist required |
- Never: private keys in source code, environment example files, frontend code, logs, or tests.
- Server-side: keys loaded from a secrets manager or KMS at runtime, never committed.
- Testnet (development only):
TestnetLocalSignerloads a secret key from theSTELLAR_TESTNET_SECRET_KEYenvironment variable. It is testnet-only, never committed, never exposed through the API, and never logged. - Production planning: HSM or MPC-based signing with policy-gated access. The
Signerinterface is the seam — production implementations (UserWalletSigner, AgentPermissionSigner, HardwareSigner, KmsSigner) plug in without changing the pipeline.
The LLM's role is strictly intent generation:
LLM output (natural language) → Intent schema (typed, validated) → Policy engine → Transaction
The LLM:
- May NOT produce raw transaction envelopes or XDR.
- May NOT choose which rules to apply.
- May NOT bypass policy evaluation.
- May NOT sign transactions directly.
- May NOT receive a reference to the Signer.
The policy engine is a pure function with no dependency on the LLM or any external state except the explicit rule set.
The Signer interface is the cryptographic boundary:
interface Signer {
getAccountId(): string;
getNetworkPassphrase(): string;
sign(transaction: Transaction): Promise<Transaction>;
}
The pipeline holds a Signer reference at construction time. The LLM layer never receives one. The interface exposes only the public account ID and network passphrase — never private key material, seeds, or mnemonics.
TestnetLocalSigner is development infrastructure, not the final wallet architecture. It exists for controlled testing against the Stellar testnet. Production implementations will include:
UserWalletSigner— user-custodied wallet signing (e.g., WalletConnect / xBull / Freighter)AgentPermissionSigner— scoped on-chain signer with an on-chain policy contractHardwareSigner/KmsSigner— hardware-backed or cloud-KMS-backed signing
These are not yet implemented. The interface is designed so they can be added without changing the pipeline.
Rules are evaluated in order:
- Transaction type restriction — is this intent type allowed at all? (deny by default)
- Amount checks — exceeds per-tx max? daily limit? requires approval?
- Asset allowlist — is the asset permitted?
- Destination allowlist — is the destination permitted? (payments only)
- Contract ID allowlist — is the contract permitted? (contract calls only)
A single deny short-circuits to deny. Amounts at or above the approval threshold return requires_approval, routing to a human review queue.
The pipeline runs each gate in sequence. A failure at any gate aborts the chain:
1. Intent validation (IntentValidator)
2. Policy evaluation (PolicyEngine)
3. Authorization gate (allow + !requires_approval, or pre-approved)
4. Transaction construction (StellarTransactionBuilder)
5. Simulation (StellarSimulator)
6. Signing (Signer — interface-abstracted)
7. Submission (StellarSubmitter)
8. Activity record
No individual step is exposed as a public method that callers can chain arbitrarily. TransactionPipeline.execute() is the only public entry point.
Every intent that passes policy evaluation enters the simulation gate:
- Construct the transaction (unsigned) from the validated intent.
- Call the Stellar Horizon API to verify:
- The transaction envelope XDR is well-formed.
- The source account exists.
- The transaction sequence number is exactly the on-chain sequence + 1.
- The fee is >= the network base fee.
- The source account's XLM balance covers amount + fee + minimum reserve.
- If simulation succeeds: record fee, warnings, operation count. Proceed to signing.
- If simulation fails: record error, abort. Activity status =
failed.
Simulation is a MANDATORY gate. A transaction that is not simulated, or whose simulation failed, MUST NOT be signed or submitted. There are no fake or cached simulation results.
Stellar's native sequence numbers are used. Each transaction includes:
- Current account sequence number
- Timebounds (valid window)
- (Future) Memo for intent correlation
| Limit | Default | Scope |
|---|---|---|
maxTxAmount |
100 XLM | Per-transaction |
dailySpendingLimit |
500 XLM | Per-agent per-day |
approvalThreshold |
50 XLM | Amount at/above requires human approval |
Limits are configurable per-agent via policy rules. The policy engine is the single source of truth.
Intent passes policy → Simulation succeeds → Decision = requires_approval?
→ YES → Queue for human approval (owner or designated approver)
→ NO → Proceed to signing
Approval state is recorded in the activity log. An approval is bound to a specific intent hash — it cannot be reused for a different intent.
LIMITATION (Phase 3): Approval state is persisted in an ApprovalStore (SQLite or in-memory). The approve/reject endpoints are stateless HTTP handlers that transition records. Cross-agent approval isolation IS enforced — canApprove/canReject check that the caller owns the agent via ResourceAuthorizationService.
After approval, execution is enqueued through the persistent ExecutionQueue with retry and dead-letter semantics. If the process exits mid-execution, the ExecutionRecoveryService re-queues stuck records on startup.
Every agent has an ownerId. Authorization is enforced server-side via RequestContext + ResourceAuthorizationService:
Agent {
id: string;
ownerId: string;
stellarAddress: string;
capabilities: string[];
}
ActivityRecord {
id: string;
agentId: string;
ownerId: string;
intent: AgentIntent;
status: string;
}
ApprovalRecord {
id: string;
agentId: string;
ownerId: string;
status: ApprovalStatus;
}
Authorization Rules:
- Caller can only read/write own agents
- Caller can only read own activity
- Caller can only read own approvals
- Caller can only approve/reject own approvals
- Cross-owner access returns 404 (not 403) to avoid leaking existence
Development Identity: In dev/test, identity is deterministic via ServerOptions.requestContext: { ownerId }. No production auth is implemented.
Known Limitation: Identity mechanism is abstraction-ready but NOT production-ready. No OAuth/JWT/wallet auth implemented.
The API server supports two authentication modes, selected by environment configuration at startup:
When API_KEYS is not set, DevAuthProvider authenticates every request as a
single development owner (DEV_OWNER_ID, default operator). This mode exists
for local development and testing only. The dashboard does not require a token.
When API_KEYS is set, ProductionApiKeyAuthProvider validates requests via
the Authorization: Bearer <key> header. Each API key maps to exactly one
server-configured identity.
API_KEYS=<key>:<ownerId>:<subject>,<key>:<ownerId>:<subject>
- Entries are separated by
,; fields within an entry by:. key— the credential secret. Generate a cryptographically random value, store it as a server-side secret, never commit it, never log it.ownerId— the owner identity the key maps to. Comes only from this server-side configuration; a client can never choose or override itsownerId(verified byproduction-auth.test.ts).subject— optional identity metadata for the authenticated request. Defaults tokey:<first 8 chars>when omitted.
Behavior:
- Invalid credentials (missing/malformed header, unknown key) → request is
unauthenticated; protected endpoints return
401. - Malformed
API_KEYSconfiguration (entry withoutkey:ownerId) → the server fails fast at startup (process.exit(1)). It never silently falls back toDevAuthProviderin production mode. - An empty
API_KEYSvalue is treated as unset → development mode.
See packages/shared/src/production-auth.ts and
apps/api/src/server.ts for the implementation.
(Future work in MVP, but planned as a core primitive)
- The policy engine reads rules from a config that can be hot-swapped.
- An emergency "kill switch" key (held by the deployment operator) can push an all-deny policy to all agents instantly.
- On-chain agent registry supports
deactivate_agentto permanently tombstone an agent's identity (the id cannot be re-registered, even by the original owner; there is no reactivation entrypoint).stellar_addressis bound with that address's consent on first registration.
Frontend (web) ———— no secrets ————> Backend API (api)
| |
| reads public agent data | holds keys, policy engine,
| displays activity | Stellar adapter, orchestrator
| submits intents for approval |
The frontend NEVER receives:
- Private keys or seeds
- Raw signing capability
- Policy rule definitions (only the resulting decision)
All signing happens in the backend through a hardened, isolated path. The frontend can request approval status and view activity records, but cannot trigger signing directly.
Every action produces an ActivityRecord:
agentId | intent | policyDecision | authorizationStatus | simulationResult | txHash | status | timestamp | error
Records are append-only. The store's record() method runs assertNoSecrets() before persisting — if any field contains a marker like "secret", "seed", "private_key", or "mnemonic", the write is rejected. In the MVP they are stored in the database; future work moves critical fields on-chain via the AgentRegistry contract.