Skip to main content

cratefield_module_guardrails/
lib.rs

1//! The policy engine every automated value-moving action must pass
2//! (issue #763). Default deny everywhere; fail closed on any port error.
3//! The README below is the crate documentation: the decision order, the
4//! ports, and the limits of what a policy alone can enforce.
5
6#![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/// Why the builder refused to build: a required port was not wired.
59#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60#[error("guardrails: required port not wired: {0}")]
61pub struct BuildError(&'static str);
62
63/// Why the wallet signer refused.
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65#[error("wallet signer: {0}")]
66pub struct SignError(pub String);
67
68/// The port that turns an approved request into a signed transaction or
69/// signature — the thing a Turnkey-style provider adapter implements.
70/// Reached only through [`GuardedSigner`], which re-checks the kill switch
71/// first.
72#[async_trait]
73pub trait WalletSigner: Send + Sync {
74    /// Signs the request.
75    ///
76    /// # Errors
77    ///
78    /// Whatever the provider reports; the caller audits it as a port error.
79    async fn sign(&self, request: &Request) -> Result<Vec<u8>, SignError>;
80}
81
82/// A token that exists only because `Guardrails::check` returned `Ok`.
83/// Opaque and deliberately not `Clone`: one approval is one audited
84/// decision. Constructible by nobody outside this crate — the private
85/// `Seal` field sees to that.
86#[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    /// The approved request, for the signer to act on.
97    #[must_use]
98    pub fn request(&self) -> &Request {
99        &self.request
100    }
101}
102
103/// A refusal, with every reason the engine collected.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Denied {
106    /// Why, in the order the gates ran.
107    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
122/// The engine. Built once per venture, from ports and a [`Policy`]; see
123/// [`Guardrails::builder`].
124pub 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    /// Starts a builder. Every port is required except the allowance
138    /// source, which only the hygiene scan needs; the clock and id
139    /// generator default to core's [`SystemClock`] and [`UlidIdGen`].
140    #[must_use]
141    pub fn builder() -> GuardrailsBuilder {
142        GuardrailsBuilder::default()
143    }
144
145    /// The policy in force.
146    #[must_use]
147    pub fn policy(&self) -> &Policy {
148        &self.policy
149    }
150
151    /// Whether jobs may be scheduled for this venture and subject at all:
152    /// the kill-switch check alone, for the actor that queues work. A port
153    /// error answers `false` — fail closed.
154    pub async fn job_allowed(&self, venture: &str, subject: &str) -> bool {
155        self.kill_denies(venture, subject).await.is_empty()
156    }
157
158    /// Runs the full gauntlet and, on allow, hands back the one approval
159    /// token [`GuardedSigner::sign`] accepts.
160    ///
161    /// # Errors
162    ///
163    /// [`Denied`] with every reason the engine could cheaply collect, when
164    /// any gate refuses. An audit-sink error after the ledger recorded the
165    /// spend leaves the spend recorded (over-counting is the conservative
166    /// direction) and still denies.
167    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                // Gate 6: the ledger, only on the way to an allow.
189                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                // Gate 7: the audit, last — its error is still a deny.
209                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    /// Reads a wallet's open allowances and reports the ones today's policy
233    /// would not issue, each with its `approve(spender, 0)` revoke. With
234    /// `auto_revoke` on the subject's policy, the revoke is sent through
235    /// [`Guardrails::check`] — simulated, capped and audited like any other
236    /// action — and reported [`Remedy::Revoked`] only when allowed.
237    ///
238    /// EVM chains only; see [`crate::AllowanceSource`] for why.
239    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                // A spender no revoke can name: report it, but never claim
282                // a revoke was sent.
283                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    /// Every kill-switch scope that blocks this actor.
309    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    /// Gates 1-5: everything up to and including the caps. `Ok` carries the
322    /// priced spend entries the ledger must record on the way to an allow.
323    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    /// The simulation report's own denies: unlimited approvals, approvals
351    /// to unknown spenders, credits to unknown destinations, delegatecalls
352    /// into unknown contracts.
353    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            // approve(spender, 0) revokes, it does not grant: the hygiene
362            // scan's auto-revoke lands here, naming a spender the policy no
363            // longer allows — which is rather the point.
364            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            // A positive delta for anyone but the actor is the actor paying
374            // them: the recipient is a destination.
375            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    /// The caps half of gate 5: the swap bound, then the three windows per
396    /// outflowing token. `Ok` is the priced entries to record.
397    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        // A swap-shaped report — the actor both pays and receives — must
408        // carry an explicit bound, or there is nothing to compare: refuse
409        // it rather than wave it through unpriced.
410        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        // A non-positive or out-of-range period would silently disable the
441        // rolling window; refuse the subject instead of trusting it.
442        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    /// Records one decision. An error is the caller's deny, not a log line.
503    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
545/// The outflows a report would move: the actor's negative balance changes,
546/// as (token, raw amount).
547fn 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/// Builds a [`Guardrails`]. Every port is required except the allowance
566/// source; the clock and id generator default to core's.
567#[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    /// Sets the policy.
582    #[must_use]
583    pub fn policy(mut self, policy: Policy) -> Self {
584        self.policy = Some(policy);
585        self
586    }
587
588    /// Wires the kill switch.
589    #[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    /// Wires the simulator.
596    #[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    /// Wires the price oracle.
603    #[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    /// Wires the spend ledger.
610    #[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    /// Wires the audit sink.
617    #[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    /// Wires the allowance source (optional; the hygiene scan only).
624    #[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    /// Overrides the clock (default [`SystemClock`]).
631    #[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    /// Overrides the id generator (default [`UlidIdGen`]).
638    #[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    /// Builds.
645    ///
646    /// # Errors
647    ///
648    /// [`BuildError`] naming the first required port that was not wired.
649    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
666/// The signer hosts hand to code that moves value. `sign` consumes the
667/// approval `Guardrails::check` issued and re-checks all three kill-switch
668/// scopes — so engaging the switch after a check blocks the in-flight job
669/// at the moment of signing, and audits that decision too.
670pub struct GuardedSigner {
671    engine: std::sync::Arc<Guardrails>,
672    inner: std::sync::Arc<dyn WalletSigner>,
673}
674
675impl GuardedSigner {
676    /// Wraps `inner`, reached only through approvals from `engine`.
677    #[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    /// Signs the approved request, unless a kill switch has been engaged
686    /// since it was issued.
687    ///
688    /// # Errors
689    ///
690    /// [`Denied`] when any scope is engaged, a port errors, or the signer
691    /// itself fails — every one audited.
692    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/// One watched wallet for the module's scheduled hygiene scan.
743#[derive(Debug, Clone, PartialEq, Eq)]
744pub struct WatchedWallet {
745    /// The venture the wallet belongs to.
746    pub venture: String,
747    /// The subject whose policy governs it.
748    pub subject: String,
749    /// The chain to scan.
750    pub chain: Chain,
751    /// The wallet address.
752    pub owner: String,
753}
754
755/// The engine as a harness [`Module`]: no tables, no routes to speak of,
756/// and a `scheduled` hook that runs the hygiene scan over the watched
757/// wallets.
758#[derive(Clone)]
759pub struct GuardrailsModule {
760    engine: std::sync::Arc<Guardrails>,
761    watched: std::sync::Arc<Vec<WatchedWallet>>,
762}
763
764impl GuardrailsModule {
765    /// Mounts the engine; `watch` adds scanned wallets.
766    #[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    /// Adds a wallet to the scheduled hygiene scan.
775    #[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    /// The engine, for hosts that call `check` or `job_allowed` directly.
786    #[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        // The engine carries its own ports, wired at build time.
803        &[]
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}