Skip to main content

nexara_core/
policy_contract.rs

1use crate::policy::{ActionClass, TrustTier};
2use crate::tool::ToolDescriptor;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum PolicySourceKind {
9    OneLine,
10    Json,
11    HostProvided,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct PolicySource {
16    pub kind: PolicySourceKind,
17    pub text: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct PolicyContract {
22    pub id: String,
23    pub version: String,
24    pub source: PolicySource,
25    pub rules: Vec<PolicyRule>,
26    pub defaults: PolicyDefaults,
27    #[serde(default)]
28    pub diagnostics: Vec<PolicyDiagnostic>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub created_at: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct PolicyDefaults {
35    pub deny_by_default: bool,
36    pub deny_missing_capabilities: bool,
37}
38
39impl Default for PolicyDefaults {
40    fn default() -> Self {
41        Self {
42            deny_by_default: true,
43            deny_missing_capabilities: true,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct PolicyRule {
50    pub id: String,
51    pub effect: PolicyEffect,
52    pub selector: PolicySelector,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub condition: Option<PolicyCondition>,
55    pub reason: String,
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum PolicyEffect {
61    Allow,
62    Deny,
63    RequireConfirmation,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
67pub struct PolicySelector {
68    #[serde(default)]
69    pub capabilities: Vec<CapabilityPattern>,
70    #[serde(default)]
71    pub resources: Vec<ResourcePattern>,
72    #[serde(default)]
73    pub actions: Vec<ActionPattern>,
74    #[serde(default)]
75    pub tools: Vec<ToolPattern>,
76    #[serde(default)]
77    pub scopes: Vec<ScopePattern>,
78    #[serde(default)]
79    pub trust_tiers: Vec<TrustTier>,
80    #[serde(default)]
81    pub action_classes: Vec<ActionClass>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct CapabilityPattern(pub String);
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct ResourcePattern(pub String);
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct ActionPattern(pub String);
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct ToolPattern(pub String);
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct ScopePattern(pub String);
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct PolicyCondition {
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub requires_confirmation: Option<bool>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub max_payload_bytes: Option<usize>,
105    #[serde(default)]
106    pub allowed_hosts: Vec<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub trust_tier_at_most: Option<TrustTier>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub action_class_is: Option<ActionClass>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct PolicyDiagnostic {
115    pub severity: PolicyDiagnosticSeverity,
116    pub code: PolicyDiagnosticCode,
117    pub message: String,
118}
119
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "snake_case")]
122pub enum PolicyDiagnosticSeverity {
123    Info,
124    Warning,
125    Error,
126}
127
128#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "snake_case")]
130pub enum PolicyDiagnosticCode {
131    UnknownResource,
132    UnknownOperation,
133    AmbiguousVerb,
134    ConflictingRule,
135    OverBroadAllow,
136    MissingCatalogMapping,
137    DenyOverridesAllow,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct MatchedPolicyRule {
142    pub rule_id: String,
143    pub effect: PolicyEffect,
144    pub reason: String,
145}
146
147#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "snake_case")]
149pub enum PolicyEvaluationDecision {
150    Allowed,
151    Denied,
152    ConfirmationRequired,
153    NoMatchingAllow,
154    DescriptorMissingCapabilities,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158pub struct PolicyEvaluation {
159    pub decision: PolicyEvaluationDecision,
160    #[serde(default)]
161    pub matched_rules: Vec<MatchedPolicyRule>,
162    pub explanation: String,
163}
164
165impl PolicyContract {
166    pub fn evaluate_tool(&self, tool: &ToolDescriptor, confirmed: bool) -> PolicyEvaluation {
167        if self.defaults.deny_missing_capabilities && tool.capabilities.is_empty() {
168            return PolicyEvaluation {
169                decision: PolicyEvaluationDecision::DescriptorMissingCapabilities,
170                matched_rules: Vec::new(),
171                explanation: format!(
172                    "Denied because '{}' does not declare semantic capability metadata.",
173                    tool.name
174                ),
175            };
176        }
177
178        let mut matches = Vec::new();
179        for rule in &self.rules {
180            if rule.selector.matches_tool(tool) {
181                matches.push(MatchedPolicyRule {
182                    rule_id: rule.id.clone(),
183                    effect: rule.effect,
184                    reason: rule.reason.clone(),
185                });
186            }
187        }
188
189        if let Some(rule) = matches
190            .iter()
191            .find(|rule| matches!(rule.effect, PolicyEffect::Deny))
192            .cloned()
193        {
194            return PolicyEvaluation {
195                decision: PolicyEvaluationDecision::Denied,
196                matched_rules: matches,
197                explanation: format!("Denied by policy rule '{}': {}", rule.rule_id, rule.reason),
198            };
199        }
200
201        if let Some(rule) = matches
202            .iter()
203            .find(|rule| matches!(rule.effect, PolicyEffect::RequireConfirmation))
204            .cloned()
205            && !confirmed
206        {
207            return PolicyEvaluation {
208                decision: PolicyEvaluationDecision::ConfirmationRequired,
209                matched_rules: matches,
210                explanation: format!(
211                    "Confirmation required by policy rule '{}': {}",
212                    rule.rule_id, rule.reason
213                ),
214            };
215        }
216
217        if matches
218            .iter()
219            .any(|rule| matches!(rule.effect, PolicyEffect::Allow))
220        {
221            return PolicyEvaluation {
222                decision: PolicyEvaluationDecision::Allowed,
223                matched_rules: matches,
224                explanation: format!("Allowed by policy contract '{}'.", self.id),
225            };
226        }
227
228        if self.defaults.deny_by_default {
229            PolicyEvaluation {
230                decision: PolicyEvaluationDecision::NoMatchingAllow,
231                matched_rules: matches,
232                explanation: format!(
233                    "Denied because '{}' did not match any allow rule in policy contract '{}'.",
234                    tool.name, self.id
235                ),
236            }
237        } else {
238            PolicyEvaluation {
239                decision: PolicyEvaluationDecision::Allowed,
240                matched_rules: matches,
241                explanation: format!(
242                    "Allowed by policy contract '{}' default allow behavior.",
243                    self.id
244                ),
245            }
246        }
247    }
248}
249
250impl PolicySelector {
251    pub fn matches_tool(&self, tool: &ToolDescriptor) -> bool {
252        self.matches_tools(tool)
253            && self.matches_action_classes(tool)
254            && self.matches_trust_tiers(tool)
255            && self.matches_scopes(tool)
256            && self.matches_capabilities(tool)
257            && self.matches_resources(tool)
258            && self.matches_actions(tool)
259    }
260
261    fn matches_tools(&self, tool: &ToolDescriptor) -> bool {
262        self.tools.is_empty()
263            || self
264                .tools
265                .iter()
266                .any(|pattern| pattern_matches(&pattern.0, &tool.name))
267    }
268
269    fn matches_action_classes(&self, tool: &ToolDescriptor) -> bool {
270        self.action_classes.is_empty() || self.action_classes.contains(&tool.action_class)
271    }
272
273    fn matches_trust_tiers(&self, tool: &ToolDescriptor) -> bool {
274        self.trust_tiers.is_empty() || self.trust_tiers.contains(&tool.trust_tier)
275    }
276
277    fn matches_scopes(&self, tool: &ToolDescriptor) -> bool {
278        self.scopes.is_empty()
279            || self.scopes.iter().any(|pattern| {
280                tool.scopes
281                    .iter()
282                    .any(|scope| pattern_matches(&pattern.0, scope))
283            })
284    }
285
286    fn matches_capabilities(&self, tool: &ToolDescriptor) -> bool {
287        self.capabilities.is_empty()
288            || self.capabilities.iter().any(|pattern| {
289                tool.capabilities
290                    .iter()
291                    .any(|capability| pattern_matches(&pattern.0, &capability.id))
292            })
293    }
294
295    fn matches_resources(&self, tool: &ToolDescriptor) -> bool {
296        self.resources.is_empty()
297            || self.resources.iter().any(|pattern| {
298                tool.capabilities.iter().any(|capability| {
299                    pattern_matches(&pattern.0, &capability.resource_path.join("."))
300                        || pattern_matches(&pattern.0, &capability.resource)
301                })
302            })
303    }
304
305    fn matches_actions(&self, tool: &ToolDescriptor) -> bool {
306        self.actions.is_empty()
307            || self.actions.iter().any(|pattern| {
308                tool.capabilities
309                    .iter()
310                    .any(|capability| pattern_matches(&pattern.0, &capability.operation))
311            })
312    }
313}
314
315pub fn pattern_matches(pattern: &str, value: &str) -> bool {
316    let pattern = pattern.trim();
317    let value = value.trim();
318    if pattern == "*" || pattern == value {
319        return true;
320    }
321    if let Some(prefix) = pattern.strip_suffix(".*") {
322        return value == prefix || value.starts_with(&format!("{prefix}."));
323    }
324    if let Some(suffix) = pattern.strip_prefix("*.") {
325        return value == suffix || value.ends_with(&format!(".{suffix}"));
326    }
327    false
328}
329
330pub fn matched_rules_metadata(rules: &[MatchedPolicyRule]) -> BTreeMap<String, String> {
331    let mut metadata = BTreeMap::new();
332    if !rules.is_empty() {
333        metadata.insert(
334            "policy.matched_rules".to_string(),
335            rules
336                .iter()
337                .map(|rule| rule.rule_id.as_str())
338                .collect::<Vec<_>>()
339                .join(","),
340        );
341    }
342    metadata
343}