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::{PolicyFactory, PolicyRegistration};
41use super::secrets::SecretFinding;
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}
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}