Anchor 0.30.1 / Rust on-chain program. Deploys to Solana devnet.
Seeds: ["policy", owner, agent]
Size: ~500 bytes with room for growth
#[account]
pub struct PermissionPolicy {
pub owner: Pubkey, // Human owner who can modify policy
pub agent: Pubkey, // Agent session pubkey (from Swig or raw)
pub allowed_programs: Vec<Pubkey>, // Whitelist — max 10 for MVP
pub max_tx_lamports: u64, // Per-txn SOL cap
pub max_tx_token_units: u64, // Per-txn SPL cap (scaled, see §4)
pub daily_budget_lamports: u64, // Rolling 24h SOL cap
pub daily_spent_lamports: u64, // Running counter
pub last_reset_ts: i64, // For daily budget rollover
pub session_expiry: i64, // Unix ts; agent key unusable after
pub is_active: bool, // Kill switch
pub paused_by: Option<Pubkey>, // Who paused (monitor / owner / squad)
pub paused_reason: [u8; 64], // Short reason code
pub squads_multisig: Option<Pubkey>,// If set, txns > escalation_threshold require Squads approval
pub escalation_threshold: u64, // Lamports above which Squads approval required
pub authorized_monitors: Vec<Pubkey>,// Off-chain monitors allowed to pause (max 3)
pub anomaly_score: u8, // Last judge score 0-100
pub bump: u8,
}Seeds: ["tracker", policy_pubkey]
Separate account so frequent updates don't realloc the main policy.
#[account]
pub struct SpendTracker {
pub policy: Pubkey,
pub window_start: i64,
pub txn_count_24h: u32,
pub lamports_spent_24h: u64,
pub last_txn_ts: i64,
pub last_txn_program: Pubkey,
pub bump: u8,
}| Instruction | Who calls | What it does |
|---|---|---|
initialize_policy |
Owner | Creates PermissionPolicy + SpendTracker PDAs |
update_policy |
Owner | Modifies limits, whitelists, monitors |
guarded_execute |
Agent | Validates intent, CPIs to target program with PDA signer |
pause_agent |
Owner OR authorized monitor | Flips is_active = false with reason |
resume_agent |
Owner only | Re-enables after manual review |
rotate_agent_key |
Owner | Swap to a new agent pubkey without losing spend history |
escalate_to_squads |
Internally called by guarded_execute |
Creates a Squads proposal for txns above threshold |
Agent signs a txn calling Guardrails::guarded_execute with:
- target_program: Pubkey
- instruction_data: Vec<u8>
- account_metas: Vec<AccountMeta> (reconstructed for CPI)
- amount_hint: u64 (for budget enforcement; verified separately)
Program logic (in order):
1. Load PermissionPolicy for (owner, agent) — fail if PDA doesn't exist
2. Assert policy.is_active == true → else PolicyPaused
3. Assert clock.unix_timestamp < session_expiry → else SessionExpired
4. Assert target_program ∈ allowed_programs → else ProgramNotWhitelisted
5. Assert amount_hint <= max_tx_lamports → else AmountExceedsLimit
6. Roll daily budget if now > last_reset_ts + 86400
7. Assert daily_spent + amount_hint <= daily_budget → else DailyBudgetExceeded
8. If amount_hint > escalation_threshold AND squads_multisig.is_some():
CPI to Squads to create proposal; return early with "Escalated"
9. Emit GuardedTxnAttempted event (for Helius webhook)
10. CPI to target_program with reconstructed instruction, signer_seeds = [policy seeds]
11. On success: update SpendTracker (atomic), emit GuardedTxnExecuted event
12. On failure: emit GuardedTxnRejected event with reason
An agent could pass a false amount_hint. Mitigations:
- System Program: parse instruction data to extract true amount before CPI
- Token Program: inspect instruction discriminator + amount bytes
- Jupiter/DeFi: use pre/post balance diff on designated "source" token account — flag post-hoc via monitor rather than block on-chain
- MVP: whitelist only System Program + Token Program + Jupiter where we hand-parse amounts; other programs allowed but not budget-tracked
System Program transfer:
Bytes 0-3: instruction index (2 = Transfer, little-endian u32)
Bytes 4-11: lamports amount (u64 little-endian)
account_metas[0] = source (signer, writable)
account_metas[1] = destination (writable)
Token Program transfer:
Byte 0: instruction discriminator (3 = Transfer)
Bytes 1-8: amount (u64 little-endian)
account_metas[0] = source token account (writable)
account_metas[1] = destination token account (writable)
account_metas[2] = authority (signer)
Parse these before CPI to verify amount_hint matches. If mismatch, reject with AmountExceedsLimit.
- SpendTracker is updated only on successful CPI (step 11, not before step 10)
- If CPI fails, amount is NOT deducted — the
GuardedTxnRejectedevent is emitted instead lamports_spent_24his incremented by the verified amount (parsed from instruction data, notamount_hint)txn_count_24his incremented by 1 per successfulguarded_execute- Rolling window reset: if
Clock::get().unix_timestamp > window_start + 86400, resetlamports_spent_24h = 0,txn_count_24h = 0,window_start = now
The transaction has TWO signers:
- Agent session key — signs the outer
guarded_executetransaction (fee payer) - Policy PDA — signs the inner CPI via
invoke_signedwith seeds["policy", owner, agent, &[bump]]
The agent's keypair is NOT passed to the CPI. Only the policy PDA authority is used downstream. This means the target program sees the policy PDA as the signer/authority, not the agent.
When amount_hint > escalation_threshold and squads_multisig.is_some():
- Do NOT execute the CPI to the target program
- Instead, return the
EscalatedToMultisigerror (step 8) - The server worker pipeline catches this error and creates a Squads v4 proposal via the TypeScript SDK (not CPI from Anchor — simpler for MVP)
- The proposal includes: target program, instruction data, amount, policy pubkey
- Once Squads members approve and execute the proposal, the funds move from the Squads vault directly — bypassing Guardrails for the approved txn
MVP approach: The on-chain program only returns the error. All Squads proposal creation happens off-chain in the server. This avoids complex CPI to Squads from within the program.
The PermissionPolicy PDA is the signer authority for the agent's scoped actions. The agent session pubkey signs the guarded_execute txn, but the actual downstream CPI is signed by the policy PDA using invoke_signed with the bump seed.
The agent's keypair holds no direct funds. Funds live in a token account owned by the policy PDA. The agent instructs, the PDA acts.
What Helius will stream to the server worker pipeline.
#[event]
pub struct GuardedTxnExecuted {
pub policy: Pubkey,
pub agent: Pubkey,
pub target_program: Pubkey,
pub amount: u64,
pub timestamp: i64,
pub txn_sig: String,
}
#[event]
pub struct GuardedTxnRejected {
pub policy: Pubkey,
pub agent: Pubkey,
pub reason: u8, // Enum: ProgramNotWhitelisted, AmountExceeds, etc.
pub timestamp: i64,
}
#[event]
pub struct AgentPaused {
pub policy: Pubkey,
pub paused_by: Pubkey,
pub reason: [u8; 64],
pub timestamp: i64,
}
#[event]
pub struct EscalatedToSquads {
pub policy: Pubkey,
pub squads_proposal: Pubkey,
pub amount: u64,
}#[error_code]
pub enum GuardrailsError {
#[msg("Policy is paused by owner or monitor")]
PolicyPaused,
#[msg("Session has expired")]
SessionExpired,
#[msg("Target program is not on the allow-list")]
ProgramNotWhitelisted,
#[msg("Transaction amount exceeds per-tx limit")]
AmountExceedsLimit,
#[msg("Daily budget exceeded")]
DailyBudgetExceeded,
#[msg("Caller is not an authorized monitor or owner")]
UnauthorizedPauser,
#[msg("Only owner can resume a paused agent")]
ResumeRequiresOwner,
#[msg("Escalation required — proposal created on Squads")]
EscalatedToMultisig,
}LiteSVM in-process tests (no validator needed):
cd program && anchor test --skip-local-validator --skip-deployTest cases:
initialize_policy— creates both PDAs, verify field valuesupdate_policy— modify limits, add/remove programs, change monitorsguarded_executehappy path — System Program SOL transfer succeedsguarded_executerejection — wrong program, over limit, expired session, pausedpause_agent— authorized monitor can pause, unauthorized cannotresume_agent— only owner, not monitorSpendTracker— partial spends, rollover at 24h, exceeding daily budgetescalate_to_squads— amount above threshold triggers proposal
- Account structs + errors + events (Mon W1)
initialize_policy+update_policy+ tests (Tue W1)guarded_executeSystem Program happy path (Wed W1)guarded_executeToken Program + Jupiter (Thu W1)pause_agent+resume_agent(Fri W1)- Integration tests — all paths (Sat W1)
- Deploy devnet + IDL + smoke test (Sun W1)
SpendTracker+ rolling budget (Mon-Tue W2)- Session expiry +
rotate_agent_key(Wed W2) - Swig integration (Thu-Fri W2)
- Squads v4 escalation (Sat W2)