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