pub mod builtin;
pub mod coverage;
pub mod floor;
pub mod org;
pub mod runtime;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
const MAX_EXTENDS_DEPTH: usize = 8;
pub const KNOWN_READ_MODES: &[&str] = &[
"auto",
"full",
"map",
"signatures",
"diff",
"task",
"reference",
"aggressive",
"entropy",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyPack {
pub name: String,
pub version: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extends: Option<String>,
#[serde(default)]
pub context: ContextRules,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub redaction: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "FilterRules::is_empty")]
pub filters: FilterRules,
#[serde(default, skip_serializing_if = "EgressRules::is_empty")]
pub egress: EgressRules,
#[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
pub routing: RoutingPolicyRules,
#[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
pub budgets: BudgetRules,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContextRules {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_read_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_retention_days: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FilterRules {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pii: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classification: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub injection: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_labels: Vec<String>,
}
impl FilterRules {
#[must_use]
pub fn is_empty(&self) -> bool {
self.pii.is_none()
&& self.classification.is_none()
&& self.injection.is_none()
&& self.blocked_labels.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EgressRules {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forbidden_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_secrets: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_writes_per_min: Option<u32>,
}
impl EgressRules {
#[must_use]
pub fn is_empty(&self) -> bool {
self.forbidden_patterns.is_empty()
&& self.block_secrets.is_none()
&& self.max_writes_per_min.is_none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoutingPolicyRules {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_models: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forbid_downgrade_for: Vec<String>,
}
impl RoutingPolicyRules {
#[must_use]
pub fn is_empty(&self) -> bool {
self.allowed_models.is_empty() && self.forbid_downgrade_for.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BudgetRules {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cost_usd_per_person_per_day: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cost_usd_per_project_per_month: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_requests_per_minute_per_person: Option<u32>,
}
impl BudgetRules {
#[must_use]
pub fn is_empty(&self) -> bool {
self.max_cost_usd_per_person_per_day.is_none()
&& self.max_cost_usd_per_project_per_month.is_none()
&& self.max_requests_per_minute_per_person.is_none()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedPolicy {
pub name: String,
pub version: String,
pub description: String,
pub chain: Vec<String>,
pub default_read_mode: Option<String>,
pub allow_tools: Option<Vec<String>>,
pub deny_tools: Vec<String>,
pub max_context_tokens: Option<u32>,
pub audit_retention_days: Option<u32>,
pub redaction: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "FilterRules::is_empty")]
pub filters: FilterRules,
#[serde(default, skip_serializing_if = "EgressRules::is_empty")]
pub egress: EgressRules,
#[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
pub routing: RoutingPolicyRules,
#[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
pub budgets: BudgetRules,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyError {
Toml(String),
InvalidName(String),
InvalidVersion(String),
EmptyDescription,
UnknownReadMode(String),
BadRegex { pattern_name: String, error: String },
ZeroMaxTokens,
AllowDenyOverlap(Vec<String>),
UnknownParent(String),
ExtendsCycle(Vec<String>),
ExtendsTooDeep(usize),
UnknownFilterAction { field: String, value: String },
InvalidBudget { field: String, value: String },
EmptyModelPattern,
}
impl std::fmt::Display for PolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
PolicyError::InvalidName(n) => write!(
f,
"invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
),
PolicyError::InvalidVersion(v) => {
write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
}
PolicyError::EmptyDescription => write!(f, "description must not be empty"),
PolicyError::UnknownReadMode(m) => write!(
f,
"unknown default_read_mode '{m}' (one of: {})",
KNOWN_READ_MODES.join(", ")
),
PolicyError::BadRegex {
pattern_name,
error,
} => write!(
f,
"redaction pattern '{pattern_name}' is not a valid regex: {error}"
),
PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
PolicyError::AllowDenyOverlap(tools) => write!(
f,
"tools listed in both allow_tools and deny_tools: {}",
tools.join(", ")
),
PolicyError::UnknownParent(p) => write!(
f,
"extends '{p}' does not name a known pack (built-ins: {})",
builtin::names().join(", ")
),
PolicyError::ExtendsCycle(chain) => {
write!(f, "extends cycle: {}", chain.join(" -> "))
}
PolicyError::ExtendsTooDeep(d) => write!(
f,
"extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
),
PolicyError::UnknownFilterAction { field, value } => write!(
f,
"filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
),
PolicyError::InvalidBudget { field, value } => write!(
f,
"budgets.{field} must be a positive, finite USD amount (got {value})"
),
PolicyError::EmptyModelPattern => {
write!(f, "routing.allowed_models must not contain empty patterns")
}
}
}
}
impl std::error::Error for PolicyError {}
pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
let pack: PolicyPack =
toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
validate(&pack)?;
Ok(pack)
}
pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
let text = std::fs::read_to_string(path)
.map_err(|e| PolicyError::Toml(format!("{}: {e}", path.display())))?;
parse(&text)
}
pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
if pack.name.is_empty()
|| !pack
.name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|| pack.name.starts_with('-')
|| pack.name.ends_with('-')
{
return Err(PolicyError::InvalidName(pack.name.clone()));
}
if !valid_semver(&pack.version) {
return Err(PolicyError::InvalidVersion(pack.version.clone()));
}
if pack.description.trim().is_empty() {
return Err(PolicyError::EmptyDescription);
}
if let Some(mode) = pack.context.default_read_mode.as_deref()
&& !KNOWN_READ_MODES.contains(&mode)
{
return Err(PolicyError::UnknownReadMode(mode.to_string()));
}
if let Some(max) = pack.context.max_context_tokens
&& max == 0
{
return Err(PolicyError::ZeroMaxTokens);
}
if let Some(allow) = &pack.context.allow_tools {
let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
let overlap: Vec<String> = allow
.iter()
.filter(|t| deny.contains(t.as_str()))
.cloned()
.collect();
if !overlap.is_empty() {
return Err(PolicyError::AllowDenyOverlap(overlap));
}
}
for (name, pattern) in &pack.redaction {
if let Err(e) = regex::Regex::new(pattern) {
return Err(PolicyError::BadRegex {
pattern_name: name.clone(),
error: e.to_string(),
});
}
}
validate_filter_action("pii", pack.filters.pii.as_deref())?;
validate_filter_action("classification", pack.filters.classification.as_deref())?;
validate_filter_action("injection", pack.filters.injection.as_deref())?;
for pattern in &pack.egress.forbidden_patterns {
if let Err(e) = regex::Regex::new(pattern) {
return Err(PolicyError::BadRegex {
pattern_name: format!("egress.forbidden_patterns: {pattern}"),
error: e.to_string(),
});
}
}
if pack
.routing
.allowed_models
.iter()
.any(|p| p.trim().is_empty())
{
return Err(PolicyError::EmptyModelPattern);
}
validate_budget(
"max_cost_usd_per_person_per_day",
pack.budgets.max_cost_usd_per_person_per_day,
)?;
validate_budget(
"max_cost_usd_per_project_per_month",
pack.budgets.max_cost_usd_per_project_per_month,
)?;
Ok(())
}
fn validate_budget(field: &str, value: Option<f64>) -> Result<(), PolicyError> {
if let Some(v) = value
&& !(v.is_finite() && v > 0.0)
{
return Err(PolicyError::InvalidBudget {
field: field.to_string(),
value: v.to_string(),
});
}
Ok(())
}
fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
if let Some(v) = value
&& crate::core::input_filters::FilterAction::parse(v).is_none()
{
return Err(PolicyError::UnknownFilterAction {
field: field.to_string(),
value: v.to_string(),
});
}
Ok(())
}
fn valid_semver(v: &str) -> bool {
let parts: Vec<&str> = v.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
}
pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
let mut seen: Vec<String> = vec![pack.name.clone()];
let mut next_parent = pack.extends.clone();
while let Some(parent_name) = next_parent.take() {
if seen.contains(&parent_name) {
seen.push(parent_name);
return Err(PolicyError::ExtendsCycle(seen));
}
if lineage.len() >= MAX_EXTENDS_DEPTH {
return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
}
let parent =
builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
seen.push(parent_name);
next_parent.clone_from(&parent.extends);
lineage.push(parent);
}
let mut resolved = ResolvedPolicy {
name: pack.name.clone(),
version: pack.version.clone(),
description: pack.description.clone(),
chain: seen.iter().skip(1).rev().cloned().collect(),
default_read_mode: None,
allow_tools: None,
deny_tools: Vec::new(),
max_context_tokens: None,
audit_retention_days: None,
redaction: BTreeMap::new(),
filters: FilterRules::default(),
egress: EgressRules::default(),
routing: RoutingPolicyRules::default(),
budgets: BudgetRules::default(),
};
for layer in lineage.iter().rev() {
if let Some(mode) = &layer.context.default_read_mode {
resolved.default_read_mode = Some(mode.clone());
}
if let Some(allow) = &layer.context.allow_tools {
resolved.allow_tools = Some(allow.clone());
}
for tool in &layer.context.deny_tools {
if !resolved.deny_tools.contains(tool) {
resolved.deny_tools.push(tool.clone());
}
}
if let Some(max) = layer.context.max_context_tokens {
resolved.max_context_tokens = Some(max);
}
if let Some(days) = layer.context.audit_retention_days {
resolved.audit_retention_days = Some(days);
}
for (name, pattern) in &layer.redaction {
resolved.redaction.insert(name.clone(), pattern.clone());
}
if let Some(v) = &layer.filters.pii {
resolved.filters.pii = Some(v.clone());
}
if let Some(v) = &layer.filters.classification {
resolved.filters.classification = Some(v.clone());
}
if let Some(v) = &layer.filters.injection {
resolved.filters.injection = Some(v.clone());
}
for label in &layer.filters.blocked_labels {
if !resolved.filters.blocked_labels.contains(label) {
resolved.filters.blocked_labels.push(label.clone());
}
}
for pattern in &layer.egress.forbidden_patterns {
if !resolved.egress.forbidden_patterns.contains(pattern) {
resolved.egress.forbidden_patterns.push(pattern.clone());
}
}
if let Some(v) = layer.egress.block_secrets {
resolved.egress.block_secrets = Some(v);
}
if let Some(v) = layer.egress.max_writes_per_min {
resolved.egress.max_writes_per_min = Some(v);
}
for pattern in &layer.routing.allowed_models {
if !resolved.routing.allowed_models.contains(pattern) {
resolved.routing.allowed_models.push(pattern.clone());
}
}
for project in &layer.routing.forbid_downgrade_for {
if !resolved.routing.forbid_downgrade_for.contains(project) {
resolved.routing.forbid_downgrade_for.push(project.clone());
}
}
if let Some(v) = layer.budgets.max_cost_usd_per_person_per_day {
resolved.budgets.max_cost_usd_per_person_per_day = Some(v);
}
if let Some(v) = layer.budgets.max_cost_usd_per_project_per_month {
resolved.budgets.max_cost_usd_per_project_per_month = Some(v);
}
}
if let Some(allow) = &resolved.allow_tools {
let overlap: Vec<String> = allow
.iter()
.filter(|t| resolved.deny_tools.contains(*t))
.cloned()
.collect();
if !overlap.is_empty() {
return Err(PolicyError::AllowDenyOverlap(overlap));
}
}
Ok(resolved)
}
pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
resolve(&parse(toml_text)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
PolicyPack {
name: name.to_string(),
version: "1.0.0".to_string(),
description: "test pack".to_string(),
extends: extends.map(str::to_string),
context: ContextRules::default(),
redaction: BTreeMap::new(),
filters: FilterRules::default(),
egress: EgressRules::default(),
routing: RoutingPolicyRules::default(),
budgets: BudgetRules::default(),
}
}
#[test]
fn parses_a_full_pack() {
let pack = parse(
r#"
name = "acme-internal"
version = "2.1.0"
description = "ACME internal baseline"
extends = "strict-redaction"
[context]
default_read_mode = "map"
deny_tools = ["ctx_url_read"]
max_context_tokens = 12000
audit_retention_days = 365
[redaction]
employee_id = 'EMP-\d{6}'
"#,
)
.expect("parses");
assert_eq!(pack.name, "acme-internal");
assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
assert!(pack.redaction.contains_key("employee_id"));
}
#[test]
fn parses_gateway_governance_sections() {
let resolved = load(
r#"
name = "acme-gateway"
version = "1.0.0"
description = "org gateway governance"
[routing]
allowed_models = ["claude-*", "gpt-4o-mini"]
forbid_downgrade_for = ["prod"]
[budgets]
max_cost_usd_per_person_per_day = 50.0
max_cost_usd_per_project_per_month = 20000.0
"#,
)
.expect("resolves");
assert_eq!(
resolved.routing.allowed_models,
vec!["claude-*".to_string(), "gpt-4o-mini".to_string()]
);
assert_eq!(resolved.routing.forbid_downgrade_for, vec!["prod"]);
assert_eq!(resolved.budgets.max_cost_usd_per_person_per_day, Some(50.0));
assert_eq!(
resolved.budgets.max_cost_usd_per_project_per_month,
Some(20000.0)
);
}
#[test]
fn rejects_invalid_budget_and_empty_model_pattern() {
let neg = parse(
r#"
name = "bad-budget"
version = "1.0.0"
description = "x"
[budgets]
max_cost_usd_per_person_per_day = -5.0
"#,
);
assert!(matches!(neg, Err(PolicyError::InvalidBudget { .. })));
let empty = parse(
r#"
name = "bad-pattern"
version = "1.0.0"
description = "x"
[routing]
allowed_models = ["claude-*", " "]
"#,
);
assert_eq!(empty.unwrap_err(), PolicyError::EmptyModelPattern);
}
#[test]
fn unknown_keys_are_rejected() {
let err = parse(
r#"
name = "typo"
version = "1.0.0"
description = "x"
[context]
alow_tools = ["ctx_read"]
"#,
)
.unwrap_err();
assert!(matches!(err, PolicyError::Toml(_)), "{err}");
}
#[test]
fn validation_catches_each_field() {
let mut p = minimal("Bad Name", None);
assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
p = minimal("ok", None);
p.version = "1.0".into();
assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
p = minimal("ok", None);
p.description = " ".into();
assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
p = minimal("ok", None);
p.context.default_read_mode = Some("lines:1-5".into());
assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
p = minimal("ok", None);
p.context.max_context_tokens = Some(0);
assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
p = minimal("ok", None);
p.redaction.insert("broken".into(), "(unclosed".into());
assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
p = minimal("ok", None);
p.context.allow_tools = Some(vec!["ctx_read".into()]);
p.context.deny_tools = vec!["ctx_read".into()];
assert!(matches!(
validate(&p),
Err(PolicyError::AllowDenyOverlap(_))
));
}
#[test]
fn resolve_overrides_scalars_and_accumulates_denies() {
let mut child = minimal("child", Some("finance-eu"));
child.context.default_read_mode = Some("signatures".into());
child.context.deny_tools = vec!["ctx_shell".into()];
let r = resolve(&child).expect("resolves");
assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
assert!(r.redaction.contains_key("iban"));
assert!(r.redaction.contains_key("private_key"));
assert_eq!(r.chain, vec!["baseline", "strict-redaction", "finance-eu"]);
}
#[test]
fn resolve_rejects_unknown_parent_and_cycle() {
let p = minimal("orphan", Some("no-such-pack"));
assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
let p = minimal("loop", Some("loop"));
assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
}
#[test]
fn child_redaction_overrides_same_named_parent_pattern() {
let mut child = minimal("child", Some("baseline"));
child
.redaction
.insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
let r = resolve(&child).expect("resolves");
assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
}
}