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, PolicyMode};
30use super::registry::{PolicyFactory, PolicyRegistration};
31use super::types::{GovernancePolicy, PolicyContext};
32use crate::authz::types::{Decision, DenyReason, 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 {
70 static ENGINE: LazyLock<GovernanceEngine> = LazyLock::new(|| {
71 let config = governance_config_path()
72 .map_or_else(GovernanceConfig::defaults, |p| GovernanceConfig::load(&p));
73 GovernanceEngine::from_config(&config)
74 });
75 &ENGINE
76 }
77
78 #[must_use]
79 pub fn from_config(config: &GovernanceConfig) -> Self {
80 if !config.enabled {
81 tracing::warn!(
82 "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
83 check will run on any request"
84 );
85 }
86 let factories: HashMap<&'static str, PolicyFactory> =
87 inventory::iter::<PolicyRegistration>()
88 .map(|r| (r.id, r.factory))
89 .collect();
90
91 let mut entries = Vec::with_capacity(config.policies.len());
92 for cfg in &config.policies {
93 let Some(factory) = factories.get(cfg.id.as_str()) else {
94 tracing::warn!(
95 policy = %cfg.id,
96 "governance policy in config has no registered impl — skipping"
97 );
98 continue;
99 };
100 entries.push(ChainEntry {
101 config: cfg.clone(),
102 instance: factory(&cfg.params),
103 });
104 }
105
106 let mentioned: HashSet<&str> = entries.iter().map(|e| e.config.id.as_str()).collect();
107 let unmentioned: Vec<&PolicyRegistration> = inventory::iter::<PolicyRegistration>()
108 .filter(|r| !mentioned.contains(r.id))
109 .collect();
110 for r in unmentioned {
111 let cfg = PolicyConfig {
112 id: r.id.to_owned(),
113 enabled: false,
114 mode: PolicyMode::Enforce,
115 params: serde_yaml::Value::Null,
116 };
117 let instance = (r.factory)(&cfg.params);
118 entries.push(ChainEntry {
119 config: cfg,
120 instance,
121 });
122 }
123
124 Self {
125 enabled: config.enabled,
126 entries,
127 }
128 }
129
130 pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
131 self.entries
132 .iter()
133 .map(|e| (&e.config, e.instance.as_ref()))
134 }
135
136 #[must_use]
137 fn master_switch_off(&self) -> Evaluation {
138 Evaluation {
139 decision: Decision::Allow {
140 matched_by: MatchedBy::DefaultIncluded,
141 },
142 chain: self
143 .entries
144 .iter()
145 .map(|entry| {
146 chain_entry(
147 &entry.config,
148 ChainEntryResult::Disabled,
149 "Governance disabled by master switch",
150 )
151 })
152 .collect(),
153 }
154 }
155
156 pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
157 if !self.enabled {
158 return self.master_switch_off();
159 }
160
161 let mut chain: Vec<ChainEntryOutcome> = Vec::with_capacity(self.entries.len());
162 let mut halted: Option<Decision> = None;
166 let mut first_warn: Option<DenyReason> = None;
171
172 for entry in &self.entries {
173 if !entry.config.enabled {
174 chain.push(chain_entry(
175 &entry.config,
176 ChainEntryResult::Disabled,
177 "Policy disabled in governance config",
178 ));
179 continue;
180 }
181 if halted.is_some() {
182 chain.push(chain_entry(
183 &entry.config,
184 ChainEntryResult::Skip,
185 "Skipped — already halted by an earlier policy",
186 ));
187 continue;
188 }
189 let started = std::time::Instant::now();
190 let decision = entry.instance.evaluate(ctx);
191 let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
192 let (outcome, warn, halt) = classify(entry, &decision, duration_ms);
193 chain.push(outcome);
194 if let Some(reason) = warn
195 && first_warn.is_none()
196 {
197 first_warn = Some(reason);
198 }
199 if halt {
200 halted = Some(decision);
201 }
202 }
203
204 let decision = halted.unwrap_or_else(|| {
205 first_warn.map_or(
206 Decision::Allow {
207 matched_by: MatchedBy::DefaultIncluded,
208 },
209 |reason| Decision::Warn { reason },
210 )
211 });
212 Evaluation { decision, chain }
213 }
214}
215
216fn classify(
220 entry: &ChainEntry,
221 decision: &Decision,
222 duration_ms: f64,
223) -> (ChainEntryOutcome, Option<DenyReason>, bool) {
224 let row = |result, detail| ChainEntryOutcome {
225 policy_id: entry.instance.id(),
226 result,
227 detail,
228 duration_ms,
229 };
230 match decision {
231 Decision::Allow { matched_by } => (
232 row(ChainEntryResult::Pass, allow_detail(matched_by)),
233 None,
234 false,
235 ),
236 Decision::Deny { reason } if entry.config.mode.is_warn() => {
237 tracing::warn!(
238 policy = %entry.config.id,
239 reason = %reason,
240 "governance policy in warn mode would have denied this call; allowing it"
241 );
242 (
243 row(ChainEntryResult::Warn, reason.to_string()),
244 Some(reason.clone()),
245 false,
246 )
247 },
248 Decision::Deny { reason } => (row(ChainEntryResult::Fail, reason.to_string()), None, true),
249 Decision::Warn { reason } => (
253 row(ChainEntryResult::Warn, reason.to_string()),
254 Some(reason.clone()),
255 false,
256 ),
257 Decision::Pending { reason } => {
258 (row(ChainEntryResult::Hold, reason.to_string()), None, true)
259 },
260 }
261}
262
263fn governance_config_path() -> Option<PathBuf> {
264 let profile = ProfileBootstrap::get()
265 .inspect_err(|e| {
266 tracing::error!(
267 error = %e,
268 "governance profile bootstrap failed; policies fall back to built-in defaults"
269 );
270 })
271 .ok()?;
272 Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
273}
274
275fn chain_entry(cfg: &PolicyConfig, result: ChainEntryResult, detail: &str) -> ChainEntryOutcome {
276 ChainEntryOutcome {
277 policy_id: PolicyId::new(cfg.id.clone()),
278 result,
279 detail: detail.to_owned(),
280 duration_ms: 0.0,
281 }
282}
283
284fn allow_detail(matched_by: &MatchedBy) -> String {
285 match matched_by {
286 MatchedBy::PolicyAllow { detail, .. } => detail.to_string(),
287 MatchedBy::UserAllow => "user allow".to_owned(),
288 MatchedBy::RoleAllow { role } => format!("role allow: {role}"),
289 MatchedBy::AttributeAllow { rule_type, value } => format!("{rule_type} allow: {value}"),
290 MatchedBy::DefaultIncluded => "default included".to_owned(),
291 }
292}