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::{PolicyFactory, PolicyRegistration};
41use super::secrets::SecretFinding;
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}
60
61struct ChainEntry {
62 config: PolicyConfig,
63 instance: Box<dyn GovernancePolicy>,
64}
65
66pub struct GovernanceEngine {
67 enabled: bool,
68 entries: Vec<ChainEntry>,
69}
70
71impl std::fmt::Debug for GovernanceEngine {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("GovernanceEngine")
74 .field("enabled", &self.enabled)
75 .field(
76 "policies",
77 &self
78 .entries
79 .iter()
80 .map(|e| e.config.id.as_str())
81 .collect::<Vec<_>>(),
82 )
83 .finish()
84 }
85}
86
87impl GovernanceEngine {
88 pub fn global() -> Result<&'static Self, GovernanceEngineError> {
89 static ENGINE: LazyLock<Result<GovernanceEngine, GovernanceEngineError>> =
90 LazyLock::new(|| {
91 let config = governance_config_path()
92 .map_or_else(GovernanceConfig::defaults, |p| GovernanceConfig::load(&p));
93 GovernanceEngine::from_config(&config)
94 });
95 ENGINE.as_ref().map_err(Clone::clone)
96 }
97
98 pub fn from_config(config: &GovernanceConfig) -> Result<Self, GovernanceEngineError> {
99 if !config.enabled {
100 tracing::warn!(
101 "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
102 check will run on any request"
103 );
104 }
105 let factories: HashMap<&'static str, PolicyFactory> =
106 inventory::iter::<PolicyRegistration>()
107 .map(|r| (r.id, r.factory))
108 .collect();
109
110 let mut entries = Vec::with_capacity(config.policies.len());
111 for cfg in &config.policies {
112 let factory = factories
113 .get(cfg.id.as_str())
114 .ok_or_else(|| GovernanceEngineError::UnknownPolicyId { id: cfg.id.clone() })?;
115 entries.push(ChainEntry {
116 config: cfg.clone(),
117 instance: factory(&cfg.params),
118 });
119 }
120
121 let mentioned: HashSet<&str> = config.policies.iter().map(|p| p.id.as_str()).collect();
122 for r in inventory::iter::<PolicyRegistration>().filter(|r| !mentioned.contains(r.id)) {
123 let config = PolicyConfig {
124 id: r.id.to_owned(),
125 enabled: false,
126 mode: PolicyMode::Enforce,
127 params: serde_yaml::Value::Null,
128 };
129 let instance = (r.factory)(&config.params);
130 entries.push(ChainEntry { config, instance });
131 }
132
133 Ok(Self {
134 enabled: config.enabled,
135 entries,
136 })
137 }
138
139 #[must_use]
140 pub fn enforces_prompt_secrets(&self) -> bool {
141 self.enabled
142 && self.entries.iter().any(|entry| {
143 entry.config.enabled
144 && !entry.config.mode.is_warn()
145 && entry.config.id == SECRET_SCAN_ID
146 })
147 }
148
149 pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
150 self.entries
151 .iter()
152 .map(|e| (&e.config, e.instance.as_ref()))
153 }
154
155 pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
156 self.evaluate_chain(ctx, None)
157 }
158
159 pub fn evaluate_with_prompt_recovery(
160 &self,
161 ctx: &PolicyContext<'_>,
162 mut recover: impl FnMut(&[SecretFinding]) -> Option<GovernedInput>,
163 ) -> Evaluation {
164 self.evaluate_chain(ctx, Some(&mut recover))
165 }
166}
167
168fn governance_config_path() -> Option<PathBuf> {
169 let profile = ProfileBootstrap::get()
170 .inspect_err(|e| {
171 tracing::error!(
172 error = %e,
173 "governance profile bootstrap failed; policies fall back to built-in defaults"
174 );
175 })
176 .ok()?;
177 Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
178}