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}