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. [`GovernanceEngine::global`] is therefore the
13//! way every enforcement point in a process reaches the chain: the MCP
14//! governance webhook and the `/v1/messages` gateway must charge the same
15//! limiter, not one each. [`GovernanceEngine::from_config`] remains available
16//! for tests and for callers that genuinely want an isolated chain.
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::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::{PolicyConfigurationError, PolicyFactory, PolicyRegistration};
41use super::secrets::{SecretFinding, SecretScanner};
42use super::types::{GovernancePolicy, PolicyContext};
43use crate::authz::types::Decision;
44
45/// The outcome of one traced chain run: the first-deny-wins [`Decision`] and
46/// the ordered per-entry trace destined for the audit row.
47#[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    #[error("governance policy `{id}` has invalid configuration: {source}")]
60    InvalidPolicyConfiguration {
61        id: String,
62        #[source]
63        source: PolicyConfigurationError,
64    },
65    #[error("governance config at {path} was rejected: {message}")]
66    ConfigRejected { path: String, message: String },
67}
68
69struct ChainEntry {
70    config: PolicyConfig,
71    instance: Box<dyn GovernancePolicy>,
72}
73
74pub struct GovernanceEngine {
75    enabled: bool,
76    entries: Vec<ChainEntry>,
77}
78
79impl std::fmt::Debug for GovernanceEngine {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("GovernanceEngine")
82            .field("enabled", &self.enabled)
83            .field(
84                "policies",
85                &self
86                    .entries
87                    .iter()
88                    .map(|e| e.config.id.as_str())
89                    .collect::<Vec<_>>(),
90            )
91            .finish()
92    }
93}
94
95impl GovernanceEngine {
96    pub fn global() -> Result<&'static Self, GovernanceEngineError> {
97        static ENGINE: LazyLock<Result<GovernanceEngine, GovernanceEngineError>> =
98            LazyLock::new(|| {
99                let config = match governance_config_path() {
100                    Some(path) => GovernanceConfig::load(&path).map_err(|error| {
101                        GovernanceEngineError::ConfigRejected {
102                            path: path.display().to_string(),
103                            message: error.to_string(),
104                        }
105                    })?,
106                    None => GovernanceConfig::defaults(),
107                };
108                GovernanceEngine::from_config(&config)
109            });
110        ENGINE.as_ref().map_err(Clone::clone)
111    }
112
113    pub fn from_config(config: &GovernanceConfig) -> Result<Self, GovernanceEngineError> {
114        if !config.enabled {
115            tracing::warn!(
116                "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
117                 check will run on any request"
118            );
119        }
120        let factories: HashMap<&'static str, PolicyFactory> =
121            inventory::iter::<PolicyRegistration>()
122                .map(|r| (r.id, r.factory))
123                .collect();
124
125        let mut entries = Vec::with_capacity(config.policies.len());
126        for cfg in &config.policies {
127            let factory = factories
128                .get(cfg.id.as_str())
129                .ok_or_else(|| GovernanceEngineError::UnknownPolicyId { id: cfg.id.clone() })?;
130            let instance = factory(&cfg.params).map_err(|source| {
131                GovernanceEngineError::InvalidPolicyConfiguration {
132                    id: cfg.id.clone(),
133                    source,
134                }
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: r.id.to_owned(),
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: r.id.to_owned(),
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 governance_config_path() -> Option<PathBuf> {
203    let profile = ProfileBootstrap::get()
204        .inspect_err(|e| {
205            tracing::error!(
206                error = %e,
207                "governance profile bootstrap failed; policies fall back to built-in defaults"
208            );
209        })
210        .ok()?;
211    Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
212}