Skip to main content

systemprompt_security/policy/
config.rs

1//! Governance-chain configuration.
2//!
3//! One YAML document (`governance.enabled` plus `governance.policies: [{id,
4//! enabled, ...params}]`) declares whether the chain runs at all, which
5//! policies it contains, in what order, and with what per-policy parameters.
6//!
7//! Two loaders, because startup and the request path want opposite failure
8//! modes. [`GovernanceConfig::validate`] is for boot: it returns the error so
9//! a misconfigured installation refuses to start.
10//! [`GovernanceConfig::load`] is for the request path: it degrades to
11//! [`GovernanceConfig::defaults`] and logs, because a governance deployment
12//! that failed closed on a config typo would block every tool call.
13//! [`GovernanceConfig::parse`] is the strict form over a string.
14//!
15//! Note the fallback direction: defaults enable every policy, so a file that
16//! cannot be read yields *more* enforcement than it declared, never less.
17//! Governance cannot be disabled by deleting or breaking this file — only by
18//! `governance.enabled: false` or per-policy `enabled: false`.
19//!
20//! Path resolution is the caller's concern: core takes a path, extensions
21//! resolve it from their profile (`<services>/governance/config.yaml`).
22//!
23//! Copyright (c) systemprompt.io — Business Source License 1.1.
24//! See <https://systemprompt.io> for licensing details.
25
26use std::path::Path;
27
28use serde_yaml::Value as YamlValue;
29use thiserror::Error;
30
31#[derive(Debug, Error)]
32pub enum GovernanceConfigError {
33    #[error("governance config is not valid YAML: {0}")]
34    Yaml(#[from] serde_yaml::Error),
35    #[error("governance config has no `governance.policies` sequence")]
36    MissingPolicies,
37    #[error("governance config policy entry {index} has no string `id`")]
38    MissingPolicyId { index: usize },
39    #[error("governance config exists but could not be read: {0}")]
40    Unreadable(#[from] std::io::Error),
41}
42
43/// One entry of the configured chain: which policy, whether it runs, and the
44/// raw YAML mapping handed to the policy's factory as parameters.
45#[derive(Debug, Clone)]
46pub struct PolicyConfig {
47    pub id: String,
48    pub enabled: bool,
49    pub params: YamlValue,
50}
51
52/// The ordered policy chain declaration.
53#[derive(Debug, Clone)]
54pub struct GovernanceConfig {
55    /// `governance.enabled: false` switches the whole chain off in one key,
56    /// leaving the per-policy declarations below intact so the configuration
57    /// survives being turned back on.
58    pub enabled: bool,
59    pub policies: Vec<PolicyConfig>,
60}
61
62impl GovernanceConfig {
63    /// The four built-in policies, enabled, with default parameters, in
64    /// first-deny-wins order: cheap-and-fatal checks before stateful ones.
65    #[must_use]
66    pub fn defaults() -> Self {
67        let policies = ["secret_scan", "scope_check", "tool_blocklist", "rate_limit"]
68            .into_iter()
69            .map(|id| PolicyConfig {
70                id: id.to_owned(),
71                enabled: true,
72                params: YamlValue::Null,
73            })
74            .collect();
75        Self {
76            enabled: true,
77            policies,
78        }
79    }
80
81    /// Strict parse of a YAML document.
82    pub fn parse(yaml: &str) -> Result<Self, GovernanceConfigError> {
83        let root: YamlValue = serde_yaml::from_str(yaml)?;
84        let governance = root.get("governance");
85        let enabled = governance
86            .and_then(|g| g.get("enabled"))
87            .and_then(YamlValue::as_bool)
88            .unwrap_or(true);
89        let policies = governance
90            .and_then(|g| g.get("policies"))
91            .and_then(YamlValue::as_sequence)
92            .ok_or(GovernanceConfigError::MissingPolicies)?;
93
94        let mut out = Vec::with_capacity(policies.len());
95        for (index, entry) in policies.iter().enumerate() {
96            let id = entry
97                .get("id")
98                .and_then(YamlValue::as_str)
99                .ok_or(GovernanceConfigError::MissingPolicyId { index })?
100                .to_owned();
101            let enabled = entry
102                .get("enabled")
103                .and_then(YamlValue::as_bool)
104                .unwrap_or(true);
105            out.push(PolicyConfig {
106                id,
107                enabled,
108                params: entry.clone(),
109            });
110        }
111        Ok(Self {
112            enabled,
113            policies: out,
114        })
115    }
116
117    /// `Ok(None)` when the file is simply absent — the one failure that is a
118    /// legitimate deployment, not a mistake.
119    fn read(path: &Path) -> Result<Option<Self>, GovernanceConfigError> {
120        match std::fs::read_to_string(path) {
121            Ok(text) => Self::parse(&text).map(Some),
122            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
123            Err(e) => Err(GovernanceConfigError::Unreadable(e)),
124        }
125    }
126
127    /// Boot-time check, for callers that can still refuse to start.
128    ///
129    /// Why: [`Self::load`] cannot fail, so a typo in the policy chain reaches
130    /// the runtime as silently-restored defaults — which for governance means
131    /// *more* enforcement than was asked for, and for an operator who edited
132    /// the file to relax a policy, the exact opposite of their intent. Calling
133    /// this once during startup converts that into a refusal to boot, while
134    /// leaving the request path unable to die on a config read.
135    pub fn validate(path: &Path) -> Result<(), GovernanceConfigError> {
136        Self::read(path).map(|_| ())
137    }
138
139    /// Lenient load for the request path: every failure falls back to
140    /// [`Self::defaults`] and logs, because a governance deployment that
141    /// failed closed on a config typo would block every tool call in the
142    /// installation. Pair with [`Self::validate`] at startup to catch the typo
143    /// before it gets this far.
144    #[must_use]
145    pub fn load(path: &Path) -> Self {
146        match Self::read(path) {
147            Ok(Some(config)) => config,
148            Ok(None) => {
149                tracing::warn!(
150                    path = %path.display(),
151                    "governance config not found; falling back to the built-in defaults, \
152                     which enable every policy"
153                );
154                Self::defaults()
155            },
156            Err(error) => {
157                tracing::error!(
158                    path = %path.display(),
159                    %error,
160                    "governance config rejected; falling back to the built-in defaults, \
161                     which enable every policy and may not be what this file asked for"
162                );
163                Self::defaults()
164            },
165        }
166    }
167}