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 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/// The outcome of one traced chain run: the first-deny-wins [`Decision`] and
44/// the ordered per-entry trace destined for the audit row.
45#[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
197// Why: the warn-only defaults rely on an empty pattern catalog being legal,
198// but an operator-authored `enforce` block that compiles to a scanner which
199// can never deny is a silent non-enforcement — refuse it at boot. A globally
200// disabled engine enforces nothing, so it is not silently non-enforcing.
201fn 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}