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