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
93fn read_mode(
94    node: Option<&YamlValue>,
95    location: &str,
96) -> Result<Option<PolicyMode>, GovernanceConfigError> {
97    let Some(raw) = node.and_then(|n| n.get("mode")) else {
98        return Ok(None);
99    };
100    let text = raw
101        .as_str()
102        .ok_or_else(|| GovernanceConfigError::InvalidMode {
103            location: location.to_owned(),
104            value: format!("{raw:?}"),
105        })?;
106    PolicyMode::parse_str(text)
107        .map(Some)
108        .ok_or_else(|| GovernanceConfigError::InvalidMode {
109            location: location.to_owned(),
110            value: text.to_owned(),
111        })
112}
113
114/// One entry of the configured chain: which policy, whether it runs, and the
115/// raw YAML mapping handed to the policy's factory as parameters.
116#[derive(Debug, Clone)]
117pub struct PolicyConfig {
118    pub id: String,
119    pub enabled: bool,
120    pub mode: PolicyMode,
121    pub params: YamlValue,
122}
123
124/// The ordered policy chain declaration.
125#[derive(Debug, Clone)]
126pub struct GovernanceConfig {
127    pub enabled: bool,
128    pub mode: PolicyMode,
129    pub policies: Vec<PolicyConfig>,
130}
131
132impl GovernanceConfig {
133    #[must_use]
134    pub fn defaults() -> Self {
135        let policies = ["scope_check", "secret_scan", "tool_blocklist", "rate_limit"]
136            .into_iter()
137            .map(|id| PolicyConfig {
138                id: id.to_owned(),
139                enabled: true,
140                mode: PolicyMode::Enforce,
141                params: YamlValue::Null,
142            })
143            .collect();
144        Self {
145            enabled: true,
146            mode: PolicyMode::Enforce,
147            policies,
148        }
149    }
150
151    pub fn parse(yaml: &str) -> Result<Self, GovernanceConfigError> {
152        let root: YamlValue = serde_yaml::from_str(yaml)?;
153        let governance = root.get("governance");
154        let enabled = governance
155            .and_then(|g| g.get("enabled"))
156            .and_then(YamlValue::as_bool)
157            .unwrap_or(true);
158        let default_mode = read_mode(governance, "governance")?.unwrap_or_default();
159        let policies = governance
160            .and_then(|g| g.get("policies"))
161            .and_then(YamlValue::as_sequence)
162            .ok_or(GovernanceConfigError::MissingPolicies)?;
163
164        let mut out = Vec::with_capacity(policies.len());
165        for (index, entry) in policies.iter().enumerate() {
166            let id = entry
167                .get("id")
168                .and_then(YamlValue::as_str)
169                .ok_or(GovernanceConfigError::MissingPolicyId { index })?
170                .to_owned();
171            let enabled = entry
172                .get("enabled")
173                .and_then(YamlValue::as_bool)
174                .unwrap_or(true);
175            let mode = read_mode(Some(entry), &format!("governance.policies[{index}] ({id})"))?
176                .unwrap_or(default_mode);
177            out.push(PolicyConfig {
178                id,
179                enabled,
180                mode,
181                params: entry.clone(),
182            });
183        }
184        Ok(Self {
185            enabled,
186            mode: default_mode,
187            policies: out,
188        })
189    }
190
191    fn read(path: &Path) -> Result<Option<Self>, GovernanceConfigError> {
192        match std::fs::read_to_string(path) {
193            Ok(text) => Self::parse(&text).map(Some),
194            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
195            Err(e) => Err(GovernanceConfigError::Unreadable(e)),
196        }
197    }
198
199    pub fn validate(path: &Path) -> Result<(), GovernanceConfigError> {
200        Self::read(path).map(|_| ())
201    }
202
203    #[must_use]
204    pub fn load(path: &Path) -> Self {
205        match Self::read(path) {
206            Ok(Some(config)) => config,
207            Ok(None) => {
208                tracing::warn!(
209                    path = %path.display(),
210                    "governance config not found; falling back to the built-in defaults, \
211                     which enable every policy"
212                );
213                Self::defaults()
214            },
215            Err(error) => {
216                tracing::error!(
217                    path = %path.display(),
218                    %error,
219                    "governance config rejected; falling back to the built-in defaults, \
220                     which enable every policy and may not be what this file asked for"
221                );
222                Self::defaults()
223            },
224        }
225    }
226}