systemprompt_security/policy/
config.rs1use std::path::Path;
34
35use serde_yaml::Value as YamlValue;
36use systemprompt_identifiers::PolicyId;
37use thiserror::Error;
38
39use super::builtin::SECRET_SCAN_ID;
40use super::secrets::{SecretPatternError, SecretScanner};
41
42#[derive(Debug, Error)]
43pub enum GovernanceConfigError {
44 #[error("governance config is not valid YAML: {0}")]
45 Yaml(#[from] serde_yaml::Error),
46 #[error("governance config has no `governance.policies` sequence")]
47 MissingPolicies,
48 #[error("governance config policy entry {index} has no non-empty string `id`")]
49 MissingPolicyId { index: usize },
50 #[error("governance config exists but could not be read: {0}")]
51 Unreadable(#[from] std::io::Error),
52 #[error(
53 "governance config has an unknown mode `{value}` at {location}; expected `enforce` or `warn`"
54 )]
55 InvalidMode { location: String, value: String },
56 #[error("governance secret pattern catalog is invalid: {0}")]
57 InvalidSecretPatterns(#[from] SecretPatternError),
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
62pub enum PolicyMode {
63 #[default]
64 Enforce,
65 Warn,
66}
67
68impl PolicyMode {
69 #[must_use]
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 Self::Enforce => "enforce",
73 Self::Warn => "warn",
74 }
75 }
76
77 #[must_use]
78 pub const fn is_warn(self) -> bool {
79 matches!(self, Self::Warn)
80 }
81
82 fn parse_str(value: &str) -> Option<Self> {
83 match value {
84 "enforce" => Some(Self::Enforce),
85 "warn" => Some(Self::Warn),
86 _ => None,
87 }
88 }
89}
90
91impl std::fmt::Display for PolicyMode {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.write_str(self.as_str())
94 }
95}
96
97fn read_mode(
98 node: Option<&YamlValue>,
99 location: &str,
100) -> Result<Option<PolicyMode>, GovernanceConfigError> {
101 let Some(raw) = node.and_then(|n| n.get("mode")) else {
102 return Ok(None);
103 };
104 let text = raw
105 .as_str()
106 .ok_or_else(|| GovernanceConfigError::InvalidMode {
107 location: location.to_owned(),
108 value: format!("{raw:?}"),
109 })?;
110 PolicyMode::parse_str(text)
111 .map(Some)
112 .ok_or_else(|| GovernanceConfigError::InvalidMode {
113 location: location.to_owned(),
114 value: text.to_owned(),
115 })
116}
117
118#[derive(Debug, Clone)]
121pub struct PolicyConfig {
122 pub id: PolicyId,
123 pub enabled: bool,
124 pub mode: PolicyMode,
125 pub params: YamlValue,
126}
127
128#[derive(Debug, Clone)]
130pub struct GovernanceConfig {
131 pub enabled: bool,
132 pub mode: PolicyMode,
133 pub policies: Vec<PolicyConfig>,
134}
135
136impl GovernanceConfig {
137 #[must_use]
138 pub fn defaults() -> Self {
139 let policies = ["scope_check", "secret_scan", "tool_blocklist", "rate_limit"]
140 .into_iter()
141 .map(|id| PolicyConfig {
142 id: PolicyId::new(id),
143 enabled: true,
144 mode: PolicyMode::Warn,
145 params: YamlValue::Null,
146 })
147 .collect();
148 Self {
149 enabled: true,
150 mode: PolicyMode::Warn,
151 policies,
152 }
153 }
154
155 pub fn parse(yaml: &str) -> Result<Self, GovernanceConfigError> {
156 let root: YamlValue = serde_yaml::from_str(yaml)?;
157 let governance = root.get("governance");
158 let enabled = governance
159 .and_then(|g| g.get("enabled"))
160 .and_then(YamlValue::as_bool)
161 .unwrap_or(true);
162 let default_mode = read_mode(governance, "governance")?.unwrap_or_default();
163 let policies = governance
164 .and_then(|g| g.get("policies"))
165 .and_then(YamlValue::as_sequence)
166 .ok_or(GovernanceConfigError::MissingPolicies)?;
167
168 let mut out = Vec::with_capacity(policies.len());
169 for (index, entry) in policies.iter().enumerate() {
170 let id = entry
171 .get("id")
172 .and_then(YamlValue::as_str)
173 .and_then(|raw| PolicyId::try_new(raw).ok())
174 .ok_or(GovernanceConfigError::MissingPolicyId { index })?;
175 let enabled = entry
176 .get("enabled")
177 .and_then(YamlValue::as_bool)
178 .unwrap_or(true);
179 let mode = read_mode(Some(entry), &format!("governance.policies[{index}] ({id})"))?
180 .unwrap_or(default_mode);
181 if id == SECRET_SCAN_ID {
182 SecretScanner::from_policy_yaml(entry)?;
183 }
184 out.push(PolicyConfig {
185 id,
186 enabled,
187 mode,
188 params: entry.clone(),
189 });
190 }
191 Ok(Self {
192 enabled,
193 mode: default_mode,
194 policies: out,
195 })
196 }
197
198 fn read(path: &Path) -> Result<Option<Self>, GovernanceConfigError> {
199 match std::fs::read_to_string(path) {
200 Ok(text) => Self::parse(&text).map(Some),
201 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
202 Err(e) => Err(GovernanceConfigError::Unreadable(e)),
203 }
204 }
205
206 pub fn load(path: &Path) -> Result<Self, GovernanceConfigError> {
207 let config = Self::read(path)?;
208 Ok(config.unwrap_or_else(|| {
209 tracing::warn!(
210 path = %path.display(),
211 "governance config not found; falling back to the vendor-neutral warn-only chain"
212 );
213 Self::defaults()
214 }))
215 }
216}