systemprompt_security/policy/
engine.rs1use std::collections::{HashMap, HashSet};
22use std::path::PathBuf;
23use std::sync::LazyLock;
24
25use systemprompt_config::ProfileBootstrap;
26use systemprompt_identifiers::PolicyId;
27
28use super::audit::{ChainEntryOutcome, ChainEntryResult};
29use super::config::{GovernanceConfig, PolicyConfig};
30use super::registry::{PolicyFactory, PolicyRegistration};
31use super::types::{GovernancePolicy, PolicyContext};
32use crate::authz::types::{Decision, MatchedBy};
33
34#[derive(Debug)]
37pub struct Evaluation {
38 pub decision: Decision,
39 pub chain: Vec<ChainEntryOutcome>,
40}
41
42struct ChainEntry {
43 config: PolicyConfig,
44 instance: Box<dyn GovernancePolicy>,
45}
46
47pub struct GovernanceEngine {
48 enabled: bool,
49 entries: Vec<ChainEntry>,
50}
51
52impl std::fmt::Debug for GovernanceEngine {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("GovernanceEngine")
55 .field("enabled", &self.enabled)
56 .field(
57 "policies",
58 &self
59 .entries
60 .iter()
61 .map(|e| e.config.id.as_str())
62 .collect::<Vec<_>>(),
63 )
64 .finish()
65 }
66}
67
68impl GovernanceEngine {
69 pub fn global() -> &'static Self {
79 static ENGINE: LazyLock<GovernanceEngine> = LazyLock::new(|| {
80 let config = governance_config_path()
81 .map_or_else(GovernanceConfig::defaults, |p| GovernanceConfig::load(&p));
82 GovernanceEngine::from_config(&config)
83 });
84 &ENGINE
85 }
86
87 #[must_use]
94 pub fn from_config(config: &GovernanceConfig) -> Self {
95 if !config.enabled {
96 tracing::warn!(
97 "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
98 check will run on any request"
99 );
100 }
101 let factories: HashMap<&'static str, PolicyFactory> =
102 inventory::iter::<PolicyRegistration>()
103 .map(|r| (r.id, r.factory))
104 .collect();
105
106 let mut entries = Vec::with_capacity(config.policies.len());
107 for cfg in &config.policies {
108 let Some(factory) = factories.get(cfg.id.as_str()) else {
109 tracing::warn!(
110 policy = %cfg.id,
111 "governance policy in config has no registered impl — skipping"
112 );
113 continue;
114 };
115 entries.push(ChainEntry {
116 config: cfg.clone(),
117 instance: factory(&cfg.params),
118 });
119 }
120
121 let mentioned: HashSet<&str> = entries.iter().map(|e| e.config.id.as_str()).collect();
122 let unmentioned: Vec<&PolicyRegistration> = inventory::iter::<PolicyRegistration>()
123 .filter(|r| !mentioned.contains(r.id))
124 .collect();
125 for r in unmentioned {
126 let cfg = PolicyConfig {
127 id: r.id.to_owned(),
128 enabled: false,
129 params: serde_yaml::Value::Null,
130 };
131 let instance = (r.factory)(&cfg.params);
132 entries.push(ChainEntry {
133 config: cfg,
134 instance,
135 });
136 }
137
138 Self {
139 enabled: config.enabled,
140 entries,
141 }
142 }
143
144 pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
147 self.entries
148 .iter()
149 .map(|e| (&e.config, e.instance.as_ref()))
150 }
151
152 #[must_use]
159 pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
160 if !self.enabled {
161 return Evaluation {
162 decision: Decision::Allow {
163 matched_by: MatchedBy::DefaultIncluded,
164 },
165 chain: self
166 .entries
167 .iter()
168 .map(|entry| {
169 chain_entry(
170 &entry.config,
171 ChainEntryResult::Disabled,
172 "Governance disabled by master switch",
173 )
174 })
175 .collect(),
176 };
177 }
178
179 let mut chain: Vec<ChainEntryOutcome> = Vec::with_capacity(self.entries.len());
180 let mut denied: Option<Decision> = None;
181
182 for entry in &self.entries {
183 if !entry.config.enabled {
184 chain.push(chain_entry(
185 &entry.config,
186 ChainEntryResult::Disabled,
187 "Policy disabled in governance config",
188 ));
189 continue;
190 }
191 if denied.is_some() {
192 chain.push(chain_entry(
193 &entry.config,
194 ChainEntryResult::Skip,
195 "Skipped — already denied by an earlier policy",
196 ));
197 continue;
198 }
199 let started = std::time::Instant::now();
200 let decision = entry.instance.evaluate(ctx);
201 let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
202 match &decision {
203 Decision::Allow { matched_by } => chain.push(ChainEntryOutcome {
204 policy_id: entry.instance.id(),
205 result: ChainEntryResult::Pass,
206 detail: allow_detail(matched_by),
207 duration_ms,
208 }),
209 Decision::Deny { reason } => {
210 chain.push(ChainEntryOutcome {
211 policy_id: entry.instance.id(),
212 result: ChainEntryResult::Fail,
213 detail: reason.to_string(),
214 duration_ms,
215 });
216 denied = Some(decision);
217 },
218 }
219 }
220
221 Evaluation {
222 decision: denied.unwrap_or(Decision::Allow {
223 matched_by: MatchedBy::DefaultIncluded,
224 }),
225 chain,
226 }
227 }
228}
229
230fn governance_config_path() -> Option<PathBuf> {
231 let profile = ProfileBootstrap::get()
232 .inspect_err(|e| {
233 tracing::error!(
234 error = %e,
235 "governance profile bootstrap failed; policies fall back to built-in defaults"
236 );
237 })
238 .ok()?;
239 Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
240}
241
242fn chain_entry(cfg: &PolicyConfig, result: ChainEntryResult, detail: &str) -> ChainEntryOutcome {
243 ChainEntryOutcome {
244 policy_id: PolicyId::new(cfg.id.clone()),
245 result,
246 detail: detail.to_owned(),
247 duration_ms: 0.0,
248 }
249}
250
251fn allow_detail(matched_by: &MatchedBy) -> String {
252 match matched_by {
253 MatchedBy::PolicyAllow { detail, .. } => detail.to_string(),
254 MatchedBy::UserAllow => "user allow".to_owned(),
255 MatchedBy::RoleAllow { role } => format!("role allow: {role}"),
256 MatchedBy::AttributeAllow { rule_type, value } => format!("{rule_type} allow: {value}"),
257 MatchedBy::DefaultIncluded => "default included".to_owned(),
258 }
259}