1pub mod audit_filter;
24pub mod builtin;
25pub mod content;
26pub mod coverage;
27pub mod diagnostics;
28pub mod floor;
29pub mod hive_filters;
30pub mod hive_floor;
31pub mod org;
32pub mod runtime;
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::path::Path;
36
37use serde::{Deserialize, Serialize};
38
39pub mod files;
40
41const MAX_EXTENDS_DEPTH: usize = 8;
44
45pub const KNOWN_READ_MODES: &[&str] = &[
49 "auto",
50 "full",
51 "map",
52 "signatures",
53 "diff",
54 "task",
55 "reference",
56 "aggressive",
57 "entropy",
58];
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct PolicyPack {
67 pub name: String,
69 pub version: String,
71 pub description: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub extends: Option<String>,
76 #[serde(default)]
78 pub context: ContextRules,
79 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
82 pub redaction: BTreeMap<String, String>,
83 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
86 pub filters: FilterRules,
87 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
89 pub egress: EgressRules,
90 #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
93 pub routing: RoutingPolicyRules,
94 #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
96 pub budgets: BudgetRules,
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct ContextRules {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub default_read_mode: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub allow_tools: Option<Vec<String>>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub deny_tools: Vec<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub max_context_tokens: Option<u32>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub audit_retention_days: Option<u32>,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct FilterRules {
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub pii: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub classification: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub injection: Option<String>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub blocked_labels: Vec<String>,
142 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
145 pub blocked_patterns: BTreeMap<String, String>,
146 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
149 pub managed_personal_rules: BTreeSet<String>,
150}
151
152impl FilterRules {
153 #[must_use]
155 pub fn is_empty(&self) -> bool {
156 self.pii.is_none()
157 && self.classification.is_none()
158 && self.injection.is_none()
159 && self.blocked_labels.is_empty()
160 && self.blocked_patterns.is_empty()
161 && self.managed_personal_rules.is_empty()
162 }
163}
164
165pub(super) fn merge_block_patterns(
168 target: &mut BTreeMap<String, String>,
169 incoming: &BTreeMap<String, String>,
170) {
171 for (name, pattern) in incoming {
172 match target.get_mut(name) {
173 Some(previous) if previous != pattern => {
174 *previous = format!("(?:{previous})|(?:{pattern})");
175 }
176 Some(_) => {}
177 None => {
178 target.insert(name.clone(), pattern.clone());
179 }
180 }
181 }
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct EgressRules {
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub forbidden_patterns: Vec<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub block_secrets: Option<bool>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub max_writes_per_min: Option<u32>,
200}
201
202impl EgressRules {
203 #[must_use]
205 pub fn is_empty(&self) -> bool {
206 self.forbidden_patterns.is_empty()
207 && self.block_secrets.is_none()
208 && self.max_writes_per_min.is_none()
209 }
210}
211
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct RoutingPolicyRules {
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub allowed_models: Vec<String>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub model_ceiling_groups: Vec<Vec<String>>,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub forbid_downgrade_for: Vec<String>,
233}
234
235impl RoutingPolicyRules {
236 #[must_use]
238 pub fn is_empty(&self) -> bool {
239 self.allowed_models.is_empty()
240 && self.model_ceiling_groups.is_empty()
241 && self.forbid_downgrade_for.is_empty()
242 }
243
244 #[must_use]
246 pub fn model_allowed(&self, model: &str) -> bool {
247 model_allowed_by_ceilings(&self.allowed_models, &self.model_ceiling_groups, model)
248 }
249}
250
251fn unique_model_ceilings<'a>(groups: impl Iterator<Item = &'a Vec<String>>) -> Vec<Vec<String>> {
254 let mut seen = std::collections::BTreeSet::new();
255 groups
256 .filter(|group| seen.insert(*group))
257 .cloned()
258 .collect()
259}
260
261pub fn model_allowed_by_ceilings(primary: &[String], groups: &[Vec<String>], model: &str) -> bool {
262 let matches = |patterns: &[String]| {
263 patterns
264 .iter()
265 .any(|pattern| model_pattern_matches(pattern, model))
266 };
267 (primary.is_empty() || matches(primary)) && groups.iter().all(|group| matches(group))
268}
269
270pub fn model_pattern_matches(pattern: &str, model: &str) -> bool {
272 let pattern = pattern.trim().as_bytes();
273 let model = model.trim().as_bytes();
274 let mut matched = vec![false; model.len() + 1];
276 matched[0] = true;
277 for &byte in pattern {
278 if byte == b'*' {
279 for index in 1..=model.len() {
280 matched[index] |= matched[index - 1];
281 }
282 } else {
283 for index in (1..=model.len()).rev() {
284 matched[index] = matched[index - 1] && model[index - 1] == byte;
285 }
286 matched[0] = false;
287 }
288 }
289 matched[model.len()]
290}
291
292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct BudgetRules {
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub max_cost_usd_per_person_per_day: Option<f64>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub max_cost_usd_per_project_per_month: Option<f64>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub max_requests_per_minute_per_person: Option<u32>,
309}
310
311impl BudgetRules {
312 #[must_use]
314 pub fn is_empty(&self) -> bool {
315 self.max_cost_usd_per_person_per_day.is_none()
316 && self.max_cost_usd_per_project_per_month.is_none()
317 && self.max_requests_per_minute_per_person.is_none()
318 }
319}
320
321#[derive(Debug, Clone, Serialize)]
326pub struct ResolvedPolicy {
327 pub name: String,
328 pub version: String,
329 pub description: String,
330 pub chain: Vec<String>,
333 pub default_read_mode: Option<String>,
334 pub allow_tools: Option<Vec<String>>,
335 pub deny_tools: Vec<String>,
336 pub max_context_tokens: Option<u32>,
337 pub audit_retention_days: Option<u32>,
338 pub redaction: BTreeMap<String, String>,
339 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
341 pub filters: FilterRules,
342 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
344 pub egress: EgressRules,
345 #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
347 pub routing: RoutingPolicyRules,
348 #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
350 pub budgets: BudgetRules,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum PolicyError {
359 Toml(String),
360 InvalidName(String),
361 InvalidVersion(String),
362 EmptyDescription,
363 UnknownReadMode(String),
364 BadRegex { pattern_name: String, error: String },
365 ZeroMaxTokens,
366 AllowDenyOverlap(Vec<String>),
367 UnknownParent(String),
368 ExtendsCycle(Vec<String>),
369 ExtendsTooDeep(usize),
370 UnknownFilterAction { field: String, value: String },
371 InvalidBudget { field: String, value: String },
372 EmptyModelPattern,
373}
374
375impl std::fmt::Display for PolicyError {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 match self {
378 PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
379 PolicyError::InvalidName(n) => write!(
380 f,
381 "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
382 ),
383 PolicyError::InvalidVersion(v) => {
384 write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
385 }
386 PolicyError::EmptyDescription => write!(f, "description must not be empty"),
387 PolicyError::UnknownReadMode(m) => write!(
388 f,
389 "unknown default_read_mode '{m}' (one of: {})",
390 KNOWN_READ_MODES.join(", ")
391 ),
392 PolicyError::BadRegex {
393 pattern_name,
394 error,
395 } => write!(
396 f,
397 "redaction pattern '{pattern_name}' is not a valid regex: {error}"
398 ),
399 PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
400 PolicyError::AllowDenyOverlap(tools) => write!(
401 f,
402 "tools listed in both allow_tools and deny_tools: {}",
403 tools.join(", ")
404 ),
405 PolicyError::UnknownParent(p) => match builtin::paid_tier(p) {
406 Some(_) => write!(f, "extends {}", builtin::unknown_pack_hint(p)),
407 None => write!(
408 f,
409 "extends '{p}' does not name a known pack (built-ins: {})",
410 builtin::names().join(", ")
411 ),
412 },
413 PolicyError::ExtendsCycle(chain) => {
414 write!(f, "extends cycle: {}", chain.join(" -> "))
415 }
416 PolicyError::ExtendsTooDeep(d) => write!(
417 f,
418 "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
419 ),
420 PolicyError::UnknownFilterAction { field, value } => write!(
421 f,
422 "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
423 ),
424 PolicyError::InvalidBudget { field, value } => write!(
425 f,
426 "budgets.{field} must be a positive, finite USD amount (got {value})"
427 ),
428 PolicyError::EmptyModelPattern => {
429 write!(f, "routing.allowed_models must not contain empty patterns")
430 }
431 }
432 }
433}
434
435impl std::error::Error for PolicyError {}
436
437pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
442 let pack: PolicyPack =
443 toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
444 validate(&pack)?;
445 Ok(pack)
446}
447
448pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
451 let text = files::read(path, true)
452 .map_err(PolicyError::Toml)?
453 .ok_or_else(|| PolicyError::Toml("policy input is missing".into()))?;
454 parse(&text)
455}
456
457pub(crate) const HIVE_LABEL_PREFIX: &str = "hive:";
460
461pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
462 if pack.name.is_empty()
463 || !pack
464 .name
465 .bytes()
466 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
467 || pack.name.starts_with('-')
468 || pack.name.ends_with('-')
469 {
470 return Err(PolicyError::InvalidName(pack.name.clone()));
471 }
472 if !valid_semver(&pack.version) {
473 return Err(PolicyError::InvalidVersion(pack.version.clone()));
474 }
475 if pack.description.trim().is_empty() {
476 return Err(PolicyError::EmptyDescription);
477 }
478 if let Some(mode) = pack.context.default_read_mode.as_deref()
479 && !KNOWN_READ_MODES.contains(&mode)
480 {
481 return Err(PolicyError::UnknownReadMode(mode.to_string()));
482 }
483 if let Some(max) = pack.context.max_context_tokens
484 && max == 0
485 {
486 return Err(PolicyError::ZeroMaxTokens);
487 }
488 if let Some(allow) = &pack.context.allow_tools {
489 let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
490 let overlap: Vec<String> = allow
491 .iter()
492 .filter(|t| deny.contains(t.as_str()))
493 .cloned()
494 .collect();
495 if !overlap.is_empty() {
496 return Err(PolicyError::AllowDenyOverlap(overlap));
497 }
498 }
499 if pack.redaction.len() + pack.filters.blocked_patterns.len() > content::MAX_REDACTION_RULES
500 || pack.egress.forbidden_patterns.len() > content::MAX_REDACTION_RULES
501 || pack.filters.managed_personal_rules.len() > 32
502 {
503 return Err(PolicyError::BadRegex {
504 pattern_name: "policy".into(),
505 error: "too many policy patterns".into(),
506 });
507 }
508 if pack
511 .redaction
512 .keys()
513 .chain(pack.filters.blocked_patterns.keys())
514 .any(|label| label.starts_with(HIVE_LABEL_PREFIX))
515 {
516 return Err(PolicyError::BadRegex {
517 pattern_name: "policy".into(),
518 error: format!(
519 "rule labels starting with `{HIVE_LABEL_PREFIX}` are reserved for Hive filter packs"
520 ),
521 });
522 }
523 if pack.filters.managed_personal_rules.iter().any(|name| {
524 !name.starts_with("personal_")
525 || name.len() <= 9
526 || name.len() > content::MAX_RULE_LABEL_BYTES
527 || !name
528 .bytes()
529 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-".contains(&b))
530 || (!pack.redaction.contains_key(name)
531 && !pack.filters.blocked_patterns.contains_key(name))
532 }) {
533 return Err(PolicyError::BadRegex {
534 pattern_name: "policy".into(),
535 error: "invalid personal rule ownership".into(),
536 });
537 }
538 for (name, pattern) in pack.redaction.iter().chain(&pack.filters.blocked_patterns) {
539 if name.is_empty()
540 || name.len() > content::MAX_RULE_LABEL_BYTES
541 || name.chars().any(char::is_control)
542 || pattern.len() > content::MAX_RULE_PATTERN_BYTES
543 {
544 return Err(PolicyError::BadRegex {
545 pattern_name: "redaction".into(),
546 error: "policy pattern bounds exceeded".into(),
547 });
548 }
549 if let Err(e) = regex::RegexBuilder::new(pattern)
550 .size_limit(1024 * 1024)
551 .dfa_size_limit(1024 * 1024)
552 .build()
553 {
554 return Err(PolicyError::BadRegex {
555 pattern_name: name.clone(),
556 error: e.to_string(),
557 });
558 }
559 }
560 validate_filter_action("pii", pack.filters.pii.as_deref())?;
561 validate_filter_action("classification", pack.filters.classification.as_deref())?;
562 validate_filter_action("injection", pack.filters.injection.as_deref())?;
563 for pattern in &pack.egress.forbidden_patterns {
564 if pattern.len() > content::MAX_RULE_PATTERN_BYTES {
565 return Err(PolicyError::BadRegex {
566 pattern_name: "egress.forbidden_patterns".into(),
567 error: "policy pattern bounds exceeded".into(),
568 });
569 }
570 if let Err(e) = regex::RegexBuilder::new(pattern)
571 .size_limit(1024 * 1024)
572 .dfa_size_limit(1024 * 1024)
573 .build()
574 {
575 return Err(PolicyError::BadRegex {
576 pattern_name: format!("egress.forbidden_patterns: {pattern}"),
577 error: e.to_string(),
578 });
579 }
580 }
581 if pack
582 .routing
583 .allowed_models
584 .iter()
585 .chain(pack.routing.model_ceiling_groups.iter().flatten())
586 .any(|p| p.trim().is_empty())
587 {
588 return Err(PolicyError::EmptyModelPattern);
589 }
590 validate_budget(
591 "max_cost_usd_per_person_per_day",
592 pack.budgets.max_cost_usd_per_person_per_day,
593 )?;
594 validate_budget(
595 "max_cost_usd_per_project_per_month",
596 pack.budgets.max_cost_usd_per_project_per_month,
597 )?;
598 Ok(())
599}
600
601fn validate_budget(field: &str, value: Option<f64>) -> Result<(), PolicyError> {
603 if let Some(v) = value
604 && !(v.is_finite() && v > 0.0)
605 {
606 return Err(PolicyError::InvalidBudget {
607 field: field.to_string(),
608 value: v.to_string(),
609 });
610 }
611 Ok(())
612}
613
614fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
616 if let Some(v) = value
617 && crate::core::input_filters::FilterAction::parse(v).is_none()
618 {
619 return Err(PolicyError::UnknownFilterAction {
620 field: field.to_string(),
621 value: v.to_string(),
622 });
623 }
624 Ok(())
625}
626
627fn valid_semver(v: &str) -> bool {
629 let parts: Vec<&str> = v.split('.').collect();
630 parts.len() == 3
631 && parts
632 .iter()
633 .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
634}
635
636pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
641 let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
643 let mut seen: Vec<String> = vec![pack.name.clone()];
644 let mut next_parent = pack.extends.clone();
645 while let Some(parent_name) = next_parent.take() {
646 if seen.contains(&parent_name) {
647 seen.push(parent_name);
648 return Err(PolicyError::ExtendsCycle(seen));
649 }
650 if lineage.len() >= MAX_EXTENDS_DEPTH {
651 return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
652 }
653 let parent =
654 builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
655 seen.push(parent_name);
656 next_parent.clone_from(&parent.extends);
657 lineage.push(parent);
658 }
659
660 let mut resolved = ResolvedPolicy {
663 name: pack.name.clone(),
664 version: pack.version.clone(),
665 description: pack.description.clone(),
666 chain: seen.iter().skip(1).rev().cloned().collect(),
667 default_read_mode: None,
668 allow_tools: None,
669 deny_tools: Vec::new(),
670 max_context_tokens: None,
671 audit_retention_days: None,
672 redaction: BTreeMap::new(),
673 filters: FilterRules::default(),
674 egress: EgressRules::default(),
675 routing: RoutingPolicyRules::default(),
676 budgets: BudgetRules::default(),
677 };
678 for layer in lineage.iter().rev() {
679 if let Some(mode) = &layer.context.default_read_mode {
680 resolved.default_read_mode = Some(mode.clone());
681 }
682 if let Some(allow) = &layer.context.allow_tools {
683 resolved.allow_tools = Some(allow.clone());
684 }
685 for tool in &layer.context.deny_tools {
686 if !resolved.deny_tools.contains(tool) {
687 resolved.deny_tools.push(tool.clone());
688 }
689 }
690 if let Some(max) = layer.context.max_context_tokens {
691 resolved.max_context_tokens = Some(max);
692 }
693 if let Some(days) = layer.context.audit_retention_days {
694 resolved.audit_retention_days = Some(days);
695 }
696 for (name, pattern) in &layer.redaction {
697 resolved.redaction.insert(name.clone(), pattern.clone());
698 }
699 if let Some(v) = &layer.filters.pii {
701 resolved.filters.pii = Some(v.clone());
702 }
703 if let Some(v) = &layer.filters.classification {
704 resolved.filters.classification = Some(v.clone());
705 }
706 if let Some(v) = &layer.filters.injection {
707 resolved.filters.injection = Some(v.clone());
708 }
709 for label in &layer.filters.blocked_labels {
710 if !resolved.filters.blocked_labels.contains(label) {
711 resolved.filters.blocked_labels.push(label.clone());
712 }
713 }
714 merge_block_patterns(
715 &mut resolved.filters.blocked_patterns,
716 &layer.filters.blocked_patterns,
717 );
718 resolved
719 .filters
720 .managed_personal_rules
721 .clone_from(&layer.filters.managed_personal_rules);
722 for pattern in &layer.egress.forbidden_patterns {
724 if !resolved.egress.forbidden_patterns.contains(pattern) {
725 resolved.egress.forbidden_patterns.push(pattern.clone());
726 }
727 }
728 if let Some(v) = layer.egress.block_secrets {
729 resolved.egress.block_secrets = Some(v);
730 }
731 if let Some(v) = layer.egress.max_writes_per_min {
732 resolved.egress.max_writes_per_min = Some(v);
733 }
734 for pattern in &layer.routing.allowed_models {
736 if !resolved.routing.allowed_models.contains(pattern) {
737 resolved.routing.allowed_models.push(pattern.clone());
738 }
739 }
740 resolved.routing.model_ceiling_groups = unique_model_ceilings(
741 resolved
742 .routing
743 .model_ceiling_groups
744 .iter()
745 .chain(layer.routing.model_ceiling_groups.iter()),
746 );
747 for project in &layer.routing.forbid_downgrade_for {
748 if !resolved.routing.forbid_downgrade_for.contains(project) {
749 resolved.routing.forbid_downgrade_for.push(project.clone());
750 }
751 }
752 if let Some(v) = layer.budgets.max_cost_usd_per_person_per_day {
755 resolved.budgets.max_cost_usd_per_person_per_day = Some(v);
756 }
757 if let Some(v) = layer.budgets.max_cost_usd_per_project_per_month {
758 resolved.budgets.max_cost_usd_per_project_per_month = Some(v);
759 }
760 }
761
762 if let Some(allow) = &resolved.allow_tools {
764 let overlap: Vec<String> = allow
765 .iter()
766 .filter(|t| resolved.deny_tools.contains(*t))
767 .cloned()
768 .collect();
769 if !overlap.is_empty() {
770 return Err(PolicyError::AllowDenyOverlap(overlap));
771 }
772 }
773 Ok(resolved)
774}
775
776pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
778 resolve(&parse(toml_text)?)
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
786 PolicyPack {
787 name: name.to_string(),
788 version: "1.0.0".to_string(),
789 description: "test pack".to_string(),
790 extends: extends.map(str::to_string),
791 context: ContextRules::default(),
792 redaction: BTreeMap::new(),
793 filters: FilterRules::default(),
794 egress: EgressRules::default(),
795 routing: RoutingPolicyRules::default(),
796 budgets: BudgetRules::default(),
797 }
798 }
799
800 #[test]
801 fn parses_a_full_pack() {
802 let pack = parse(
803 r#"
804name = "acme-internal"
805version = "2.1.0"
806description = "ACME internal baseline"
807extends = "strict-redaction"
808
809[context]
810default_read_mode = "map"
811deny_tools = ["ctx_url_read"]
812max_context_tokens = 12000
813audit_retention_days = 365
814
815[redaction]
816employee_id = 'EMP-\d{6}'
817"#,
818 )
819 .expect("parses");
820 assert_eq!(pack.name, "acme-internal");
821 assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
822 assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
823 assert!(pack.redaction.contains_key("employee_id"));
824 }
825
826 #[test]
827 fn parses_gateway_governance_sections() {
828 let resolved = load(
830 r#"
831name = "acme-gateway"
832version = "1.0.0"
833description = "org gateway governance"
834
835[routing]
836allowed_models = ["claude-*", "gpt-4o-mini"]
837forbid_downgrade_for = ["prod"]
838
839[budgets]
840max_cost_usd_per_person_per_day = 50.0
841max_cost_usd_per_project_per_month = 20000.0
842"#,
843 )
844 .expect("resolves");
845 assert_eq!(
846 resolved.routing.allowed_models,
847 vec!["claude-*".to_string(), "gpt-4o-mini".to_string()]
848 );
849 assert_eq!(resolved.routing.forbid_downgrade_for, vec!["prod"]);
850 assert_eq!(resolved.budgets.max_cost_usd_per_person_per_day, Some(50.0));
851 assert_eq!(
852 resolved.budgets.max_cost_usd_per_project_per_month,
853 Some(20000.0)
854 );
855 }
856
857 #[test]
858 fn rejects_invalid_budget_and_empty_model_pattern() {
859 let neg = parse(
860 r#"
861name = "bad-budget"
862version = "1.0.0"
863description = "x"
864
865[budgets]
866max_cost_usd_per_person_per_day = -5.0
867"#,
868 );
869 assert!(matches!(neg, Err(PolicyError::InvalidBudget { .. })));
870
871 let empty = parse(
872 r#"
873name = "bad-pattern"
874version = "1.0.0"
875description = "x"
876
877[routing]
878allowed_models = ["claude-*", " "]
879"#,
880 );
881 assert_eq!(empty.unwrap_err(), PolicyError::EmptyModelPattern);
882 }
883
884 #[test]
885 fn unknown_keys_are_rejected() {
886 let err = parse(
887 r#"
888name = "typo"
889version = "1.0.0"
890description = "x"
891
892[context]
893alow_tools = ["ctx_read"]
894"#,
895 )
896 .unwrap_err();
897 assert!(matches!(err, PolicyError::Toml(_)), "{err}");
898 }
899
900 #[test]
901 fn validation_catches_each_field() {
902 let mut p = minimal("Bad Name", None);
903 assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
904
905 p = minimal("ok", None);
906 p.version = "1.0".into();
907 assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
908
909 p = minimal("ok", None);
910 p.description = " ".into();
911 assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
912
913 p = minimal("ok", None);
914 p.context.default_read_mode = Some("lines:1-5".into());
915 assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
916
917 p = minimal("ok", None);
918 p.context.max_context_tokens = Some(0);
919 assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
920
921 p = minimal("ok", None);
922 p.redaction.insert("broken".into(), "(unclosed".into());
923 assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
924
925 p = minimal("ok", None);
926 p.context.allow_tools = Some(vec!["ctx_read".into()]);
927 p.context.deny_tools = vec!["ctx_read".into()];
928 assert!(matches!(
929 validate(&p),
930 Err(PolicyError::AllowDenyOverlap(_))
931 ));
932 }
933
934 #[test]
935 fn resolve_overrides_scalars_and_accumulates_denies() {
936 let mut child = minimal("child", Some("strict-redaction"));
937 child.context.default_read_mode = Some("signatures".into());
938 child.context.deny_tools = vec!["ctx_url_read".into(), "ctx_shell".into()];
939 child
940 .redaction
941 .insert("employee_id".into(), r"EMP-\d{4}".into());
942 let r = resolve(&child).expect("resolves");
943
944 assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
946 assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
948 assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
949 assert!(r.redaction.contains_key("employee_id"));
951 assert!(r.redaction.contains_key("private_key"));
952 assert_eq!(r.chain, vec!["baseline", "strict-redaction"]);
954 }
955
956 #[test]
957 fn resolve_rejects_unknown_parent_and_cycle() {
958 let p = minimal("orphan", Some("no-such-pack"));
959 assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
960
961 let p = minimal("regulated", Some("healthcare"));
963 let err = resolve(&p).expect_err("not bundled");
964 assert!(err.to_string().contains("LeanCTX | Hive"), "{err}");
965
966 let p = minimal("loop", Some("loop"));
969 assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
970 }
971
972 #[test]
973 fn child_redaction_overrides_same_named_parent_pattern() {
974 let mut child = minimal("child", Some("baseline"));
975 child
976 .redaction
977 .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
978 let r = resolve(&child).expect("resolves");
979 assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
980 }
981}