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