1pub mod builtin;
24pub mod coverage;
25pub mod floor;
26pub mod org;
27pub mod runtime;
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::Path;
31
32use serde::{Deserialize, Serialize};
33
34const MAX_EXTENDS_DEPTH: usize = 8;
37
38pub const KNOWN_READ_MODES: &[&str] = &[
42 "auto",
43 "full",
44 "map",
45 "signatures",
46 "diff",
47 "task",
48 "reference",
49 "aggressive",
50 "entropy",
51];
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct PolicyPack {
60 pub name: String,
62 pub version: String,
64 pub description: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub extends: Option<String>,
69 #[serde(default)]
71 pub context: ContextRules,
72 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75 pub redaction: BTreeMap<String, String>,
76 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
79 pub filters: FilterRules,
80 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
82 pub egress: EgressRules,
83 #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
86 pub routing: RoutingPolicyRules,
87 #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
89 pub budgets: BudgetRules,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct ContextRules {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub default_read_mode: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub allow_tools: Option<Vec<String>>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub deny_tools: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub max_context_tokens: Option<u32>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub audit_retention_days: Option<u32>,
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct FilterRules {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub pii: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub classification: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub injection: Option<String>,
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub blocked_labels: Vec<String>,
135}
136
137impl FilterRules {
138 #[must_use]
140 pub fn is_empty(&self) -> bool {
141 self.pii.is_none()
142 && self.classification.is_none()
143 && self.injection.is_none()
144 && self.blocked_labels.is_empty()
145 }
146}
147
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct EgressRules {
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub forbidden_patterns: Vec<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub block_secrets: Option<bool>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub max_writes_per_min: Option<u32>,
164}
165
166impl EgressRules {
167 #[must_use]
169 pub fn is_empty(&self) -> bool {
170 self.forbidden_patterns.is_empty()
171 && self.block_secrets.is_none()
172 && self.max_writes_per_min.is_none()
173 }
174}
175
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct RoutingPolicyRules {
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub allowed_models: Vec<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub forbid_downgrade_for: Vec<String>,
192}
193
194impl RoutingPolicyRules {
195 #[must_use]
197 pub fn is_empty(&self) -> bool {
198 self.allowed_models.is_empty() && self.forbid_downgrade_for.is_empty()
199 }
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct BudgetRules {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub max_cost_usd_per_person_per_day: Option<f64>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub max_cost_usd_per_project_per_month: Option<f64>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub max_requests_per_minute_per_person: Option<u32>,
219}
220
221impl BudgetRules {
222 #[must_use]
224 pub fn is_empty(&self) -> bool {
225 self.max_cost_usd_per_person_per_day.is_none()
226 && self.max_cost_usd_per_project_per_month.is_none()
227 && self.max_requests_per_minute_per_person.is_none()
228 }
229}
230
231#[derive(Debug, Clone, Serialize)]
236pub struct ResolvedPolicy {
237 pub name: String,
238 pub version: String,
239 pub description: String,
240 pub chain: Vec<String>,
243 pub default_read_mode: Option<String>,
244 pub allow_tools: Option<Vec<String>>,
245 pub deny_tools: Vec<String>,
246 pub max_context_tokens: Option<u32>,
247 pub audit_retention_days: Option<u32>,
248 pub redaction: BTreeMap<String, String>,
249 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
251 pub filters: FilterRules,
252 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
254 pub egress: EgressRules,
255 #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
257 pub routing: RoutingPolicyRules,
258 #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
260 pub budgets: BudgetRules,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum PolicyError {
269 Toml(String),
270 InvalidName(String),
271 InvalidVersion(String),
272 EmptyDescription,
273 UnknownReadMode(String),
274 BadRegex { pattern_name: String, error: String },
275 ZeroMaxTokens,
276 AllowDenyOverlap(Vec<String>),
277 UnknownParent(String),
278 ExtendsCycle(Vec<String>),
279 ExtendsTooDeep(usize),
280 UnknownFilterAction { field: String, value: String },
281 InvalidBudget { field: String, value: String },
282 EmptyModelPattern,
283}
284
285impl std::fmt::Display for PolicyError {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 match self {
288 PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
289 PolicyError::InvalidName(n) => write!(
290 f,
291 "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
292 ),
293 PolicyError::InvalidVersion(v) => {
294 write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
295 }
296 PolicyError::EmptyDescription => write!(f, "description must not be empty"),
297 PolicyError::UnknownReadMode(m) => write!(
298 f,
299 "unknown default_read_mode '{m}' (one of: {})",
300 KNOWN_READ_MODES.join(", ")
301 ),
302 PolicyError::BadRegex {
303 pattern_name,
304 error,
305 } => write!(
306 f,
307 "redaction pattern '{pattern_name}' is not a valid regex: {error}"
308 ),
309 PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
310 PolicyError::AllowDenyOverlap(tools) => write!(
311 f,
312 "tools listed in both allow_tools and deny_tools: {}",
313 tools.join(", ")
314 ),
315 PolicyError::UnknownParent(p) => write!(
316 f,
317 "extends '{p}' does not name a known pack (built-ins: {})",
318 builtin::names().join(", ")
319 ),
320 PolicyError::ExtendsCycle(chain) => {
321 write!(f, "extends cycle: {}", chain.join(" -> "))
322 }
323 PolicyError::ExtendsTooDeep(d) => write!(
324 f,
325 "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
326 ),
327 PolicyError::UnknownFilterAction { field, value } => write!(
328 f,
329 "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
330 ),
331 PolicyError::InvalidBudget { field, value } => write!(
332 f,
333 "budgets.{field} must be a positive, finite USD amount (got {value})"
334 ),
335 PolicyError::EmptyModelPattern => {
336 write!(f, "routing.allowed_models must not contain empty patterns")
337 }
338 }
339 }
340}
341
342impl std::error::Error for PolicyError {}
343
344pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
349 let pack: PolicyPack =
350 toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
351 validate(&pack)?;
352 Ok(pack)
353}
354
355pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
358 let text = std::fs::read_to_string(path)
359 .map_err(|e| PolicyError::Toml(format!("{}: {e}", path.display())))?;
360 parse(&text)
361}
362
363pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
365 if pack.name.is_empty()
366 || !pack
367 .name
368 .bytes()
369 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
370 || pack.name.starts_with('-')
371 || pack.name.ends_with('-')
372 {
373 return Err(PolicyError::InvalidName(pack.name.clone()));
374 }
375 if !valid_semver(&pack.version) {
376 return Err(PolicyError::InvalidVersion(pack.version.clone()));
377 }
378 if pack.description.trim().is_empty() {
379 return Err(PolicyError::EmptyDescription);
380 }
381 if let Some(mode) = pack.context.default_read_mode.as_deref()
382 && !KNOWN_READ_MODES.contains(&mode)
383 {
384 return Err(PolicyError::UnknownReadMode(mode.to_string()));
385 }
386 if let Some(max) = pack.context.max_context_tokens
387 && max == 0
388 {
389 return Err(PolicyError::ZeroMaxTokens);
390 }
391 if let Some(allow) = &pack.context.allow_tools {
392 let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
393 let overlap: Vec<String> = allow
394 .iter()
395 .filter(|t| deny.contains(t.as_str()))
396 .cloned()
397 .collect();
398 if !overlap.is_empty() {
399 return Err(PolicyError::AllowDenyOverlap(overlap));
400 }
401 }
402 for (name, pattern) in &pack.redaction {
403 if let Err(e) = regex::Regex::new(pattern) {
404 return Err(PolicyError::BadRegex {
405 pattern_name: name.clone(),
406 error: e.to_string(),
407 });
408 }
409 }
410 validate_filter_action("pii", pack.filters.pii.as_deref())?;
411 validate_filter_action("classification", pack.filters.classification.as_deref())?;
412 validate_filter_action("injection", pack.filters.injection.as_deref())?;
413 for pattern in &pack.egress.forbidden_patterns {
414 if let Err(e) = regex::Regex::new(pattern) {
415 return Err(PolicyError::BadRegex {
416 pattern_name: format!("egress.forbidden_patterns: {pattern}"),
417 error: e.to_string(),
418 });
419 }
420 }
421 if pack
422 .routing
423 .allowed_models
424 .iter()
425 .any(|p| p.trim().is_empty())
426 {
427 return Err(PolicyError::EmptyModelPattern);
428 }
429 validate_budget(
430 "max_cost_usd_per_person_per_day",
431 pack.budgets.max_cost_usd_per_person_per_day,
432 )?;
433 validate_budget(
434 "max_cost_usd_per_project_per_month",
435 pack.budgets.max_cost_usd_per_project_per_month,
436 )?;
437 Ok(())
438}
439
440fn validate_budget(field: &str, value: Option<f64>) -> Result<(), PolicyError> {
442 if let Some(v) = value
443 && !(v.is_finite() && v > 0.0)
444 {
445 return Err(PolicyError::InvalidBudget {
446 field: field.to_string(),
447 value: v.to_string(),
448 });
449 }
450 Ok(())
451}
452
453fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
455 if let Some(v) = value
456 && crate::core::input_filters::FilterAction::parse(v).is_none()
457 {
458 return Err(PolicyError::UnknownFilterAction {
459 field: field.to_string(),
460 value: v.to_string(),
461 });
462 }
463 Ok(())
464}
465
466fn valid_semver(v: &str) -> bool {
468 let parts: Vec<&str> = v.split('.').collect();
469 parts.len() == 3
470 && parts
471 .iter()
472 .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
473}
474
475pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
480 let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
482 let mut seen: Vec<String> = vec![pack.name.clone()];
483 let mut next_parent = pack.extends.clone();
484 while let Some(parent_name) = next_parent.take() {
485 if seen.contains(&parent_name) {
486 seen.push(parent_name);
487 return Err(PolicyError::ExtendsCycle(seen));
488 }
489 if lineage.len() >= MAX_EXTENDS_DEPTH {
490 return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
491 }
492 let parent =
493 builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
494 seen.push(parent_name);
495 next_parent.clone_from(&parent.extends);
496 lineage.push(parent);
497 }
498
499 let mut resolved = ResolvedPolicy {
502 name: pack.name.clone(),
503 version: pack.version.clone(),
504 description: pack.description.clone(),
505 chain: seen.iter().skip(1).rev().cloned().collect(),
506 default_read_mode: None,
507 allow_tools: None,
508 deny_tools: Vec::new(),
509 max_context_tokens: None,
510 audit_retention_days: None,
511 redaction: BTreeMap::new(),
512 filters: FilterRules::default(),
513 egress: EgressRules::default(),
514 routing: RoutingPolicyRules::default(),
515 budgets: BudgetRules::default(),
516 };
517 for layer in lineage.iter().rev() {
518 if let Some(mode) = &layer.context.default_read_mode {
519 resolved.default_read_mode = Some(mode.clone());
520 }
521 if let Some(allow) = &layer.context.allow_tools {
522 resolved.allow_tools = Some(allow.clone());
523 }
524 for tool in &layer.context.deny_tools {
525 if !resolved.deny_tools.contains(tool) {
526 resolved.deny_tools.push(tool.clone());
527 }
528 }
529 if let Some(max) = layer.context.max_context_tokens {
530 resolved.max_context_tokens = Some(max);
531 }
532 if let Some(days) = layer.context.audit_retention_days {
533 resolved.audit_retention_days = Some(days);
534 }
535 for (name, pattern) in &layer.redaction {
536 resolved.redaction.insert(name.clone(), pattern.clone());
537 }
538 if let Some(v) = &layer.filters.pii {
540 resolved.filters.pii = Some(v.clone());
541 }
542 if let Some(v) = &layer.filters.classification {
543 resolved.filters.classification = Some(v.clone());
544 }
545 if let Some(v) = &layer.filters.injection {
546 resolved.filters.injection = Some(v.clone());
547 }
548 for label in &layer.filters.blocked_labels {
549 if !resolved.filters.blocked_labels.contains(label) {
550 resolved.filters.blocked_labels.push(label.clone());
551 }
552 }
553 for pattern in &layer.egress.forbidden_patterns {
555 if !resolved.egress.forbidden_patterns.contains(pattern) {
556 resolved.egress.forbidden_patterns.push(pattern.clone());
557 }
558 }
559 if let Some(v) = layer.egress.block_secrets {
560 resolved.egress.block_secrets = Some(v);
561 }
562 if let Some(v) = layer.egress.max_writes_per_min {
563 resolved.egress.max_writes_per_min = Some(v);
564 }
565 for pattern in &layer.routing.allowed_models {
567 if !resolved.routing.allowed_models.contains(pattern) {
568 resolved.routing.allowed_models.push(pattern.clone());
569 }
570 }
571 for project in &layer.routing.forbid_downgrade_for {
572 if !resolved.routing.forbid_downgrade_for.contains(project) {
573 resolved.routing.forbid_downgrade_for.push(project.clone());
574 }
575 }
576 if let Some(v) = layer.budgets.max_cost_usd_per_person_per_day {
579 resolved.budgets.max_cost_usd_per_person_per_day = Some(v);
580 }
581 if let Some(v) = layer.budgets.max_cost_usd_per_project_per_month {
582 resolved.budgets.max_cost_usd_per_project_per_month = Some(v);
583 }
584 }
585
586 if let Some(allow) = &resolved.allow_tools {
588 let overlap: Vec<String> = allow
589 .iter()
590 .filter(|t| resolved.deny_tools.contains(*t))
591 .cloned()
592 .collect();
593 if !overlap.is_empty() {
594 return Err(PolicyError::AllowDenyOverlap(overlap));
595 }
596 }
597 Ok(resolved)
598}
599
600pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
602 resolve(&parse(toml_text)?)
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
610 PolicyPack {
611 name: name.to_string(),
612 version: "1.0.0".to_string(),
613 description: "test pack".to_string(),
614 extends: extends.map(str::to_string),
615 context: ContextRules::default(),
616 redaction: BTreeMap::new(),
617 filters: FilterRules::default(),
618 egress: EgressRules::default(),
619 routing: RoutingPolicyRules::default(),
620 budgets: BudgetRules::default(),
621 }
622 }
623
624 #[test]
625 fn parses_a_full_pack() {
626 let pack = parse(
627 r#"
628name = "acme-internal"
629version = "2.1.0"
630description = "ACME internal baseline"
631extends = "strict-redaction"
632
633[context]
634default_read_mode = "map"
635deny_tools = ["ctx_url_read"]
636max_context_tokens = 12000
637audit_retention_days = 365
638
639[redaction]
640employee_id = 'EMP-\d{6}'
641"#,
642 )
643 .expect("parses");
644 assert_eq!(pack.name, "acme-internal");
645 assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
646 assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
647 assert!(pack.redaction.contains_key("employee_id"));
648 }
649
650 #[test]
651 fn parses_gateway_governance_sections() {
652 let resolved = load(
654 r#"
655name = "acme-gateway"
656version = "1.0.0"
657description = "org gateway governance"
658
659[routing]
660allowed_models = ["claude-*", "gpt-4o-mini"]
661forbid_downgrade_for = ["prod"]
662
663[budgets]
664max_cost_usd_per_person_per_day = 50.0
665max_cost_usd_per_project_per_month = 20000.0
666"#,
667 )
668 .expect("resolves");
669 assert_eq!(
670 resolved.routing.allowed_models,
671 vec!["claude-*".to_string(), "gpt-4o-mini".to_string()]
672 );
673 assert_eq!(resolved.routing.forbid_downgrade_for, vec!["prod"]);
674 assert_eq!(resolved.budgets.max_cost_usd_per_person_per_day, Some(50.0));
675 assert_eq!(
676 resolved.budgets.max_cost_usd_per_project_per_month,
677 Some(20000.0)
678 );
679 }
680
681 #[test]
682 fn rejects_invalid_budget_and_empty_model_pattern() {
683 let neg = parse(
684 r#"
685name = "bad-budget"
686version = "1.0.0"
687description = "x"
688
689[budgets]
690max_cost_usd_per_person_per_day = -5.0
691"#,
692 );
693 assert!(matches!(neg, Err(PolicyError::InvalidBudget { .. })));
694
695 let empty = parse(
696 r#"
697name = "bad-pattern"
698version = "1.0.0"
699description = "x"
700
701[routing]
702allowed_models = ["claude-*", " "]
703"#,
704 );
705 assert_eq!(empty.unwrap_err(), PolicyError::EmptyModelPattern);
706 }
707
708 #[test]
709 fn unknown_keys_are_rejected() {
710 let err = parse(
711 r#"
712name = "typo"
713version = "1.0.0"
714description = "x"
715
716[context]
717alow_tools = ["ctx_read"]
718"#,
719 )
720 .unwrap_err();
721 assert!(matches!(err, PolicyError::Toml(_)), "{err}");
722 }
723
724 #[test]
725 fn validation_catches_each_field() {
726 let mut p = minimal("Bad Name", None);
727 assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
728
729 p = minimal("ok", None);
730 p.version = "1.0".into();
731 assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
732
733 p = minimal("ok", None);
734 p.description = " ".into();
735 assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
736
737 p = minimal("ok", None);
738 p.context.default_read_mode = Some("lines:1-5".into());
739 assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
740
741 p = minimal("ok", None);
742 p.context.max_context_tokens = Some(0);
743 assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
744
745 p = minimal("ok", None);
746 p.redaction.insert("broken".into(), "(unclosed".into());
747 assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
748
749 p = minimal("ok", None);
750 p.context.allow_tools = Some(vec!["ctx_read".into()]);
751 p.context.deny_tools = vec!["ctx_read".into()];
752 assert!(matches!(
753 validate(&p),
754 Err(PolicyError::AllowDenyOverlap(_))
755 ));
756 }
757
758 #[test]
759 fn resolve_overrides_scalars_and_accumulates_denies() {
760 let mut child = minimal("child", Some("finance-eu"));
761 child.context.default_read_mode = Some("signatures".into());
762 child.context.deny_tools = vec!["ctx_shell".into()];
763 let r = resolve(&child).expect("resolves");
764
765 assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
767 assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
769 assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
770 assert!(r.redaction.contains_key("iban"));
772 assert!(r.redaction.contains_key("private_key"));
773 assert_eq!(r.chain, vec!["baseline", "strict-redaction", "finance-eu"]);
775 }
776
777 #[test]
778 fn resolve_rejects_unknown_parent_and_cycle() {
779 let p = minimal("orphan", Some("no-such-pack"));
780 assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
781
782 let p = minimal("loop", Some("loop"));
785 assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
786 }
787
788 #[test]
789 fn child_redaction_overrides_same_named_parent_pattern() {
790 let mut child = minimal("child", Some("baseline"));
791 child
792 .redaction
793 .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
794 let r = resolve(&child).expect("resolves");
795 assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
796 }
797}