Skip to main content

submilli_engine/runtime/
approvals.rs

1//! The embedder-supplied seam for approvals: a call a permission rule marks
2//! `ask-human` or `ask-admin` waits until the person in the conversation, or an
3//! administrator, decides.
4//!
5//! The policy answers *ask* ([`CheckOutcome::Ask`](super::CheckOutcome::Ask)).
6//! The gate then runs one effect of its own, [`APPROVAL_OPERATION`], which awaits
7//! the embedder's [`ApprovalProvider`] and records the decision. Effects do not
8//! nest, so the approval comes after the check and before the operation's own
9//! effect. A replay returns the recorded decision without asking again, and the
10//! operation runs at most once.
11//!
12//! **Waiting is not running**, as for `@tools`: the wait is added to
13//! [`StoreData::clock_paused`]. The provider bounds the wait itself. Dropping the
14//! future returned by [`ApprovalProvider::request`] is how the engine cancels it.
15
16use std::future::Future;
17use std::pin::Pin;
18use std::time::Instant;
19
20use serde::{Deserialize, Serialize};
21use wasmtime::Caller;
22
23use super::StoreData;
24use super::decision::CallSite;
25use super::durable::EffectId;
26use super::host::{
27    DenialSource, fatal_host_error, permission_denied, permission_denied_by, quota_exceeded_error,
28};
29use super::metrics::MetricsSink;
30use super::security::{AuditDecision, SecurityCheck, denial_reason};
31
32/// The effect every approval is recorded under.
33pub const APPROVAL_OPERATION: &str = "policy.approval";
34
35/// Who decides an asked call.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum Approver {
39    /// The person in the conversation, through the harness (`ask-human`).
40    Human,
41    /// An administrator of the embedder (`ask-admin`).
42    Admin,
43}
44
45impl Approver {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::Human => "human",
49            Self::Admin => "admin",
50        }
51    }
52
53    /// The rule action that asks this approver.
54    pub fn action(self) -> &'static str {
55        match self {
56            Self::Human => "ask-human",
57            Self::Admin => "ask-admin",
58        }
59    }
60
61    /// The source of a denial this approver decided.
62    pub fn denial_source(self) -> DenialSource {
63        match self {
64            Self::Human => DenialSource::Human,
65            Self::Admin => DenialSource::Admin,
66        }
67    }
68
69    fn who(self) -> &'static str {
70        match self {
71            Self::Human => "the person asked",
72            Self::Admin => "an administrator",
73        }
74    }
75}
76
77/// The rule that asked: its caller block, position, and, when it has them, its
78/// name and its filter as written.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct AskRule {
81    pub caller: String,
82    pub index: usize,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub name: Option<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub filter: Option<String>,
87}
88
89/// One call waiting for a decision, as the approver is shown it. `context` holds
90/// the values the policy tested, which are the values the operation uses.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct ApprovalRequest {
93    pub approver: Approver,
94    pub caller: String,
95    pub capability: String,
96    pub context: serde_json::Value,
97    pub rule: AskRule,
98}
99
100/// What became of an approval request. Every variant but `Approved` refuses the
101/// call with a catchable `PermissionDeniedError`.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub enum ApprovalOutcome {
104    Approved {
105        reason: Option<String>,
106    },
107    /// The approver said no. Reported with the approver as its source.
108    Denied {
109        reason: Option<String>,
110    },
111    /// No decision came in time. Reported with the approver as its source.
112    Expired,
113    /// The request could not be put to its approver, as when a harness answered
114    /// it like a tool call. Denied as a deny rule would deny the call, and
115    /// reported ([`ApprovalRequest::report_unasked`]) with `message`.
116    Unanswerable {
117        message: String,
118    },
119}
120
121/// Why the embedder gave no outcome.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub enum ApprovalError {
124    /// The embedder could not hold another waiting call. Thrown as
125    /// `QuotaExceededError`.
126    QuotaExceeded { message: String },
127    /// The wait was abandoned, as when the session ends. The run ends with it.
128    Cancelled,
129}
130
131/// The embedder-provided approver of asked calls. Object-safe with boxed
132/// futures, like [`ToolProvider`](super::ToolProvider), so an embedder may
133/// answer in process or forward the request over the wire.
134pub trait ApprovalProvider: Send + Sync {
135    fn request<'a>(
136        &'a self,
137        effect_id: EffectId,
138        request: &'a ApprovalRequest,
139    ) -> Pin<Box<dyn Future<Output = Result<ApprovalOutcome, ApprovalError>> + Send + 'a>>;
140}
141
142/// Why a call that needs approval was denied without asking anyone.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum Unasked {
146    /// The run has no approval provider, as over MCP or for a one-off run.
147    NoProvider,
148    /// The call is made where the run can't wait, as during another operation.
149    CannotWait,
150    /// The provider could not put the request to its approver, as when a harness
151    /// answered it like a tool call.
152    Unanswerable,
153}
154
155impl Unasked {
156    pub fn as_str(self) -> &'static str {
157        match self {
158            Self::NoProvider => "no_provider",
159            Self::CannotWait => "cannot_wait",
160            Self::Unanswerable => "unanswerable",
161        }
162    }
163}
164
165impl ApprovalRequest {
166    /// The reason a call that can't be asked is denied with: the one a deny rule gives.
167    pub fn denial(&self) -> String {
168        denial_reason(&self.caller, &self.capability, &self.context)
169    }
170
171    /// Reports this request as denied without asking, for `why`: a warning in the log
172    /// for the operator, and a count in `metrics`. The program is told only that the
173    /// call was denied.
174    pub fn report_unasked(&self, why: Unasked, detail: Option<&str>, metrics: &dyn MetricsSink) {
175        tracing::warn!(
176            caller = %self.caller,
177            capability = %self.capability,
178            approver = self.approver.as_str(),
179            rule_caller = %self.rule.caller,
180            rule = self.rule.index,
181            why = why.as_str(),
182            detail,
183            "denied a call that needs approval without asking"
184        );
185        metrics.approval_unasked(self.approver, why);
186    }
187}
188
189/// Denies `request` without asking anyone, as a deny rule would: audits the denial
190/// and returns its reason.
191pub(crate) fn deny_unasked(
192    request: &ApprovalRequest,
193    security: &dyn SecurityCheck,
194    site: CallSite,
195) -> String {
196    let denial = request.denial();
197    audit(
198        security,
199        request,
200        &Err((DenialSource::Policy, denial.clone())),
201        site,
202    );
203    denial
204}
205
206/// Refuses a call `request` asks about without asking, for `why`: reports it, then
207/// throws the policy denial [`deny_unasked`] audits.
208pub(crate) fn refuse(
209    request: ApprovalRequest,
210    why: Unasked,
211    security: &dyn SecurityCheck,
212    metrics: &dyn MetricsSink,
213    site: CallSite,
214) -> wasmtime::Error {
215    request.report_unasked(why, None, metrics);
216    let denial = deny_unasked(&request, security, site);
217    permission_denied(request.caller, request.capability, denial)
218}
219
220/// The approval outcome recorded in `bytes`, for an embedder that tracks waiting
221/// runs from their history. `None` when the bytes hold something else.
222pub fn recorded_outcome(bytes: &super::effects::OutcomeBytes) -> Option<ApprovalOutcome> {
223    super::effects::decode(&bytes.0).ok()
224}
225
226/// What an outcome decides: allowed, with the approver's reason if they gave one, or
227/// denied, with the denial's source and reason.
228type Decision = Result<Option<String>, (DenialSource, String)>;
229
230/// Puts `request` to its approver and waits for the decision, as one effect. Ok means
231/// approved; every other outcome is a thrown denial.
232pub(crate) async fn ask(
233    caller: &mut Caller<'_, StoreData>,
234    request: ApprovalRequest,
235    site: CallSite,
236) -> wasmtime::Result<()> {
237    let Some(provider) = caller.data().approval_provider.clone() else {
238        let data = caller.data();
239        return Err(refuse(
240            request,
241            Unasked::NoProvider,
242            data.security_check.as_ref(),
243            data.metrics.as_ref(),
244            site,
245        ));
246    };
247    let outcome = super::effects::execute(
248        caller,
249        APPROVAL_OPERATION,
250        request.clone(),
251        move |caller, effect_id, request| {
252            Box::pin(async move {
253                let waited = Instant::now();
254                let outcome = provider.request(effect_id, &request).await;
255                let data = caller.data_mut();
256                data.clock_paused = data.clock_paused.saturating_add(waited.elapsed());
257                let outcome = outcome.map_err(|error| match error {
258                    ApprovalError::QuotaExceeded { message } => {
259                        quota_exceeded_error(format!("{}: {message}", request.capability))
260                    }
261                    ApprovalError::Cancelled => fatal_host_error(format!(
262                        "{}: the approval was cancelled",
263                        request.capability
264                    )),
265                })?;
266                // Reported and audited once, when decided: a replay returns the
267                // recorded outcome without running this.
268                let data = caller.data();
269                if let ApprovalOutcome::Unanswerable { message } = &outcome {
270                    request.report_unasked(
271                        Unasked::Unanswerable,
272                        Some(message),
273                        data.metrics.as_ref(),
274                    );
275                }
276                let decision = decision_of(&request, &outcome);
277                audit(data.security_check.as_ref(), &request, &decision, site);
278                Ok(outcome)
279            })
280        },
281    )
282    .await?;
283    match decision_of(&request, &outcome) {
284        Ok(_) => Ok(()),
285        Err((source, reason)) => Err(permission_denied_by(
286            source,
287            request.caller,
288            request.capability,
289            reason,
290        )),
291    }
292}
293
294/// What `outcome` decides for `request`.
295fn decision_of(request: &ApprovalRequest, outcome: &ApprovalOutcome) -> Decision {
296    let approver = request.approver;
297    let who = approver.who();
298    let action = approver.action();
299    match outcome {
300        ApprovalOutcome::Approved { reason } => Ok(reason.clone()),
301        ApprovalOutcome::Denied { reason } => Err((
302            approver.denial_source(),
303            match reason {
304                Some(reason) if !reason.is_empty() => {
305                    format!("{who} declined the call ({action}): {reason}")
306                }
307                _ => format!("{who} declined the call ({action})"),
308            },
309        )),
310        ApprovalOutcome::Expired => Err((
311            approver.denial_source(),
312            format!("{who} did not decide in time ({action})"),
313        )),
314        ApprovalOutcome::Unanswerable { .. } => Err((DenialSource::Policy, request.denial())),
315    }
316}
317
318fn audit(
319    security: &dyn SecurityCheck,
320    request: &ApprovalRequest,
321    decision: &Decision,
322    site: CallSite,
323) {
324    let (source, reason) = match decision {
325        Ok(reason) => (request.approver.as_str(), reason.as_deref()),
326        Err((source, reason)) => (source.as_str(), Some(reason.as_str())),
327    };
328    // The explanation is for the recorder alone, as at the gate.
329    let explanation = security.recorder().and_then(|_| {
330        security.explain(&request.caller, &request.capability, &request.context, "/")
331    });
332    security.audit(
333        AuditDecision::new(
334            &request.caller,
335            &request.capability,
336            &request.context,
337            decision.is_ok(),
338            source,
339            Some(request.rule.index),
340            reason,
341        )
342        .with_explanation(explanation.as_ref())
343        .with_site(site),
344    );
345}
346
347#[cfg(test)]
348mod tests {
349    use std::sync::{Arc, Mutex};
350    use std::time::Duration;
351
352    use wasmtime::{Linker, Module};
353
354    use super::*;
355    use crate::runtime::host::DenialSource;
356    use crate::runtime::security::CheckOutcome;
357    use crate::runtime::{
358        RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
359        install_tenant_limits,
360    };
361
362    /// Asks `approver` about `test.refund` and allows everything else.
363    struct AsksRefunds(Approver);
364
365    impl SecurityCheck for AsksRefunds {
366        fn check(&self, caller: &str, capability: &str, _: &serde_json::Value) -> CheckOutcome {
367            if capability != "test.refund" {
368                return CheckOutcome::Allow { rule: None };
369            }
370            CheckOutcome::Ask {
371                approver: self.0,
372                rule: AskRule {
373                    caller: caller.to_owned(),
374                    index: 0,
375                    name: Some("refunds".to_owned()),
376                    filter: None,
377                },
378            }
379        }
380    }
381
382    type Asked = Arc<Mutex<Vec<ApprovalRequest>>>;
383
384    /// Records every request, waits `delay`, then answers `outcome`.
385    struct Decides {
386        outcome: ApprovalOutcome,
387        delay: Duration,
388        asked: Asked,
389    }
390
391    impl ApprovalProvider for Decides {
392        fn request<'a>(
393            &'a self,
394            _effect_id: EffectId,
395            request: &'a ApprovalRequest,
396        ) -> Pin<Box<dyn Future<Output = Result<ApprovalOutcome, ApprovalError>> + Send + 'a>>
397        {
398            self.asked.lock().unwrap().push(request.clone());
399            Box::pin(async move {
400                tokio::time::sleep(self.delay).await;
401                Ok(self.outcome.clone())
402            })
403        }
404    }
405
406    /// Counts the calls denied without asking.
407    #[derive(Default)]
408    struct Unaskeds(Mutex<Vec<(Approver, Unasked)>>);
409
410    impl MetricsSink for Unaskeds {
411        fn approval_unasked(&self, approver: Approver, why: Unasked) {
412            self.0.lock().unwrap().push((approver, why));
413        }
414    }
415
416    struct Run {
417        result: wasmtime::Result<Option<String>>,
418        asked: Vec<ApprovalRequest>,
419        unasked: Vec<(Approver, Unasked)>,
420        clock_paused: Duration,
421    }
422
423    async fn run(
424        source: &str,
425        approver: Approver,
426        outcome: Option<ApprovalOutcome>,
427        delay: Duration,
428    ) -> Run {
429        let compiled =
430            crate::compile_script(source, "<test>", crate::FileId(0), &[], &[]).expect("compiles");
431        let cfg = RuntimeConfig::default();
432        let engine = cfg.engine().unwrap();
433        let asked = Asked::default();
434        let unasked = Arc::new(Unaskeds::default());
435        let mut data = StoreData::with_vfs("test-run".into(), Vfs::none());
436        data.install_type_info(compiled.type_info.clone());
437        data.security_check = Arc::new(AsksRefunds(approver));
438        data.metrics = unasked.clone();
439        if let Some(outcome) = outcome {
440            data.approval_provider = Some(Arc::new(Decides {
441                outcome,
442                delay,
443                asked: asked.clone(),
444            }));
445        }
446        let mut store = cfg.store_async(&engine, data).unwrap();
447        install_tenant_limits(&mut store);
448        let module = Module::new(&engine, &compiled.wasm).unwrap();
449        let mut linker = Linker::<StoreData>::new(&engine);
450        install_runtime_async(&mut linker, &mut store)
451            .await
452            .unwrap();
453        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
454        let result = dispatch_main_async(&mut store, &instance).await;
455        let asked = asked.lock().unwrap().clone();
456        let unasked = unasked.0.lock().unwrap().clone();
457        Run {
458            result,
459            asked,
460            unasked,
461            clock_paused: store.data().clock_paused,
462        }
463    }
464
465    const REFUND: &str = r#"import { check } from "submilli:security";
466        function main(): string { check("test.refund", { amount: 250 }); return "refunded"; }"#;
467
468    const REFUND_OR_REASON: &str = r#"import { check } from "submilli:security";
469        function main(): string {
470            try {
471                check("test.refund", { amount: 250 });
472                return "refunded";
473            } catch (e) {
474                if (e instanceof PermissionDeniedError) { return e.reason; }
475                throw e;
476            }
477        }"#;
478
479    fn escaped_source(error: &wasmtime::Error) -> Option<DenialSource> {
480        error
481            .downcast_ref::<crate::backtrace::ThrownError>()
482            .and_then(|thrown| thrown.denial.as_ref())
483            .map(|denial| denial.source)
484    }
485
486    #[tokio::test]
487    async fn an_approved_call_runs_once_and_the_wait_is_not_running_time() {
488        let run = run(
489            REFUND,
490            Approver::Human,
491            Some(ApprovalOutcome::Approved { reason: None }),
492            Duration::from_millis(30),
493        )
494        .await;
495        assert_eq!(run.result.unwrap().as_deref(), Some("refunded"));
496        assert_eq!(run.asked.len(), 1);
497        let asked = &run.asked[0];
498        assert_eq!(asked.approver, Approver::Human);
499        assert_eq!(asked.caller, "main");
500        assert_eq!(asked.capability, "test.refund");
501        assert_eq!(asked.context, serde_json::json!({ "amount": 250 }));
502        assert_eq!(asked.rule.name.as_deref(), Some("refunds"));
503        assert!(run.clock_paused >= Duration::from_millis(30));
504    }
505
506    #[tokio::test]
507    async fn a_denial_is_catchable_and_names_who_declined() {
508        let run = run(
509            REFUND_OR_REASON,
510            Approver::Human,
511            Some(ApprovalOutcome::Denied {
512                reason: Some("not this customer".to_owned()),
513            }),
514            Duration::ZERO,
515        )
516        .await;
517        assert_eq!(
518            run.result.unwrap().as_deref(),
519            Some("the person asked declined the call (ask-human): not this customer")
520        );
521    }
522
523    #[tokio::test]
524    async fn an_escaped_refusal_reports_the_approver_as_its_source() {
525        for (approver, outcome, source) in [
526            (
527                Approver::Human,
528                ApprovalOutcome::Denied { reason: None },
529                DenialSource::Human,
530            ),
531            (
532                Approver::Admin,
533                ApprovalOutcome::Expired,
534                DenialSource::Admin,
535            ),
536            (
537                Approver::Human,
538                ApprovalOutcome::Unanswerable {
539                    message: "no terminal".to_owned(),
540                },
541                DenialSource::Policy,
542            ),
543        ] {
544            let run = run(REFUND, approver, Some(outcome), Duration::ZERO).await;
545            let error = run.result.expect_err("refused");
546            assert_eq!(escaped_source(&error), Some(source), "{error:#}");
547        }
548    }
549
550    /// What a deny rule on the same call gives.
551    const REFUND_DENIED: &str = "policy denied test.refund with amount = 250 for main";
552
553    #[tokio::test]
554    async fn without_an_approver_an_ask_is_denied_as_a_deny_rule_would_deny_it() {
555        let caught = run(REFUND_OR_REASON, Approver::Admin, None, Duration::ZERO).await;
556        assert_eq!(caught.result.unwrap().as_deref(), Some(REFUND_DENIED));
557        assert_eq!(caught.unasked, [(Approver::Admin, Unasked::NoProvider)]);
558        let escaped = run(REFUND, Approver::Human, None, Duration::ZERO).await;
559        let error = escaped.result.expect_err("refused");
560        assert_eq!(escaped_source(&error), Some(DenialSource::Policy));
561    }
562
563    #[tokio::test]
564    async fn an_unanswerable_request_is_denied_as_a_deny_rule_would_deny_it() {
565        let run = run(
566            REFUND_OR_REASON,
567            Approver::Human,
568            Some(ApprovalOutcome::Unanswerable {
569                message: "the harness answered it like a tool call".to_owned(),
570            }),
571            Duration::ZERO,
572        )
573        .await;
574        assert_eq!(run.result.unwrap().as_deref(), Some(REFUND_DENIED));
575        assert_eq!(run.unasked, [(Approver::Human, Unasked::Unanswerable)]);
576    }
577
578    #[tokio::test]
579    async fn a_decided_request_is_not_reported_as_unasked() {
580        let run = run(
581            REFUND,
582            Approver::Human,
583            Some(ApprovalOutcome::Denied { reason: None }),
584            Duration::ZERO,
585        )
586        .await;
587        assert!(run.result.is_err());
588        assert_eq!(run.unasked, []);
589    }
590
591    #[test]
592    fn a_package_check_recorded_before_approvals_replays_as_allowed() {
593        let legacy = crate::runtime::effects::encode(&()).unwrap();
594        let request: Option<ApprovalRequest> = crate::runtime::effects::decode(&legacy).unwrap();
595        assert_eq!(request, None);
596    }
597
598    #[test]
599    fn a_recorded_outcome_reads_back() {
600        let bytes = crate::runtime::effects::OutcomeBytes(
601            crate::runtime::effects::encode(&ApprovalOutcome::Expired).unwrap(),
602        );
603        assert_eq!(recorded_outcome(&bytes), Some(ApprovalOutcome::Expired));
604    }
605}