systemprompt_security/policy/engine/
mod.rs1mod chain;
28
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31
32use systemprompt_identifiers::PolicyId;
33use thiserror::Error;
34
35use super::audit::ChainEntryOutcome;
36use super::builtin::SECRET_SCAN_ID;
37use super::config::{GovernanceConfig, GovernanceConfigError, PolicyConfig, PolicyMode};
38use super::governed::GovernedInput;
39use super::registry::{PolicyConfigurationError, PolicyFactory, PolicyRegistration};
40use super::secrets::{SecretFinding, SecretScanner};
41use super::types::{GovernancePolicy, PolicyContext};
42use crate::authz::types::Decision;
43
44#[derive(Debug)]
47pub struct Evaluation {
48 pub decision: Decision,
49 pub chain: Vec<ChainEntryOutcome>,
50}
51
52#[derive(Debug, Error)]
53pub enum GovernanceEngineError {
54 #[error(
55 "governance config names policy `{id}`, but no implementation is linked into this binary"
56 )]
57 UnknownPolicyId { id: PolicyId },
58 #[error("governance policy `{id}` has invalid configuration: {source}")]
59 InvalidPolicyConfiguration {
60 id: PolicyId,
61 #[source]
62 source: PolicyConfigurationError,
63 },
64 #[error("governance config at {path} was rejected: {source}")]
65 ConfigRejected {
66 path: String,
67 #[source]
68 source: GovernanceConfigError,
69 },
70}
71
72struct ChainEntry {
73 config: PolicyConfig,
74 instance: Box<dyn GovernancePolicy>,
75}
76
77pub struct GovernanceEngine {
78 enabled: bool,
79 entries: Vec<ChainEntry>,
80}
81
82impl std::fmt::Debug for GovernanceEngine {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("GovernanceEngine")
85 .field("enabled", &self.enabled)
86 .field(
87 "policies",
88 &self
89 .entries
90 .iter()
91 .map(|e| e.config.id.as_str())
92 .collect::<Vec<_>>(),
93 )
94 .finish()
95 }
96}
97
98impl GovernanceEngine {
99 pub fn from_services_root(services_root: &Path) -> Result<Self, GovernanceEngineError> {
100 let path = services_root.join("governance/config.yaml");
101 let config = GovernanceConfig::load(&path).map_err(|source| {
102 GovernanceEngineError::ConfigRejected {
103 path: path.display().to_string(),
104 source,
105 }
106 })?;
107 Self::from_config(&config)
108 }
109
110 pub fn from_config(config: &GovernanceConfig) -> Result<Self, GovernanceEngineError> {
111 if !config.enabled {
112 tracing::warn!(
113 "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
114 check will run on any request"
115 );
116 }
117 let factories: HashMap<&'static str, PolicyFactory> =
118 inventory::iter::<PolicyRegistration>()
119 .map(|r| (r.id, r.factory))
120 .collect();
121
122 let mut entries = Vec::with_capacity(config.policies.len());
123 for cfg in &config.policies {
124 let factory = factories
125 .get(cfg.id.as_str())
126 .ok_or_else(|| GovernanceEngineError::UnknownPolicyId { id: cfg.id.clone() })?;
127 let instance = factory(&cfg.params).map_err(|source| {
128 GovernanceEngineError::InvalidPolicyConfiguration {
129 id: cfg.id.clone(),
130 source,
131 }
132 })?;
133 if config.enabled {
134 reject_toothless_enforcement(cfg, instance.as_ref())?;
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: PolicyId::new(r.id),
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: config.id.clone(),
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 reject_toothless_enforcement(
207 cfg: &PolicyConfig,
208 instance: &dyn GovernancePolicy,
209) -> Result<(), GovernanceEngineError> {
210 if cfg.id != SECRET_SCAN_ID || !cfg.enabled || cfg.mode.is_warn() {
211 return Ok(());
212 }
213 let toothless = instance
214 .secret_scanner()
215 .is_none_or(|scanner| scanner.pattern_count() == 0);
216 if toothless {
217 return Err(GovernanceEngineError::InvalidPolicyConfiguration {
218 id: cfg.id.clone(),
219 source: PolicyConfigurationError::ToothlessSecretScan,
220 });
221 }
222 Ok(())
223}