Skip to main content

systemprompt_security/policy/
engine.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.
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//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use std::collections::{HashMap, HashSet};
22use std::path::PathBuf;
23use std::sync::LazyLock;
24
25use systemprompt_config::ProfileBootstrap;
26use systemprompt_identifiers::PolicyId;
27
28use super::audit::{ChainEntryOutcome, ChainEntryResult};
29use super::config::{GovernanceConfig, PolicyConfig, PolicyMode};
30use super::registry::{PolicyFactory, PolicyRegistration};
31use super::types::{GovernancePolicy, PolicyContext};
32use crate::authz::types::{Decision, DenyReason, MatchedBy};
33
34/// The outcome of one traced chain run: the first-deny-wins [`Decision`] and
35/// the ordered per-entry trace destined for the audit row.
36#[derive(Debug)]
37pub struct Evaluation {
38    pub decision: Decision,
39    pub chain: Vec<ChainEntryOutcome>,
40}
41
42struct ChainEntry {
43    config: PolicyConfig,
44    instance: Box<dyn GovernancePolicy>,
45}
46
47pub struct GovernanceEngine {
48    enabled: bool,
49    entries: Vec<ChainEntry>,
50}
51
52impl std::fmt::Debug for GovernanceEngine {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("GovernanceEngine")
55            .field("enabled", &self.enabled)
56            .field(
57                "policies",
58                &self
59                    .entries
60                    .iter()
61                    .map(|e| e.config.id.as_str())
62                    .collect::<Vec<_>>(),
63            )
64            .finish()
65    }
66}
67
68impl GovernanceEngine {
69    pub fn global() -> &'static Self {
70        static ENGINE: LazyLock<GovernanceEngine> = LazyLock::new(|| {
71            let config = governance_config_path()
72                .map_or_else(GovernanceConfig::defaults, |p| GovernanceConfig::load(&p));
73            GovernanceEngine::from_config(&config)
74        });
75        &ENGINE
76    }
77
78    #[must_use]
79    pub fn from_config(config: &GovernanceConfig) -> Self {
80        if !config.enabled {
81            tracing::warn!(
82                "governance is DISABLED by config: no scope, secret, blocklist or rate-limit \
83                 check will run on any request"
84            );
85        }
86        let factories: HashMap<&'static str, PolicyFactory> =
87            inventory::iter::<PolicyRegistration>()
88                .map(|r| (r.id, r.factory))
89                .collect();
90
91        let mut entries = Vec::with_capacity(config.policies.len());
92        for cfg in &config.policies {
93            let Some(factory) = factories.get(cfg.id.as_str()) else {
94                tracing::warn!(
95                    policy = %cfg.id,
96                    "governance policy in config has no registered impl — skipping"
97                );
98                continue;
99            };
100            entries.push(ChainEntry {
101                config: cfg.clone(),
102                instance: factory(&cfg.params),
103            });
104        }
105
106        let mentioned: HashSet<&str> = entries.iter().map(|e| e.config.id.as_str()).collect();
107        let unmentioned: Vec<&PolicyRegistration> = inventory::iter::<PolicyRegistration>()
108            .filter(|r| !mentioned.contains(r.id))
109            .collect();
110        for r in unmentioned {
111            let cfg = PolicyConfig {
112                id: r.id.to_owned(),
113                enabled: false,
114                mode: PolicyMode::Enforce,
115                params: serde_yaml::Value::Null,
116            };
117            let instance = (r.factory)(&cfg.params);
118            entries.push(ChainEntry {
119                config: cfg,
120                instance,
121            });
122        }
123
124        Self {
125            enabled: config.enabled,
126            entries,
127        }
128    }
129
130    pub fn policies(&self) -> impl Iterator<Item = (&PolicyConfig, &dyn GovernancePolicy)> {
131        self.entries
132            .iter()
133            .map(|e| (&e.config, e.instance.as_ref()))
134    }
135
136    #[must_use]
137    fn master_switch_off(&self) -> Evaluation {
138        Evaluation {
139            decision: Decision::Allow {
140                matched_by: MatchedBy::DefaultIncluded,
141            },
142            chain: self
143                .entries
144                .iter()
145                .map(|entry| {
146                    chain_entry(
147                        &entry.config,
148                        ChainEntryResult::Disabled,
149                        "Governance disabled by master switch",
150                    )
151                })
152                .collect(),
153        }
154    }
155
156    pub fn evaluate(&self, ctx: &PolicyContext<'_>) -> Evaluation {
157        if !self.enabled {
158            return self.master_switch_off();
159        }
160
161        let mut chain: Vec<ChainEntryOutcome> = Vec::with_capacity(self.entries.len());
162        // Why: `Pending` halts the chain for the same reason `Deny` does — a
163        // later policy cannot un-hold a call, and running it would charge the
164        // rate limiter for a call that has not been authorised yet.
165        let mut halted: Option<Decision> = None;
166        // Why: warn mode deliberately does not halt, so later policies still
167        // run and the report shows every finding on the call rather than only
168        // the first. The first warn is the one reported, matching first-deny
169        // -wins ordering.
170        let mut first_warn: Option<DenyReason> = None;
171
172        for entry in &self.entries {
173            if !entry.config.enabled {
174                chain.push(chain_entry(
175                    &entry.config,
176                    ChainEntryResult::Disabled,
177                    "Policy disabled in governance config",
178                ));
179                continue;
180            }
181            if halted.is_some() {
182                chain.push(chain_entry(
183                    &entry.config,
184                    ChainEntryResult::Skip,
185                    "Skipped — already halted by an earlier policy",
186                ));
187                continue;
188            }
189            let started = std::time::Instant::now();
190            let decision = entry.instance.evaluate(ctx);
191            let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
192            let (outcome, warn, halt) = classify(entry, &decision, duration_ms);
193            chain.push(outcome);
194            if let Some(reason) = warn
195                && first_warn.is_none()
196            {
197                first_warn = Some(reason);
198            }
199            if halt {
200                halted = Some(decision);
201            }
202        }
203
204        let decision = halted.unwrap_or_else(|| {
205            first_warn.map_or(
206                Decision::Allow {
207                    matched_by: MatchedBy::DefaultIncluded,
208                },
209                |reason| Decision::Warn { reason },
210            )
211        });
212        Evaluation { decision, chain }
213    }
214}
215
216// Why: returns the chain row, the reason to record if this is the first warn,
217// and whether the chain halts here — the three things the caller does with a
218// verdict, kept together so `evaluate` reads as the loop it is.
219fn classify(
220    entry: &ChainEntry,
221    decision: &Decision,
222    duration_ms: f64,
223) -> (ChainEntryOutcome, Option<DenyReason>, bool) {
224    let row = |result, detail| ChainEntryOutcome {
225        policy_id: entry.instance.id(),
226        result,
227        detail,
228        duration_ms,
229    };
230    match decision {
231        Decision::Allow { matched_by } => (
232            row(ChainEntryResult::Pass, allow_detail(matched_by)),
233            None,
234            false,
235        ),
236        Decision::Deny { reason } if entry.config.mode.is_warn() => {
237            tracing::warn!(
238                policy = %entry.config.id,
239                reason = %reason,
240                "governance policy in warn mode would have denied this call; allowing it"
241            );
242            (
243                row(ChainEntryResult::Warn, reason.to_string()),
244                Some(reason.clone()),
245                false,
246            )
247        },
248        Decision::Deny { reason } => (row(ChainEntryResult::Fail, reason.to_string()), None, true),
249        // Why: a warn verdict from a policy itself is passed through unchanged
250        // in either mode. Warn is already the weaker verdict, so enforce mode
251        // has nothing to escalate it to.
252        Decision::Warn { reason } => (
253            row(ChainEntryResult::Warn, reason.to_string()),
254            Some(reason.clone()),
255            false,
256        ),
257        Decision::Pending { reason } => {
258            (row(ChainEntryResult::Hold, reason.to_string()), None, true)
259        },
260    }
261}
262
263fn governance_config_path() -> Option<PathBuf> {
264    let profile = ProfileBootstrap::get()
265        .inspect_err(|e| {
266            tracing::error!(
267                error = %e,
268                "governance profile bootstrap failed; policies fall back to built-in defaults"
269            );
270        })
271        .ok()?;
272    Some(PathBuf::from(&profile.paths.services).join("governance/config.yaml"))
273}
274
275fn chain_entry(cfg: &PolicyConfig, result: ChainEntryResult, detail: &str) -> ChainEntryOutcome {
276    ChainEntryOutcome {
277        policy_id: PolicyId::new(cfg.id.clone()),
278        result,
279        detail: detail.to_owned(),
280        duration_ms: 0.0,
281    }
282}
283
284fn allow_detail(matched_by: &MatchedBy) -> String {
285    match matched_by {
286        MatchedBy::PolicyAllow { detail, .. } => detail.to_string(),
287        MatchedBy::UserAllow => "user allow".to_owned(),
288        MatchedBy::RoleAllow { role } => format!("role allow: {role}"),
289        MatchedBy::AttributeAllow { rule_type, value } => format!("{rule_type} allow: {value}"),
290        MatchedBy::DefaultIncluded => "default included".to_owned(),
291    }
292}