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::approvals::{Approver, AskRule};
9use super::decision::{CallSite, DecisionExplanation, DecisionRecorder};
10use crate::stdlib::capabilities;
11
12#[non_exhaustive]
13pub enum CheckOutcome {
14    Allow {
15        rule: Option<usize>,
16    },
17    Deny {
18        reason: String,
19        rule: Option<usize>,
20    },
21    /// The call may run once `approver` approves it. A call that cannot be put to
22    /// them is denied, with the [`denial_reason`] a deny gives.
23    Ask {
24        approver: Approver,
25        rule: AskRule,
26    },
27}
28
29/// A decision observation; recording must never change authorization semantics.
30///
31/// `#[non_exhaustive]` so it can grow: build one with [`AuditDecision::new`] and the
32/// `with_*` methods. Sinks that predate a field ignore it.
33#[derive(Clone, Copy)]
34#[non_exhaustive]
35pub struct AuditDecision<'a> {
36    pub caller: &'a str,
37    pub capability: &'a str,
38    pub context: &'a serde_json::Value,
39    pub allowed: bool,
40    pub source: &'a str,
41    pub rule: Option<usize>,
42    pub reason: Option<&'a str>,
43    /// Why the policy decided as it did. Present only while a recorder is installed.
44    pub explanation: Option<&'a DecisionExplanation>,
45    /// How the decision was reached, and the call it belongs to.
46    pub site: CallSite,
47}
48
49impl<'a> AuditDecision<'a> {
50    pub fn new(
51        caller: &'a str,
52        capability: &'a str,
53        context: &'a serde_json::Value,
54        allowed: bool,
55        source: &'a str,
56        rule: Option<usize>,
57        reason: Option<&'a str>,
58    ) -> Self {
59        Self {
60            caller,
61            capability,
62            context,
63            allowed,
64            source,
65            rule,
66            reason,
67            explanation: None,
68            site: CallSite::default(),
69        }
70    }
71
72    pub fn with_explanation(mut self, explanation: Option<&'a DecisionExplanation>) -> Self {
73        self.explanation = explanation;
74        self
75    }
76
77    pub fn with_site(mut self, site: CallSite) -> Self {
78        self.site = site;
79        self
80    }
81}
82
83pub trait SecurityCheck: Send + Sync {
84    /// Optional embedder audit sink. No guest fuel is charged for observation.
85    fn audit(&self, _decision: AuditDecision<'_>) {}
86    /// The metadata actually tested by the policy, including lexical VFS normalization.
87    fn audit_context<'a>(
88        &self,
89        _capability: &str,
90        context: &'a serde_json::Value,
91        _cwd: &str,
92    ) -> std::borrow::Cow<'a, serde_json::Value> {
93        std::borrow::Cow::Borrowed(context)
94    }
95    fn check_with_cwd(
96        &self,
97        caller: &str,
98        capability: &str,
99        context: &serde_json::Value,
100        _cwd: &str,
101    ) -> CheckOutcome {
102        self.check(caller, capability, context)
103    }
104    fn check(&self, caller: &str, capability: &str, context: &serde_json::Value) -> CheckOutcome;
105    /// The per-run decision recorder, when one is installed. The runtime computes
106    /// explanations and captures source lines only while this is `Some`.
107    fn recorder(&self) -> Option<&dyn DecisionRecorder> {
108        None
109    }
110    /// Why the policy would decide as it does for this call, for a recorder.
111    /// Called only while [`Self::recorder`] is `Some`, with the context as the host
112    /// function built it; the policy applies its own normalization.
113    fn explain(
114        &self,
115        _caller: &str,
116        _capability: &str,
117        _context: &serde_json::Value,
118        _cwd: &str,
119    ) -> Option<DecisionExplanation> {
120        None
121    }
122}
123
124/// Default policy: allows all calls, logging each to stderr so stdout carries
125/// only the program's result.
126pub struct AllowAllCheck;
127
128impl SecurityCheck for AllowAllCheck {
129    fn check(&self, caller: &str, capability: &str, context: &serde_json::Value) -> CheckOutcome {
130        use std::io::Write;
131        let _ = writeln!(
132            std::io::stderr().lock(),
133            "[security] caller={caller} capability={capability} context={context}"
134        );
135        CheckOutcome::Allow { rule: None }
136    }
137}
138
139pub fn default_check() -> Arc<dyn SecurityCheck> {
140    Arc::new(AllowAllCheck)
141}
142
143/// The reason a policy denial gives `caller` for `capability`. `context` is the one
144/// the policy tested ([`SecurityCheck::audit_context`]), so a filesystem path is named
145/// as normalized.
146pub fn denial_reason(caller: &str, capability: &str, context: &serde_json::Value) -> String {
147    format!(
148        "policy denied {capability}{} for {caller}",
149        denial_target(capability, context)
150    )
151}
152
153/// What a denial was for, named in its reason: the VFS paths of a filesystem capability,
154/// or the values of an application-defined one. Empty for other capabilities.
155fn denial_target(capability: &str, context: &serde_json::Value) -> String {
156    let paths = filesystem_target(capability, context);
157    if paths.is_empty() && is_application_defined(capability) {
158        request_values(context)
159    } else {
160        paths
161    }
162}
163
164/// The most values a denial names, and the most of each string value it shows.
165const MAX_NAMED_VALUES: usize = 4;
166const MAX_NAMED_VALUE_CHARS: usize = 64;
167
168/// The scalar values of an application-defined capability's request, so a model can tell
169/// the request refused (`customerId = "cus_globex"`) from the capability being refused,
170/// and so a narrowed filter can be fixed from the message. Every one was put there by the
171/// program or by a package it called. A built-in capability's values are left out: a
172/// runtime library can fill them with what the program never saw, such as a redirect's
173/// URL.
174fn request_values(context: &serde_json::Value) -> String {
175    let Some(fields) = context.as_object() else {
176        return String::new();
177    };
178    let named: Vec<String> = fields
179        .iter()
180        .filter_map(|(name, value)| {
181            let shown = match value {
182                serde_json::Value::String(text) => {
183                    let mut chars = text.chars();
184                    let mut cut: String = chars.by_ref().take(MAX_NAMED_VALUE_CHARS).collect();
185                    if chars.next().is_some() {
186                        cut.push('…');
187                    }
188                    serde_json::Value::String(cut).to_string()
189                }
190                serde_json::Value::Number(_) | serde_json::Value::Bool(_) => value.to_string(),
191                _ => return None,
192            };
193            Some(format!("{name} = {shown}"))
194        })
195        .collect();
196    if named.is_empty() {
197        return String::new();
198    }
199    let more = if named.len() > MAX_NAMED_VALUES {
200        ", …"
201    } else {
202        ""
203    };
204    format!(
205        " with {}{more}",
206        named[..named.len().min(MAX_NAMED_VALUES)].join(", ")
207    )
208}
209
210/// Whether `capability` is one an application's package declares: not one the runtime
211/// gates itself, nor an MCP server's tool.
212fn is_application_defined(capability: &str) -> bool {
213    !capability.starts_with("mcp.") && capabilities::find_any(capability).is_none()
214}
215
216/// The normalized VFS paths a denial was for, so a narrowed filter can be fixed
217/// from the message. The library may have probed a path on the program's
218/// behalf, as `submilli:code` does for ignore files, so the program's own
219/// arguments do not always name it. Empty for capabilities without VFS paths.
220fn filesystem_target(capability: &str, context: &serde_json::Value) -> String {
221    let path = |field: &&str| context.get(*field).and_then(serde_json::Value::as_str);
222    match capabilities::vfs_path_fields(capability) {
223        [single] => path(single).map_or_else(String::new, |path| format!(" on {path}")),
224        [from, to] => match (path(from), path(to)) {
225            (Some(from), Some(to)) => format!(" from {from} to {to}"),
226            _ => String::new(),
227        },
228        _ => String::new(),
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn allow_all_returns_allow() {
238        let check = AllowAllCheck;
239        let outcome = check.check("main", "test.com/op", &serde_json::json!({"foo": 1}));
240        assert!(matches!(outcome, CheckOutcome::Allow { .. }));
241    }
242
243    #[test]
244    fn default_check_is_allow_all() {
245        let check = default_check();
246        let outcome = check.check("main", "x", &serde_json::Value::Null);
247        assert!(matches!(outcome, CheckOutcome::Allow { .. }));
248    }
249}