Skip to main content

submilli_engine/runtime/
security.rs

1//! Embedder-supplied security policy trait.
2//!
3//! Caller identity is fixed by the host-function binding's closure; Wasm code
4//! cannot forge a different `caller` value.
5
6use std::sync::Arc;
7
8use super::decision::{CallSite, DecisionExplanation, DecisionRecorder};
9
10#[non_exhaustive]
11pub enum CheckOutcome {
12    Allow { rule: Option<usize> },
13    Deny { reason: String, rule: Option<usize> },
14}
15
16/// A decision observation; recording must never change authorization semantics.
17///
18/// `#[non_exhaustive]` so it can grow: build one with [`AuditDecision::new`] and the
19/// `with_*` methods. Sinks that predate a field ignore it.
20#[derive(Clone, Copy)]
21#[non_exhaustive]
22pub struct AuditDecision<'a> {
23    pub caller: &'a str,
24    pub capability: &'a str,
25    pub context: &'a serde_json::Value,
26    pub allowed: bool,
27    pub source: &'a str,
28    pub rule: Option<usize>,
29    pub reason: Option<&'a str>,
30    /// Why the policy decided as it did. Present only while a recorder is installed.
31    pub explanation: Option<&'a DecisionExplanation>,
32    /// How the decision was reached, and the call it belongs to.
33    pub site: CallSite,
34}
35
36impl<'a> AuditDecision<'a> {
37    pub fn new(
38        caller: &'a str,
39        capability: &'a str,
40        context: &'a serde_json::Value,
41        allowed: bool,
42        source: &'a str,
43        rule: Option<usize>,
44        reason: Option<&'a str>,
45    ) -> Self {
46        Self {
47            caller,
48            capability,
49            context,
50            allowed,
51            source,
52            rule,
53            reason,
54            explanation: None,
55            site: CallSite::default(),
56        }
57    }
58
59    pub fn with_explanation(mut self, explanation: Option<&'a DecisionExplanation>) -> Self {
60        self.explanation = explanation;
61        self
62    }
63
64    pub fn with_site(mut self, site: CallSite) -> Self {
65        self.site = site;
66        self
67    }
68}
69
70pub trait SecurityCheck: Send + Sync {
71    /// Optional embedder audit sink. No guest fuel is charged for observation.
72    fn audit(&self, _decision: AuditDecision<'_>) {}
73    /// The metadata actually tested by the policy, including lexical VFS normalization.
74    fn audit_context<'a>(
75        &self,
76        _capability: &str,
77        context: &'a serde_json::Value,
78        _cwd: &str,
79    ) -> std::borrow::Cow<'a, serde_json::Value> {
80        std::borrow::Cow::Borrowed(context)
81    }
82    fn check_with_cwd(
83        &self,
84        caller: &str,
85        capability: &str,
86        context: &serde_json::Value,
87        _cwd: &str,
88    ) -> CheckOutcome {
89        self.check(caller, capability, context)
90    }
91    fn check(&self, caller: &str, capability: &str, context: &serde_json::Value) -> CheckOutcome;
92    /// The per-run decision recorder, when one is installed. The runtime computes
93    /// explanations and captures source lines only while this is `Some`.
94    fn recorder(&self) -> Option<&dyn DecisionRecorder> {
95        None
96    }
97    /// Why the policy would decide as it does for this call, for a recorder.
98    /// Called only while [`Self::recorder`] is `Some`, with the context as the host
99    /// function built it; the policy applies its own normalization.
100    fn explain(
101        &self,
102        _caller: &str,
103        _capability: &str,
104        _context: &serde_json::Value,
105        _cwd: &str,
106    ) -> Option<DecisionExplanation> {
107        None
108    }
109}
110
111/// Default policy: allows all calls, logging each to stderr so stdout carries
112/// only the program's result.
113pub struct AllowAllCheck;
114
115impl SecurityCheck for AllowAllCheck {
116    fn check(&self, caller: &str, capability: &str, context: &serde_json::Value) -> CheckOutcome {
117        use std::io::Write;
118        let _ = writeln!(
119            std::io::stderr().lock(),
120            "[security] caller={caller} capability={capability} context={context}"
121        );
122        CheckOutcome::Allow { rule: None }
123    }
124}
125
126pub fn default_check() -> Arc<dyn SecurityCheck> {
127    Arc::new(AllowAllCheck)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn allow_all_returns_allow() {
136        let check = AllowAllCheck;
137        let outcome = check.check("main", "test.com/op", &serde_json::json!({"foo": 1}));
138        assert!(matches!(outcome, CheckOutcome::Allow { .. }));
139    }
140
141    #[test]
142    fn default_check_is_allow_all() {
143        let check = default_check();
144        let outcome = check.check("main", "x", &serde_json::Value::Null);
145        assert!(matches!(outcome, CheckOutcome::Allow { .. }));
146    }
147}