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 hive_io;
32pub mod org;
33pub mod runtime;
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::path::Path;
37
38use serde::{Deserialize, Serialize};
39
40pub mod files;
41
42const MAX_EXTENDS_DEPTH: usize = 8;
45
46pub const KNOWN_READ_MODES: &[&str] = &[
50 "auto",
51 "full",
52 "map",
53 "signatures",
54 "diff",
55 "task",
56 "reference",
57 "aggressive",
58 "entropy",
59];
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct PolicyPack {
68 pub name: String,
70 pub version: String,
72 pub description: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub extends: Option<String>,
77 #[serde(default)]
79 pub context: ContextRules,
80 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83 pub redaction: BTreeMap<String, String>,
84 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
87 pub filters: FilterRules,
88 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
90 pub egress: EgressRules,
91 #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
94 pub routing: RoutingPolicyRules,
95 #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
97 pub budgets: BudgetRules,
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct ContextRules {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub default_read_mode: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub allow_tools: Option<Vec<String>>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub deny_tools: Vec<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub max_context_tokens: Option<u32>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub audit_retention_days: Option<u32>,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct FilterRules {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub pii: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub classification: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub injection: Option<String>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub blocked_labels: Vec<String>,
143 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146 pub blocked_patterns: BTreeMap<String, String>,
147 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
150 pub managed_personal_rules: BTreeSet<String>,
151}
152
153impl FilterRules {
154 #[must_use]
156 pub fn is_empty(&self) -> bool {
157 self.pii.is_none()
158 && self.classification.is_none()
159 && self.injection.is_none()
160 && self.blocked_labels.is_empty()
161 && self.blocked_patterns.is_empty()
162 && self.managed_personal_rules.is_empty()
163 }
164}
165
166pub(super) fn merge_block_patterns(
169 target: &mut BTreeMap<String, String>,
170 incoming: &BTreeMap<String, String>,
171) {
172 for (name, pattern) in incoming {
173 match target.get_mut(name) {
174 Some(previous) if previous != pattern => {
175 *previous = format!("(?:{previous})|(?:{pattern})");
176 }
177 Some(_) => {}
178 None => {
179 target.insert(name.clone(), pattern.clone());
180 }
181 }
182 }
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct EgressRules {
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub forbidden_patterns: Vec<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub block_secrets: Option<bool>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub max_writes_per_min: Option<u32>,
201}
202
203impl EgressRules {
204 #[must_use]
206 pub fn is_empty(&self) -> bool {
207 self.forbidden_patterns.is_empty()
208 && self.block_secrets.is_none()
209 && self.max_writes_per_min.is_none()
210 }
211}
212
213#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct RoutingPolicyRules {
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub allowed_models: Vec<String>,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub model_ceiling_groups: Vec<Vec<String>>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub forbid_downgrade_for: Vec<String>,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub blocked_model_origins: Vec<String>,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub blocked_host_countries: Vec<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub vendor_direct_cn: Option<bool>,
248}
249
250impl RoutingPolicyRules {
251 #[must_use]
253 pub fn is_empty(&self) -> bool {
254 self.allowed_models.is_empty()
255 && self.model_ceiling_groups.is_empty()
256 && self.forbid_downgrade_for.is_empty()
257 && self.blocked_model_origins.is_empty()
258 && self.blocked_host_countries.is_empty()
259 && self.vendor_direct_cn.is_none()
260 }
261
262 #[must_use]
264 pub fn origin_blocked(&self, country: &str) -> bool {
265 self.blocked_model_origins.iter().any(|c| c == country)
266 }
267
268 #[must_use]
270 pub fn host_blocked(&self, country: &str) -> bool {
271 self.blocked_host_countries.iter().any(|c| c == country)
272 }
273
274 #[must_use]
276 pub fn model_allowed(&self, model: &str) -> bool {
277 model_allowed_by_ceilings(&self.allowed_models, &self.model_ceiling_groups, model)
278 }
279}
280
281fn unique_model_ceilings<'a>(groups: impl Iterator<Item = &'a Vec<String>>) -> Vec<Vec<String>> {
284 let mut seen = std::collections::BTreeSet::new();
285 groups
286 .filter(|group| seen.insert(*group))
287 .cloned()
288 .collect()
289}
290
291pub fn model_allowed_by_ceilings(primary: &[String], groups: &[Vec<String>], model: &str) -> bool {
292 let matches = |patterns: &[String]| {
293 patterns
294 .iter()
295 .any(|pattern| model_pattern_matches(pattern, model))
296 };
297 (primary.is_empty() || matches(primary)) && groups.iter().all(|group| matches(group))
298}
299
300pub fn model_pattern_matches(pattern: &str, model: &str) -> bool {
302 let pattern = pattern.trim().as_bytes();
303 let model = model.trim().as_bytes();
304 let mut matched = vec![false; model.len() + 1];
306 matched[0] = true;
307 for &byte in pattern {
308 if byte == b'*' {
309 for index in 1..=model.len() {
310 matched[index] |= matched[index - 1];
311 }
312 } else {
313 for index in (1..=model.len()).rev() {
314 matched[index] = matched[index - 1] && model[index - 1] == byte;
315 }
316 matched[0] = false;
317 }
318 }
319 matched[model.len()]
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct BudgetRules {
328 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub max_cost_usd_per_person_per_day: Option<f64>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub max_cost_usd_per_project_per_month: Option<f64>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub max_requests_per_minute_per_person: Option<u32>,
339}
340
341impl BudgetRules {
342 #[must_use]
344 pub fn is_empty(&self) -> bool {
345 self.max_cost_usd_per_person_per_day.is_none()
346 && self.max_cost_usd_per_project_per_month.is_none()
347 && self.max_requests_per_minute_per_person.is_none()
348 }
349}
350
351#[derive(Debug, Clone, Serialize)]
356pub struct ResolvedPolicy {
357 pub name: String,
358 pub version: String,
359 pub description: String,
360 pub chain: Vec<String>,
363 pub default_read_mode: Option<String>,
364 pub allow_tools: Option<Vec<String>>,
365 pub deny_tools: Vec<String>,
366 pub max_context_tokens: Option<u32>,
367 pub audit_retention_days: Option<u32>,
368 pub redaction: BTreeMap<String, String>,
369 #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
371 pub filters: FilterRules,
372 #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
374 pub egress: EgressRules,
375 #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
377 pub routing: RoutingPolicyRules,
378 #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
380 pub budgets: BudgetRules,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum PolicyError {
389 Toml(String),
390 InvalidName(String),
391 InvalidVersion(String),
392 EmptyDescription,
393 UnknownReadMode(String),
394 BadRegex { pattern_name: String, error: String },
395 ZeroMaxTokens,
396 AllowDenyOverlap(Vec<String>),
397 UnknownParent(String),
398 ExtendsCycle(Vec<String>),
399 ExtendsTooDeep(usize),
400 UnknownFilterAction { field: String, value: String },
401 InvalidBudget { field: String, value: String },
402 EmptyModelPattern,
403 InvalidCountry { field: String, value: String },
404}
405
406#[must_use]
408pub fn false_wins(a: Option<bool>, b: Option<bool>) -> Option<bool> {
409 match (a, b) {
410 (Some(false), _) | (_, Some(false)) => Some(false),
411 (Some(true), _) | (_, Some(true)) => Some(true),
412 (None, None) => None,
413 }
414}
415
416impl std::fmt::Display for PolicyError {
417 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418 match self {
419 PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
420 PolicyError::InvalidName(n) => write!(
421 f,
422 "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
423 ),
424 PolicyError::InvalidVersion(v) => {
425 write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
426 }
427 PolicyError::EmptyDescription => write!(f, "description must not be empty"),
428 PolicyError::UnknownReadMode(m) => write!(
429 f,
430 "unknown default_read_mode '{m}' (one of: {})",
431 KNOWN_READ_MODES.join(", ")
432 ),
433 PolicyError::BadRegex {
434 pattern_name,
435 error,
436 } => write!(
437 f,
438 "redaction pattern '{pattern_name}' is not a valid regex: {error}"
439 ),
440 PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
441 PolicyError::AllowDenyOverlap(tools) => write!(
442 f,
443 "tools listed in both allow_tools and deny_tools: {}",
444 tools.join(", ")
445 ),
446 PolicyError::UnknownParent(p) => match builtin::paid_tier(p) {
447 Some(_) => write!(f, "extends {}", builtin::unknown_pack_hint(p)),
448 None => write!(
449 f,
450 "extends '{p}' does not name a known pack (built-ins: {})",
451 builtin::names().join(", ")
452 ),
453 },
454 PolicyError::ExtendsCycle(chain) => {
455 write!(f, "extends cycle: {}", chain.join(" -> "))
456 }
457 PolicyError::ExtendsTooDeep(d) => write!(
458 f,
459 "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
460 ),
461 PolicyError::UnknownFilterAction { field, value } => write!(
462 f,
463 "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
464 ),
465 PolicyError::InvalidBudget { field, value } => write!(
466 f,
467 "budgets.{field} must be a positive, finite USD amount (got {value})"
468 ),
469 PolicyError::EmptyModelPattern => {
470 write!(f, "routing.allowed_models must not contain empty patterns")
471 }
472 PolicyError::InvalidCountry { field, value } => write!(
473 f,
474 "routing.{field} '{value}' is not an ISO 3166-1 alpha-2 country code (e.g. \"CN\")"
475 ),
476 }
477 }
478}
479
480impl std::error::Error for PolicyError {}
481
482pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
487 let pack: PolicyPack =
488 toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
489 validate(&pack)?;
490 Ok(pack)
491}
492
493pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
496 let text = files::read(path, true)
497 .map_err(PolicyError::Toml)?
498 .ok_or_else(|| PolicyError::Toml("policy input is missing".into()))?;
499 parse(&text)
500}
501
502pub(crate) const HIVE_LABEL_PREFIX: &str = "hive:";
505
506pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
507 if pack.name.is_empty()
508 || !pack
509 .name
510 .bytes()
511 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
512 || pack.name.starts_with('-')
513 || pack.name.ends_with('-')
514 {
515 return Err(PolicyError::InvalidName(pack.name.clone()));
516 }
517 if !valid_semver(&pack.version) {
518 return Err(PolicyError::InvalidVersion(pack.version.clone()));
519 }
520 if pack.description.trim().is_empty() {
521 return Err(PolicyError::EmptyDescription);
522 }
523 if let Some(mode) = pack.context.default_read_mode.as_deref()
524 && !KNOWN_READ_MODES.contains(&mode)
525 {
526 return Err(PolicyError::UnknownReadMode(mode.to_string()));
527 }
528 if let Some(max) = pack.context.max_context_tokens
529 && max == 0
530 {
531 return Err(PolicyError::ZeroMaxTokens);
532 }
533 if let Some(allow) = &pack.context.allow_tools {
534 let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
535 let overlap: Vec<String> = allow
536 .iter()
537 .filter(|t| deny.contains(t.as_str()))
538 .cloned()
539 .collect();
540 if !overlap.is_empty() {
541 return Err(PolicyError::AllowDenyOverlap(overlap));
542 }
543 }
544 if pack.redaction.len() + pack.filters.blocked_patterns.len() > content::MAX_REDACTION_RULES
545 || pack.egress.forbidden_patterns.len() > content::MAX_REDACTION_RULES
546 || pack.filters.managed_personal_rules.len() > 32
547 {
548 return Err(PolicyError::BadRegex {
549 pattern_name: "policy".into(),
550 error: "too many policy patterns".into(),
551 });
552 }
553 if pack
556 .redaction
557 .keys()
558 .chain(pack.filters.blocked_patterns.keys())
559 .any(|label| label.starts_with(HIVE_LABEL_PREFIX))
560 {
561 return Err(PolicyError::BadRegex {
562 pattern_name: "policy".into(),
563 error: format!(
564 "rule labels starting with `{HIVE_LABEL_PREFIX}` are reserved for Hive filter packs"
565 ),
566 });
567 }
568 if pack.filters.managed_personal_rules.iter().any(|name| {
569 !name.starts_with("personal_")
570 || name.len() <= 9
571 || name.len() > content::MAX_RULE_LABEL_BYTES
572 || !name
573 .bytes()
574 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-".contains(&b))
575 || (!pack.redaction.contains_key(name)
576 && !pack.filters.blocked_patterns.contains_key(name))
577 }) {
578 return Err(PolicyError::BadRegex {
579 pattern_name: "policy".into(),
580 error: "invalid personal rule ownership".into(),
581 });
582 }
583 for (name, pattern) in pack.redaction.iter().chain(&pack.filters.blocked_patterns) {
584 if name.is_empty()
585 || name.len() > content::MAX_RULE_LABEL_BYTES
586 || name.chars().any(char::is_control)
587 || pattern.len() > content::MAX_RULE_PATTERN_BYTES
588 {
589 return Err(PolicyError::BadRegex {
590 pattern_name: "redaction".into(),
591 error: "policy pattern bounds exceeded".into(),
592 });
593 }
594 if let Err(e) = regex::RegexBuilder::new(pattern)
595 .size_limit(1024 * 1024)
596 .dfa_size_limit(1024 * 1024)
597 .build()
598 {
599 return Err(PolicyError::BadRegex {
600 pattern_name: name.clone(),
601 error: e.to_string(),
602 });
603 }
604 }
605 validate_filter_action("pii", pack.filters.pii.as_deref())?;
606 validate_filter_action("classification", pack.filters.classification.as_deref())?;
607 validate_filter_action("injection", pack.filters.injection.as_deref())?;
608 for pattern in &pack.egress.forbidden_patterns {
609 if pattern.len() > content::MAX_RULE_PATTERN_BYTES {
610 return Err(PolicyError::BadRegex {
611 pattern_name: "egress.forbidden_patterns".into(),
612 error: "policy pattern bounds exceeded".into(),
613 });
614 }
615 if let Err(e) = regex::RegexBuilder::new(pattern)
616 .size_limit(1024 * 1024)
617 .dfa_size_limit(1024 * 1024)
618 .build()
619 {
620 return Err(PolicyError::BadRegex {
621 pattern_name: format!("egress.forbidden_patterns: {pattern}"),
622 error: e.to_string(),
623 });
624 }
625 }
626 if pack
627 .routing
628 .allowed_models
629 .iter()
630 .chain(pack.routing.model_ceiling_groups.iter().flatten())
631 .any(|p| p.trim().is_empty())
632 {
633 return Err(PolicyError::EmptyModelPattern);
634 }
635 for (field, countries) in [
636 ("blocked_model_origins", &pack.routing.blocked_model_origins),
637 (
638 "blocked_host_countries",
639 &pack.routing.blocked_host_countries,
640 ),
641 ] {
642 if let Some(bad) = countries
643 .iter()
644 .find(|c| !lean_ctx_protocol::hive::effective_policy::is_country(c))
645 {
646 return Err(PolicyError::InvalidCountry {
647 field: field.into(),
648 value: bad.clone(),
649 });
650 }
651 }
652 validate_budget(
653 "max_cost_usd_per_person_per_day",
654 pack.budgets.max_cost_usd_per_person_per_day,
655 )?;
656 validate_budget(
657 "max_cost_usd_per_project_per_month",
658 pack.budgets.max_cost_usd_per_project_per_month,
659 )?;
660 Ok(())
661}
662
663fn validate_budget(field: &str, value: Option<f64>) -> Result<(), PolicyError> {
665 if let Some(v) = value
666 && !(v.is_finite() && v > 0.0)
667 {
668 return Err(PolicyError::InvalidBudget {
669 field: field.to_string(),
670 value: v.to_string(),
671 });
672 }
673 Ok(())
674}
675
676fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
678 if let Some(v) = value
679 && crate::core::input_filters::FilterAction::parse(v).is_none()
680 {
681 return Err(PolicyError::UnknownFilterAction {
682 field: field.to_string(),
683 value: v.to_string(),
684 });
685 }
686 Ok(())
687}
688
689fn valid_semver(v: &str) -> bool {
691 let parts: Vec<&str> = v.split('.').collect();
692 parts.len() == 3
693 && parts
694 .iter()
695 .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
696}
697
698pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
703 let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
705 let mut seen: Vec<String> = vec![pack.name.clone()];
706 let mut next_parent = pack.extends.clone();
707 while let Some(parent_name) = next_parent.take() {
708 if seen.contains(&parent_name) {
709 seen.push(parent_name);
710 return Err(PolicyError::ExtendsCycle(seen));
711 }
712 if lineage.len() >= MAX_EXTENDS_DEPTH {
713 return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
714 }
715 let parent =
716 builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
717 seen.push(parent_name);
718 next_parent.clone_from(&parent.extends);
719 lineage.push(parent);
720 }
721
722 let mut resolved = ResolvedPolicy {
725 name: pack.name.clone(),
726 version: pack.version.clone(),
727 description: pack.description.clone(),
728 chain: seen.iter().skip(1).rev().cloned().collect(),
729 default_read_mode: None,
730 allow_tools: None,
731 deny_tools: Vec::new(),
732 max_context_tokens: None,
733 audit_retention_days: None,
734 redaction: BTreeMap::new(),
735 filters: FilterRules::default(),
736 egress: EgressRules::default(),
737 routing: RoutingPolicyRules::default(),
738 budgets: BudgetRules::default(),
739 };
740 for layer in lineage.iter().rev() {
741 if let Some(mode) = &layer.context.default_read_mode {
742 resolved.default_read_mode = Some(mode.clone());
743 }
744 if let Some(allow) = &layer.context.allow_tools {
745 resolved.allow_tools = Some(allow.clone());
746 }
747 for tool in &layer.context.deny_tools {
748 if !resolved.deny_tools.contains(tool) {
749 resolved.deny_tools.push(tool.clone());
750 }
751 }
752 if let Some(max) = layer.context.max_context_tokens {
753 resolved.max_context_tokens = Some(max);
754 }
755 if let Some(days) = layer.context.audit_retention_days {
756 resolved.audit_retention_days = Some(days);
757 }
758 for (name, pattern) in &layer.redaction {
759 resolved.redaction.insert(name.clone(), pattern.clone());
760 }
761 if let Some(v) = &layer.filters.pii {
763 resolved.filters.pii = Some(v.clone());
764 }
765 if let Some(v) = &layer.filters.classification {
766 resolved.filters.classification = Some(v.clone());
767 }
768 if let Some(v) = &layer.filters.injection {
769 resolved.filters.injection = Some(v.clone());
770 }
771 for label in &layer.filters.blocked_labels {
772 if !resolved.filters.blocked_labels.contains(label) {
773 resolved.filters.blocked_labels.push(label.clone());
774 }
775 }
776 merge_block_patterns(
777 &mut resolved.filters.blocked_patterns,
778 &layer.filters.blocked_patterns,
779 );
780 resolved
781 .filters
782 .managed_personal_rules
783 .clone_from(&layer.filters.managed_personal_rules);
784 for pattern in &layer.egress.forbidden_patterns {
786 if !resolved.egress.forbidden_patterns.contains(pattern) {
787 resolved.egress.forbidden_patterns.push(pattern.clone());
788 }
789 }
790 if let Some(v) = layer.egress.block_secrets {
791 resolved.egress.block_secrets = Some(v);
792 }
793 if let Some(v) = layer.egress.max_writes_per_min {
794 resolved.egress.max_writes_per_min = Some(v);
795 }
796 for pattern in &layer.routing.allowed_models {
798 if !resolved.routing.allowed_models.contains(pattern) {
799 resolved.routing.allowed_models.push(pattern.clone());
800 }
801 }
802 resolved.routing.model_ceiling_groups = unique_model_ceilings(
803 resolved
804 .routing
805 .model_ceiling_groups
806 .iter()
807 .chain(layer.routing.model_ceiling_groups.iter()),
808 );
809 for project in &layer.routing.forbid_downgrade_for {
810 if !resolved.routing.forbid_downgrade_for.contains(project) {
811 resolved.routing.forbid_downgrade_for.push(project.clone());
812 }
813 }
814 for country in &layer.routing.blocked_model_origins {
815 if !resolved.routing.blocked_model_origins.contains(country) {
816 resolved.routing.blocked_model_origins.push(country.clone());
817 }
818 }
819 for country in &layer.routing.blocked_host_countries {
820 if !resolved.routing.blocked_host_countries.contains(country) {
821 resolved
822 .routing
823 .blocked_host_countries
824 .push(country.clone());
825 }
826 }
827 resolved.routing.vendor_direct_cn = false_wins(
828 resolved.routing.vendor_direct_cn,
829 layer.routing.vendor_direct_cn,
830 );
831 if let Some(v) = layer.budgets.max_cost_usd_per_person_per_day {
834 resolved.budgets.max_cost_usd_per_person_per_day = Some(v);
835 }
836 if let Some(v) = layer.budgets.max_cost_usd_per_project_per_month {
837 resolved.budgets.max_cost_usd_per_project_per_month = Some(v);
838 }
839 }
840
841 if let Some(allow) = &resolved.allow_tools {
843 let overlap: Vec<String> = allow
844 .iter()
845 .filter(|t| resolved.deny_tools.contains(*t))
846 .cloned()
847 .collect();
848 if !overlap.is_empty() {
849 return Err(PolicyError::AllowDenyOverlap(overlap));
850 }
851 }
852 Ok(resolved)
853}
854
855pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
857 resolve(&parse(toml_text)?)
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
865 PolicyPack {
866 name: name.to_string(),
867 version: "1.0.0".to_string(),
868 description: "test pack".to_string(),
869 extends: extends.map(str::to_string),
870 context: ContextRules::default(),
871 redaction: BTreeMap::new(),
872 filters: FilterRules::default(),
873 egress: EgressRules::default(),
874 routing: RoutingPolicyRules::default(),
875 budgets: BudgetRules::default(),
876 }
877 }
878
879 #[test]
880 fn parses_a_full_pack() {
881 let pack = parse(
882 r#"
883name = "acme-internal"
884version = "2.1.0"
885description = "ACME internal baseline"
886extends = "strict-redaction"
887
888[context]
889default_read_mode = "map"
890deny_tools = ["ctx_url_read"]
891max_context_tokens = 12000
892audit_retention_days = 365
893
894[redaction]
895employee_id = 'EMP-\d{6}'
896"#,
897 )
898 .expect("parses");
899 assert_eq!(pack.name, "acme-internal");
900 assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
901 assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
902 assert!(pack.redaction.contains_key("employee_id"));
903 }
904
905 #[test]
906 fn parses_gateway_governance_sections() {
907 let resolved = load(
909 r#"
910name = "acme-gateway"
911version = "1.0.0"
912description = "org gateway governance"
913
914[routing]
915allowed_models = ["claude-*", "gpt-4o-mini"]
916forbid_downgrade_for = ["prod"]
917
918[budgets]
919max_cost_usd_per_person_per_day = 50.0
920max_cost_usd_per_project_per_month = 20000.0
921"#,
922 )
923 .expect("resolves");
924 assert_eq!(
925 resolved.routing.allowed_models,
926 vec!["claude-*".to_string(), "gpt-4o-mini".to_string()]
927 );
928 assert_eq!(resolved.routing.forbid_downgrade_for, vec!["prod"]);
929 assert_eq!(resolved.budgets.max_cost_usd_per_person_per_day, Some(50.0));
930 assert_eq!(
931 resolved.budgets.max_cost_usd_per_project_per_month,
932 Some(20000.0)
933 );
934 }
935
936 #[test]
937 fn rejects_invalid_budget_and_empty_model_pattern() {
938 let neg = parse(
939 r#"
940name = "bad-budget"
941version = "1.0.0"
942description = "x"
943
944[budgets]
945max_cost_usd_per_person_per_day = -5.0
946"#,
947 );
948 assert!(matches!(neg, Err(PolicyError::InvalidBudget { .. })));
949
950 let empty = parse(
951 r#"
952name = "bad-pattern"
953version = "1.0.0"
954description = "x"
955
956[routing]
957allowed_models = ["claude-*", " "]
958"#,
959 );
960 assert_eq!(empty.unwrap_err(), PolicyError::EmptyModelPattern);
961 }
962
963 #[test]
964 fn unknown_keys_are_rejected() {
965 let err = parse(
966 r#"
967name = "typo"
968version = "1.0.0"
969description = "x"
970
971[context]
972alow_tools = ["ctx_read"]
973"#,
974 )
975 .unwrap_err();
976 assert!(matches!(err, PolicyError::Toml(_)), "{err}");
977 }
978
979 #[test]
980 fn validation_catches_each_field() {
981 let mut p = minimal("Bad Name", None);
982 assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
983
984 p = minimal("ok", None);
985 p.version = "1.0".into();
986 assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
987
988 p = minimal("ok", None);
989 p.description = " ".into();
990 assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
991
992 p = minimal("ok", None);
993 p.context.default_read_mode = Some("lines:1-5".into());
994 assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
995
996 p = minimal("ok", None);
997 p.context.max_context_tokens = Some(0);
998 assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
999
1000 p = minimal("ok", None);
1001 p.redaction.insert("broken".into(), "(unclosed".into());
1002 assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
1003
1004 p = minimal("ok", None);
1005 p.context.allow_tools = Some(vec!["ctx_read".into()]);
1006 p.context.deny_tools = vec!["ctx_read".into()];
1007 assert!(matches!(
1008 validate(&p),
1009 Err(PolicyError::AllowDenyOverlap(_))
1010 ));
1011 }
1012
1013 #[test]
1014 fn resolve_overrides_scalars_and_accumulates_denies() {
1015 let mut child = minimal("child", Some("strict-redaction"));
1016 child.context.default_read_mode = Some("signatures".into());
1017 child.context.deny_tools = vec!["ctx_url_read".into(), "ctx_shell".into()];
1018 child
1019 .redaction
1020 .insert("employee_id".into(), r"EMP-\d{4}".into());
1021 let r = resolve(&child).expect("resolves");
1022
1023 assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
1025 assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
1027 assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
1028 assert!(r.redaction.contains_key("employee_id"));
1030 assert!(r.redaction.contains_key("private_key"));
1031 assert_eq!(r.chain, vec!["baseline", "strict-redaction"]);
1033 }
1034
1035 #[test]
1036 fn resolve_rejects_unknown_parent_and_cycle() {
1037 let p = minimal("orphan", Some("no-such-pack"));
1038 assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
1039
1040 let p = minimal("regulated", Some("healthcare"));
1042 let err = resolve(&p).expect_err("not bundled");
1043 assert!(err.to_string().contains("LeanCTX | Hive"), "{err}");
1044
1045 let p = minimal("loop", Some("loop"));
1048 assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
1049 }
1050
1051 #[test]
1052 fn child_redaction_overrides_same_named_parent_pattern() {
1053 let mut child = minimal("child", Some("baseline"));
1054 child
1055 .redaction
1056 .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
1057 let r = resolve(&child).expect("resolves");
1058 assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
1059 }
1060}