1#![doc = include_str!("../README.md")]
7#![forbid(unsafe_code)]
8
9mod action;
10mod audit;
11mod caps;
12mod deny;
13mod fakes;
14mod hygiene;
15mod kill;
16mod policy;
17mod simulate;
18
19use async_trait::async_trait;
20use time::OffsetDateTime;
21
22use cratefield_core::{
23 AnyError, BoxFuture, Clock, Config, ConfigError, IdGen, Migrations, Module, ModuleContext,
24 Port, SystemClock, UlidIdGen,
25};
26
27pub use crate::action::{
28 Action, Authorization7702, Chain, Request, SolanaInstruction, SwapQuote, TokenKey, U256,
29 u256_from_u128, u256_is_unlimited, u256_to_u128,
30};
31pub use crate::audit::{
32 AuditEntry, AuditError, AuditSink, CapExceeded, DenyReason, HardDeny, NotAllowlisted, Severity,
33 Verdict,
34};
35pub use crate::caps::{Caps, LedgerError, Prices, PricesError, SpendEntry, SpendLedger};
36pub use crate::deny::{
37 SEL_APPROVE, SEL_INCREASE_ALLOWANCE, SEL_PERMIT_2612, SEL_PERMIT_DAI, SEL_PERMIT2_APPROVE,
38 SEL_PERMIT2_BATCH, SEL_PERMIT2_SINGLE, SEL_SET_APPROVAL_FOR_ALL, SEL_TRANSFER,
39 SEL_TRANSFER_FROM, SPL_TOKEN_2022_PROGRAM, SPL_TOKEN_PROGRAM, SYS_ADVANCE_NONCE, SYS_ASSIGN,
40 SYS_ASSIGN_WITH_SEED, SYS_TRANSFER, SYSTEM_PROGRAM, TOK_APPROVE, TOK_APPROVE_CHECKED,
41 TOK_REVOKE, TOK_SET_AUTHORITY, TOK_TRANSFER, TOK_TRANSFER_CHECKED, hard_denies,
42 system_discriminant, tok_discriminant, word, word_addr,
43};
44pub use crate::fakes::{
45 FailingAudit, FixedPrices, MemoryAllowances, MemoryAudit, MemoryKillSwitch, MemoryLedger,
46 RecordedSpend, RecordingSigner, Script, ScriptedSimulator, SignScript,
47};
48pub use crate::hygiene::{
49 Allowance, AllowanceError, AllowanceSource, HygieneFinding, HygieneReason, Remedy,
50 revoke_action, revoke_calldata,
51};
52pub use crate::kill::{KillSwitch, KillSwitchError, Scope, turnkey_deny_policy};
53pub use crate::policy::{Allowlist, Policy, SubjectPolicy, allowlist_denies, solana_destination};
54pub use crate::simulate::{
55 ApprovalChange, BalanceChange, CallFrame, CallKind, SimError, SimulationReport, Simulator,
56};
57
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60#[error("guardrails: required port not wired: {0}")]
61pub struct BuildError(&'static str);
62
63#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65#[error("wallet signer: {0}")]
66pub struct SignError(pub String);
67
68#[async_trait]
73pub trait WalletSigner: Send + Sync {
74 async fn sign(&self, request: &Request) -> Result<Vec<u8>, SignError>;
80}
81
82#[derive(Debug)]
87pub struct Approval {
88 request: Request,
89 _seal: Seal,
90}
91
92#[derive(Debug, Clone, Copy)]
93pub(crate) struct Seal;
94
95impl Approval {
96 #[must_use]
98 pub fn request(&self) -> &Request {
99 &self.request
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Denied {
106 pub reasons: Vec<DenyReason>,
108}
109
110impl std::fmt::Display for Denied {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 write!(f, "denied:")?;
113 for reason in &self.reasons {
114 write!(f, " {reason}")?;
115 }
116 Ok(())
117 }
118}
119
120impl std::error::Error for Denied {}
121
122pub struct Guardrails {
125 policy: Policy,
126 kill: std::sync::Arc<dyn KillSwitch>,
127 sim: std::sync::Arc<dyn Simulator>,
128 prices: std::sync::Arc<dyn Prices>,
129 ledger: std::sync::Arc<dyn SpendLedger>,
130 audit: std::sync::Arc<dyn AuditSink>,
131 clock: std::sync::Arc<dyn Clock>,
132 ids: std::sync::Arc<dyn IdGen>,
133 allowances: Option<std::sync::Arc<dyn AllowanceSource>>,
134}
135
136impl Guardrails {
137 #[must_use]
141 pub fn builder() -> GuardrailsBuilder {
142 GuardrailsBuilder::default()
143 }
144
145 #[must_use]
147 pub fn policy(&self) -> &Policy {
148 &self.policy
149 }
150
151 pub async fn job_allowed(&self, venture: &str, subject: &str) -> bool {
155 self.kill_denies(venture, subject).await.is_empty()
156 }
157
158 pub async fn check(&self, req: Request) -> Result<Approval, Denied> {
168 let summary = req.summary();
169 let now = self.clock.now();
170 match self.evaluate(&req, now).await {
171 Err(mut reasons) => {
172 if self
173 .audit_decision(
174 "check",
175 &req.venture,
176 &req.subject,
177 &summary,
178 Verdict::Deny(reasons.clone()),
179 )
180 .await
181 .is_err()
182 {
183 reasons.push(DenyReason::Port("audit"));
184 }
185 Err(Denied { reasons })
186 }
187 Ok(entries) => {
188 if !entries.is_empty()
190 && self
191 .ledger
192 .record(&req.subject, &entries, now)
193 .await
194 .is_err()
195 {
196 let reasons = vec![DenyReason::Port("ledger")];
197 let _ = self
198 .audit_decision(
199 "check",
200 &req.venture,
201 &req.subject,
202 &summary,
203 Verdict::Deny(reasons.clone()),
204 )
205 .await;
206 return Err(Denied { reasons });
207 }
208 if self
210 .audit_decision(
211 "check",
212 &req.venture,
213 &req.subject,
214 &summary,
215 Verdict::Allow,
216 )
217 .await
218 .is_err()
219 {
220 return Err(Denied {
221 reasons: vec![DenyReason::Port("audit")],
222 });
223 }
224 Ok(Approval {
225 request: req,
226 _seal: Seal,
227 })
228 }
229 }
230 }
231
232 pub async fn scan_allowances(
240 &self,
241 venture: &str,
242 subject: &str,
243 chain: &Chain,
244 owner: &str,
245 ) -> Vec<HygieneFinding> {
246 let Some(source) = &self.allowances else {
247 return Vec::new();
248 };
249 let Chain::Evm { chain_id } = chain else {
250 return Vec::new();
251 };
252 let Ok(open) = source.open_allowances(chain, owner).await else {
253 tracing::warn!(venture, subject, owner, "guardrails: allowance scan failed");
254 return Vec::new();
255 };
256 let mut findings = Vec::new();
257 for allowance in open {
258 let reason = if u256_is_unlimited(&allowance.amount) {
259 HygieneReason::Unlimited
260 } else if !self.policy.spender_allowed(subject, &allowance.spender) {
261 HygieneReason::SpenderNotAllowlisted
262 } else {
263 continue;
264 };
265 let deny = match reason {
266 HygieneReason::Unlimited => DenyReason::HardDeny(HardDeny::UnlimitedApproval),
267 HygieneReason::SpenderNotAllowlisted => {
268 DenyReason::NotAllowlisted(NotAllowlisted::Spender(allowance.spender.clone()))
269 }
270 };
271 let _ = self
272 .audit_decision(
273 "hygiene",
274 venture,
275 subject,
276 &format!("allowance {} <- {}", allowance.token, allowance.spender),
277 Verdict::Deny(vec![deny]),
278 )
279 .await;
280 let remedy = match revoke_action(*chain_id, &allowance) {
281 None => Remedy::Unremediable,
284 Some(action) if self.policy.auto_revokes(subject) => {
285 let req = Request {
286 venture: venture.to_owned(),
287 subject: subject.to_owned(),
288 from: owner.to_owned(),
289 action: action.clone(),
290 swap: None,
291 };
292 match self.check(req).await {
293 Ok(_) => Remedy::Revoked(action),
294 Err(_) => Remedy::Suggest(action),
295 }
296 }
297 Some(action) => Remedy::Suggest(action),
298 };
299 findings.push(HygieneFinding {
300 allowance,
301 reason,
302 remedy,
303 });
304 }
305 findings
306 }
307
308 async fn kill_denies(&self, venture: &str, subject: &str) -> Vec<DenyReason> {
310 let mut out = Vec::new();
311 for scope in Scope::for_actor(venture, subject) {
312 match self.kill.engaged(&scope).await {
313 Ok(true) => out.push(DenyReason::KillSwitch(scope)),
314 Ok(false) => {}
315 Err(_) => out.push(DenyReason::Port("kill switch")),
316 }
317 }
318 out
319 }
320
321 async fn evaluate(
324 &self,
325 req: &Request,
326 now: OffsetDateTime,
327 ) -> Result<Vec<SpendEntry>, Vec<DenyReason>> {
328 let mut reasons = self.kill_denies(&req.venture, &req.subject).await;
329 if reasons.is_empty() {
330 reasons = hard_denies(req, &self.policy);
331 }
332 if reasons.is_empty() {
333 reasons = allowlist_denies(req, &self.policy);
334 }
335 if !reasons.is_empty() {
336 return Err(reasons);
337 }
338 let report = match self.sim.simulate(req).await {
339 Ok(report) if report.success => report,
340 Ok(_) => return Err(vec![DenyReason::SimulationReverted]),
341 Err(_) => return Err(vec![DenyReason::SimulationFailed]),
342 };
343 reasons = self.report_denies(req, &report);
344 if reasons.is_empty() {
345 return self.cap_denies(req, &report, now).await;
346 }
347 Err(reasons)
348 }
349
350 fn report_denies(&self, req: &Request, report: &SimulationReport) -> Vec<DenyReason> {
354 let subject = &req.subject;
355 let evm = req.chain().is_evm();
356 let mut out = Vec::new();
357 for approval in &report.approvals {
358 if approval.is_unlimited() {
359 out.push(DenyReason::HardDeny(HardDeny::UnlimitedApproval));
360 }
361 if approval.amount != [0_u8; 32]
365 && !self.policy.spender_allowed(subject, &approval.spender)
366 {
367 out.push(DenyReason::NotAllowlisted(NotAllowlisted::Spender(
368 approval.spender.clone(),
369 )));
370 }
371 }
372 for change in &report.balance_changes {
373 if change.delta > 0
376 && change.owner != req.from
377 && !self.policy.destination_allowed(subject, &change.owner, evm)
378 {
379 out.push(DenyReason::NotAllowlisted(NotAllowlisted::Destination(
380 change.owner.clone(),
381 )));
382 }
383 }
384 for call in &report.calls {
385 if call.kind == CallKind::DelegateCall && !self.policy.contract_known(subject, &call.to)
386 {
387 out.push(DenyReason::HardDeny(HardDeny::DelegateCall(
388 call.to.clone(),
389 )));
390 }
391 }
392 out
393 }
394
395 async fn cap_denies(
398 &self,
399 req: &Request,
400 report: &SimulationReport,
401 now: OffsetDateTime,
402 ) -> Result<Vec<SpendEntry>, Vec<DenyReason>> {
403 let Some(caps) = self.policy.caps_for(&req.subject) else {
404 return Err(vec![DenyReason::NoCaps]);
405 };
406 let mut out = Vec::new();
407 let pays = report
411 .balance_changes
412 .iter()
413 .any(|c| c.owner == req.from && c.delta < 0);
414 let receives = report
415 .balance_changes
416 .iter()
417 .any(|c| c.owner == req.from && c.delta > 0);
418 match (&req.swap, pays && receives) {
419 (Some(quote), _) => {
420 let over_bps = if quote.quoted_out == 0 || quote.min_out > quote.quoted_out {
421 u128::MAX
422 } else {
423 (quote.quoted_out - quote.min_out).saturating_mul(10_000) / quote.quoted_out
424 };
425 if over_bps > u128::from(caps.max_slippage_bps) {
426 out.push(DenyReason::SlippageExceeded);
427 }
428 }
429 (None, true) => out.push(DenyReason::SlippageUnbounded),
430 (None, false) => {}
431 }
432 let outflows = outflows_of(req, report);
433 if outflows.is_empty() {
434 return if out.is_empty() {
435 Ok(Vec::new())
436 } else {
437 Err(out)
438 };
439 }
440 if caps.period <= time::Duration::ZERO {
443 return Err(vec![DenyReason::InvalidCaps]);
444 }
445 let Some(period_start) = now.checked_sub(caps.period) else {
446 return Err(vec![DenyReason::InvalidCaps]);
447 };
448 let day_start = now.replace_time(time::Time::MIDNIGHT);
449 let mut entries = Vec::new();
450 for (token, raw) in outflows {
451 let usd = match self.prices.micro_usd(&token.chain, &token.token, raw).await {
452 Ok(Some(usd)) => usd,
453 Ok(None) => {
454 out.push(DenyReason::NoPrice(token.clone()));
455 continue;
456 }
457 Err(_) => {
458 out.push(DenyReason::Port("prices"));
459 continue;
460 }
461 };
462 let limit = |m: &std::collections::BTreeMap<TokenKey, u128>| {
463 m.get(&token).copied().unwrap_or(0)
464 };
465 if usd > limit(&caps.per_action) {
466 out.push(DenyReason::CapExceeded(CapExceeded::PerAction));
467 }
468 match self.ledger.spent(&req.subject, &token, day_start).await {
469 Ok(spent)
470 if spent
471 .checked_add(usd)
472 .is_none_or(|t| t > limit(&caps.per_day)) =>
473 {
474 out.push(DenyReason::CapExceeded(CapExceeded::PerDay));
475 }
476 Ok(_) => {}
477 Err(_) => out.push(DenyReason::Port("ledger")),
478 }
479 match self.ledger.spent(&req.subject, &token, period_start).await {
480 Ok(spent)
481 if spent
482 .checked_add(usd)
483 .is_none_or(|t| t > limit(&caps.per_period)) =>
484 {
485 out.push(DenyReason::CapExceeded(CapExceeded::PerPeriod));
486 }
487 Ok(_) => {}
488 Err(_) => out.push(DenyReason::Port("ledger")),
489 }
490 entries.push(SpendEntry {
491 key: token.clone(),
492 micro_usd: usd,
493 });
494 }
495 if out.is_empty() {
496 Ok(entries)
497 } else {
498 Err(out)
499 }
500 }
501
502 async fn audit_decision(
504 &self,
505 stage: &'static str,
506 venture: &str,
507 subject: &str,
508 action: &str,
509 verdict: Verdict,
510 ) -> Result<(), AuditError> {
511 let severity = match &verdict {
512 Verdict::Allow => Severity::Info,
513 Verdict::Deny(reasons) if reasons.iter().any(DenyReason::is_critical) => {
514 Severity::Critical
515 }
516 Verdict::Deny(_) => Severity::Warn,
517 };
518 let entry = AuditEntry {
519 id: self.ids.ulid(),
520 at: self.clock.now(),
521 venture: venture.to_owned(),
522 subject: subject.to_owned(),
523 action: action.to_owned(),
524 stage,
525 verdict,
526 severity,
527 };
528 let result = self.audit.record(&entry).await;
529 match (&entry.severity, &result) {
530 (Severity::Critical, _) => {
531 tracing::error!(audit = %entry.id, venture = %entry.venture, subject = %entry.subject, stage, "guardrails {}: {}", stage, entry.action);
532 }
533 (Severity::Warn, _) => {
534 tracing::warn!(audit = %entry.id, venture = %entry.venture, subject = %entry.subject, stage, "guardrails {}: {}", stage, entry.action);
535 }
536 (Severity::Info, Err(_)) => {
537 tracing::error!(audit = %entry.id, stage, "guardrails: audit sink failed after an allow");
538 }
539 (Severity::Info, Ok(())) => {}
540 }
541 result
542 }
543}
544
545fn outflows_of(req: &Request, report: &SimulationReport) -> Vec<(TokenKey, u128)> {
548 let chain = req.chain();
549 report
550 .balance_changes
551 .iter()
552 .filter(|c| c.owner == req.from && c.delta < 0)
553 .map(|c| {
554 (
555 TokenKey {
556 chain: chain.clone(),
557 token: c.token.clone(),
558 },
559 c.delta.unsigned_abs(),
560 )
561 })
562 .collect()
563}
564
565#[derive(Default)]
568pub struct GuardrailsBuilder {
569 policy: Option<Policy>,
570 kill: Option<std::sync::Arc<dyn KillSwitch>>,
571 simulator: Option<std::sync::Arc<dyn Simulator>>,
572 prices: Option<std::sync::Arc<dyn Prices>>,
573 ledger: Option<std::sync::Arc<dyn SpendLedger>>,
574 audit: Option<std::sync::Arc<dyn AuditSink>>,
575 allowances: Option<std::sync::Arc<dyn AllowanceSource>>,
576 clock: Option<std::sync::Arc<dyn Clock>>,
577 ids: Option<std::sync::Arc<dyn IdGen>>,
578}
579
580impl GuardrailsBuilder {
581 #[must_use]
583 pub fn policy(mut self, policy: Policy) -> Self {
584 self.policy = Some(policy);
585 self
586 }
587
588 #[must_use]
590 pub fn kill_switch(mut self, kill: impl KillSwitch + 'static) -> Self {
591 self.kill = Some(std::sync::Arc::new(kill));
592 self
593 }
594
595 #[must_use]
597 pub fn simulator(mut self, simulator: impl Simulator + 'static) -> Self {
598 self.simulator = Some(std::sync::Arc::new(simulator));
599 self
600 }
601
602 #[must_use]
604 pub fn prices(mut self, prices: impl Prices + 'static) -> Self {
605 self.prices = Some(std::sync::Arc::new(prices));
606 self
607 }
608
609 #[must_use]
611 pub fn ledger(mut self, ledger: impl SpendLedger + 'static) -> Self {
612 self.ledger = Some(std::sync::Arc::new(ledger));
613 self
614 }
615
616 #[must_use]
618 pub fn audit(mut self, audit: impl AuditSink + 'static) -> Self {
619 self.audit = Some(std::sync::Arc::new(audit));
620 self
621 }
622
623 #[must_use]
625 pub fn allowances(mut self, allowances: impl AllowanceSource + 'static) -> Self {
626 self.allowances = Some(std::sync::Arc::new(allowances));
627 self
628 }
629
630 #[must_use]
632 pub fn clock(mut self, clock: impl Clock + 'static) -> Self {
633 self.clock = Some(std::sync::Arc::new(clock));
634 self
635 }
636
637 #[must_use]
639 pub fn id_gen(mut self, ids: impl IdGen + 'static) -> Self {
640 self.ids = Some(std::sync::Arc::new(ids));
641 self
642 }
643
644 pub fn build(self) -> Result<Guardrails, BuildError> {
650 Ok(Guardrails {
651 policy: self.policy.ok_or(BuildError("policy"))?,
652 kill: self.kill.ok_or(BuildError("kill switch"))?,
653 sim: self.simulator.ok_or(BuildError("simulator"))?,
654 prices: self.prices.ok_or(BuildError("prices"))?,
655 ledger: self.ledger.ok_or(BuildError("ledger"))?,
656 audit: self.audit.ok_or(BuildError("audit"))?,
657 clock: self
658 .clock
659 .unwrap_or_else(|| std::sync::Arc::new(SystemClock)),
660 ids: self.ids.unwrap_or_else(|| std::sync::Arc::new(UlidIdGen)),
661 allowances: self.allowances,
662 })
663 }
664}
665
666pub struct GuardedSigner {
671 engine: std::sync::Arc<Guardrails>,
672 inner: std::sync::Arc<dyn WalletSigner>,
673}
674
675impl GuardedSigner {
676 #[must_use]
678 pub fn new(
679 engine: std::sync::Arc<Guardrails>,
680 inner: std::sync::Arc<dyn WalletSigner>,
681 ) -> Self {
682 Self { engine, inner }
683 }
684
685 pub async fn sign(&self, approval: Approval) -> Result<Vec<u8>, Denied> {
693 let req = approval.request;
694 let summary = req.summary();
695 let reasons = self.engine.kill_denies(&req.venture, &req.subject).await;
696 if !reasons.is_empty() {
697 let mut reasons = reasons;
698 if self
699 .engine
700 .audit_decision(
701 "sign",
702 &req.venture,
703 &req.subject,
704 &summary,
705 Verdict::Deny(reasons.clone()),
706 )
707 .await
708 .is_err()
709 {
710 reasons.push(DenyReason::Port("audit"));
711 }
712 return Err(Denied { reasons });
713 }
714 if self
715 .engine
716 .audit_decision("sign", &req.venture, &req.subject, &summary, Verdict::Allow)
717 .await
718 .is_err()
719 {
720 return Err(Denied {
721 reasons: vec![DenyReason::Port("audit")],
722 });
723 }
724 if let Ok(signature) = self.inner.sign(&req).await {
725 return Ok(signature);
726 }
727 let reasons = vec![DenyReason::Port("signer")];
728 let _ = self
729 .engine
730 .audit_decision(
731 "sign",
732 &req.venture,
733 &req.subject,
734 &summary,
735 Verdict::Deny(reasons.clone()),
736 )
737 .await;
738 Err(Denied { reasons })
739 }
740}
741
742#[derive(Debug, Clone, PartialEq, Eq)]
744pub struct WatchedWallet {
745 pub venture: String,
747 pub subject: String,
749 pub chain: Chain,
751 pub owner: String,
753}
754
755#[derive(Clone)]
759pub struct GuardrailsModule {
760 engine: std::sync::Arc<Guardrails>,
761 watched: std::sync::Arc<Vec<WatchedWallet>>,
762}
763
764impl GuardrailsModule {
765 #[must_use]
767 pub fn new(engine: std::sync::Arc<Guardrails>) -> Self {
768 Self {
769 engine,
770 watched: std::sync::Arc::new(Vec::new()),
771 }
772 }
773
774 #[must_use]
776 pub fn watch(&self, wallet: WatchedWallet) -> Self {
777 let mut watched = (*self.watched).clone();
778 watched.push(wallet);
779 Self {
780 engine: std::sync::Arc::clone(&self.engine),
781 watched: std::sync::Arc::new(watched),
782 }
783 }
784
785 #[must_use]
787 pub fn engine(&self) -> std::sync::Arc<Guardrails> {
788 std::sync::Arc::clone(&self.engine)
789 }
790}
791
792impl Module for GuardrailsModule {
793 fn name(&self) -> &'static str {
794 "guardrails"
795 }
796
797 fn version(&self) -> &'static str {
798 env!("CARGO_PKG_VERSION")
799 }
800
801 fn requires(&self) -> &'static [Port] {
802 &[]
804 }
805
806 fn migrations(&self) -> Migrations {
807 Migrations::EMPTY
808 }
809
810 fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
811 Ok(())
812 }
813
814 fn router(&self, _ctx: ModuleContext) -> axum::Router {
815 axum::Router::new().route("/", axum::routing::get(|| async { "guardrails" }))
816 }
817
818 fn scheduled<'a>(
819 &'a self,
820 ctx: &'a ModuleContext,
821 cron: &'a str,
822 ) -> BoxFuture<'a, Result<(), AnyError>> {
823 let _ = (ctx, cron);
824 Box::pin(async move {
825 for wallet in self.watched.iter() {
826 for finding in self
827 .engine
828 .scan_allowances(
829 &wallet.venture,
830 &wallet.subject,
831 &wallet.chain,
832 &wallet.owner,
833 )
834 .await
835 {
836 tracing::warn!(
837 venture = %wallet.venture,
838 subject = %wallet.subject,
839 owner = %wallet.owner,
840 "guardrails hygiene: {:?} on {} <- {}",
841 finding.reason,
842 finding.allowance.token,
843 finding.allowance.spender
844 );
845 }
846 }
847 Ok(())
848 })
849 }
850}