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//! Each policy also carries a [`PolicyMode`]. `enforce` is the default and
16//! halts the chain on a deny; `warn` records the identical finding and lets
17//! the call through, so tunables can be calibrated against real traffic
18//! instead of guesses. A top-level `governance.mode` sets the default for
19//! every policy that does not name its own. An unrecognised mode is a parse
20//! error rather than a silent fallback: reading `mode: warnn` as `enforce`
21//! would block traffic an operator believed they had unblocked, and reading it
22//! as `warn` would disable enforcement nobody asked to disable.
23//!
24//! Note the fallback direction: defaults enable every policy, so a file that
25//! cannot be read yields *more* enforcement than it declared, never less.
26//! Governance cannot be disabled by deleting or breaking this file — only by
27//! `governance.enabled: false` or per-policy `enabled: false`.
28//!
29//! Path resolution is the caller's concern: core takes a path, extensions
30//! resolve it from their profile (`<services>/governance/config.yaml`).
31//!
32//! Copyright (c) systemprompt.io — Business Source License 1.1.
33//! See <https://systemprompt.io> for licensing details.
34
35use std::path::Path;
36
37use serde_yaml::Value as YamlValue;
38use thiserror::Error;
39
40#[derive(Debug, Error)]
41pub enum GovernanceConfigError {
42    #[error("governance config is not valid YAML: {0}")]
43    Yaml(#[from] serde_yaml::Error),
44    #[error("governance config has no `governance.policies` sequence")]
45    MissingPolicies,
46    #[error("governance config policy entry {index} has no string `id`")]
47    MissingPolicyId { index: usize },
48    #[error("governance config exists but could not be read: {0}")]
49    Unreadable(#[from] std::io::Error),
50    #[error(
51        "governance config has an unknown mode `{value}` at {location}; expected `enforce` or `warn`"
52    )]
53    InvalidMode { location: String, value: String },
54}
55
56/// Whether a policy halts the chain on a finding or only records it.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
58pub enum PolicyMode {
59    #[default]
60    Enforce,
61    Warn,
62}
63
64impl PolicyMode {
65    #[must_use]
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Enforce => "enforce",
69            Self::Warn => "warn",
70        }
71    }
72
73    #[must_use]
74    pub const fn is_warn(self) -> bool {
75        matches!(self, Self::Warn)
76    }
77
78    fn parse_str(value: &str) -> Option<Self> {
79        match value {
80            "enforce" => Some(Self::Enforce),
81            "warn" => Some(Self::Warn),
82            _ => None,
83        }
84    }
85}
86
87impl std::fmt::Display for PolicyMode {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93// Why: an absent key inherits, a present-but-unreadable key is an error. Both
94// are distinct from "present and valid", so the return is a three-way option
95// rather than a defaulted value.
96fn read_mode(
97    node: Option<&YamlValue>,
98    location: &str,
99) -> Result<Option<PolicyMode>, GovernanceConfigError> {
100    let Some(raw) = node.and_then(|n| n.get("mode")) else {
101        return Ok(None);
102    };
103    let text = raw
104        .as_str()
105        .ok_or_else(|| GovernanceConfigError::InvalidMode {
106            location: location.to_owned(),
107            value: format!("{raw:?}"),
108        })?;
109    PolicyMode::parse_str(text)
110        .map(Some)
111        .ok_or_else(|| GovernanceConfigError::InvalidMode {
112            location: location.to_owned(),
113            value: text.to_owned(),
114        })
115}
116
117/// One entry of the configured chain: which policy, whether it runs, and the
118/// raw YAML mapping handed to the policy's factory as parameters.
119#[derive(Debug, Clone)]
120pub struct PolicyConfig {
121    pub id: String,
122    pub enabled: bool,
123    pub mode: PolicyMode,
124    pub params: YamlValue,
125}
126
127/// The ordered policy chain declaration.
128#[derive(Debug, Clone)]
129pub struct GovernanceConfig {
130    pub enabled: bool,
131    pub mode: PolicyMode,
132    pub policies: Vec<PolicyConfig>,
133}
134
135impl GovernanceConfig {
136    #[must_use]
137    pub fn defaults() -> Self {
138        let policies = ["secret_scan", "scope_check", "tool_blocklist", "rate_limit"]
139            .into_iter()
140            .map(|id| PolicyConfig {
141                id: id.to_owned(),
142                enabled: true,
143                mode: PolicyMode::Enforce,
144                params: YamlValue::Null,
145            })
146            .collect();
147        Self {
148            enabled: true,
149            mode: PolicyMode::Enforce,
150            policies,
151        }
152    }
153
154    pub fn parse(yaml: &str) -> Result<Self, GovernanceConfigError> {
155        let root: YamlValue = serde_yaml::from_str(yaml)?;
156        let governance = root.get("governance");
157        let enabled = governance
158            .and_then(|g| g.get("enabled"))
159            .and_then(YamlValue::as_bool)
160            .unwrap_or(true);
161        let default_mode = read_mode(governance, "governance")?.unwrap_or_default();
162        let policies = governance
163            .and_then(|g| g.get("policies"))
164            .and_then(YamlValue::as_sequence)
165            .ok_or(GovernanceConfigError::MissingPolicies)?;
166
167        let mut out = Vec::with_capacity(policies.len());
168        for (index, entry) in policies.iter().enumerate() {
169            let id = entry
170                .get("id")
171                .and_then(YamlValue::as_str)
172                .ok_or(GovernanceConfigError::MissingPolicyId { index })?
173                .to_owned();
174            let enabled = entry
175                .get("enabled")
176                .and_then(YamlValue::as_bool)
177                .unwrap_or(true);
178            let mode = read_mode(Some(entry), &format!("governance.policies[{index}] ({id})"))?
179                .unwrap_or(default_mode);
180            out.push(PolicyConfig {
181                id,
182                enabled,
183                mode,
184                params: entry.clone(),
185            });
186        }
187        Ok(Self {
188            enabled,
189            mode: default_mode,
190            policies: out,
191        })
192    }
193
194    fn read(path: &Path) -> Result<Option<Self>, GovernanceConfigError> {
195        match std::fs::read_to_string(path) {
196            Ok(text) => Self::parse(&text).map(Some),
197            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
198            Err(e) => Err(GovernanceConfigError::Unreadable(e)),
199        }
200    }
201
202    pub fn validate(path: &Path) -> Result<(), GovernanceConfigError> {
203        Self::read(path).map(|_| ())
204    }
205
206    #[must_use]
207    pub fn load(path: &Path) -> Self {
208        match Self::read(path) {
209            Ok(Some(config)) => config,
210            Ok(None) => {
211                tracing::warn!(
212                    path = %path.display(),
213                    "governance config not found; falling back to the built-in defaults, \
214                     which enable every policy"
215                );
216                Self::defaults()
217            },
218            Err(error) => {
219                tracing::error!(
220                    path = %path.display(),
221                    %error,
222                    "governance config rejected; falling back to the built-in defaults, \
223                     which enable every policy and may not be what this file asked for"
224                );
225                Self::defaults()
226            },
227        }
228    }
229}