systemprompt_security/policy/
engine.rs1use std::collections::{HashMap, HashSet};
19
20use systemprompt_identifiers::PolicyId;
21
22use super::audit::{ChainEntryOutcome, ChainEntryResult};
23use super::config::{GovernanceConfig, PolicyConfig};
24use super::registry::{PolicyFactory, PolicyRegistration};
25use super::types::{GovernancePolicy, PolicyContext};
26use crate::authz::types::{Decision, MatchedBy};
27
28#[derive(Debug)]
31pub struct Evaluation {
32 pub decision: Decision,
33 pub chain: Vec<ChainEntryOutcome>,
34}
35
36struct ChainEntry {
37 config: PolicyConfig,
38 instance: Box<dyn GovernancePolicy>,
39}
40
41pub struct GovernanceEngine {
42 entries: Vec<ChainEntry>,
43}
44
45impl std::fmt::Debug for GovernanceEngine {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("GovernanceEngine")
48 .field(
49 "policies",
50 &self
51 .entries
52 .iter()
53 .map(|e| e.config.id.as_str())
54 .collect::<Vec<_>>(),
55 )
56 .finish()
57 }
58}
59
60impl GovernanceEngine {
61 #[must_use]
68 pub fn from_config(config: &GovernanceConfig) -> Self {
69 let factories: HashMap<&'static str, PolicyFactory> =
70 inventory::iter::<PolicyRegistration>()
71 .map(|r| (r.id, r.factory))
72 .collect();
73
74 let mut entries = Vec::with_capacity(config.policies.len());
75 for cfg in &config.policies {
76 let Some(factory) = factories.get(cfg.id.as_str()) else {
77 tracing::warn!(
78 policy = %cfg.id,
79 "governance policy in config has no registered impl — skipping"
80 );
81 continue;
82 };
83 entries.push(ChainEntry {
84 config: cfg.clone(),
85 instance: factory(&cfg.params),
86 });
87 }
88
89 let mentioned: HashSet<&str> = entries.iter().map(|e| e.config.id.as_str()).collect();
90 let unmentioned: Vec<&PolicyRegistration> = inventory::iter::<PolicyRegistration>()
91 .filter(|r| !mentioned.contains(r.id))
92 .collect();
93 for r in unmentioned {
94 let cfg = PolicyConfig {
95 id: r.id.to_owned(),
96 enabled: false,
97 params: serde_yaml::Value::Null,
98 };
99 let instance = (r.factory)(&cfg.params);
100 entries.push(ChainEntry {
101 config: cfg,
102 instance,
103 });
104 }
105
106 Self { entries }
107 }
108
109 pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
112 self.entries
113 .iter()
114 .map(|e| (&e.config, e.instance.as_ref()))
115 }
116
117 #[must_use]
123 pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
124 let mut chain: Vec<ChainEntryOutcome> = Vec::with_capacity(self.entries.len());
125 let mut denied: Option<Decision> = None;
126
127 for entry in &self.entries {
128 if !entry.config.enabled {
129 chain.push(skip_entry(
130 &entry.config,
131 "Policy disabled in governance config",
132 ));
133 continue;
134 }
135 if denied.is_some() {
136 chain.push(skip_entry(
137 &entry.config,
138 "Skipped — already denied by an earlier policy",
139 ));
140 continue;
141 }
142 let started = std::time::Instant::now();
143 let decision = entry.instance.evaluate(ctx);
144 let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
145 match &decision {
146 Decision::Allow { matched_by } => chain.push(ChainEntryOutcome {
147 policy_id: entry.instance.id(),
148 result: ChainEntryResult::Pass,
149 detail: allow_detail(matched_by),
150 duration_ms,
151 }),
152 Decision::Deny { reason } => {
153 chain.push(ChainEntryOutcome {
154 policy_id: entry.instance.id(),
155 result: ChainEntryResult::Fail,
156 detail: reason.to_string(),
157 duration_ms,
158 });
159 denied = Some(decision);
160 },
161 }
162 }
163
164 Evaluation {
165 decision: denied.unwrap_or(Decision::Allow {
166 matched_by: MatchedBy::DefaultIncluded,
167 }),
168 chain,
169 }
170 }
171}
172
173fn skip_entry(cfg: &PolicyConfig, detail: &str) -> ChainEntryOutcome {
174 ChainEntryOutcome {
175 policy_id: PolicyId::new(cfg.id.clone()),
176 result: ChainEntryResult::Skip,
177 detail: detail.to_owned(),
178 duration_ms: 0.0,
179 }
180}
181
182fn allow_detail(matched_by: &MatchedBy) -> String {
183 match matched_by {
184 MatchedBy::PolicyAllow { detail, .. } => detail.to_string(),
185 MatchedBy::UserAllow => "user allow".to_owned(),
186 MatchedBy::RoleAllow { role } => format!("role allow: {role}"),
187 MatchedBy::AttributeAllow { rule_type, value } => format!("{rule_type} allow: {value}"),
188 MatchedBy::DefaultIncluded => "default included".to_owned(),
189 }
190}