Skip to main content

lean_ctx/core/policy/
mod.rs

1//! Context Policy Packs v1 — Policies-as-Code (GL #489).
2//!
3//! A policy pack is a declarative, versioned governance preset: which tools an
4//! agent may call, the default read mode, redaction patterns for sensitive
5//! data, an audit-retention expectation and a context-budget cap. Packs are
6//! plain TOML, support single inheritance via `extends`, and resolve into one
7//! [`ResolvedPolicy`] a team can review like code.
8//!
9//! v1 ships the **format, validation, resolution, curated built-ins and the
10//! `lean-ctx policy` CLI** (see `cli::policy_cmd`). Runtime enforcement wires
11//! in afterward (deliberately decoupled so this module stays free of hot-path
12//! churn — see the contract `docs/contracts/context-policy-packs-v1.md`).
13//!
14//! Inheritance semantics are security-first and predictable:
15//! - scalars (`default_read_mode`, `max_context_tokens`,
16//!   `audit_retention_days`) — the child **overrides** when set;
17//! - `deny_tools` and `[redaction]` — **accumulate** down the chain
18//!   (restrictions inherited from a parent can never be silently dropped;
19//!   a child may only tighten or re-point a named redaction pattern);
20//! - `allow_tools` — the child **overrides** when set (an allowlist is a
21//!   deliberate posture choice, not an accumulating set).
22
23pub 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
42/// Maximum `extends` chain depth (defense against runaway chains; built-ins
43/// use at most 2).
44const MAX_EXTENDS_DEPTH: usize = 8;
45
46/// Read modes a pack may pin as `default_read_mode` — the documented
47/// `ctx_read` mode vocabulary (range reads like `lines:N-M` are call-site
48/// specific and make no sense as a policy default).
49pub 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// ── Wire format ──────────────────────────────────────────────────────────────
62
63/// One policy pack as written in TOML. Unknown keys are rejected so a typo
64/// (`alow_tools`) fails validation instead of silently weakening a policy.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct PolicyPack {
68    /// Stable identifier: lowercase, digits and hyphens (`acme-platform`).
69    pub name: String,
70    /// Semantic version of the pack itself (`1.0.0`).
71    pub version: String,
72    /// One-line human description.
73    pub description: String,
74    /// Optional parent pack (built-in name) this pack inherits from.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub extends: Option<String>,
77    /// Context-governance expectations.
78    #[serde(default)]
79    pub context: ContextRules,
80    /// Named redaction patterns: name → regex (matched against content before
81    /// it enters the model context).
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub redaction: BTreeMap<String, String>,
84    /// Inbound content filters (PII / classification / prompt-injection) — the
85    /// input side of the Great Filter (GL #675).
86    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
87    pub filters: FilterRules,
88    /// Egress/output DLP on agent writes & actions (GL #676).
89    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
90    pub egress: EgressRules,
91    /// Gateway routing governance: model allowlist + downgrade exemptions
92    /// (enterprise#25). Enforced by the org gateway under a signed policy.
93    #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
94    pub routing: RoutingPolicyRules,
95    /// Hard org spend caps per person/project (enterprise#25).
96    #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
97    pub budgets: BudgetRules,
98}
99
100/// The `[context]` section of a pack. All fields optional — only what a pack
101/// states is constrained; everything else stays at engine defaults.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct ContextRules {
105    /// Default `ctx_read` mode the policy expects (see [`KNOWN_READ_MODES`]).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub default_read_mode: Option<String>,
108    /// Allowlist of tool names; when set, only these may be called.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub allow_tools: Option<Vec<String>>,
111    /// Denylist of tool names; always additive down the `extends` chain.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub deny_tools: Vec<String>,
114    /// Upper bound on tokens a single context assembly may spend.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub max_context_tokens: Option<u32>,
117    /// Audit-retention expectation in days (governance intent; the hosted
118    /// plane enforces its own plan window — see org-audit-log-v1).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub audit_retention_days: Option<u32>,
121}
122
123/// The `[filters]` section — inbound content detectors (GL #675). Each action
124/// is one of `off` / `warn` / `redact` / `block` (absent ⇒ `off`). Compiled
125/// into a [`crate::core::input_filters::FilterConfig`] at load time and run on
126/// tool output before it reaches the agent.
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct FilterRules {
130    /// PII detection (email, phone, payment cards).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub pii: Option<String>,
133    /// Data-classification marking gate (confidential/secret banners).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub classification: Option<String>,
136    /// Prompt-injection detection (OWASP LLM01) on inbound content.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub injection: Option<String>,
139    /// Classification labels that gate; overrides the built-in default set.
140    /// Accumulates down the `extends` chain (a child may add, never drop).
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub blocked_labels: Vec<String>,
143    /// Named inbound block patterns. Evaluated on original content before any
144    /// masking; inherited and organizational restrictions accumulate.
145    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146    pub blocked_patterns: BTreeMap<String, String>,
147    /// Ownership of locally published personal rules; never infer ownership from
148    /// a prefix used by an existing hand-authored policy.
149    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
150    pub managed_personal_rules: BTreeSet<String>,
151}
152
153impl FilterRules {
154    /// True when no filter is configured (all actions absent, no labels).
155    #[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
166/// Same-name restrictions retain both patterns. Bounds are checked again when
167/// compiling the resolved policy; oversized composition denies all access.
168pub(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/// The `[egress]` section — output/DLP enforcement on agent writes & actions
186/// (GL #676). Gates `ctx_edit` writes and `ctx_shell` actions before they
187/// execute. Compiled into a `crate::core::egress::EgressConfig` at load time.
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct EgressRules {
191    /// Regexes that block a write/action when matched (e.g. a prod-DB DSN).
192    /// Accumulates down the `extends` chain.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub forbidden_patterns: Vec<String>,
195    /// Block writes/actions carrying detected secrets or PII.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub block_secrets: Option<bool>,
198    /// Rate limit: max agent write/action tool calls per 60 s.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub max_writes_per_min: Option<u32>,
201}
202
203impl EgressRules {
204    /// True when no egress rule is configured.
205    #[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/// The `[routing]` section — org gateway routing governance (enterprise#25,
214/// Doc 08 §4.3). Enforced in the gateway forward path when this pack arrives
215/// via a signed, trusted, `enforced = true` [`org::OrgPolicyV1`].
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct RoutingPolicyRules {
219    /// Model allowlist patterns (`"claude-*"`, `"gpt-4o-mini"`). A request for
220    /// a model matching none of them is refused org-wide. Empty = no
221    /// restriction. Accumulates down the `extends` chain (union — the floor
222    /// merge then intersects org vs. local, see `floor`).
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub allowed_models: Vec<String>,
225    /// Additional conjunctive ceilings. Every group must match; an empty
226    /// group denies every model. Preserves glob intersections without turning
227    /// a disjoint intersection into the unrestricted empty primary list.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub model_ceiling_groups: Vec<Vec<String>>,
230    /// Projects whose requests the router must never downgrade to a cheaper
231    /// tier (`["security", "prod"]`). Accumulates down the chain.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub forbid_downgrade_for: Vec<String>,
234    /// Countries of origin (ISO 3166-1 alpha-2, the model catalog's
235    /// `origin_country`) whose model weights are never used, whatever the
236    /// host (mz14). Accumulates (union) down the chain and across the floor.
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub blocked_model_origins: Vec<String>,
239    /// Countries where no model request is processed: a catalog host's
240    /// `host_country`, and a vendor's own API (`[[direct]]`). Union.
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub blocked_host_countries: Vec<String>,
243    /// `Some(false)` voids the local `[proxy] allow_vendor_direct_cn` opt-in.
244    /// `false` wins over `true` in every merge; `true` grants nothing by
245    /// itself (the opt-in stays local).
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub vendor_direct_cn: Option<bool>,
248}
249
250impl RoutingPolicyRules {
251    /// True when no routing governance is configured.
252    #[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    /// Whether `country` is a blocked model origin.
263    #[must_use]
264    pub fn origin_blocked(&self, country: &str) -> bool {
265        self.blocked_model_origins.iter().any(|c| c == country)
266    }
267
268    /// Whether `country` is a blocked host country.
269    #[must_use]
270    pub fn host_blocked(&self, country: &str) -> bool {
271        self.blocked_host_countries.iter().any(|c| c == country)
272    }
273
274    /// Apply all inherited ceilings to one explicitly selected model.
275    #[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
281/// Keep the first occurrence of every exact conjunction, including deny-all
282/// empty groups. Reapplying a floor must not multiply identical checks.
283fn 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
300/// Glob-lite matching shared by project admission and the gateway.
301pub fn model_pattern_matches(pattern: &str, model: &str) -> bool {
302    let pattern = pattern.trim().as_bytes();
303    let model = model.trim().as_bytes();
304    // Dynamic programming avoids exponential wildcard backtracking.
305    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/// The `[budgets]` section — hard org spend caps (enterprise#25, Doc 08 §4.3).
323/// USD amounts against the measured `cost_usd` of the usage meter; breaching a
324/// cap makes the gateway refuse further requests (429) until the window rolls.
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct BudgetRules {
328    /// Max measured spend per person per UTC day.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub max_cost_usd_per_person_per_day: Option<f64>,
331    /// Max measured spend per project per UTC month.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub max_cost_usd_per_project_per_month: Option<f64>,
334    /// Max accepted requests per person per UTC minute (enterprise#66) —
335    /// protects shared upstreams from a single runaway agent. Counted per
336    /// gateway process; multi-replica deployments multiply accordingly.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub max_requests_per_minute_per_person: Option<u32>,
339}
340
341impl BudgetRules {
342    /// True when no cap is configured.
343    #[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// ── Resolved view ────────────────────────────────────────────────────────────
352
353/// A pack with its full `extends` chain folded in — what enforcement and
354/// `policy show` consume.
355#[derive(Debug, Clone, Serialize)]
356pub struct ResolvedPolicy {
357    pub name: String,
358    pub version: String,
359    pub description: String,
360    /// Inheritance chain, base-most first (`["baseline", "strict-redaction"]`
361    /// for a pack extending `strict-redaction`). Empty for root packs.
362    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    /// Folded inbound-filter actions + label set (GL #675).
370    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
371    pub filters: FilterRules,
372    /// Folded egress/output DLP rules (GL #676).
373    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
374    pub egress: EgressRules,
375    /// Folded gateway routing governance (enterprise#25).
376    #[serde(default, skip_serializing_if = "RoutingPolicyRules::is_empty")]
377    pub routing: RoutingPolicyRules,
378    /// Folded org spend caps (enterprise#25).
379    #[serde(default, skip_serializing_if = "BudgetRules::is_empty")]
380    pub budgets: BudgetRules,
381}
382
383// ── Errors ───────────────────────────────────────────────────────────────────
384
385/// Why a pack failed to parse, validate or resolve. Rendered verbatim by the
386/// CLI, so every variant names the offending field and value.
387#[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/// `false` wins: a `false` anywhere stays `false`; otherwise any `true`.
407#[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
482// ── Parse + validate ─────────────────────────────────────────────────────────
483
484/// Parse one pack from TOML text (no I/O) and validate it standalone.
485/// `extends` is checked against the built-ins during [`resolve`].
486pub 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
493/// Parse a pack from a file path. Read errors surface as [`PolicyError::Toml`]
494/// with the OS message — the CLI shows them verbatim.
495pub 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
502/// Field-level validation of a single (unresolved) pack.
503/// Label prefix of the rules a Hive filter pack adds.
504pub(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    // `hive:` labels belong to the Hive filter packs; a pack using one
554    // could shadow or be shadowed by a Hive rule.
555    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
663/// A budget cap must be a positive, finite USD amount.
664fn 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
676/// Reject a `[filters]` action that is not a known token.
677fn 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
689/// `MAJOR.MINOR.PATCH`, digits only — packs don't need pre-release tags.
690fn 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
698// ── Resolve (extends) ────────────────────────────────────────────────────────
699
700/// Fold a pack's `extends` chain (against the built-ins) into one
701/// [`ResolvedPolicy`]. See the module docs for the inheritance semantics.
702pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
703    // Walk to the root, collecting the chain (child first).
704    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    // Fold base-most first so children override scalars and accumulate
723    // restrictions on top.
724    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        // Filter actions override (child wins); labels accumulate.
762        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        // Egress: forbidden patterns accumulate; scalars override (child wins).
785        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        // Routing governance: restriction lists accumulate down the chain.
797        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        // Budgets: scalar caps override (child wins) — the floor merge takes
832        // the stricter side across org vs. local separately.
833        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    // A resolved allowlist must not collide with accumulated denies.
842    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
855/// Parse + validate + resolve in one step — the common CLI path.
856pub 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        // enterprise#25: [routing] + [budgets] ride inside the same signed pack.
908        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        // Scalar overridden by the child.
1024        assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
1025        // The child's denies apply.
1026        assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
1027        assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
1028        // Redaction accumulated from the whole chain (baseline + strict + child).
1029        assert!(r.redaction.contains_key("employee_id"));
1030        assert!(r.redaction.contains_key("private_key"));
1031        // Chain is base-most first and excludes the pack itself.
1032        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        // A former built-in that moved to a paid tier says so.
1041        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        // Self-reference is the minimal cycle reachable without registering
1046        // custom packs (built-ins are acyclic by construction + test below).
1047        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}