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