1use 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 Ask {
24 approver: Approver,
25 rule: AskRule,
26 },
27}
28
29#[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 pub explanation: Option<&'a DecisionExplanation>,
45 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 fn audit(&self, _decision: AuditDecision<'_>) {}
86 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 fn recorder(&self) -> Option<&dyn DecisionRecorder> {
108 None
109 }
110 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
124pub 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
143pub 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
153fn 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
164const MAX_NAMED_VALUES: usize = 4;
166const MAX_NAMED_VALUE_CHARS: usize = 64;
167
168fn 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
210fn is_application_defined(capability: &str) -> bool {
213 !capability.starts_with("mcp.") && capabilities::find_any(capability).is_none()
214}
215
216fn 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}