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    pub enabled: bool,
56    pub policies: Vec<PolicyConfig>,
57}
58
59impl GovernanceConfig {
60    #[must_use]
61    pub fn defaults() -> Self {
62        let policies = ["secret_scan", "scope_check", "tool_blocklist", "rate_limit"]
63            .into_iter()
64            .map(|id| PolicyConfig {
65                id: id.to_owned(),
66                enabled: true,
67                params: YamlValue::Null,
68            })
69            .collect();
70        Self {
71            enabled: true,
72            policies,
73        }
74    }
75
76    pub fn parse(yaml: &str) -> Result<Self, GovernanceConfigError> {
77        let root: YamlValue = serde_yaml::from_str(yaml)?;
78        let governance = root.get("governance");
79        let enabled = governance
80            .and_then(|g| g.get("enabled"))
81            .and_then(YamlValue::as_bool)
82            .unwrap_or(true);
83        let policies = governance
84            .and_then(|g| g.get("policies"))
85            .and_then(YamlValue::as_sequence)
86            .ok_or(GovernanceConfigError::MissingPolicies)?;
87
88        let mut out = Vec::with_capacity(policies.len());
89        for (index, entry) in policies.iter().enumerate() {
90            let id = entry
91                .get("id")
92                .and_then(YamlValue::as_str)
93                .ok_or(GovernanceConfigError::MissingPolicyId { index })?
94                .to_owned();
95            let enabled = entry
96                .get("enabled")
97                .and_then(YamlValue::as_bool)
98                .unwrap_or(true);
99            out.push(PolicyConfig {
100                id,
101                enabled,
102                params: entry.clone(),
103            });
104        }
105        Ok(Self {
106            enabled,
107            policies: out,
108        })
109    }
110
111    fn read(path: &Path) -> Result<Option<Self>, GovernanceConfigError> {
112        match std::fs::read_to_string(path) {
113            Ok(text) => Self::parse(&text).map(Some),
114            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
115            Err(e) => Err(GovernanceConfigError::Unreadable(e)),
116        }
117    }
118
119    pub fn validate(path: &Path) -> Result<(), GovernanceConfigError> {
120        Self::read(path).map(|_| ())
121    }
122
123    #[must_use]
124    pub fn load(path: &Path) -> Self {
125        match Self::read(path) {
126            Ok(Some(config)) => config,
127            Ok(None) => {
128                tracing::warn!(
129                    path = %path.display(),
130                    "governance config not found; falling back to the built-in defaults, \
131                     which enable every policy"
132                );
133                Self::defaults()
134            },
135            Err(error) => {
136                tracing::error!(
137                    path = %path.display(),
138                    %error,
139                    "governance config rejected; falling back to the built-in defaults, \
140                     which enable every policy and may not be what this file asked for"
141                );
142                Self::defaults()
143            },
144        }
145    }
146}