Skip to main content

systemprompt_security/policy/engine/
mod.rs

1//! Traced first-deny-wins evaluation of the configured policy chain.
2//!
3//! [`GovernanceEngine`] owns the instantiated chain: policies resolved from
4//! the inventory registry against a [`GovernanceConfig`], in declaration
5//! order. [`GovernanceEngine::evaluate`] records a per-entry
6//! [`ChainEntryOutcome`] — including disabled and skipped-after-deny entries —
7//! so the audit row preserves the full evaluation order, not just the first
8//! deny. The walk itself lives in `chain`.
9//!
10//! Policies that accumulate state (the rate limiter) scope it to their
11//! instance, so two engines never share buckets — a second engine would
12//! silently double every budget. The engine is therefore built once at the
13//! composition root ([`GovernanceEngine::from_services_root`]) and injected
14//! into every enforcement point: the MCP governance webhook and the
15//! `/v1/messages` gateway charge the same limiter, not one each.
16//! [`GovernanceEngine::from_config`] builds an isolated chain for tests.
17//!
18//! [`GovernanceEngine::evaluate_with_prompt_recovery`] is the opt-in variant
19//! for prompt targets: when a policy denies with a located secret leak, the
20//! caller is offered the findings and may hand back a sanitized input, which
21//! the same policy re-verifies before the chain resumes with it. Earlier
22//! policies are never re-run, so a stateful policy charges the call once.
23//!
24//! Copyright (c) systemprompt.io — Business Source License 1.1.
25//! See <https://systemprompt.io> for licensing details.
26
27mod 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/// The outcome of one traced chain run: the first-deny-wins [`Decision`] and
45/// the ordered per-entry trace destined for the audit row.
46#[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
202// Why: the warn-only defaults rely on an empty pattern catalog being legal,
203// but an operator-authored `enforce` block that compiles to a scanner which
204// can never deny is a silent non-enforcement — refuse it at boot. A globally
205// disabled engine enforces nothing, so it is not silently non-enforcing.
206fn 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}