systemprompt_security/policy/engine/
mod.rs1mod chain;
28
29use std::collections::{HashMap, HashSet};
30use std::path::PathBuf;
31use std::sync::LazyLock;
32
33use systemprompt_config::ProfileBootstrap;
34use thiserror::Error;
35
36use super::audit::ChainEntryOutcome;
37use super::builtin::SECRET_SCAN_ID;
38use super::config::{GovernanceConfig, PolicyConfig, PolicyMode};
39use super::governed::GovernedInput;
40use super::registry::{PolicyConfigurationError, PolicyFactory, PolicyRegistration};
41use super::secrets::{SecretFinding, SecretScanner};
42use super::types::{GovernancePolicy, PolicyContext};
43use crate::authz::types::Decision;
44
45#[derive(Debug)]
48pub struct Evaluation {
49 pub decision: Decision,
50 pub chain: Vec<ChainEntryOutcome>,
51}
52
53#[derive(Debug, Error, Clone, PartialEq, Eq)]
54pub enum GovernanceEngineError {
55 #[error(
56 "governance config names policy `{id}`, but no implementation is linked into this binary"
57 )]
58 UnknownPolicyId { id: String },
59 #[error("governance policy `{id}` has invalid configuration: {source}")]
60 InvalidPolicyConfiguration {
61 id: String,
62 #[source]
63 source: PolicyConfigurationError,
64 },
65 #[error("governance config at {path} was rejected: {message}")]
66 ConfigRejected { path: String, message: String },
67}
68
69struct ChainEntry {
70 config: PolicyConfig,
71 instance: Box<dyn GovernancePolicy>,
72}
73
74pub struct GovernanceEngine {
75 enabled: bool,
76 entries: Vec<ChainEntry>,
77}
78
79impl std::fmt::Debug for GovernanceEngine {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("GovernanceEngine")
82 .field("enabled", &self.enabled)
83 .field(
84 "policies",
85 &self
86 .entries
87 .iter()
88 .map(|e| e.config.id.as_str())
89 .collect::<Vec<_>>(),
90 )
91 .finish()
92 }
93}
94
95impl GovernanceEngine {
96 pub fn global() -> Result<&'static Self, GovernanceEngineError> {
97 static ENGINE: LazyLock<Result<GovernanceEngine, GovernanceEngineError>> =
98 LazyLock::new(|| {
99 let config = match governance_config_path() {
100 Some(path) => GovernanceConfig::load(&path).map_err(|error| {
101 GovernanceEngineError::ConfigRejected {
102 path: path.display().to_string(),
103 message: error.to_string(),
104 }
105 })?,
106 None => GovernanceConfig::defaults(),
107 };
108 GovernanceEngine::from_config(&config)
109 });
110 ENGINE.as_ref().map_err(Clone::clone)
111 }
112
113 pub fn from_config(config: &GovernanceConfig) -> Result<Self, GovernanceEngineError> {
114 if !config.enabled {
115 tracing::warn!(
116 "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
117 check will run on any request"
118 );
119 }
120 let factories: HashMap<&'static str, PolicyFactory> =
121 inventory::iter::<PolicyRegistration>()
122 .map(|r| (r.id, r.factory))
123 .collect();
124
125 let mut entries = Vec::with_capacity(config.policies.len());
126 for cfg in &config.policies {
127 let factory = factories
128 .get(cfg.id.as_str())
129 .ok_or_else(|| GovernanceEngineError::UnknownPolicyId { id: cfg.id.clone() })?;
130 let instance = factory(&cfg.params).map_err(|source| {
131 GovernanceEngineError::InvalidPolicyConfiguration {
132 id: cfg.id.clone(),
133 source,
134 }
135 })?;
136 entries.push(ChainEntry {
137 config: cfg.clone(),
138 instance,
139 });
140 }
141
142 let mentioned: HashSet<&str> = config.policies.iter().map(|p| p.id.as_str()).collect();
143 for r in inventory::iter::<PolicyRegistration>().filter(|r| !mentioned.contains(r.id)) {
144 let config = PolicyConfig {
145 id: r.id.to_owned(),
146 enabled: false,
147 mode: PolicyMode::Enforce,
148 params: serde_yaml::Value::Null,
149 };
150 let instance = (r.factory)(&config.params).map_err(|source| {
151 GovernanceEngineError::InvalidPolicyConfiguration {
152 id: r.id.to_owned(),
153 source,
154 }
155 })?;
156 entries.push(ChainEntry { config, instance });
157 }
158
159 Ok(Self {
160 enabled: config.enabled,
161 entries,
162 })
163 }
164
165 #[must_use]
166 pub fn enforces_prompt_secrets(&self) -> bool {
167 self.enabled
168 && self.entries.iter().any(|entry| {
169 entry.config.enabled
170 && !entry.config.mode.is_warn()
171 && entry.config.id == SECRET_SCAN_ID
172 })
173 }
174
175 #[must_use]
176 pub fn secret_scanner(&self) -> Option<&SecretScanner> {
177 self.entries
178 .iter()
179 .find(|entry| entry.config.id == SECRET_SCAN_ID)
180 .and_then(|entry| entry.instance.secret_scanner())
181 }
182
183 pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
184 self.entries
185 .iter()
186 .map(|e| (&e.config, e.instance.as_ref()))
187 }
188
189 pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
190 self.evaluate_chain(ctx, None)
191 }
192
193 pub fn evaluate_with_prompt_recovery(
194 &self,
195 ctx: &PolicyContext<'_>,
196 mut recover: impl FnMut(&[SecretFinding]) -> Option<GovernedInput>,
197 ) -> Evaluation {
198 self.evaluate_chain(ctx, Some(&mut recover))
199 }
200}
201
202fn governance_config_path() -> Option<PathBuf> {
203 let profile = ProfileBootstrap::get()
204 .inspect_err(|e| {
205 tracing::error!(
206 error = %e,
207 "governance profile bootstrap failed; policies fall back to built-in defaults"
208 );
209 })
210 .ok()?;
211 Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
212}