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//! [`GovernanceConfig::load`] reads the installation's file: a missing file is
8//! the documented warn-only fallback ([`GovernanceConfig::defaults`]), while a
9//! file that exists but is rejected — unreadable, invalid YAML, an unknown
10//! mode, a bad secret catalog — is an error the engine refuses to start on,
11//! so a typo can never silently downgrade enforcement.
12//! [`GovernanceConfig::parse`] is the strict form over a string.
13//!
14//! Each policy also carries a [`PolicyMode`]. A declared policy without a mode
15//! defaults to `enforce`, while the vendor-neutral fallback chain is explicitly
16//! warn-only. `enforce` halts the chain on a deny; `warn` records the identical
17//! finding and lets the call through, so tunables can be calibrated against
18//! real traffic instead of guesses. A top-level `governance.mode` sets the
19//! default for every policy that does not name its own. An unrecognised mode is
20//! a parse error rather than a silent fallback: reading `mode: warnn` as
21//! `enforce` would block traffic an operator believed they had unblocked, and
22//! reading it as `warn` would disable enforcement nobody asked to disable.
23//!
24//! The fallback chain runs every policy in warn mode. Its secret scanner has no
25//! signatures because credential applicability belongs to the installation.
26//!
27//! Path resolution is the caller's concern: core takes a path, extensions
28//! resolve it from their profile (`<services>/governance/config.yaml`).
29//!
30//! Copyright (c) systemprompt.io — Business Source License 1.1.
31//! See <https://systemprompt.io> for licensing details.
32
33use 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/// Whether a policy halts the chain on a finding or only records it.
61#[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/// One entry of the configured chain: which policy, whether it runs, and the
119/// raw YAML mapping handed to the policy's factory as parameters.
120#[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/// The ordered policy chain declaration.
129#[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}